Merge pull request #4031 from PeterPonyu/feat/config-disabled-providers

feat(config): add disabled_providers schema + helper
This commit is contained in:
YeonGyu-Kim
2026-05-21 12:58:42 +09:00
committed by GitHub
5 changed files with 477 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
mock.module("./logger", () => ({ log: (..._args: unknown[]) => {} }))
import {
applyDisabledProviders,
filterDisabledProviderModels,
getModelProvider,
isProviderDisabled,
} from "./disabled-providers"
import { clearConfigLoadErrors, getConfigLoadErrors } from "./config-errors"
import type { OhMyOpenCodeConfig } from "../config"
beforeEach(() => {
clearConfigLoadErrors()
})
describe("getModelProvider", () => {
test("returns the first segment before the slash", () => {
expect(getModelProvider("github-copilot/gpt-5.5")).toBe("github-copilot")
expect(getModelProvider("anthropic/claude-opus-4-7")).toBe("anthropic")
expect(getModelProvider("vercel/openai/gpt-5.5")).toBe("vercel")
})
test("returns undefined when the string has no provider prefix", () => {
expect(getModelProvider("gpt-5.5")).toBeUndefined()
expect(getModelProvider("")).toBeUndefined()
expect(getModelProvider("/foo")).toBeUndefined()
})
})
describe("isProviderDisabled", () => {
test("returns true only when the model's provider exactly matches a disabled entry", () => {
expect(isProviderDisabled("github-copilot/foo", ["github-copilot"])).toBe(true)
expect(isProviderDisabled("github-copilot-extra/foo", ["github-copilot"])).toBe(false)
expect(isProviderDisabled("opencode-go/glm-5.1", ["github-copilot", "vercel"])).toBe(false)
})
test("short-circuits when the disabled list is empty", () => {
expect(isProviderDisabled("github-copilot/foo", [])).toBe(false)
})
test("handles undefined model", () => {
expect(isProviderDisabled(undefined, ["github-copilot"])).toBe(false)
})
test("is case-insensitive: matches regardless of casing on either side", () => {
expect(isProviderDisabled("GitHub-Copilot/gpt-5.5", ["github-copilot"])).toBe(true)
expect(isProviderDisabled("github-copilot/gpt-5.5", ["GitHub-Copilot"])).toBe(true)
expect(isProviderDisabled("OPENAI/gpt-5.5", ["openai"])).toBe(true)
expect(isProviderDisabled("openai/gpt-5.5", ["VERCEL"])).toBe(false)
})
})
describe("filterDisabledProviderModels", () => {
test("preserves entries whose provider is not disabled, dropping the rest", () => {
const input = [
"github-copilot/gpt-5.5",
"openai/gpt-5.5",
{ model: "vercel/openai/gpt-5.5", variant: "medium" },
{ model: "opencode/gpt-5.5", variant: "medium" },
]
const result = filterDisabledProviderModels(input, ["github-copilot", "vercel"])
expect(result).toEqual([
"openai/gpt-5.5",
{ model: "opencode/gpt-5.5", variant: "medium" },
])
})
test("returns a fresh copy when the disabled list is empty", () => {
const input = ["openai/gpt-5.5"]
const result = filterDisabledProviderModels(input, [])
expect(result).toEqual(input)
expect(result).not.toBe(input)
})
})
describe("applyDisabledProviders", () => {
test("no-op when disabled_providers is unset or empty", () => {
const config = {
agents: {
hephaestus: {
model: "github-copilot/gpt-5.5",
fallback_models: ["github-copilot/gpt-5.4-mini", "openai/gpt-5.5"],
},
},
} as unknown as OhMyOpenCodeConfig
applyDisabledProviders(config)
const agents = config.agents as Record<string, { model?: string; fallback_models?: unknown }>
expect(agents.hephaestus.model).toBe("github-copilot/gpt-5.5")
expect(agents.hephaestus.fallback_models).toEqual(["github-copilot/gpt-5.4-mini", "openai/gpt-5.5"])
})
test("filters fallback chain and substitutes primary from the first allowed entry", () => {
const config = {
disabled_providers: ["github-copilot", "vercel"],
agents: {
hephaestus: {
model: "github-copilot/gpt-5.5",
fallback_models: [
"github-copilot/gpt-5.4-mini",
{ model: "openai/gpt-5.5", variant: "medium" },
{ model: "vercel/openai/gpt-5.5", variant: "medium" },
"opencode/gpt-5.5",
],
},
sisyphus: {
model: "anthropic/claude-opus-4-7",
fallback_models: [{ model: "github-copilot/claude-sonnet-4.6" }, "opencode-go/glm-5.1"],
},
},
} as unknown as OhMyOpenCodeConfig
applyDisabledProviders(config)
const agents = config.agents as Record<string, { model?: string; fallback_models?: unknown }>
expect(agents.hephaestus.model).toBe("openai/gpt-5.5")
expect(agents.hephaestus.fallback_models).toEqual([
{ model: "openai/gpt-5.5", variant: "medium" },
"opencode/gpt-5.5",
])
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
expect(agents.sisyphus.fallback_models).toEqual(["opencode-go/glm-5.1"])
})
test("leaves primary unchanged but records a config-load error when every chain entry is also disabled", () => {
const config = {
disabled_providers: ["github-copilot"],
agents: {
oracle: {
model: "github-copilot/gpt-5.5",
fallback_models: ["github-copilot/gpt-5.4-mini", { model: "github-copilot/gemini-3" }],
},
},
} as unknown as OhMyOpenCodeConfig
applyDisabledProviders(config)
const agents = config.agents as Record<string, { model?: string; fallback_models?: unknown }>
expect(agents.oracle.model).toBe("github-copilot/gpt-5.5")
// Empty chain is normalized to undefined so "no chain declared" and
// "empty chain declared" stay semantically distinct downstream.
expect(agents.oracle.fallback_models).toBeUndefined()
const errors = getConfigLoadErrors()
expect(errors.length).toBe(1)
expect(errors[0]!.path).toBe("disabled_providers:agents.oracle")
expect(errors[0]!.error).toContain("github-copilot/gpt-5.5")
expect(errors[0]!.error).toContain("disabled provider")
})
test("treats provider names case-insensitively across primary and chain entries", () => {
const config = {
disabled_providers: ["GitHub-Copilot"],
agents: {
hephaestus: {
model: "github-copilot/gpt-5.5",
fallback_models: [
"GITHUB-COPILOT/gpt-5.4-mini",
"openai/gpt-5.5",
],
},
},
} as unknown as OhMyOpenCodeConfig
applyDisabledProviders(config)
const agents = config.agents as Record<string, { model?: string; fallback_models?: unknown }>
expect(agents.hephaestus.model).toBe("openai/gpt-5.5")
expect(agents.hephaestus.fallback_models).toEqual(["openai/gpt-5.5"])
})
test("applies the same rules to categories", () => {
const config = {
disabled_providers: ["github-copilot"],
categories: {
deep: {
model: "github-copilot/gpt-5.5",
fallback_models: ["openai/gpt-5.5", "github-copilot/claude-sonnet-4.6"],
},
},
} as unknown as OhMyOpenCodeConfig
applyDisabledProviders(config)
const cats = config.categories as Record<string, { model?: string; fallback_models?: unknown }>
expect(cats.deep.model).toBe("openai/gpt-5.5")
expect(cats.deep.fallback_models).toEqual(["openai/gpt-5.5"])
})
})
+123
View File
@@ -0,0 +1,123 @@
import type { OhMyOpenCodeConfig } from "../config"
import type { FallbackModelObject } from "../config/schema/fallback-models"
import { addConfigLoadError } from "./config-errors"
import { log } from "./logger"
import { normalizeFallbackModels } from "./model-resolver"
const HOOK_NAME = "disabled-providers"
export function getModelProvider(model: string): string | undefined {
const slash = model.indexOf("/")
if (slash <= 0) return undefined
return model.slice(0, slash)
}
export function isProviderDisabled(
model: string | undefined,
disabled: readonly string[],
): boolean {
if (!model || disabled.length === 0) return false
const provider = getModelProvider(model)
if (provider === undefined) return false
const providerLower = provider.toLowerCase()
return disabled.some((entry) => entry.toLowerCase() === providerLower)
}
export function filterDisabledProviderModels<T extends string | FallbackModelObject>(
models: readonly T[],
disabled: readonly string[],
): T[] {
if (disabled.length === 0) return [...models]
return models.filter((entry) => {
const model = typeof entry === "string" ? entry : entry.model
return !isProviderDisabled(model, disabled)
})
}
type ModelHolder = {
model?: string | unknown
fallback_models?: string | (string | FallbackModelObject)[]
}
function findFirstAllowedReplacement(
chain: (string | FallbackModelObject)[] | undefined,
disabled: readonly string[],
): string | undefined {
if (!chain) return undefined
for (const entry of chain) {
const model = typeof entry === "string" ? entry : entry.model
if (!isProviderDisabled(model, disabled)) return model
}
return undefined
}
function applyToHolder(label: string, holder: ModelHolder, disabled: readonly string[]): void {
const normalizedChain = normalizeFallbackModels(holder.fallback_models)
if (normalizedChain) {
const filteredChain = filterDisabledProviderModels(normalizedChain, disabled)
if (filteredChain.length !== normalizedChain.length) {
log(`[${HOOK_NAME}] Filtered disabled-provider entries from fallback chain`, {
label,
removed: normalizedChain.length - filteredChain.length,
remaining: filteredChain.length,
})
}
// Normalize empty chain to undefined so downstream "no chain declared"
// and "empty chain declared" stay semantically distinct.
holder.fallback_models = filteredChain.length === 0 ? undefined : filteredChain
}
if (typeof holder.model === "string" && isProviderDisabled(holder.model, disabled)) {
const replacement = findFirstAllowedReplacement(
normalizeFallbackModels(holder.fallback_models),
disabled,
)
if (replacement) {
log(`[${HOOK_NAME}] Substituted primary model from fallback chain`, {
label,
from: holder.model,
to: replacement,
})
holder.model = replacement
} else {
// Surface to the user-facing config-error channel so this does not
// hide as a runtime ProviderModelNotFoundError on first delegation.
const message =
`${label} primary model "${holder.model}" uses a disabled provider and no allowed entry is available in fallback_models. ` +
`Either remove the provider from disabled_providers or add an allowed entry to fallback_models.`
addConfigLoadError({ path: `disabled_providers:${label}`, error: message })
log(`[${HOOK_NAME}] ${message}`, { label, primary: holder.model })
}
}
}
/**
* Filters `disabled_providers`-listed entries out of every agent/category
* fallback chain and substitutes any primary `model` referencing a disabled
* provider with the first allowed entry from the same chain.
*
* Returns the same config reference (mutated in place). Safe to call when
* `disabled_providers` is unset or empty - it becomes a no-op.
*/
export function applyDisabledProviders(config: OhMyOpenCodeConfig): OhMyOpenCodeConfig {
const disabled = config.disabled_providers ?? []
if (disabled.length === 0) return config
if (config.agents) {
for (const [name, agentConfig] of Object.entries(config.agents)) {
if (agentConfig && typeof agentConfig === "object") {
applyToHolder(`agents.${name}`, agentConfig as ModelHolder, disabled)
}
}
}
if (config.categories) {
for (const [name, categoryConfig] of Object.entries(config.categories)) {
if (categoryConfig && typeof categoryConfig === "object") {
applyToHolder(`categories.${name}`, categoryConfig as ModelHolder, disabled)
}
}
}
return config
}