mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-16 11:20:42 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b99646cf1 | ||
|
|
37e3912d8e | ||
|
|
f0c122f717 | ||
|
|
e3d10de997 | ||
|
|
538f8b1da6 | ||
|
|
f09eee5002 | ||
|
|
4cce6aef4e | ||
|
|
ccc6a3fd59 | ||
|
|
32b41bab13 | ||
|
|
e3d5a3bdd3 | ||
|
|
723a2f6892 | ||
|
|
b0a8e2501b | ||
|
|
eeec76bb62 |
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")
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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?.(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user