Compare commits

..

6 Commits

Author SHA1 Message Date
Adam 7e0353cca9 fix(stats): correct r2 daily totals 2026-08-12 11:36:23 -05:00
SKY ZHAO 959c8bd498 docs: fix provider display name and PAT typos (#42034)
Co-authored-by: skyzhao1223 <skyzhao1223@users.noreply.github.com>
2026-08-12 10:32:23 -05:00
SKY ZHAO ca3df21b7f docs: fix broken DigitalOcean and Daytona links (#42048)
Co-authored-by: skyzhao1223 <skyzhao1223@users.noreply.github.com>
2026-08-12 10:31:47 -05:00
Matthew Feroz 8571a922db fix(provider): add Merge Gateway reasoning variants (#41867) 2026-08-12 10:31:19 -05:00
Frank d92d1e654b docs(zen): add Grok 4.6 2026-08-12 10:53:38 -04:00
Adam 46a14e685a feat(stats): query r2 data catalog 2026-08-12 09:49:10 -05:00
36 changed files with 559 additions and 354 deletions
+13 -1
View File
@@ -181,6 +181,16 @@ const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
},
})
const r2SqlAuthToken = new sst.Secret("R2SqlAuthToken")
const r2Sql = new sst.Linkable("R2Sql", {
properties: {
accountId: "15d29c8639fd3733b1b5486a2acfd968",
bucket: `platform-${$app.stage}-lake`,
namespace: "inference",
table: "generation",
},
})
export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
@@ -193,7 +203,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
link: [database, inferenceEvent, statsSyncConfig],
// Keep the legacy Athena link and IAM permissions during the first R2-backed
// release so reverting the application code remains a one-deploy rollback.
link: [database, inferenceEvent, r2Sql, r2SqlAuthToken, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,
+4
View File
@@ -6,10 +6,14 @@ import { ChildProcess } from "effect/unstable/process"
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
@@ -84,6 +84,8 @@ function sdkKey(npm: string): string | undefined {
return "gateway"
case "@openrouter/ai-sdk-provider":
return "openrouter"
case "merge-gateway-ai-sdk-provider":
return "mergeGateway"
case "ai-gateway-provider":
// ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }),
// and @ai-sdk/openai-compatible parses compatibleOptions from one of
@@ -1772,6 +1774,7 @@ function reasoningEffort(model: Provider.Model, effort: string) {
case "@ai-sdk/togetherai":
case "venice-ai-sdk-provider":
case "ai-gateway-provider":
case "merge-gateway-ai-sdk-provider":
return { reasoningEffort: effort }
case "@ai-sdk/cohere":
case "@ai-sdk/perplexity":
+89 -114
View File
@@ -22,11 +22,9 @@ export type FileDiff = typeof FileDiff.Type
const prune = "7.days"
const limit = 2 * 1024 * 1024
// Operational flags are safe for both repositories. Working-tree semantics are
// resolved from the source repository and persisted in the private snapshot repository.
const operational = ["-c", "core.longpaths=true"]
const quote = [...operational, "-c", "core.quotepath=false"]
const mirrored = ["core.autocrlf", "core.symlinks"]
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
@@ -101,25 +99,27 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
),
)
const required = Effect.fnUntraced(function* (
cmd: string[],
opts?: { cwd?: string; env?: Record<string, string>; stdin?: string },
) {
const result = yield* git(cmd, opts)
if (result.code === 0) return result
return yield* Effect.die(
new Error(`git ${cmd.join(" ")} failed with exit code ${result.code}: ${result.stderr.trim()}`),
)
})
const ignore = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return new Set<string>()
// check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix.
const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item))
const check = yield* git([...quote, "check-ignore", "--no-index", "--stdin", "-z"], {
cwd: state.worktree,
stdin: encodeNulTerminatedPaths(checkIgnorePaths),
})
const check = yield* git(
[
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--stdin",
"-z",
],
{
cwd: state.worktree,
stdin: encodeNulTerminatedPaths(checkIgnorePaths),
},
)
if (check.code !== 0 && check.code !== 1) return new Set<string>()
return new Set(
check.text
@@ -131,9 +131,9 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const drop = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return
yield* required(
yield* git(
[
...operational,
...cfg,
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
],
{
@@ -145,13 +145,18 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const stage = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return
yield* required(
[...operational, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
const result = yield* git(
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
{
cwd: state.worktree,
stdin: encodeTopLevelLiteralPathspecs(files),
},
)
if (result.code === 0) return
yield* Effect.logWarning("failed to add snapshot files", {
exitCode: result.code,
stderr: result.stderr,
})
})
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
@@ -221,62 +226,12 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
// Seed the index from the source repo so already-hashed entries are reused.
// Best-effort: a missing/incompatible index just falls back to a full add.
const index = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "index"], {
cwd: state.worktree,
})
const sourceIndex = index.text.trim()
const sourceIndex = path.join(source, "index")
if (yield* exists(sourceIndex)) {
yield* fs.copyFile(sourceIndex, path.join(state.gitdir, "index")).pipe(Effect.catch(() => Effect.void))
}
})
const prepare = Effect.fnUntraced(function* () {
const existed = yield* exists(state.gitdir)
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
if (!(yield* exists(path.join(state.gitdir, "config")))) {
yield* required(["init"], {
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
})
}
const semantics = yield* Effect.forEach(mirrored, (key) =>
git(["config", "--get", key], { cwd: state.worktree }).pipe(
Effect.map((result) => {
if (result.code === 0) return [key, result.text.trim()] as const
if (result.code === 1) return [key, key === "core.autocrlf" ? "false" : "true"] as const
throw new Error(`failed to resolve ${key}: ${result.stderr.trim()}`)
}),
),
)
const fingerprint = `v1;${semantics.map(([key, value]) => `${key}=${value}`).join(";")}`
const previous = yield* git([
"--git-dir",
state.gitdir,
"config",
"--local",
"--get",
"opencode.snapshotSemantics",
])
if (previous.text.trim() === fingerprint) return
const config = [
...semantics,
["core.longpaths", "true"],
["core.fsmonitor", "false"],
["feature.manyFiles", "true"],
["index.version", "4"],
["index.threads", "true"],
["core.untrackedCache", "true"],
] as const
yield* Effect.forEach(
config,
([key, value]) => required(["--git-dir", state.gitdir, "config", "--local", key, value]),
{ discard: true },
)
yield* seed()
yield* required(["--git-dir", state.gitdir, "config", "--local", "opencode.snapshotSemantics", fingerprint])
if (!existed) yield* Effect.logInfo("initialized")
})
const add = Effect.fnUntraced(function* () {
yield* sync()
const [diff, other] = yield* Effect.all(
@@ -291,11 +246,13 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
{ concurrency: 2 },
)
if (diff.code !== 0 || other.code !== 0) {
return yield* Effect.die(
new Error(
`failed to list snapshot files: diff-files=${diff.code} ${diff.stderr.trim()} ls-files=${other.code} ${other.stderr.trim()}`,
),
)
yield* Effect.logWarning("failed to list snapshot files", {
diffCode: diff.code,
diffStderr: diff.stderr,
otherCode: other.code,
otherStderr: other.stderr,
})
return
}
const tracked = diff.text.split("\0").filter(Boolean)
@@ -362,9 +319,26 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
return yield* locked(
Effect.gen(function* () {
if (!(yield* enabled())) return
yield* prepare()
const existed = yield* exists(state.gitdir)
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
})
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
// Tuning for very large worktrees so the first add stays bounded.
yield* git(["--git-dir", state.gitdir, "config", "feature.manyFiles", "true"])
yield* git(["--git-dir", state.gitdir, "config", "index.version", "4"])
yield* git(["--git-dir", state.gitdir, "config", "index.threads", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.untrackedCache", "true"])
yield* seed()
yield* Effect.logInfo("initialized")
}
yield* add()
const result = yield* required(args(["write-tree"]), { cwd: state.directory })
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
const hash = result.text.trim()
yield* Effect.logInfo("tracking", { hash, cwd: state.directory, git: state.gitdir })
return hash
@@ -375,7 +349,6 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const patch = Effect.fnUntraced(function* (hash: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
@@ -409,11 +382,24 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const restore = Effect.fnUntraced(function* (snapshot: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
yield* Effect.logInfo("restore", { commit: snapshot })
yield* required([...operational, ...args(["read-tree", snapshot])], { cwd: state.worktree })
yield* required([...operational, ...args(["checkout-index", "-a", "-f"])], {
cwd: state.worktree,
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
cwd: state.worktree,
})
if (checkout.code === 0) return
yield* Effect.logError("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
yield* Effect.logError("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
}),
)
@@ -422,7 +408,6 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const revert = Effect.fnUntraced(function* (patches: Patch[]) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
const ops: { hash: string; file: string; rel: string }[] = []
const seen = new Set<string>()
for (const item of patches) {
@@ -439,21 +424,20 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
yield* Effect.logInfo("reverting", { file: op.file, hash: op.hash })
const result = yield* git([...operational, ...args(["checkout", op.hash, "--", op.file])], {
const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
cwd: state.worktree,
})
if (result.code === 0) return
const tree = yield* git([...operational, ...args(["ls-tree", op.hash, "--", op.rel])], {
const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
cwd: state.worktree,
})
if (tree.code !== 0)
yield* Effect.die(
new Error(`failed to inspect ${op.file} in snapshot ${op.hash}: ${tree.stderr.trim()}`),
)
if (tree.text.trim())
yield* Effect.die(
new Error(`failed to restore ${op.file} from snapshot ${op.hash}: ${result.stderr.trim()}`),
)
if (tree.code === 0 && tree.text.trim()) {
yield* Effect.logInfo("file existed in snapshot but checkout failed, keeping", {
file: op.file,
hash: op.hash,
})
return
}
yield* Effect.logInfo("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
yield* remove(op.file)
})
@@ -480,10 +464,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
}
const tree = yield* git(
[
...operational,
...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)]),
],
[...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
{
cwd: state.worktree,
},
@@ -512,7 +493,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
if (list.length) {
yield* Effect.logInfo("reverting", { hash: first.hash, files: list.length })
const result = yield* git(
[...operational, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
[...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
{
cwd: state.worktree,
},
@@ -545,7 +526,6 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const diff = Effect.fnUntraced(function* (hash: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
yield* add()
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
@@ -566,7 +546,6 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
return yield* locked(
Effect.gen(function* () {
yield* prepare()
type Row = {
file: string
status: "added" | "deleted" | "modified"
@@ -586,14 +565,12 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
if (row.status === "added") {
return [
"",
yield* git([...operational, ...args(["show", `${to}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
]
}
if (row.status === "deleted") {
return [
yield* git([...operational, ...args(["show", `${from}:${row.file}`])]).pipe(
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
"",
@@ -601,10 +578,8 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
}
return yield* Effect.all(
[
git([...operational, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
git([...operational, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
@@ -627,7 +602,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
if (!refs.length) return new Map<string, { before: string; after: string }>()
const batch = yield* appProcess.run(
ChildProcess.make("git", [...operational, ...args(["cat-file", "--batch"])], {
ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
cwd: state.directory,
extendEnv: true,
}),
-33
View File
@@ -115,39 +115,6 @@ describe("Git", () => {
}),
)
it.live("respects repository line ending configuration", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(tmp.path).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "line-endings.txt"), "before\n", "utf-8"))
yield* Effect.promise(() => $`git add line-endings.txt`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add line endings"`.cwd(tmp.path).quiet())
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "line-endings.txt")))
yield* Effect.promise(() => $`git checkout -- line-endings.txt`.cwd(tmp.path).quiet())
const git = yield* Git.Service
expect(yield* git.status(tmp.path)).toEqual([])
expect(yield* git.diff(tmp.path, "HEAD")).toEqual([])
}),
)
it.live("respects repository symlink configuration", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
const blob = yield* Effect.promise(() => $`echo -n target.txt | git hash-object -w --stdin`.cwd(tmp.path).text())
yield* Effect.promise(() =>
$`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(tmp.path).quiet(),
)
yield* Effect.promise(() => $`git commit --no-gpg-sign -m "add symlink"`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git config core.symlinks false`.cwd(tmp.path).quiet())
yield* Effect.promise(() => $`git checkout-index -f link.txt`.cwd(tmp.path).quiet())
const git = yield* Git.Service
expect(yield* git.status(tmp.path)).toEqual([])
expect(yield* git.diff(tmp.path, "HEAD")).toEqual([])
}),
)
it.live("patch() returns capped native patch output", () =>
Effect.gen(function* () {
const tmp = yield* scopedTmpdir({ git: true })
@@ -1548,6 +1548,33 @@ test("models.dev reasoning options replace generated variants and unsupported to
expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants)
})
test("MERGE Gateway exposes declared effort variants without model-specific handling", () => {
const provider = {
id: "merge-gateway",
name: "MERGE Gateway",
env: ["MERGE_GATEWAY_API_KEY"],
npm: "merge-gateway-ai-sdk-provider",
models: {
"openai/gpt-5.6-sol": {
id: "openai/gpt-5.6-sol",
name: "GPT-5.6 Sol",
reasoning: true,
reasoning_options: [{ type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }],
limit: { context: 128_000, output: 64_000 },
},
},
} as unknown as ModelsDev.Provider
expect(Provider.fromModelsDevProvider(provider).models["openai/gpt-5.6-sol"].variants).toEqual({
none: { reasoningEffort: "none" },
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
xhigh: { reasoningEffort: "xhigh" },
max: { reasoningEffort: "max" },
})
})
test("public provider info omits invalid models", () => {
const provider = Provider.fromModelsDevProvider({
id: "test",
@@ -3370,6 +3370,7 @@ describe("ProviderTransform.reasoningVariants", () => {
["@ai-sdk/togetherai", { reasoningEffort: "high" }],
["venice-ai-sdk-provider", { reasoningEffort: "high" }],
["ai-gateway-provider", { reasoningEffort: "high" }],
["merge-gateway-ai-sdk-provider", { reasoningEffort: "high" }],
["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }],
])("converts effort for %s", (npm, expected, ...args) => {
const id = args[0] as string | undefined
@@ -5555,6 +5556,25 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
})
})
describe("ProviderTransform.providerOptions - merge-gateway-ai-sdk-provider", () => {
const model = {
id: "merge-gateway/openai/gpt-5.6-sol",
providerID: "merge-gateway",
api: {
id: "openai/gpt-5.6-sol",
url: "https://api-gateway.merge.dev/v1/ai-sdk",
npm: "merge-gateway-ai-sdk-provider",
},
capabilities: { reasoning: true },
} as any
test("routes normalized effort under the adapter's mergeGateway key", () => {
expect(ProviderTransform.providerOptions(model, { reasoningEffort: "high" })).toEqual({
mergeGateway: { reasoningEffort: "high" },
})
})
})
describe("ProviderTransform.options - kimi family adaptive thinking", () => {
const createModel = (overrides: Record<string, any> = {}) =>
({
@@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import fs from "fs/promises"
import path from "path"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Effect, Fiber, Layer } from "effect"
import { Snapshot } from "../../src/snapshot"
import {
disposeAllInstances,
@@ -117,68 +117,6 @@ it.instance(
{ git: true },
)
it.instance(
"uses source repository line ending semantics",
Effect.gen(function* () {
const tmp = yield* TestInstance
const file = path.join(tmp.directory, "line-endings.txt")
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"])
yield* write(file, "before\n")
yield* exec(tmp.directory, ["git", "add", "line-endings.txt"])
yield* exec(tmp.directory, ["git", "commit", "--no-gpg-sign", "-m", "add line endings"])
yield* rm(file)
yield* exec(tmp.directory, ["git", "checkout", "--", "line-endings.txt"])
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
yield* write(file, "before\n")
const after = yield* snapshot.track()
expect(after).toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "false"])
yield* write(file, "before\r\n")
expect(yield* snapshot.track()).not.toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"])
expect(yield* snapshot.track()).toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "input"])
yield* write(file, "changed\n")
yield* snapshot.restore(before!)
expect(yield* readText(file)).toBe("before\n")
yield* exec(tmp.directory, ["git", "config", "core.autocrlf", "true"])
yield* write(file, "changed\n")
yield* snapshot.restore(before!)
expect(yield* readText(file)).toBe("before\r\n")
}),
{ git: true },
)
it.instance(
"uses source repository symlink semantics",
Effect.gen(function* () {
const tmp = yield* TestInstance
const blob = yield* Effect.promise(() =>
$`printf target.txt | git hash-object -w --stdin`.cwd(tmp.directory).text(),
)
yield* exec(tmp.directory, ["git", "update-index", "--add", "--cacheinfo", `120000,${blob.trim()},link.txt`])
yield* exec(tmp.directory, ["git", "commit", "--no-gpg-sign", "-m", "add symlink"])
yield* exec(tmp.directory, ["git", "config", "core.symlinks", "false"])
yield* exec(tmp.directory, ["git", "checkout-index", "-f", "link.txt"])
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
yield* exec(tmp.directory, ["git", "config", "core.symlinks", "true"])
expect(yield* snapshot.track()).not.toBe(before)
yield* exec(tmp.directory, ["git", "config", "core.symlinks", "false"])
expect(yield* snapshot.track()).toBe(before)
}),
{ git: true },
)
it.instance(
"revert should remove new files",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
@@ -349,20 +287,6 @@ it.instance(
{ git: true },
)
it.instance(
"revert preserves files when snapshot lookup fails",
withTrackedSnapshot(({ tmp, snapshot }) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "protected.txt")
yield* write(file, "keep")
const result = yield* Effect.exit(snapshot.revert([{ hash: "invalid-hash", files: [file] }]))
expect(Exit.isFailure(result)).toBe(true)
expect(yield* readText(file)).toBe("keep")
}),
),
{ git: true },
)
it.instance(
"unicode filenames",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
+1
View File
@@ -12,6 +12,7 @@
"./database": "./src/database.ts",
"./database/*": "./src/database/*.ts",
"./domain/*": "./src/domain/*.ts",
"./r2-sql": "./src/r2-sql.ts",
"./runtime": "./src/runtime.ts",
"./stat-sync": "./src/stat-sync.ts"
},
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { buildStatsQueries, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
describe("inference stat normalization", () => {
@@ -82,6 +82,65 @@ describe("inference stat normalization", () => {
}),
).toMatchObject([{ period_key: "2026-W20" }])
})
test("builds bounded R2 SQL queries for each day and week", () => {
const queries = buildStatsQueries(new Date("2026-08-10T00:00:00.000Z"), new Date("2026-08-12T12:00:00.000Z"), {
namespace: "inference",
table: "generation",
dataset: "zen",
})
expect(queries).toHaveLength(8)
expect(queries[0]).toContain("'week' AS grain")
expect(queries[0]).toContain("'2026-W33' AS period_key")
expect(queries[2]).toContain("'2026-08-10' AS period_key")
expect(queries[6]).toContain("'2026-08-12' AS period_key")
expect(queries[0]).toContain('FROM "inference"."generation"')
expect(queries[0]).toContain("event_type = 'generation.completed'")
expect(queries[0]).toContain("product = 'go'")
expect(queries[0]).toContain("LIMIT 10000")
expect(queries[0]).toContain("approx_distinct(session) AS sessions")
expect(queries[1]).toContain("'geo_model' ELSE 'geo'")
expect(queries[1]).toContain("0 AS sessions")
})
test("aligns periods to UTC calendar boundaries", () => {
const queries = buildStatsQueries(
new Date("2026-06-17T15:56:00.000Z"),
new Date("2026-06-19T15:56:00.000Z"),
{
namespace: "inference",
table: "generation",
dataset: "zen",
},
)
expect(queries).toHaveLength(8)
expect(queries[0]).toContain("'2026-W25' AS period_key")
expect(queries[0]).toContain("started_at >= '2026-06-15T00:00:00.000Z'")
expect(queries[2]).toContain("'2026-06-17' AS period_key")
expect(queries[2]).toContain("started_at >= '2026-06-17T00:00:00.000Z'")
expect(queries[2]).toContain("started_at < '2026-06-18T00:00:00.000Z'")
expect(queries[6]).toContain("'2026-06-19' AS period_key")
expect(queries[6]).toContain("started_at < '2026-06-19T15:56:00.000Z'")
})
test("uses an exclusive live and legacy source handoff", () => {
const [query] = buildStatsQueries(
new Date("2026-08-11T00:00:00.000Z"),
new Date("2026-08-12T00:00:00.000Z"),
{
namespace: "inference",
table: "generation",
dataset: "zen",
},
)
expect(query).toContain(
"(source = 'inference-legacy' AND started_at < '2026-08-11T10:57:48.186Z')",
)
expect(query).toContain("(source = 'inference' AND started_at >= '2026-08-11T10:57:48.186Z')")
})
})
function aggregate(model: string, provider: string) {
+147 -105
View File
@@ -1,5 +1,5 @@
import { Resource } from "sst/resource"
import type { AthenaData } from "../athena"
import type { R2SqlData } from "../r2-sql"
import type { GeoStatAggregate } from "./geo"
import type { ModelStatAggregate } from "./model"
import {
@@ -10,25 +10,80 @@ import {
statProvider,
} from "./model-normalization"
import type { ProviderStatAggregate } from "./provider"
import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
import {
normalizeCountry,
normalizeTier,
periodKeyFor,
startOfIsoWeek,
startOfUtcDay,
type StatBaseAggregate,
} from "./stat"
export type StatDimension = "model" | "provider" | "geo" | "geo_model"
export type StatsQuerySource = { namespace: string; table: string; dataset: string }
type StatsQueryFamily = "usage" | "geo"
// All stat dimensions and both grains are computed in one query via GROUPING SETS so
// the source table is scanned once per sync pass; separate queries per dimension (and
// the previous weekly/daily UNION ALL) each re-scanned the same events.
export function buildStatsQuery(periodStart: Date, periodEnd: Date) {
const periodStartValue = sqlString(periodStart.toISOString())
const periodEndValue = sqlString(periodEnd.toISOString())
const periodStartDateValue = sqlString(periodStart.toISOString().slice(0, 10))
const periodEndDateValue = sqlString(periodEnd.toISOString().slice(0, 10))
const sourceTable = [Resource.InferenceEvent.catalog, Resource.InferenceEvent.database, Resource.InferenceEvent.table]
.map(sqlIdentifier)
.join(".")
const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS
// The typed production stream began before the legacy backfill's original end
// boundary. Use one exclusive handoff so the overlapping rows are never counted
// from both sources.
const LIVE_SOURCE_START = "2026-08-11T10:57:48.186Z"
// R2 SQL limits result sets to 10,000 rows and does not support OFFSET. Two
// queries per day/week keep each result bounded and avoid combining the costly
// distinct user/session aggregates with the high-cardinality geo dimensions.
export function buildStatsQueries(periodStart: Date, periodEnd: Date, input?: StatsQuerySource) {
const source = input ?? {
namespace: Resource.R2Sql.namespace,
table: Resource.R2Sql.table,
dataset: Resource.StatsSyncConfig.dataset,
}
return [...statPeriods("week", periodStart, periodEnd), ...statPeriods("day", periodStart, periodEnd)].flatMap(
(period) => [buildStatsQuery(period, source, "usage"), buildStatsQuery(period, source, "geo")],
)
}
function buildStatsQuery(
period: { grain: "day" | "week"; key: string; start: Date; end: Date },
source: StatsQuerySource,
family: StatsQueryFamily,
) {
const periodStartValue = sqlString(period.start.toISOString())
const periodEndValue = sqlString(period.end.toISOString())
const ingestEndValue = sqlString(new Date(period.end.getTime() + DAY_MS).toISOString())
const sourceTable = [source.namespace, source.table].map(sqlIdentifier).join(".")
const dimensions =
family === "usage"
? `CASE WHEN grouping(model) = 0 THEN 'model' ELSE 'provider' END AS dimension,
tier,
provider,
CASE WHEN grouping(model) = 0 THEN model END AS model,
CASE WHEN grouping(model) = 0 THEN COALESCE(MAX(NULLIF(provider_model, '')), '') END AS provider_model,
null AS country,
null AS continent`
: `CASE WHEN grouping(model) = 0 THEN 'geo_model' ELSE 'geo' END AS dimension,
tier,
CASE WHEN grouping(model) = 0 THEN provider ELSE 'all' END AS provider,
CASE WHEN grouping(model) = 0 THEN model ELSE 'all' END AS model,
null AS provider_model,
country,
COALESCE(MAX(NULLIF(continent, '')), '') AS continent`
const distinctColumns =
family === "usage"
? `approx_distinct(session) AS sessions,
approx_distinct(user_key) AS unique_users`
: `0 AS sessions,
0 AS unique_users`
const groupingSets =
family === "usage"
? `(tier, provider, model),
(tier, provider)`
: `(tier, country),
(tier, provider, model, country)`
const aggregateColumns = `
COUNT(DISTINCT session) AS sessions,
${distinctColumns},
COUNT(*) AS requests,
COUNT(DISTINCT user_key) AS unique_users,
COALESCE(SUM(tokens_input), 0) AS input_tokens,
COALESCE(SUM(tokens_output), 0) AS output_tokens,
COALESCE(SUM(tokens_reasoning), 0) AS reasoning_tokens,
@@ -38,65 +93,61 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date) {
COALESCE(SUM(cost_output_microcents), 0) AS output_cost_microcents,
COALESCE(SUM(cost_total_microcents), 0) AS total_cost_microcents,
AVG(duration_ms) AS avg_duration_ms,
approx_percentile(CAST(duration_ms AS double), 0.5) AS p50_duration_ms,
approx_percentile(CAST(duration_ms AS double), 0.95) AS p95_duration_ms,
null AS p50_duration_ms,
null AS p95_duration_ms,
AVG(ttfb_ms) AS avg_ttfb_ms,
approx_percentile(CAST(ttfb_ms AS double), 0.5) AS p50_ttfb_ms,
approx_percentile(CAST(ttfb_ms AS double), 0.95) AS p95_ttfb_ms,
null AS p50_ttfb_ms,
null AS p95_ttfb_ms,
AVG(output_tps) AS avg_output_tps,
SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS error_count,
SUM(CASE WHEN outcome = 'succeeded' THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS error_count,
COUNT(*) AS sample_count`
return `
WITH normalized AS (
SELECT
from_iso8601_timestamp(event_timestamp) AS event_time,
model AS raw_model,
${statModelSql("model", "provider_model")} AS model,
COALESCE(NULLIF(provider_model, ''), '') AS provider_model,
COALESCE(NULLIF(provider, ''), '') AS raw_provider,
UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
COALESCE(NULLIF(cf_continent, ''), '') AS continent,
session,
COALESCE(NULLIF(workspace, ''), '') AS workspace,
COALESCE(NULLIF(api_key, ''), '') AS api_key,
model_requested AS raw_model,
${statModelSql("model_requested", "route_model")} AS model,
COALESCE(NULLIF(route_model, ''), '') AS provider_model,
COALESCE(NULLIF(provider_id, ''), '') AS raw_provider,
UPPER(COALESCE(NULLIF(country, ''), 'ZZ')) AS country,
COALESCE(NULLIF(continent, ''), '') AS continent,
session_id AS session,
COALESCE(NULLIF(workspace_id, ''), '') AS workspace,
COALESCE(NULLIF(service_api_key_id, ''), '') AS api_key,
COALESCE(NULLIF(user_id, ''), '') AS user_id,
status,
duration AS duration_ms,
time_to_first_byte AS ttfb_ms,
timestamp_first_byte,
timestamp_last_byte,
outcome,
duration_ms,
time_to_first_token_ms AS ttfb_ms,
CASE
WHEN first_token_at IS NULL OR last_token_at IS NULL THEN null
ELSE date_part('epoch', last_token_at) - date_part('epoch', first_token_at)
END AS output_seconds,
tokens_input,
tokens_output,
tokens_reasoning,
tokens_cache_read,
tokens_cache_write_5m,
tokens_cache_write_1h,
cost_input_microcents,
cost_output_microcents,
cost_total_microcents,
cost_input,
cost_output,
cost_total,
source
tokens_cache_write,
cost_input AS cost_input_microcents,
cost_output AS cost_output_microcents,
cost_total AS cost_total_microcents
FROM ${sourceTable}
WHERE event_type = 'completions'
AND model IS NOT NULL
AND model <> ''
AND source = 'lite'
AND event_date >= ${periodStartDateValue}
AND event_date <= ${periodEndDateValue}
AND event_timestamp >= ${periodStartValue}
AND event_timestamp < ${periodEndValue}
WHERE event_type = 'generation.completed'
AND source IN ('inference', 'inference-legacy')
AND (
(source = 'inference-legacy' AND started_at < ${sqlString(LIVE_SOURCE_START)})
OR (source = 'inference' AND started_at >= ${sqlString(LIVE_SOURCE_START)})
)
AND product = 'go'
AND model_requested IS NOT NULL
AND model_requested <> ''
AND __ingest_ts >= ${periodStartValue}
AND __ingest_ts < ${ingestEndValue}
AND started_at >= ${periodStartValue}
AND started_at < ${periodEndValue}
), filtered AS (
SELECT
event_time,
CASE
WHEN source = 'lite' THEN 'Go'
WHEN raw_model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR regexp_like(raw_model, '-free(:global)?$') THEN 'Free'
ELSE 'Paid'
END AS tier,
'Go' AS tier,
${statProviderSql("model", "provider_model", "raw_provider")} AS provider,
provider_model,
model,
@@ -104,63 +155,39 @@ WITH normalized AS (
continent,
session,
COALESCE(NULLIF(user_id, ''), NULLIF(workspace, ''), NULLIF(api_key, '')) AS user_key,
status,
outcome,
duration_ms,
ttfb_ms,
CASE
WHEN timestamp_last_byte - timestamp_first_byte < 100 THEN null
ELSE CAST(tokens_output AS double) / (timestamp_last_byte - timestamp_first_byte) * 1000
WHEN output_seconds < 0.1 THEN null
ELSE CAST(tokens_output AS double) / output_seconds
END AS output_tps,
tokens_input,
tokens_output,
tokens_reasoning,
tokens_cache_read,
COALESCE(tokens_cache_read, 0) + COALESCE(tokens_cache_write_5m, 0) + COALESCE(tokens_cache_write_1h, 0) + COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) AS tokens_total,
COALESCE(cost_input_microcents, cost_input * 1000000) AS cost_input_microcents,
COALESCE(cost_output_microcents, cost_output * 1000000) AS cost_output_microcents,
COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents
COALESCE(tokens_cache_read, 0) + COALESCE(tokens_cache_write, 0) + COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) AS tokens_total,
cost_input_microcents,
cost_output_microcents,
cost_total_microcents
FROM normalized
WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")})
), periods AS (
SELECT
concat(CAST(year_of_week(event_time) AS varchar), '-W', lpad(CAST(week(event_time) AS varchar), 2, '0')) AS week_key,
substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key,
*
FROM filtered
)
SELECT
CASE WHEN grouping(week_key) = 0 THEN 'week' ELSE 'day' END AS grain,
COALESCE(week_key, day_key) AS period_key,
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
CASE
WHEN grouping(country) = 0 AND grouping(model) = 0 THEN 'geo_model'
WHEN grouping(country) = 0 THEN 'geo'
WHEN grouping(model) = 0 THEN 'model'
ELSE 'provider'
END AS dimension,
tier,
CASE WHEN grouping(provider) = 0 THEN provider ELSE 'all' END AS provider,
CASE WHEN grouping(model) = 0 THEN model WHEN grouping(country) = 0 THEN 'all' END AS model,
CASE WHEN grouping(model) = 0 AND grouping(country) = 1 THEN COALESCE(MAX(NULLIF(provider_model, '')), '') END AS provider_model,
CASE WHEN grouping(country) = 0 THEN country END AS country,
CASE WHEN grouping(country) = 0 THEN COALESCE(MAX(NULLIF(continent, '')), '') END AS continent,
${sqlString(period.grain)} AS grain,
${sqlString(period.key)} AS period_key,
${sqlString(source.dataset)} AS dataset,
${dimensions},
${aggregateColumns}
FROM periods
FROM filtered
GROUP BY GROUPING SETS (
(week_key, tier, provider, model),
(week_key, tier, provider),
(week_key, tier, country),
(week_key, tier, provider, model, country),
(day_key, tier, provider, model),
(day_key, tier, provider),
(day_key, tier, country),
(day_key, tier, provider, model, country)
${groupingSets}
)
ORDER BY grain, period_key, total_tokens DESC
LIMIT 10000
`
}
export function toModelAggregate(data: AthenaData): ModelStatAggregate[] {
export function toModelAggregate(data: R2SqlData): ModelStatAggregate[] {
const model = statModel(data.model, data.provider_model)
const provider = statProvider(model, data.provider_model, data.provider)
if (!provider) return []
@@ -170,13 +197,13 @@ export function toModelAggregate(data: AthenaData): ModelStatAggregate[] {
])
}
export function toProviderAggregate(data: AthenaData): ProviderStatAggregate[] {
export function toProviderAggregate(data: R2SqlData): ProviderStatAggregate[] {
return toStatBaseAggregate(data).flatMap((base) => [
{ ...base, provider: statProvider(data.model, data.provider_model, data.provider) || "unknown" },
])
}
export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
export function toGeoAggregate(data: R2SqlData): GeoStatAggregate[] {
return toStatBaseAggregate(data).flatMap((base) => [
{
...base,
@@ -188,7 +215,7 @@ export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
])
}
function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
function toStatBaseAggregate(data: R2SqlData): StatBaseAggregate[] {
const grain = data.grain === "day" || data.grain === "week" ? data.grain : undefined
if (!grain || !data.period_key) return []
@@ -223,21 +250,21 @@ function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
]
}
function integer(data: AthenaData, key: string) {
function integer(data: R2SqlData, key: string) {
return Math.round(number(data, key))
}
function nullableNumber(data: AthenaData, key: string) {
function nullableNumber(data: R2SqlData, key: string) {
if (data[key] === undefined || data[key] === "") return null
return Number(number(data, key).toFixed(2))
}
function nullableInteger(data: AthenaData, key: string) {
function nullableInteger(data: R2SqlData, key: string) {
if (data[key] === undefined || data[key] === "") return null
return Math.round(number(data, key))
}
function number(data: AthenaData, key: string) {
function number(data: R2SqlData, key: string) {
const value = Number(data[key])
return Number.isFinite(value) ? value : 0
}
@@ -250,6 +277,21 @@ function sqlString(value: string) {
return `'${value.replace(/'/g, "''")}'`
}
function statPeriods(grain: "day" | "week", periodStart: Date, periodEnd: Date) {
const interval = grain === "day" ? DAY_MS : WEEK_MS
const first = grain === "day" ? startOfUtcDay(periodStart) : startOfIsoWeek(periodStart)
const count = Math.max(0, Math.ceil((periodEnd.getTime() - first.getTime()) / interval))
return Array.from({ length: count }, (_, index) => {
const start = new Date(first.getTime() + index * interval)
return {
grain,
key: periodKeyFor(grain, start),
start,
end: new Date(Math.min(start.getTime() + interval, periodEnd.getTime())),
}
})
}
function statModelSql(model: string, providerModel: string) {
return `COALESCE(NULLIF(regexp_replace(CASE
WHEN lower(${model}) = 'big-pickle' THEN NULLIF(${providerModel}, '')
+105
View File
@@ -0,0 +1,105 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Resource } from "sst/resource"
const R2_SQL_MAX_ROWS = 10_000
const R2SqlValue = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Null])
const R2SqlResponse = Schema.Struct({
success: Schema.Boolean,
result: Schema.optional(
Schema.NullOr(
Schema.Struct({
request_id: Schema.String,
rows: Schema.Array(Schema.Record(Schema.String, R2SqlValue)),
}),
),
),
errors: Schema.Array(Schema.Unknown),
})
const decodeResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(R2SqlResponse))
export type R2SqlData = Record<string, string>
export class R2SqlQueryError extends Error {
readonly _tag = "R2SqlQueryError"
readonly requestId?: string
readonly status?: number
constructor(input: { message: string; requestId?: string; status?: number; cause?: unknown }) {
super(input.message, { cause: input.cause })
this.name = "R2SqlQueryError"
this.requestId = input.requestId
this.status = input.status
}
}
export declare namespace R2Sql {
export interface Service {
readonly query: (query: string) => Effect.Effect<R2SqlData[], R2SqlQueryError>
}
}
export class R2Sql extends Context.Service<R2Sql, R2Sql.Service>()("@opencode/stats/R2Sql") {
static readonly layer: Layer.Layer<R2Sql> = Layer.succeed(
R2Sql,
R2Sql.of({
query: Effect.fn("R2Sql.query")(function* (query: string) {
const response = yield* Effect.tryPromise({
try: () =>
Bun.fetch(
`https://api.sql.cloudflarestorage.com/api/v1/accounts/${Resource.R2Sql.accountId}/r2-sql/query/${Resource.R2Sql.bucket}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Resource.R2SqlAuthToken.value}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
},
),
catch: (cause) => new R2SqlQueryError({ message: "Failed to run R2 SQL stats query", cause }),
})
const body = yield* Effect.tryPromise({
try: () => response.text(),
catch: (cause) =>
new R2SqlQueryError({ message: "Failed to read R2 SQL stats response", status: response.status, cause }),
})
const decoded = yield* decodeResponse(body).pipe(
Effect.mapError(
(cause) =>
new R2SqlQueryError({
message: "R2 SQL returned an invalid stats response",
status: response.status,
cause,
}),
),
)
if (!response.ok || !decoded.success || !decoded.result)
return yield* Effect.fail(
new R2SqlQueryError({
message: `R2 SQL stats query failed: ${JSON.stringify(decoded.errors)}`,
requestId: decoded.result?.request_id,
status: response.status,
}),
)
// R2 SQL has no OFFSET support and caps LIMIT at 10,000. Each stats
// query is scoped to one day or week, and reaching the cap is treated as
// an error so a newly high-cardinality period can never be truncated.
if (decoded.result.rows.length >= R2_SQL_MAX_ROWS)
return yield* Effect.fail(
new R2SqlQueryError({
message: `R2 SQL stats query reached the ${R2_SQL_MAX_ROWS} row limit`,
requestId: decoded.result.request_id,
status: response.status,
}),
)
return decoded.result.rows.map((row) =>
Object.fromEntries(
Object.entries(row).flatMap(([key, value]) => (value === null ? [] : [[key, String(value)]])),
),
)
}),
}),
)
}
+11
View File
@@ -11,6 +11,17 @@ declare module "sst/resource" {
type: "sst.sst.Linkable"
workgroup: string
}
R2Sql: {
accountId: string
bucket: string
namespace: string
table: string
type: "sst.sst.Linkable"
}
R2SqlAuthToken: {
type: "sst.sst.Secret"
value: string
}
StatsSyncConfig: {
dataset: string
type: "sst.sst.Linkable"
+14 -13
View File
@@ -1,12 +1,12 @@
import { DateTime, Effect } from "effect"
import { Resource } from "sst/resource"
import { Athena, AthenaQueryError, AthenaQueryTimeoutError } from "./athena"
import { DatabaseError } from "./database"
import { GeoStatRepo, rowsFromAggregates as geoRowsFromAggregates } from "./domain/geo"
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./domain/inference"
import { buildStatsQueries, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./domain/inference"
import { ModelStatRepo, rowsFromAggregates as modelRowsFromAggregates } from "./domain/model"
import { ProviderStatRepo, rowsFromAggregates as providerRowsFromAggregates } from "./domain/provider"
import { startOfIsoWeek } from "./domain/stat"
import { R2Sql, R2SqlQueryError } from "./r2-sql"
const DATALAKE_INGESTION_LAG_MS = 5 * 60_000
const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime()
@@ -18,23 +18,25 @@ const DISPLAY_WINDOW_MS = 56 * 86_400_000
const INCREMENTAL_LOOKBACK_MS = 2 * 3_600_000
export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
export type SyncStatsError = R2SqlQueryError | DatabaseError
export const syncStats: (options?: {
full?: boolean
}) => Effect.Effect<SyncStatsResult, SyncStatsError, Athena | ModelStatRepo | ProviderStatRepo | GeoStatRepo> =
}) => Effect.Effect<SyncStatsResult, SyncStatsError, R2Sql | ModelStatRepo | ProviderStatRepo | GeoStatRepo> =
Effect.fn("StatSync.sync")(function* (options?: { full?: boolean }) {
const startedAt = yield* DateTime.nowAsDate
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
const periodStart = options?.full ? fullPeriodStart(periodEnd) : incrementalPeriodStart(periodEnd)
const athena = yield* Athena
const r2Sql = yield* R2Sql
const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
const geoStats = yield* GeoStatRepo
yield* logRuntimeCheck()
const rows = yield* athena.query(buildStatsQuery(periodStart, periodEnd))
const rows = yield* Effect.forEach(buildStatsQueries(periodStart, periodEnd), r2Sql.query, {
concurrency: 4,
}).pipe(Effect.map((batches) => batches.flat()))
const modelRows = modelRowsFromAggregates(rows.filter((row) => row.dimension === "model").flatMap(toModelAggregate))
const providerRows = providerRowsFromAggregates(
rows.filter((row) => row.dimension === "provider").flatMap(toProviderAggregate),
@@ -77,7 +79,7 @@ export const syncStats: (options?: {
}
})
// May 27 was partial, so keep Athena stats anchored at the first complete day.
// May 27 was partial, so keep stats anchored at the first complete day.
function fullPeriodStart(periodEnd: Date) {
return new Date(
Math.max(
@@ -99,13 +101,12 @@ function incrementalPeriodStart(periodEnd: Date) {
function logRuntimeCheck() {
return Effect.logInfo(
`athena stats runtime check ${JSON.stringify({
catalog: Resource.InferenceEvent.catalog,
database: Resource.InferenceEvent.database,
`r2 sql stats runtime check ${JSON.stringify({
accountId: Resource.R2Sql.accountId,
bucket: Resource.R2Sql.bucket,
dataset: Resource.StatsSyncConfig.dataset,
table: Resource.InferenceEvent.table,
workgroup: Resource.InferenceEvent.workgroup,
region: Resource.InferenceEvent.region,
namespace: Resource.R2Sql.namespace,
table: Resource.R2Sql.table,
stage: Resource.App.stage,
})}`,
)
+5 -5
View File
@@ -1,6 +1,6 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import { Athena } from "@opencode-ai/stats-core/athena"
import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model"
import { R2Sql } from "@opencode-ai/stats-core/r2-sql"
import { layer as statsLayer } from "@opencode-ai/stats-core/runtime"
import { syncStats } from "@opencode-ai/stats-core/stat-sync"
import { Cause, Duration, Effect, Layer, Schedule } from "effect"
@@ -8,7 +8,7 @@ import { Cause, Duration, Effect, Layer, Schedule } from "effect"
const SYNC_INTERVAL = "1 hour"
const SYNC_INTERVAL_MS = 3_600_000
const runtimeLayer = Layer.mergeAll(statsLayer, Athena.layer)
const runtimeLayer = Layer.mergeAll(statsLayer, R2Sql.layer)
const daemon = Effect.gen(function* () {
yield* Effect.logInfo("stats sync daemon started")
@@ -40,9 +40,9 @@ const daemon = Effect.gen(function* () {
yield* pass.pipe(Effect.repeat(Schedule.fixed(SYNC_INTERVAL)))
}).pipe(Effect.forkScoped)
// A restarted daemon must not immediately re-run the expensive Athena pass; resume
// the hourly cadence from the last completed sync instead. This caps the Athena
// spend of a crash loop at one pass per interval.
// A restarted daemon must not immediately re-run the R2 SQL pass; resume the
// hourly cadence from the last completed sync instead. This caps the query spend
// of a crash loop at one pass per interval.
const initialDelay = Effect.fnUntraced(function* () {
const modelStats = yield* ModelStatRepo
const lastSynced = yield* modelStats.lastSyncedAt().pipe(Effect.catchCause(() => Effect.succeed(null)))
+3
View File
@@ -90,6 +90,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -178,6 +179,8 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -95,6 +95,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -95,6 +95,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -86,6 +86,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -174,6 +175,8 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+1 -1
View File
@@ -17,7 +17,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| Name | Description |
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| [opencode-daytona](https://github.com/daytonaio/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews |
| [opencode-daytona](https://github.com/daytona/integrations/tree/main/packages/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews |
| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
+3
View File
@@ -95,6 +95,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -86,6 +86,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -174,6 +175,8 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+1 -1
View File
@@ -97,7 +97,7 @@ Or you can set it up manually.
issues: write
```
You can also use a [personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)(PAT) if preferred.
You can also use a [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)(PAT) if preferred.
---
+3
View File
@@ -95,6 +95,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -86,6 +86,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -174,6 +175,8 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -86,6 +86,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -174,6 +175,8 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -95,6 +95,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -95,6 +95,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3 -3
View File
@@ -759,7 +759,7 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
### DigitalOcean
DigitalOcean's [Inference Engine](https://docs.digitalocean.com/products/inference/) provides access to open models like GPT-OSS, Llama, Qwen, and DeepSeek, plus custom [Inference Routers](https://docs.digitalocean.com/products/genai-platform/concepts/inference-routers/) that route each request to the cheapest, fastest, or best-fit model for a task.
DigitalOcean's [Inference Engine](https://docs.digitalocean.com/products/inference/) provides access to open models like GPT-OSS, Llama, Qwen, and DeepSeek, plus custom [Inference Routers](https://docs.digitalocean.com/products/inference/how-to/use-inference-router/) that route each request to the cheapest, fastest, or best-fit model for a task.
OpenCode supports two authentication methods:
@@ -2487,7 +2487,7 @@ You can use any OpenAI-compatible provider with opencode. Most modern AI provide
"provider": {
"myprovider": {
"npm": "@ai-sdk/openai-compatible",
"name": "My AI ProviderDisplay Name",
"name": "My AI Provider Display Name",
"options": {
"baseURL": "https://api.myprovider.com/v1"
},
@@ -2525,7 +2525,7 @@ Here's an example setting the `apiKey`, `headers`, and model `limit` options.
"provider": {
"myprovider": {
"npm": "@ai-sdk/openai-compatible",
"name": "My AI ProviderDisplay Name",
"name": "My AI Provider Display Name",
"options": {
"baseURL": "https://api.myprovider.com/v1",
"apiKey": "{env:ANTHROPIC_API_KEY}",
@@ -86,6 +86,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -174,6 +175,8 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -95,6 +95,7 @@ OpenCode Zen работает как любой другой провайдер
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -88,6 +88,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -176,6 +177,8 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -86,6 +86,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -174,6 +175,8 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
+3
View File
@@ -95,6 +95,7 @@ You can also access our models through the following API endpoints.
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -185,6 +186,8 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
@@ -86,6 +86,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -174,6 +175,8 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
@@ -90,6 +90,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -179,6 +180,8 @@ https://opencode.ai/zen/v1/models
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - |
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |