Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/ai-bedrock-mantle-per-model-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@tanstack/ai-bedrock": patch
---

Pick the `bedrock-mantle` URL path per model family. The mantle endpoint was
hardcoded to `/v1`, but AWS serves different model families on different
paths: Gemma needs `/openai/v1` (per its model card's "In-Region endpoint
URL"), while gpt-oss and DeepSeek use the `/v1` default. The wrong path
returned a misleading `401 ... is not enabled for this account (access_denied)`
instead of a 404 / "wrong path" error, which made the bug hard to diagnose.

`withBedrockDefaults` now takes the model id as a third argument and threads
it into `buildBaseURL` β†’ `mantlePathForModel`, which routes `google.gemma-*`
to `/openai/v1` and falls back to `/v1` for every other id. The chat and
responses adapter constructors pass the model id through. An explicit
`baseURL` override still wins (preserves the existing escape hatch and the
E2E β†’ aimock wiring).

The runtime (`bedrock-runtime`) endpoint is unchanged β€” every chat-capable
model is served at `/openai/v1` there, so the model parameter is unused on
that branch.

Out of scope: a catalog-driven refactor (Direction 1 in #925) that carries
the path per `(endpoint, api)` on the generated catalog entry would replace
this prefix switch with a data-driven lookup. That's a larger follow-up;
this is the minimal bug fix. Claude-on-mantle at `/anthropic/v1/messages`
is also out of scope β€” that path serves the Anthropic Messages API, which
has a different wire format from OpenAI Chat Completions, so the chat
adapter can't drive it regardless of the path. The catalog already marks
Claude models as `chat: false`, so that combination typechecks as an error
today.

Fixes #925.
6 changes: 4 additions & 2 deletions packages/ai-bedrock/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ export class BedrockResponsesTextAdapter<

constructor(config: BedrockResponsesConfig, model: TModel) {
// Responses is mantle-only β€” force the mantle base URL (an explicit
// config.baseURL still wins, e.g. E2E pointing at aimock).
// config.baseURL still wins, e.g. E2E pointing at aimock). `model` reaches
// `buildBaseURL` so the mantle path matches the model family (Gemma needs
// `/openai/v1` instead of the `/v1` default) β€” see #925.
super(
model,
'bedrock-responses',
new OpenAI(withBedrockDefaults(config, 'mantle')),
new OpenAI(withBedrockDefaults(config, 'mantle', model)),
)
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/ai-bedrock/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export class BedrockTextAdapter<

constructor(config: BedrockTextConfig, model: TModel) {
// No `forced` -> honors config.endpoint ('runtime' default, 'mantle' allowed).
super(model, 'bedrock', new OpenAI(withBedrockDefaults(config)))
// `model` reaches `buildBaseURL` so the mantle path matches the model family
// (e.g. Gemma needs `/openai/v1` instead of the `/v1` default) β€” see #925.
super(model, 'bedrock', new OpenAI(withBedrockDefaults(config, undefined, model)))
}

/**
Expand Down
52 changes: 47 additions & 5 deletions packages/ai-bedrock/src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,58 @@ const DEFAULT_REGION = 'us-east-1'
/** OpenAI SDK requires a non-empty apiKey even when a signed fetch overrides Authorization. */
const SIGV4_PLACEHOLDER_KEY = 'bedrock-sigv4'

function buildBaseURL(region: string, endpoint: BedrockEndpoint): string {
/**
* Per-model URL path on the `bedrock-mantle` endpoint (#925).
*
* AWS serves different model families on different mantle paths:
* - `google.gemma-*` β†’ `/openai/v1` (OpenAI-compatible translation layer)
* - `deepseek.*` β†’ `/v1` (default OpenAI-compatible path)
* - `openai.gpt-oss-*` β†’ `/v1` (default)
* - `anthropic.claude-*` β†’ `/anthropic/v1/messages` β€” *NOT* supported by the
* chat adapter: that path serves the Anthropic Messages API, which has a
* different wire format from OpenAI Chat Completions. Calling
* `bedrockText('anthropic.claude-…', { endpoint: 'mantle' })` will not work
* regardless of the path; use the Converse adapter or wait for a
* Messages-API adapter. The catalog already marks Claude models as
* `chat: false`, so this combination typechecks as an error today.
*
* Evidence: AWS model cards β†’ "Programmatic Access" β†’ "In-Region endpoint URL".
* - Gemma: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-google-gemma-4-31b.html
* - Claude Haiku 4.5: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-haiku-4-5.html
* - DeepSeek: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-deepseek-deepseek-v3-2.html
*
* The runtime (`bedrock-runtime`) endpoint is unaffected β€” every chat-capable
* model is served at `/openai/v1` there, so the model parameter is unused on
* that branch.
*
* Forward-looking design (not implemented here, see issue #925 "Suggested
* directions"): carry the path per (endpoint, api) on the generated catalog
* entry so this becomes data-driven instead of a prefix switch. The prefix
* match is the minimal fix for the reported bug; the catalog refactor is a
* larger follow-up.
*/
function mantlePathForModel(model: string | undefined): string {
if (model && model.startsWith('google.gemma-')) {
return '/openai/v1'
}
return '/v1'
}

function buildBaseURL(
region: string,
endpoint: BedrockEndpoint,
model?: string,
): string {
return endpoint === 'mantle'
? `https://bedrock-mantle.${region}.api.aws/v1`
? `https://bedrock-mantle.${region}.api.aws${mantlePathForModel(model)}`
: `https://bedrock-runtime.${region}.amazonaws.com/openai/v1`
}

/** Builds OpenAI ClientOptions for the requested endpoint. `forced` pins the endpoint (responses β†’ 'mantle'). */
/** Builds OpenAI ClientOptions for the requested endpoint. `forced` pins the endpoint (responses β†’ 'mantle'). `model` is the Bedrock model id β€” only used to pick the correct path on the mantle endpoint (#925); pass it from the adapter constructor so the URL matches the model family. */
export function withBedrockDefaults(
config: BedrockClientConfig,
forced?: BedrockEndpoint,
model?: string,
): ClientOptions {
const { region, endpoint, auth, apiKey, baseURL, fetch, ...rest } = config
const resolvedRegion = region ?? DEFAULT_REGION
Expand All @@ -48,14 +90,14 @@ export function withBedrockDefaults(
if (resolved.kind === 'bearer') {
return {
...rest,
baseURL: baseURL ?? buildBaseURL(resolvedRegion, resolvedEndpoint),
baseURL: baseURL ?? buildBaseURL(resolvedRegion, resolvedEndpoint, model),
apiKey: resolved.token,
...(fetch ? { fetch } : {}),
}
}
return {
...rest,
baseURL: baseURL ?? buildBaseURL(resolvedRegion, resolvedEndpoint),
baseURL: baseURL ?? buildBaseURL(resolvedRegion, resolvedEndpoint, model),
apiKey: SIGV4_PLACEHOLDER_KEY,
fetch: fetch ?? createSigV4Fetch(resolved),
}
Expand Down
179 changes: 179 additions & 0 deletions packages/ai-bedrock/tests/mantle-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import { withBedrockDefaults } from '../src/utils/client'

/**
* Regression tests for issue #925: `@tanstack/ai-bedrock` hardcoded the
* `bedrock-mantle` base URL to `/v1`, but AWS serves different model families
* on different mantle paths. Gemma needs `/openai/v1`; gpt-oss / DeepSeek use
* the `/v1` default. Sending a request to the wrong path returned a misleading
* `401 ... is not enabled for this account (access_denied)` instead of a
* 404 / "wrong path" error, which made the bug hard to diagnose.
*
* The fix threads the model id through `withBedrockDefaults` β†’ `buildBaseURL`
* β†’ `mantlePathForModel` and picks the path per model family. These tests pin
* the per-family path so a future refactor can't silently regress to the
* hardcoded `/v1`.
*
* Note: `anthropic.claude-*` on mantle uses the Anthropic Messages API at
* `/anthropic/v1/messages` β€” a different wire format from OpenAI Chat
* Completions, so the chat adapter can't drive it regardless of the path. The
* catalog already marks Claude as `chat: false`, so that combination
* typechecks as an error today. These tests therefore do not assert a Claude
* mantle path; they assert only that the chat-capable model families route to
* the documented path.
*/
describe('withBedrockDefaults β€” mantle path per model (#925)', () => {
it('routes Gemma to /openai/v1 on mantle', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'eu-central-1', endpoint: 'mantle' },
undefined,
'google.gemma-4-31b',
)
expect(out.baseURL).toBe(
'https://bedrock-mantle.eu-central-1.api.aws/openai/v1',
)
})

it('routes a versioned Gemma id to /openai/v1 on mantle', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
'google.gemma-4-31b:0',
)
expect(out.baseURL).toBe(
'https://bedrock-mantle.us-east-1.api.aws/openai/v1',
)
})

it('routes a future Gemma variant (gemma-7b) to /openai/v1 on mantle', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
'google.gemma-7b-instruct-v1:0',
)
expect(out.baseURL).toBe(
'https://bedrock-mantle.us-east-1.api.aws/openai/v1',
)
})

it('routes DeepSeek to the /v1 default on mantle', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
'deepseek.r1-v1:0',
)
expect(out.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
})

