Skip to content

Commit 2001fb9

Browse files
committed
fix(core): migrate provider whitelist and blacklist to model overrides
1 parent 7b3d4ce commit 2001fb9

9 files changed

Lines changed: 173 additions & 13 deletions

File tree

‎packages/core/src/config/normalize.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const unsupportedExperimental = [
5050
"primary_tools",
5151
"continue_loop_on_deny",
5252
] as const
53-
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
53+
const unsupportedProvider = ["id"] as const
5454
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
5555
const LegacyAutoupdate = Schema.Literals([true, false, "notify"])
5656

@@ -175,6 +175,7 @@ export function normalize(input: unknown): Result {
175175
isRecord(input.provider) || isRecord(input.providers),
176176
diagnostics,
177177
)
178+
diagnoseModelWildcardSettings(encoded.providers, diagnostics)
178179

179180
const toolRules = migrateTools(input.tools, diagnostics)
180181
const permissionRules = migratePermissions(input.permission, diagnostics)
@@ -534,6 +535,8 @@ function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
534535
const candidates = Object.entries(value).flatMap(([name, raw]) => {
535536
const path = ["provider", name]
536537
diagnoseProviderUnsupported(raw, path, diagnostics)
538+
if (name === "google-vertex-anthropic" && isRecord(raw))
539+
unsupportedIfPresent(raw, "whitelist", [...path, "whitelist"], diagnostics)
537540
if (invalidProviderOverlays(raw, path, diagnostics)) return []
538541
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
539542
if (provider === undefined) return []
@@ -567,6 +570,23 @@ function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Di
567570
return headersInvalid || bodyInvalid
568571
}
569572

573+
function diagnoseModelWildcardSettings(value: unknown, diagnostics: Diagnostic[]) {
574+
if (!isRecord(value)) return
575+
Object.entries(value).forEach(([name, provider]) => {
576+
if (!isRecord(provider) || !isRecord(provider.models)) return
577+
const wildcard = provider.models[ConfigProvider.ModelWildcard]
578+
if (!isRecord(wildcard)) return
579+
Object.keys(wildcard).forEach((key) => {
580+
if (key === "disabled") return
581+
diagnostics.push({
582+
kind: "unsupported",
583+
path: ["providers", name, "models", ConfigProvider.ModelWildcard, key],
584+
message: "ignored setting not supported on the model wildcard",
585+
})
586+
})
587+
})
588+
}
589+
570590
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
571591
if (!isRecord(value)) return
572592
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))

