Skip to content

Commit ae63741

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 51e310c commit ae63741

8 files changed

Lines changed: 546 additions & 12 deletions

File tree

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

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

630+
const resolved = await Provider.resolveSelection(args.model, args.variant)
631+
const model = resolved.model
632+
const variant = resolved.variant
633+
630634
if (args.command) {
631635
await sdk.session.command({
632636
sessionID,
633637
agent,
634-
model: args.model,
638+
model,
635639
command: args.command,
636640
arguments: message,
637-
variant: args.variant,
641+
variant,
638642
})
639643
} else {
640-
const model = args.model ? Provider.parseModel(args.model) : undefined
644+
const modelObj = model ? Provider.parseModel(model) : undefined
641645
await sdk.session.prompt({
642646
sessionID,
643647
agent,
644-
model,
645-
variant: args.variant,
648+
model: modelObj,
649+
variant,
646650
parts: [...files, { type: "text", text: message }],
647651
})
648652
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
345345
})
346346
local.model.set({ providerID, modelID }, { recent: true })
347347
}
348+
if (args.variant) local.model.variant.set(args.variant)
348349
if (args.sessionID && !args.fork) {
349350
route.navigate({
350351
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
@@ -22,6 +22,7 @@ import {
2222
sanitizedProcessEnv,
2323
} from "@opencode-ai/core/util/opencode-process"
2424
import { validateSession } from "./validate-session"
25+
import { Provider } from "@/provider/provider"
2526

2627
declare global {
2728
const OPENCODE_WORKER_PATH: string
@@ -112,6 +113,10 @@ export const TuiThreadCommand = cmd({
112113
.option("agent", {
113114
type: "string",
114115
describe: "agent to use",
116+
})
117+
.option("variant", {
118+
type: "string",
119+
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
115120
}),
116121
handler: async (args) => {
117122
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
@@ -139,6 +144,9 @@ export const TuiThreadCommand = cmd({
139144
return
140145
}
141146
const cwd = Filesystem.resolve(process.cwd())
147+
const pick = await Provider.resolveSelection(args.model, args.variant)
148+
const model = pick.model
149+
const variant = pick.variant
142150
const env = sanitizedProcessEnv({
143151
[OPENCODE_PROCESS_ROLE]: "worker",
144152
[OPENCODE_RUN_ID]: ensureRunID(),
@@ -244,7 +252,8 @@ export const TuiThreadCommand = cmd({
244252
continue: args.continue,
245253
sessionID: args.session,
246254
agent: args.agent,
247-
model: args.model,
255+
model,
256+
variant,
248257
prompt,
249258
fork: args.fork,
250259
},

packages/opencode/src/provider/provider.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { pathToFileURL } from "url"
2222
import { Effect, Layer, Context, Schema, Types } from "effect"
2323
import { EffectBridge } from "@/effect/bridge"
2424
import { InstanceState } from "@/effect/instance-state"
25+
import { makeRuntime } from "@/effect/run-service"
2526
import { AppFileSystem } from "@opencode-ai/core/filesystem"
2627
import { isRecord } from "@/util/record"
2728
import { optionalOmitUndefined, withStatics } from "@/util/schema"
@@ -1735,6 +1736,53 @@ export function sort<T extends { id: string }>(models: T[]) {
17351736
)
17361737
}
17371738

1739+
const FREE = "free"
1740+
export const ANY = "any"
1741+
1742+
function isFree(model: Model) {
1743+
const extra = model.cost.experimentalOver200K
1744+
return (
1745+
model.providerID === ProviderID.opencode &&
1746+
model.cost.input === 0 &&
1747+
model.cost.output === 0 &&
1748+
model.cost.cache.read === 0 &&
1749+
model.cost.cache.write === 0 &&
1750+
(!extra || (extra.input === 0 && extra.output === 0 && extra.cache.read === 0 && extra.cache.write === 0))
1751+
)
1752+
}
1753+
1754+
function isListed(model: Model) {
1755+
return model.id === "big-pickle" || model.id.endsWith("-free")
1756+
}
1757+
1758+
function freeVariants(model: Model) {
1759+
return Object.keys(model.variants ?? {})
1760+
.toSorted()
1761+
.filter((item) => item !== "default")
1762+
}
1763+
1764+
const { runPromise } = makeRuntime(Service, defaultLayer)
1765+
1766+
export async function resolveSelection(model?: string, variant?: string) {
1767+
if (!model) return { model, variant }
1768+
if (model !== FREE) return { model, variant }
1769+
const providers = await runPromise((svc) => svc.list())
1770+
const provider = providers[ProviderID.opencode]
1771+
const models = sort(Object.values(provider?.models ?? {}).filter((item) => isFree(item) && isListed(item)))
1772+
const pick = models[Math.floor(Math.random() * models.length)]
1773+
if (!pick) throw new Error("No free opencode models found")
1774+
const next = variant === "any" ? freeVariants(pick) : []
1775+
const value = variant !== "any" ? variant : next[Math.floor(Math.random() * next.length)]
1776+
return {
1777+
model: `${pick.providerID}/${pick.id}`,
1778+
variant: value,
1779+
}
1780+
}
1781+
1782+
export async function resolveModel(model: string) {
1783+
return (await resolveSelection(model)).model!
1784+
}
1785+
17381786
export function parseModel(model: string) {
17391787
const [providerID, ...rest] = model.split("/")
17401788
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/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+
})

0 commit comments

Comments
 (0)