Skip to content

Commit 08d15b0

Browse files
committed
feat(provider): add --model free to pick a random zero-cost opencode model
Resolves --model free to a randomly-chosen zero-cost opencode model per invocation. Filter is structural (providerID === opencode and every cost dim === 0), not name-based, so the resolver picks up new catalog members automatically as models.dev rotates the OpenCode Zen tier. Provider.resolveSelection takes an explicit InstanceContext arg because Service.list() needs InstanceRef in the Effect fiber; plain-async CLI callers (run and TUI) have no current fiber. bootstrap() forwards its loaded ctx to its callback so the TUI can thread it through. Resolution runs on both interactive/--mini and non-interactive paths. --attach + --model free fails fast with a clean CLI error since the local catalog isn't loaded in attach mode. Removes the --variant flag and its threading: the free-model catalog rotates fast enough that variant pinning doesn't make sense.
1 parent 4394b32 commit 08d15b0

28 files changed

Lines changed: 425 additions & 40 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env bash
2+
# Parallel stress test for `opencode run --model free`.
3+
# Verifies the structural filter (provider==opencode, all costs==0) reaches
4+
# the full live catalog of zero-cost models.
5+
#
6+
# Usage:
7+
# script/stress-free-models.sh [DURATION_SECONDS] [WORKERS]
8+
#
9+
# Defaults: 300s, 3 workers. Each worker loops, calling `run --model free "x"`,
10+
# captures the resolved model from the build line, appends to a shared log.
11+
# At the end, prints distribution + unique-model count.
12+
#
13+
# Requires: gtimeout (brew install coreutils).
14+
15+
set -euo pipefail
16+
17+
DURATION="${1:-300}"
18+
WORKERS="${2:-3}"
19+
20+
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
21+
BIN="$REPO_ROOT/packages/opencode/dist/opencode-darwin-arm64/bin/opencode"
22+
23+
if [ ! -x "$BIN" ]; then
24+
echo "ERROR: binary not found at $BIN" >&2
25+
echo " run \`bun run build\` from $REPO_ROOT/packages/opencode first" >&2
26+
exit 1
27+
fi
28+
if ! command -v gtimeout >/dev/null 2>&1; then
29+
echo "ERROR: gtimeout not found. Install via: brew install coreutils" >&2
30+
exit 1
31+
fi
32+
33+
WORKDIR="$(mktemp -d -t oc-stress-XXXXXX)"
34+
LOG="$WORKDIR/stress.log"
35+
: > "$LOG"
36+
END=$(($(date +%s) + DURATION))
37+
38+
export END BIN LOG WORKDIR
39+
40+
worker() {
41+
local id=$1
42+
local dir="$WORKDIR/w$id"
43+
mkdir -p "$dir"
44+
cd "$dir"
45+
local n=0
46+
while [ "$(date +%s)" -lt "$END" ]; do
47+
raw=$(gtimeout 12 "$BIN" run --model free "x" 2>&1 | sed $'s,\x1b\\[[0-9;]*[a-zA-Z],,g')
48+
pick=$(echo "$raw" | grep -oE '> build · [a-z0-9.-]+' | sed 's/> build · //' | head -1)
49+
if [ -n "$pick" ]; then
50+
echo "$(date +%s) w$id #$n $pick" >> "$LOG"
51+
else
52+
echo "$(date +%s) w$id #$n MISS" >> "$LOG"
53+
fi
54+
n=$((n + 1))
55+
done
56+
}
57+
58+
export -f worker
59+
60+
echo "stress test: ${DURATION}s × ${WORKERS} workers → $LOG"
61+
for i in $(seq 1 "$WORKERS"); do
62+
worker "$i" &
63+
done
64+
wait
65+
66+
echo
67+
echo "=== completed ==="
68+
echo "total samples: $(wc -l < "$LOG" | tr -d ' ')"
69+
echo "--- distribution ---"
70+
awk '{print $NF}' "$LOG" | sort | uniq -c | sort -rn
71+
echo "--- unique non-MISS models ---"
72+
awk '{print $NF}' "$LOG" | grep -v MISS | sort -u
73+
echo "--- unique count ---"
74+
awk '{print $NF}' "$LOG" | grep -v MISS | sort -u | wc -l | tr -d ' '
75+
echo "--- per-worker counts ---"
76+
awk '{print $2}' "$LOG" | sort | uniq -c
77+
echo
78+
echo "raw log: $LOG"

packages/opencode/src/cli/bootstrap.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { InstanceRuntime } from "../project/instance-runtime"
2-
import { context } from "../project/instance-context"
2+
import { context, type InstanceContext } from "../project/instance-context"
33