‎packages/core/src/config/plugin/provider.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ export const Plugin = define({
7474
})
7575
const definitions = new Map<string, { readonly inherit: boolean; readonly base?: Model.Info }>()
7676
for (const [id, config] of Object.entries(item.models ?? {})) {
77+
if (id === ConfigProvider.ModelWildcard) continue
7778
const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id)
7879
const inherit = changed || !current?.models.has(id)
80+
if (!base && !current?.models.has(id) && isToggleOnly(config)) continue
7981
// Bind the source at this point in the provider fold. Later source edits/removal
8082
// and its credential availability must not change an already-defined alias.
8183
definitions.set(id, { inherit, base: base && structuredClone(base) })
@@ -99,8 +101,16 @@ export const Plugin = define({
99101
if (configuredDefault !== undefined) models.default.set(configuredDefault.providerID, configuredDefault.model)
100102
for (const [item, definition] of sources.models) {
101103
const providerID = definition.providerID
104+
const wildcard = item.models?.[ConfigProvider.ModelWildcard]
105+
if (wildcard?.disabled !== undefined)
106+
for (const model of models.list(providerID))
107+
models.update(providerID, model.id, (draft) => {
108+
draft.enabled = !wildcard.disabled
109+
})
102110
for (const [id, config] of Object.entries(item.models ?? {})) {
111+
if (id === ConfigProvider.ModelWildcard) continue
103112
const source = definition.models.get(id)
113+
if (!source?.base && !models.get(providerID, id) && isToggleOnly(config)) continue
104114
const inherit = source?.inherit || !models.get(providerID, id)
105115
models.update(providerID, id, (model) => {
106116
if (inherit && source?.base) {
@@ -163,6 +173,13 @@ export const Plugin = define({
163173
}),
164174
})
165175

176+
function isToggleOnly(config: { readonly disabled?: boolean }) {
177+
return (
178+
config.disabled !== undefined &&
179+
Object.entries(config).every(([key, value]) => key === "disabled" || value === undefined)
180+
)
181+
}
182+
166183
function configuredProviders(entries: readonly Entry[]) {
167184
return entries
168185
.filter((entry): entry is Document => entry.type === "document")

‎packages/core/src/plugin/provider/opencode.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
274274
})
275275

276276
for (const [modelID, config] of Object.entries(item.models ?? {})) {
277+
if (modelID === ConfigProvider.ModelWildcard) continue
277278
const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID)
278279
providers.models.update(providerID, modelID, (model) => {
279280
Object.assign(model, structuredClone(base ?? model))

‎packages/core/src/v1/config/migrate.ts‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * as ConfigMigrateV1 from "./migrate.js"
22

33
import { ConfigAgent } from "@opencode/schema/config/agent"
4+
import { ConfigProvider } from "@opencode/schema/config/provider"
45
import { Schema } from "effect"
56
import { ConfigAgentV1 } from "./agent.js"
67
import { ConfigCommandV1 } from "./command.js"
@@ -118,12 +119,30 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
118119
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
119120
headers: info.options && options.headers,
120121
body: info.options && options.body,
121-
models:
122-
info.models &&
123-
Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])),
122+
models: withModelFilters(migrateModels(info), info),
124123
}
125124
}
126125

126+
function migrateModels(info: ConfigProviderV1.Info) {
127+
return (
128+
info.models && Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)]))
129+
)
130+
}
131+
132+
function withModelFilters<Model extends { readonly disabled?: boolean }>(
133+
models: Record<string, Model> | undefined,
134+
filters: Pick<ConfigProviderV1.Info, "whitelist" | "blacklist">,
135+
) {
136+
if (!filters.whitelist && !filters.blacklist) return models
137+
const result: Record<string, Model | { disabled: boolean }> = { ...models }
138+
if (filters.whitelist) {
139+
result[ConfigProvider.ModelWildcard] = { ...result[ConfigProvider.ModelWildcard], disabled: true }
140+
for (const name of filters.whitelist) result[name] = { ...result[name], disabled: false }
141+
}
142+
for (const name of filters.blacklist ?? []) result[name] = { ...result[name], disabled: true }
143+
return result
144+
}
145+
127146
function migrateAzureCognitiveServicesProvider(info: ConfigProviderV1.Info) {
128147
const standard = migrateStandardProvider(info)
129148
const migrated = {
@@ -143,19 +162,22 @@ function migrateAzureCognitiveServicesProvider(info: ConfigProviderV1.Info) {
143162
function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
144163
const migrated = migrateStandardProvider(info)
145164
const packageName = migrated.package ?? Provider.aisdk("@ai-sdk/google-vertex/anthropic")
165+
const models = migrateModels(info)
146166
return {
147167
...migrated,
148168
// The current Google Vertex provider includes Gemini and Claude. Keep the Anthropic SDK on Claude models
149169
// instead of changing the package inherited by every model on the provider.
150170
package: undefined,
151-
models:
152-
migrated.models &&
153-
Object.fromEntries(
154-
Object.entries(migrated.models).map(([name, model]) => [
155-
name,
156-
model.package ? model : { ...model, package: packageName },
157-
]),
158-
),
171+
models: withModelFilters(
172+
models &&
173+
Object.fromEntries(
174+
Object.entries(models).map(([name, model]) => [
175+
name,
176+
model.package ? model : { ...model, package: packageName },
177+
]),
178+
),
179+
{ blacklist: info.blacklist },
180+
),
159181
}
160182
}
161183

‎packages/core/test/config/normalization.test.ts‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,69 @@ describe("ConfigNormalize", () => {
503503
})
504504
})
505505