it('routes gpt-oss to the /v1 default on mantle', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
'openai.gpt-oss-120b-1:0',
)
expect(out.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
})

it('routes an unknown model id to the /v1 default on mantle (backward compat)', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
'acme.future-model-v1:0',
)
expect(out.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
})

it('keeps the /v1 default when model is omitted (backward compat)', () => {
// Pre-#925 callers pass no `model` arg. Their URL must stay exactly what
// it was before the fix so existing deployments don't shift baseURL.
const out = withBedrockDefaults({
apiKey: 'k',
region: 'eu-west-1',
endpoint: 'mantle',
})
expect(out.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/v1')
})

it('keeps the /v1 default when model is undefined (explicit undefined arg)', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
undefined,
)
expect(out.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
})

it('keeps the /v1 default when model is an empty string', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
'',
)
expect(out.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
})

it('does not match google.gemma- as a substring in unrelated ids', () => {
// A model id like `acme.google.gemma-fake-v1:0` would be a substring match
// for `google.gemma-` β€” verify it still routes to /v1 because we use
// `startsWith`, not `includes`. (Bedrock model ids always start with the
// provider prefix, so this is the right contract.)
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'mantle' },
undefined,
'acme.google.gemma-fake-v1:0',
)
expect(out.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1')
})

it('honors an explicit baseURL override even when model would route elsewhere', () => {
const out = withBedrockDefaults(
{
apiKey: 'k',
region: 'us-east-1',
endpoint: 'mantle',
baseURL: 'http://127.0.0.1:4010/v1',
},
undefined,
'google.gemma-4-31b',
)
expect(out.baseURL).toBe('http://127.0.0.1:4010/v1')
})
})

describe('withBedrockDefaults β€” runtime endpoint ignores model (#925)', () => {
// The runtime endpoint serves every chat-capable model at /openai/v1, so the
// model parameter has no effect on the runtime branch. Pinning this prevents
// a future refactor from accidentally prefix-matching on the runtime path.
it('routes gpt-oss to /openai/v1 on runtime regardless of model', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'runtime' },
undefined,
'openai.gpt-oss-120b-1:0',
)
expect(out.baseURL).toBe(
'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1',
)
})

it('routes Gemma to /openai/v1 on runtime (model has no effect on runtime)', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'runtime' },
undefined,
'google.gemma-4-31b',
)
expect(out.baseURL).toBe(
'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1',
)
})

it('routes an unknown model to /openai/v1 on runtime', () => {
const out = withBedrockDefaults(
{ apiKey: 'k', region: 'us-east-1', endpoint: 'runtime' },
undefined,
'acme.future-model-v1:0',
)
expect(out.baseURL).toBe(
'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1',
)
})
})