4-
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
4+
export async function bootstrap<T>(directory: string, cb: (ctx: InstanceContext) => Promise<T>) {
55
const ctx = await InstanceRuntime.load({ directory })
66
try {
7-
return await context.provide(ctx, cb)
7+
return await context.provide(ctx, () => cb(ctx))
88
} finally {
99
await InstanceRuntime.disposeInstance(ctx)
1010
}

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

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { EOL } from "os"
2424
import { Filesystem } from "@/util/filesystem"
2525
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
2626
import { FormatError, FormatUnknownError } from "../error"
27+
import { Provider } from "@/provider/provider"
2728
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
2829

2930
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
@@ -209,10 +210,6 @@ export const RunCommand = effectCmd({
209210
type: "number",
210211
describe: "port for the local server (defaults to random port if no value provided)",
211212
})
212-
.option("variant", {
213-
type: "string",
214-
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
215-
})
216213
.option("thinking", {
217214
type: "boolean",
218215
describe: "show thinking blocks",
@@ -277,6 +274,19 @@ export const RunCommand = effectCmd({
277274
UI.error(message)
278275
process.exit(1)
279276
}
277+
if (args.attach && args.model === "free") {
278+
die(
279+
"--model free is not supported with --attach; specify a concrete model id (e.g., --model opencode/mimo-v2.5-free)",
280+
)
281+
}
282+
const resolved = await (async () => {
283+
if (args.attach) return { model: args.model }
284+
try {
285+
return await Provider.resolveSelection(args.model, localInstance)
286+
} catch (error) {
287+
return die(error instanceof Error ? error.message : String(error))
288+
}
289+
})()
280290
const dieInteractive = (error: unknown): never => {
281291
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) {
282292
die(error.message)
@@ -836,15 +846,13 @@ export const RunCommand = effectCmd({
836846
const error = await completed
837847
if (error) process.exitCode = 1
838848
}
839-
840849
if (args.command) {
841850
const result = await client.session.command({
842851
sessionID,
843852
agent,
844-
model: args.model,
853+
model: resolved.model,
845854
command: args.command,
846855
arguments: message,
847-
variant: args.variant,
848856
})
849857
if (result.error) {
850858
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
@@ -855,12 +863,11 @@ export const RunCommand = effectCmd({
855863
return
856864
}
857865

858-
const model = pick(args.model)
866+
const model = resolved.model ? Provider.parseModel(resolved.model) : undefined
859867
const result = await client.session.prompt({
860868
sessionID,
861869
agent,
862870
model,
863-
variant: args.variant,
864871
parts: [...files, { type: "text", text: message }],
865872
})
866873
if (result.error) {
@@ -872,7 +879,7 @@ export const RunCommand = effectCmd({
872879
return
873880
}
874881

875-
const model = pick(args.model)
882+
const model = pick(resolved.model)
876883
const { runInteractiveMode } = await import("./run/runtime")
877884
try {
878885
await runInteractiveMode({
@@ -885,7 +892,7 @@ export const RunCommand = effectCmd({
885892
replayLimit: args["replay-limit"],
886893
agent,
887894
model,
888-
variant: args.variant,
895+
variant: undefined,
889896
files,
890897
initialInput,
891898
createSession: createFreshSession,
@@ -900,7 +907,7 @@ export const RunCommand = effectCmd({
900907
}
901908

902909
if (interactive && !args.attach && !args.session && !args.continue) {
903-
const model = pick(args.model)
910+
const model = pick(resolved.model)
904911
const { runInteractiveLocalMode } = await import("./run/runtime")
905912
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
906913
const { Server } = await import("@/server/server")
@@ -921,7 +928,7 @@ export const RunCommand = effectCmd({
921928
createSession: createFreshSession,
922929
agent: args.agent,
923930
model,
924-
variant: args.variant,
931+
variant: undefined,
925932
replay,
926933
replayLimit: args["replay-limit"],
927934
files,
@@ -995,7 +1002,6 @@ export async function runMini(input: MiniCommandInput) {
9951002
username: input.username,
9961003
dir: input.directory,
9971004
port: undefined,
998-
variant: undefined,
9991005
thinking: undefined,
10001006
mini: true,
10011007
interactive: false,

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { writeHeapSnapshot } from "v8"
1414
import { ServerAuth } from "@/server/auth"
1515
import { validateSession } from "../tui/validate-session"
1616
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
17+
import { Provider } from "@/provider/provider"
18+
import { bootstrap } from "@/cli/bootstrap"
1719

1820
declare global {
1921
const OPENCODE_WORKER_PATH: string
@@ -206,6 +208,20 @@ export const TuiThreadCommand = cmd({
206208
return
207209
}
208210
const cwd = Filesystem.resolve(process.cwd())
211+
// Resolving `--model free` requires Provider.Service.list, which needs the
212+
// Instance ALS context the effectCmd wrapper would normally provide. The
213+
// TUI handler runs outside it, so bootstrap it here only for `free`; any
214+
// other model is passed through directly to avoid the load/dispose cost.
215+
let model = args.model
216+
if (args.model === "free") {
217+
try {
218+
model = (await bootstrap(cwd, (ctx) => Provider.resolveSelection(args.model, ctx))).model
219+
} catch (error) {
220+
UI.error(error instanceof Error ? error.message : String(error))
221+
process.exitCode = 1
222+
return
223+
}
224+
}
209225

210226
const worker = new Worker(file, {
211227
env: Object.fromEntries(
@@ -288,7 +304,7 @@ export const TuiThreadCommand = cmd({
288304
continue: args.continue,
289305
sessionID: args.session,
290306
agent: args.agent,
291-
model: args.model,
307+
model,
292308
prompt,
293309
fork: args.fork,
294310
auto: args.auto || args.yolo || args["dangerously-skip-permissions"],

packages/opencode/src/provider/provider.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2+
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
23
import os from "os"
34
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
45
import fuzzysort from "fuzzysort"
@@ -23,6 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
2324
import { InstanceState } from "@/effect/instance-state"
2425
import { EffectPromise } from "@/effect/promise"
2526
import { FSUtil } from "@opencode-ai/core/fs-util"
27+
import { makeRuntime } from "@/effect/run-service"
2628
import { isRecord } from "@/util/record"
2729
import { optional } from "@opencode-ai/core/schema"
2830
import { ProviderTransform } from "./transform"
@@ -31,6 +33,8 @@ import { ModelV2 } from "@opencode-ai/core/model"
3133
import { ModelStatus } from "./model-status"
3234
import { RuntimeFlags } from "@/effect/runtime-flags"
3335
import { ProviderError } from "./error"
36+
import { InstanceRef } from "@/effect/instance-ref"
37+
import type { InstanceContext } from "@/project/instance-context"
3438

3539
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
3640

@@ -1987,6 +1991,48 @@ export function sort<T extends { id: string }>(models: T[]) {
19871991
)
19881992
}
19891993

1994+
const FREE = "free"
1995+
1996+
export function isFree(model: Model) {
1997+
// Structural filter only: any opencode model whose every cost dimension is
1998+
// zero is a candidate. models.dev does not expose a "Zen tier" flag, and the
1999+
// OpenCode Zen endpoint (api.url = opencode.ai/zen/v1) is shared by both the
2000+
// -free suffix models and promotional zero-cost models (e.g. "big-pickle").
2001+
const extra = model.cost.experimentalOver200K
2002+
return (
2003+
model.providerID === ProviderV2.ID.opencode &&
2004+
model.cost.input === 0 &&
2005+
model.cost.output === 0 &&
2006+
model.cost.cache.read === 0 &&
2007+
model.cost.cache.write === 0 &&
2008+
(!extra || (extra.input === 0 && extra.output === 0 && extra.cache.read === 0 && extra.cache.write === 0))
2009+
)
2010+
}
2011+
2012+
export async function resolveSelection(model?: string, instance?: InstanceContext) {
2013+
if (!model) return { model }
2014+
if (model !== FREE) return { model }
2015+
// Service.list() requires InstanceRef in the Effect fiber. Callers from plain
2016+
// async code (run.ts handler post-await, thread.ts bootstrap callback) have no
2017+
// current fiber, so we provide it explicitly when given. Tests run inside an
2018+
// Effect fiber that already has InstanceRef set up, so the arg is optional.
2019+
const providers = await runPromise((svc) =>
2020+
instance ? svc.list().pipe(Effect.provideService(InstanceRef, instance)) : svc.list(),
2021+
)
2022+
const provider = providers[ProviderV2.ID.opencode]
2023+
const models = sort(Object.values(provider?.models ?? {}).filter(isFree))
2024+
// Unseeded by design: the same `--model free` in two terminals picks
2025+
// different models.
2026+
const pick = models[Math.floor(Math.random() * models.length)]
2027+
if (!pick)
2028+
throw new Error(
2029+
`No free opencode models found. The opencode provider must be configured (set OPENCODE_API_KEY) and at least one model in its catalog must have all costs set to 0.`,
2030+
)
2031+
return {
2032+
model: `${pick.providerID}/${pick.id}`,
2033+
}
2034+
}
2035+
19902036
export function parseModel(model: string) {
19912037
const [providerID, ...rest] = model.split("/")
19922038
return {
@@ -2001,4 +2047,6 @@ export const node = LayerNode.make({
20012047
deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node],
20022048
})
20032049

2050+
const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node))
2051+
20042052
export * as Provider from "./provider"

0 commit comments

Comments
 (0)