Skip to content

Commit cd5548b

Browse files
committed
feat: add free model resolution for --model free
Resolves --model free to a random free opencode model before prompting. Supports --variant any for random variant selection. Closes #21863
1 parent 378b8ca commit cd5548b

File tree

8 files changed

+429
-11
lines changed

8 files changed

+429
-11
lines changed

packages/opencode/src/cli/cmd/run.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -644,22 +644,26 @@ export const RunCommand = cmd({
644644
process.exit(1)
645645
})
646646

647+
const resolved = await Provider.resolveSelection(args.model, args.variant)
648+
const model = resolved.model
649+
const variant = resolved.variant
650+
647651
if (args.command) {
648652
await sdk.session.command({
649653
sessionID,
650654
agent,
651-
model: args.model,
655+
model,
652656
command: args.command,
653657
arguments: message,
654-
variant: args.variant,
658+
variant,
655659
})
656660
} else {
657-
const model = args.model ? Provider.parseModel(args.model) : undefined
661+
const modelObj = model ? Provider.parseModel(model) : undefined
658662
await sdk.session.prompt({
659663
sessionID,
660664
agent,
661-
model,
662-
variant: args.variant,
665+
model: modelObj,
666+
variant,
663667
parts: [...files, { type: "text", text: message }],
664668
})
665669
}

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
388388
})
389389
local.model.set({ providerID, modelID }, { recent: true })
390390
}
391+
if (args.variant) local.model.variant.set(args.variant)
391392
// Handle --session without --fork immediately (fork is handled in createEffect below)
392393
if (args.sessionID && !args.fork) {
393394
route.navigate({

packages/opencode/src/cli/cmd/tui/context/args.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createSimpleContext } from "./helper"
22

33
export interface Args {
44
model?: string
5+
variant?: string
56
agent?: string
67
prompt?: string
78
continue?: boolean

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
1616
import { TuiConfig } from "@/config/tui"
1717
import { Instance } from "@/project/instance"
1818
import { writeHeapSnapshot } from "v8"
19+
import { Provider } from "@/provider/provider"
1920

2021
declare global {
2122
const OPENCODE_WORKER_PATH: string
@@ -100,6 +101,10 @@ export const TuiThreadCommand = cmd({
100101
.option("agent", {
101102
type: "string",
102103
describe: "agent to use",
104+
})
105+
.option("variant", {
106+
type: "string",
107+
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
103108
}),
104109
handler: async (args) => {
105110
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
@@ -130,6 +135,9 @@ export const TuiThreadCommand = cmd({
130135
return
131136
}
132137
const cwd = Filesystem.resolve(process.cwd())
138+
const pick = await Provider.resolveSelection(args.model, args.variant)
139+
const model = pick.model
140+
const variant = pick.variant
133141

134142
const worker = new Worker(file, {
135143
env: Object.fromEntries(
@@ -217,7 +225,8 @@ export const TuiThreadCommand = cmd({
217225
continue: args.continue,
218226
sessionID: args.session,
219227
agent: args.agent,
220-
model: args.model,
228+
model,
229+
variant,
221230
prompt,
222231
fork: args.fork,
223232
},

packages/opencode/src/provider/provider.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1697,6 +1697,50 @@ export namespace Provider {
16971697
return runPromise((svc) => svc.defaultModel())
16981698
}
16991699

1700+
const FREE = "free"
1701+
export const ANY = "any"
1702+
1703+
function free(model: Model) {
1704+
const extra = model.cost.experimentalOver200K
1705+
return (
1706+
model.providerID === ProviderID.opencode &&
1707+
model.cost.input === 0 &&
1708+
model.cost.output === 0 &&
1709+
model.cost.cache.read === 0 &&
1710+
model.cost.cache.write === 0 &&
1711+
(!extra || (extra.input === 0 && extra.output === 0 && extra.cache.read === 0 && extra.cache.write === 0))
1712+
)
1713+
}
1714+
1715+
function listed(model: Model) {
1716+
return model.id === "big-pickle" || model.id.endsWith("-free")
1717+
}
1718+
1719+
function variants(model: Model) {
1720+
return Object.keys(model.variants ?? {})
1721+
.toSorted()
1722+
.filter((item) => item !== "default")
1723+
}
1724+
1725+
export async function resolveSelection(model?: string, variant?: string) {
1726+
if (!model) return { model, variant }
1727+
if (model !== FREE) return { model, variant }
1728+
const provider = (await list())[ProviderID.opencode]
1729+
const models = sort(Object.values(provider?.models ?? {}).filter((item) => free(item) && listed(item)))
1730+
const pick = models[Math.floor(Math.random() * models.length)]
1731+
if (!pick) throw new Error("No free opencode models found")
1732+
const next = variant === "any" ? variants(pick) : []
1733+
const value = variant !== "any" ? variant : next[Math.floor(Math.random() * next.length)]
1734+
return {
1735+
model: `${pick.providerID}/${pick.id}`,
1736+
variant: value,
1737+
}
1738+
}
1739+
1740+
export async function resolveModel(model: string) {
1741+
return (await resolveSelection(model)).model!
1742+
}
1743+
17001744
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
17011745
export function sort<T extends { id: string }>(models: T[]) {
17021746
return sortBy(
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
2+
import * as SDK from "@opencode-ai/sdk/v2"
3+
import { Provider } from "../../../src/provider/provider"
4+
5+
const seen = {
6+
prompt: [] as any[],
7+
command: [] as any[],
8+
variant: [] as any[],
9+
}
10+
11+
function setup() {
12+
spyOn(Provider, "resolveSelection").mockImplementation(async (model, variant) => ({
13+
model: model === "free" ? "opencode/freebie" : model,
14+
variant: variant === "any" ? "high" : variant,
15+
}))
16+
spyOn(SDK, "createOpencodeClient").mockImplementation(
17+
() =>
18+
({
19+
config: {
20+
get: async () => ({ data: { share: "manual" } }),
21+
},
22+
event: {
23+
subscribe: async () => ({
24+
stream: (async function* () {})(),
25+
}),
26+
},
27+
session: {
28+
create: async () => ({ data: { id: "session-1" } }),
29+
prompt: async (input: any) => {
30+
seen.prompt.push(input)
31+
seen.variant.push(input.variant)
32+
return {}
33+
},
34+
command: async (input: any) => {
35+
seen.command.push(input)
36+
seen.variant.push(input.variant)
37+
return {}
38+
},
39+
},
40+
}) as any,
41+
)
42+
}
43+
44+
describe("run command", () => {
45+
afterEach(() => {
46+
mock.restore()
47+
seen.prompt.length = 0
48+
seen.command.length = 0
49+
seen.variant.length = 0
50+
})
51+
52+
async function call(extra?: Record<string, unknown>) {
53+
setup()
54+
const { RunCommand } = await import("../../../src/cli/cmd/run")
55+
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
56+
57+
Object.defineProperty(process.stdin, "isTTY", {
58+
configurable: true,
59+
value: true,
60+
})
61+
62+
try {
63+
await RunCommand.handler({
64+
_: [],
65+
$0: "opencode",
66+
message: ["hi"],
67+
command: undefined,
68+
continue: false,
69+
session: undefined,
70+
fork: false,
71+
share: false,
72+
model: "free",
73+
agent: undefined,
74+
format: "default",
75+
file: undefined,
76+
title: undefined,
77+
attach: "http://127.0.0.1:4096",
78+
password: undefined,
79+
dir: undefined,
80+
port: undefined,
81+
variant: undefined,
82+
thinking: false,
83+
"dangerously-skip-permissions": false,
84+
"--": [],
85+
...extra,
86+
} as any)
87+
} finally {
88+
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
89+
else delete (process.stdin as { isTTY?: boolean }).isTTY
90+
}
91+
}
92+
93+
test("resolves free before prompting", async () => {
94+
await call()
95+
96+
expect(seen.prompt).toHaveLength(1)
97+
expect(String(seen.prompt[0].model.providerID)).toBe("opencode")
98+
expect(String(seen.prompt[0].model.modelID)).toBe("freebie")
99+
})
100+
101+
test("passes the resolved model to command sessions", async () => {
102+
await call({ command: "echo" })
103+
104+
expect(seen.command).toHaveLength(1)
105+
expect(seen.command[0].model).toBe("opencode/freebie")
106+
})
107+
108+
test("passes the resolved any variant to sessions", async () => {
109+
await call({ variant: "any" })
110+
111+
expect(seen.prompt).toHaveLength(1)
112+
expect(seen.variant[0]).toBe("high")
113+
})
114+
})

packages/opencode/test/cli/tui/thread.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import * as Network from "../../../src/cli/network"
1010
import * as Win32 from "../../../src/cli/cmd/tui/win32"
1111
import { TuiConfig } from "../../../src/config/tui"
1212
import { Instance } from "../../../src/project/instance"
13+
import { Provider } from "../../../src/provider/provider"
1314

1415
const stop = new Error("stop")
1516
const seen = {
1617
tui: [] as string[],
1718
inst: [] as string[],
19+
model: [] as (string | undefined)[],
20+
variant: [] as (string | undefined)[],
1821
}
1922

2023
function setup() {
@@ -25,6 +28,8 @@ function setup() {
2528
// https://github.com/oven-sh/bun/issues/7823 and #12823.
2629
spyOn(App, "tui").mockImplementation(async (input) => {
2730
if (input.directory) seen.tui.push(input.directory)
31+
seen.model.push(input.args.model)
32+
seen.variant.push(input.args.variant)
2833
throw stop
2934
})
3035
spyOn(Rpc, "client").mockImplementation(() => ({
@@ -47,21 +52,26 @@ function setup() {
4752
seen.inst.push(input.directory)
4853
return input.fn()
4954
})
55+
spyOn(Provider, "resolveSelection").mockImplementation(async (model, variant) => ({
56+
model: model === "free" ? "opencode/freebie" : model,
57+
variant: variant === "any" ? "high" : variant,
58+
}))
5059
}
5160

5261
describe("tui thread", () => {
5362
afterEach(() => {
5463
mock.restore()
5564
})
5665

57-
async function call(project?: string) {
66+
async function call(project?: string, model?: string, variant?: string) {
5867
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
5968
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
6069
_: [],
6170
$0: "opencode",
6271
project,
6372
prompt: "hi",
64-
model: undefined,
73+
model,
74+
variant,
6575
agent: undefined,
6676
session: undefined,
6777
continue: false,
@@ -76,7 +86,12 @@ describe("tui thread", () => {
7686
return TuiThreadCommand.handler(args)
7787
}
7888

79-
async function check(project?: string) {
89+
async function check(
90+
project?: string,
91+
model?: string,
92+
variant?: string,
93+
expected?: { model?: string; variant?: string },
94+
) {
8095
setup()
8196
await using tmp = await tmpdir({ git: true })
8297
const cwd = process.cwd()
@@ -87,6 +102,8 @@ describe("tui thread", () => {
87102
const type = process.platform === "win32" ? "junction" : "dir"
88103
seen.tui.length = 0
89104
seen.inst.length = 0
105+
seen.model.length = 0
106+
seen.variant.length = 0
90107
await fs.symlink(tmp.path, link, type)
91108

92109
Object.defineProperty(process.stdin, "isTTY", {
@@ -104,9 +121,11 @@ describe("tui thread", () => {
104121
try {
105122
process.chdir(tmp.path)
106123
process.env.PWD = link
107-
await expect(call(project)).rejects.toBe(stop)
124+
await expect(call(project, model, variant)).rejects.toBe(stop)
108125
expect(seen.inst[0]).toBe(tmp.path)
109126
expect(seen.tui[0]).toBe(tmp.path)
127+
if (expected?.model) expect(seen.model[0]).toBe(expected.model)
128+
if (expected?.variant) expect(seen.variant[0]).toBe(expected.variant)
110129
} finally {
111130
process.chdir(cwd)
112131
if (pwd === undefined) delete process.env.PWD
@@ -125,4 +144,12 @@ describe("tui thread", () => {
125144
test("uses the real cwd after resolving a relative project from PWD", async () => {
126145
await check(".")
127146
})
147+
148+
test("resolves the free model alias before launching the tui", async () => {
149+
await check(undefined, "free", undefined, { model: "opencode/freebie" })
150+
})
151+
152+
test("passes the resolved any variant before launching the tui", async () => {
153+
await check(undefined, "free", "any", { model: "opencode/freebie", variant: "high" })
154+
})
128155
})

0 commit comments

Comments
 (0)