diff --git a/bun.lock b/bun.lock index 50747e1ee1..ef900aa833 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.2.1", - "oh-my-opencode-darwin-x64": "3.2.1", - "oh-my-opencode-linux-arm64": "3.2.1", - "oh-my-opencode-linux-arm64-musl": "3.2.1", - "oh-my-opencode-linux-x64": "3.2.1", - "oh-my-opencode-linux-x64-musl": "3.2.1", - "oh-my-opencode-windows-x64": "3.2.1", + "oh-my-opencode-darwin-arm64": "3.2.2", + "oh-my-opencode-darwin-x64": "3.2.2", + "oh-my-opencode-linux-arm64": "3.2.2", + "oh-my-opencode-linux-arm64-musl": "3.2.2", + "oh-my-opencode-linux-x64": "3.2.2", + "oh-my-opencode-linux-x64-musl": "3.2.2", + "oh-my-opencode-windows-x64": "3.2.2", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-IvhHRUXTr/g/hJlkKTU2oCdgRl2BDl/Qre31Rukhs4NumlvME6iDmdnm8mM7bTxugfCBkfUUr7QJLxxLhzjdLA=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KyfoWcANfcvpfanrrX+Wc8vH8vr9mvr7dJMHBe2bkvuhdtHnLHOG18hQwLg6jk4HhdoZAeBEmkolOsK2k4XajA=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-V2JbAdThAVfhBOcb+wBPZrAI0vBxPPRBdvmAixAxBOFC49CIJUrEFIRBUYFKhSQGHYWrNy8z0zJYoNQm4oQPog=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ajZ1E36Ixwdz6rvSUKUI08M2xOaNIl1ZsdVjknZTrPRtct9xgS+BEFCoSCov9bnV/9DrZD3mlZtO/+FFDbseUg=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-SeT8P7Icq5YH/AIaEF28J4q+ifUnOqO2UgMFtdFusr8JLadYFy+6dTdeAuD2uGGToDQ3ZNKuaG+lo84KzEhA5w=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ItJsYfigXcOa8/ejTjopC4qk5BCeYioMQ693kPTpeYHK3ByugTjJk8aamE7bHlVnmrdgWldz91QFzaP82yOAdg=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-wJUEVVUn1gyVIFNV4mxWg9cYo1rQdTKUXdGLfiqPiyQhWhZLRfPJ+9qpghvIVv7Dne6rzkbhYWdwdk/tew5RtQ=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/TvjYe/Kb//ZSHnJzgRj0QPKpS5Y2nermVTSaMTGS2btObXQyQWzuphDhsVRu60SVrNLbflHzfuTdqb3avDjyA=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-p/XValXi1RRTZV8mEsdStXwZBkyQpgZjB41HLf0VfizPMAKRr6/bhuFZ9BDZFIhcDnLYcGV54MAVEsWms5yC2A=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ka5j+tjuQkNnpESVzcTzW5tZMlBhOfP9F12+UaR72cIcwFpSoLMBp84rV6R0vXM0zUcrrN7mPeW66DvQ6A0XQQ=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-G7aNMqAMO2P+wUUaaAV8sXymm59cX4G9aVNXKAd/PM6RgFWh2F4HkXkOhOdHKYZzCl1QRhjh672mNillYsvebg=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ISl0sTNShKCgPFO+rsDqEDsvVHQAMfOSAxO0KuWbHFKaH+KaRV4d3N/ihgxZ2M94CZjJLzZEuln+6kLZ93cvzQ=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-pyqTGlNxirKxQgXx9YJBq2y8KN/1oIygVupClmws7dDPj9etI1l8fs/SBEnMsYzMqTlGbLVeJ5+kj9p+yg7YDA=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-KeiJLQvJuZ+UYf/+eMsQXvCiHDRPk6tD15lL+qruLvU19va62JqMNvTuOv97732uF19iG0ZMiiVhqIMbSyVPqQ=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/config/schema.ts b/src/config/schema.ts index 19b4ad89a4..0b2bf31049 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -101,6 +101,13 @@ export const BuiltinCommandNameSchema = z.enum([ "start-work", ]) +export const FallbackChainEntrySchema = z.object({ + providers: z.array(z.string()), + model: z.string(), + variant: z.string().optional(), +}) +export const FallbackChainSchema = z.array(FallbackChainEntrySchema) + export const AgentOverrideConfigSchema = z.object({ /** @deprecated Use `category` instead. Model is inherited from category defaults. */ model: z.string().optional(), @@ -135,6 +142,8 @@ export const AgentOverrideConfigSchema = z.object({ textVerbosity: z.enum(["low", "medium", "high"]).optional(), /** Provider-specific options. Passed directly to OpenCode SDK. */ providerOptions: z.record(z.string(), z.unknown()).optional(), + /** Custom fallback chain for model resolution. Overrides built-in agent fallback chains. */ + fallback_chain: FallbackChainSchema.optional(), }) export const AgentOverridesSchema = z.object({ @@ -189,6 +198,8 @@ export const CategoryConfigSchema = z.object({ prompt_append: z.string().optional(), /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ is_unstable_agent: z.boolean().optional(), + /** Custom fallback chain for model resolution. Each entry specifies providers, model, and optional variant. */ + fallback_chain: FallbackChainSchema.optional(), }) export const BuiltinCategoryNameSchema = z.enum([ diff --git a/src/shared/agent-variant.test.ts b/src/shared/agent-variant.test.ts index 16c46e9134..2ead4e9169 100644 --- a/src/shared/agent-variant.test.ts +++ b/src/shared/agent-variant.test.ts @@ -211,4 +211,201 @@ describe("resolveVariantForModel", () => { // then expect(variant).toBe("max") }) + + test("matches model with antigravity- prefix to base model", () => { + // #given + const config = {} as OhMyOpenCodeConfig + const model = { providerID: "anthropic", modelID: "antigravity-claude-opus-4-5" } + + // #when + const variant = resolveVariantForModel(config, "sisyphus", model) + + // #then + expect(variant).toBe("max") + }) + + test("uses user-defined fallback_chain when provided for agent", () => { + // #given + const config = { + agents: { + sisyphus: { + fallback_chain: [ + { providers: ["custom-provider"], model: "custom-model", variant: "custom-variant" } + ] + } + } + } as OhMyOpenCodeConfig + const model = { providerID: "custom-provider", modelID: "custom-model" } + + // #when + const variant = resolveVariantForModel(config, "sisyphus", model) + + // #then + expect(variant).toBe("custom-variant") + }) + + test("uses user-defined fallback_chain when provided for category", () => { + // #given + const config = { + agents: { + "custom-agent": { category: "my-category" } + }, + categories: { + "my-category": { + model: "some/model", + fallback_chain: [ + { providers: ["google"], model: "gemini-3-pro", variant: "high" } + ] + } + } + } as OhMyOpenCodeConfig + const model = { providerID: "google", modelID: "antigravity-gemini-3-pro" } + + // #when + const variant = resolveVariantForModel(config, "custom-agent", model) + + // #then + expect(variant).toBe("high") + }) + + test("falls back to agent variant when no fallback chain matches", () => { + // #given + const config = { + agents: { + "custom-agent": { variant: "fallback-variant" } + } + } as OhMyOpenCodeConfig + const model = { providerID: "unknown-provider", modelID: "unknown-model" } + + // #when + const variant = resolveVariantForModel(config, "custom-agent", model) + + // #then + expect(variant).toBe("fallback-variant") + }) + + test("falls back to category variant when no fallback chain matches", () => { + // #given + const config = { + agents: { + "custom-agent": { category: "my-category" } + }, + categories: { + "my-category": { + model: "some/model", + variant: "category-fallback-variant" + } + } + } as OhMyOpenCodeConfig + const model = { providerID: "unknown-provider", modelID: "unknown-model" } + + // #when + const variant = resolveVariantForModel(config, "custom-agent", model) + + // #then + expect(variant).toBe("category-fallback-variant") + }) + + // Edge case tests for PR #1307 fix + test("does not select variant when provider matches but model does not", () => { + // given + const config = { + agents: { + testAgent: { + fallback_chain: [ + { providers: ["anthropic"], model: "claude-opus-4", variant: "max" } + ] + } + } + } as OhMyOpenCodeConfig + const model = { providerID: "anthropic", modelID: "claude-sonnet-4" } // Different model + + // when + const variant = resolveVariantForModel(config, "testAgent", model) + + // then + expect(variant).toBeUndefined() + }) + + test("selects variant when provider matches and entry has no model", () => { + // given + const config = { + agents: { + testAgent: { + fallback_chain: [ + { providers: ["anthropic"], variant: "high" } // No model specified + ] + } + } + } as OhMyOpenCodeConfig + const model = { providerID: "anthropic", modelID: "any-model" } + + // when + const variant = resolveVariantForModel(config, "testAgent", model) + + // then + expect(variant).toBe("high") + }) + + test("does not match when modelID is undefined but entry requires model", () => { + // given + const config = { + agents: { + testAgent: { + fallback_chain: [ + { providers: ["anthropic"], model: "claude-opus-4", variant: "max" } + ] + } + } + } as OhMyOpenCodeConfig + const model = { providerID: "anthropic", modelID: undefined } as any // Forced undefined + + // when + const variant = resolveVariantForModel(config, "testAgent", model) + + // then + expect(variant).toBeUndefined() + }) + + test("skips mismatched entries and finds later matching entry", () => { + // given + const config = { + agents: { + testAgent: { + fallback_chain: [ + { providers: ["anthropic"], model: "claude-opus-4", variant: "max" }, // Wrong model + { providers: ["anthropic"], model: "claude-sonnet-4", variant: "high" } // Correct + ] + } + } + } as OhMyOpenCodeConfig + const model = { providerID: "anthropic", modelID: "claude-sonnet-4" } + + // when + const variant = resolveVariantForModel(config, "testAgent", model) + + // then + expect(variant).toBe("high") + }) + + test("respects model matching for entries with multiple providers", () => { + // given + const config = { + agents: { + testAgent: { + fallback_chain: [ + { providers: ["anthropic", "openai"], model: "gpt-4", variant: "high" } + ] + } + } + } as OhMyOpenCodeConfig + // Provider matches (anthropic) but model (claude-opus-4) doesn't match gpt-4 + const model = { providerID: "anthropic", modelID: "claude-opus-4" } + + // when + const variant = resolveVariantForModel(config, "testAgent", model) + + // then + expect(variant).toBeUndefined() + }) }) diff --git a/src/shared/agent-variant.ts b/src/shared/agent-variant.ts index 756f503e97..1303755068 100644 --- a/src/shared/agent-variant.ts +++ b/src/shared/agent-variant.ts @@ -1,5 +1,41 @@ import type { OhMyOpenCodeConfig } from "../config" -import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements" +import { + AGENT_MODEL_REQUIREMENTS, + CATEGORY_MODEL_REQUIREMENTS, + type FallbackEntry +} from "./model-requirements" + +const MODEL_PREFIXES_TO_STRIP = ["antigravity-", "proxy-", "custom-"] + +function normalizeModelId(modelId: string): string { + let normalized = modelId + for (const prefix of MODEL_PREFIXES_TO_STRIP) { + if (normalized.startsWith(prefix)) { + normalized = normalized.slice(prefix.length) + break + } + } + return normalized +} + +function modelMatches(modelId: string, pattern: string): boolean { + if (modelId === pattern) return true + if (normalizeModelId(modelId) === pattern) return true + if (modelId === normalizeModelId(pattern)) return true + if (normalizeModelId(modelId) === normalizeModelId(pattern)) return true + return false +} + +type AgentOverrideWithFallback = { + variant?: string + category?: string + fallback_chain?: FallbackEntry[] +} + +type CategoryConfigWithFallback = { + variant?: string + fallback_chain?: FallbackEntry[] +} export function resolveAgentVariant( config: OhMyOpenCodeConfig, @@ -38,25 +74,65 @@ export function resolveVariantForModel( currentModel: { providerID: string; modelID: string }, ): string | undefined { const agentOverrides = config.agents as - | Record + | Record | undefined const agentOverride = agentOverrides ? agentOverrides[agentName] ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] : undefined - if (agentOverride?.variant) { - return agentOverride.variant - } - const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentName] - if (agentRequirement) { - return findVariantInChain(agentRequirement.fallbackChain, currentModel) + // 1. User-provided agent fallback chain takes absolute priority + if (agentOverride?.fallback_chain && agentOverride.fallback_chain.length > 0) { + const chainVariant = findVariantInChain( + agentOverride.fallback_chain, + currentModel.providerID, + currentModel.modelID + ) + if (chainVariant) return chainVariant } + + // 2. User-provided agent variant override + if (agentOverride?.variant) return agentOverride.variant + const categoryName = agentOverride?.category if (categoryName) { - const categoryRequirement = CATEGORY_MODEL_REQUIREMENTS[categoryName] - if (categoryRequirement) { - return findVariantInChain(categoryRequirement.fallbackChain, currentModel) + const categoryConfig = config.categories?.[categoryName] as CategoryConfigWithFallback | undefined + + // 3. User-provided category fallback chain + if (categoryConfig?.fallback_chain && categoryConfig.fallback_chain.length > 0) { + const chainVariant = findVariantInChain( + categoryConfig.fallback_chain, + currentModel.providerID, + currentModel.modelID + ) + if (chainVariant) return chainVariant + } + + // 4. Category variant override + if (categoryConfig?.variant) return categoryConfig.variant + } + + // 5. Default agent fallback chain + const defaultAgentChain = AGENT_MODEL_REQUIREMENTS[agentName]?.fallbackChain + if (defaultAgentChain) { + const chainVariant = findVariantInChain( + defaultAgentChain, + currentModel.providerID, + currentModel.modelID + ) + if (chainVariant) return chainVariant + } + + // 6. Default category fallback chain + if (categoryName) { + const defaultCategoryChain = CATEGORY_MODEL_REQUIREMENTS[categoryName]?.fallbackChain + if (defaultCategoryChain) { + const chainVariant = findVariantInChain( + defaultCategoryChain, + currentModel.providerID, + currentModel.modelID + ) + if (chainVariant) return chainVariant } } @@ -64,15 +140,19 @@ export function resolveVariantForModel( } function findVariantInChain( - fallbackChain: { providers: string[]; model: string; variant?: string }[], - currentModel: { providerID: string; modelID: string }, + fallbackChain: FallbackEntry[], + providerID: string, + modelID?: string, ): string | undefined { for (const entry of fallbackChain) { - if ( - entry.providers.includes(currentModel.providerID) - && entry.model === currentModel.modelID - ) { - return entry.variant + if (entry.providers.includes(providerID)) { + if (entry.model) { + if (modelID && modelMatches(modelID, entry.model)) { + return entry.variant + } + } else { + return entry.variant + } } } return undefined diff --git a/src/shared/model-requirements.ts b/src/shared/model-requirements.ts index 8b5eabc116..5e411c1524 100644 --- a/src/shared/model-requirements.ts +++ b/src/shared/model-requirements.ts @@ -1,7 +1,7 @@ export type FallbackEntry = { providers: string[] model: string - variant?: string // Entry-specific variant (e.g., GPT→high, Opus→max) + variant?: string } export type ModelRequirement = { @@ -11,6 +11,32 @@ export type ModelRequirement = { requiresAnyModel?: boolean // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable) } +export type FallbackChainConfig = { + providers: string[] + model: string + variant?: string +} + +export function getEffectiveFallbackChain( + agentName: string, + userFallbackChain?: FallbackChainConfig[], +): FallbackEntry[] { + if (userFallbackChain && userFallbackChain.length > 0) { + return userFallbackChain + } + return AGENT_MODEL_REQUIREMENTS[agentName]?.fallbackChain ?? [] +} + +export function getEffectiveCategoryFallbackChain( + categoryName: string, + userFallbackChain?: FallbackChainConfig[], +): FallbackEntry[] { + if (userFallbackChain && userFallbackChain.length > 0) { + return userFallbackChain + } + return CATEGORY_MODEL_REQUIREMENTS[categoryName]?.fallbackChain ?? [] +} + export const AGENT_MODEL_REQUIREMENTS: Record = { sisyphus: { fallbackChain: [