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)