Skip to content

@tanstack/ai-bedrock: bedrock-mantle base URL is hardcoded to /v1, but the path is per-model (/openai/v1, /anthropic/v1/messages, /v1) — Gemma/Claude 401 #925

Description

@jsve

TanStack AI version

0.40.0

Framework/Library version

Tanstack Start

Describe the bug and the steps to reproduce it

First and most importantly: I really love this initiative and project. Amazing like anything that gets the Tanstack touch!

Second: I realise patching the lib is probably not what you had in mind and that the @tanstack/ai-bedrock is a very new package still under development. What i hope here is to bring forward some of the findings I've done with using your package as well as some specifics about the bedrock api that I have tried to wrap my head around (but in typical aws fashion, that is probably not possible).

Third, the issue:

Package: @tanstack/ai-bedrock@0.1.2

Summary

buildBaseURL returns a single path per endpoint:

endpoint === 'mantle'
  ? `https://bedrock-mantle.${region}.api.aws/v1`
  : `https://bedrock-runtime.${region}.amazonaws.com/openai/v1`

On the bedrock-mantle endpoint, though, AWS serves different models on different paths. The hardcoded /v1 is correct only for a subset of models. For Gemma the path is /openai/v1; for Claude it's /anthropic/v1/messages. Sending a request to the wrong path returns a confusing, misleading 401 ... is not enabled for this account (access_denied) rather than a 404/"wrong path" error, which makes this very hard to diagnose.

Evidence (AWS model cards → "Programmatic Access")

Model ID Endpoint In-Region endpoint URL (verbatim from the model card)
google.gemma-4-31b bedrock-mantle https://bedrock-mantle.{region}.api.aws/openai/v1
anthropic.claude-haiku-4-5 bedrock-mantle https://bedrock-mantle.{region}.api.aws/anthropic/v1/messages
deepseek.* bedrock-mantle https://bedrock-mantle.{region}.api.aws/v1

The Gemma card explicitly calls this out: "This model is available on the openai/v1/... path on the bedrock-mantle endpoint. This is different from the v1/... path used by other models."

Reproduction

const adapter = bedrockText('google.gemma-4-31b', {
  api: 'chat',
  endpoint: 'mantle',
  region: 'eu-central-1',
})
// → POSTs to https://bedrock-mantle.eu-central-1.api.aws/v1/chat/completions
// → 401 "... is not enabled for this account (access_denied)"

The SigV4 signing service (bedrock-mantle) is correct — only the path is wrong. Overriding baseURL to https://bedrock-mantle.eu-central-1.api.aws/openai/v1 makes the exact same call succeed.

Broader design concerns surfaced while adding our own models

We're pinning + patching model-catalog.generated.ts to add models that ship faster than the catalog updates. Doing that exposed a few gaps beyond the path bug:

  1. The catalog encodes apis (converse/chat/responses) but not the endpoint or the URL path. There's nothing to derive the correct base URL from, so buildBaseURL can only be a per-endpoint constant — which is wrong for any model whose mantle path isn't /v1. The catalog seems like the right place to carry, per model, which endpoint(s), which API(s), and which path.

  2. api: 'chat' can't represent Claude-on-mantle. Claude's mantle path is /anthropic/v1/messages — the Anthropic Messages API, not OpenAI Chat Completions. So "chat over mantle" differs by model in both path and wire format, and there's no api value that captures the Messages shape.

  3. region is decoupled from the model id, but AWS couples them. The catalog lists geo-prefixed ids (e.g. us.anthropic.claude-...) as first-class models, yet bedrockText(model, { region }) accepts any region. bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', { region: 'eu-central-1' }) typechecks but is contradictory (a US geo-inference profile invoked from an EU region). Per-model region/geo availability (us. / eu. / global. / in-region) isn't captured, so invalid combinations only fail as a remote error.

Suggested directions

1. Make the generated catalog carry endpoint / path / region

Have the generated catalog compile, per model, everything needed to build a correct request without the caller guessing:

  • supported endpoint(s): runtime / mantle
  • supported api(s) per endpoint (ideally including wire format, since mantle-Claude is Anthropic Messages, not OpenAI chat)
  • the URL path per (endpoint, api): /v1, /openai/v1, /anthropic/v1/messages
  • region / geo availability

Then buildBaseURL and adapter selection can be catalog-driven rather than per-endpoint constants, and invalid (model, endpoint, api, region) combinations can fail at the type level or with a clear local error instead of a remote 401 access_denied.

2. A first-class way to declare a custom model (so users don't patch the lib)

Today the only way to use a model the catalog doesn't ship yet is to edit model-catalog.generated.ts — which for a pinned dependency means patching the package (bun patch / patch-package). That's brittle: the patch is version-pinned and silently stops applying on upgrade, and it can't be caught by typecheck. The catalog is both the type source (the model-id union via IdsWhere<'chat'>) and the runtime behavior source (apis/modalities, and — per this issue — ideally the path), so a custom model has to supply both, and there's currently no public seam to do it. New Bedrock models ship faster than a catalog release (this is how we hit Gemma and Kimi before they were in the generated catalog), so an escape hatch matters.

Options that would remove the need to patch:

(a) Accept an inline model descriptor at the call site. Let bedrockText take either a catalog key (as today, with full inference) or a fully-specified object for anything not in the catalog:

bedrockText(
  {
    id: 'google.gemma-4-31b',
    endpoint: 'mantle',
    api: 'chat',
    path: '/openai/v1',          // per-model, from the model card
    input: ['text'],
    output: ['text'],
  },
  { region: 'eu-central-1' },
)

When a descriptor is passed, skip the catalog lookup and use its fields directly. This also self-serves the path bug above — users set the correct path without waiting for a catalog release.

(b) A defineBedrockModel({...}) helper that returns a typed, reusable model object to pass to bedrockText (nice for sharing a custom model across a codebase).

(c) A registry + module augmentation: registerBedrockModels([...]) that merges entries into the runtime catalog, plus an exported interface consumers can declare module-augment to extend the id union — so custom ids typecheck without editing generated files.

Any of these lets teams adopt just-shipped models without a lockfile-pinned patch that breaks on the next upgrade, while keeping the fully-inferred experience for catalog models.

Workaround (today)

bedrockText('google.gemma-4-31b', {
  api: 'chat',
  endpoint: 'mantle',
  region: 'eu-central-1',
  baseURL: `https://bedrock-mantle.${region}.api.aws/openai/v1`,
})

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

see above

Screenshots or Videos (Optional)

not applicable

Do you intend to try to help solve this bug with your own PR?

None

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions