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) {