From ad69b11536bf7f1f0c802327661cdc1ccef73749 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 20 Apr 2026 10:14:10 +0900 Subject: [PATCH 1/2] fix(model-resolution): ensure subagents use free models when no paid providers configured (fixes #1883) --- .../delegate-task/free-model-fallback.ts | 61 +++++++++++++++++ .../delegate-task/model-selection-input.ts | 40 +++++++++++ ...odel-selection.free-model-fallback.test.ts | 61 +++++++++++++++++ src/tools/delegate-task/model-selection.ts | 66 ++++++------------- 4 files changed, 183 insertions(+), 45 deletions(-) create mode 100644 src/tools/delegate-task/free-model-fallback.ts create mode 100644 src/tools/delegate-task/model-selection-input.ts create mode 100644 src/tools/delegate-task/model-selection.free-model-fallback.test.ts 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..2f03f7bf268 --- /dev/null +++ b/src/tools/delegate-task/free-model-fallback.ts @@ -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 +} 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..8ad8659e011 --- /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/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" }) + }) +}) 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) { From 49a1540afe0c6f2adf41710c74edd6dd72c68ee2 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Thu, 30 Apr 2026 19:10:20 +0900 Subject: [PATCH 2/2] fix(model-resolution): drop deprecated kimi-k2.5-free from free-only fallback chain Per https://github.com/code-yeongyu/oh-my-openagent/pull/3529#issuecomment review and the live opencode catalog (https://opencode.ai/zen/v1/models, https://models.dev/api.json), kimi-k2.5-free is now marked deprecated and no longer resolves. Shipping it in FREE_ONLY_FALLBACK_CHAIN reproduces the same shape of artifact that triggered #1883. Replaces it with the currently live free opencode models that are non-deprecated and have cost.input == 0: hy3-preview-free, nemotron-3-super-free (plus the existing big-pickle, minimax-m2.5-free, gpt-5-nano). Keeps the chain ordered by capability (multimodal -> general). Adds a regression test that asserts FREE_ONLY_FALLBACK_CHAIN never contains models in a known-deprecated list and that every entry passes isKnownFreeModel, so future drift fails CI instead of silently shipping a stale model. --- .../delegate-task/free-model-fallback.test.ts | 22 +++++++++++++++++++ .../delegate-task/free-model-fallback.ts | 6 +++-- ...odel-selection.free-model-fallback.test.ts | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 src/tools/delegate-task/free-model-fallback.test.ts 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 index 2f03f7bf268..47c0fdd1896 100644 --- a/src/tools/delegate-task/free-model-fallback.ts +++ b/src/tools/delegate-task/free-model-fallback.ts @@ -4,14 +4,16 @@ const FREE_ONLY_PROVIDER_IDS = new Set(["opencode"]) const KNOWN_FREE_MODEL_IDS = new Set([ "big-pickle", "gpt-5-nano", - "kimi-k2.5-free", + "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: "kimi-k2.5-free" }, { 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" }, ] 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 8ad8659e011..5671a64b28f 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 @@ -22,7 +22,7 @@ describe("resolveModelForDelegateTask free-only fallback", () => { availableModels: new Set([ "opencode/gpt-5.4", "opencode/big-pickle", - "opencode/kimi-k2.5-free", + "opencode/minimax-m2.5-free", ]), })