diff --git a/src/tools/delegate-task/free-model-fallback.test.ts b/src/tools/delegate-task/free-model-fallback.test.ts index 975add9760d..cd990b640d8 100644 --- a/src/tools/delegate-task/free-model-fallback.test.ts +++ b/src/tools/delegate-task/free-model-fallback.test.ts @@ -1,11 +1,18 @@ /// import { describe, expect, test } from "bun:test" -import { FREE_ONLY_FALLBACK_CHAIN, isKnownFreeModel } from "./free-model-fallback" +import { + FREE_ONLY_FALLBACK_CHAIN, + appendFreeModelFallbacks, + isFreeOnlyProviderConfiguration, + isKnownFreeModel, +} from "./free-model-fallback" +import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" + +const ultrabrainFallbackChain = CATEGORY_MODEL_REQUIREMENTS.ultrabrain.fallbackChain 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", () => { @@ -19,4 +26,70 @@ describe("FREE_ONLY_FALLBACK_CHAIN", () => { expect(isKnownFreeModel(entry.model)).toBe(true) } }) + + test("every provider in the chain is recognized as a free-only provider", () => { + for (const entry of FREE_ONLY_FALLBACK_CHAIN) { + for (const provider of entry.providers) { + expect(isFreeOnlyProviderConfiguration([provider])).toBe(true) + } + } + }) +}) + +describe("isFreeOnlyProviderConfiguration", () => { + test("returns true when all providers are in FREE_ONLY_PROVIDER_IDS", () => { + expect(isFreeOnlyProviderConfiguration(["opencode"])).toBe(true) + }) + + test("returns false when any provider is not in FREE_ONLY_PROVIDER_IDS", () => { + expect(isFreeOnlyProviderConfiguration(["opencode", "anthropic"])).toBe(false) + }) + + test("returns false for null", () => { + expect(isFreeOnlyProviderConfiguration(null)).toBe(false) + }) + + test("returns false for empty array", () => { + expect(isFreeOnlyProviderConfiguration([])).toBe(false) + }) +}) + +describe("appendFreeModelFallbacks", () => { + test("returns FREE_ONLY_FALLBACK_CHAIN when original chain is undefined", () => { + const result = appendFreeModelFallbacks(undefined) + expect(result).toEqual(FREE_ONLY_FALLBACK_CHAIN) + }) + + test("returns FREE_ONLY_FALLBACK_CHAIN when original chain is empty", () => { + const result = appendFreeModelFallbacks([]) + expect(result).toEqual(FREE_ONLY_FALLBACK_CHAIN) + }) + + test("preserves original chain order then appends free models in FREE_ONLY_FALLBACK_CHAIN order", () => { + const result = appendFreeModelFallbacks(ultrabrainFallbackChain) + expect(result.length).toBeGreaterThan(ultrabrainFallbackChain.length) + expect(result.slice(0, ultrabrainFallbackChain.length)).toEqual(ultrabrainFallbackChain) + + const appended = result.slice(ultrabrainFallbackChain.length) + const expectedAppended = FREE_ONLY_FALLBACK_CHAIN.filter( + (entry) => !ultrabrainFallbackChain.some((e) => e.model === entry.model), + ) + expect(appended).toEqual(expectedAppended) + }) + + test("does not duplicate free models already present in the chain", () => { + const chainWithFreeEntry = [ + ...ultrabrainFallbackChain, + FREE_ONLY_FALLBACK_CHAIN[0], + ] + const result = appendFreeModelFallbacks(chainWithFreeEntry) + + const modelCounts = new Map() + for (const entry of result) { + modelCounts.set(entry.model, (modelCounts.get(entry.model) ?? 0) + 1) + } + for (const [model, count] of modelCounts) { + expect(count).toBe(1) + } + }) }) diff --git a/src/tools/delegate-task/free-model-fallback.ts b/src/tools/delegate-task/free-model-fallback.ts index 47c0fdd1896..f0314d071ca 100644 --- a/src/tools/delegate-task/free-model-fallback.ts +++ b/src/tools/delegate-task/free-model-fallback.ts @@ -31,33 +31,15 @@ export function isFreeOnlyProviderConfiguration(connectedProviders: string[] | n && 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( +export function appendFreeModelFallbacks( fallbackChain: FallbackEntry[] | undefined, - freeOnlyProviderConfiguration: boolean, -): FallbackEntry[] | undefined { - if (!freeOnlyProviderConfiguration || !fallbackChain || fallbackChain.length === 0) { - return fallbackChain +): FallbackEntry[] { + if (!fallbackChain || fallbackChain.length === 0) { + return FREE_ONLY_FALLBACK_CHAIN } - const freeEntries = fallbackChain.filter((entry) => - entry.providers.includes("opencode") && isKnownFreeModel(entry.model), - ) - - return freeEntries.length > 0 ? freeEntries : FREE_ONLY_FALLBACK_CHAIN + // Ordering matters: original chain first, then free models in FREE_ONLY_FALLBACK_CHAIN priority order. + const existingModels = new Set(fallbackChain.map((entry) => entry.model)) + const newEntries = FREE_ONLY_FALLBACK_CHAIN.filter((entry) => !existingModels.has(entry.model)) + return [...fallbackChain, ...newEntries] } 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 index 5671a64b28f..13dcd952bd3 100644 --- a/src/tools/delegate-task/model-selection.free-model-fallback.test.ts +++ b/src/tools/delegate-task/model-selection.free-model-fallback.test.ts @@ -3,6 +3,14 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import { resolveModelForDelegateTask } from "./model-selection" import * as connectedProvidersCache from "../../shared/connected-providers-cache" +import { FREE_ONLY_FALLBACK_CHAIN } from "./free-model-fallback" +import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" + +const ultrabrainFallbackChain = CATEGORY_MODEL_REQUIREMENTS.ultrabrain.fallbackChain + +function qualifiedModel(entry: { providers: string[]; model: string }, provider = "opencode"): string { + return `${provider}/${entry.model}` +} describe("resolveModelForDelegateTask free-only fallback", () => { beforeEach(() => { @@ -12,50 +20,86 @@ describe("resolveModelForDelegateTask free-only fallback", () => { spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["opencode"]) }) - test("uses a free opencode fallback instead of a paid category default when only free providers are configured", () => { + test("resolves to a free model when availableModels contains only free models", () => { const result = resolveModelForDelegateTask({ - categoryDefaultModel: "opencode/gpt-5.4", - fallbackChain: [ - { providers: ["opencode"], model: "gpt-5.4", variant: "medium" }, - { providers: ["opencode"], model: "big-pickle" }, - ], + categoryDefaultModel: qualifiedModel(ultrabrainFallbackChain[0]), + fallbackChain: ultrabrainFallbackChain, availableModels: new Set([ - "opencode/gpt-5.4", - "opencode/big-pickle", - "opencode/minimax-m2.5-free", + qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[0]), + qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[1]), ]), }) expect(result).toEqual({ - model: "opencode/big-pickle", - fallbackEntry: { providers: ["opencode"], model: "big-pickle" }, + model: qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[0]), + fallbackEntry: FREE_ONLY_FALLBACK_CHAIN[0], matchedFallback: true, }) }) - test("falls back to a free global opencode model when the hardcoded chain only contains paid models", () => { + test("resolves to a free model on cold cache when only free providers are connected", () => { const result = resolveModelForDelegateTask({ - fallbackChain: [ - { providers: ["opencode"], model: "gpt-5.4", variant: "high" }, - { providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" }, - ], + fallbackChain: ultrabrainFallbackChain, availableModels: new Set(), }) expect(result).toEqual({ - model: "opencode/big-pickle", - fallbackEntry: { providers: ["opencode"], model: "big-pickle" }, + model: qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[0]), + fallbackEntry: FREE_ONLY_FALLBACK_CHAIN[0], matchedFallback: true, }) }) test("keeps an explicit user-configured category model even in free-only mode", () => { const result = resolveModelForDelegateTask({ - categoryDefaultModel: "opencode/gpt-5.4", + categoryDefaultModel: qualifiedModel(ultrabrainFallbackChain[0]), isUserConfiguredCategoryModel: true, - availableModels: new Set(["opencode/gpt-5.4", "opencode/big-pickle"]), + availableModels: new Set([ + qualifiedModel(ultrabrainFallbackChain[0]), + qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[0]), + ]), + }) + + expect(result).toEqual({ model: qualifiedModel(ultrabrainFallbackChain[0]) }) + }) + + test("does not downgrade a paid Zen subscriber whose availableModels contains paid models", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: qualifiedModel(ultrabrainFallbackChain[0]), + fallbackChain: ultrabrainFallbackChain, + availableModels: new Set([ + qualifiedModel(ultrabrainFallbackChain[0]), + qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[0]), + qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[1]), + ]), + }) + + expect(result).toEqual({ model: qualifiedModel(ultrabrainFallbackChain[0]) }) + }) + + test("does not rewrite a paid Zen subscriber's fallback chain to the free-only chain", () => { + const result = resolveModelForDelegateTask({ + fallbackChain: ultrabrainFallbackChain, + availableModels: new Set([ + qualifiedModel(ultrabrainFallbackChain[0]), + qualifiedModel(FREE_ONLY_FALLBACK_CHAIN[0]), + ]), + }) + + expect(result).toEqual({ + model: qualifiedModel(ultrabrainFallbackChain[0]), + variant: ultrabrainFallbackChain[0].variant, + fallbackEntry: ultrabrainFallbackChain[0], + matchedFallback: true, + }) + }) + + test("does not silently drop a paid category default when connectedProviders is opencode-only", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: qualifiedModel(ultrabrainFallbackChain[0]), + availableModels: new Set([qualifiedModel(ultrabrainFallbackChain[0])]), }) - expect(result).toEqual({ model: "opencode/gpt-5.4" }) + expect(result).toEqual({ model: qualifiedModel(ultrabrainFallbackChain[0]) }) }) }) diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts index 48e12df7603..4c72797bf64 100644 --- a/src/tools/delegate-task/model-selection.ts +++ b/src/tools/delegate-task/model-selection.ts @@ -5,9 +5,9 @@ import { transformModelForProvider } from "../../shared/provider-model-id-transf import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache" import { log } from "../../shared/logger" import { - getFallbackChainForFreeOnlyProviders, - getFreeOnlyCategoryDefaultModel, + appendFreeModelFallbacks, isFreeOnlyProviderConfiguration, + isKnownFreeModel, } from "./free-model-fallback" import { getExplicitHighBaseModel, @@ -33,18 +33,13 @@ export function resolveModelForDelegateTask(input: { return { model: userModel } } - const connectedProviders = readConnectedProvidersCache() - const freeOnlyProviderConfiguration = isFreeOnlyProviderConfiguration(connectedProviders) + const connectedProviders = input.availableModels.size === 0 ? readConnectedProvidersCache() : null if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) { return { skipped: true } } - const categoryDefault = normalizeModel(getFreeOnlyCategoryDefaultModel({ - categoryDefaultModel: input.categoryDefaultModel, - isUserConfiguredCategoryModel: input.isUserConfiguredCategoryModel, - freeOnlyProviderConfiguration, - })) + const categoryDefault = normalizeModel(input.categoryDefaultModel) const explicitHighBaseModel = categoryDefault ? getExplicitHighBaseModel(categoryDefault) : null const explicitHighModel = explicitHighBaseModel ? categoryDefault : undefined if (categoryDefault) { @@ -113,15 +108,22 @@ export function resolveModelForDelegateTask(input: { } } - const fallbackChain = getFallbackChainForFreeOnlyProviders( - input.fallbackChain, - freeOnlyProviderConfiguration, - ) + const hasModelList = input.availableModels.size > 0 + const allAvailableModelsAreFree = hasModelList && [...input.availableModels].every((m) => isKnownFreeModel(m)) + // TODO: connectedProviders can't distinguish free vs paid Zen. Removable once subagent-resolver falls back to a free model instead of matchedAgent.model on cold cache. + const onlyFreeProvidersConnected = !hasModelList && isFreeOnlyProviderConfiguration(connectedProviders) + const userHasOnlyFreeModels = allAvailableModelsAreFree || onlyFreeProvidersConnected + + const fallbackChain = userHasOnlyFreeModels + ? appendFreeModelFallbacks(input.fallbackChain) + : input.fallbackChain if (fallbackChain && fallbackChain.length > 0) { if (input.availableModels.size === 0) { if (connectedProviders) { const connectedSet = new Set(connectedProviders) for (const entry of fallbackChain) { + // Cold cache matches by provider, not availability — skip paid models for free-only users. + if (userHasOnlyFreeModels && !isKnownFreeModel(entry.model)) continue for (const provider of entry.providers) { if (connectedSet.has(provider)) { const transformedModelId = transformModelForProvider(provider, entry.model)