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
18 changes: 18 additions & 0 deletions packages/core/src/config/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, { readonly inherit: boolean; readonly base?: Model.Info }>()
for (const [id, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id)
Expand Down Expand Up @@ -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
}
}
84 changes: 84 additions & 0 deletions packages/core/test/config/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading