From f86d94002929bcf98c202d008487467fd1296b4f Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:40:10 +0800 Subject: [PATCH] fix(ai-bedrock): pick the bedrock-mantle URL path per model family (#925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildBaseURL` returned a single hardcoded path per endpoint — `/v1` on `bedrock-mantle`. AWS serves different model families on different mantle paths: Gemma is served on `/openai/v1` (per its model card's "In-Region endpoint URL"), while gpt-oss and 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 (#925). Thread the model id through `withBedrockDefaults` → `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, preserving 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 (called out as "Suggested directions" in #925): a catalog- driven refactor that carries the path per `(endpoint, api)` on the generated catalog entry. 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. Adds `tests/mantle-path.test.ts` (14 cases) pinning the per-family mantle path, the backward-compat default, the `baseURL` override, and the runtime-branch invariant that the model parameter has no effect there. Fixes #925. --- .../ai-bedrock-mantle-per-model-path.md | 33 ++++ .../ai-bedrock/src/adapters/responses-text.ts | 6 +- packages/ai-bedrock/src/adapters/text.ts | 4 +- packages/ai-bedrock/src/utils/client.ts | 52 ++++- packages/ai-bedrock/tests/mantle-path.test.ts | 179 ++++++++++++++++++ 5 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 .changeset/ai-bedrock-mantle-per-model-path.md create mode 100644 packages/ai-bedrock/tests/mantle-path.test.ts diff --git a/.changeset/ai-bedrock-mantle-per-model-path.md b/.changeset/ai-bedrock-mantle-per-model-path.md new file mode 100644 index 000000000..44cbc27cf --- /dev/null +++ b/.changeset/ai-bedrock-mantle-per-model-path.md @@ -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. diff --git a/packages/ai-bedrock/src/adapters/responses-text.ts b/packages/ai-bedrock/src/adapters/responses-text.ts index 41448f006..e2cf2ea5f 100644 --- a/packages/ai-bedrock/src/adapters/responses-text.ts +++ b/packages/ai-bedrock/src/adapters/responses-text.ts @@ -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)), ) } } diff --git a/packages/ai-bedrock/src/adapters/text.ts b/packages/ai-bedrock/src/adapters/text.ts index 103fde3ae..387382531 100644 --- a/packages/ai-bedrock/src/adapters/text.ts +++ b/packages/ai-bedrock/src/adapters/text.ts @@ -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))) } /** diff --git a/packages/ai-bedrock/src/utils/client.ts b/packages/ai-bedrock/src/utils/client.ts index 75fee20ef..b162c0c13 100644 --- a/packages/ai-bedrock/src/utils/client.ts +++ b/packages/ai-bedrock/src/utils/client.ts @@ -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 @@ -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), } diff --git a/packages/ai-bedrock/tests/mantle-path.test.ts b/packages/ai-bedrock/tests/mantle-path.test.ts new file mode 100644 index 000000000..20d662bb6 --- /dev/null +++ b/packages/ai-bedrock/tests/mantle-path.test.ts @@ -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', + ) + }) +})