diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 5ea1c70a58e7..998a96c9f252 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -58,12 +58,14 @@ export const Plugin = define({ const current = providers.get(providerID) const source = providers.get(item.canonical ?? current?.provider.canonical ?? providerID) const changed = item.canonical !== undefined && item.canonical !== current?.provider.canonical + let catalogBaseURL: string | undefined providers.update(providerID, (provider) => { if (changed && source && source.provider !== provider) Object.assign(provider, structuredClone(source.provider), { id: provider.id, integrationID: provider.integrationID, }) + catalogBaseURL = provider.settings?.baseURL provider.activation = "enabled" if (item.canonical !== undefined) provider.canonical = item.canonical if (item.name) provider.name = item.name @@ -74,6 +76,14 @@ export const Plugin = define({ if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers) if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body) }) + // A catalog model can carry a variant of the provider endpoint, which would otherwise win over the + // configured one. A model on another origin (Azure, Bedrock, Vertex partner models) keeps its own. + if (item.settings?.baseURL !== undefined && catalogBaseURL !== undefined) + for (const [id, model] of providers.get(providerID)?.models ?? []) + if (model.settings?.baseURL !== undefined && sameOrigin(model.settings.baseURL, catalogBaseURL)) + providers.models.update(providerID, id, (model) => { + if (model.settings) delete model.settings.baseURL + }) const definitions = new Map() for (const [id, config] of Object.entries(item.models ?? {})) { const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id) @@ -196,3 +206,11 @@ function configuredProviders(entries: readonly Entry[]) { .filter((entry): entry is Document => entry.type === "document") .flatMap((file) => Object.entries(file.info.providers ?? {})) } + +const sameOrigin = (a: string, b: string) => { + try { + return new URL(a).origin === new URL(b).origin + } catch { + return false + } +} diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index a4bab665fc20..5efc67d1abdd 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -690,6 +690,90 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("sends catalog models with their own endpoint to the configured provider baseURL", () => + Effect.gen(function* () { + const providers = yield* Provider.Service + const models = yield* Model.Service + const providerID = Provider.ID.make("custom") + yield* providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.package = "@opencode/ai/providers/openai/chat" + provider.settings = { baseURL: "https://catalog.example/v1" } + }) + for (const id of ["chat", "listed"]) + editor.models.update(providerID, Model.ID.make(id), (model) => { + model.settings = { baseURL: "https://catalog.example/v1" } + }) + }) + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + settings: { baseURL: "https://gateway.example/v1" }, + models: { listed: { settings: { baseURL: "https://listed.example/v1" } } }, + }, + }, + }), + }), + ]) + + const resolver = yield* ModelResolver.Service + const endpoint = (id: string) => + models.get(providerID, Model.ID.make(id)).pipe( + Effect.flatMap((model) => resolver.resolveModel(required(model))), + Effect.map((resolved) => resolved.model.route.endpoint.baseURL), + ) + expect(yield* endpoint("chat")).toBe("https://gateway.example/v1") + expect(yield* endpoint("listed")).toBe("https://listed.example/v1") + }).pipe(Effect.provide(ModelResolver.layer)), + ) + + it.effect("keeps a catalog model endpoint on another origin, or under a provider with no catalog baseURL", () => + Effect.gen(function* () { + const providers = yield* Provider.Service + const models = yield* Model.Service + const withBase = Provider.ID.make("custom") + const withoutBase = Provider.ID.make("partner") + yield* providers.transform((editor) => { + editor.update(withBase, (provider) => { + provider.package = "@opencode/ai/providers/openai/chat" + provider.settings = { baseURL: "https://catalog.example/v1" } + }) + editor.models.update(withBase, Model.ID.make("partner-model"), (model) => { + model.settings = { baseURL: "https://partner.example/models" } + }) + editor.update(withoutBase, (provider) => { + provider.package = "@opencode/ai/providers/openai/chat" + }) + editor.models.update(withoutBase, Model.ID.make("claude"), (model) => { + model.settings = { baseURL: "https://resource.example/anthropic/v1" } + }) + }) + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { settings: { baseURL: "https://gateway.example/v1" } }, + partner: { settings: { baseURL: "https://vpc.example/v1" } }, + }, + }), + }), + ]) + + const resolver = yield* ModelResolver.Service + const endpoint = (providerID: Provider.ID, id: string) => + models.get(providerID, Model.ID.make(id)).pipe( + Effect.flatMap((model) => resolver.resolveModel(required(model))), + Effect.map((resolved) => resolved.model.route.endpoint.baseURL), + ) + expect(yield* endpoint(withBase, "partner-model")).toBe("https://partner.example/models") + expect(yield* endpoint(withoutBase, "claude")).toBe("https://resource.example/anthropic/v1") + }).pipe(Effect.provide(ModelResolver.layer)), + ) + it.effect("generates variants after rewriting a configured model package", () => Effect.gen(function* () { const models = yield* Model.Service