Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 61 additions & 0 deletions src/tools/delegate-task/free-model-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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",
"kimi-k2.5-free",
"minimax-m2.5-free",
])

export const FREE_ONLY_FALLBACK_CHAIN: FallbackEntry[] = [
{ providers: ["opencode"], model: "big-pickle" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["opencode"], model: "minimax-m2.5-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
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Returning the filtered chain here keeps single-model free fallbacks like gpt-5-nano and skips the new stable free-only chain, so free-only resolution can still fail when another free opencode model is available.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tools/delegate-task/free-model-fallback.ts, line 60:

<comment>Returning the filtered chain here keeps single-model free fallbacks like `gpt-5-nano` and skips the new stable free-only chain, so free-only resolution can still fail when another free opencode model is available.</comment>

<file context>
@@ -0,0 +1,61 @@
+    entry.providers.includes("opencode") && isKnownFreeModel(entry.model),
+  )
+
+  return freeEntries.length > 0 ? freeEntries : FREE_ONLY_FALLBACK_CHAIN
+}
</file context>
Fix with Cubic

}
40 changes: 40 additions & 0 deletions src/tools/delegate-task/model-selection-input.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/// <reference types="bun-types" />

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/kimi-k2.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" })
})
})
66 changes: 21 additions & 45 deletions src/tools/delegate-task/model-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Free-only mode is derived from the wrong cache source here. If connected-providers.json is stale but availableModels came from a newer provider-models cache, this can incorrectly rewrite a valid paid resolution to the free opencode fallback chain.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tools/delegate-task/model-selection.ts, line 37:

<comment>Free-only mode is derived from the wrong cache source here. If `connected-providers.json` is stale but `availableModels` came from a newer provider-models cache, this can incorrectly rewrite a valid paid resolution to the free opencode fallback chain.</comment>

<file context>
@@ -63,15 +33,18 @@ export function resolveModelForDelegateTask(input: {
 
-  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.
</file context>
Suggested change
const freeOnlyProviderConfiguration = isFreeOnlyProviderConfiguration(connectedProviders)
const providersForFreeOnlyDetection = input.availableModels.size > 0
? [...new Set([...input.availableModels].map((model) => model.split("/")[0]))]
: connectedProviders
const freeOnlyProviderConfiguration = isFreeOnlyProviderConfiguration(providersForFreeOnlyDetection)
Fix with Cubic


// 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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading