Compare commits

..

1 Commits

Author SHA1 Message Date
Hona 9876d7aa7c refactor(i18n): centralize locale-aware rendering 2026-08-09 17:13:17 +00:00
198 changed files with 2072 additions and 3531 deletions
-1
View File
@@ -10,7 +10,6 @@ kitlangton
kommander
ludvigrask
MrMushrooooom
neriousy
nexxeln
R44VC0RP
rekram1-node
-1
View File
@@ -35,7 +35,6 @@ jobs:
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const
+28 -29
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.18",
"version": "1.18.15",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.18",
"version": "1.18.15",
"bin": {
"opencode": "./bin/opencode",
},
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -435,7 +435,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.18",
"version": "1.18.15",
"bin": {
"opencode": "./bin/opencode",
},
@@ -690,7 +690,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -766,7 +766,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,7 +796,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -836,7 +836,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -849,7 +849,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -883,7 +883,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -902,7 +902,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -944,7 +944,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -971,7 +971,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1022,7 +1022,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1075,7 +1075,6 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
+1 -13
View File
@@ -181,16 +181,6 @@ 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",
@@ -203,9 +193,7 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
// 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],
link: [database, inferenceEvent, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-TNwKfqxD83UpZuCKN8FdEWN+CcQUP9CkCQSLGNqR/sA=",
"aarch64-linux": "sha256-qzvOJZzmq2QhlauElw8GwgQnCPHdhexI52L0md5zrxQ=",
"aarch64-darwin": "sha256-ZzoyLayOFfcYUAg35ZbZ2WapxDdd9IUWqy2xkxZH4QM=",
"x86_64-darwin": "sha256-maP/qLeaC3q8VcmNIPyIKlnplxFXJ7ULho3v21/16Mw="
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
}
}
+1 -2
View File
@@ -159,7 +159,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch"
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.18",
"version": "1.18.15",
"description": "",
"type": "module",
"exports": {
@@ -103,7 +103,6 @@ describe("normalizeProviderList", () => {
)
expect(result.connected).toEqual(["openai"])
expect(result.defaultModel).toEqual({ providerID: "openai", modelID: "gpt-5" })
expect(result.default).toEqual({ openai: "gpt-5" })
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
@@ -114,10 +113,6 @@ describe("normalizeProviderList", () => {
variants: { high: {} },
})
})
test("preserves an empty current default", () => {
expect(normalizeProviderList([] as ProviderListOutput["data"], [], null).defaultModel).toBeNull()
})
})
describe("directoryKey", () => {
@@ -139,7 +139,6 @@ export function normalizeProviderList(
return {
all,
connected: providers.map((provider) => provider.id),
defaultModel: defaultModel ? { providerID: defaultModel.providerID, modelID: defaultModel.id } : null,
default: Object.fromEntries(
providers.flatMap((provider) => {
const model =
+4 -3
View File
@@ -6,7 +6,6 @@ import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { resolveDefaultModel } from "@/hooks/provider-catalog"
import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
@@ -150,8 +149,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const configuredModel = () => {
const model = resolveDefaultModel(providers.defaultModel(), sync().data.config.model)
if (!model) return
const configured = sync().data.config.model
if (!configured) return
const [providerID, modelID] = configured.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model
}
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { resolveDefaultModel, selectProviderCatalog } from "./provider-catalog"
import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): NormalizedProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
@@ -57,21 +57,3 @@ test("falls back to the global catalog for route consumers", () => {
}),
).toBe(global)
})
test("uses the current server default model", () => {
expect(resolveDefaultModel({ providerID: "openai", modelID: "gpt-5" }, "anthropic/claude")).toEqual({
providerID: "openai",
modelID: "gpt-5",
})
})
test("does not use legacy config when the current server has no default", () => {
expect(resolveDefaultModel(null, "anthropic/claude")).toBeUndefined()
})
test("uses config for legacy servers", () => {
expect(resolveDefaultModel(undefined, "anthropic/claude")).toEqual({
providerID: "anthropic",
modelID: "claude",
})
})
@@ -25,13 +25,3 @@ export function selectProviderCatalog(input: ProviderCatalogInput) {
if (input.explicit) return emptyProviderCatalog
return input.global
}
export function resolveDefaultModel(
current: NormalizedProviderListResponse["defaultModel"],
legacy: string | undefined,
) {
if (current !== undefined) return current ?? undefined
if (!legacy) return undefined
const [providerID, modelID] = legacy.split("/")
return { providerID, modelID }
}
-1
View File
@@ -41,7 +41,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
return {
all: () => providers().all,
default: () => providers().default,
defaultModel: () => providers().defaultModel,
popular: () =>
pipe(
providers().all,
+17
View File
@@ -167,6 +167,23 @@ describe("i18n parity", () => {
}
}
})
test("UI rich templates keep exactly one typed slot", async () => {
const expected = {
"ui.lineComment.label": "selection",
"ui.lineComment.editorLabel": "selection",
"ui.list.emptyWithFilter": "query",
} as const
const legacy = Object.keys(expected).flatMap((key) => [`${key}.prefix`, `${key}.suffix`])
for (const locale of ["en", ...appLocales]) {
const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`)
for (const [key, slot] of Object.entries(expected)) {
expect({ locale, key, placeholders: placeholders(target[key]) }).toEqual({ locale, key, placeholders: [slot] })
}
expect({ locale, legacy: legacy.filter((key) => Object.hasOwn(target, key)) }).toEqual({ locale, legacy: [] })
}
})
})
describe("i18n plural parity", () => {
+7 -7
View File
@@ -583,7 +583,7 @@ export const dict = {
"dialog.usageExceeded.dontShowAgain": "不再显示",
"context.breakdown.title": "上下文细分",
"context.breakdown.note": "输入 token 的大致细分。“其他”包含工具定义和开销。",
"context.breakdown.note": "输入词元的大致细分。“其他”包含工具定义和开销。",
"context.breakdown.system": "系统",
"context.breakdown.user": "用户",
"context.breakdown.assistant": "助手",
@@ -598,18 +598,18 @@ export const dict = {
"context.stats.provider": "提供商",
"context.stats.model": "模型",
"context.stats.limit": "上下文限制",
"context.stats.totalTokens": "总 token",
"context.stats.totalTokens": "词元总数",
"context.stats.usage": "使用率",
"context.stats.inputTokens": "输入 token",
"context.stats.outputTokens": "输出 token",
"context.stats.reasoningTokens": "推理 token",
"context.stats.cacheTokens": "缓存 token(读/写)",
"context.stats.inputTokens": "输入词元",
"context.stats.outputTokens": "输出词元",
"context.stats.reasoningTokens": "推理词元",
"context.stats.cacheTokens": "缓存词元(读/写)",
"context.stats.userMessages": "用户消息",
"context.stats.assistantMessages": "助手消息",
"context.stats.totalCost": "总成本",
"context.stats.sessionCreated": "创建时间",
"context.stats.lastActivity": "最后活动",
"context.usage.tokens": "Token",
"context.usage.tokens": "词元数",
"context.usage.usage": "使用率",
"context.usage.cost": "成本",
"context.usage.clickToView": "点击查看上下文",
@@ -6,7 +6,6 @@ import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useProviders } from "@/hooks/use-providers"
import { resolveDefaultModel } from "@/hooks/provider-catalog"
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
const sdk = useSDK()
@@ -22,8 +21,10 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
}
const configured = () => {
const model = resolveDefaultModel(providers.defaultModel(), sync().data.config.model)
if (!model) return
const value = sync().data.config.model
if (!value) return
const [providerID, modelID] = value.split("/")
const model = { providerID, modelID }
if (valid(model)) return model
}
@@ -37,8 +37,7 @@ export function useUsageExceededDialogs() {
const sdk = useSDK()
const dialog = useDialog()
const { params } = useSessionLayout()
const { t, locale } = useI18n()
const isEnglish = () => locale() === "en"
const { tDynamic } = useI18n()
const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
@@ -68,9 +67,9 @@ export function useUsageExceededDialogs() {
if (action.reason === "free_tier_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
title={tDynamic("dialog.usageExceeded.freeTier.title", action.title)}
description={tDynamic("dialog.usageExceeded.freeTier.description", action.message)}
actionLabel={tDynamic("dialog.usageExceeded.freeTier.actionLabel", action.label)}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
@@ -88,9 +87,9 @@ export function useUsageExceededDialogs() {
} else if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.accountRateLimit.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.accountRateLimit.actionLabel")}
title={tDynamic("dialog.usageExceeded.accountRateLimit.title", action.title)}
description={tDynamic("dialog.usageExceeded.accountRateLimit.description", action.message)}
actionLabel={tDynamic("dialog.usageExceeded.accountRateLimit.actionLabel", action.label)}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.18",
"version": "1.18.15",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"scripts": {
+1 -2
View File
@@ -373,8 +373,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
"zen.api.error.missingApiKey": "Missing API key.",
"zen.api.error.invalidApiKey": "Invalid API key.",
"zen.api.error.requestBlockedByUpstreamProvider":
"This account has found to be committing fraud or is in breach of terms of services and has been blocked. If you believe this is a mistake please send an email to help@anoma.ly.",
"zen.api.error.requestBlockedByUpstreamProvider": "Request blocked by upstream provider.",
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
@@ -1,158 +0,0 @@
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
export async function GET(input: APIEvent) {
const apiKey = input.request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1]
if (!apiKey) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "AuthError",
message: "Missing API key.",
},
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
},
},
)
}
const auth = await Database.use((tx) =>
tx
.select({
userID: KeyTable.userID,
workspaceID: KeyTable.workspaceID,
})
.from(KeyTable)
.innerJoin(
UserTable,
and(
eq(UserTable.workspaceID, KeyTable.workspaceID),
eq(UserTable.id, KeyTable.userID),
isNull(UserTable.timeDeleted),
),
)
.innerJoin(WorkspaceTable, and(eq(WorkspaceTable.id, KeyTable.workspaceID), isNull(WorkspaceTable.timeDeleted)))
.innerJoin(
BillingTable,
and(eq(BillingTable.workspaceID, KeyTable.workspaceID), isNull(BillingTable.timeDeleted)),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
if (!auth) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "AuthError",
message: "Unauthorized",
},
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
},
},
)
}
const row = await Database.use((tx) =>
tx
.select({
timeCreated: LiteTable.timeCreated,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
monthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
})
.from(LiteTable)
.where(
and(
eq(LiteTable.workspaceID, auth.workspaceID),
eq(LiteTable.userID, auth.userID),
isNull(LiteTable.timeDeleted),
),
)
.then((rows) => rows[0]),
)
if (!row) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "EntitlementError",
message: "OpenCode Go subscription required.",
},
}),
{
status: 403,
headers: {
"Content-Type": "application/json",
},
},
)
}
const limits = LiteData.getLimits()
return new Response(
JSON.stringify({
usage: {
rolling: formatUsage(
Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
),
weekly: formatUsage(
Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
),
monthly: formatUsage(
Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
),
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
)
}
function formatUsage(usage: { status: "ok" | "rate-limited"; resetInSec: number; usagePercent: number }) {
return {
status: usage.status,
percent: usage.usagePercent,
resetsAt: new Date(Date.now() + usage.resetInSec * 1000).toISOString(),
}
}
@@ -139,7 +139,7 @@ export async function handler(
if (
authInfo &&
opts.modelList === "lite" &&
["deepseek-v4-flash", "deepseek-v4-pro"].includes(modelInfo.id) &&
modelInfo.id === "deepseek-v4-flash" &&
!allowedRegions?.includes("cn")
)
throw new RegionError(
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.18",
"version": "1.18.15",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.18",
"version": "1.18.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.18",
"version": "1.18.15",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+6 -2
View File
@@ -30,11 +30,15 @@ Guidelines:
Complete the user's search request efficiently and report your findings clearly.`
const PROMPT_COMPACTION = `You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.`
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
@@ -40,7 +40,7 @@ already-loaded config until then.
| Scope | Path |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
| Global config | `~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc` (NOT `~/.opencode/`) |
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
+1 -4
View File
@@ -264,10 +264,7 @@ const layer = Layer.effect(
}),
line: match.line_number,
offset: match.absolute_offset,
text:
match.lines.text.length > 2_000
? match.lines.text.slice(0, 2_000).replace(/[\uD800-\uDBFF]$/, "") + "..."
: match.lines.text,
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
submatches: match.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
+19 -37
View File
@@ -44,15 +44,6 @@ Rules:
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
const SUMMARY_UPDATE_INSTRUCTIONS = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
When combining:
- Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
- The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
- Add new progress, decisions, constraints, and context from the conversation.
- Move completed work from "Active" to "Completed".
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
- Update "Objective" and "Next Move" to reflect the current work state.`
type Entry = {
readonly seq: number
@@ -145,45 +136,36 @@ const select = (
if (conversation.length === 0) return
let total = 0
let split = conversation.length
let splitPrefix = ""
let splitSuffix = ""
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index])
if (next > tokens) break
if (next > tokens) {
const remaining = Math.max(0, tokens - total) * 4
if (remaining > 0) {
splitPrefix = conversation[index].slice(0, -remaining)
splitSuffix = conversation[index].slice(-remaining)
split = index + 1
}
break
}
total = next
split = index
}
return {
head: conversation.slice(0, split).join("\n\n"),
recent: conversation.slice(split).join("\n\n"),
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
}
}
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => {
const conversation = `Here is the conversation so far:\n\n<conversation>\n${input.context.join("\n\n")}\n</conversation>`
if (!input.previousSummary)
return [
conversation,
"Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.",
SUMMARY_TEMPLATE,
].join("\n\n")
return [
conversation,
`Here is the summary of the conversation before the <conversation> above:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
SUMMARY_UPDATE_INSTRUCTIONS,
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
...input.context,
].join("\n\n")
}
export const buildReplayPrompt = (previousSummary?: string) => {
const instruction =
"Create a new anchored summary from the conversation messages above so another coding agent can continue the work."
if (!previousSummary) return [instruction, SUMMARY_TEMPLATE].join("\n\n")
return [
instruction,
`Here is the summary of the conversation before the messages above:\n\n<prior-summary>\n${previousSummary}\n</prior-summary>`,
SUMMARY_UPDATE_INSTRUCTIONS,
SUMMARY_TEMPLATE,
].join("\n\n")
}
export const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config)
+1 -1
View File
@@ -156,7 +156,7 @@ export const Info = Schema.Struct({
}),
tail_turns: Schema.optional(NonNegativeInt).annotate({
description:
"Maximum number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction. By default retention is limited only by the preserved token budget.",
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
}),
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
-28
View File
@@ -1,28 +0,0 @@
import { createGroq } from "@ai-sdk/groq"
import { expect, test } from "bun:test"
test("Groq passes through unknown reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created: 0,
model: "openai/gpt-oss-120b",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createGroq({ apiKey: "test", fetch: mockFetch })("openai/gpt-oss-120b")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { groq: { reasoningEffort: "custom" } },
})
expect(body?.reasoning_effort).toBe("custom")
})
@@ -27,32 +27,6 @@ test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
expect(body?.prompt_cache_key).toBe("session-123")
})
test("Mistral passes through unknown reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created: 0,
model: "mistral-large-latest",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { mistral: { reasoningEffort: "custom" } },
})
expect(body?.reasoning_effort).toBe("custom")
})
test("Mistral round-trips native reasoning in assistant history", async () => {
let body: { messages?: unknown[] } | undefined
const mockFetch = Object.assign(
@@ -30,56 +30,3 @@ test("xAI Responses sends promptCacheKey as prompt_cache_key", async () => {
expect(body?.prompt_cache_key).toBe("session-123")
})
test("xAI Responses passes through xhigh reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created_at: 0,
model: "grok-4",
object: "response",
output: [],
usage: { input_tokens: 1, output_tokens: 0 },
status: "completed",
})
},
{ preconnect: fetch.preconnect },
)
const model = createXai({ apiKey: "test", fetch: mockFetch }).responses("grok-4")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { xai: { reasoningEffort: "xhigh" } },
})
expect(body?.reasoning).toEqual({ effort: "xhigh" })
})
test("xAI Chat passes through xhigh reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "chat-1",
created: 0,
model: "grok-4",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createXai({ apiKey: "test", fetch: mockFetch }).chat("grok-4")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { xai: { reasoningEffort: "xhigh" } },
})
expect(body?.reasoning_effort).toBe("xhigh")
})
-20
View File
@@ -62,24 +62,4 @@ describe("Ripgrep", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("does not split surrogate pairs in oversized line previews", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(path.join(tmp.path, "unicode.txt"), `needle${"x".repeat(1_993)}😀\n`),
)
const matches = yield* (yield* Ripgrep.Service).grep({
cwd: tmp.path,
pattern: "needle",
limit: 10,
})
expect(matches[0]?.text).toBe(`needle${"x".repeat(1_993)}...`)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})
@@ -4,32 +4,12 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
expect(prompt).toStartWith(
"Here is the conversation so far:\n\n<conversation>\nconversation history\n</conversation>",
)
expect(prompt.indexOf("</conversation>")).toBeLessThan(prompt.indexOf("Create a new anchored summary"))
expect(prompt).toContain("conversation history in the <conversation> tags above")
expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active")
expect(prompt).toContain("### Blocked")
expect(prompt).toContain("## Relevant Files")
})
test("compaction prompt gives update instructions for a prior summary", () => {
const prompt = SessionCompaction.buildPrompt({
context: ["new conversation"],
previousSummary: "existing summary",
})
expect(prompt.indexOf("<conversation>")).toBeLessThan(prompt.indexOf("<prior-summary>"))
expect(prompt.indexOf("</prior-summary>")).toBeLessThan(prompt.indexOf("The <prior-summary> summarizes"))
expect(prompt).toContain(
"Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary>",
)
expect(prompt).toContain('Move completed work from "Active" to "Completed".')
expect(prompt).toContain('Update "Objective" and "Next Move" to reflect the current work state.')
})
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
+1 -62
View File
@@ -1135,7 +1135,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<prior-summary>\n## Objective\n- Preserve the task\n</prior-summary>",
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
@@ -1145,67 +1145,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retains only complete serialized messages during compaction", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const earlier = `EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`
const recent = `RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: earlier }), resume: false })
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: recent }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const summary = userTexts(requests[0])[0]
const continuation = userTexts(requests[1])[0]
expect(summary.match(/EARLIER_BOUNDARY/g)).toHaveLength(1)
expect(summary).toContain(`EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`)
expect(summary).not.toContain("RECENT_BOUNDARY")
expect(continuation).not.toContain("EARLIER_BOUNDARY")
expect(continuation).not.toContain("EARLIER_END")
expect(continuation).toContain("<recent-context>\n[Assistant]: Earlier answer")
expect(continuation).toContain(`RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`)
}),
)
it.effect("summarizes an oversized newest message without retaining a fragment", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Earlier question" }), resume: false })
yield* session.resume(sessionID)
const oversized = `OVERSIZED_BOUNDARY ${"x".repeat(4_500)} OVERSIZED_END`
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: oversized }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const summary = userTexts(requests[0])[0]
const continuation = userTexts(requests[1])[0]
expect(summary.match(/OVERSIZED_BOUNDARY/g)).toHaveLength(1)
expect(summary).toContain(oversized)
expect(continuation).not.toContain("OVERSIZED_BOUNDARY")
expect(continuation).not.toContain("OVERSIZED_END")
expect(continuation).toContain("<recent-context>\n\n</recent-context>")
}),
)
it.effect("forces one compaction and retries after provider context overflow", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.18",
"version": "1.18.15",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.18",
"version": "1.18.15",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.18",
"version": "1.18.15",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.18",
"version": "1.18.15",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.18",
"version": "1.18.15",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.18",
"version": "1.18.15",
"name": "opencode",
"type": "module",
"license": "MIT",
+15 -20
View File
@@ -216,6 +216,21 @@ const layer = Layer.effect(
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
@@ -255,26 +270,6 @@ const layer = Layer.effect(
continue
}
let item = agents[key]
// The compaction agent is not registered by default: compaction inherits
// the session's active agent so its request stays prefix-identical for
// prompt caching. An explicit `agent.compaction` config opts into the
// legacy dedicated agent, seeded here so overrides merge as before.
if (!item && key === "compaction")
item = agents[key] = {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
}
if (!item)
item = agents[key] = {
name: key,
@@ -1,5 +1,9 @@
You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
You are an anchored context summarization assistant for coding sessions.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.
@@ -88,9 +88,6 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const pdf =
(remote.capabilities.supports.vision ?? false) &&
(remote.capabilities.limits.vision?.supported_media_types?.includes("application/pdf") ?? false)
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
const endpoint: CopilotEndpoint | undefined = isMsgApi
@@ -130,7 +127,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
audio: false,
image,
video: false,
pdf,
pdf: false,
},
output: {
text: true,
@@ -84,8 +84,6 @@ 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
@@ -553,12 +551,6 @@ export function topP(model: Provider.Model) {
if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
return 0.95
}
if (
["deepseek-v4-flash-0731", "deepseek-v4-flash:0731"].some((name) => id.includes(name)) ||
(id.includes("deepseek-v4-flash") && (model.providerID === "deepseek" || model.providerID.startsWith("opencode")))
) {
return 0.95
}
return undefined
}
@@ -1774,7 +1766,6 @@ 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":
+40 -71
View File
@@ -20,7 +20,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { buildPrompt, buildReplayPrompt } from "@opencode-ai/core/session/compaction"
import { buildPrompt } from "@opencode-ai/core/session/compaction"
import { SessionCompactionEvent } from "@opencode-ai/schema/session-compaction-event"
export const Event = SessionCompactionEvent
@@ -29,8 +29,9 @@ export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
const DEFAULT_TAIL_TURNS = 2
const MIN_PRESERVE_RECENT_TOKENS = 2_000
const MAX_PRESERVE_RECENT_TOKENS = 15_000
const MAX_PRESERVE_RECENT_TOKENS = 8_000
type Turn = {
start: number
end: number
@@ -162,15 +163,6 @@ function splitTurn(input: {
})
}
export type Run = (input: {
user: SessionV1.User
agent: Agent.Info
model: Provider.Model
processor: SessionProcessor.Handle
messages: SessionV1.WithParts[]
prompt: string
}) => Effect.Effect<SessionProcessor.Result>
export interface Interface {
readonly isOverflow: (input: {
tokens: SessionV1.Assistant["tokens"]
@@ -183,7 +175,6 @@ export interface Interface {
sessionID: SessionID
auto: boolean
overflow?: boolean
run?: Run
}) => Effect.Effect<"continue" | "stop">
readonly create: (input: {
sessionID: SessionID
@@ -235,22 +226,27 @@ const layer = Layer.effect(
cfg: ConfigV1.Info
model: Provider.Model
}) {
const limit = input.cfg.compaction?.tail_turns
if (limit !== undefined && limit <= 0) return { head: input.messages, tail_start_id: undefined }
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
const all = turns(input.messages)
if (!all.length) return { head: input.messages, tail_start_id: undefined }
const recent = limit === undefined ? all : all.slice(-limit)
const recent = all.slice(-limit)
const sizes = yield* Effect.forEach(
recent,
(turn) =>
estimate({
messages: input.messages.slice(turn.start, turn.end),
model: input.model,
}),
{ concurrency: 1 },
)
let total = 0
let keep: Tail | undefined
for (let i = recent.length - 1; i >= 0; i--) {
const turn = recent[i]!
// estimate lazily so cost stays proportional to the retained tail, not the whole session
const size = yield* estimate({
messages: input.messages.slice(turn.start, turn.end),
model: input.model,
})
const size = sizes[i]
if (total + size <= budget) {
total += size
keep = { start: turn.start, id: turn.id }
@@ -332,7 +328,6 @@ const layer = Layer.effect(
sessionID: SessionID
auto: boolean
overflow?: boolean
run?: Run
}) {
const parent = input.messages.findLast((m) => m.info.id === input.parentID)
if (!parent || parent.info.role !== "user") {
@@ -366,13 +361,11 @@ const layer = Layer.effect(
}
}
const agent = yield* agents.get("compaction")
const model = agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
const cfg = yield* config.get()
const configured = cfg.agent?.compaction !== undefined
const agent = yield* agents.get(configured ? "compaction" : userMessage.agent)
const model =
configured && agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
const prior = completedCompactions(history)
const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
@@ -388,18 +381,10 @@ const layer = Layer.effect(
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// A configured compaction agent keeps the legacy serialized-transcript path.
const run = configured ? undefined : input.run
const conversation = run ? "" : msgs.map(serialize).filter(Boolean).join("\n\n")
const nextPrompt = [
compacting.prompt ??
(run ? buildReplayPrompt(previousSummary) : buildPrompt({ previousSummary, context: [conversation] })),
...compacting.context,
]
.filter(Boolean)
.join("\n\n")
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
const ctx = yield* InstanceState.context
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
@@ -433,43 +418,27 @@ const layer = Layer.effect(
sessionID: input.sessionID,
model,
})
// The synthetic compaction marker carries no per-turn system/tools settings.
const active = msgs.findLast(
(message) => message.info.role === "user" && !message.parts.some((part) => part.type === "compaction"),
)
const result = run
? yield* run({
user: active?.info.role === "user" ? active.info : userMessage,
agent,
model,
processor,
messages: msgs,
prompt: nextPrompt,
})
: yield* processor.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
const result = yield* processor.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
{
role: "user",
content: [
{
role: "user",
content: [
{
type: "text",
text: [
nextPrompt,
...(compacting.prompt ? ["The following is the conversation history:", conversation] : []),
]
.filter(Boolean)
.join("\n\n"),
},
],
type: "text",
text: [nextPrompt, "The following is the conversation history:", conversation]
.filter(Boolean)
.join("\n\n"),
},
],
model,
})
},
],
model,
})
if (result === "compact") {
processor.message.error = new SessionV1.ContextOverflowError({
+46 -66
View File
@@ -149,45 +149,6 @@ const layer = Layer.effect(
} satisfies TaskPromptOps
})
// One assembly path keeps compaction requests prefix-identical to normal turns for prompt caching.
const prepare = Effect.fn("SessionPrompt.prepare")(function* (input: {
session: Session.Info
agent: Agent.Info
model: Provider.Model
processor: SessionProcessor.Handle
messages: SessionV1.WithParts[]
}) {
const lastUserMsg = input.messages.findLast((message) => message.info.role === "user")
const tools = yield* SessionTools.resolve({
agent: input.agent,
session: input.session,
model: input.model,
processor: input.processor,
bypassAgentCheck: lastUserMsg?.parts.some((part) => part.type === "agent") ?? false,
messages: input.messages,
promptOps: yield* ops(),
}).pipe(
Effect.provideService(Plugin.Service, plugin),
Effect.provideService(Permission.Service, permission),
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(RuntimeFlags.Service, flags),
)
const [skills, env, instructions, mcpInstructions, modelMessages] = yield* Effect.all([
sys.skills(input.agent),
sys.environment(input.model),
instruction.system().pipe(Effect.orDie),
sys.mcp(input.agent, input.session.permission),
MessageV2.toModelMessagesEffect(input.messages, input.model),
])
return {
tools,
modelMessages,
system: [...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : [])],
}
})
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
yield* Effect.logInfo("cancel", { "session.id": sessionID })
yield* state.cancel(sessionID)
@@ -1192,21 +1153,6 @@ const layer = Layer.effect(
sessionID,
auto: task.auto,
overflow: task.overflow,
run: ({ user, agent, model, processor, messages, prompt }) =>
Effect.gen(function* () {
const prepared = yield* prepare({ session, agent, model, processor, messages })
return yield* processor.process({
user,
agent,
permission: session.permission,
sessionID,
parentSessionID: session.parentID,
system: prepared.system,
messages: [...prepared.modelMessages, { role: "user", content: prompt }],
tools: prepared.tools,
model,
})
}),
})
if (result === "stop") break
continue
@@ -1273,34 +1219,68 @@ const layer = Layer.effect(
.pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
if (step === 1)
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const promptOps = yield* ops()
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const tools = yield* SessionTools.resolve({
agent,
session,
model,
processor: handle,
bypassAgentCheck,
messages: msgs,
promptOps,
}).pipe(
Effect.provideService(Plugin.Service, plugin),
Effect.provideService(Permission.Service, permission),
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(RuntimeFlags.Service, flags),
)
const prepared = yield* prepare({ session, agent, model, processor: handle, messages: msgs })
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") {
prepared.system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
prepared.tools["StructuredOutput"] = createStructuredOutputTool({
schema: format.schema,
if (lastUser.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({
schema: lastUser.format.schema,
onSuccess(output) {
structured = output
},
})
}
if (step === 1)
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
instruction.system().pipe(Effect.orDie),
sys.mcp(agent, session.permission),
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [
...env,
...instructions,
...(mcpInstructions ? [mcpInstructions] : []),
...(skills ? [skills] : []),
]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
const result = yield* handle.process({
user: lastUser,
agent,
permission: session.permission,
sessionID,
parentSessionID: session.parentID,
system: prepared.system,
system,
messages: [
...prepared.modelMessages,
...modelMsgs,
...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []),
],
tools: prepared.tools,
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
})
@@ -1,4 +1,4 @@
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by {{MODEL_NAME}}, a large language model trained by Meta MSL.
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by Muse Spark, a large language model trained by Meta MSL.
Use the instructions below and the tools available to assist the user.
@@ -61,5 +61,5 @@ Use the instructions below and the tools available to assist the user.
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
# User Help & Feedback
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta {{MODEL_NAME}}.
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta Muse Spark.
- When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from the OpenCode docs at https://opencode.ai/docs.
+3 -11
View File
@@ -25,10 +25,8 @@ export type Retryable = {
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_JITTER_FACTOR = 0.25
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export const RETRY_MAX_RETRIES = 5
const RETRYABLE_MESSAGE_PATTERNS = [
/429|500|502|503|504|524/i,
@@ -43,7 +41,7 @@ function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
export function delay(attempt: number, error?: SessionV1.APIError, random = Math.random()) {
export function delay(attempt: number, error?: SessionV1.APIError) {
if (error) {
const headers = error.data.responseHeaders
if (headers) {
@@ -69,16 +67,11 @@ export function delay(attempt: number, error?: SessionV1.APIError, random = Math
}
}
return cap(exponential(attempt, random))
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
}
}
return cap(Math.min(exponential(attempt, random), RETRY_MAX_DELAY_NO_HEADERS))
}
function exponential(attempt: number, random: number) {
const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
return Math.ceil(base + base * RETRY_JITTER_FACTOR * random)
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
}
export function retryable(error: Err, provider: string) {
@@ -189,7 +182,6 @@ export function policy(opts: {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
+2 -9
View File
@@ -25,10 +25,7 @@ import { MCP } from "@/mcp"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
export function provider(model: Provider.Model) {
if (model.api.id.includes("muse")) {
const name = model.api.id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
return [PROMPT_META.replaceAll("{{MODEL_NAME}}", name)]
}
if (model.api.id.includes("muse-spark")) return [PROMPT_META]
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) {
@@ -40,11 +37,7 @@ export function provider(model: Provider.Model) {
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
if (
model.api.id.toLowerCase().includes("kimi") ||
["kimi-for-coding", "moonshotai", "moonshotai-cn"].includes(model.providerID)
)
return [PROMPT_KIMI]
if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
return [PROMPT_DEFAULT]
}
+7 -27
View File
@@ -52,7 +52,7 @@ it.instance("returns default native agents when no config", () =>
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).not.toContain("compaction")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
}),
@@ -170,34 +170,17 @@ it.instance("general agent denies todo tools", () =>
}),
)
it.instance("compaction agent is absent without configuration", () =>
it.instance("compaction agent denies all permissions", () =>
Effect.gen(function* () {
const compaction = yield* load((svc) => svc.get("compaction"))
expect(compaction).toBeUndefined()
expect(compaction).toBeDefined()
expect(compaction?.hidden).toBe(true)
expect(evalPerm(compaction, "bash")).toBe("deny")
expect(evalPerm(compaction, "edit")).toBe("deny")
expect(evalPerm(compaction, "read")).toBe("deny")
}),
)
it.instance(
"configured compaction agent keeps native defaults",
() =>
Effect.gen(function* () {
const compaction = yield* load((svc) => svc.get("compaction"))
expect(compaction).toBeDefined()
expect(compaction?.native).toBe(true)
expect(compaction?.hidden).toBe(true)
expect(evalPerm(compaction, "bash")).toBe("deny")
expect(evalPerm(compaction, "edit")).toBe("deny")
expect(evalPerm(compaction, "read")).toBe("deny")
}),
{
config: {
agent: {
compaction: {},
},
},
},
)
it.instance(
"custom agent from config creates new agent",
() =>
@@ -727,9 +710,6 @@ it.instance(
{
config: {
default_agent: "compaction",
agent: {
compaction: {},
},
},
},
)
@@ -187,74 +187,6 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
expect(models["ignored-non-chat-record"]).toBeUndefined()
})
test("detects PDF input support when vision and media type are advertised", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "pdf-model",
name: "PDF Model",
version: "pdf-model-2026-06-01",
capabilities: {
family: "pdf-model",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["application/pdf"],
},
},
supports: {
streaming: true,
vision: true,
tool_calls: true,
},
},
},
{
model_picker_enabled: true,
id: "vision-only-model",
name: "Vision Only Model",
version: "vision-only-model-2026-06-01",
capabilities: {
family: "vision-only-model",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png"],
},
},
supports: {
streaming: true,
vision: true,
tool_calls: true,
},
},
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch
const models = (await CopilotModels.get("https://api.githubcopilot.com")).models
const model = models["pdf-model"]
expect(model.capabilities.input.pdf).toBe(true)
expect(models["vision-only-model"].capabilities.input.pdf).toBe(false)
})
test("uses zero cost when Copilot reports a zero billing batch size", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
@@ -1548,33 +1548,6 @@ 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",
@@ -3272,37 +3272,6 @@ describe("ProviderTransform sampling defaults - Gemini", () => {
})
})
describe("ProviderTransform sampling defaults - DeepSeek", () => {
const model = (providerID: string, id: string) =>
({
id: `${providerID}/${id}`,
providerID,
api: { id },
}) as any
test.each([
["deepseek", "deepseek-v4-flash"],
["opencode", "deepseek-v4-flash"],
["opencode-go", "deepseek-v4-flash"],
["openrouter", "deepseek/deepseek-v4-flash-0731"],
["ollama-cloud", "deepseek-v4-flash:0731"],
])("defaults top_p for %s/%s", (providerID, id) => {
expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topP(model(providerID, id))).toBe(0.95)
expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined()
})
test.each([
["openrouter", "deepseek/deepseek-v4-flash"],
["vercel", "deepseek/deepseek-v4-flash"],
["custom", "deepseek-ai/DeepSeek-V4-Flash"],
])("preserves legacy defaults for %s/%s", (providerID, id) => {
expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topP(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined()
})
})
describe("ProviderTransform.reasoningVariants", () => {
const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model
const target = (npm: string, id = "test-model") =>
@@ -3370,7 +3339,6 @@ 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
@@ -5556,25 +5524,6 @@ 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> = {}) =>
({
@@ -222,11 +222,6 @@ function cfg(compaction?: ConfigV1.Info["compaction"]) {
return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed({ ...base, compaction }) }))
}
function cfgAgent(agent: NonNullable<ConfigV1.Info["agent"]>) {
const config = Schema.decodeUnknownSync(ConfigV1.Info)({ agent }) as ConfigV1.Info
return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed(config) }))
}
const defaultProvider = wide()
const compactionTestNode = LayerNode.group([
SessionCompaction.node,
@@ -370,20 +365,6 @@ function autocontinue(enabled: boolean) {
})
}
function compactionContext(context: string) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.session.compacting") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { context: string[] }).context.push(context)
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
describe("session.compaction.isOverflow", () => {
it.live(
"returns true when token count exceeds usable context",
@@ -817,75 +798,6 @@ describe("session.compaction.prune", () => {
})
describe("session.compaction.process", () => {
itCompaction.instance(
"inherits the last agent when compaction is not configured",
() => {
const stub = llm()
let captured: LLM.StreamInput | undefined
stub.push(reply("summary", (input) => (captured = input)))
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
const msg = yield* createUserMessage(session.id, "hello")
const msgs = yield* ssn.messages({ sessionID: session.id })
yield* SessionCompaction.use.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
auto: false,
})
expect(captured?.agent.name).toBe("build")
expect(captured?.agent.prompt).toBe("custom build prompt")
expect(captured?.agent.temperature).toBe(0.37)
expect(String(captured?.model.providerID)).toBe("test")
expect(String(captured?.model.id)).toBe("test-model")
}).pipe(
withCompaction({
llm: stub.llmLayer,
config: cfgAgent({
build: { prompt: "custom build prompt", temperature: 0.37, model: "missing/missing" },
}),
}),
)
},
{ git: true },
)
itCompaction.instance(
"preserves an explicitly configured compaction agent",
() => {
const stub = llm()
let captured: LLM.StreamInput | undefined
stub.push(reply("summary", (input) => (captured = input)))
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
const msg = yield* createUserMessage(session.id, "hello")
const msgs = yield* ssn.messages({ sessionID: session.id })
yield* SessionCompaction.use.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
auto: false,
})
expect(captured?.agent.name).toBe("compaction")
expect(captured?.agent.prompt).toBe("custom compaction prompt")
expect(captured?.agent.temperature).toBe(0.81)
expect(captured?.agent.hidden).toBe(true)
}).pipe(
withCompaction({
llm: stub.llmLayer,
config: cfgAgent({ compaction: { prompt: "custom compaction prompt", temperature: 0.81 } }),
}),
)
},
{ git: true },
)
it.instance(
"throws when parent is not a user message",
Effect.gen(function* () {
@@ -1477,21 +1389,11 @@ describe("session.compaction.process", () => {
const captured = JSON.stringify(messages)
expect(messages).toHaveLength(1)
expect(messages[0]?.role).toBe("user")
expect(captured).toContain("Here is the conversation so far:")
expect(captured).toContain("<conversation>")
expect(captured.indexOf("[User]: older context")).toBeLessThan(
captured.indexOf("Create a new anchored summary"),
)
expect(captured).toContain("[User]: older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?")
}).pipe(
withCompaction({
llm: stub.llmLayer,
config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }),
}),
)
}).pipe(withCompaction({ llm: stub.llmLayer }))
},
{ git: true },
)
@@ -1528,11 +1430,9 @@ describe("session.compaction.process", () => {
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
expect(captured).toContain("<prior-summary>")
expect(captured).toContain("<previous-summary>")
expect(captured).toContain("summary one")
expect(captured.match(/summary one/g)?.length).toBe(1)
expect(captured.indexOf("latest turn")).toBeLessThan(captured.indexOf("<prior-summary>"))
expect(captured).toContain("summary of the conversation before the <conversation> above")
expect(captured).toContain("## Important Details")
expect(captured).toContain("## Work State")
}).pipe(withCompaction({ llm: stub.llmLayer }))
@@ -1540,49 +1440,6 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance(
"keeps plugin context outside the serialized conversation",
() => {
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "older context")
yield* createUserMessage(session.id, "keep this turn")
yield* createUserMessage(session.id, "and this one too")
yield* createCompactionMarker(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
})
expect(captured).toContain("Prioritize unresolved migration details")
expect(captured.indexOf("</conversation>")).toBeLessThan(
captured.indexOf("Prioritize unresolved migration details"),
)
}).pipe(
withCompaction({
llm: stub.llmLayer,
plugin: compactionContext("Prioritize unresolved migration details"),
}),
)
},
{ git: true },
)
itCompaction.instance(
"serializes repeated compaction history as one user message",
() => {
@@ -444,34 +444,6 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
// Loop semantics
it.instance("default compaction reuses the normal system and tools", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const { prompt, chat } = yield* boot()
const compact = yield* SessionCompaction.Service
yield* user(chat.id, "cache prefix message")
yield* llm.text("normal response")
yield* prompt.loop({ sessionID: chat.id })
yield* compact.create({ sessionID: chat.id, agent: "build", model: ref, auto: false })
yield* llm.text("compacted summary")
yield* prompt.loop({ sessionID: chat.id })
const inputs = yield* llm.inputs
expect(inputs).toHaveLength(2)
expect(inputs[1]?.tools).toEqual(inputs[0]?.tools)
const normalMessages = Array.isArray(inputs[0]?.messages) ? inputs[0].messages : []
const compactMessages = Array.isArray(inputs[1]?.messages) ? inputs[1].messages : []
const system = (message: unknown) =>
typeof message === "object" && message !== null && "role" in message && message.role === "system"
expect(compactMessages.find(system)).toEqual(normalMessages.find(system))
expect(JSON.stringify(compactMessages)).toContain("cache prefix message")
expect(JSON.stringify(compactMessages)).toContain("normal response")
expect(JSON.stringify(compactMessages)).toContain("Create a new anchored summary")
expect(JSON.stringify(compactMessages)).not.toContain("[User]: cache prefix message")
}),
)
noLLMServer.instance(
"loop exits immediately when last assistant has stop finish",
() =>
+4 -35
View File
@@ -35,18 +35,10 @@ function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
describe("session.retry.delay", () => {
test("caps delay at 30 seconds when headers missing", () => {
const error = apiError()
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error, 0))
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
})
test("adds jitter to exponential delays", () => {
const error = apiError()
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
expect(SessionRetry.delay(1, error, 1)).toBe(2500)
expect(SessionRetry.delay(4, error, 1)).toBe(20000)
expect(SessionRetry.delay(5, error, 1)).toBe(30000)
})
test("prefers retry-after-ms when shorter than exponential", () => {
const error = apiError({ "retry-after-ms": "1500" })
expect(SessionRetry.delay(4, error)).toBe(1500)
@@ -67,18 +59,18 @@ describe("session.retry.delay", () => {
test("ignores invalid retry hints", () => {
const error = apiError({ "retry-after": "not-a-number" })
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
expect(SessionRetry.delay(1, error)).toBe(2000)
})
test("ignores malformed date retry hints", () => {
const error = apiError({ "retry-after": "Invalid Date String" })
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
expect(SessionRetry.delay(1, error)).toBe(2000)
})
test("ignores past date retry hints", () => {
const pastDate = new Date(Date.now() - 5000).toUTCString()
const error = apiError({ "retry-after": pastDate })
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
expect(SessionRetry.delay(1, error)).toBe(2000)
})
test("uses retry-after values even when exceeding 10 minutes with headers", () => {
@@ -123,29 +115,6 @@ describe("session.retry.delay", () => {
})
}),
)
it.instance("policy stops after five retries", () =>
Effect.gen(function* () {
const attempts: number[] = []
const error = apiError({ "retry-after-ms": "0" })
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
provider: "test",
parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema),
set: (info) =>
Effect.sync(() => {
attempts.push(info.attempt)
}),
}),
)
yield* Effect.forEach(Array.from({ length: SessionRetry.RETRY_MAX_RETRIES + 1 }), () =>
Effect.ignore(step(error)),
)
expect(attempts).toStrictEqual([1, 2, 3, 4, 5])
}),
)
})
describe("session.retry.retryable", () => {
+3 -22
View File
@@ -85,28 +85,9 @@ const it = testEffect(
describe("session.system", () => {
test("selects the Meta prompt for Muse Spark model IDs", () => {
for (const id of ["meta/muse-spark-preview", "muse-spark-1.1", "muse-spark-1.2"]) {
const prompt = SystemPrompt.provider({ api: { id } } as Provider.Model)[0]
expect(prompt).toContain("powered by Muse Spark,")
expect(prompt).toContain("using Meta Muse Spark.")
expect(prompt).not.toContain("{{MODEL_NAME}}")
}
})
test("selects the Meta prompt for Muse Glimmer model IDs", () => {
for (const id of ["meta/muse-glimmer", "meta/muse-glimmer-30b", "muse-glimmer-30b"]) {
const prompt = SystemPrompt.provider({ api: { id } } as Provider.Model)[0]
expect(prompt).toContain("powered by Muse Glimmer,")
expect(prompt).toContain("using Meta Muse Glimmer.")
expect(prompt).not.toContain("{{MODEL_NAME}}")
}
})
test("selects the Kimi prompt for official provider model IDs", () => {
for (const providerID of ["kimi-for-coding", "moonshotai", "moonshotai-cn"]) {
const prompt = SystemPrompt.provider({ providerID, api: { id: "k3" } } as Provider.Model)[0]
expect(prompt).toContain("# Prompt and Tool Use")
}
expect(SystemPrompt.provider({ api: { id: "meta/muse-spark-preview" } } as Provider.Model)[0]).toContain(
"Meta Muse Spark",
)
})
it.effect("skills output is sorted by name and stable across calls", () =>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
+2
View File
@@ -3,5 +3,7 @@
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale and grammar logic behind the shared typed i18n API. Session components should use `t(...)`, `plural(...)`, `parts(...)`, or `pluralParts(...)`; they must not inspect locales, choose plural categories, construct category-suffixed keys, or assemble translated grammatical fragments.
- Prefer complete phrase templates with typed rich slots for styled values. If the current API cannot express a phrase, deepen the shared context instead of leaking locale mechanics into session components.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
@@ -146,11 +146,7 @@ export const LineComment = (props: LineCommentProps) => {
<div data-slot="line-comment-tools">{split.actions}</div>
</Show>
</div>
<div data-slot="line-comment-label">
{i18n.t("ui.lineComment.label.prefix")}
{split.selection}
{i18n.t("ui.lineComment.label.suffix")}
</div>
<div data-slot="line-comment-label">{i18n.parts("ui.lineComment.label", { selection: split.selection })}</div>
</div>
</LineCommentAnchor>
)
@@ -395,9 +391,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
</Show>
<div data-slot="line-comment-actions">
<div data-slot="line-comment-editor-label">
{i18n.t("ui.lineComment.editorLabel.prefix")}
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
{i18n.parts("ui.lineComment.editorLabel", { selection: split.selection })}
</div>
<Show
when={!props.inline}
@@ -1,57 +1,6 @@
[data-component="tool-count-label"] {
display: inline-flex;
align-items: baseline;
white-space: nowrap;
white-space: pre;
gap: 0;
[data-slot="tool-count-label-before"] {
display: inline-block;
white-space: pre;
line-height: inherit;
}
[data-slot="tool-count-label-word"] {
display: inline-flex;
align-items: baseline;
white-space: pre;
line-height: inherit;
}
[data-slot="tool-count-label-stem"] {
display: inline-block;
white-space: pre;
}
[data-slot="tool-count-label-suffix"] {
display: inline-grid;
grid-template-columns: 0fr;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
overflow: hidden;
transform: translateX(-0.04em);
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration: 250ms, 250ms, 250ms, 250ms;
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="tool-count-label-suffix"][data-active="true"] {
grid-template-columns: 1fr;
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
[data-slot="tool-count-label-suffix-inner"] {
min-width: 0;
overflow: hidden;
white-space: pre;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="tool-count-label"] [data-slot="tool-count-label-suffix"] {
transition-duration: 0ms;
}
}
@@ -1,61 +1,18 @@
import { createMemo } from "solid-js"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
function split(text: string) {
const match = /{{\s*count\s*}}/.exec(text)
if (!match) return { before: "", after: text }
if (match.index === undefined) return { before: "", after: text }
return {
before: text.slice(0, match.index),
after: text.slice(match.index + match[0].length),
}
}
function common(one: string, other: string) {
const a = Array.from(one)
const b = Array.from(other)
let i = 0
while (i < a.length && i < b.length && a[i] === b[i]) i++
return {
stem: a.slice(0, i).join(""),
one: a.slice(i).join(""),
other: b.slice(i).join(""),
}
}
import { useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
const i18n = useI18n()
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one"))))
const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other"))))
const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category()))))
const suffix = createMemo(() => common(one().after, other().after))
const splitSuffix = createMemo(
() =>
(category() === "one" || category() === "other") &&
one().before === other().before &&
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
const parts = createMemo(() =>
i18n.pluralParts(props.plural, Math.round(props.count), {
count: <AnimatedNumber value={props.count} />,
}),
)
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
const tail = createMemo(() => {
if (!splitSuffix()) return ""
if (category() === "one") return suffix().one
return suffix().other
})
const showTail = createMemo(() => splitSuffix() && tail().length > 0)
return (
<span data-component="tool-count-label" class={props.class}>
<span data-slot="tool-count-label-before">{before()}</span>
<AnimatedNumber value={props.count} />
<span data-slot="tool-count-label-word">
<span data-slot="tool-count-label-stem">{stem()}</span>
<span data-slot="tool-count-label-suffix" data-active={showTail() ? "true" : "false"}>
<span data-slot="tool-count-label-suffix-inner">{tail()}</span>
</span>
</span>
{parts()}
</span>
)
}
-4
View File
@@ -5,10 +5,6 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
export type NormalizedProviderListResponse = {
all: Map<string, Provider>
defaultModel?: {
providerID: string
modelID: string
} | null
default: {
[key: string]: string
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
+1 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
@@ -12,7 +12,6 @@
"./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 { buildStatsQueries, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
describe("inference stat normalization", () => {
@@ -82,55 +82,6 @@ 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) {
+105 -147
View File
@@ -1,5 +1,5 @@
import { Resource } from "sst/resource"
import type { R2SqlData } from "../r2-sql"
import type { AthenaData } from "../athena"
import type { GeoStatAggregate } from "./geo"
import type { ModelStatAggregate } from "./model"
import {
@@ -10,80 +10,25 @@ import {
statProvider,
} from "./model-normalization"
import type { ProviderStatAggregate } from "./provider"
import {
normalizeCountry,
normalizeTier,
periodKeyFor,
startOfIsoWeek,
startOfUtcDay,
type StatBaseAggregate,
} from "./stat"
import { normalizeCountry, normalizeTier, 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"
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)`
// 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 aggregateColumns = `
${distinctColumns},
COUNT(DISTINCT session) AS sessions,
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,
@@ -93,61 +38,65 @@ function buildStatsQuery(
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,
null AS p50_duration_ms,
null AS p95_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,
AVG(ttfb_ms) AS avg_ttfb_ms,
null AS p50_ttfb_ms,
null AS p95_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,
AVG(output_tps) AS avg_output_tps,
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,
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,
COUNT(*) AS sample_count`
return `
WITH normalized AS (
SELECT
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,
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,
COALESCE(NULLIF(user_id, ''), '') AS user_id,
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,
status,
duration AS duration_ms,
time_to_first_byte AS ttfb_ms,
timestamp_first_byte,
timestamp_last_byte,
tokens_input,
tokens_output,
tokens_reasoning,
tokens_cache_read,
tokens_cache_write,
cost_input AS cost_input_microcents,
cost_output AS cost_output_microcents,
cost_total AS cost_total_microcents
tokens_cache_write_5m,
tokens_cache_write_1h,
cost_input_microcents,
cost_output_microcents,
cost_total_microcents,
cost_input,
cost_output,
cost_total,
source
FROM ${sourceTable}
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}
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}
), filtered AS (
SELECT
'Go' AS tier,
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,
${statProviderSql("model", "provider_model", "raw_provider")} AS provider,
provider_model,
model,
@@ -155,39 +104,63 @@ WITH normalized AS (
continent,
session,
COALESCE(NULLIF(user_id, ''), NULLIF(workspace, ''), NULLIF(api_key, '')) AS user_key,
outcome,
status,
duration_ms,
ttfb_ms,
CASE
WHEN output_seconds < 0.1 THEN null
ELSE CAST(tokens_output AS double) / output_seconds
WHEN timestamp_last_byte - timestamp_first_byte < 100 THEN null
ELSE CAST(tokens_output AS double) / (timestamp_last_byte - timestamp_first_byte) * 1000
END AS output_tps,
tokens_input,
tokens_output,
tokens_reasoning,
tokens_cache_read,
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
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
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
${sqlString(period.grain)} AS grain,
${sqlString(period.key)} AS period_key,
${sqlString(source.dataset)} AS dataset,
${dimensions},
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,
${aggregateColumns}
FROM filtered
FROM periods
GROUP BY GROUPING SETS (
${groupingSets}
(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)
)
LIMIT 10000
ORDER BY grain, period_key, total_tokens DESC
`
}
export function toModelAggregate(data: R2SqlData): ModelStatAggregate[] {
export function toModelAggregate(data: AthenaData): ModelStatAggregate[] {
const model = statModel(data.model, data.provider_model)
const provider = statProvider(model, data.provider_model, data.provider)
if (!provider) return []
@@ -197,13 +170,13 @@ export function toModelAggregate(data: R2SqlData): ModelStatAggregate[] {
])
}
export function toProviderAggregate(data: R2SqlData): ProviderStatAggregate[] {
export function toProviderAggregate(data: AthenaData): ProviderStatAggregate[] {
return toStatBaseAggregate(data).flatMap((base) => [
{ ...base, provider: statProvider(data.model, data.provider_model, data.provider) || "unknown" },
])
}
export function toGeoAggregate(data: R2SqlData): GeoStatAggregate[] {
export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
return toStatBaseAggregate(data).flatMap((base) => [
{
...base,
@@ -215,7 +188,7 @@ export function toGeoAggregate(data: R2SqlData): GeoStatAggregate[] {
])
}
function toStatBaseAggregate(data: R2SqlData): StatBaseAggregate[] {
function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
const grain = data.grain === "day" || data.grain === "week" ? data.grain : undefined
if (!grain || !data.period_key) return []
@@ -250,21 +223,21 @@ function toStatBaseAggregate(data: R2SqlData): StatBaseAggregate[] {
]
}
function integer(data: R2SqlData, key: string) {
function integer(data: AthenaData, key: string) {
return Math.round(number(data, key))
}
function nullableNumber(data: R2SqlData, key: string) {
function nullableNumber(data: AthenaData, key: string) {
if (data[key] === undefined || data[key] === "") return null
return Number(number(data, key).toFixed(2))
}
function nullableInteger(data: R2SqlData, key: string) {
function nullableInteger(data: AthenaData, key: string) {
if (data[key] === undefined || data[key] === "") return null
return Math.round(number(data, key))
}
function number(data: R2SqlData, key: string) {
function number(data: AthenaData, key: string) {
const value = Number(data[key])
return Number.isFinite(value) ? value : 0
}
@@ -277,21 +250,6 @@ 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
@@ -1,105 +0,0 @@
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,17 +11,6 @@ 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"
+13 -14
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 { buildStatsQueries, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./domain/inference"
import { buildStatsQuery, 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,25 +18,23 @@ 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 = R2SqlQueryError | DatabaseError
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
export const syncStats: (options?: {
full?: boolean
}) => Effect.Effect<SyncStatsResult, SyncStatsError, R2Sql | ModelStatRepo | ProviderStatRepo | GeoStatRepo> =
}) => Effect.Effect<SyncStatsResult, SyncStatsError, Athena | 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 r2Sql = yield* R2Sql
const athena = yield* Athena
const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
const geoStats = yield* GeoStatRepo
yield* logRuntimeCheck()
const rows = yield* Effect.forEach(buildStatsQueries(periodStart, periodEnd), r2Sql.query, {
concurrency: 4,
}).pipe(Effect.map((batches) => batches.flat()))
const rows = yield* athena.query(buildStatsQuery(periodStart, periodEnd))
const modelRows = modelRowsFromAggregates(rows.filter((row) => row.dimension === "model").flatMap(toModelAggregate))
const providerRows = providerRowsFromAggregates(
rows.filter((row) => row.dimension === "provider").flatMap(toProviderAggregate),
@@ -79,7 +77,7 @@ export const syncStats: (options?: {
}
})
// May 27 was partial, so keep stats anchored at the first complete day.
// May 27 was partial, so keep Athena stats anchored at the first complete day.
function fullPeriodStart(periodEnd: Date) {
return new Date(
Math.max(
@@ -101,12 +99,13 @@ function incrementalPeriodStart(periodEnd: Date) {
function logRuntimeCheck() {
return Effect.logInfo(
`r2 sql stats runtime check ${JSON.stringify({
accountId: Resource.R2Sql.accountId,
bucket: Resource.R2Sql.bucket,
`athena stats runtime check ${JSON.stringify({
catalog: Resource.InferenceEvent.catalog,
database: Resource.InferenceEvent.database,
dataset: Resource.StatsSyncConfig.dataset,
namespace: Resource.R2Sql.namespace,
table: Resource.R2Sql.table,
table: Resource.InferenceEvent.table,
workgroup: Resource.InferenceEvent.workgroup,
region: Resource.InferenceEvent.region,
stage: Resource.App.stage,
})}`,
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
+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, R2Sql.layer)
const runtimeLayer = Layer.mergeAll(statsLayer, Athena.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 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.
// 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.
const initialDelay = Effect.fnUntraced(function* () {
const modelStats = yield* ModelStatRepo
const lastSynced = yield* modelStats.lastSyncedAt().pipe(Effect.catchCause(() => Effect.succeed(null)))
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.18.18",
"version": "1.18.15",
"private": true,
"type": "module",
"license": "MIT",
+2
View File
@@ -3,6 +3,8 @@
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for component defaults, visible copy, placeholders, accessible labels, tooltips, dialogs, toasts, empty states, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale and grammar logic behind the shared typed i18n API. Components should use `t(...)`, `plural(...)`, `parts(...)`, or `pluralParts(...)`; they must not inspect locales, choose plural categories, construct category-suffixed keys, or assemble translated grammatical fragments.
- Prefer complete phrase templates with typed rich slots for styled values. If the current API cannot express a phrase, deepen the shared context instead of leaking locale mechanics into components.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer established usage in the target language's developer community over literal translations. Cross-check maintained Firefox, KDE, and VS Code localizations, using at least two independent corpora when available. Keep established English loanwords and acronyms instead of inventing unfamiliar terms.
- Translate whole UI phrases in context rather than substituting glossary words. Audit recurring concepts for consistency and review every exact-English value; retain it only when it is an intentional product/provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.18.18",
"version": "1.18.15",
"type": "module",
"license": "MIT",
"repository": {
+3 -10
View File
@@ -247,16 +247,9 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
const query = filter()
if (!query) return i18n.t("ui.list.empty")
const suffix = i18n.t("ui.list.emptyWithFilter.suffix")
return (
<>
<span>{i18n.t("ui.list.emptyWithFilter.prefix")}</span>
<span data-slot="list-filter">&quot;{query}&quot;</span>
<Show when={suffix}>
<span>{suffix}</span>
</Show>
</>
)
return i18n.parts("ui.list.emptyWithFilter", {
query: <span data-slot="list-filter">&quot;{query}&quot;</span>,
})
}
return (
+56 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { pluralCategory } from "./i18n"
import { createUiI18n, pluralCategory, pluralKey, type UiI18nParams, type UiI18nSource } from "./i18n"
describe("pluralCategory", () => {
test.each([
@@ -22,3 +22,58 @@ describe("pluralCategory", () => {
expect(pluralCategory(locale, count)).toBe(expected)
})
})
function i18n(locale: string, messages: Record<string, string>) {
const source: UiI18nSource = {
locale: () => locale,
t: (key, params) => resolve(messages[key] ?? key, params),
plural: (key, count, params) =>
source.t(pluralKey(key, pluralCategory(source.locale(), count)), { ...params, count }),
}
return createUiI18n(source)
}
function resolve(template: string, params?: UiI18nParams) {
if (!params) return template
return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, key) => String(params[String(key)] ?? ""))
}
describe("createUiI18n", () => {
test("keeps dynamic source copy for English locale tags", () => {
const value = i18n("en-US", { title: "Dictionary title" })
expect(value.tDynamic("title", "Runtime {{name}}", { name: "title" })).toBe("Runtime title")
})
test("uses dictionary copy for non-English locales", () => {
const value = i18n("fr", { title: "Titre traduit" })
expect(value.tDynamic("title", "Runtime title")).toBe("Titre traduit")
})
test("inserts rich values in dictionary order", () => {
const value = i18n("ja", {
"ui.lineComment.label": "{{selection}}へのコメント",
"ui.lineComment.editorLabel": "Commenting on {{selection}}",
"ui.list.emptyWithFilter": "没有关于{{query}}的结果",
})
const selection = { id: "selection" }
expect(value.parts("ui.lineComment.label", { selection })).toEqual(["", selection, "へのコメント"])
expect(value.parts("ui.list.emptyWithFilter", { query: "needle" })).toEqual(["没有关于", "needle", "的结果"])
})
test("rejects malformed rich templates", () => {
const value = i18n("en", { "ui.lineComment.label": "Comment" })
expect(() => value.parts("ui.lineComment.label", { selection: "path" })).toThrow()
})
test("selects plural copy before inserting the animated count", () => {
const value = i18n("ar", {
"ui.messagePart.context.read.two": "تمت قراءة ملفين: {{count}} في {{folder}}",
})
const count = { id: "animated-count" }
expect(value.pluralParts("ui.messagePart.context.read", 2, { count }, { folder: "src" })).toEqual([
"تمت قراءة ملفين: ",
count,
" في src",
])
})
})
+55 -8
View File
@@ -16,13 +16,32 @@ export type UiI18nPluralLookupKey = `${UiI18nPluralKey}.${UiPluralCategory}`
export type UiI18nParams = Record<string, string | number | boolean>
export type UiI18n = {
export const UI_RICH_KEYS = {
"ui.lineComment.label": "selection",
"ui.lineComment.editorLabel": "selection",
"ui.list.emptyWithFilter": "query",
} as const
export type UiI18nRichKey = keyof typeof UI_RICH_KEYS
export type UiI18nRichSlot<K extends UiI18nRichKey> = (typeof UI_RICH_KEYS)[K]
export type UiI18nPart<T> = string | T
export type UiI18nSource = {
locale: Accessor<string>
layoutLocale?: Accessor<string>
t: (key: UiI18nKey, params?: UiI18nParams) => string
plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string
}
export type UiI18n = UiI18nSource & {
/** Preserve runtime-generated English copy while using the keyed dictionary for every other locale. */
tDynamic: (key: UiI18nKey, source: string, params?: UiI18nParams) => string
parts: <K extends UiI18nRichKey, S extends Record<UiI18nRichSlot<K>, unknown>>(
key: K,
slots: S,
) => UiI18nPart<S[UiI18nRichSlot<K>]>[]
pluralParts: <T>(key: UiI18nPluralKey, count: number, slots: { count: T }, params?: UiI18nParams) => UiI18nPart<T>[]
}
const rules = new Map<string, Intl.PluralRules>()
export function pluralCategory(locale: string, count: number): UiPluralCategory {
@@ -35,7 +54,7 @@ export function pluralCategory(locale: string, count: number): UiPluralCategory
}
export function pluralKey(key: UiI18nPluralKey, category: UiPluralCategory) {
return `${key}.${category}` as UiI18nPluralLookupKey
return `${key}.${category}`
}
function resolveTemplate(text: string, params?: UiI18nParams) {
@@ -47,22 +66,50 @@ function resolveTemplate(text: string, params?: UiI18nParams) {
})
}
const fallback: UiI18n = {
function resolveParts<T>(template: string, slot: string, value: T, params?: UiI18nParams): UiI18nPart<T>[] {
const matches = Array.from(template.matchAll(/{{\s*([^}]+?)\s*}}/g)).filter((match) => match[1] === slot)
if (matches.length !== 1) throw new Error(`Expected exactly one {{${slot}}} placeholder`)
const match = matches[0]
const index = match.index
return [
resolveTemplate(template.slice(0, index), params),
value,
resolveTemplate(template.slice(index + match[0].length), params),
]
}
export function createUiI18n(source: UiI18nSource): UiI18n {
return {
...source,
tDynamic: (key, value, params) =>
source.locale().toLowerCase().split("-")[0] === "en" ? resolveTemplate(value, params) : source.t(key, params),
parts: (key, slots) => {
const slot = UI_RICH_KEYS[key]
return resolveParts(source.t(key), slot, slots[slot])
},
pluralParts: (key, count, slots, params) =>
resolveParts(source.t(pluralKey(key, pluralCategory(source.locale(), count))), "count", slots.count, params),
}
}
const fallbackSource: UiI18nSource = {
locale: () => "en",
t: (key, params) => {
const value = en[key] ?? String(key)
const value = en[key] ?? key
return resolveTemplate(value, params)
},
plural: (key, count, params) =>
fallback.t(pluralKey(key, pluralCategory(fallback.locale(), count)), { ...params, count }),
fallbackSource.t(pluralKey(key, pluralCategory(fallbackSource.locale(), count)), { ...params, count }),
}
const fallback = createUiI18n(fallbackSource)
const Context = createContext<UiI18n>(fallback)
function UiI18nProvider(props: ParentProps<{ value: UiI18n }>) {
function UiI18nProvider(props: ParentProps<{ value: UiI18nSource }>) {
const value = createUiI18n(props.value)
return (
<I18nProvider locale={(props.value.layoutLocale ?? props.value.locale)()}>
<Context.Provider value={props.value}>{props.children}</Context.Provider>
<I18nProvider locale={(value.layoutLocale ?? value.locale)()}>
<Context.Provider value={value}>{props.children}</Context.Provider>
</I18nProvider>
)
}
+3 -6
View File
@@ -43,10 +43,8 @@ export const dict: Record<string, string> = {
"ui.fileMedia.binary.title": "ሁለትዮሽ ፋይል",
"ui.fileMedia.binary.description.path": "{{path}} ሁለትዮሽ ነው።",
"ui.fileMedia.binary.description.default": "ሁለትዮሽ ይዘት",
"ui.lineComment.label.prefix": "አስተያየት በ ላይ ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "በላይ አስተያየት መስጠት ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "አስተያየት በ ላይ {{selection}}",
"ui.lineComment.editorLabel": "በላይ አስተያየት መስጠት {{selection}}",
"ui.lineComment.placeholder": "አስተያየት ያክሉ",
"ui.lineComment.contextPlaceholder": "ለዚህ ለውጥ አውድ ጨምር",
"ui.lineComment.submit": "አስተያየት",
@@ -102,8 +100,7 @@ export const dict: Record<string, string> = {
"ui.list.loading": "በመጫን ላይ",
"ui.list.empty": "ምንም ውጤቶች",
"ui.list.clearFilter": "ማጣሪያን አጽዳ",
"ui.list.emptyWithFilter.prefix": "ምንም ውጤቶች ለ",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "ምንም ውጤቶች ለ{{query}}",
"ui.fileSearch.placeholder": "አግኝ",
"ui.fileSearch.previousMatch": "የቀድሞ ግጥሚያ",
"ui.fileSearch.nextMatch": "ቀጣይ ተዛማጅ",
+3 -6
View File
@@ -44,10 +44,8 @@ export const dict = {
"ui.fileMedia.binary.description.path": "{{path}} عبارة عن ملف ثنائي ولا يمكن عرضه.",
"ui.fileMedia.binary.description.default": "هذا ملف ثنائي ولا يمكن عرضه.",
"ui.lineComment.label.prefix": "تعليق على ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "جارٍ التعليق على ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "تعليق على {{selection}}",
"ui.lineComment.editorLabel": "جارٍ التعليق على {{selection}}",
"ui.lineComment.placeholder": "أضف تعليقًا",
"ui.lineComment.contextPlaceholder": "أضف سياقًا لهذا التغيير",
"ui.lineComment.submit": "تعليق",
@@ -126,8 +124,7 @@ export const dict = {
"ui.list.loading": "جارٍ التحميل",
"ui.list.empty": "لا توجد نتائج",
"ui.list.clearFilter": "مسح عامل التصفية",
"ui.list.emptyWithFilter.prefix": "لا توجد نتائج لـ",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "لا توجد نتائج لـ{{query}}",
"ui.messageNav.newMessage": "رسالة جديدة",
+3 -6
View File
@@ -43,10 +43,8 @@ export const dict: Record<string, string> = {
"ui.fileMedia.binary.title": "İkili fayl",
"ui.fileMedia.binary.description.path": "{{path}} ikili fayldır.",
"ui.fileMedia.binary.description.default": "İkili məzmun",
"ui.lineComment.label.prefix": "Şərh: ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Şərh yazılır: ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Şərh: {{selection}}",
"ui.lineComment.editorLabel": "Şərh yazılır: {{selection}}",
"ui.lineComment.placeholder": "Şərh əlavə et",
"ui.lineComment.contextPlaceholder": "Bu dəyişiklik üçün kontekst əlavə et",
"ui.lineComment.submit": "Şərh et",
@@ -103,8 +101,7 @@ export const dict: Record<string, string> = {
"ui.list.loading": "Yüklənir",
"ui.list.empty": "Nəticə yoxdur",
"ui.list.clearFilter": "Filtri təmizlə",
"ui.list.emptyWithFilter.prefix": "Nəticə tapılmadı:",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "Nəticə tapılmadı:{{query}}",
"ui.fileSearch.placeholder": "Axtar",
"ui.fileSearch.previousMatch": "Əvvəlki uyğunluq",
"ui.fileSearch.nextMatch": "Növbəti uyğunluq",
+3 -6
View File
@@ -43,10 +43,8 @@ export const dict = {
"ui.fileMedia.binary.title": "Двоичен файл",
"ui.fileMedia.binary.description.path": "{{path}} е двоичен.",
"ui.fileMedia.binary.description.default": "Двоично съдържание",
"ui.lineComment.label.prefix": "Коментирайте",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Коментиране на",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Коментирайте{{selection}}",
"ui.lineComment.editorLabel": "Коментиране на{{selection}}",
"ui.lineComment.placeholder": "Добавете коментар",
"ui.lineComment.contextPlaceholder": "Добавете контекст за тази промяна",
"ui.lineComment.submit": "Коментирайте",
@@ -103,8 +101,7 @@ export const dict = {
"ui.list.loading": "Зарежда се",
"ui.list.empty": "Няма резултати",
"ui.list.clearFilter": "Изчистване на филтъра",
"ui.list.emptyWithFilter.prefix": "Няма резултати за",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "Няма резултати за{{query}}",
"ui.fileSearch.placeholder": "Намерете",
"ui.fileSearch.previousMatch": "Предишен мач",
"ui.fileSearch.nextMatch": "Следващ мач",
+5 -8
View File
@@ -45,10 +45,8 @@ export const dict: Record<string, string> = {
"ui.fileMedia.binary.title": "বাইনারি ফাইল",
"ui.fileMedia.binary.description.path": "{{path}} হল বাইনারি।",
"ui.fileMedia.binary.description.default": "বাইনারি বিষয়বস্তু",
"ui.lineComment.label.prefix": "মন্তব্য করুন",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "মন্তব্য করছেন",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "মন্তব্য করুন{{selection}}",
"ui.lineComment.editorLabel": "মন্তব্য করছেন{{selection}}",
"ui.lineComment.placeholder": "মন্তব্য যোগ করুন",
"ui.lineComment.contextPlaceholder": "এই পরিবর্তনের জন্য প্রসঙ্গ যোগ করুন",
"ui.lineComment.submit": "মন্তব্য করুন",
@@ -105,8 +103,7 @@ export const dict: Record<string, string> = {
"ui.list.loading": "লোড হচ্ছে",
"ui.list.empty": "কোন ফলাফল নেই",
"ui.list.clearFilter": "ফিল্টার পরিষ্কার করুন",
"ui.list.emptyWithFilter.prefix": "জন্য কোন ফলাফল",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "জন্য কোন ফলাফল{{query}}",
"ui.fileSearch.placeholder": "খুঁজুন",
"ui.fileSearch.previousMatch": "আগের ম্যাচ",
"ui.fileSearch.nextMatch": "পরের ম্যাচ",
@@ -141,8 +138,8 @@ export const dict: Record<string, string> = {
"ui.tool.grep": "Grep",
"ui.tool.task": "টাস্ক",
"ui.tool.webfetch": "ওয়েবফেচ",
"ui.tool.websearch": "Web Search",
"ui.tool.websearch.provider": "{{provider}} Web Search",
"ui.tool.websearch": "ওয়েব অনুসন্ধান",
"ui.tool.websearch.provider": "{{provider}} ওয়েব অনুসন্ধান",
"ui.tool.shell": "শেল",
"ui.tool.patch": "প্যাচ",
"ui.tool.todos": "করণীয়",
+3 -6
View File
@@ -44,10 +44,8 @@ export const dict = {
"ui.fileMedia.binary.description.path": "Não é possível exibir {{path}} porque é um arquivo binário.",
"ui.fileMedia.binary.description.default": "Não é possível exibir o arquivo porque ele é binário.",
"ui.lineComment.label.prefix": "Comentar em ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Comentando em ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Comentar em {{selection}}",
"ui.lineComment.editorLabel": "Comentando em {{selection}}",
"ui.lineComment.placeholder": "Adicionar comentário",
"ui.lineComment.contextPlaceholder": "Adicionar contexto para esta alteração",
"ui.lineComment.submit": "Comentar",
@@ -114,8 +112,7 @@ export const dict = {
"ui.list.loading": "Carregando",
"ui.list.empty": "Nenhum resultado",
"ui.list.clearFilter": "Limpar filtro",
"ui.list.emptyWithFilter.prefix": "Nenhum resultado para",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "Nenhum resultado para{{query}}",
"ui.messageNav.newMessage": "Nova mensagem",
+3 -6
View File
@@ -48,10 +48,8 @@ export const dict = {
"ui.fileMedia.binary.description.path": "{{path}} je binarna datoteka.",
"ui.fileMedia.binary.description.default": "Binarni sadržaj",
"ui.lineComment.label.prefix": "Komentar na ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Komentarišeš ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Komentar na {{selection}}",
"ui.lineComment.editorLabel": "Komentarišeš {{selection}}",
"ui.lineComment.placeholder": "Dodaj komentar",
"ui.lineComment.contextPlaceholder": "Dodaj kontekst za ovu izmjenu",
"ui.lineComment.submit": "Komentariši",
@@ -118,8 +116,7 @@ export const dict = {
"ui.list.loading": "Učitavanje",
"ui.list.empty": "Nema rezultata",
"ui.list.clearFilter": "Očisti filter",
"ui.list.emptyWithFilter.prefix": "Nema rezultata za",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "Nema rezultata za{{query}}",
"ui.messageNav.newMessage": "Nova poruka",
+3 -6
View File
@@ -43,10 +43,8 @@ export const dict: Record<string, string> = {
"ui.fileMedia.binary.title": "Fitxer binari",
"ui.fileMedia.binary.description.path": "{{path}} és binari.",
"ui.fileMedia.binary.description.default": "Contingut binari",
"ui.lineComment.label.prefix": "Comenta ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Comentant ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Comenta {{selection}}",
"ui.lineComment.editorLabel": "Comentant {{selection}}",
"ui.lineComment.placeholder": "Afegeix un comentari",
"ui.lineComment.contextPlaceholder": "Afegeix context per a aquest canvi",
"ui.lineComment.submit": "Comenta",
@@ -107,8 +105,7 @@ export const dict: Record<string, string> = {
"ui.list.loading": "Carregant",
"ui.list.empty": "Sense resultats",
"ui.list.clearFilter": "Esborra el filtre",
"ui.list.emptyWithFilter.prefix": "No hi ha resultats per a",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "No hi ha resultats per a{{query}}",
"ui.fileSearch.placeholder": "Troba",
"ui.fileSearch.previousMatch": "Coincidència anterior",
"ui.fileSearch.nextMatch": "Coincidència següent",
+3 -6
View File
@@ -43,10 +43,8 @@ export const dict: Record<string, string> = {
"ui.fileMedia.binary.title": "Binární soubor",
"ui.fileMedia.binary.description.path": "{{path}} je binární.",
"ui.fileMedia.binary.description.default": "Binární obsah",
"ui.lineComment.label.prefix": "Komentář k ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Komentování ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Komentář k {{selection}}",
"ui.lineComment.editorLabel": "Komentování {{selection}}",
"ui.lineComment.placeholder": "Přidat komentář",
"ui.lineComment.contextPlaceholder": "Přidejte kontext pro tuto změnu",
"ui.lineComment.submit": "Komentář",
@@ -111,8 +109,7 @@ export const dict: Record<string, string> = {
"ui.list.loading": "Načítání",
"ui.list.empty": "Žádné výsledky",
"ui.list.clearFilter": "Vymazat filtr",
"ui.list.emptyWithFilter.prefix": "Žádné výsledky pro",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "Žádné výsledky pro{{query}}",
"ui.fileSearch.placeholder": "Najít",
"ui.fileSearch.previousMatch": "Předchozí shoda",
"ui.fileSearch.nextMatch": "Další shoda",
+3 -6
View File
@@ -44,10 +44,8 @@ export const dict = {
"ui.fileMedia.binary.title": "Binær fil",
"ui.fileMedia.binary.description.path": "{{path}} kan ikke vises, fordi det er en binær fil.",
"ui.fileMedia.binary.description.default": "Denne fil kan ikke vises, fordi det er en binær fil.",
"ui.lineComment.label.prefix": "Skriv en kommentar til ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Skriver en kommentar til ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Skriv en kommentar til {{selection}}",
"ui.lineComment.editorLabel": "Skriver en kommentar til {{selection}}",
"ui.lineComment.placeholder": "Tilføj kommentar",
"ui.lineComment.submit": "Kommenter",
"ui.lineComment.cancel": "Annuller",
@@ -108,8 +106,7 @@ export const dict = {
"ui.list.loading": "Indlæser",
"ui.list.empty": "Ingen resultater",
"ui.list.clearFilter": "Ryd filter",
"ui.list.emptyWithFilter.prefix": "Ingen resultater for",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "Ingen resultater for{{query}}",
"ui.messageNav.newMessage": "Ny besked",

Some files were not shown because too many files have changed in this diff Show More