Compare commits

...
Author SHA1 Message Date
Jack 7b99646cf1 docs(go): remove retired Go models (#55761) 2026-10-16 16:38:49 +08:00
Jack 37e3912d8e docs(console): add GLM-5.3-Flash Free (#55975) 2026-10-16 14:34:24 +08:00
Aiden Cline f0c122f717 fix(update): cache rollout fallback reads (#55205) 2026-10-16 01:33:46 -05:00
opencode-agent[bot]anddc85 e3d10de997 docs(console): add LL Alpha Free (#55969)
Co-authored-by: dc85 <83497+dc85@users.noreply.github.com>
2026-10-15 23:31:33 -07:00
Aiden Cline 538f8b1da6 fix(core): respell variants when config changes a catalog model's package (#55962) 2026-10-16 01:26:53 -05:00
Aiden Cline f09eee5002 fix(core): treat unreadable config file references as empty (#55959) 2026-10-16 01:12:37 -05:00
Aiden Cline 4cce6aef4e fix(core): prioritize preferred free OpenCode models when signed out (#55972) 2026-10-16 01:09:50 -05:00
Aiden Cline ccc6a3fd59 fix(core): escape env config values that break parsing (#55915) 2026-10-16 00:33:51 -05:00
Aiden Cline 32b41bab13 test(cli): simplify service env test (#55952) 2026-10-16 00:14:35 -05:00
Lucas Kimandkimnamu e3d5a3bdd3 fix(core): hide newer Bedrock bare IDs that require inference profiles (#54486)
Co-authored-by: kimnamu <kimnamu@users.noreply.github.com>
2026-10-16 00:14:09 -05:00
JasonH 723a2f6892 fix(cli): load saved environment when starting managed services (#50907) 2026-10-16 00:05:51 -05:00
Aiden Cline b0a8e2501b fix(ai): honor Anthropic cache format on Bedrock Converse (#55942) 2026-10-15 23:53:14 -05:00
Jensen eeec76bb62 fix(tui): transparent background for embedded terminal panel (#55396) 2026-10-15 22:46:19 -04:00
23 changed files with 431 additions and 139 deletions

No files matched your search

@@ -516,7 +516,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
)
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
const breakpoints = BedrockCache.breakpoints(request.model)
const toolConfig = (() => {
if (flattened.tools.length === 0) return undefined
return {
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import type { CacheHint } from "../../schema/index.js"
import type { CacheHint, LLMRequest } from "../../schema/index.js"
import { newBreakpoints, ttlBucket } from "./cache.js"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
@@ -28,14 +28,17 @@ const CLAUDE_5M = [
// Callers share the four-breakpoint budget across system, messages, and tools.
export const BEDROCK_BREAKPOINT_CAP = 4
export const breakpoints = (modelID: string) => {
export const breakpoints = (model: LLMRequest["model"]) => {
// Substring matching also handles regional prefixes and model-bearing ARNs.
const short = CLAUDE_5M.some((id) => modelID.includes(id))
const short = CLAUDE_5M.some((id) => model.id.includes(id))
return {
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
// An explicit Anthropic cache format opts opaque IDs such as application inference profile ARNs in.
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
// Other model families use implicit caching where available.
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
supported:
model.compatibility?.cacheFormat === "anthropic" ||
(model.id.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => model.id.includes(id)))),
ttl1h: !short,
}
}
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
import { CacheHint, LanguageModel, LLM, Message, ToolCallPart } from "../../src/index.js"
import { AmazonBedrock } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -116,4 +116,53 @@ describe("Bedrock Converse cache policy", () => {
)
}
}
const arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123"
for (const ttlSeconds of [undefined, 3600]) {
it.effect(
`emits checkpoints for an opaque ARN declaring the Anthropic cache format (TTL: ${ttlSeconds ?? "default"})`,
() =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(bedrock.model(arn), { compatibility: { cacheFormat: "anthropic" } }),
system: [
{ type: "text", text: "Agent" },
{ type: "text", text: "Project" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
prompt: "Question",
cache:
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
}),
)
const marker = { cachePoint: ttlSeconds === undefined ? { type: "default" } : { type: "default", ttl: "1h" } }
expect(prepared.body).toMatchObject({
modelId: arn,
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
})
if (ttlSeconds === undefined) expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
}),
)
}
for (const id of [arn, "amazon.nova-lite-v1:0"]) {
it.effect(`ignores the Alibaba cache format for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(bedrock.model(id), { compatibility: { cacheFormat: "alibaba" } }),
system: "System prefix",
prompt: "Question",
}),
)
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
}),
)
}
})
+2
View File
@@ -71,6 +71,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
? Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (findIncumbent !== undefined && (yield* findIncumbent) !== undefined) return
// Desktop can launch the service directly without injecting its saved environment.
Object.assign(process.env, config.env)
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
if (options.mode === "service" && process.platform === "win32" && RetainedImage.installed(global.home))
yield* RetainedImage.retain(global.cache, "service")
+56
View File
@@ -192,6 +192,62 @@ test("service config manages environment variables", async () => {
}
})
test("managed service loads configured environment", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-"))
const registration = path.join(root, "state", "opencode", ServiceConfig.filename())
await fs.mkdir(path.join(root, "config"), { recursive: true })
await fs.writeFile(
path.join(root, "config", ServiceConfig.filename()),
JSON.stringify({ env: { OPENCODE_SERVICE_ENV_TEST: "configured", OPENCODE_SERVICE_ENV_EMPTY_TEST: "" } }),
)
await fs.writeFile(
path.join(root, "config", "opencode.json"),
JSON.stringify({
mcp: {
servers: {
env: {
type: "local",
command: ["true"],
environment: {
configured: "{env:OPENCODE_SERVICE_ENV_TEST}",
empty: "{env:OPENCODE_SERVICE_ENV_EMPTY_TEST}",
inherited: "{env:OPENCODE_SERVICE_ENV_INHERITED_TEST}",
},
},
},
},
}),
)
const owner = Bun.spawn(
[process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service", "--port", "0"],
{
env: isolatedEnv(root, {
OPENCODE_SERVICE_ENV_TEST: "inherited",
OPENCODE_SERVICE_ENV_EMPTY_TEST: "inherited",
OPENCODE_SERVICE_ENV_INHERITED_TEST: "inherited",
}),
stderr: "pipe",
stdout: "ignore",
},
)
try {
const info = await waitForInfo(registration)
const url = new URL("/api/config", info.url)
url.searchParams.set("location[directory]", root)
const response = await fetch(url, { headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) } })
expect(response.status).toBe(200)
expect((await response.json())[0].info.mcp.servers.env.environment).toEqual({
configured: "configured",
empty: "",
inherited: "inherited",
})
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("dev")).toBe("service.json")
@@ -3,7 +3,6 @@ export const goModels = [
{ id: "kimi-k3", name: "Kimi K3", requests: 110, allowance: 15, featured: true },
{ id: "qwen3.8-max", name: "Qwen3.8 Max", requests: 160, allowance: 15 },
{ id: "grok-4.7", name: "Grok 4.7", requests: 169, allowance: 15 },
{ id: "grok-4.6", name: "Grok 4.6", requests: 169, allowance: 15 },
{ id: "glm-5.3", name: "GLM-5.3", requests: 220, allowance: 15 },
{
id: "gemini-4-argon-contributor",
@@ -24,7 +23,6 @@ export const goModels = [
{ id: "minimax-m3", name: "MiniMax M3", requests: 3200, allowance: 60, featured: true },
{ id: "mimo-v2.6-pro", name: "MiMo-V2.6-Pro", requests: 3250, allowance: 15 },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", requests: 3250, allowance: 15 },
{ id: "minimax-m2.7", name: "MiniMax M2.7", requests: 3400, allowance: 60 },
{ id: "claude-haiku-5-5", name: "Claude Haiku 5.5", requests: 3850, allowance: 15, featured: true, fresh: true },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", requests: 4300, allowance: 60, featured: true },
{ id: "gpt-6-luna", name: "GPT 6 Luna", requests: 4230, allowance: 15, featured: true },
@@ -96,7 +94,6 @@ const plusLimits: Record<string, [number, number]> = {
"kimi-k3": [440, 60],
"qwen3.8-max": [640, 60],
"grok-4.7": [676, 60],
"grok-4.6": [676, 60],
"glm-5.3": [1760, 120],
"gemini-4-argon-contributor": [1760, 240],
"glm-5.2": [2640, 180],
@@ -109,7 +106,6 @@ const plusLimits: Record<string, [number, number]> = {
"minimax-m3": [9600, 180],
"mimo-v2.6-pro": [13000, 60],
"mimo-v2.5-pro": [13000, 60],
"minimax-m2.7": [13600, 240],
"qwen3.7-plus": [12900, 180],
"gpt-6-luna": [16920, 60],
"claude-haiku-5-5": [15380, 60],
+1 -3
View File
@@ -36,7 +36,6 @@ const models = [
{ name: "Step 5 Preview", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "Cat Jam Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "Grok 4.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "Grok 4.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "GPT 6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "GPT 5.6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "Claude Haiku 5.5", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
@@ -55,7 +54,6 @@ const models = [
{ name: "Qwen3.8 Flash", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "Qwen3.7 Plus", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "MiniMax M3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "MiniMax M2.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "Muse Spark 1.3 Contributor", training: "go.faq.a5.used", retention: "go.faq.a5.notZdr" },
{ name: "Muse Spark 1.2 Contributor", training: "go.faq.a5.used", retention: "go.faq.a5.notZdr" },
{ name: "DeepSeek V4.1 Flash", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
@@ -304,7 +302,7 @@ export default function Home() {
</div>
<div data-slot="faq-retention-notes">
<p>
<strong>Grok 4.7/4.6:</strong> {i18n.t("go.faq.a5.grokRetention")}{" "}
<strong>Grok 4.7:</strong> {i18n.t("go.faq.a5.grokRetention")}{" "}
<a href="https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr">
{i18n.t("go.faq.a5.learnMore")}
</a>
+7 -1
View File
@@ -158,7 +158,13 @@ export const Plugin = define({
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
if (config.variants === undefined && !source?.base)
// Inherited variants are spelled for the base model's package, so a package override needs fresh ones.
// A base without variants has no reasoning support to respell.
const repackaged =
source?.base !== undefined &&
source.base.variants.length > 0 &&
models.get(providerID, id)?.package !== source.base.package
if (config.variants === undefined && (!source?.base || repackaged))
models.update(providerID, id, (model) => {
model.variants = [
...Variant.resolve({
+23 -22
View File
@@ -3,8 +3,8 @@ export * as ConfigVariable from "./variable.js"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { type ParseError, parse } from "jsonc-parser"
import { FSUtil } from "@opencode/util/fs-util"
import { InvalidError } from "../v1/config/error.js"
type ParseSource =
| {
@@ -24,12 +24,17 @@ type SubstituteInput = ParseSource & {
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
const text = input.text.replace(
/\{env:([^}]+)\}/g,
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
)
if (!text.includes("{file:")) return text
return yield* substituteFiles(input, text)
const env = (escape: boolean) =>
input.text.replace(/\{env:([^}]+)\}/g, (_, name: string) => {
const value = (input.env?.[name] ?? process.env[name]) || ""
return escape ? JSON.stringify(value).slice(1, -1) : value
})
// Escape only when raw values break the JSON (e.g. Windows paths), so configs that parse today keep their meaning.
const raw = yield* substituteFiles(input, env(false))
const errors: ParseError[] = []
if (input.text.includes("{env:")) parse(raw, errors, { allowTrailingComma: true })
if (!errors.length) return raw
return yield* substituteFiles(input, env(true))
})
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
@@ -55,24 +60,20 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
const filePath = token.slice("{file:".length, -1)
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs
.readFileStringSafe(resolvedPath)
.pipe(
Effect.mapError(
(error) =>
new InvalidError({ path: configSource, message: `bad file reference: "${token}"` }, { cause: error }),
),
)
// Like a missing {env:VAR}, a missing or unreadable file substitutes empty so one stale secret
// reference cannot take down every Location that loads this config.
if (fileContent === undefined)
yield* Effect.logWarning("config file reference is missing or unreadable", {
source: configSource,
reference: token,
path: resolvedPath,
})
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((cause) =>
Effect.logWarning("config file reference is missing or unreadable", {
source: configSource,
reference: token,
path: resolvedPath,
cause,
}).pipe(Effect.as("")),
),
)
out += JSON.stringify((fileContent ?? "").trim()).slice(1, -1)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
cursor = index + token.length
}
+13 -9
View File
@@ -222,10 +222,7 @@ const layer = Layer.effect(
const all = Array.from(byProvider.values())
.flatMap((models) => Array.from(models.values()))
.sort((left, right) => right.time.released - left.time.released)
cached = freeze(
{ data, all, available: preferBigPickle(all.filter((model) => model.enabled)), byProvider },
true,
)
cached = freeze({ data, all, available: preferFree(all.filter((model) => model.enabled)), byProvider }, true)
return cached
}
})
@@ -290,14 +287,21 @@ const layer = Layer.effect(
export const node = makeLocationNode({ service: Service, layer, deps: [Provider.node, Bus.node, Location.node] })
// Signed out, only free OpenCode models are enabled. Clients default to the first available model, so keep
// big-pickle ahead of each new free launch instead of letting release order pick it.
function preferBigPickle(models: Info[]) {
// these free models ahead of each new free launch instead of letting release order pick the default.
const PREFERRED_FREE = [
"big-pickle",
"deepseek-v4.1-flash-free",
"glm-5.3-flash-free",
"muse-spark-1.3-contributor-free",
]
function preferFree(models: Info[]) {
const freeOnly = models.every(
(model) => model.providerID === Provider.ID.opencode && !model.cost.some((cost) => cost.input > 0),
)
const pick = freeOnly ? models.find((model) => model.id === "big-pickle") : undefined
if (!pick) return models
return [pick, ...models.filter((model) => model !== pick)]
if (!freeOnly) return models
const picks = PREFERRED_FREE.flatMap((id) => models.find((model) => model.id === id) ?? [])
return [...picks, ...models.filter((model) => !picks.includes(model))]
}
export function supportsText(model: Pick<Info, "capabilities">) {
+7 -1
View File
@@ -7,12 +7,13 @@ import { Model } from "../model.js"
import { ModelsDev } from "../models-dev.js"
// These catalog entries require inference profiles on Bedrock Runtime.
// Opus/Sonnet 4.6 support in-region calls in eu-west-2 and must remain available.
// Opus/Sonnet 4.6 and Opus 5.5 support in-region calls in eu-west-2 and must remain available.
const BEDROCK_PROFILE_ONLY_IDS = [
"amazon.nova-2-lite-v1:0",
"anthropic.claude-fable-5",
"anthropic.claude-fable-5-1",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-haiku-5-5",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-opus-4-7",
@@ -20,8 +21,13 @@ const BEDROCK_PROFILE_ONLY_IDS = [
"anthropic.claude-opus-5",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-sonnet-5",
"anthropic.claude-sonnet-5-5",
"deepseek.r1-v1:0",
"meta.llama4-maverick-17b-instruct-v1:0",
"meta.llama4-scout-17b-instruct-v1:0",
"mistral.pixtral-large-2502-v1:0",
"writer.palmyra-x4-v1:0",
"writer.palmyra-x5-v1:0",
]
export const ModelsDevPlugin = define({
+8 -6
View File
@@ -576,16 +576,18 @@ describe("Provider and Model", () => {
const models = yield* Model.Service
yield* providers.transform((editor) => {
editor.update(Provider.ID.opencode, () => {})
editor.models.update(Provider.ID.opencode, Model.ID.make("big-pickle"), (model) => {
model.time.released = 1000
})
editor.models.update(Provider.ID.opencode, Model.ID.make("new-free"), (model) => {
model.time.released = 2000
})
;["muse-spark-1.3-contributor-free", "new-free", "deepseek-v4.1-flash-free", "big-pickle"].forEach(
(id, index) =>
editor.models.update(Provider.ID.opencode, Model.ID.make(id), (model) => {
model.time.released = 4000 - index * 1000
}),
)
})
expect((yield* models.available()).map((model) => model.id)).toEqual([
Model.ID.make("big-pickle"),
Model.ID.make("deepseek-v4.1-flash-free"),
Model.ID.make("muse-spark-1.3-contributor-free"),
Model.ID.make("new-free"),
])
expect((yield* models.default())?.id).toBe(Model.ID.make("big-pickle"))
+26 -3
View File
@@ -14,6 +14,8 @@ import { Credential } from "@opencode/core/credential"
import { ConfigV1 } from "../fixture/v1-config/config"
import { ConfigPermissionV1 } from "@opencode/core/v1/config/permission"
import { ConfigNormalize } from "@opencode/core/config/normalize"
import { ConfigVariable } from "@opencode/core/config/variable"
import { FSUtil } from "@opencode/util/fs-util"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Bus } from "@opencode/core/bus"
import { Global } from "@opencode/util/global"
@@ -1335,8 +1337,10 @@ describe("Config", () => {
const previous = {
token: process.env.OPENCODE_TEST_MCP_TOKEN,
missing: process.env.OPENCODE_TEST_MISSING,
flag: process.env.OPENCODE_TEST_FLAG,
}
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
process.env.OPENCODE_TEST_FLAG = "false"
process.env.OPENCODE_TEST_MCP_TOKEN = String.raw`C:\Users\new\"secret"`
delete process.env.OPENCODE_TEST_MISSING
return previous
}),
@@ -1353,6 +1357,7 @@ describe("Config", () => {
`{
// Ignored reference: {file:.}
"username": "user-{env:OPENCODE_TEST_MISSING}",
"snapshots": {env:OPENCODE_TEST_FLAG},
"mcp": {
"servers": {
"remote": {
@@ -1361,7 +1366,8 @@ describe("Config", () => {
"headers": {
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
"X-Token": "{file:token.txt}",
"X-Missing": "Bearer {file:~/.opencode-test-missing/key}"
"X-Missing": "Bearer {file:~/.opencode-test-missing/key}",
"X-Folder": "{file:.}"
}
}
}
@@ -1375,13 +1381,15 @@ describe("Config", () => {
const config = yield* Config.Service
const document = (yield* config.entries()).find((entry) => entry.type === "document")
expect(document?.info.username).toBe("user-")
expect(document?.info.snapshots).toBe(false)
const remote = document?.info.mcp?.servers?.remote
expect(remote?.type).toBe("remote")
if (remote?.type !== "remote") return
expect(remote.headers).toEqual({
Authorization: "Bearer secret",
Authorization: String.raw`Bearer C:\Users\new\"secret"`,
"X-Token": 'file\n"token"',
"X-Missing": "Bearer ",
"X-Folder": "",
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
@@ -1393,10 +1401,25 @@ describe("Config", () => {
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
else process.env.OPENCODE_TEST_MISSING = previous.missing
if (previous.flag === undefined) delete process.env.OPENCODE_TEST_FLAG
else process.env.OPENCODE_TEST_FLAG = previous.flag
}),
),
)
it.effect("keeps env values unescaped when the config already parses", () =>
Effect.gen(function* () {
const text = yield* ConfigVariable.substitute({
type: "virtual",
source: "test",
dir: "/",
text: '{ "quoted": "{env:QUOTED}", "fragment": {env:FRAGMENT} }',
env: { QUOTED: String.raw`a\"b`, FRAGMENT: '{"k":"v"}' },
})
expect(JSON.parse(text)).toEqual({ quoted: 'a"b', fragment: { k: "v" } })
}).pipe(Effect.provide(AppNodeBuilder.build(FSUtil.node))),
)
it.live("does not load legacy config.json files", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
+67 -1
View File
@@ -49,7 +49,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
settings: { compaction: { type: "native" } },
models: {
native: {},
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
local: {
settings: { compaction: { type: "summary" } },
package: "@opencode/ai/providers/openai/chat",
},
unsupported: { package: "@opencode/ai/providers/openai/chat" },
},
},
@@ -696,6 +699,69 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("respells inherited variants when config moves a catalog model to another package", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("bedrock")
const modelID = Model.ID.make("anthropic.claude-opus-5-5")
yield* providers.transform((editor) => {
editor.models.update(providerID, modelID, (model) => {
model.package = "@opencode/ai/providers/amazon-bedrock"
model.variants = [
{
id: Model.VariantID.make("high"),
body: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
]
})
})
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
bedrock: { models: { [modelID]: { package: "@opencode/ai/providers/amazon-bedrock/mantle" } } },
},
}),
}),
])
const model = required(yield* models.get(providerID, modelID))
expect(model.package).toBe("@opencode/ai/providers/amazon-bedrock/mantle/messages")
expect(model.variants.find((variant) => variant.id === "high")).toEqual({
id: Model.VariantID.make("high"),
settings: { effort: "high", thinking: { type: "adaptive", display: "summarized" } },
})
}),
)
it.effect("keeps a moved catalog model without reasoning variants free of variants", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("bedrock")
const modelID = Model.ID.make("anthropic.claude-plain")
yield* providers.transform((editor) => {
editor.models.update(providerID, modelID, (model) => {
model.package = "@opencode/ai/providers/amazon-bedrock"
})
})
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
bedrock: { models: { [modelID]: { package: "@opencode/ai/providers/amazon-bedrock/mantle" } } },
},
}),
}),
])
expect(required(yield* models.get(providerID, modelID)).variants).toEqual([])
}),
)
it.effect("keeps configured model variant bodies unchanged", () =>
Effect.gen(function* () {
const models = yield* Model.Service
+5 -33
View File
@@ -5,7 +5,6 @@ import { describe, expect } from "bun:test"
import { Config } from "@opencode/schema/config"
import { Money } from "@opencode/schema/money"
import {
Cause,
DateTime,
Deferred,
Duration,
@@ -79,11 +78,10 @@ const itWithActivity = testEffect(
)
describe("LocationServiceMap", () => {
for (const failure of ["file", "permissions", "config reference"] as const) {
for (const failure of ["file", "permissions"] as const) {
for (const invalidate of [false, true]) {
// The file-path fixture boots on Windows rather than failing during
// discovery. The config-reference case covers repair on every OS.
// Windows does not enforce POSIX directory modes, and root bypasses them.
// discovery. Windows does not enforce POSIX directory modes, and root bypasses them.
const test =
(failure === "file" && process.platform === "win32") ||
(failure === "permissions" && (process.platform === "win32" || process.getuid?.() === 0))
@@ -102,20 +100,8 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.mkdir(directory, { mode: 0o000 }))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
}
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
}
const first = yield* Effect.exit(load)
expect(Exit.isFailure(first)).toBe(true)
if (failure === "config reference" && Exit.isFailure(first)) {
expect(Cause.squash(first.cause)).toMatchObject({
name: "ConfigInvalidError",
data: { message: expect.stringContaining('bad file reference: "{file:username.txt}"') },
})
}
if (!invalidate) expect(yield* locations.contextEffectOption(ref).pipe(Effect.scoped)).toEqual(Option.none())
if (failure === "file") {
@@ -123,10 +109,6 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.mkdir(directory))
}
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
if (failure === "config reference") {
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
}
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
if (invalidate) yield* locations.invalidate(ref)
const repaired = yield* Effect.exit(load)
@@ -137,8 +119,8 @@ describe("LocationServiceMap", () => {
}
}
for (const failure of ["file", "missing", "config reference"] as const) {
// A file-path Location boots on Windows; use the missing config reference there.
for (const failure of ["file", "missing"] as const) {
// A file-path Location boots on Windows; the missing directory covers it there.
const test = failure === "file" && process.platform === "win32" ? it.live.skip : it.live
test(`keeps the repaired graph after concurrent ${failure} failures release`, () =>
Effect.gen(function* () {
@@ -147,12 +129,6 @@ describe("LocationServiceMap", () => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const locations = yield* LocationServiceMap.Service
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
}
const scopes = yield* Effect.forEach(Array.from({ length: 8 }), () =>
Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)),
@@ -164,11 +140,7 @@ describe("LocationServiceMap", () => {
)
expect(failures.every(Exit.isFailure)).toBe(true)
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
if (failure === "config reference") {
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
}
yield* Effect.promise(() => fs.mkdir(directory))
const repaired = yield* locations.contextEffect(ref)
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void))
+1 -5
View File
@@ -293,11 +293,7 @@ describe("Session.move", () => {
replacements: [
...bindings,
...(broken
? [
Config.node.replace(
Config.configured({ project: false, global: false, content: config }),
),
]
? [Config.node.replace(Layer.effect(Config.Service, Effect.die("broken config")))]
: []),
],
}),
@@ -128,6 +128,7 @@ describe("inference stat normalization", () => {
expect(statModel("x-preview-f", "")).toBe("glm-5.3-flash")
expect(statModel("ox-alpha", "")).toBe("glm-5.3-flash")
expect(statModel("ox-alpha-free", "")).toBe("glm-5.3-flash")
expect(statModel("glm-5.3-flash-free", "")).toBe("glm-5.3-flash")
expect(statModel("big-pickle", "zhipuai/ox-alpha-free")).toBe("glm-5.3-flash")
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
expect(toModelAggregate(aggregate("x-preview-f", "unknown"))).toMatchObject([
@@ -295,6 +295,7 @@ export function TerminalPane(props: {
<Show when={!failure()} fallback={<text fg={theme.text.feedback.error.base}>{failure()}</text>}>
<>
<embeddedTerminal
transparentBackground={themes.currentTokens().background.raised.base.a === 0}
ref={(value) => {
terminal = value
props.onFocusRequest?.(() => {
+69 -18
View File
@@ -100,25 +100,33 @@ async function lookup(request: Request, env: Env, url: URL, path: string[]) {
.bind(resolved)
.first<{ duration_hours: number }>(),
)
const duration = (rollout?.duration_hours ?? 0) * 3_600_000
const ip = request.headers.get("CF-Connecting-IP")
const hash =
rollout?.duration_hours && ip
duration && ip
? new DataView(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${resolved}:${ip}`))).getUint32(0)
: undefined
// Missing IPs wait for full rollout. The same IP keeps its position across releases.
const cutoff =
Date.now() - (rollout?.duration_hours ?? 0) * 3_600_000 * (hash === undefined ? 1 : (hash + 1) / 2 ** 32)
const now = Date.now()
const cutoff = now - duration * (hash === undefined ? 1 : (hash + 1) / 2 ** 32)
if (path.length === 4) {
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff, path[3])
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff, duration, now, path[3])
}
if (path.length === 1) return channel(env.DB, resolved, caller, cutoff)
if (path.length === 2) return artifactName(env.DB, resolved, path[1], caller, cutoff)
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff)
if (path.length === 1) return channel(env.DB, resolved, caller, cutoff, duration, now)
if (path.length === 2) return artifactName(env.DB, resolved, path[1], caller, cutoff, duration, now)
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff, duration, now)
}
async function channel(db: D1Database, channel: string, current: string | undefined, cutoff: number) {
async function channel(
db: D1Database,
channel: string,
current: string | undefined,
cutoff: number,
duration: number,
now: number,
) {
const rows = await candidates(db, "channel = ?", [channel])
const artifacts = await selectArtifacts(db, rows, current, cutoff)
const artifacts = await selectArtifacts(db, rows, current, cutoff, duration, now)
if (!artifacts.length) return json({ error: "Channel not found" }, 404)
return updateResponse({ channel, artifacts: artifacts.map(decodeArtifact) })
}
@@ -129,9 +137,11 @@ async function artifactName(
name: string,
current: string | undefined,
cutoff: number,
duration: number,
now: number,
) {
const rows = await candidates(db, "channel = ? AND name = ?", [channel, name])
const artifacts = await selectArtifacts(db, rows, current, cutoff)
const artifacts = await selectArtifacts(db, rows, current, cutoff, duration, now)
if (!artifacts.length) return json({ error: "Artifact not found" }, 404)
return updateResponse({ channel, name, artifacts: artifacts.map(decodeArtifact) })
}
@@ -143,10 +153,12 @@ async function artifactDistribution(
distribution: string,
current: string | undefined,
cutoff: number,
duration: number,
now: number,
manifest?: string,
) {
const rows = await candidates(db, "channel = ? AND name = ? AND distribution = ?", [channel, name, distribution])
const artifact = (await selectArtifacts(db, rows, current, cutoff))[0]
const artifact = (await selectArtifacts(db, rows, current, cutoff, duration, now))[0]
if (!artifact) return json({ error: "Artifact not found" }, 404)
if (manifest) {
const metadata = decodeMetadata(artifact.metadata)
@@ -181,7 +193,14 @@ function candidates(db: D1Database, filter: string, values: string[]) {
)
}
async function selectArtifacts(db: D1Database, rows: ArtifactRow[], current: string | undefined, cutoff: number) {
async function selectArtifacts(
db: D1Database,
rows: ArtifactRow[],
current: string | undefined,
cutoff: number,
duration: number,
now: number,
) {
const caller = current === undefined ? undefined : releaseVersion(current)
const artifacts = await Promise.all(
rows
@@ -193,12 +212,10 @@ async function selectArtifacts(db: D1Database, rows: ArtifactRow[], current: str
const floor = minimum && releaseVersion(minimum.version)
if (current !== undefined && minimum && (!floor || !caller || semver.lt(caller, floor))) return minimum
if (active.time_created <= cutoff) return active
const previous = await db
.prepare(
`${select} WHERE channel = ? AND name = ? AND distribution = ? AND time_created < ? AND time_created <= ? ORDER BY time_created DESC, version DESC LIMIT 1`,
)
.bind(active.channel, active.name, active.distribution, active.time_created, cutoff)
.first<ArtifactRow>()
const start = now >= active.time_created ? active.time_created : Math.floor(now / readTtl) * readTtl
const windowFloor = Math.min(active.time_created - 1, Math.floor(start - duration))
const previous =
(await rolloutCandidates(db, active, windowFloor)).find((row) => row.time_created <= cutoff) ?? null
// A rollout must not send an identified client back below its compatibility floor.
if (current !== undefined && minimum) {
const version = previous && releaseVersion(previous.version)
@@ -210,6 +227,40 @@ async function selectArtifacts(db: D1Database, rows: ArtifactRow[], current: str
return artifacts.filter((artifact) => artifact !== null)
}
function rolloutCandidates(db: D1Database, active: ArtifactRow, floor: number) {
return cachedRead(
`history:${active.channel}/${active.name}/${active.distribution}/${active.time_created}/${floor}`,
() =>
db
.prepare(
`SELECT * FROM (
${select} INDEXED BY artifact_rollout
WHERE channel = ? AND name = ? AND distribution = ? AND time_created < ? AND time_created > ?
ORDER BY time_created DESC, version DESC
)
UNION ALL
SELECT * FROM (
${select} INDEXED BY artifact_rollout
WHERE channel = ? AND name = ? AND distribution = ? AND time_created <= ?
ORDER BY time_created DESC, version DESC LIMIT 1
)`,
)
.bind(
active.channel,
active.name,
active.distribution,
active.time_created,
floor,
active.channel,
active.name,
active.distribution,
floor,
)
.all<ArtifactRow>()
.then((result) => result.results),
)
}
async function configureRollout(request: Request, env: Env, prefix: string) {
const invalid = validMutation(request)
if (invalid) return invalid
+23
View File
@@ -140,3 +140,26 @@ test("selects the minimum and active artifacts from the partial indexes", async
const body = (await response.json()) as { artifacts: { version: string }[] }
expect(body.artifacts.map((artifact) => artifact.version)).toEqual(["3.0.0"])
})
test("caches rollout fallback reads across caller IPs and overlapping releases", async () => {
const db = database()
const base = Date.parse("2026-10-13T00:00:00Z")
db.sqlite.query("INSERT INTO channel_rollout (channel, duration_hours) VALUES ('rollout', 6)").run()
db.insert({ channel: "rollout", version: "1.0.0", time: base })
db.insert({ channel: "rollout", version: "1.1.0", time: base + 5 * 3_600_000 })
db.insert({ channel: "rollout", version: "1.2.0", time: base + 7 * 3_600_000, active: true })
setSystemTime(new Date(base + 7 * 3_600_000 + 1_000))
// Missing IP waits for full 6h rollout -> cutoff is base + 1h + 1s, selects 1.0.0.
expect(await version(await lookup(db.env, "rollout/cli/opencode"))).toBe("1.0.0")
expect(db.queries).toHaveLength(3)
// Different caller IPs evaluate different cutoffs against the cached rollout history without extra D1 reads.
const versions = await Promise.all(
Array.from({ length: 20 }, (_, i) =>
lookup(db.env, "rollout/cli/opencode", { "CF-Connecting-IP": `203.0.113.${i + 1}` }).then(version),
),
)
expect(new Set(versions)).toEqual(new Set(["1.0.0", "1.1.0"]))
expect(db.queries).toHaveLength(3)
})
+53
View File
@@ -556,3 +556,56 @@ headers, and model variants.
Provider `settings.transport: "websocket"` selects its session WebSocket.
See the [providers guide](/providers) for credentials, custom endpoints, provider packages, the WebSocket transport, and model configuration.
## Variables
You can use variable substitution in your config files to reference environment variables and file contents. Place references inside JSON strings.
### Env vars
Use `{env:VARIABLE_NAME}` to substitute environment variables:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "{env:OPENCODE_MODEL}",
"providers": {
"anthropic": {
"settings": {
"apiKey": "{env:ANTHROPIC_API_KEY}",
},
},
},
}
```
If the environment variable is not set, it will be replaced with an empty string.
### Files
Use `{file:path/to/file}` to substitute the contents of a file:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"settings": {
"apiKey": "{file:~/.secrets/openai-key}",
},
},
},
}
```
File paths can be:
- Relative to the config file directory
- Absolute, or starting with `~/` for your home directory
If the file does not exist, it will be replaced with an empty string.
These are useful for:
- Keeping sensitive data like API keys in separate files.
- Sharing common configuration snippets across multiple config files.
+3 -27
View File
@@ -37,7 +37,6 @@ The service is designed primarily for international users and provides stable gl
The current list of models includes:
- **Grok 4.7**
- **Grok 4.6**
- **GLM-5.3-Flash**
- **GLM-5.3**
- **GLM-5.2**
@@ -47,7 +46,6 @@ The current list of models includes:
- **Gemini 4 Argon Contributor**
- **Kimi K3**
- **Kimi K2.7 Code**
- **Kimi K2.6**
- **LongCat-2.0**
- **LongCat 2.5 Preview Free** (limited time)
- **Step 5 Preview**
@@ -57,7 +55,6 @@ The current list of models includes:
- **MiMo-V2.5**
- **MiMo-V2.5-Pro**
- **MiniMax M3**
- **MiniMax M2.7**
- **Muse Spark 1.3 Contributor** ([limited regions](https://ai.developer.meta.com/legal/geographic-use-policy))
- **Muse Spark 1.2 Contributor** ([limited regions](https://ai.developer.meta.com/legal/geographic-use-policy))
- **Qwen3.8 Max**
@@ -135,7 +132,6 @@ Token prices are per 1M tokens.
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$60** |
| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$15** |
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$60** |
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$60** |
| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$60** |
| LongCat 2.5 Preview Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
| Step 5 Preview | $1.00 | $2.70 | $0.10 | - | ~~$15~~ **$60**<br /><small>4x · Limited time</small> |
@@ -145,7 +141,6 @@ Token prices are per 1M tokens.
| MiMo-V2.5 | $0.14 | $0.28 | $0.0028 | - | **$60** |
| MiMo-V2.5-Pro | $0.435 | $0.87 | $0.003625 | - | **$15** |
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$60** |
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** |
| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** |
| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** |
| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$15** |
@@ -161,8 +156,6 @@ Token prices are per 1M tokens.
| Space Bunny | $0.15 | $0.60 | $0.03 | - | **$30** |
| Grok 4.7 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
| Grok 4.7 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
| GPT 6 Luna (≤ 272K tokens) | $0.10 | $0.50 | $0.01 | $0.125 | **$15** |
| GPT 6 Luna (> 272K tokens) | $0.20 | $0.75 | $0.02 | $0.25 | **$15** |
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$15** |
@@ -181,7 +174,6 @@ Token prices are per 1M tokens.
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$180** |
| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$60** |
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$180** |
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$240** |
| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$240** |
| LongCat 2.5 Preview Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
| Step 5 Preview | $1.00 | $2.70 | $0.10 | - | ~~$60~~ **$240**<br /><small>4x · Limited time</small> |
@@ -191,7 +183,6 @@ Token prices are per 1M tokens.
| MiMo-V2.5 | $0.14 | $0.28 | $0.0028 | - | **$120** |
| MiMo-V2.5-Pro | $0.435 | $0.87 | $0.003625 | - | **$60** |
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$180** |
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$240** |
| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$120** |
| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$120** |
| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$60** |
@@ -207,8 +198,6 @@ Token prices are per 1M tokens.
| Space Bunny | $0.15 | $0.60 | $0.03 | - | **$120** |
| Grok 4.7 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$60** |
| Grok 4.7 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$60** |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$60** |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$60** |
| GPT 6 Luna (≤ 272K tokens) | $0.10 | $0.50 | $0.01 | $0.125 | **$60** |
| GPT 6 Luna (> 272K tokens) | $0.20 | $0.75 | $0.02 | $0.25 | **$60** |
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$60** |
@@ -241,7 +230,6 @@ Plus estimates scale with each model's higher usage limit.
| GLM-5.2 | 880 | 2,150 | 4,300 |
| Kimi K3 | 110 | 250 | 490 |
| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 |
| Kimi K2.6 | 1,150 | 2,880 | 5,750 |
| LongCat-2.0 | 11,400 | 28,600 | 57,200 |
| LongCat 2.5 Preview Free | Unlimited | Unlimited | Unlimited |
| Step 5 Preview<br /><small>4x · Limited time</small> | ~~430~~<br />**1,720** | ~~1,070~~<br />**4,280** | ~~2,130~~<br />**8,520** |
@@ -251,7 +239,6 @@ Plus estimates scale with each model's higher usage limit.
| MiMo-V2.5 | 30,100 | 75,200 | 150,400 |
| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 |
| MiniMax M3 | 3,200 | 8,000 | 16,000 |
| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |
| Muse Spark 1.3 Contributor | 45,300 | 113,300 | 226,600 |
| Muse Spark 1.2 Contributor | 45,300 | 113,300 | 226,600 |
| Qwen3.8 Max | 160 | 400 | 810 |
@@ -263,7 +250,6 @@ Plus estimates scale with each model's higher usage limit.
| Hy3 | 4,300 | 10,750 | 21,500 |
| Space Bunny | 3,130 | 7,810 | 15,630 |
| Grok 4.7 | 169 | 423 | 845 |
| Grok 4.6 | 169 | 423 | 845 |
| GPT 6 Luna | 4,230 | 10,560 | 21,130 |
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
| Claude Haiku 5.5 | 3,850 | 9,620 | 19,230 |
@@ -279,7 +265,6 @@ Plus estimates scale with each model's higher usage limit.
| GLM-5.2 | 2,640 | 6,450 | 12,900 |
| Kimi K3 | 440 | 1,000 | 1,960 |
| Kimi K2.7 Code | 4,050 | 10,140 | 20,250 |
| Kimi K2.6 | 4,600 | 11,520 | 23,000 |
| LongCat-2.0 | 45,600 | 114,400 | 228,800 |
| LongCat 2.5 Preview Free | Unlimited | Unlimited | Unlimited |
| Step 5 Preview<br /><small>4x · Limited time</small> | ~~1,720~~<br />**6,880** | ~~4,280~~<br />**17,120** | ~~8,520~~<br />**34,080** |
@@ -289,7 +274,6 @@ Plus estimates scale with each model's higher usage limit.
| MiMo-V2.5 | 60,200 | 150,400 | 300,800 |
| MiMo-V2.5-Pro | 13,000 | 32,600 | 65,200 |
| MiniMax M3 | 9,600 | 24,000 | 48,000 |
| MiniMax M2.7 | 13,600 | 34,000 | 68,000 |
| Muse Spark 1.3 Contributor | 90,600 | 226,600 | 453,200 |
| Muse Spark 1.2 Contributor | 90,600 | 226,600 | 453,200 |
| Qwen3.8 Max | 640 | 1,600 | 3,240 |
@@ -301,7 +285,6 @@ Plus estimates scale with each model's higher usage limit.
| Hy3 | 17,200 | 43,000 | 86,000 |
| Space Bunny | 12,500 | 31,250 | 62,500 |
| Grok 4.7 | 676 | 1,692 | 3,380 |
| Grok 4.6 | 676 | 1,692 | 3,380 |
| GPT 6 Luna | 16,920 | 42,240 | 84,520 |
| GPT 5.6 Luna | 8,200 | 20,400 | 41,000 |
| Claude Haiku 5.5 | 15,380 | 38,460 | 76,920 |
@@ -312,20 +295,19 @@ Plus estimates scale with each model's higher usage limit.
The estimates use the following token counts per request; actual usage varies.
- Grok 4.7/4.6 — 390 input, 32,500 cached, 120 output tokens per request
- Grok 4.7 — 390 input, 32,500 cached, 120 output tokens per request
- GLM-5.3-Flash — 1,000 input, 55,000 cached, 200 output tokens per request
- GLM-5.3/5.2 — 700 input, 52,000 cached, 150 output tokens per request
- GPT 6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
- GPT 5.6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
- Claude Haiku 5.5 — 1,100 input, 55,000 cached, 240 output tokens per request
- Kimi K3 — 1,050 input, 76,500 cached, 300 output tokens per request
- Kimi K2.7/K2.6 — 870 input, 55,000 cached, 200 output tokens per request
- Kimi K2.7 Code — 870 input, 55,000 cached, 200 output tokens per request
- LongCat-2.0 — 920 input, 88,900 cached, 200 output tokens per request
- Step 5 Preview — 1,000 input, 55,000 cached, 200 output tokens per request
- DeepSeek V4.1 Flash — 410 input, 71,300 cached, 310 output tokens per request
- DeepSeek V4 Pro — 750 input, 82,000 cached, 290 output tokens per request
- MiniMax M3 — 510 input, 56,000 cached, 190 output tokens per request
- MiniMax M2.7 — 300 input, 55,000 cached, 125 output tokens per request
- Muse Spark 1.3 Contributor — 620 input, 71,400 cached, 300 output tokens per request
- Muse Spark 1.2 Contributor — 620 input, 71,400 cached, 300 output tokens per request
- MiMo-V2.6-Flash — 830 input, 71,500 cached, 295 output tokens per request
@@ -372,7 +354,6 @@ You can also access Go models through the following API endpoints.
| Model | Model ID | Endpoint | AI SDK Package |
| -------------------------- | -------------------------- | ---------------------------------------------------------------------------------- | --------------------------- |
| Grok 4.7 | grok-4.7 | `https://opencode.ai/inference/go/openai/v1/responses` | `@ai-sdk/openai` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/inference/go/openai/v1/responses` | `@ai-sdk/openai` |
| GPT 6 Luna | gpt-6-luna | `https://opencode.ai/inference/go/openai/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/inference/go/openai/v1/responses` | `@ai-sdk/openai` |
| Claude Haiku 5.5 | claude-haiku-5-5 | `https://opencode.ai/inference/go/anthropic/v1/messages` | `@ai-sdk/anthropic` |
@@ -382,7 +363,6 @@ You can also access Go models through the following API endpoints.
| GLM-5.2 | glm-5.2 | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K3 | kimi-k3 | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| LongCat-2.0 | longcat-2.0 | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| DeepSeek V4.1 Flash | deepseek-v4.1-flash | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -391,7 +371,6 @@ You can also access Go models through the following API endpoints.
| MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/inference/go/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiniMax M3 | minimax-m3 | `https://opencode.ai/inference/go/anthropic/v1/messages` | `@ai-sdk/anthropic` |
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/inference/go/anthropic/v1/messages` | `@ai-sdk/anthropic` |
| Muse Spark 1.3 Contributor | muse-spark-1.3-contributor | `https://opencode.ai/inference/go/openai/v1/responses` | `@ai-sdk/openai` |
| Muse Spark 1.2 Contributor | muse-spark-1.2-contributor | `https://opencode.ai/inference/go/openai/v1/responses` | `@ai-sdk/openai` |
| Qwen3.8 Max | qwen3.8-max | `https://opencode.ai/inference/go/anthropic/v1/messages` | `@ai-sdk/anthropic` |
@@ -429,7 +408,6 @@ curl https://opencode.ai/inference/go/v1/models
| Model | Model training | Data retention |
| ---------------------------- | -------------- | -------------- |
| Grok 4.7 | Not used | 30 days |
| Grok 4.6 | Not used | 30 days |
| GPT 6 Luna | Not used | 30 days |
| GPT 5.6 Luna | Not used | 30 days |
| Claude Haiku 5.5 | Not used | 30 days |
@@ -439,7 +417,6 @@ curl https://opencode.ai/inference/go/v1/models
| GLM-5.2 | Not used | 0 days |
| Kimi K3 | Not used | 0 days |
| Kimi K2.7 Code | Not used | 0 days |
| Kimi K2.6 | Not used | 0 days |
| LongCat-2.0 | Not used | 0 days |
| MiMo-V2.6-Pro | Not used | 0 days |
| MiMo-V2.6-Flash | Not used | 0 days |
@@ -449,7 +426,6 @@ curl https://opencode.ai/inference/go/v1/models
| Qwen3.8 Flash | Not used | 0 days |
| Qwen3.7 Plus | Not used | 0 days |
| MiniMax M3 | Not used | 0 days |
| MiniMax M2.7 | Not used | 0 days |
| Muse Spark 1.3 Contributor | Yes | Not ZDR |
| Muse Spark 1.2 Contributor | Yes | Not ZDR |
| DeepSeek V4.1 Flash | Not used | 0 days\* |
@@ -461,7 +437,7 @@ curl https://opencode.ai/inference/go/v1/models
| Step 5 Preview | Not used | 0 days |
| Cat Jam Free | Not used | 30 days |
- **Grok 4.7/4.6:** ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API. [Learn more](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr).
- **Grok 4.7:** ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API. [Learn more](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr).
- **GPT 6 Luna / GPT 5.6 Luna:** Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days. [Learn more](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring).
- **Claude Haiku 5.5:** Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
- **Gemini 4 Argon Contributor:** Your prompts and completions may be used to train future Google models.
@@ -110,9 +110,11 @@ You can also access the models directly through the following API endpoints.
| Jev 1.13 | jev-1.13 | `https://opencode.ai/inference/systemone/v1/systemone` | - |
| Jev 1.13 Free | jev-1.13-free | `https://opencode.ai/inference/systemone/v1/systemone` | - |
| Big Pickle | big-pickle | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM-5.3-Flash Free | glm-5.3-flash-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| DeepSeek V4.1 Flash Free | deepseek-v4.1-flash-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Space Bunny Free | space-bunny-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Cat Jam Free | cat-jam-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| LL Alpha Free | ll-alpha-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| LongCat 2.5 Preview Free | longcat-2.5-preview-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Exo Free | exo-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.6-Flash Free | mimo-v2.6-flash-free | `https://opencode.ai/inference/openai/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -203,9 +205,11 @@ Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | --------------- | --------------- | --------------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| GLM-5.3-Flash Free | Free | Free | Free | - |
| DeepSeek V4.1 Flash Free | Free | Free | Free | - |
| Space Bunny Free | Free | Free | Free | - |
| Cat Jam Free | Free | Free | Free | - |
| LL Alpha Free | Free | Free | Free | - |
| LongCat 2.5 Preview Free | Free | Free | Free | - |
| Exo Free | Free | Free | Free | - |
| MiMo-V2.6-Flash Free | Free | Free | Free | - |
@@ -318,6 +322,7 @@ models to generate session titles.
### Free models
- GLM-5.3-Flash Free is free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- DeepSeek V4.1 Flash Free is free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- Exo Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiMo-V2.6-Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
@@ -328,6 +333,7 @@ models to generate session titles.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Space Bunny Free is a stealth model that's free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- Cat Jam Free is a stealth model that's free on OpenCode for a limited time. Its provider does not use your data for model training and retains it for 30 days for safety reasons. It's not available in Angola, Belarus, Cambodia, China (including Hong Kong and Macau), Cuba, the Democratic Republic of the Congo, Equatorial Guinea, Iran, Iraq, Laos, Libya, Nigeria, North Korea, Russia, Syria, Turkmenistan, Uzbekistan, Venezuela, or the Crimea, Donetsk, and Luhansk regions of Ukraine.
- LL Alpha Free is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- LongCat 2.5 Preview Free is free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- Muse Spark 1.3 Contributor Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Jev 1.13 Free is available on OpenCode for a limited time.
@@ -374,6 +380,7 @@ you might still be charged more than $20 when your balance goes below $5.
All our models are hosted in the US and EU. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions:
- Big Pickle: During its free period, collected data may be used to improve the model.
- LL Alpha Free: During its free period, collected data may be used to improve the model. Data is retained for 30 days.
- Exo Free: During its free period, collected data may be used to improve the model.
- MiMo-V2.6-Flash Free: During its free period, collected data may be used to improve the model.
- MiMo-V2.5 Free: During its free period, collected data may be used to improve the model.