diff --git a/src/tools/delegate-task/free-model-fallback.test.ts b/src/tools/delegate-task/free-model-fallback.test.ts new file mode 100644 index 00000000000..975add9760d --- /dev/null +++ b/src/tools/delegate-task/free-model-fallback.test.ts @@ -0,0 +1,22 @@ +/// + +import { describe, expect, test } from "bun:test" +import { FREE_ONLY_FALLBACK_CHAIN, isKnownFreeModel } from "./free-model-fallback" + +describe("FREE_ONLY_FALLBACK_CHAIN", () => { + // Deprecated by opencode (https://opencode.ai/zen/v1/models, models.dev catalog). + // If any of these reappear, the chain ships a model that no longer resolves. + const deprecatedFreeModelIds = ["kimi-k2.5-free", "kimi-k2-free", "kimi-k2-thinking-free"] + + test("does not contain models that opencode has marked deprecated", () => { + for (const entry of FREE_ONLY_FALLBACK_CHAIN) { + expect(deprecatedFreeModelIds).not.toContain(entry.model) + } + }) + + test("only contains entries that isKnownFreeModel recognizes", () => { + for (const entry of FREE_ONLY_FALLBACK_CHAIN) { + expect(isKnownFreeModel(entry.model)).toBe(true) + } + }) +}) diff --git a/src/tools/delegate-task/free-model-fallback.ts b/src/tools/delegate-task/free-model-fallback.ts new file mode 100644 index 00000000000..47c0fdd1896 --- /dev/null +++ b/src/tools/delegate-task/free-model-fallback.ts @@ -0,0 +1,63 @@ +import type { FallbackEntry } from "../../shared/model-requirements" + +const FREE_ONLY_PROVIDER_IDS = new Set(["opencode"]) +const KNOWN_FREE_MODEL_IDS = new Set([ + "big-pickle", + "gpt-5-nano", + "hy3-preview-free", + "minimax-m2.5-free", + "nemotron-3-super-free", +]) + +export const FREE_ONLY_FALLBACK_CHAIN: FallbackEntry[] = [ + { providers: ["opencode"], model: "big-pickle" }, + { providers: ["opencode"], model: "minimax-m2.5-free" }, + { providers: ["opencode"], model: "hy3-preview-free" }, + { providers: ["opencode"], model: "nemotron-3-super-free" }, + { providers: ["opencode"], model: "gpt-5-nano" }, +] + +function getModelId(model: string): string { + return model.includes("/") ? model.split("/").slice(1).join("/") : model +} + +export function isKnownFreeModel(model: string): boolean { + return KNOWN_FREE_MODEL_IDS.has(getModelId(model)) +} + +export function isFreeOnlyProviderConfiguration(connectedProviders: string[] | null): boolean { + return connectedProviders !== null + && connectedProviders.length > 0 + && connectedProviders.every((provider) => FREE_ONLY_PROVIDER_IDS.has(provider)) +} + +export function getFreeOnlyCategoryDefaultModel(input: { + categoryDefaultModel?: string + isUserConfiguredCategoryModel?: boolean + freeOnlyProviderConfiguration: boolean +}): string | undefined { + if (!input.freeOnlyProviderConfiguration || input.isUserConfiguredCategoryModel) { + return input.categoryDefaultModel + } + + if (!input.categoryDefaultModel || !isKnownFreeModel(input.categoryDefaultModel)) { + return undefined + } + + return input.categoryDefaultModel +} + +export function getFallbackChainForFreeOnlyProviders( + fallbackChain: FallbackEntry[] | undefined, + freeOnlyProviderConfiguration: boolean, +): FallbackEntry[] | undefined { + if (!freeOnlyProviderConfiguration || !fallbackChain || fallbackChain.length === 0) { + return fallbackChain + } + + const freeEntries = fallbackChain.filter((entry) => + entry.providers.includes("opencode") && isKnownFreeModel(entry.model), + ) + + return freeEntries.length > 0 ? freeEntries : FREE_ONLY_FALLBACK_CHAIN +} diff --git a/src/tools/delegate-task/model-selection-input.ts b/src/tools/delegate-task/model-selection-input.ts new file mode 100644 index 00000000000..524efd8cb2d --- /dev/null +++ b/src/tools/delegate-task/model-selection-input.ts @@ -0,0 +1,40 @@ +import { normalizeModel } from "../../shared/model-normalization" +import { parseModelString, parseVariantFromModelID } from "../../shared/model-string-parser" + +export function isExplicitHighModel(model: string): boolean { + return /(?:^|\/)[^/]+-high$/.test(model) +} + +export function getExplicitHighBaseModel(model: string): string | null { + return isExplicitHighModel(model) ? model.replace(/-high$/, "") : null +} + +export function parseUserFallbackModel(fallbackModel: string): { + baseModel: string + providerHint?: string[] + variant?: string +} | undefined { + const normalizedFallback = normalizeModel(fallbackModel) + if (!normalizedFallback) { + return undefined + } + + const parsedFullModel = parseModelString(normalizedFallback) + if (parsedFullModel) { + return { + baseModel: `${parsedFullModel.providerID}/${parsedFullModel.modelID}`, + providerHint: [parsedFullModel.providerID], + variant: parsedFullModel.variant, + } + } + + const parsedModel = parseVariantFromModelID(normalizedFallback) + if (!parsedModel.modelID) { + return undefined + } + + return { + baseModel: parsedModel.modelID, + variant: parsedModel.variant, + } +} diff --git a/src/tools/delegate-task/model-selection.free-model-fallback.test.ts b/src/tools/delegate-task/model-selection.free-model-fallback.test.ts new file mode 100644 index 00000000000..5671a64b28f --- /dev/null +++ b/src/tools/delegate-task/model-selection.free-model-fallback.test.ts @@ -0,0 +1,61 @@ +/// + +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { resolveModelForDelegateTask } from "./model-selection" +import * as connectedProvidersCache from "../../shared/connected-providers-cache" + +describe("resolveModelForDelegateTask free-only fallback", () => { + beforeEach(() => { + mock.restore() + spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true) + spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true) + spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["opencode"]) + }) + + test("uses a free opencode fallback instead of a paid category default when only free providers are configured", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "opencode/gpt-5.4", + fallbackChain: [ + { providers: ["opencode"], model: "gpt-5.4", variant: "medium" }, + { providers: ["opencode"], model: "big-pickle" }, + ], + availableModels: new Set([ + "opencode/gpt-5.4", + "opencode/big-pickle", + "opencode/minimax-m2.5-free", + ]), + }) + + expect(result).toEqual({ + model: "opencode/big-pickle", + fallbackEntry: { providers: ["opencode"], model: "big-pickle" }, + matchedFallback: true, + }) + }) + + test("falls back to a free global opencode model when the hardcoded chain only contains paid models", () => { + const result = resolveModelForDelegateTask({ + fallbackChain: [ + { providers: ["opencode"], model: "gpt-5.4", variant: "high" }, + { providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" }, + ], + availableModels: new Set(), + }) + + expect(result).toEqual({ + model: "opencode/big-pickle", + fallbackEntry: { providers: ["opencode"], model: "big-pickle" }, + matchedFallback: true, + }) + }) + + test("keeps an explicit user-configured category model even in free-only mode", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "opencode/gpt-5.4", + isUserConfiguredCategoryModel: true, + availableModels: new Set(["opencode/gpt-5.4", "opencode/big-pickle"]), + }) + + expect(result).toEqual({ model: "opencode/gpt-5.4" }) + }) +}) diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts index 43fa4741bd3..48e12df7603 100644 --- a/src/tools/delegate-task/model-selection.ts +++ b/src/tools/delegate-task/model-selection.ts @@ -4,46 +4,16 @@ import { fuzzyMatchModel } from "../../shared/model-availability" import { transformModelForProvider } from "../../shared/provider-model-id-transform" import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache" import { log } from "../../shared/logger" -import { parseModelString, parseVariantFromModelID } from "../../shared/model-string-parser" - -function isExplicitHighModel(model: string): boolean { - return /(?:^|\/)[^/]+-high$/.test(model) -} - -function getExplicitHighBaseModel(model: string): string | null { - return isExplicitHighModel(model) ? model.replace(/-high$/, "") : null -} - -function parseUserFallbackModel(fallbackModel: string): { - baseModel: string - providerHint?: string[] - variant?: string -} | undefined { - const normalizedFallback = normalizeModel(fallbackModel) - if (!normalizedFallback) { - return undefined - } - - const parsedFullModel = parseModelString(normalizedFallback) - if (parsedFullModel) { - return { - baseModel: `${parsedFullModel.providerID}/${parsedFullModel.modelID}`, - providerHint: [parsedFullModel.providerID], - variant: parsedFullModel.variant, - } - } - - const parsedModel = parseVariantFromModelID(normalizedFallback) - if (!parsedModel.modelID) { - return undefined - } - - return { - baseModel: parsedModel.modelID, - variant: parsedModel.variant, - } -} - +import { + getFallbackChainForFreeOnlyProviders, + getFreeOnlyCategoryDefaultModel, + isFreeOnlyProviderConfiguration, +} from "./free-model-fallback" +import { + getExplicitHighBaseModel, + isExplicitHighModel, + parseUserFallbackModel, +} from "./model-selection-input" export function resolveModelForDelegateTask(input: { userModel?: string @@ -63,15 +33,18 @@ export function resolveModelForDelegateTask(input: { return { model: userModel } } - const connectedProviders = input.availableModels.size === 0 ? readConnectedProvidersCache() : null + const connectedProviders = readConnectedProvidersCache() + const freeOnlyProviderConfiguration = isFreeOnlyProviderConfiguration(connectedProviders) - // Before provider cache is created (first run), skip model resolution entirely. - // OpenCode will use its system default model when no model is specified in the prompt. if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) { return { skipped: true } } - const categoryDefault = normalizeModel(input.categoryDefaultModel) + const categoryDefault = normalizeModel(getFreeOnlyCategoryDefaultModel({ + categoryDefaultModel: input.categoryDefaultModel, + isUserConfiguredCategoryModel: input.isUserConfiguredCategoryModel, + freeOnlyProviderConfiguration, + })) const explicitHighBaseModel = categoryDefault ? getExplicitHighBaseModel(categoryDefault) : null const explicitHighModel = explicitHighBaseModel ? categoryDefault : undefined if (categoryDefault) { @@ -140,7 +113,10 @@ export function resolveModelForDelegateTask(input: { } } - const fallbackChain = input.fallbackChain + const fallbackChain = getFallbackChainForFreeOnlyProviders( + input.fallbackChain, + freeOnlyProviderConfiguration, + ) if (fallbackChain && fallbackChain.length > 0) { if (input.availableModels.size === 0) { if (connectedProviders) {