Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,22 +627,26 @@ export const RunCommand = cmd({
process.exit(1)
})

const resolved = await Provider.resolveSelection(args.model, args.variant)
const model = resolved.model
const variant = resolved.variant

if (args.command) {
await sdk.session.command({
sessionID,
agent,
model: args.model,
model,
command: args.command,
arguments: message,
variant: args.variant,
variant,
})
} else {
const model = args.model ? Provider.parseModel(args.model) : undefined
const modelObj = model ? Provider.parseModel(model) : undefined
await sdk.session.prompt({
sessionID,
agent,
model,
variant: args.variant,
model: modelObj,
variant,
parts: [...files, { type: "text", text: message }],
})
}
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
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",
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/context/args.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createSimpleContext } from "./helper"

export interface Args {
model?: string
variant?: string
agent?: string
prompt?: string
continue?: boolean
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "./validate-session"
import { Provider } from "@/provider/provider"

declare global {
const OPENCODE_WORKER_PATH: string
Expand Down Expand Up @@ -112,6 +113,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.
Expand Down Expand Up @@ -139,6 +144,9 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const pick = await 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(),
Expand Down Expand Up @@ -244,7 +252,8 @@ export const TuiThreadCommand = cmd({
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
model,
variant,
prompt,
fork: args.fork,
},
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1735,6 +1736,53 @@ export function sort<T extends { id: string }>(models: T[]) {
)
}

const FREE = "free"
export const ANY = "any"

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))
)
}

function isListed(model: Model) {
return model.id === "big-pickle" || 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)))
const pick = models[Math.floor(Math.random() * models.length)]
if (!pick) throw new Error("No free opencode models found")
const next = variant === "any" ? freeVariants(pick) : []
const value = variant !== "any" ? variant : next[Math.floor(Math.random() * next.length)]
return {
model: `${pick.providerID}/${pick.id}`,
variant: value,
}
}

export async function resolveModel(model: string) {
return (await resolveSelection(model)).model!
}

export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
Expand Down
114 changes: 114 additions & 0 deletions packages/opencode/test/cli/cmd/run.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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")
})
})
Loading
Loading