diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 75f68e8ea0ab..afebc39edac9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -634,22 +634,24 @@ export const RunCommand = effectCmd({ process.exit(1) }) + const resolved = await Provider.resolveSelection(args.model, args.variant) + if (args.command) { await sdk.session.command({ sessionID, agent, - model: args.model, + model: resolved.model, command: args.command, arguments: message, - variant: args.variant, + variant: resolved.variant, }) } else { - const model = args.model ? Provider.parseModel(args.model) : undefined + const model = resolved.model ? Provider.parseModel(resolved.model) : undefined await sdk.session.prompt({ sessionID, agent, model, - variant: args.variant, + variant: resolved.variant, parts: [...files, { type: "text", text: message }], }) } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ea742f699708..870255ed522a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -350,6 +350,7 @@ function App(props: { onSnapshot?: () => Promise }) { }) local.model.set({ providerID, modelID }, { recent: true }) } + if (args.variant) local.model.variant.set(args.variant) if (args.sessionID && !args.fork) { route.navigate({ type: "session", diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index 8a229ffaba69..cf37bc567094 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -2,6 +2,7 @@ import { createSimpleContext } from "./helper" export interface Args { model?: string + variant?: string agent?: string prompt?: string continue?: boolean diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 384b6fc4ff57..c3e6efddf6b7 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -22,6 +22,8 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "./validate-session" +import { Provider } from "@/provider/provider" +import { WithInstance } from "@/project/with-instance" declare global { const OPENCODE_WORKER_PATH: string @@ -112,6 +114,10 @@ export const TuiThreadCommand = cmd({ .option("agent", { type: "string", describe: "agent to use", + }) + .option("variant", { + type: "string", + describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", }), handler: async (args) => { // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it. @@ -139,6 +145,15 @@ export const TuiThreadCommand = cmd({ return } const cwd = Filesystem.resolve(process.cwd()) + // TUI handler runs outside effectCmd, so the Instance ALS context that + // Provider.Service.list needs isn't established. Provide it here. The + // worker spawned below sets up its own. + const pick = await WithInstance.provide({ + directory: cwd, + fn: () => Provider.resolveSelection(args.model, args.variant), + }) + const model = pick.model + const variant = pick.variant const env = sanitizedProcessEnv({ [OPENCODE_PROCESS_ROLE]: "worker", [OPENCODE_RUN_ID]: ensureRunID(), @@ -244,7 +259,8 @@ export const TuiThreadCommand = cmd({ continue: args.continue, sessionID: args.session, agent: args.agent, - model: args.model, + model, + variant, prompt, fork: args.fork, }, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 939110e044fb..c0e42f2430dd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -22,6 +22,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema, Types } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" import { optionalOmitUndefined, withStatics } from "@/util/schema" @@ -1737,6 +1738,62 @@ export function sort(models: T[]) { ) } +const FREE = "free" +export const ANY = "any" + +// "big-pickle" predates the "-free" suffix convention. +const FREE_LEGACY_IDS = new Set(["big-pickle"]) + +export function isFree(model: Model) { + const extra = model.cost.experimentalOver200K + return ( + model.providerID === ProviderID.opencode && + model.cost.input === 0 && + model.cost.output === 0 && + model.cost.cache.read === 0 && + model.cost.cache.write === 0 && + (!extra || (extra.input === 0 && extra.output === 0 && extra.cache.read === 0 && extra.cache.write === 0)) + ) +} + +export function isListed(model: Model) { + return FREE_LEGACY_IDS.has(model.id) || model.id.endsWith("-free") +} + +function freeVariants(model: Model) { + return Object.keys(model.variants ?? {}) + .toSorted() + .filter((item) => item !== "default") +} + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function resolveSelection(model?: string, variant?: string) { + if (!model) return { model, variant } + if (model !== FREE) return { model, variant } + const providers = await runPromise((svc) => svc.list()) + const provider = providers[ProviderID.opencode] + const models = sort(Object.values(provider?.models ?? {}).filter((item) => isFree(item) && isListed(item))) + // Unseeded by design: the same `--model free` in two terminals picks + // different models. + const pick = models[Math.floor(Math.random() * models.length)] + if (!pick) + throw new Error( + `No free opencode models found. The opencode provider must be configured (set OPENCODE_API_KEY) and at least one model in its catalog must satisfy: cost = 0, id is "big-pickle" or ends with "-free".`, + ) + const value = + variant === ANY + ? (() => { + const choices = freeVariants(pick) + return choices[Math.floor(Math.random() * choices.length)] + })() + : variant + return { + model: `${pick.providerID}/${pick.id}`, + variant: value, + } +} + export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { diff --git a/packages/opencode/test/cli/cmd/run.test.ts b/packages/opencode/test/cli/cmd/run.test.ts new file mode 100644 index 000000000000..4bde064464de --- /dev/null +++ b/packages/opencode/test/cli/cmd/run.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import * as SDK from "@opencode-ai/sdk/v2" +import { Provider } from "../../../src/provider/provider" + +const seen = { + prompt: [] as any[], + command: [] as any[], + variant: [] as any[], +} + +function setup() { + spyOn(Provider, "resolveSelection").mockImplementation(async (model, variant) => ({ + model: model === "free" ? "opencode/freebie" : model, + variant: variant === "any" ? "high" : variant, + })) + spyOn(SDK, "createOpencodeClient").mockImplementation( + () => + ({ + config: { + get: async () => ({ data: { share: "manual" } }), + }, + event: { + subscribe: async () => ({ + stream: (async function* () {})(), + }), + }, + session: { + create: async () => ({ data: { id: "session-1" } }), + prompt: async (input: any) => { + seen.prompt.push(input) + seen.variant.push(input.variant) + return {} + }, + command: async (input: any) => { + seen.command.push(input) + seen.variant.push(input.variant) + return {} + }, + }, + }) as any, + ) +} + +describe("run command", () => { + afterEach(() => { + mock.restore() + seen.prompt.length = 0 + seen.command.length = 0 + seen.variant.length = 0 + }) + + async function call(extra?: Record) { + setup() + const { RunCommand } = await import("../../../src/cli/cmd/run") + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + + try { + await RunCommand.handler({ + _: [], + $0: "opencode", + message: ["hi"], + command: undefined, + continue: false, + session: undefined, + fork: false, + share: false, + model: "free", + agent: undefined, + format: "default", + file: undefined, + title: undefined, + attach: "http://127.0.0.1:4096", + password: undefined, + dir: undefined, + port: undefined, + variant: undefined, + thinking: false, + "dangerously-skip-permissions": false, + "--": [], + ...extra, + } as any) + } finally { + if (tty) Object.defineProperty(process.stdin, "isTTY", tty) + else delete (process.stdin as { isTTY?: boolean }).isTTY + } + } + + test("resolves free before prompting", async () => { + await call() + + expect(seen.prompt).toHaveLength(1) + expect(String(seen.prompt[0].model.providerID)).toBe("opencode") + expect(String(seen.prompt[0].model.modelID)).toBe("freebie") + }) + + test("passes the resolved model to command sessions", async () => { + await call({ command: "echo" }) + + expect(seen.command).toHaveLength(1) + expect(seen.command[0].model).toBe("opencode/freebie") + }) + + test("passes the resolved any variant to sessions", async () => { + await call({ variant: "any" }) + + expect(seen.prompt).toHaveLength(1) + expect(seen.variant[0]).toBe("high") + }) +}) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 53b7488c2682..7eadc8f92721 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,19 +1,150 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" -import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread" +import * as App from "../../../src/cli/cmd/tui/app" +import { UI } from "../../../src/cli/ui" +import * as Timeout from "../../../src/util/timeout" +import * as Network from "../../../src/cli/network" +import * as Win32 from "../../../src/cli/cmd/tui/win32" +import { Provider } from "../../../src/provider/provider" + +const stop = new Error("stop") +const packageRoot = path.resolve(import.meta.dir, "../../..") +const seen = { + tui: [] as string[], + model: [] as (string | undefined)[], + variant: [] as (string | undefined)[], +} + +class TestWorker extends EventTarget { + onerror: Worker["onerror"] = null + onmessage: Worker["onmessage"] = null + onmessageerror: Worker["onmessageerror"] = null + + postMessage(data: string) { + const parsed = JSON.parse(data) + if (!parsed || typeof parsed !== "object" || !("method" in parsed) || !("id" in parsed)) return + if (typeof parsed.method !== "string" || typeof parsed.id !== "number") return + const result = + parsed.method === "fetch" + ? { status: 200, headers: {}, body: "" } + : parsed.method === "server" + ? { url: "http://127.0.0.1" } + : parsed.method === "snapshot" + ? "" + : undefined + queueMicrotask(() => { + this.onmessage?.( + new MessageEvent("message", { data: JSON.stringify({ type: "rpc.result", result, id: parsed.id }) }), + ) + }) + } + + terminate() {} +} + +function setup() { + // Intentionally avoid mock.module() here: Bun keeps module overrides in cache + // and mock.restore() does not reset mock.module values. If this switches back + // to module mocks, later suites can see mocked @/config/tui and fail (e.g. + // plugin-loader tests expecting real TuiConfig.waitForDependencies). See: + // https://github.com/oven-sh/bun/issues/7823 and #12823. + spyOn(App, "tui").mockImplementation(async (input) => { + if (input.directory) seen.tui.push(input.directory) + seen.model.push(input.args.model) + seen.variant.push(input.args.variant) + throw stop + }) + spyOn(UI, "error").mockImplementation(() => {}) + spyOn(Timeout, "withTimeout").mockImplementation((input) => input) + spyOn(Network, "resolveNetworkOptions").mockResolvedValue({ + mdns: false, + port: 0, + hostname: "127.0.0.1", + mdnsDomain: "opencode.local", + cors: [], + }) + spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) + spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) + spyOn(Provider, "resolveSelection").mockImplementation(async (model, variant) => ({ + model: model === "free" ? "opencode/freebie" : model, + variant: variant === "any" ? "high" : variant, + })) +} describe("tui thread", () => { - async function check(project?: string) { + afterEach(() => { + mock.restore() + }) + + async function call(project?: string, model?: string, variant?: string) { + const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread") + const args: Parameters>[0] = { + _: [], + $0: "opencode", + project, + prompt: "hi", + model, + variant, + agent: undefined, + session: undefined, + continue: false, + fork: false, + port: 0, + hostname: "127.0.0.1", + mdns: false, + "mdns-domain": "opencode.local", + mdnsDomain: "opencode.local", + cors: [], + } + return TuiThreadCommand.handler(args) + } + + async function check( + project?: string, + model?: string, + variant?: string, + expected?: { model?: string; variant?: string }, + ) { + setup() + const pwd = process.env.PWD + const worker = globalThis.Worker + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" + seen.tui.length = 0 + seen.model.length = 0 + seen.variant.length = 0 + await fs.symlink(tmp.path, link, type) + + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + Object.defineProperty(globalThis, "Worker", { configurable: true, value: TestWorker }) try { - await fs.symlink(tmp.path, link, type) - expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path) + process.chdir(tmp.path) + process.env.PWD = link + let error: unknown + try { + await call(project, model, variant) + } catch (caught) { + error = caught + } + expect(error).toBe(stop) + expect(seen.tui[0]).toBe(tmp.path) + if (expected?.model) expect(seen.model[0]).toBe(expected.model) + if (expected?.variant) expect(seen.variant[0]).toBe(expected.variant) } finally { + process.chdir(packageRoot) + if (pwd === undefined) delete process.env.PWD + else process.env.PWD = pwd + if (tty) Object.defineProperty(process.stdin, "isTTY", tty) + else delete (process.stdin as { isTTY?: boolean }).isTTY + Object.defineProperty(globalThis, "Worker", { configurable: true, value: worker }) await fs.rm(link, { recursive: true, force: true }).catch(() => undefined) } } @@ -25,4 +156,12 @@ describe("tui thread", () => { test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) + + test("resolves the free model alias before launching the tui", async () => { + await check(undefined, "free", undefined, { model: "opencode/freebie" }) + }) + + test("passes the resolved any variant before launching the tui", async () => { + await check(undefined, "free", "any", { model: "opencode/freebie", variant: "high" }) + }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index cdb9d2057245..b2f69409f037 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { afterEach, expect, mock, spyOn, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" @@ -70,6 +70,83 @@ function paid(providers: Awaited>) { return Object.values(item.models).filter((model) => model.cost.input > 0).length } +function free(model: { cost: { input: number; output: number; cache: { read: number; write: number } } }) { + return ( + model.cost.input === 0 && model.cost.output === 0 && model.cost.cache.read === 0 && model.cost.cache.write === 0 + ) +} + +function listed(id: string) { + return id === "big-pickle" || id.endsWith("-free") +} + +afterEach(() => { + mock.restore() +}) + +async function freecase(fn: () => Promise) { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + opencode: { + whitelist: ["alpha-free", "plain-free"], + options: { + apiKey: "test-api-key", + }, + models: { + "alpha-free": { + name: "Alpha Free", + reasoning: true, + cost: { + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }, + limit: { + context: 128000, + output: 4096, + }, + variants: { + low: { + effort: "low", + }, + high: { + effort: "high", + }, + }, + }, + "plain-free": { + name: "Plain Free", + reasoning: false, + cost: { + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }, + limit: { + context: 128000, + output: 4096, + }, + }, + }, + }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn, + }) +} + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -470,6 +547,214 @@ test("parseModel handles model IDs with slashes", () => { expect(String(result.modelID)).toBe("anthropic/claude-3-opus") }) +test("resolveSelection picks only valid opencode free listings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + opencode: { + options: { + apiKey: "test-api-key", + }, + }, + openrouter: { + options: { + apiKey: "test-api-key", + }, + models: { + "free-router": { + name: "Free Router", + cost: { + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }, + tool_call: true, + limit: { + context: 128000, + output: 4096, + }, + }, + }, + }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await list() + const opencode = providers[ProviderID.opencode] + const openrouter = providers[ProviderID.openrouter] + expect(opencode).toBeDefined() + expect(openrouter).toBeDefined() + expect(openrouter.models["free-router"]).toBeDefined() + + const freeModels = Provider.sort(Object.values(opencode.models).filter(free)).map((model) => String(model.id)) + const listedModels = freeModels.filter(listed) + const rest = freeModels.filter((id) => !listed(id)) + + expect(listedModels.length).toBeGreaterThan(0) + expect(rest.length).toBeGreaterThan(0) + expect(rest).toContain("gpt-5-nano") + + spyOn(Math, "random").mockReturnValue(0) + + const result = await Provider.resolveSelection("free") + const parsed = Provider.parseModel(result.model!) + + expect(String(parsed.providerID)).toBe("opencode") + expect(listedModels).toContain(String(parsed.modelID)) + expect(rest).not.toContain(String(parsed.modelID)) + expect(String(parsed.modelID)).not.toBe("free-router") + }, + }) +}) + +test("resolveSelection picks a variant from the chosen free model", async () => { + await freecase(async () => { + const provider = (await list())[ProviderID.opencode] + const models = Provider.sort( + Object.values(provider.models) + .filter(free) + .filter((item) => listed(String(item.id))), + ) + const index = models.findIndex((item) => String(item.id) === "alpha-free") + const choices = Object.keys(provider.models["alpha-free"].variants ?? {}).toSorted() + + expect(index).toBeGreaterThanOrEqual(0) + expect(choices.length).toBeGreaterThan(0) + + let count = 0 + spyOn(Math, "random").mockImplementation(() => { + count += 1 + if (count === 1) return (index + 0.1) / models.length + return (choices.length - 1 + 0.1) / choices.length + }) + + const result = await Provider.resolveSelection("free", "any") + + expect(result.model).toBe("opencode/alpha-free") + expect(result.variant).toBe(choices.at(-1)) + }) +}) + +test("resolveSelection keeps explicit variants unchanged", async () => { + await freecase(async () => { + const provider = (await list())[ProviderID.opencode] + const models = Provider.sort( + Object.values(provider.models) + .filter(free) + .filter((item) => listed(String(item.id))), + ) + const index = models.findIndex((item) => String(item.id) === "alpha-free") + + expect(index).toBeGreaterThanOrEqual(0) + + spyOn(Math, "random").mockReturnValue((index + 0.1) / models.length) + + const result = await Provider.resolveSelection("free", "max") + + expect(result.model).toBe("opencode/alpha-free") + expect(result.variant).toBe("max") + }) +}) + +test("resolveSelection falls back to no variant when the chosen free model has none", async () => { + await freecase(async () => { + const provider = (await list())[ProviderID.opencode] + const models = Provider.sort( + Object.values(provider.models) + .filter(free) + .filter((item) => listed(String(item.id))), + ) + const index = models.findIndex((item) => String(item.id) === "plain-free") + + expect(index).toBeGreaterThanOrEqual(0) + expect(provider.models["plain-free"].variants).toEqual({}) + + spyOn(Math, "random").mockReturnValue((index + 0.1) / models.length) + + const result = await Provider.resolveSelection("free", "any") + + expect(result.model).toBe("opencode/plain-free") + expect(result.variant).toBeUndefined() + }) +}) + +test("resolveSelection passes through when model is undefined", async () => { + const result = await Provider.resolveSelection(undefined, "any") + expect(result.model).toBeUndefined() + expect(result.variant).toBe("any") +}) + +test("resolveSelection short-circuits any with an explicit non-free model", async () => { + const result = await Provider.resolveSelection("opencode/big-pickle", "any") + expect(result.model).toBe("opencode/big-pickle") + expect(result.variant).toBe("any") +}) + +test("resolveSelection throws with actionable message when no free models are available", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + opencode: { + whitelist: ["paid-only"], + options: { apiKey: "test-api-key" }, + models: { + "paid-only": { + name: "Paid Only", + cost: { input: 1, output: 2, cache_read: 0, cache_write: 0 }, + limit: { context: 128000, output: 4096 }, + }, + }, + }, + }, + }), + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + let caught: Error | undefined + try { + await Provider.resolveSelection("free") + } catch (e) { + caught = e as Error + } + expect(caught).toBeDefined() + expect(caught!.message).toContain("OPENCODE_API_KEY") + expect(caught!.message).toContain("-free") + }, + }) +}) + +test("isListed accepts big-pickle and -free suffix, rejects everything else", () => { + const make = (id: string) => + ({ + id, + providerID: "opencode", + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + }) as any + expect(Provider.isListed(make("big-pickle"))).toBe(true) + expect(Provider.isListed(make("foo-free"))).toBe(true) + expect(Provider.isListed(make("nemotron-3-super-free"))).toBe(true) + expect(Provider.isListed(make("foo"))).toBe(false) + expect(Provider.isListed(make("gpt-5-nano"))).toBe(false) + expect(Provider.isListed(make("free"))).toBe(false) +}) + test("defaultModel returns first available model when no config set", async () => { await using tmp = await tmpdir({ init: async (dir) => {