Skip to content

Commit 2bb71d0

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 266e965 commit 2bb71d0

8 files changed

Lines changed: 433 additions & 11 deletions

File tree

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

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

639+
const resolved = await Provider.resolveSelection(args.model, args.variant)
640+
const model = resolved.model
641+
const variant = resolved.variant
642+
639643
if (args.command) {
640644
await sdk.session.command({
641645
sessionID,
642646
agent,
643-
model: args.model,
647+
model,
644648
command: args.command,
645649
arguments: message,
646-
variant: args.variant,
650+
variant,
647651
})
648652
} else {
649-
const model = args.model ? Provider.parseModel(args.model) : undefined
653+
const modelObj = model ? Provider.parseModel(model) : undefined
650654
await sdk.session.prompt({
651655
sessionID,
652656
agent,
653-
model,
654-
variant: args.variant,
657+
model: modelObj,
658+
variant,
655659
parts: [...files, { type: "text", text: message }],
656660
})
657661
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
342342
})
343343
local.model.set({ providerID, modelID }, { recent: true })
344344
}
345+
if (args.variant) local.model.variant.set(args.variant)
345346
if (args.sessionID && !args.fork) {
346347
route.navigate({
347348
type: "session",

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
@@ -17,6 +17,7 @@ import { writeHeapSnapshot } from "v8"
1717
import { TuiConfig } from "./config/tui"
1818
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
1919
import { validateSession } from "./validate-session"
20+
import { Provider } from "@/provider"
2021

2122
declare global {
2223
const OPENCODE_WORKER_PATH: string
@@ -101,6 +102,10 @@ export const TuiThreadCommand = cmd({
101102
.option("agent", {
102103
type: "string",
103104
describe: "agent to use",
105+
})
106+
.option("variant", {
107+
type: "string",
108+
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
104109
}),
105110
handler: async (args) => {
106111
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
@@ -131,6 +136,9 @@ export const TuiThreadCommand = cmd({
131136
return
132137
}
133138
const cwd = Filesystem.resolve(process.cwd())
139+
const pick = await Provider.resolveSelection(args.model, args.variant)
140+
const model = pick.model
141+
const variant = pick.variant
134142
const env = sanitizedProcessEnv({
135143
[OPENCODE_PROCESS_ROLE]: "worker",
136144
[OPENCODE_RUN_ID]: ensureRunID(),
@@ -236,7 +244,8 @@ export const TuiThreadCommand = cmd({
236244
continue: args.continue,
237245
sessionID: args.session,
238246
agent: args.agent,
239-
model: args.model,
247+
model,
248+
variant,
240249
prompt,
241250
fork: args.fork,
242251
},

packages/opencode/src/provider/provider.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { pathToFileURL } from "url"
2323
import { Effect, Layer, Context, Schema, Types } from "effect"
2424
import { EffectBridge } from "@/effect"
2525
import { InstanceState } from "@/effect"
26+
import { makeRuntime } from "@/effect/run-service"
2627
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
2728
import { isRecord } from "@/util/record"
2829
import { withStatics } from "@/util/schema"
@@ -1705,6 +1706,53 @@ export function sort<T extends { id: string }>(models: T[]) {
17051706
)
17061707
}
17071708

1709+
const FREE = "free"
1710+
export const ANY = "any"
1711+
1712+
function isFree(model: Model) {
1713+
const extra = model.cost.experimentalOver200K
1714+
return (
1715+
model.providerID === ProviderID.opencode &&
1716+
model.cost.input === 0 &&
1717+
model.cost.output === 0 &&
1718+
model.cost.cache.read === 0 &&
1719+
model.cost.cache.write === 0 &&
1720+
(!extra || (extra.input === 0 && extra.output === 0 && extra.cache.read === 0 && extra.cache.write === 0))
1721+
)
1722+
}
1723+
1724+
function isListed(model: Model) {
1725+
return model.id === "big-pickle" || model.id.endsWith("-free")
1726+
}
1727+
1728+
function freeVariants(model: Model) {
1729+
return Object.keys(model.variants ?? {})
1730+
.toSorted()
1731+
.filter((item) => item !== "default")
1732+
}
1733+
1734+
const { runPromise } = makeRuntime(Service, defaultLayer)
1735+
1736+
export async function resolveSelection(model?: string, variant?: string) {
1737+
if (!model) return { model, variant }
1738+
if (model !== FREE) return { model, variant }
1739+
const providers = await runPromise((svc) => svc.list())
1740+
const provider = providers[ProviderID.opencode]
1741+
const models = sort(Object.values(provider?.models ?? {}).filter((item) => isFree(item) && isListed(item)))
1742+
const pick = models[Math.floor(Math.random() * models.length)]
1743+
if (!pick) throw new Error("No free opencode models found")
1744+
const next = variant === "any" ? freeVariants(pick) : []
1745+
const value = variant !== "any" ? variant : next[Math.floor(Math.random() * next.length)]
1746+
return {
1747+
model: `${pick.providerID}/${pick.id}`,
1748+
variant: value,
1749+
}
1750+
}
1751+
1752+
export async function resolveModel(model: string) {
1753+
return (await resolveSelection(model)).model!
1754+
}
1755+
17081756
export function parseModel(model: string) {
17091757
const [providerID, ...rest] = model.split("/")
17101758
return {
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"
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
@@ -9,10 +9,13 @@ import * as Timeout from "../../../src/util/timeout"
99
import * as Network from "../../../src/cli/network"
1010
import * as Win32 from "../../../src/cli/cmd/tui/win32"
1111
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
12+
import { Provider } from "../../../src/provider"
1213

1314
const stop = new Error("stop")
1415
const seen = {
1516
tui: [] as string[],
17+
model: [] as (string | undefined)[],
18+
variant: [] as (string | undefined)[],
1619
}
1720

1821
function setup() {
@@ -23,6 +26,8 @@ function setup() {
2326
// https://github.com/oven-sh/bun/issues/7823 and #12823.
2427
spyOn(App, "tui").mockImplementation(async (input) => {
2528
if (input.directory) seen.tui.push(input.directory)
29+
seen.model.push(input.args.model)
30+
seen.variant.push(input.args.variant)
2631
throw stop
2732
})
2833
spyOn(Rpc, "client").mockImplementation(() => ({
@@ -40,21 +45,26 @@ function setup() {
4045
})
4146
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
4247
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
48+
spyOn(Provider, "resolveSelection").mockImplementation(async (model, variant) => ({
49+
model: model === "free" ? "opencode/freebie" : model,
50+
variant: variant === "any" ? "high" : variant,
51+
}))
4352
}
4453

4554
describe("tui thread", () => {
4655
afterEach(() => {
4756
mock.restore()
4857
})
4958

50-
async function call(project?: string) {
59+
async function call(project?: string, model?: string, variant?: string) {
5160
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
5261
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
5362
_: [],
5463
$0: "opencode",
5564
project,
5665
prompt: "hi",
57-
model: undefined,
66+
model,
67+
variant,
5868
agent: undefined,
5969
session: undefined,
6070
continue: false,
@@ -69,7 +79,12 @@ describe("tui thread", () => {
6979
return TuiThreadCommand.handler(args)
7080
}
7181

72-
async function check(project?: string) {
82+
async function check(
83+
project?: string,
84+
model?: string,
85+
variant?: string,
86+
expected?: { model?: string; variant?: string },
87+
) {
7388
setup()
7489
await using tmp = await tmpdir({ git: true })
7590
const cwd = process.cwd()
@@ -79,6 +94,8 @@ describe("tui thread", () => {
7994
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
8095
const type = process.platform === "win32" ? "junction" : "dir"
8196
seen.tui.length = 0
97+
seen.model.length = 0
98+
seen.variant.length = 0
8299
await fs.symlink(tmp.path, link, type)
83100

84101
Object.defineProperty(process.stdin, "isTTY", {
@@ -96,8 +113,10 @@ describe("tui thread", () => {
96113
try {
97114
process.chdir(tmp.path)
98115
process.env.PWD = link
99-
await expect(call(project)).rejects.toBe(stop)
116+
await expect(call(project, model, variant)).rejects.toBe(stop)
100117
expect(seen.tui[0]).toBe(tmp.path)
118+
if (expected?.model) expect(seen.model[0]).toBe(expected.model)
119+
if (expected?.variant) expect(seen.variant[0]).toBe(expected.variant)
101120
} finally {
102121
process.chdir(cwd)
103122
if (pwd === undefined) delete process.env.PWD
@@ -116,4 +135,12 @@ describe("tui thread", () => {
116135
test("uses the real cwd after resolving a relative project from PWD", async () => {
117136
await check(".")
118137
})
138+
139+
test("resolves the free model alias before launching the tui", async () => {
140+
await check(undefined, "free", undefined, { model: "opencode/freebie" })
141+
})
142+
143+
test("passes the resolved any variant before launching the tui", async () => {
144+
await check(undefined, "free", "any", { model: "opencode/freebie", variant: "high" })
145+
})
119146
})

0 commit comments

Comments
 (0)