506+
test("migrates legacy provider whitelist and blacklist to model overrides", () => {
507+
const result = normalized({
508+
provider: {
509+
allowed: { whitelist: ["first", "second"], blacklist: ["second"], models: { first: { name: "First" } } },
510+
blocked: { blacklist: ["third"] },
511+
},
512+
})
513+
expect(result.encoded.providers).toEqual({
514+
allowed: {
515+
models: {
516+
"*": { disabled: true },
517+
first: { name: "First", disabled: false },
518+
second: { disabled: true },
519+
},
520+
},
521+
blocked: { models: { third: { disabled: true } } },
522+
})
523+
expect(result.diagnostics).toEqual([])
524+
})
525+
526+
test("keeps legacy Google Vertex Anthropic filters off Gemini models", () => {
527+
const result = normalized({
528+
provider: {
529+
"google-vertex-anthropic": {
530+
whitelist: ["claude"],
531+
blacklist: ["claude", "missing"],
532+
models: { claude: { name: "Claude" } },
533+
},
534+
},
535+
})
536+
expect(result.encoded.providers).toEqual({
537+
"google-vertex": {
538+
models: {
539+
claude: { name: "Claude", package: "aisdk:@ai-sdk/google-vertex/anthropic", disabled: true },
540+
missing: { disabled: true },
541+
},
542+
},
543+
})
544+
expect(result.diagnostics).toEqual([
545+
{
546+
kind: "unsupported",
547+
path: ["provider", "google-vertex-anthropic", "whitelist"],
548+
message: "omitted unsupported legacy setting",
549+
},
550+
])
551+
})
552+
553+
test("reports settings other than disabled on the model wildcard without changing them", () => {
554+
const result = normalized({
555+
providers: { custom: { models: { "*": { disabled: true, name: "All" }, first: { name: "First" } } } },
556+
})
557+
expect(result.encoded.providers).toEqual({
558+
custom: { models: { "*": { disabled: true, name: "All" }, first: { name: "First" } } },
559+
})
560+
expect(result.diagnostics).toEqual([
561+
{
562+
kind: "unsupported",
563+
path: ["providers", "custom", "models", "*", "name"],
564+
message: "ignored setting not supported on the model wildcard",
565+
},
566+
])
567+
})
568+
506569
test("reports unsupported legacy settings without including their values", () => {
507570
const secret = "do-not-log-this-value"
508571
const result = normalized({
@@ -527,7 +590,6 @@ describe("ConfigNormalize", () => {
527590
["logLevel"],
528591
["agent", "reviewer", "name"],
529592
["provider", "custom", "id"],
530-
["provider", "custom", "whitelist"],
531593
["provider", "custom", "models", "model", "release_date"],
532594
["provider", "custom", "models", "model", "status"],
533595
["provider", "custom", "models", "model", "interleaved"],

‎packages/core/test/config/provider.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,4 +766,36 @@ describe("ConfigProviderPlugin.Plugin", () => {
766766
}),
767767
),
768768
)
769+
it.effect("applies a wildcard model override before explicit models", () =>
770+
Effect.gen(function* () {
771+
const providers = yield* Provider.Service
772+
const models = yield* Model.Service
773+
const providerID = Provider.ID.make("custom")
774+
const first = Model.ID.make("first")
775+
const second = Model.ID.make("second")
776+
yield* providers.transform((editor) => {
777+
editor.update(providerID, () => {})
778+
editor.models.update(providerID, first, () => {})
779+
editor.models.update(providerID, second, () => {})
780+
})
781+
yield* addPlugin([
782+
new Document({
783+
type: "document",
784+
info: decode({
785+
providers: {
786+
custom: {
787+
models: { "*": { disabled: true }, second: { disabled: false }, missing: { disabled: false } },
788+
},
789+
},
790+
}),
791+
}),
792+
])
793+
794+
expect((yield* providers.get(providerID))?.activation).toBe("enabled")
795+
expect((yield* models.get(providerID, first))?.enabled).toBe(false)
796+
expect((yield* models.get(providerID, second))?.enabled).toBe(true)
797+
expect(yield* models.get(providerID, Model.ID.make("*"))).toBeUndefined()
798+
expect(yield* models.get(providerID, Model.ID.make("missing"))).toBeUndefined()
799+
}),
800+
)
769801
})

‎packages/core/test/plugin/provider-opencode.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ describe("OpencodePlugin", () => {
452452
settings: { baseURL: `${origin}/anthropic` },
453453
},
454454
disabled: { name: "Disabled", disabled: true },
455+
"*": { disabled: true },
455456
},
456457
},
457458
},
@@ -554,6 +555,7 @@ describe("OpencodePlugin", () => {
554555
?.settings?.baseURL,
555556
).toBe("https://upstream.example/v1")
556557
expect(yield* models.get(Provider.ID.openai, Model.ID.make("api-model"))).toBeUndefined()
558+
expect(yield* models.get(Provider.ID.make("remote"), Model.ID.make("*"))).toBeUndefined()
557559

558560
yield* credentials.update(initial.id, { label: "Renamed" })
559561
yield* Effect.yieldNow

‎packages/schema/src/config/provider.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ class Model extends Schema.Class<Model>("Config.Model")({
9090
limit: Limit.pipe(optional),
9191
}) {}
9292

93+
export const ModelWildcard = "*"
94+
9395
export class Info extends Schema.Class<Info>("Config.Provider")({
9496
canonical: Provider.ID.pipe(optional),
9597
name: Schema.String.pipe(optional),

‎packages/web/src/content/docs/providers.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ Both options take an array of model IDs — the same IDs shown in the `/models`
8282
- `whitelist` keeps only the listed models and hides the rest.
8383
- You can combine them: `whitelist` narrows the set, then `blacklist` removes entries from it.
8484

85+
In the `providers` format, the same is expressed with model overrides: `"*": { "disabled": true }` disables every model of the provider, and explicit entries such as `"claude-sonnet-4-20250514": { "disabled": false }` re-enable individual models.
86+
8587
---
8688

8789
## OpenCode Zen

0 commit comments

Comments
 (0)