Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/olive-moons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode": patch
---

Apply a provider block's `npm` to every model of that provider, not only to models the same block redeclares. A config that set `npm` together with `options.baseURL` but declared no `models` had its `npm` silently ignored while the `baseURL` was applied, so the catalog's SDK was paired with the configured host — for example the Anthropic `/v1/messages` path sent to an OpenAI-compatible host, which 404s. Omitting `npm` still keeps the catalog package while overriding the host, and a per-model `provider.npm` still wins over the provider-level value.
15 changes: 15 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1516,6 +1516,21 @@ const layer = Layer.effect(
)
parsed.models[modelID] = parsedModel
}
// A provider-level npm selects the SDK for every model of that provider, not just the ones
// redeclared above. Leaving catalog models on the catalog package pairs them with an
// overridden options.baseURL, producing an endpoint neither source describes. Variants are
// regenerated for the new package the same way the redeclare path does it.
if (provider.npm !== undefined) {
for (const [modelID, model] of Object.entries(parsed.models)) {
if (provider.models?.[modelID] !== undefined) continue
if (model.api.npm === provider.npm) continue
const next = { ...model, api: { ...model.api, npm: provider.npm } }
parsed.models[modelID] = {
...next,
variants: mapValues(ProviderTransform.variants(next), (v) => v),
}
}
}
database[providerID] = parsed
}

Expand Down
64 changes: 64 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,70 @@ it.instance(
},
)

it.instance(
"provider-level npm applies to catalog models the config does not redeclare",
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const models = providers[ProviderV2.ID.anthropic].models
// Without this the catalog package (@ai-sdk/anthropic) would stay paired with the overridden
// baseURL, sending the Anthropic /messages path to an OpenAI-compatible host.
for (const model of Object.values(models)) expect(model.api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderV2.ID.anthropic].options.baseURL).toBe("https://proxy.test/v1")
}),
{
config: {
provider: {
anthropic: {
npm: "@ai-sdk/openai-compatible",
options: { baseURL: "https://proxy.test/v1" },
},
},
},
},
)

it.instance(
"omitting npm keeps the catalog package while overriding the host",
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const models = providers[ProviderV2.ID.anthropic].models
for (const model of Object.values(models)) expect(model.api.npm).toBe("@ai-sdk/anthropic")
expect(providers[ProviderV2.ID.anthropic].options.baseURL).toBe("https://proxy.test/v1")
}),
{
config: {
provider: {
anthropic: {
options: { baseURL: "https://proxy.test/v1" },
},
},
},
},
)

it.instance(
"per-model npm still wins over the provider-level value",
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const models = providers[ProviderV2.ID.anthropic].models
expect(models["claude-opus-4-5"].api.npm).toBe("@ai-sdk/anthropic")
expect(models["claude-haiku-4-5-20251001"].api.npm).toBe("@ai-sdk/openai-compatible")
}),
{
config: {
provider: {
anthropic: {
npm: "@ai-sdk/openai-compatible",
models: { "claude-opus-4-5": { provider: { npm: "@ai-sdk/anthropic" } } },
},
},
},
},
)

// Edge cases for model configuration

it.instance(
Expand Down
Loading