From e3336725db215eb5a20b774535544ccb6d1fdecc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 15:42:54 -0400 Subject: [PATCH] fix(core): normalize provider endpoint templates (#41816) --- packages/core/src/aisdk-native.ts | 23 ++- packages/core/src/model-resolver.ts | 180 +++++++++++++--------- packages/core/test/aisdk-native.test.ts | 15 +- packages/core/test/model-resolver.test.ts | 117 +++++++++++++- 4 files changed, 254 insertions(+), 81 deletions(-) diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index d22211f4d3e..3092a2ccb90 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -88,9 +88,13 @@ function mapBedrockSettings( : typeof settings.bearerToken === "string" ? settings.bearerToken : undefined - const credentials = mapBedrockCredentials(settings) + const region = bedrockRegion(settings) + const credentials = mapBedrockCredentials(settings, region) return { ...baseSettings, + ...(typeof baseSettings.baseURL === "string" && region !== undefined + ? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) } + : {}), ...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string" ? { baseURL: settings.endpoint } : {}), @@ -155,14 +159,8 @@ function mapBedrockRequest(input: MapInput): Pick { } } -function mapBedrockCredentials(settings: Readonly>) { +function mapBedrockCredentials(settings: Readonly>, region: string | undefined) { const credentials = isRecord(settings.credentials) ? settings.credentials : settings - const region = - typeof settings.region === "string" - ? settings.region - : typeof credentials.region === "string" - ? credentials.region - : undefined if ( region === undefined || typeof credentials.accessKeyId !== "string" || @@ -177,6 +175,15 @@ function mapBedrockCredentials(settings: Readonly>) { } } +function bedrockRegion(settings: Readonly>) { + const credentials = isRecord(settings.credentials) ? settings.credentials : settings + return typeof settings.region === "string" + ? settings.region + : typeof credentials.region === "string" + ? credentials.region + : undefined +} + function mapOpenAIOptions(settings: Readonly>) { const options = { ...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}), diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 346c2402cfd..87c0b851736 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -158,45 +158,42 @@ export const fromCatalogModel = ( model: Info, credential?: Credential.Value, dependencies?: Dependencies, -): Effect.Effect => { - const prepared = prepareRuntimeModel(model, credential) - if (prepared.unresolved.length > 0) - return Effect.fail( - new UnresolvedProviderVariablesError({ - providerID: model.providerID, - modelID: model.id, - variables: prepared.unresolved, - }), - ) - const resolved = prepared.model +): Effect.Effect => + resolveCatalogModel(model, credential, dependencies).pipe( + Effect.flatMap((resolved) => validateProviderVariables(model, resolved)), + ) + +const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* ( + model: Info, + credential?: Credential.Value, + dependencies?: Dependencies, +) { + const resolved = prepareRuntimeModel(model, credential) const packageName = Provider.packageName(resolved.package) const key = apiKey(resolved, credential) const configuration = credential?.type === "key" ? credential.configuration : undefined if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { - return Effect.succeed( - withDefaults(resolved, OpenAIResponses.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) + const runtime = yield* prepareProviderModel(resolved) + return withDefaults(runtime, OpenAIResponses.route) + .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) + .model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility }) } if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { - return Effect.succeed( - withDefaults(resolved, AnthropicMessages.route) - .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) + const runtime = yield* prepareProviderModel(resolved) + return withDefaults(runtime, AnthropicMessages.route) + .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) + .model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility }) } if ( Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai-compatible" && typeof resolved.settings?.baseURL === "string" ) { - return Effect.succeed( - withDefaults(resolved, OpenAICompatibleChat.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) + const runtime = yield* prepareProviderModel(resolved) + return withDefaults(runtime, OpenAICompatibleChat.route) + .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) + .model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility }) } const configured = { ...resolved.settings, ...credential?.metadata, ...configuration } const mapping = Provider.isAISDK(resolved.package) @@ -208,64 +205,107 @@ export const fromCatalogModel = ( : undefined const native = mapping?.package ?? resolved.package if (Provider.isAISDK(resolved.package) && !mapping) { - if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved)) - const runtime = produce(resolved, (draft) => { - draft.settings = Provider.mergeOverlay(draft.settings, { + const loadAISDK = dependencies?.loadAISDK + if (!loadAISDK) return yield* unsupported(resolved) + const settings = yield* prepareProviderSettings( + resolved, + Provider.mergeOverlay(resolved.settings, { ...nativeCredentialSettings(resolved.package ?? "", credential), ...credential?.metadata, ...configuration, - }) + }) ?? {}, + ) + const runtime = produce(resolved, (draft) => { + draft.settings = settings }) - return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) + return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) } - if (!native) return Effect.fail(unsupported(resolved)) + if (!native) return yield* unsupported(resolved) const specifier = native - return Effect.gen(function* () { - const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe( - Effect.mapError(() => unsupported(resolved)), - ) - const mapped = mapping?.settings ?? configured - const settings = { - ...(credential ? withoutNativeAuthSettings(mapped) : mapped), - ...nativeCredentialSettings(specifier, credential), - headers: Provider.mergeHeaders(mapping?.headers, resolved.headers), - body: Provider.mergeOverlay(mapping?.body, resolved.body), - limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output }, - } - return yield* Effect.try({ - try: () => { - const runtime = module.model(resolved.modelID ?? resolved.id, settings) - return LanguageModel.update(runtime, { - provider: resolved.providerID, - compatibility: resolved.compatibility - ? Object.assign({}, runtime.compatibility, resolved.compatibility) - : runtime.compatibility, - }) - }, - catch: () => unsupported(resolved), - }) + const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured) + const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe( + Effect.mapError(() => unsupported(resolved)), + ) + const settings = { + ...(credential ? withoutNativeAuthSettings(mapped) : mapped), + ...nativeCredentialSettings(specifier, credential), + headers: Provider.mergeHeaders(mapping?.headers, resolved.headers), + body: Provider.mergeOverlay(mapping?.body, resolved.body), + limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output }, + } + return yield* Effect.try({ + try: () => { + const runtime = module.model(resolved.modelID ?? resolved.id, settings) + return LanguageModel.update(runtime, { + provider: resolved.providerID, + compatibility: resolved.compatibility + ? Object.assign({}, runtime.compatibility, resolved.compatibility) + : runtime.compatibility, + }) + }, + catch: () => unsupported(resolved), }) -} +}) function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) { - const prepared = produce(model, (draft) => { + if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model + return produce(model, (draft) => { if (draft.settings?.apiKey === "") delete draft.settings.apiKey if (credential?.type === "key" && credential.metadata !== undefined) draft.body = Provider.mergeOverlay(draft.body, credential.metadata) - if (typeof draft.settings?.baseURL !== "string") return - draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => { - return process.env[name] ?? placeholder - }) }) - const baseURL = prepared.settings?.baseURL - const unresolved = - typeof baseURL === "string" - ? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter( - (name, index, names) => names.indexOf(name) === index, - ) - : [] - return { model: prepared, unresolved } +} + +function validateProviderVariables( + model: Info, + resolved: LanguageModel, +): Effect.Effect { + const baseURL = resolved.route.endpoint.baseURL + if (typeof baseURL !== "string") return Effect.succeed(resolved) + const failure = unresolvedProviderVariables(model, baseURL) + return failure ? Effect.fail(failure) : Effect.succeed(resolved) +} + +function prepareProviderModel(model: Info): Effect.Effect { + if (!model.settings) return Effect.succeed(model) + return prepareProviderSettings(model, model.settings).pipe( + Effect.map((settings) => + settings === model.settings + ? model + : produce(model, (draft) => { + draft.settings = settings + }), + ), + ) +} + +function prepareProviderSettings( + model: Info, + settings: Readonly>, +): Effect.Effect>, UnresolvedProviderVariablesError> { + const baseURL = settings.baseURL + if (typeof baseURL !== "string") return Effect.succeed(settings) + return prepareProviderURL(model, baseURL).pipe( + Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })), + ) +} + +function prepareProviderURL(model: Info, baseURL: string): Effect.Effect { + if (!baseURL.includes("${")) return Effect.succeed(baseURL) + const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder) + const failure = unresolvedProviderVariables(model, prepared) + return failure ? Effect.fail(failure) : Effect.succeed(prepared) +} + +function unresolvedProviderVariables(model: Info, baseURL: string) { + const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1])) + if (variables.size === 0) return + return new UnresolvedProviderVariablesError({ + providerID: model.providerID, + modelID: model.id, + variables: Array.from(variables), + }) } const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index a127ed2acd6..ceb390f85df 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -123,6 +123,16 @@ describe("AISDKNative", () => { expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe( "@opencode-ai/ai/providers/amazon-bedrock/mantle/chat", ) + expect( + map( + "@ai-sdk/amazon-bedrock/mantle", + { + region: "us-west-2", + baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1", + }, + "openai.gpt-5.5", + ), + ).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } }) }) test("maps static Bedrock Mantle credentials without leaking connection options", () => { @@ -134,8 +144,9 @@ describe("AISDKNative", () => { accessKeyId: "key", secretAccessKey: "secret", sessionToken: "session", + region: "eu-west-1", }, - region: "eu-west-1", + baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1", profile: "ignored", credentialProvider: "ignored", fetch: "ignored", @@ -152,7 +163,7 @@ describe("AISDKNative", () => { sessionToken: "session", region: "eu-west-1", }, - region: "eu-west-1", + baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1", providerOptions: { openai: { store: false } }, }, }) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 7d04516f8cc..08aeee3b3f3 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -123,6 +123,24 @@ describe("ModelResolver", () => { }), ) + it.effect("resolves environment templates before native providers inspect endpoints", () => + withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () => + Effect.gen(function* () { + const resolved = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/azure"), { + providerID: Provider.ID.azure, + settings: { baseURL: "https://${AZURE_HOST}/openai" }, + }), + ) + + expect(resolved.route.endpoint).toMatchObject({ + baseURL: "https://resource.openai.azure.com/openai/v1", + query: { "api-version": "v1" }, + }) + }), + ), + ) + it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () => Effect.gen(function* () { const credential = Credential.Key.make({ type: "key", key: "secret" }) @@ -152,6 +170,46 @@ describe("ModelResolver", () => { }), ) + it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () => + withEnv({ AWS_REGION: undefined }, () => + Effect.gen(function* () { + const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), { + providerID: Provider.ID.amazonBedrock, + modelID: "openai.gpt-5.5", + settings: { + region: "us-west-2", + baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1", + }, + }) + const resolved = yield* ModelResolver.fromCatalogModel(catalog) + + expect(resolved.route).toMatchObject({ + id: "bedrock-mantle-responses", + endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" }, + }) + expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1") + }), + ), + ) + + it.effect("prefers the configured Mantle region over the environment", () => + withEnv({ AWS_REGION: "us-east-1" }, () => + Effect.gen(function* () { + const resolved = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), { + modelID: "openai.gpt-5.5", + settings: { + region: "us-west-2", + baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1", + }, + }), + ) + + expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1") + }), + ), + ) + it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () => Effect.gen(function* () { const catalog = model(Provider.aisdk("@ai-sdk/openai"), { @@ -265,7 +323,7 @@ describe("ModelResolver", () => { ), ) - it.effect("rejects unresolved provider URL variables before route construction", () => + it.effect("rejects unresolved variables in constructed provider routes", () => withEnv({ REQUIRED_HOST: undefined }, () => Effect.gen(function* () { const failure = yield* ModelResolver.fromCatalogModel( @@ -823,6 +881,63 @@ describe("ModelResolver", () => { }), ) + it.effect("rejects unresolved variables before loading opaque AISDK packages", () => + withEnv({ REQUIRED_HOST: undefined }, () => + Effect.gen(function* () { + const failure = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { baseURL: "https://${REQUIRED_HOST}/v1" }, + }), + undefined, + { loadAISDK: () => Effect.die("AI SDK loader should not be called") }, + ).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.UnresolvedProviderVariablesError", + variables: ["REQUIRED_HOST"], + }) + }), + ), + ) + + it.effect("rejects placeholders introduced by environment expansion before loading providers", () => + withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () => + Effect.gen(function* () { + const failure = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { baseURL: "https://${PROVIDER_HOST}/v1" }, + }), + undefined, + { loadAISDK: () => Effect.die("AI SDK loader should not be called") }, + ).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.UnresolvedProviderVariablesError", + variables: ["MISSING_HOST"], + }) + }), + ), + ) + + it.effect("rejects unresolved variables before loading native provider packages", () => + withEnv({ REQUIRED_HOST: undefined }, () => + Effect.gen(function* () { + const failure = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/google"), { + settings: { baseURL: "https://${REQUIRED_HOST}/v1" }, + }), + undefined, + { loadPackage: () => Effect.die("Native package loader should not be called") }, + ).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.UnresolvedProviderVariablesError", + variables: ["REQUIRED_HOST"], + }) + }), + ), + ) + it.effect("rejects AISDK packages without an available loader", () => Effect.gen(function* () { const failure = yield* ModelResolver.fromCatalogModel(