Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions src/tools/delegate-task/free-model-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
/// <reference types="bun-types" />

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", () => {
Expand All @@ -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<string, number>()
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)
}
})
})
34 changes: 8 additions & 26 deletions src/tools/delegate-task/free-model-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
86 changes: 65 additions & 21 deletions src/tools/delegate-task/model-selection.free-model-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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]) })
})
})
28 changes: 15 additions & 13 deletions src/tools/delegate-task/model-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
Loading