Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c09c2f17f | |||
| c4132543c3 | |||
| 52f7ba7d4d | |||
| 19fce2bc6f | |||
| 4bae84c8b0 | |||
| f240bba8e7 | |||
| bcee247988 | |||
| 64c504212a | |||
| 6592d80460 | |||
| d821650b48 | |||
| 6c9f12bb2d | |||
| fca89e5e4d | |||
| 1eff01b6a5 | |||
| e93b1f3a7f | |||
| 8874d4a42f | |||
| c933504d9c | |||
| 2d0d3d596e | |||
| 3bd98ea055 | |||
| 7e997cfba4 | |||
| 5d6f2a1524 | |||
| b1cb71856e | |||
| 518264fcd9 | |||
| 7235c9c9b8 | |||
| 274033cd52 | |||
| 9b369ee815 | |||
| 721ff5121e | |||
| cfbf5d1c6f | |||
| 02cb7e7b71 | |||
| 942630eb4a | |||
| ce66b191d1 | |||
| 6f1f5944ce | |||
| 1c49b2ed67 | |||
| 3c6be604ec | |||
| ddc02c2893 | |||
| 2703eff2e2 | |||
| 38e4540119 | |||
| 4100fcbd17 | |||
| 83cb0f60ec | |||
| effd96755e | |||
| 5801cce1b5 | |||
| 77e6c0d329 | |||
| fed716ada5 | |||
| 426d92e352 | |||
| d0412a213b | |||
| 16aa67086b | |||
| 7cea32ee08 | |||
| 128d10d9e9 | |||
| 5ef72e1101 | |||
| d8060dc9ad | |||
| f71fb18d3d | |||
| 5654dd2aad | |||
| 64dde0cb15 | |||
| a658e43eeb | |||
| ca8a42c973 | |||
| 8f5f75db12 | |||
| 4ff0d07b1d | |||
| 2a571b3cee | |||
| 0fffcdfe46 | |||
| 2ba2ee52e8 | |||
| 56d818fc34 | |||
| 9c8da69196 | |||
| a78018697c | |||
| e45b6ef1de | |||
| b616543ac2 | |||
| 2bd3d9a696 | |||
| fa15dbc5ec | |||
| 312e5c7a7c | |||
| 049502fac6 | |||
| cc2915be16 | |||
| ce061bf661 | |||
| 3b8790e034 | |||
| a4f3cedcdf | |||
| 1c9a2eb239 | |||
| 4fb417d3b5 | |||
| 11030c627b | |||
| c104098a66 | |||
| 49ee3ba85a | |||
| 4fc538378d | |||
| d28b5ad2f4 | |||
| 6589a66822 | |||
| 5cf9abe743 | |||
| 903d81819d | |||
| 472f9e64a6 | |||
| c04fa9e253 | |||
| 3a78fb1f42 | |||
| 85ce6a5f95 | |||
| 5217e6c1af | |||
| 3a7f617098 | |||
| d9150413cb | |||
| bcbc1dba22 | |||
| ce3235e115 | |||
| a9a2a597d5 | |||
| 3753601f87 | |||
| fb4bab8a66 | |||
| b3526f6ce9 | |||
| f220f02a2f | |||
| 235a86fb60 | |||
| 67b9c9c027 | |||
| 2f11c9f7ed | |||
| e1c1193f3e | |||
| 29250a0efb | |||
| c6e6bdf59f | |||
| d80e1199ca | |||
| 10ea59066f | |||
| 79d6b10d7c | |||
| 6e78f36a0f | |||
| 16866e1180 | |||
| 6d130e5deb | |||
| e30d8173c1 | |||
| 7a79f3a5ea | |||
| b8ca71d309 | |||
| 6849f96825 | |||
| 00c3248295 | |||
| 818b56dbd0 | |||
| 29b5b24787 | |||
| 11363170ca | |||
| ba9e4b67ed | |||
| bd1029b19f | |||
| 43b51f09d0 | |||
| c61ab51886 | |||
| d373c562f2 | |||
| 5fa5d876fc | |||
| 805af011c9 | |||
| 480aa8b23b | |||
| d62442bb5d | |||
| 8602937a37 | |||
| 77da433e0a | |||
| ad79f3e0cf | |||
| 6c2dfd2f52 | |||
| 9a8b54fe62 | |||
| dcdbdb218f | |||
| 5e49029e70 | |||
| 19abadaf27 | |||
| 6fea0178eb | |||
| ebe9dcf27e | |||
| 57efec4429 | |||
| e22144806f | |||
| 27fa297a42 | |||
| b1cd25de3d | |||
| 780bbb0f3b | |||
| 817cb076a8 | |||
| 347526e9fd | |||
| 092bc674a5 | |||
| b24a4e897e | |||
| 3afa622eab | |||
| d01cb7f013 | |||
| 28b03595bf | |||
| 32684e70e6 | |||
| b2baddcd37 | |||
| dbd48d423d | |||
| e7cc8259b5 | |||
| a9ccb0804f | |||
| ebe6087e8f | |||
| dc978cb889 | |||
| 8cbc43fbb0 | |||
| 82359c4b1b | |||
| 7f3e51453b | |||
| cbdb2d9825 | |||
| 96bde05f6e | |||
| dcb8ed8eb0 | |||
| aab82cc1a7 | |||
| cd1d1e81a6 | |||
| cff441909a | |||
| 3615d5aab1 | |||
| 11d9e82eaf | |||
| eadda11ec9 | |||
| f73a56c223 | |||
| c0acf5c43f | |||
| 4d585464f3 | |||
| f0cb17a812 | |||
| 357a74714a | |||
| ffea6c7974 | |||
| 8e9550d90d | |||
| 9b7b6cb30f | |||
| cc68afb2de | |||
| 11c33d52a5 | |||
| 2a487ea990 | |||
| 9c05d4e2fd |
@@ -9,6 +9,7 @@
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -329,7 +329,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "0.0.0",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -393,10 +393,6 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -416,6 +412,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
@@ -437,8 +434,6 @@
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -465,7 +460,6 @@
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
@@ -498,12 +492,11 @@
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -541,7 +534,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -556,7 +549,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -591,7 +584,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -640,7 +633,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -685,6 +678,7 @@
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -1239,8 +1233,6 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
||||
@@ -2045,6 +2037,8 @@
|
||||
|
||||
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
||||
|
||||
+38
-24
@@ -1,6 +1,8 @@
|
||||
import { SECRET } from "./secret"
|
||||
import { domain } from "./stage"
|
||||
|
||||
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
||||
|
||||
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
|
||||
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
|
||||
url: `https://${domain}/honeycomb/webhook`,
|
||||
@@ -25,6 +27,16 @@ const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
|
||||
],
|
||||
})
|
||||
|
||||
// Honeycomb can keep stale query-local calculated fields when the name is unchanged,
|
||||
// so tie the field name to the expression while avoiding deploy-to-deploy churn.
|
||||
// https://github.com/honeycombio/terraform-provider-honeycombio/issues/852
|
||||
const calculatedField = (field: { name: string; expression: string }) => ({
|
||||
...field,
|
||||
name: `${field.name}_${(
|
||||
Array.from(field.expression).reduce((result, char) => Math.imul(31, result) + char.charCodeAt(0), 0) >>> 0
|
||||
).toString(36)}`,
|
||||
})
|
||||
|
||||
const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "model", op: "exists" },
|
||||
@@ -32,21 +44,26 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
const failedHttpStatus = calculatedField({
|
||||
name: "is_failed_http_status",
|
||||
expression:
|
||||
product === "go"
|
||||
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
|
||||
: `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
|
||||
})
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
breakdowns: ["model"],
|
||||
calculatedFields: [
|
||||
{
|
||||
name: "is_failed_http_status",
|
||||
expression:
|
||||
product === "go"
|
||||
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
|
||||
: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
|
||||
},
|
||||
],
|
||||
calculatedFields: [failedHttpStatus],
|
||||
calculations: [
|
||||
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
|
||||
{ op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters },
|
||||
{
|
||||
op: "SUM",
|
||||
name: "FAILED",
|
||||
column: failedHttpStatus.name,
|
||||
filterCombination: "AND",
|
||||
filters,
|
||||
},
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
|
||||
timeRange: 900,
|
||||
@@ -59,31 +76,30 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
const successHttpStatus = calculatedField({
|
||||
name: "is_success_http_status",
|
||||
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
|
||||
})
|
||||
const failedProviderHttpStatus = calculatedField({
|
||||
name: "is_failed_provider_http_status",
|
||||
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
|
||||
})
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
breakdowns: ["provider"],
|
||||
calculatedFields: [
|
||||
{
|
||||
name: "is_success_http_status",
|
||||
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
|
||||
},
|
||||
{
|
||||
name: "is_failed_provider_http_status",
|
||||
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
|
||||
},
|
||||
],
|
||||
calculatedFields: [successHttpStatus, failedProviderHttpStatus],
|
||||
calculations: [
|
||||
{
|
||||
op: "SUM",
|
||||
name: "SUCCESS",
|
||||
column: "is_success_http_status",
|
||||
column: successHttpStatus.name,
|
||||
filterCombination: "AND",
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "completions" }],
|
||||
},
|
||||
{
|
||||
op: "SUM",
|
||||
name: "FAILED",
|
||||
column: "is_failed_provider_http_status",
|
||||
column: failedProviderHttpStatus.name,
|
||||
filterCombination: "AND",
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
|
||||
},
|
||||
@@ -95,8 +111,6 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
}).json
|
||||
}
|
||||
|
||||
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
||||
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
name: "Increased Model HTTP Errors [Go]",
|
||||
description,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ZqTxXSIWZmrNpdIQICbesrMpoL24blahhQzAGTzupAc=",
|
||||
"aarch64-linux": "sha256-YdZAkfJXBCnqPcYS7VUbQqT9AkYnyu/ONVG5v78Lmdw=",
|
||||
"aarch64-darwin": "sha256-3Fdu1le89F53UHuaiHanHP0CB4F72fK9p1d9PC47Wsg=",
|
||||
"x86_64-darwin": "sha256-Tc4WppnKZPzYn/ICIv1vtx+NiNqAHAzd/ogTRUCQK18="
|
||||
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
|
||||
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
|
||||
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
|
||||
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { projectDirectories } from "@/pages/layout/helpers"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -284,7 +285,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
|
||||
for (const project of projects) {
|
||||
let at = 0
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const dirs = projectDirectories(project)
|
||||
for (const directory of dirs) {
|
||||
const sessions = sync.child(directory, { bootstrap: false })[0].session
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { projectDirectories } from "@/pages/layout/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
@@ -280,14 +281,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const project = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||
return layout.projects.list().find((p) => p.worktree === directory || p.worktrees?.includes(directory))
|
||||
})
|
||||
const workspaces = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
const current = project()
|
||||
if (!current) return directory ? [directory] : []
|
||||
|
||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||
const dirs = projectDirectories(current)
|
||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
|
||||
@@ -342,6 +342,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(createdWorktree.directory)
|
||||
globalSync.project.addWorktree(projectDirectory, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export function SessionHeader() {
|
||||
const project = createMemo(() => {
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||
return layout.projects.list().find((p) => p.worktree === directory || p.worktrees?.includes(directory))
|
||||
})
|
||||
const name = createMemo(() => {
|
||||
const current = project()
|
||||
|
||||
@@ -20,8 +20,8 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
|
||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||
const worktrees = createMemo(() => sync.project?.worktrees ?? [])
|
||||
const options = createMemo(() => [MAIN_WORKTREE, ...worktrees(), CREATE_WORKTREE])
|
||||
const current = createMemo(() => {
|
||||
const selection = props.worktree
|
||||
if (options().includes(selection)) return selection
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
Todo,
|
||||
@@ -27,18 +26,19 @@ import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } fr
|
||||
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import type { ProjectInfo, ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
path: Path
|
||||
project: Project[]
|
||||
project: ProjectInfo[]
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
@@ -122,18 +122,6 @@ function createGlobalSync() {
|
||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||
})
|
||||
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
setGlobalStore("project", next)
|
||||
}
|
||||
|
||||
const setBootStore = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||
setProjects(input[1] as Project[])
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: ["bootstrap"],
|
||||
queryFn: async () => {
|
||||
@@ -142,7 +130,7 @@ function createGlobalSync() {
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
setGlobalStore,
|
||||
queryClient,
|
||||
})
|
||||
bootedAt = Date.now()
|
||||
@@ -150,13 +138,31 @@ function createGlobalSync() {
|
||||
},
|
||||
}))
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
const set = setGlobalStore
|
||||
|
||||
const addProjectWorktree = (projectWorktree: string, directory: string) => {
|
||||
setGlobalStore(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.worktree === projectWorktree)
|
||||
if (!project) return
|
||||
const key = pathKey(directory)
|
||||
project.worktrees = [directory, ...(project.worktrees ?? []).filter((item) => pathKey(item) !== key)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeProjectWorktree = (projectWorktree: string, directory: string) => {
|
||||
setGlobalStore(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.worktree === projectWorktree)
|
||||
if (!project) return
|
||||
const key = pathKey(directory)
|
||||
project.worktrees = (project.worktrees ?? []).filter((item) => pathKey(item) !== key)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||
if (!sessionID) return
|
||||
@@ -344,7 +350,7 @@ function createGlobalSync() {
|
||||
if (recent) return
|
||||
bootstrap.refetch()
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
setGlobalProject: (next) => setGlobalStore("project", next),
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (recent) return
|
||||
@@ -408,6 +414,8 @@ function createGlobalSync() {
|
||||
icon(directory: string, value: string | undefined) {
|
||||
children.projectIcon(directory, value)
|
||||
},
|
||||
addWorktree: addProjectWorktree,
|
||||
removeWorktree: removeProjectWorktree,
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation(() => ({
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
OpencodeClient,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
@@ -15,7 +14,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ProjectInfo, State, VcsCache } from "./types"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
@@ -24,7 +23,7 @@ import { loadMcpQuery } from "../global-sync"
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
path: Path
|
||||
project: Project[]
|
||||
project: ProjectInfo[]
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
@@ -94,12 +93,21 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
|
||||
queryKey: ["project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
sdk.project.list().then((x) => {
|
||||
return (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
sdk.project.list().then(async (x) => {
|
||||
return Promise.all(
|
||||
(x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.map(async (project) => ({
|
||||
...project,
|
||||
worktrees: await sdk.worktree
|
||||
.list({ directory: project.worktree })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
})),
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -140,8 +148,8 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||
}, {})
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
function projectID(directory: string, projects: ProjectInfo[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.worktrees?.includes(directory))?.id
|
||||
}
|
||||
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
||||
@@ -207,7 +215,7 @@ export async function bootstrapDirectory(input: {
|
||||
global: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
project: ProjectInfo[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
queryClient: QueryClient
|
||||
|
||||
@@ -231,6 +231,7 @@ export function createChildStoreManager(input: {
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
|
||||
@@ -81,6 +81,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
limit: 10,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
...input,
|
||||
}) as State
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ProjectInfo, State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
@@ -20,8 +20,8 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
||||
project: ProjectInfo[]
|
||||
setGlobalProject: (next: ProjectInfo[] | ((draft: ProjectInfo[]) => ProjectInfo[])) => void
|
||||
refresh: () => void
|
||||
}) {
|
||||
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||
@@ -42,7 +42,7 @@ export function applyGlobalEvent(input: {
|
||||
}
|
||||
input.setGlobalProject(
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, properties)
|
||||
draft.splice(result.index, 0, { ...properties, worktrees: [] })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: {
|
||||
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
const parts = draft.part[props.messageID]
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}
|
||||
}
|
||||
delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
@@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: {
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
@@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[props.messageID]
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
@@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: {
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
|
||||
input.setStore(
|
||||
"part",
|
||||
props.messageID,
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
@@ -47,12 +48,14 @@ describe("app session cache", () => {
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
@@ -70,6 +73,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
@@ -78,6 +82,7 @@ describe("app session cache", () => {
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
@@ -18,6 +18,7 @@ type SessionCache = {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
||||
@@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
for (const key of Object.keys(store.part)) {
|
||||
const parts = store.part[key]
|
||||
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
||||
for (const part of parts) {
|
||||
delete store.part_text_accum_delta[part.id]
|
||||
}
|
||||
delete store.part[key]
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
@@ -30,6 +31,10 @@ export type ProjectMeta = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ProjectInfo = Project & {
|
||||
worktrees?: string[]
|
||||
}
|
||||
|
||||
export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
agent: Agent[]
|
||||
@@ -72,6 +77,9 @@ export type State = {
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
part_text_accum_delta: {
|
||||
[partID: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProjectInfo } from "./types"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
@@ -27,7 +28,7 @@ export function normalizeProviderList(input: ProviderListResponse): ProviderList
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeProject(project: Project) {
|
||||
export function sanitizeProject(project: ProjectInfo) {
|
||||
if (!project.icon?.url && !project.icon?.override) return project
|
||||
return {
|
||||
...project,
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import type { ProjectInfo } from "./global-sync/types"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
@@ -51,7 +51,7 @@ type TabHandoff = {
|
||||
at: number
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
export type LocalProject = Partial<ProjectInfo> & { worktree: string; expanded: boolean }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
|
||||
@@ -404,9 +404,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const roots = createMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const project of globalSync.data.project) {
|
||||
const sandboxes = project.sandboxes ?? []
|
||||
for (const sandbox of sandboxes) {
|
||||
map.set(sandbox, project.worktree)
|
||||
for (const worktree of project.worktrees ?? []) {
|
||||
map.set(worktree, project.worktree)
|
||||
}
|
||||
}
|
||||
return map
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
latestRootSession,
|
||||
projectDirectories,
|
||||
sortedRootSessions,
|
||||
} from "./layout/helpers"
|
||||
import {
|
||||
@@ -560,8 +561,8 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const projects = layout.projects.list()
|
||||
|
||||
const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key))
|
||||
if (sandbox) return sandbox
|
||||
const worktree = projects.find((p) => p.worktrees?.some((item) => pathKey(item) === key))
|
||||
if (worktree) return worktree
|
||||
|
||||
const direct = projects.find((p) => pathKey(p.worktree) === key)
|
||||
if (direct) return direct
|
||||
@@ -646,7 +647,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!expanded) continue
|
||||
const key = pathKey(directory)
|
||||
const project = projects.find(
|
||||
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
(item) => pathKey(item.worktree) === key || item.worktrees?.some((worktree) => pathKey(worktree) === key),
|
||||
)
|
||||
if (!project) continue
|
||||
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
|
||||
@@ -1223,7 +1224,7 @@ export default function Layout(props: ParentProps) {
|
||||
const key = pathKey(directory)
|
||||
const project = layout.projects
|
||||
.list()
|
||||
.find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key))
|
||||
.find((item) => pathKey(item.worktree) === key || item.worktrees?.some((worktree) => pathKey(worktree) === key))
|
||||
if (project) return project.worktree
|
||||
|
||||
const known = Object.entries(store.workspaceOrder).find(
|
||||
@@ -1274,9 +1275,7 @@ export default function Layout(props: ParentProps) {
|
||||
const root = projectRoot(directory)
|
||||
server.projects.touch(root)
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
let dirs = project
|
||||
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||
: [root]
|
||||
let dirs = project ? effectiveWorkspaceOrder(root, projectDirectories(project), store.workspaceOrder[root]) : [root]
|
||||
const canOpen = (value: string | undefined) => {
|
||||
if (!value) return false
|
||||
return dirs.some((item) => pathKey(item) === pathKey(value))
|
||||
@@ -1509,14 +1508,7 @@ export default function Layout(props: ParentProps) {
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
globalSync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.worktree === root)
|
||||
if (!project) return
|
||||
project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory)
|
||||
}),
|
||||
)
|
||||
globalSync.project.removeWorktree(root, directory)
|
||||
setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory))
|
||||
|
||||
layout.projects.close(directory)
|
||||
@@ -1528,7 +1520,7 @@ export default function Layout(props: ParentProps) {
|
||||
const nextKey = pathKey(nextCurrent)
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
const dirs = project
|
||||
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||
? effectiveWorkspaceOrder(root, projectDirectories(project), store.workspaceOrder[root])
|
||||
: [root]
|
||||
const valid = dirs.some((item) => pathKey(item) === nextKey)
|
||||
|
||||
@@ -1862,7 +1854,7 @@ export default function Layout(props: ParentProps) {
|
||||
function workspaceIds(project: LocalProject | undefined) {
|
||||
if (!project) return []
|
||||
const local = project.worktree
|
||||
const dirs = [local, ...(project.sandboxes ?? [])]
|
||||
const dirs = projectDirectories(project)
|
||||
const active = currentProject()
|
||||
const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined
|
||||
const extra =
|
||||
@@ -1954,6 +1946,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
return [created.directory, ...next]
|
||||
})
|
||||
globalSync.project.addWorktree(project.worktree, created.directory)
|
||||
|
||||
globalSync.child(created.directory)
|
||||
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
|
||||
|
||||
@@ -55,6 +55,11 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
project.name || getFilename(project.worktree)
|
||||
|
||||
export const projectDirectories = (project: { worktree: string; worktrees?: string[] }) => [
|
||||
project.worktree,
|
||||
...(project.worktrees ?? []),
|
||||
]
|
||||
|
||||
export const errorMessage = (err: unknown, fallback: string) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
|
||||
@@ -15,7 +15,7 @@ import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
|
||||
import { childSessionOnPath, hasProjectPermissions, projectDirectories } from "./helpers"
|
||||
|
||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ProjectIcon = (props: {
|
||||
const globalSync = useGlobalSync()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
||||
const dirs = createMemo(() => projectDirectories(props.project))
|
||||
const unseenCount = createMemo(() =>
|
||||
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
|
||||
@@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
@@ -70,7 +76,8 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -81,7 +88,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of props.diffs()) {
|
||||
for (const diff of diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -79,6 +80,7 @@
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UsageSection() {
|
||||
}
|
||||
|
||||
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||
return u.outputTokens + (u.reasoningTokens ?? 0)
|
||||
return u.outputTokens
|
||||
}
|
||||
|
||||
const goPrev = async () => {
|
||||
|
||||
@@ -889,10 +889,6 @@ export async function handler(
|
||||
|
||||
const inputCost = modelCost.input * inputTokens * 100
|
||||
const outputCost = modelCost.output * outputTokens * 100
|
||||
const reasoningCost = (() => {
|
||||
if (!reasoningTokens) return undefined
|
||||
return modelCost.output * reasoningTokens * 100
|
||||
})()
|
||||
const cacheReadCost = (() => {
|
||||
if (!cacheReadTokens) return undefined
|
||||
if (!modelCost.cacheRead) return undefined
|
||||
@@ -909,17 +905,11 @@ export async function handler(
|
||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||
})()
|
||||
const totalCostInCent =
|
||||
inputCost +
|
||||
outputCost +
|
||||
(reasoningCost ?? 0) +
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
|
||||
return {
|
||||
totalCostInCent,
|
||||
inputCost,
|
||||
outputCost,
|
||||
reasoningCost,
|
||||
cacheReadCost,
|
||||
cacheWrite5mCost,
|
||||
cacheWrite1hCost,
|
||||
@@ -941,8 +931,7 @@ export async function handler(
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
usageInfo
|
||||
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
|
||||
costInfo
|
||||
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
@@ -953,14 +942,12 @@ export async function handler(
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input.microcents": centsToMicroCents(inputCost),
|
||||
"cost.output.microcents": centsToMicroCents(outputCost),
|
||||
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
|
||||
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
|
||||
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
|
||||
"cost.total.microcents": centsToMicroCents(totalCostInCent),
|
||||
// deprecated - remove after May 20, 2026
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
|
||||
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
|
||||
* explosions for branded primitives (`string & Brand<"SessionID">` extends
|
||||
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
|
||||
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
|
||||
* mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
|
||||
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
|
||||
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
|
||||
*/
|
||||
@@ -11,9 +11,12 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels that default to the new effect-httpapi server backend. The legacy
|
||||
// hono backend remains the default for stable (`prod`/`latest`) installs.
|
||||
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
// Channels where new experiments default to ON (unstable / internal users).
|
||||
// Stable channels (`prod`, `latest`) stay opt-in.
|
||||
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
function unstableDefault(key: string) {
|
||||
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
|
||||
}
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
@@ -53,6 +56,9 @@ export const Flag = {
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
// Default-on for dev/beta/local; opt-in for stable. Set
|
||||
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
|
||||
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
@@ -88,14 +94,6 @@ export const Flag = {
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
// Defaults to true on dev/beta/local channels so internal users exercise the
|
||||
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
|
||||
// on the legacy hono backend until the rollout is complete. An explicit env
|
||||
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
|
||||
// stable users and an escape hatch for dev/beta users.
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI:
|
||||
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
|
||||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.41"
|
||||
version = "1.14.48"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# @opencode-ai/http-recorder
|
||||
|
||||
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
|
||||
exercise real request shapes against deterministic, version-controlled
|
||||
cassettes — no manual mocks, no flakes from upstream drift.
|
||||
|
||||
## Install
|
||||
|
||||
Internal package; depended on as `@opencode-ai/http-recorder` from another
|
||||
workspace package.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
|
||||
By default the layer records on first run and replays on subsequent runs —
|
||||
no env-var ternary at the call site, and `CI=true` forces strict replay so
|
||||
missing cassettes fail loudly in CI rather than silently re-recording.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
|
||||
return yield* response.json
|
||||
})
|
||||
|
||||
// Records if the cassette is missing, replays if it exists.
|
||||
// In CI (CI=true) always replays — fails loudly on missing fixtures.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
|
||||
|
||||
// Force a refresh — always hits upstream and overwrites.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
|
||||
| `replay` | Strict — match the request to a recorded interaction; error if none. |
|
||||
| `record` | Execute upstream, append the interaction, write the cassette. |
|
||||
| `passthrough` | Bypass the recorder entirely — just call upstream. |
|
||||
|
||||
## Cassette format
|
||||
|
||||
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
|
||||
"response": { "status": 200, "headers": {...}, "body": "..." }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Cassettes are normal source files — review them, diff them, commit them.
|
||||
|
||||
## Request matching
|
||||
|
||||
Replay walks the cassette in record order via an internal cursor: the Nth
|
||||
request executed at runtime is served by the Nth recorded interaction, and
|
||||
each one is validated as the cursor advances. Request equality is computed
|
||||
on canonicalized method, URL, headers, and JSON body (object keys sorted).
|
||||
|
||||
This is deliberately strict — content-based dispatch was removed because
|
||||
it silently returns the first recorded response for repeated identical
|
||||
requests, masking state changes that retry/polling/cache-hit tests need to
|
||||
observe. If you reorder requests in a test, re-record the cassette.
|
||||
|
||||
Supply your own matcher via `match: (incoming, recorded) => boolean` for
|
||||
custom equivalence (e.g. ignoring a timestamp field in the body).
|
||||
|
||||
## Redaction & secret safety
|
||||
|
||||
Cassettes get checked in, so the recorder is aggressive about not letting
|
||||
secrets escape. Redaction is configured by composing a `Redactor`:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
|
||||
HttpRecorder.cassetteLayer("anthropic/messages", {
|
||||
redactor: Redactor.defaults({
|
||||
requestHeaders: { allow: ["content-type", "anthropic-version"] },
|
||||
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
|
||||
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`Redactor.defaults({ … })` composes the four built-in redactors with your
|
||||
overrides. For full control, build the stack yourself:
|
||||
|
||||
```ts
|
||||
const redactor = Redactor.compose(
|
||||
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
|
||||
Redactor.responseHeaders(),
|
||||
Redactor.url({ query: ["session-id"] }),
|
||||
Redactor.body((parsed) => /* … */),
|
||||
)
|
||||
```
|
||||
|
||||
What each layer does:
|
||||
|
||||
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
|
||||
allow-list (request default: `content-type`, `accept`, `openai-beta`;
|
||||
response default: `content-type`). Sensitive headers within the
|
||||
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
|
||||
…) are replaced with `[REDACTED]`.
|
||||
- **`url`** — query parameters matching common secret names (`api_key`,
|
||||
`token`, `signature`, AWS signing params, …) are replaced with
|
||||
`[REDACTED]`. URL user/password are replaced. `transform` runs after
|
||||
built-in redaction for path-level scrubbing.
|
||||
- **`body`** — receives the parsed JSON request body and returns a redacted
|
||||
version. No-op for non-JSON bodies.
|
||||
|
||||
After assembling the cassette, the recorder scans every string for known
|
||||
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
|
||||
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
|
||||
environment variable named like a credential. If anything is found, the
|
||||
cassette is **not written** and the request fails with `UnsafeCassetteError`
|
||||
listing what was detected.
|
||||
|
||||
## WebSocket recording
|
||||
|
||||
WebSocket support records the open frame plus client/server message
|
||||
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
|
||||
can live in the same cassette.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "ws/subscribe",
|
||||
cassette,
|
||||
live: liveExecutor,
|
||||
})
|
||||
// use executor.open(...)
|
||||
})
|
||||
```
|
||||
|
||||
## Inspecting cassettes programmatically
|
||||
|
||||
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
|
||||
returns the recorded interactions for a name; the file format is hidden
|
||||
behind the seam. Useful for CI checks:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const audit = Effect.gen(function* () {
|
||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||
const entries = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||
cassettes
|
||||
.read(entry.name)
|
||||
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
)
|
||||
return issues.filter((i) => i.findings.length > 0)
|
||||
})
|
||||
```
|
||||
|
||||
`cassetteLayer` is the batteries-included entry point — it provides
|
||||
`Cassette.fileSystem({ directory })` automatically. If you want to provide
|
||||
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
|
||||
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
|
||||
`Cassette.memory` yourself.
|
||||
|
||||
## Options reference
|
||||
|
||||
```ts
|
||||
type RecordReplayOptions = {
|
||||
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
|
||||
directory?: string // default: <cwd>/test/fixtures/recordings
|
||||
metadata?: Record<string, unknown> // merged into cassette.metadata
|
||||
redactor?: Redactor // default: Redactor.defaults()
|
||||
match?: (incoming, recorded) => boolean // custom matcher
|
||||
}
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | -------------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
|
||||
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
|
||||
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.0.0",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,54 +1,76 @@
|
||||
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
|
||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
||||
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
|
||||
import { secretFindings, type SecretFinding } from "./redaction"
|
||||
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
export interface Entry {
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
|
||||
cassetteName: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cassette "${this.cassetteName}" not found`
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly path: (name: string) => string
|
||||
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
|
||||
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
|
||||
readonly append: (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
) => Effect.Effect<
|
||||
{
|
||||
readonly cassette: Cassette
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
},
|
||||
PlatformError.PlatformError
|
||||
>
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
|
||||
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
||||
|
||||
export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
|
||||
|
||||
const buildCassette = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
|
||||
interactions,
|
||||
})
|
||||
|
||||
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const fileSystem = (
|
||||
options: { readonly directory?: string } = {},
|
||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
|
||||
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||
const directoriesEnsured = new Set<string>()
|
||||
|
||||
const pathFor = (name: string) => cassettePath(name, directory)
|
||||
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
|
||||
|
||||
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
|
||||
const ensureDirectory = (name: string) =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fileSystem
|
||||
.readDirectory(directory)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const dir = path.dirname(cassettePath(name))
|
||||
if (directoriesEnsured.has(dir)) return
|
||||
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
directoriesEnsured.add(dir)
|
||||
})
|
||||
|
||||
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const nested = yield* Effect.forEach(entries, (entry) => {
|
||||
const full = path.join(directory, entry)
|
||||
return fileSystem.stat(full).pipe(
|
||||
const full = path.join(current, entry)
|
||||
return fs.stat(full).pipe(
|
||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
@@ -56,53 +78,73 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
return nested.flat()
|
||||
})
|
||||
|
||||
const read = Effect.fn("Cassette.read")(function* (name: string) {
|
||||
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
fs.readFileString(cassettePath(name)).pipe(
|
||||
Effect.map((raw) => parseCassette(raw).interactions),
|
||||
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
||||
),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.gen(function* () {
|
||||
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||
if (!recorded.has(name)) recorded.set(name, entry)
|
||||
entry.interactions.push(interaction)
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
if (findings.length === 0) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
}
|
||||
return { findings }
|
||||
}),
|
||||
exists: (name) =>
|
||||
fs.access(cassettePath(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
list: () =>
|
||||
walk(directory).pipe(
|
||||
Effect.map((files) =>
|
||||
files
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) =>
|
||||
path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
)
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
|
||||
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
|
||||
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
|
||||
})
|
||||
|
||||
const append = Effect.fn("Cassette.append")(function* (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
) {
|
||||
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
|
||||
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
|
||||
)
|
||||
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
|
||||
const findings = cassetteSecretFindings(cassette)
|
||||
if (findings.length === 0) yield* write(name, cassette)
|
||||
return { cassette, findings }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
|
||||
return yield* fileSystem.access(pathFor(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Cassette.list")(function* () {
|
||||
return (yield* walk(directory))
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => ({
|
||||
name: path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
path: file,
|
||||
}))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer()
|
||||
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
|
||||
Layer.sync(Service, () => {
|
||||
const stored = new Map<string, Interaction[]>(
|
||||
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
|
||||
)
|
||||
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
||||
|
||||
export * as Cassette from "./cassette"
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
stored.has(name)
|
||||
? Effect.succeed(stored.get(name) ?? [])
|
||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.sync(() => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const findings = accumulatedFindings.get(name)
|
||||
if (findings) findings.push(...secretFindings(interaction))
|
||||
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||
}),
|
||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Option } from "effect"
|
||||
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
|
||||
import { decodeJson } from "./matching"
|
||||
import { REDACTED, redactUrl, secretFindings } from "./redaction"
|
||||
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
|
||||
|
||||
const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
|
||||
if (!text) return String(value)
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
|
||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||
if (Object.is(expected, received)) return []
|
||||
if (
|
||||
expected &&
|
||||
received &&
|
||||
typeof expected === "object" &&
|
||||
typeof received === "object" &&
|
||||
!Array.isArray(expected) &&
|
||||
!Array.isArray(received)
|
||||
) {
|
||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||
.toSorted()
|
||||
.flatMap((key) =>
|
||||
valueDiffs(
|
||||
(expected as Record<string, unknown>)[key],
|
||||
(received as Record<string, unknown>)[key],
|
||||
`${base}.${key}`,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
.slice(0, limit)
|
||||
}
|
||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||
}
|
||||
|
||||
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
||||
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
||||
if (expected[key] === received[key]) return []
|
||||
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
||||
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
||||
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
||||
})
|
||||
|
||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
|
||||
const lines = []
|
||||
if (expected.method !== received.method) {
|
||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||
}
|
||||
if (expected.url !== received.url) {
|
||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
}
|
||||
const headers = headerDiffs(expected.headers, received.headers)
|
||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||
const expectedBody = jsonBody(expected.body)
|
||||
const receivedBody = jsonBody(received.body)
|
||||
const body =
|
||||
expectedBody !== undefined && receivedBody !== undefined
|
||||
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
||||
: expected.body === received.body
|
||||
? []
|
||||
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
||||
if (body.length > 0) lines.push("body:", ...body)
|
||||
return lines
|
||||
}
|
||||
|
||||
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
|
||||
const interactions = httpInteractions(cassette)
|
||||
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
|
||||
const ranked = interactions
|
||||
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
|
||||
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
|
||||
const best = ranked[0]
|
||||
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
|
||||
}
|
||||
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
@@ -1,62 +1,36 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option, Ref } from "effect"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
HttpBody,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
UrlParams,
|
||||
} from "effect/unstable/http"
|
||||
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
|
||||
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
|
||||
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
||||
import {
|
||||
httpInteractions,
|
||||
type Cassette,
|
||||
type CassetteMetadata,
|
||||
type HttpInteraction,
|
||||
type ResponseSnapshot,
|
||||
} from "./schema"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
export type RecordReplayMode = "record" | "replay" | "passthrough"
|
||||
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
||||
|
||||
export interface RecordReplayOptions {
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly directory?: string
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly redact?: {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly url?: (url: string) => string
|
||||
}
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
readonly responseHeaders?: ReadonlyArray<string>
|
||||
readonly redactBody?: (body: unknown) => unknown
|
||||
readonly dispatch?: "match" | "sequential"
|
||||
readonly redactor?: Redactor
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
|
||||
const responseHeaders = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
allow: ReadonlyArray<string>,
|
||||
redact: ReadonlyArray<string> | undefined,
|
||||
) => {
|
||||
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
|
||||
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
|
||||
return merged
|
||||
}
|
||||
|
||||
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
|
||||
|
||||
const isBinaryContentType = (contentType: string | undefined) => {
|
||||
if (!contentType) return false
|
||||
const lower = contentType.toLowerCase()
|
||||
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
|
||||
}
|
||||
const isBinaryContentType = (contentType: string | undefined) =>
|
||||
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
|
||||
|
||||
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
||||
isBinaryContentType(contentType)
|
||||
@@ -68,34 +42,19 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
|
||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||
|
||||
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
|
||||
}),
|
||||
})
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
|
||||
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
|
||||
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
const unsafeCassette = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
name: string,
|
||||
findings: ReadonlyArray<SecretFinding>,
|
||||
) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
|
||||
.map((item) => `${item.path} (${item.reason})`)
|
||||
.join(", ")}`,
|
||||
}),
|
||||
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
||||
})
|
||||
|
||||
export const recordingLayer = (
|
||||
@@ -107,61 +66,21 @@ export const recordingLayer = (
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* HttpClient.HttpClient
|
||||
const cassetteService = yield* CassetteService.Service
|
||||
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
|
||||
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const match = options.match ?? defaultMatcher
|
||||
const mode = options.mode ?? "replay"
|
||||
const sequential = options.dispatch === "sequential"
|
||||
const replay = yield* Ref.make<Cassette | undefined>(undefined)
|
||||
const cursor = yield* Ref.make(0)
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
||||
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||
|
||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
const raw = yield* Effect.promise(() => web.text())
|
||||
const body = options.redactBody
|
||||
? Option.match(decodeJson(raw), {
|
||||
onNone: () => raw,
|
||||
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
|
||||
})
|
||||
: raw
|
||||
return {
|
||||
return redactor.request({
|
||||
method: web.method,
|
||||
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
|
||||
headers: redactHeaders(
|
||||
Object.fromEntries(web.headers.entries()),
|
||||
requestHeadersAllow,
|
||||
options.redact?.headers,
|
||||
),
|
||||
body,
|
||||
}
|
||||
})
|
||||
|
||||
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
|
||||
Effect.gen(function* () {
|
||||
const interactions = httpInteractions(cassette)
|
||||
if (sequential) {
|
||||
const index = yield* Ref.get(cursor)
|
||||
const interaction = interactions[index]
|
||||
if (!interaction)
|
||||
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||
if (!match(incoming, interaction.request)) {
|
||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||
}
|
||||
yield* Ref.update(cursor, (n) => n + 1)
|
||||
return { interaction, detail: "" }
|
||||
}
|
||||
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
||||
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
|
||||
})
|
||||
|
||||
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* Ref.get(replay)
|
||||
if (cached) return cached
|
||||
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
|
||||
yield* Ref.set(replay, cassette)
|
||||
return cassette
|
||||
url: web.url,
|
||||
headers: Object.fromEntries(web.headers.entries()),
|
||||
body: yield* Effect.promise(() => web.text()),
|
||||
})
|
||||
})
|
||||
|
||||
return HttpClient.make((request) => {
|
||||
@@ -169,18 +88,21 @@ export const recordingLayer = (
|
||||
|
||||
if (mode === "record") {
|
||||
return Effect.gen(function* () {
|
||||
const currentRequest = yield* snapshotRequest(request)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const response = yield* upstream.execute(request)
|
||||
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
|
||||
const captured = yield* captureResponseBody(response, headers["content-type"])
|
||||
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
||||
const interaction: HttpInteraction = {
|
||||
transport: "http",
|
||||
request: currentRequest,
|
||||
response: { status: response.status, headers, ...captured },
|
||||
request: incoming,
|
||||
response: redactor.response({
|
||||
status: response.status,
|
||||
headers: response.headers as Record<string, string>,
|
||||
...captured,
|
||||
}),
|
||||
}
|
||||
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
|
||||
const findings = result.findings
|
||||
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
|
||||
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
@@ -189,14 +111,21 @@ export const recordingLayer = (
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const cassette = yield* loadReplay(request)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
||||
|
||||
const interactions = yield* replay.load.pipe(
|
||||
Effect.mapError(() =>
|
||||
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
|
||||
),
|
||||
)
|
||||
const result = selectSequential(interactions, incoming, match, yield* replay.cursor)
|
||||
if (!result.interaction)
|
||||
return yield* Effect.fail(
|
||||
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
|
||||
)
|
||||
yield* replay.advance
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -205,7 +134,7 @@ export const recordingLayer = (
|
||||
|
||||
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||
recordingLayer(name, options).pipe(
|
||||
Layer.provide(CassetteService.layer({ directory: options.directory })),
|
||||
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
export * from "./schema"
|
||||
export * from "./redaction"
|
||||
export * from "./matching"
|
||||
export * from "./diff"
|
||||
export * from "./storage"
|
||||
export * from "./websocket"
|
||||
export * from "./effect"
|
||||
export type {
|
||||
CassetteMetadata,
|
||||
HttpInteraction,
|
||||
Interaction,
|
||||
RequestSnapshot,
|
||||
ResponseSnapshot,
|
||||
WebSocketFrame,
|
||||
WebSocketInteraction,
|
||||
} from "./schema"
|
||||
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||
export { UnsafeCassetteError } from "./recorder"
|
||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||
export {
|
||||
makeWebSocketExecutor,
|
||||
type WebSocketConnection,
|
||||
type WebSocketExecutor,
|
||||
type WebSocketRecordReplayOptions,
|
||||
type WebSocketRequest,
|
||||
} from "./websocket"
|
||||
|
||||
export * as Cassette from "./cassette"
|
||||
export * as Redactor from "./redactor"
|
||||
|
||||
export * as HttpRecorder from "."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import type { RequestSnapshot } from "./schema"
|
||||
import { REDACTED, secretFindings } from "./redaction"
|
||||
import type { HttpInteraction, RequestSnapshot } from "./schema"
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
@@ -34,3 +35,72 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
|
||||
const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = JSON.stringify(value)
|
||||
if (!text) return String(value)
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
|
||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||
if (Object.is(expected, received)) return []
|
||||
if (isRecord(expected) && isRecord(received)) {
|
||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||
.toSorted()
|
||||
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||
}
|
||||
|
||||
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
||||
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
||||
if (expected[key] === received[key]) return []
|
||||
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
||||
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
||||
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
||||
})
|
||||
|
||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
|
||||
const lines: string[] = []
|
||||
if (expected.method !== received.method) {
|
||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||
}
|
||||
if (expected.url !== received.url) {
|
||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
}
|
||||
const headers = headerDiffs(expected.headers, received.headers)
|
||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||
const expectedBody = jsonBody(expected.body)
|
||||
const receivedBody = jsonBody(received.body)
|
||||
const body =
|
||||
expectedBody !== undefined && receivedBody !== undefined
|
||||
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
||||
: expected.body === received.body
|
||||
? []
|
||||
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
||||
if (body.length > 0) lines.push("body:", ...body)
|
||||
return lines
|
||||
}
|
||||
|
||||
export const selectSequential = (
|
||||
interactions: ReadonlyArray<HttpInteraction>,
|
||||
incoming: RequestSnapshot,
|
||||
match: RequestMatcher,
|
||||
index: number,
|
||||
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||
const interaction = interactions[index]
|
||||
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||
if (!match(incoming, interaction.request))
|
||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||
return { interaction, detail: "" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Effect, Ref, Schema, Scope } from "effect"
|
||||
import type * as CassetteService from "./cassette"
|
||||
import type { CassetteNotFoundError } from "./cassette"
|
||||
import { SecretFindingSchema } from "./redaction"
|
||||
import type { CassetteMetadata, Interaction } from "./schema"
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedMode = "record" | "replay" | "passthrough"
|
||||
|
||||
const isCI = () => {
|
||||
const value = process.env.CI
|
||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||
}
|
||||
|
||||
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export const appendOrFail = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Effect.Effect<void, UnsafeCassetteError> =>
|
||||
cassette
|
||||
.append(name, interaction, metadata)
|
||||
.pipe(
|
||||
Effect.flatMap(({ findings }) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||
readonly cursor: Effect.Effect<number>
|
||||
readonly advance: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const makeReplayState = <T>(
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
||||
const position = yield* Ref.make(0)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
const used = yield* Ref.get(position)
|
||||
if (used === 0) return
|
||||
const interactions = yield* load.pipe(Effect.orDie)
|
||||
if (used < interactions.length)
|
||||
yield* Effect.die(
|
||||
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) }
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Cassette } from "./schema"
|
||||
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
@@ -97,10 +95,13 @@ export const redactHeaders = (
|
||||
)
|
||||
}
|
||||
|
||||
export type SecretFinding = {
|
||||
readonly path: string
|
||||
readonly reason: string
|
||||
}
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const SecretFindingSchema = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
})
|
||||
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
|
||||
|
||||
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||
stringEntries(value).flatMap((entry) => [
|
||||
@@ -112,5 +113,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||
.filter((item) => entry.value.includes(item.value))
|
||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
||||
])
|
||||
|
||||
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Option } from "effect"
|
||||
import { decodeJson } from "./matching"
|
||||
import { redactHeaders, redactUrl } from "./redaction"
|
||||
import type { RequestSnapshot, ResponseSnapshot } from "./schema"
|
||||
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export interface Redactor {
|
||||
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
|
||||
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
|
||||
}
|
||||
|
||||
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
|
||||
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
|
||||
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
|
||||
return {
|
||||
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
|
||||
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeaderOptions {
|
||||
readonly allow?: ReadonlyArray<string>
|
||||
readonly redact?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
response: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface UrlOptions {
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly transform?: (url: string) => string
|
||||
}
|
||||
|
||||
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
|
||||
})
|
||||
|
||||
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: (parsed) => JSON.stringify(transform(parsed)),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface DefaultRedactorOverrides {
|
||||
readonly requestHeaders?: HeaderOptions
|
||||
readonly responseHeaders?: HeaderOptions
|
||||
readonly url?: UrlOptions
|
||||
readonly body?: (parsed: unknown) => unknown
|
||||
}
|
||||
|
||||
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
|
||||
compose(
|
||||
requestHeaders(overrides.requestHeaders),
|
||||
responseHeaders(overrides.responseHeaders),
|
||||
url(overrides.url),
|
||||
...(overrides.body ? [body(overrides.body)] : []),
|
||||
)
|
||||
@@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http
|
||||
|
||||
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
||||
|
||||
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
|
||||
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
|
||||
|
||||
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
|
||||
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
||||
interactions.filter(isWebSocketInteraction)
|
||||
|
||||
export const CassetteSchema = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Option } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
|
||||
|
||||
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
|
||||
name,
|
||||
recordedAt: new Date().toISOString(),
|
||||
...(metadata ?? {}),
|
||||
})
|
||||
|
||||
export const cassetteFor = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: metadataFor(name, metadata),
|
||||
interactions,
|
||||
})
|
||||
|
||||
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
|
||||
const file = cassettePath(name, options.directory)
|
||||
if (!fs.existsSync(file)) return false
|
||||
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { canonicalizeJson, decodeJson } from "./matching"
|
||||
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
|
||||
|
||||
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import type { RecordReplayMode } from "./effect"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||
|
||||
export interface WebSocketRequest {
|
||||
readonly url: string
|
||||
@@ -24,67 +24,36 @@ export interface WebSocketExecutor<E> {
|
||||
|
||||
export interface WebSocketRecordReplayOptions<E> {
|
||||
readonly name: string
|
||||
readonly mode?: "record" | "replay" | "passthrough"
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly cassette: CassetteService.Interface
|
||||
readonly live: WebSocketExecutor<E>
|
||||
readonly redact?: {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly url?: (url: string) => string
|
||||
}
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
readonly redactor?: Redactor
|
||||
readonly compareClientMessagesAsJson?: boolean
|
||||
}
|
||||
|
||||
const headersRecord = (headers: Headers.Headers) =>
|
||||
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(headers as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
Object.entries(headers as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
)
|
||||
|
||||
const openSnapshot = (
|
||||
request: WebSocketRequest,
|
||||
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
|
||||
) => ({
|
||||
url: redactUrl(request.url, options.redact?.query, options.redact?.url),
|
||||
headers: redactHeaders(
|
||||
headersRecord(request.headers),
|
||||
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
|
||||
options.redact?.headers,
|
||||
),
|
||||
})
|
||||
|
||||
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
|
||||
|
||||
const frameText = (frame: WebSocketFrame) => {
|
||||
if (frame.kind === "text") return frame.body
|
||||
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||
}
|
||||
|
||||
const frameMessage = (frame: WebSocketFrame) =>
|
||||
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
||||
|
||||
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||
typeof message === "string"
|
||||
? textFrame(message)
|
||||
? { kind: "text", body: message }
|
||||
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||
|
||||
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
||||
new Error(
|
||||
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
|
||||
.map((item) => `${item.path} (${item.reason})`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
|
||||
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
||||
|
||||
const mismatch = (message: string, actual: unknown, expected: unknown) =>
|
||||
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
const decodeFrameText = (frame: WebSocketFrame) =>
|
||||
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||
|
||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||
Effect.sync(() => {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||
throw mismatch(message, actual, expected)
|
||||
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
})
|
||||
|
||||
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
||||
@@ -94,7 +63,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin
|
||||
return Effect.sync(() => {
|
||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
||||
})
|
||||
const expectedText = frameText(expected)
|
||||
const expectedText = decodeFrameText(expected)
|
||||
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
|
||||
}
|
||||
@@ -103,7 +72,18 @@ export const makeWebSocketExecutor = <E>(
|
||||
options: WebSocketRecordReplayOptions<E>,
|
||||
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const mode = options.mode ?? "replay"
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const openSnapshot = (request: WebSocketRequest) => {
|
||||
const redacted = redactor.request({
|
||||
method: "GET",
|
||||
url: request.url,
|
||||
headers: headersRecord(request.headers),
|
||||
body: "",
|
||||
})
|
||||
return { url: redacted.url, headers: redacted.headers }
|
||||
}
|
||||
|
||||
if (mode === "passthrough") return options.live
|
||||
|
||||
@@ -118,21 +98,21 @@ export const makeWebSocketExecutor = <E>(
|
||||
const closeOnce = Effect.gen(function* () {
|
||||
if (yield* Ref.getAndSet(closed, true)) return
|
||||
yield* connection.close
|
||||
const result = yield* options.cassette
|
||||
.append(
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request, options), client, server },
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
|
||||
yield* appendOrFail(
|
||||
options.cassette,
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||
options.metadata,
|
||||
).pipe(Effect.orDie)
|
||||
})
|
||||
return {
|
||||
sendText: (message: string) =>
|
||||
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
|
||||
sendText: (message) =>
|
||||
connection
|
||||
.sendText(message)
|
||||
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||
messages: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
server.push(receivedFrame(message))
|
||||
server.push(encodeFrame(message))
|
||||
return message
|
||||
}),
|
||||
),
|
||||
@@ -142,44 +122,20 @@ export const makeWebSocketExecutor = <E>(
|
||||
}
|
||||
}
|
||||
|
||||
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const cursor = yield* Ref.make(0)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* Ref.get(replay)
|
||||
if (!input) return
|
||||
yield* assertEqual(
|
||||
`Unused recorded WebSocket interactions in ${options.name}`,
|
||||
yield* Ref.get(cursor),
|
||||
input.interactions.length,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
|
||||
const cached = yield* Ref.get(replay)
|
||||
if (cached) return cached
|
||||
const input = {
|
||||
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
|
||||
}
|
||||
yield* Ref.set(replay, input)
|
||||
return input
|
||||
})
|
||||
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
|
||||
|
||||
return {
|
||||
open: (request) => {
|
||||
return Effect.gen(function* () {
|
||||
const input = yield* loadReplay()
|
||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||
const interaction = input.interactions[index]
|
||||
open: (request) =>
|
||||
Effect.gen(function* () {
|
||||
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||
const index = yield* replay.cursor
|
||||
const interaction = interactions[index]
|
||||
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
|
||||
yield* replay.advance
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
||||
const messageIndex = yield* Ref.make(0)
|
||||
return {
|
||||
sendText: (message: string) =>
|
||||
sendText: (message) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||
yield* compareClientMessage(
|
||||
@@ -189,7 +145,7 @@ export const makeWebSocketExecutor = <E>(
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
}),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
||||
close: Effect.gen(function* () {
|
||||
yield* assertEqual(
|
||||
`WebSocket client frame count for interaction ${index + 1}`,
|
||||
@@ -198,7 +154,6 @@ export const makeWebSocketExecutor = <E>(
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -6,7 +6,16 @@ import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { HttpRecorder } from "../src"
|
||||
import { redactedErrorRequest } from "../src/diff"
|
||||
import { redactedErrorRequest } from "../src/effect"
|
||||
import type { Interaction } from "../src/schema"
|
||||
|
||||
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
|
||||
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
const post = (url: string, body: object) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -33,7 +42,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
|
||||
Effect.scoped(
|
||||
effect.pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
||||
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
||||
),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
@@ -108,7 +117,7 @@ describe("http-recorder", () => {
|
||||
|
||||
test("detects secret-looking values without returning the secret", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
HttpRecorder.secretFindings({
|
||||
version: 1,
|
||||
interactions: [
|
||||
{
|
||||
@@ -136,7 +145,7 @@ describe("http-recorder", () => {
|
||||
|
||||
test("detects secret-looking values inside metadata", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
HttpRecorder.secretFindings({
|
||||
version: 1,
|
||||
metadata: { token: "sk-123456789012345678901234" },
|
||||
interactions: [],
|
||||
@@ -144,60 +153,42 @@ describe("http-recorder", () => {
|
||||
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
||||
})
|
||||
|
||||
test("formats websocket cassettes with shared metadata", () => {
|
||||
const cassette = HttpRecorder.cassetteFor(
|
||||
"websocket/basic",
|
||||
[
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
{ provider: "openai" },
|
||||
)
|
||||
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "websocket/replay",
|
||||
cassette,
|
||||
compareClientMessagesAsJson: true,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/realtime",
|
||||
headers: Headers.fromInput({ "content-type": "application/json" }),
|
||||
})
|
||||
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
||||
yield* connection.close
|
||||
|
||||
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
|
||||
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
|
||||
})
|
||||
|
||||
test("replays websocket interactions from the shared cassette service", async () => {
|
||||
await runRecorder(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
yield* cassette.write(
|
||||
"websocket/replay",
|
||||
HttpRecorder.cassetteFor(
|
||||
"websocket/replay",
|
||||
[
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.memory({
|
||||
"websocket/replay": [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "websocket/replay",
|
||||
cassette,
|
||||
compareClientMessagesAsJson: true,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/realtime",
|
||||
headers: Headers.fromInput({ "content-type": "application/json" }),
|
||||
})
|
||||
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
||||
yield* connection.close
|
||||
|
||||
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -227,34 +218,22 @@ describe("http-recorder", () => {
|
||||
yield* connection.messages.pipe(Stream.runDrain)
|
||||
yield* connection.close
|
||||
|
||||
expect(yield* cassette.read("websocket/record")).toMatchObject({
|
||||
metadata: { name: "websocket/record", provider: "test" },
|
||||
interactions: [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* cassette.read("websocket/record")).toMatchObject([
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("default matcher dispatches multi-interaction cassettes by request shape", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
|
||||
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
|
||||
test("replay returns recorded responses in order for identical requests", async () => {
|
||||
await runWith(
|
||||
"record-replay/retry",
|
||||
{ dispatch: "sequential" },
|
||||
{},
|
||||
Effect.gen(function* () {
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
|
||||
@@ -262,21 +241,8 @@ describe("http-recorder", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("default matcher returns the first match for identical requests", async () => {
|
||||
await runWith(
|
||||
"record-replay/retry",
|
||||
{},
|
||||
Effect.gen(function* () {
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
|
||||
await runWith(
|
||||
"record-replay/multi-step",
|
||||
{ dispatch: "sequential" },
|
||||
test("replay reports cursor exhaustion when more requests are made than recorded", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
yield* post("https://example.test/echo", { step: 1 })
|
||||
yield* post("https://example.test/echo", { step: 2 })
|
||||
@@ -286,10 +252,8 @@ describe("http-recorder", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("sequential dispatch still validates each recorded request", async () => {
|
||||
await runWith(
|
||||
"record-replay/multi-step",
|
||||
{ dispatch: "sequential" },
|
||||
test("replay validates each recorded request in order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
yield* post("https://example.test/echo", { step: 1 })
|
||||
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
|
||||
@@ -300,14 +264,56 @@ describe("http-recorder", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("mismatch diagnostics show closest redacted request differences", async () => {
|
||||
test("auto mode replays when the cassette exists", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
|
||||
await seedCassetteDirectory(directory, "auto-replay", [
|
||||
{
|
||||
transport: "http",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://example.test/echo",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ step: 1 }),
|
||||
},
|
||||
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
|
||||
},
|
||||
])
|
||||
|
||||
const result = await runWith(
|
||||
"auto-replay",
|
||||
{ directory, mode: "auto" },
|
||||
post("https://example.test/echo", { step: 1 }),
|
||||
)
|
||||
expect(result).toBe('{"reply":"hi"}')
|
||||
})
|
||||
|
||||
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
|
||||
const previous = process.env.CI
|
||||
process.env.CI = "true"
|
||||
try {
|
||||
const exit = await Effect.runPromise(
|
||||
Effect.exit(
|
||||
post("https://example.test/echo", { step: 1 }).pipe(
|
||||
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CI
|
||||
else process.env.CI = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
|
||||
)
|
||||
const message = failureText(exit)
|
||||
expect(message).toContain("closest interaction: #1")
|
||||
expect(message).toContain("url:")
|
||||
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
|
||||
expect(message).toContain("body:")
|
||||
|
||||
@@ -289,6 +289,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re
|
||||
|
||||
**Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text.
|
||||
|
||||
**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
|
||||
**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
|
||||
|
||||
Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# @opencode-ai/llm
|
||||
|
||||
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode-ai/llm"
|
||||
import { OpenAI } from "@opencode-ai/llm/providers"
|
||||
|
||||
const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello in one short sentence.",
|
||||
generation: { maxTokens: 40 },
|
||||
})
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
console.log(response.text)
|
||||
})
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors.
|
||||
- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts.
|
||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||
- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams.
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
|
||||
|
||||
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
||||
|
||||
### Opting out
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
model,
|
||||
system,
|
||||
prompt: "one-off question",
|
||||
cache: "none",
|
||||
})
|
||||
```
|
||||
|
||||
### Granular policy
|
||||
|
||||
```ts
|
||||
cache: {
|
||||
tools?: boolean,
|
||||
system?: boolean,
|
||||
messages?: "latest-user-message" | "latest-assistant" | { tail: number },
|
||||
ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m
|
||||
}
|
||||
```
|
||||
|
||||
### Manual hints
|
||||
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "stable system prompt", cache: { type: "ephemeral" } },
|
||||
],
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
### Provider behavior table
|
||||
|
||||
| Protocol | `cache: "auto"` |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
|
||||
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||
|
||||
Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider.
|
||||
|
||||
## Providers
|
||||
|
||||
Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults.
|
||||
|
||||
```ts
|
||||
import { Anthropic } from "@opencode-ai/llm/providers"
|
||||
|
||||
const model = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
```
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
Three escape hatches in order of stability:
|
||||
|
||||
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
|
||||
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
|
||||
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
|
||||
|
||||
Model-level defaults are overridden by request-level values for each axis.
|
||||
|
||||
## Routes
|
||||
|
||||
Adding a new model or deployment is usually 5–15 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail.
|
||||
|
||||
## Effect
|
||||
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
|
||||
## See also
|
||||
|
||||
- `AGENTS.md` — architecture, route construction, contributor guide
|
||||
- `example/tutorial.ts` — runnable end-to-end walkthrough
|
||||
- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes
|
||||
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
stream: {
|
||||
event: Schema.String,
|
||||
initial: () => undefined,
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||
onHalt: () => [{ type: "request-finish", reason: "stop" }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts
|
||||
// the policy designates. Runs once at compile time, before the per-protocol
|
||||
// body builder, so the existing inline-hint lowering path handles the rest.
|
||||
//
|
||||
// The default `"auto"` shape places one breakpoint at the last tool definition,
|
||||
// one at the last system part, and one at the latest user message. This
|
||||
// matches what production agent harnesses (LangChain's caching middleware,
|
||||
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
|
||||
// latest user message stays put while a single turn explodes into many
|
||||
// assistant/tool round-trips, so caching at that boundary lets every
|
||||
// intra-turn API call hit the prefix.
|
||||
//
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved —
|
||||
// this function only fills gaps the caller left empty.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
system: true,
|
||||
messages: "latest-user-message",
|
||||
}
|
||||
|
||||
const NONE: CachePolicyObject = {}
|
||||
|
||||
// Resolution rules:
|
||||
// - undefined → "auto" — caching is on by default. The math favors it:
|
||||
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
||||
// so a single reuse within 5 minutes already wins.
|
||||
// - "auto" → tools + system + latest user msg.
|
||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||
// - object form → exactly what the caller asked for.
|
||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
if (policy === undefined || policy === "auto") return AUTO
|
||||
if (policy === "none") return NONE
|
||||
return policy
|
||||
}
|
||||
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
|
||||
if (tools.length === 0) return tools
|
||||
const last = tools.length - 1
|
||||
if (tools[last]!.cache) return tools
|
||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||
}
|
||||
|
||||
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
|
||||
if (system.length === 0) return system
|
||||
const last = system.length - 1
|
||||
if (system[last]!.cache) return system
|
||||
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
|
||||
}
|
||||
|
||||
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
||||
messages.findLastIndex((m) => m.role === role)
|
||||
|
||||
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
||||
// the last content part regardless of type — that's the breakpoint position
|
||||
// in tool-result-only messages too.
|
||||
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
|
||||
if (index < 0 || index >= messages.length) return messages
|
||||
const target = messages[index]!
|
||||
if (target.content.length === 0) return messages
|
||||
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
||||
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
||||
const existing = target.content[markAt]!
|
||||
if ("cache" in existing && existing.cache) return messages
|
||||
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
||||
const next = new Message({ ...target, content: nextContent })
|
||||
// Single pass over `messages`, substituting the one updated entry. Long
|
||||
// conversations call this on every request, so avoid `.map()` here — its
|
||||
// closure dispatch and identity copies show up in profiling.
|
||||
const result = messages.slice()
|
||||
result[index] = next
|
||||
return result
|
||||
}
|
||||
|
||||
const markMessages = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||
hint: CacheHint,
|
||||
): ReadonlyArray<Message> => {
|
||||
if (messages.length === 0) return messages
|
||||
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
|
||||
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
|
||||
const start = Math.max(0, messages.length - strategy.tail)
|
||||
let next = messages
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
|
||||
return next
|
||||
}
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
|
||||
const system = policy.system ? markLastSystem(request.system, hint) : request.system
|
||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
|
||||
|
||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||
return LLMRequest.update(request, { tools, system, messages })
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
@@ -25,7 +26,10 @@ export const PATH = "/messages"
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") })
|
||||
const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -193,8 +197,24 @@ const invalid = ProviderShared.invalidRequest
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const cacheControl = (cache: CacheHint | undefined) =>
|
||||
cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined
|
||||
// Anthropic accepts at most 4 explicit cache_control breakpoints per request,
|
||||
// across `tools`, `system`, and `messages`. Beyond the cap the API returns a
|
||||
// 400 — so the lowering layer counts emitted markers and silently drops any
|
||||
// that exceed it.
|
||||
const ANTHROPIC_BREAKPOINT_CAP = 4
|
||||
|
||||
const EPHEMERAL_5M = { type: "ephemeral" as const }
|
||||
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
|
||||
|
||||
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
}
|
||||
|
||||
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
|
||||
|
||||
@@ -204,10 +224,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.inputSchema,
|
||||
cache_control: cacheControl(breakpoints, tool.cache),
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -249,7 +270,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -258,7 +282,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text"]))
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"])
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
continue
|
||||
@@ -268,7 +292,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
|
||||
const content: AnthropicAssistantBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
@@ -304,6 +328,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
|
||||
tool_use_id: part.id,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
is_error: part.result.type === "error" ? true : undefined,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
})
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
@@ -330,18 +355,33 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const tools =
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
: request.tools.map((tool) => lowerTool(breakpoints, tool))
|
||||
const system =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
: request.system.map((part) => ({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
}))
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
model: request.model.id,
|
||||
system:
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
: request.system.map((part) => ({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: cacheControl(part.cache),
|
||||
})),
|
||||
messages: yield* lowerMessages(request),
|
||||
tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool),
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096,
|
||||
@@ -364,34 +404,56 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Anthropic reports the non-overlapping breakdown natively — its
|
||||
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
||||
// cache reads and writes as separate fields. We sum them to derive the
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are *not* broken out by Anthropic — they're billed as
|
||||
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
|
||||
// `outputTokens` carries the combined total.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
||||
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||
return new Usage({
|
||||
inputTokens: usage.input_tokens,
|
||||
inputTokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined,
|
||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined),
|
||||
native: usage,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { anthropic: usage },
|
||||
})
|
||||
}
|
||||
|
||||
// Anthropic emits usage on `message_start` and again on `message_delta` — the
|
||||
// final delta carries the authoritative totals. Right-biased merge: each
|
||||
// field prefers `right` when defined, falls back to `left`. `totalTokens` is
|
||||
// recomputed from the merged input/output to stay consistent.
|
||||
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
|
||||
// recomputed from the merged breakdown so the inclusive total stays
|
||||
// consistent with `nonCached + cacheRead + cacheWrite`.
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
if (!left) return right
|
||||
if (!right) return left
|
||||
const inputTokens = right.inputTokens ?? left.inputTokens
|
||||
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
|
||||
const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens
|
||||
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
||||
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
||||
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
native: { ...left.native, ...right.native },
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
...(left.providerMetadata?.["anthropic"] ?? {}),
|
||||
...(right.providerMetadata?.["anthropic"] ?? {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,14 +477,13 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
||||
? String((block.content as Record<string, unknown>).type)
|
||||
: ""
|
||||
const isError = errorPayload.endsWith("_tool_result_error")
|
||||
return {
|
||||
type: "tool-result",
|
||||
return LLMEvent.toolResult({
|
||||
id: block.tool_use_id ?? "",
|
||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||
providerExecuted: true,
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
@@ -453,18 +514,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
}
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
return [state, [{ type: "text-delta", text: block.text }]]
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]]
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
return [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "reasoning-delta",
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
text: block.thinking,
|
||||
...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -480,17 +540,25 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
const delta = event.delta
|
||||
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult
|
||||
return [
|
||||
state,
|
||||
[LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })],
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
return [
|
||||
state,
|
||||
[{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
|
||||
[
|
||||
LLMEvent.reasoningEnd({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
providerMetadata: anthropicMetadata({ signature: delta.signature }),
|
||||
}),
|
||||
],
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -524,21 +592,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
return [
|
||||
{ ...state, usage },
|
||||
[
|
||||
{
|
||||
type: "request-finish",
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
usage,
|
||||
...(event.delta?.stop_sequence
|
||||
? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
|
||||
: {}),
|
||||
},
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }],
|
||||
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
@@ -108,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type<typeof BedrockMessage>
|
||||
const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock])
|
||||
type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
|
||||
|
||||
const BedrockTool = Schema.Struct({
|
||||
const BedrockToolSpec = Schema.Struct({
|
||||
toolSpec: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
@@ -117,6 +117,9 @@ const BedrockTool = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
})
|
||||
type BedrockToolSpec = Schema.Schema.Type<typeof BedrockToolSpec>
|
||||
|
||||
const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock])
|
||||
type BedrockTool = Schema.Schema.Type<typeof BedrockTool>
|
||||
|
||||
const BedrockToolChoice = Schema.Union([
|
||||
@@ -214,7 +217,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const lowerTool = (tool: ToolDefinition): BedrockTool => ({
|
||||
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -222,11 +225,22 @@ const lowerTool = (tool: ToolDefinition): BedrockTool => ({
|
||||
},
|
||||
})
|
||||
|
||||
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
|
||||
const result: BedrockTool[] = []
|
||||
for (const tool of tools) {
|
||||
result.push(lowerToolSpec(tool))
|
||||
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
|
||||
if (cachePoint) result.push(cachePoint)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const textWithCache = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
text: string,
|
||||
cache: CacheHint | undefined,
|
||||
): Array<BedrockTextBlock | BedrockCache.CachePointBlock> => {
|
||||
const cachePoint = BedrockCache.block(cache)
|
||||
const cachePoint = BedrockCache.block(breakpoints, cache)
|
||||
return cachePoint ? [{ text }, cachePoint] : [{ text }]
|
||||
}
|
||||
|
||||
@@ -257,7 +271,10 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({
|
||||
},
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -267,7 +284,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
if (!ProviderShared.supportsContent(part, ["text", "media"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
|
||||
if (part.type === "text") {
|
||||
content.push(...textWithCache(part.text, part.cache))
|
||||
content.push(...textWithCache(breakpoints, part.text, part.cache))
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
@@ -289,7 +306,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
"tool-call",
|
||||
])
|
||||
if (part.type === "text") {
|
||||
content.push(...textWithCache(part.text, part.cache))
|
||||
content.push(...textWithCache(breakpoints, part.text, part.cache))
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
@@ -309,11 +326,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
continue
|
||||
}
|
||||
|
||||
const content: BedrockToolResultBlock[] = []
|
||||
const content: BedrockUserBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
|
||||
content.push(lowerToolResult(part))
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
}
|
||||
@@ -323,16 +342,32 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (system: ReadonlyArray<LLMRequest["system"][number]>): BedrockSystemBlock[] =>
|
||||
system.flatMap((part) => textWithCache(part.text, part.cache))
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig =
|
||||
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
? { tools: lowerTools(breakpoints, request.tools), toolChoice }
|
||||
: undefined
|
||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
system: request.system.length === 0 ? undefined : lowerSystem(request.system),
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig:
|
||||
generation?.maxTokens === undefined &&
|
||||
generation?.temperature === undefined &&
|
||||
@@ -345,10 +380,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
topP: generation?.topP,
|
||||
stopSequences: generation?.stop,
|
||||
},
|
||||
toolConfig:
|
||||
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
? { tools: request.tools.map(lowerTool), toolChoice }
|
||||
: undefined,
|
||||
toolConfig,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -363,15 +395,22 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
|
||||
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
|
||||
// the total through and derive the non-cached breakdown. Bedrock does
|
||||
// not break reasoning out of `outputTokens` for any current model.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
|
||||
return new Usage({
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
native: usage,
|
||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { bedrock: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,13 +439,26 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.text) {
|
||||
return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.textDelta({
|
||||
id: `text-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.text,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
return [
|
||||
state,
|
||||
[{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }],
|
||||
[
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.reasoningContent.text,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
}
|
||||
|
||||
@@ -449,16 +501,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
event.modelStreamErrorException?.message ??
|
||||
event.serviceUnavailableException?.message ??
|
||||
"Bedrock Converse stream error"
|
||||
return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const
|
||||
return [state, [LLMEvent.providerError({ message, retryable: true })]] as const
|
||||
}
|
||||
|
||||
if (event.validationException || event.throttlingException) {
|
||||
const message =
|
||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||
return [
|
||||
state,
|
||||
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
|
||||
] as const
|
||||
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const
|
||||
}
|
||||
|
||||
return [state, []] as const
|
||||
@@ -468,7 +517,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.pendingFinish
|
||||
? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }]
|
||||
? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })]
|
||||
: []
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type TextPart,
|
||||
@@ -281,15 +281,28 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
// Gemini reports `promptTokenCount` (inclusive total) with a
|
||||
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
|
||||
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||
// when the visible component is reported — otherwise we'd fabricate an
|
||||
// inclusive number from a partial breakdown.
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens: usage.candidatesTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
|
||||
native: usage,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -311,7 +324,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.finishReason || state.usage
|
||||
? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }]
|
||||
? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
|
||||
: []
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
@@ -332,14 +345,18 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text })
|
||||
events.push(
|
||||
part.thought
|
||||
? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
|
||||
: LLMEvent.textDelta({ id: "text-0", text: part.text }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
events.push({ type: "tool-call", id, name: part.functionCall.name, input })
|
||||
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
|
||||
hasToolCalls = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
@@ -290,15 +290,24 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
|
||||
// a `reasoning_tokens` subset. We pass the inclusive totals through and
|
||||
// derive the non-cached breakdown so the `LLM.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
|
||||
return new Usage({
|
||||
inputTokens: usage.prompt_tokens,
|
||||
outputTokens: usage.completion_tokens,
|
||||
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens,
|
||||
cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
||||
native: usage,
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -312,7 +321,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
|
||||
if (delta?.content) events.push({ type: "text-delta", text: delta.content })
|
||||
if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content }))
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
const result = ToolStream.appendOrStart(
|
||||
@@ -348,10 +357,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
return [
|
||||
...state.toolCallEvents,
|
||||
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
|
||||
]
|
||||
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
||||
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type TextPart,
|
||||
@@ -276,15 +276,23 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
// OpenAI Responses reports `input_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
|
||||
// `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// non-cached breakdown.
|
||||
const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.input_tokens_details?.cached_tokens
|
||||
const reasoning = usage.output_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
|
||||
return new Usage({
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
reasoningTokens: usage.output_tokens_details?.reasoning_tokens,
|
||||
cacheReadInputTokens: usage.input_tokens_details?.cached_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
||||
native: usage,
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -348,22 +356,20 @@ const hostedToolEvents = (
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
return [
|
||||
{
|
||||
type: "tool-call",
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -379,17 +385,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
return [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "text-delta",
|
||||
id: event.item_id,
|
||||
text: event.delta,
|
||||
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
|
||||
},
|
||||
],
|
||||
]
|
||||
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]]
|
||||
}
|
||||
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
@@ -458,30 +454,28 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "request-finish",
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||
usage: mapUsage(event.response?.usage),
|
||||
...(event.response?.id || event.response?.service_tier
|
||||
? {
|
||||
providerMetadata: openaiMetadata({
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
? openaiMetadata({
|
||||
responseId: event.response.id,
|
||||
serviceTier: event.response.service_tier,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }],
|
||||
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
|
||||
]
|
||||
|
||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }],
|
||||
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface ToolAccumulator {
|
||||
* supplied total; otherwise falls back to `inputTokens + outputTokens` only
|
||||
* when at least one is defined. Returns `undefined` when neither input nor
|
||||
* output is known so routes don't publish a misleading `0`.
|
||||
*
|
||||
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
|
||||
* are the non-cached input and visible output only. The provider-supplied
|
||||
* `total` is the source of truth when present; the computed fallback
|
||||
* under-counts cache and reasoning by design and exists mainly so
|
||||
* Anthropic-style providers (which don't surface a total) still get a
|
||||
* sensible aggregate on the input + output axes.
|
||||
*/
|
||||
export const totalTokens = (
|
||||
inputTokens: number | undefined,
|
||||
@@ -53,6 +60,35 @@ export const totalTokens = (
|
||||
return (inputTokens ?? 0) + (outputTokens ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract `subtrahend` from `total`, clamping to zero if the provider
|
||||
* reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`).
|
||||
* Used by protocol mappers when deriving a non-overlapping breakdown field
|
||||
* from a provider's inclusive total — `nonCachedInputTokens` from
|
||||
* `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`.
|
||||
*
|
||||
* If `total` is `undefined`, returns `undefined` (we don't fabricate
|
||||
* counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
|
||||
* provider-native breakdown stays available on `Usage.native` for debugging.
|
||||
*/
|
||||
export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
|
||||
if (total === undefined) return undefined
|
||||
if (subtrahend === undefined) return total
|
||||
return Math.max(0, total - subtrahend)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum a list of optional token counts, returning `undefined` only when
|
||||
* every value is `undefined` (so we don't fabricate a `0`). Used by
|
||||
* protocol mappers to derive the inclusive `inputTokens` total from a
|
||||
* provider that natively reports a non-overlapping breakdown
|
||||
* (e.g. Anthropic, whose `input_tokens` is already non-cached only).
|
||||
*/
|
||||
export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
|
||||
if (values.every((value) => value === undefined)) return undefined
|
||||
return values.reduce<number>((acc, value) => acc + (value ?? 0), 0)
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { Schema } from "effect"
|
||||
import type { CacheHint } from "../../schema"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache"
|
||||
|
||||
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
|
||||
// after the content the caller wants treated as a cacheable prefix.
|
||||
// after the content the caller wants treated as a cacheable prefix. Bedrock
|
||||
// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic.
|
||||
export const CachePointBlock = Schema.Struct({
|
||||
cachePoint: Schema.Struct({ type: Schema.tag("default") }),
|
||||
cachePoint: Schema.Struct({
|
||||
type: Schema.tag("default"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
}),
|
||||
})
|
||||
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
|
||||
|
||||
// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map
|
||||
// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape.
|
||||
const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages
|
||||
// API. Callers pass a shared counter through every `block()` call site so the
|
||||
// budget is respected across `system`, `messages`, and `tools`.
|
||||
export const BEDROCK_BREAKPOINT_CAP = 4
|
||||
|
||||
export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
export type { Breakpoints } from "./cache"
|
||||
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
|
||||
|
||||
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
|
||||
|
||||
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
return DEFAULT
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
}
|
||||
|
||||
export * as BedrockCache from "./bedrock-cache"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock
|
||||
// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h`
|
||||
// TTL buckets, so the counter and TTL mapping live here.
|
||||
|
||||
export interface Breakpoints {
|
||||
remaining: number
|
||||
dropped: number
|
||||
}
|
||||
|
||||
export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 })
|
||||
|
||||
// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the
|
||||
// provider default 5m). Anthropic & Bedrock both treat anything shorter than
|
||||
// an hour as 5m.
|
||||
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
|
||||
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -49,34 +49,24 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
||||
return next
|
||||
}
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
|
||||
type: "tool-input-delta",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
})
|
||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
})
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
||||
Effect.map(
|
||||
(input): ToolCall =>
|
||||
tool.providerExecuted
|
||||
? {
|
||||
type: "tool-call",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: true,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
}
|
||||
: {
|
||||
type: "tool-call",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
},
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Service as WebSocketExecutorService } from "./transport/websocket"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
import * as ProviderShared from "../protocols/shared"
|
||||
import * as ToolRuntime from "../tool-runtime"
|
||||
import type { Tools } from "../tool"
|
||||
@@ -400,7 +401,7 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
// validated provider body plus transport-private prepared data, but does not
|
||||
// execute transport.
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const resolved = resolveRequestOptions(request)
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = registeredRoute(resolved.model.route)
|
||||
if (!route) return yield* noRoute(resolved.model)
|
||||
|
||||
|
||||
@@ -1,73 +1,155 @@
|
||||
import { Schema } from "effect"
|
||||
import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelRef } from "./options"
|
||||
import { ToolResultValue } from "./messages"
|
||||
|
||||
/**
|
||||
* Token usage reported by an LLM provider.
|
||||
*
|
||||
* **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a
|
||||
* reader from any of those ecosystems sees the number they expect):
|
||||
*
|
||||
* - `inputTokens` — total prompt tokens, *including* cached reads/writes.
|
||||
* - `outputTokens` — total output tokens, *including* reasoning.
|
||||
* - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`.
|
||||
*
|
||||
* **Non-overlapping breakdown** (every field is independently meaningful;
|
||||
* consumers never have to subtract):
|
||||
*
|
||||
* - `nonCachedInputTokens` — the "fresh" portion of the prompt.
|
||||
* - `cacheReadInputTokens` — input tokens served from cache.
|
||||
* - `cacheWriteInputTokens` — input tokens written to cache.
|
||||
* - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning.
|
||||
*
|
||||
* **Invariant**: `nonCachedInputTokens + cacheReadInputTokens +
|
||||
* cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`.
|
||||
* Each protocol mapper computes whichever side it doesn't get natively,
|
||||
* with `Math.max(0, …)` clamping for defense against provider bugs. Because
|
||||
* every breakdown field is stored independently, downstream consumers can
|
||||
* read whatever they need (cost-by-category, context-pressure, AI-SDK-style
|
||||
* inclusive total) without ever subtracting — eliminating the underflow
|
||||
* class of bug where a clamped difference would silently store the wrong
|
||||
* value.
|
||||
*
|
||||
* **Semantics by provider**:
|
||||
*
|
||||
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
|
||||
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
|
||||
* derive the breakdown.
|
||||
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
|
||||
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
|
||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
||||
* combined total — a documented limitation of the Anthropic API.
|
||||
*
|
||||
* `providerMetadata` always carries the provider's raw usage payload —
|
||||
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
||||
* — for fields we don't normalize and for billing-level audit trails.
|
||||
* Matches the same escape-hatch field on `LLMEvent`.
|
||||
*/
|
||||
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
|
||||
inputTokens: Schema.optional(Schema.Number),
|
||||
outputTokens: Schema.optional(Schema.Number),
|
||||
reasoningTokens: Schema.optional(Schema.Number),
|
||||
nonCachedInputTokens: Schema.optional(Schema.Number),
|
||||
cacheReadInputTokens: Schema.optional(Schema.Number),
|
||||
cacheWriteInputTokens: Schema.optional(Schema.Number),
|
||||
reasoningTokens: Schema.optional(Schema.Number),
|
||||
totalTokens: Schema.optional(Schema.Number),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
/**
|
||||
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* to zero. The one place subtraction happens in this contract; the clamp
|
||||
* means a provider reporting `reasoningTokens > outputTokens` produces a
|
||||
* harmless zero rather than a negative that crashes downstream schemas.
|
||||
*/
|
||||
get visibleOutputTokens() {
|
||||
return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
export const RequestStart = Schema.Struct({
|
||||
type: Schema.Literal("request-start"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("request-start"),
|
||||
id: ResponseID,
|
||||
model: ModelRef,
|
||||
}).annotate({ identifier: "LLM.Event.RequestStart" })
|
||||
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.Literal("step-start"),
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
}).annotate({ identifier: "LLM.Event.StepStart" })
|
||||
export type StepStart = Schema.Schema.Type<typeof StepStart>
|
||||
|
||||
export const TextStart = Schema.Struct({
|
||||
type: Schema.Literal("text-start"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("text-start"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||
|
||||
export const TextDelta = Schema.Struct({
|
||||
type: Schema.Literal("text-delta"),
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.tag("text-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||
|
||||
export const TextEnd = Schema.Struct({
|
||||
type: Schema.Literal("text-end"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("text-end"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||
|
||||
export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.Literal("reasoning-delta"),
|
||||
id: Schema.optional(Schema.String),
|
||||
text: Schema.String,
|
||||
export const ReasoningStart = Schema.Struct({
|
||||
type: Schema.tag("reasoning-start"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
||||
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
||||
|
||||
export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.tag("reasoning-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||
|
||||
export const ReasoningEnd = Schema.Struct({
|
||||
type: Schema.tag("reasoning-end"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
|
||||
export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||
|
||||
export const ToolInputDelta = Schema.Struct({
|
||||
type: Schema.Literal("tool-input-delta"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-input-delta"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
||||
|
||||
export const ToolInputEnd = Schema.Struct({
|
||||
type: Schema.tag("tool-input-end"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
type: Schema.Literal("tool-call"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -76,8 +158,8 @@ export const ToolCall = Schema.Struct({
|
||||
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
|
||||
export const ToolResult = Schema.Struct({
|
||||
type: Schema.Literal("tool-result"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-result"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -86,8 +168,8 @@ export const ToolResult = Schema.Struct({
|
||||
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||
|
||||
export const ToolError = Schema.Struct({
|
||||
type: Schema.Literal("tool-error"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-error"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -95,7 +177,7 @@ export const ToolError = Schema.Struct({
|
||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
|
||||
export const StepFinish = Schema.Struct({
|
||||
type: Schema.Literal("step-finish"),
|
||||
type: Schema.tag("step-finish"),
|
||||
index: Schema.Number,
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
@@ -104,7 +186,7 @@ export const StepFinish = Schema.Struct({
|
||||
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||
|
||||
export const RequestFinish = Schema.Struct({
|
||||
type: Schema.Literal("request-finish"),
|
||||
type: Schema.tag("request-finish"),
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -112,7 +194,7 @@ export const RequestFinish = Schema.Struct({
|
||||
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
||||
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.Literal("provider-error"),
|
||||
type: Schema.tag("provider-error"),
|
||||
message: Schema.String,
|
||||
retryable: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -125,8 +207,12 @@ const llmEventTagged = Schema.Union([
|
||||
TextStart,
|
||||
TextDelta,
|
||||
TextEnd,
|
||||
ReasoningStart,
|
||||
ReasoningDelta,
|
||||
ReasoningEnd,
|
||||
ToolInputStart,
|
||||
ToolInputDelta,
|
||||
ToolInputEnd,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
ToolError,
|
||||
@@ -135,20 +221,52 @@ const llmEventTagged = Schema.Union([
|
||||
ProviderErrorEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||
|
||||
const responseID = (value: ResponseID | string) => ResponseID.make(value)
|
||||
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
|
||||
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||
|
||||
/**
|
||||
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
|
||||
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
|
||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||
*/
|
||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
|
||||
stepStart: StepStart.make,
|
||||
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
|
||||
ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
|
||||
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
|
||||
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
|
||||
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolResult: (input: WithID<ToolResult, ToolCallID>) => ToolResult.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
|
||||
stepFinish: StepFinish.make,
|
||||
requestFinish: RequestFinish.make,
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
requestStart: llmEventTagged.guards["request-start"],
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
textDelta: llmEventTagged.guards["text-delta"],
|
||||
textEnd: llmEventTagged.guards["text-end"],
|
||||
reasoningStart: llmEventTagged.guards["reasoning-start"],
|
||||
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
||||
reasoningEnd: llmEventTagged.guards["reasoning-end"],
|
||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||
toolCall: llmEventTagged.guards["tool-call"],
|
||||
toolResult: llmEventTagged.guards["tool-result"],
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ModelID = typeof ModelID.Type
|
||||
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
|
||||
export type ProviderID = typeof ProviderID.Type
|
||||
|
||||
export const ResponseID = Schema.String
|
||||
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
|
||||
|
||||
export const ContentBlockID = Schema.String
|
||||
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
||||
|
||||
export const ToolCallID = Schema.String
|
||||
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
|
||||
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||
import { CacheHint, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
|
||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
@@ -79,6 +79,7 @@ export const ToolResultPart = Object.assign(
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.ToolResult" }),
|
||||
@@ -94,6 +95,7 @@ export const ToolResultPart = Object.assign(
|
||||
name: input.name,
|
||||
result: ToolResultValue.make(input.result, input.resultType),
|
||||
providerExecuted: input.providerExecuted,
|
||||
cache: input.cache,
|
||||
metadata: input.metadata,
|
||||
providerMetadata: input.providerMetadata,
|
||||
}),
|
||||
@@ -151,6 +153,7 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
inputSchema: JsonSchema,
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
@@ -203,6 +206,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
http: Schema.optional(HttpOptions),
|
||||
responseFormat: Schema.optional(ResponseFormat),
|
||||
cache: Schema.optional(CachePolicy),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -220,6 +224,7 @@ export namespace LLMRequest {
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
responseFormat: request.responseFormat,
|
||||
cache: request.cache,
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
|
||||
@@ -200,3 +200,31 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
|
||||
type: Schema.Literals(["ephemeral", "persistent"]),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
|
||||
// reads this and injects `CacheHint`s at the configured boundaries; the
|
||||
// per-protocol body builders then translate those hints into wire markers as
|
||||
// usual. `"auto"` is the recommended default for agent loops — it places one
|
||||
// breakpoint at the last tool definition, one at the last system part, and one
|
||||
// at the latest user message. The combination of provider invalidation
|
||||
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
|
||||
// lookback means three trailing breakpoints reliably cover the static prefix.
|
||||
//
|
||||
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
||||
// object form to override individual choices.
|
||||
export const CachePolicyObject = Schema.Struct({
|
||||
tools: Schema.optional(Schema.Boolean),
|
||||
system: Schema.optional(Schema.Boolean),
|
||||
messages: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal("latest-user-message"),
|
||||
Schema.Literal("latest-assistant"),
|
||||
Schema.Struct({ tail: Schema.Number }),
|
||||
]),
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
})
|
||||
export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
|
||||
|
||||
export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
|
||||
export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ContentPart,
|
||||
type FinishReason,
|
||||
type LLMError,
|
||||
type LLMEvent,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ProviderMetadata,
|
||||
@@ -115,11 +115,19 @@ interface StepState {
|
||||
|
||||
const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
if (event.type === "text-delta") {
|
||||
appendStreamingText(state, "text", event.text, event.providerMetadata)
|
||||
appendStreamingText(state, "text", event.text, undefined)
|
||||
return
|
||||
}
|
||||
if (event.type === "reasoning-delta") {
|
||||
appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
|
||||
appendStreamingText(state, "reasoning", event.text, undefined)
|
||||
return
|
||||
}
|
||||
if (event.type === "reasoning-end") {
|
||||
appendStreamingText(state, "reasoning", "", event.providerMetadata)
|
||||
return
|
||||
}
|
||||
if (event.type === "text-end") {
|
||||
appendStreamingText(state, "text", "", event.providerMetadata)
|
||||
return
|
||||
}
|
||||
if (event.type === "tool-call") {
|
||||
@@ -219,10 +227,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
|
||||
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
||||
result.type === "error"
|
||||
? [
|
||||
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
|
||||
{ type: "tool-result", id: call.id, name: call.name, result },
|
||||
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
|
||||
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
|
||||
]
|
||||
: [{ type: "tool-result", id: call.id, name: call.name, result }]
|
||||
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
|
||||
|
||||
const followUpRequest = (
|
||||
request: LLMRequest,
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -50,7 +50,9 @@ const request = LLM.request({
|
||||
})
|
||||
|
||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text }
|
||||
event.type === "finish"
|
||||
? { type: "request-finish", reason: event.reason }
|
||||
: { type: "text-delta", id: "text-0", text: event.text }
|
||||
|
||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
id: "fake",
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../src"
|
||||
import { LLMClient } from "../src/route"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
|
||||
import * as BedrockConverse from "../src/protocols/bedrock-converse"
|
||||
import * as Gemini from "../src/protocols/gemini"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import { applyCachePolicy } from "../src/cache-policy"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const anthropicModel = AnthropicMessages.model({
|
||||
id: "claude-sonnet-4-5",
|
||||
baseURL: "https://api.anthropic.test/v1/",
|
||||
headers: { "x-api-key": "test" },
|
||||
})
|
||||
|
||||
const bedrockModel = BedrockConverse.model({
|
||||
id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
|
||||
})
|
||||
|
||||
const openaiModel = OpenAIChat.model({
|
||||
id: "gpt-4o-mini",
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
headers: { authorization: "Bearer test" },
|
||||
})
|
||||
|
||||
const geminiModel = Gemini.model({
|
||||
id: "gemini-2.5-flash",
|
||||
baseURL: "https://generativelanguage.test/v1beta/",
|
||||
headers: { "x-goog-api-key": "test" },
|
||||
})
|
||||
|
||||
describe("applyCachePolicy", () => {
|
||||
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "You are concise.",
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
// No explicit cache field → auto policy fires → last system part + latest
|
||||
// user message both get cache_control markers.
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys A",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")],
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "first user" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: openaiModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { messages: Array<{ content: unknown }> }
|
||||
// OpenAI doesn't accept cache_control on messages — policy must skip.
|
||||
const flat = JSON.stringify(body)
|
||||
expect(flat).not.toContain("cache_control")
|
||||
expect(flat).not.toContain("cachePoint")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: geminiModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
const flat = JSON.stringify(prepared.body)
|
||||
expect(flat).not.toContain("cache_control")
|
||||
expect(flat).not.toContain("cachePoint")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: bedrockModel,
|
||||
system: "Sys",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")],
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
toolConfig: {
|
||||
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "first user" }] },
|
||||
{ role: "assistant", content: [{ text: "reply" }] },
|
||||
{ role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'none' disables auto placement even when manual hints exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: undefined }],
|
||||
system: [{ type: "text", text: "Sys", cache_control: undefined }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("granular object form: tools-only marks just tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: { tools: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "Sys", cache_control: undefined }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("auto policy preserves manual CacheHints on other parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: [
|
||||
{ type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
|
||||
{ type: "text", text: "last system" },
|
||||
],
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
|
||||
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: { system: true, ttlSeconds: 3600 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")],
|
||||
cache: { messages: { tail: 2 } },
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'latest-assistant' marks the last assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")],
|
||||
cache: { messages: "latest-assistant" },
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
test("returns the same request reference when policy is a no-op (pure function)", () => {
|
||||
const request = LLM.request({
|
||||
model: anthropicModel,
|
||||
prompt: "hi",
|
||||
cache: "none",
|
||||
})
|
||||
expect(applyCachePolicy(request)).toBe(request)
|
||||
})
|
||||
})
|
||||
+48
File diff suppressed because one or more lines are too long
+46
File diff suppressed because one or more lines are too long
+46
File diff suppressed because one or more lines are too long
@@ -126,7 +126,7 @@ describe("llm constructors", () => {
|
||||
expect(
|
||||
LLMResponse.text({
|
||||
events: [
|
||||
{ type: "text-delta", text: "hi" },
|
||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||
{ type: "request-finish", reason: "stop" },
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = AnthropicMessages.model({
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
// Two identical generations in a row. The first call writes the prefix into
|
||||
// Anthropic's cache; the second should report a cache read against the same
|
||||
// prefix. Cassette captures both interactions in order.
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_anthropic_cache",
|
||||
model,
|
||||
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
|
||||
prompt: "Say hi.",
|
||||
// Manual hint on the system part is the only marker we want here — skip the
|
||||
// auto-policy's latest-user-message breakpoint so the cassette body matches.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "anthropic-messages-cache",
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
// Two identical requests in one cassette — replay walks the cassette in
|
||||
// recording order so the second call replays the cached-hit interaction.
|
||||
options: {
|
||||
redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }),
|
||||
},
|
||||
})
|
||||
|
||||
describe("Anthropic Messages cache recorded", () => {
|
||||
recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
// The first call may write the cache (cacheWriteInputTokens > 0) or it
|
||||
// may be a fresh miss (both fields 0) depending on whether the prefix is
|
||||
// already warm on Anthropic's side. The assertion that matters is that
|
||||
// the SECOND call reports a non-zero cache read.
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
@@ -30,7 +31,7 @@ const recorded = recordedTests({
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
})
|
||||
|
||||
describe("Anthropic Messages sad-path recorded", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, LLMError } from "../../src"
|
||||
import { CacheHint, LLM, LLMError, Usage } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -18,6 +18,9 @@ const request = LLM.request({
|
||||
model,
|
||||
system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) },
|
||||
prompt: "Say hello.",
|
||||
// This fixture predates the `cache: "auto"` default; pin the policy off so
|
||||
// existing wire-shape assertions only see the manual hint on the system part.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
|
||||
@@ -48,6 +51,7 @@ describe("Anthropic Messages route", () => {
|
||||
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -110,12 +114,13 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
inputTokens: 6,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: 1,
|
||||
totalTokens: 7,
|
||||
totalTokens: 8,
|
||||
})
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
@@ -152,7 +157,13 @@ describe("Anthropic Messages route", () => {
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } },
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -374,4 +385,134 @@ describe("Anthropic Messages route", () => {
|
||||
expect(error.message).toContain("Anthropic Messages user messages only support text content for now")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "lookup tool",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: new CacheHint({ type: "ephemeral" }),
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
LLM.user("What's the weather?"),
|
||||
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]),
|
||||
LLM.toolMessage({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: { temp: 72 },
|
||||
cache: new CacheHint({ type: "ephemeral" }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "What's the weather?" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
|
||||
Effect.gen(function* () {
|
||||
const hint = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "a", cache: hint },
|
||||
{ type: "text", text: "b", cache: hint },
|
||||
{ type: "text", text: "c", cache: hint },
|
||||
{ type: "text", text: "d", cache: hint },
|
||||
{ type: "text", text: "e", cache: hint },
|
||||
{ type: "text", text: "f", cache: hint },
|
||||
],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system
|
||||
const marked = system.filter((part) => part.cache_control !== undefined)
|
||||
expect(marked).toHaveLength(4)
|
||||
expect(system[4]?.cache_control).toBeUndefined()
|
||||
expect(system[5]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("spends breakpoint budget on tools before system before messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const hint = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
name: "t1",
|
||||
description: "t1",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
{
|
||||
name: "t2",
|
||||
description: "t2",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
{
|
||||
name: "t3",
|
||||
description: "t3",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
{
|
||||
name: "t4",
|
||||
description: "t4",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
],
|
||||
system: [{ type: "text", text: "system-tail", cache: hint }],
|
||||
messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])],
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as {
|
||||
tools: Array<{ cache_control?: unknown }>
|
||||
system: Array<{ cache_control?: unknown }>
|
||||
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||
}
|
||||
expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true)
|
||||
expect(body.system[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
|
||||
|
||||
// Use a Claude model on Bedrock — Nova has automatic prefix caching that
|
||||
// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second
|
||||
// call wouldn't deterministically prove cache mapping works. Override with
|
||||
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
|
||||
const model = BedrockConverse.model({
|
||||
id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
credentials: {
|
||||
region: RECORDING_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
|
||||
sessionToken: process.env.AWS_SESSION_TOKEN,
|
||||
},
|
||||
})
|
||||
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_bedrock_cache",
|
||||
model,
|
||||
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
|
||||
prompt: "Say hi.",
|
||||
// Manual hint on the system part is the only marker we want here — skip the
|
||||
// auto-policy's latest-user-message breakpoint so the cassette body matches.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-converse-cache",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "bedrock-converse",
|
||||
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
||||
// Two identical requests in one cassette — replay walks the cassette in
|
||||
// recording order so the second call replays the cached-hit interaction.
|
||||
})
|
||||
|
||||
describe("Bedrock Converse cache recorded", () => {
|
||||
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -63,6 +63,9 @@ const baseRequest = LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello.",
|
||||
// Wire-shape assertions in this file predate the `cache: "auto"` default;
|
||||
// pin the policy off so they only exercise the lowering path itself.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 64, temperature: 0 },
|
||||
})
|
||||
|
||||
@@ -125,6 +128,7 @@ describe("Bedrock Converse route", () => {
|
||||
LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
|
||||
LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -339,6 +343,7 @@ describe("Bedrock Converse route", () => {
|
||||
{ type: "media", mediaType: "image/webp", data: "DDDD" },
|
||||
]),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -440,6 +445,78 @@ describe("Bedrock Converse route", () => {
|
||||
expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [{ type: "text", text: "system", cache }],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
|
||||
messages: [
|
||||
LLM.user("What's the weather?"),
|
||||
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]),
|
||||
LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
toolConfig: {
|
||||
tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "What's the weather?" }] },
|
||||
{ role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops cachePoint markers past the 4-per-request cap", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "a", cache },
|
||||
{ type: "text", text: "b", cache },
|
||||
{ type: "text", text: "c", cache },
|
||||
{ type: "text", text: "d", cache },
|
||||
{ type: "text", text: "e", cache },
|
||||
{ type: "text", text: "f", cache },
|
||||
],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system
|
||||
expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=...
|
||||
@@ -484,6 +561,7 @@ describe("Bedrock Converse recorded", () => {
|
||||
model: recordedModel(),
|
||||
system: "Reply with the single word 'Hello'.",
|
||||
prompt: "Say hello.",
|
||||
cache: "none",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
@@ -506,6 +584,7 @@ describe("Bedrock Converse recorded", () => {
|
||||
prompt: "Call get_weather with city exactly Paris.",
|
||||
tools: [weatherTool],
|
||||
toolChoice: LLM.toolChoice(weatherTool),
|
||||
cache: "none",
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = Gemini.model({
|
||||
id: "gemini-2.5-flash",
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The
|
||||
// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent`
|
||||
// API is out-of-band and intentionally not wired up). This test exists to
|
||||
// pin the usage-parsing path: `cachedContentTokenCount` should surface as
|
||||
// `cacheReadInputTokens` on the second identical call.
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_gemini_cache",
|
||||
model,
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
prompt: "Say hi.",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "gemini-cache",
|
||||
provider: "google",
|
||||
protocol: "gemini",
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
// Two identical requests in one cassette — replay walks the cassette in
|
||||
// recording order so the second call replays the cached-hit interaction.
|
||||
})
|
||||
|
||||
describe("Gemini cache recorded", () => {
|
||||
recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
// Implicit caching is best-effort on Gemini's side; we assert the field
|
||||
// is at least populated and non-negative. When re-recording, verify the
|
||||
// cassette shows > 0 in the second response's usage.
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
import { LLM, LLMError, Usage } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -198,32 +198,36 @@ describe("Gemini route", () => {
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 1,
|
||||
outputTokens: 3,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
})
|
||||
expect(response.events).toEqual([
|
||||
{ type: "reasoning-delta", text: "thinking" },
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: "!" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 1,
|
||||
outputTokens: 3,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
native: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 7,
|
||||
thoughtsTokenCount: 1,
|
||||
cachedContentTokenCount: 1,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 7,
|
||||
thoughtsTokenCount: 1,
|
||||
cachedContentTokenCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -257,12 +261,13 @@ describe("Gemini route", () => {
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
native: { promptTokenCount: 5, candidatesTokenCount: 1 },
|
||||
},
|
||||
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Redactor } from "@opencode-ai/http-recorder"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) =>
|
||||
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
||||
|
||||
const cloudflareOptions = {
|
||||
redact: { url: redactCloudflareURL },
|
||||
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
|
||||
}
|
||||
|
||||
describeRecordedGoldenScenarios([
|
||||
@@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([
|
||||
prefix: "anthropic-messages",
|
||||
model: anthropicHaiku,
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
scenarios: ["text", "tool-call"],
|
||||
},
|
||||
{
|
||||
@@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([
|
||||
model: anthropicOpus,
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
tags: ["flagship"],
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
scenarios: [{ id: "tool-loop", temperature: false }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
import { LLM, LLMError, Usage } from "../../src"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -225,25 +225,28 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: "!" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
native: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = OpenAIResponses.model({
|
||||
id: "gpt-4.1-mini",
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
// OpenAI caches prefixes automatically once they cross the 1024-token threshold;
|
||||
// `CacheHint` is a no-op for the wire body. The stable signal is the
|
||||
// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard
|
||||
// so cache hits are observable.
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_openai_responses_cache",
|
||||
model,
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
prompt: "Say hi.",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-responses-cache",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
// Two identical requests in one cassette — replay walks the cassette in
|
||||
// recording order so the second call replays the cached-hit interaction,
|
||||
// not the cold-miss one.
|
||||
})
|
||||
|
||||
describe("OpenAI Responses cache recorded", () => {
|
||||
recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
import { LLM, LLMError, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -336,26 +336,29 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
native: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -394,14 +397,12 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
@@ -413,7 +414,13 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,18 @@ import { tool } from "../src/tool"
|
||||
|
||||
export const weatherToolName = "get_weather"
|
||||
|
||||
// A deterministic system prompt long enough to clear every supported provider's
|
||||
// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic
|
||||
// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating
|
||||
// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters
|
||||
// only when re-recording with `RECORD=true`.
|
||||
export const LARGE_CACHEABLE_SYSTEM = (() => {
|
||||
const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. "
|
||||
// ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely
|
||||
// above every provider's threshold.
|
||||
return sentence.repeat(250)
|
||||
})()
|
||||
|
||||
export const weatherTool = LLM.toolDefinition({
|
||||
name: weatherToolName,
|
||||
description: "Get current weather for a city.",
|
||||
@@ -39,6 +51,7 @@ export const textRequest = (input: {
|
||||
model: input.model,
|
||||
system: "You are concise.",
|
||||
prompt: input.prompt ?? "Reply with exactly: Hello!",
|
||||
cache: "none",
|
||||
generation:
|
||||
input.temperature === false
|
||||
? { maxTokens: input.maxTokens ?? 20 }
|
||||
@@ -58,6 +71,7 @@ export const weatherToolRequest = (input: {
|
||||
prompt: "Call get_weather with city exactly Paris.",
|
||||
tools: [weatherTool],
|
||||
toolChoice: LLM.toolChoice(weatherTool),
|
||||
cache: "none",
|
||||
generation:
|
||||
input.temperature === false
|
||||
? { maxTokens: input.maxTokens ?? 80 }
|
||||
@@ -76,6 +90,7 @@ export const weatherToolLoopRequest = (input: {
|
||||
model: input.model,
|
||||
system: input.system ?? "Use the get_weather tool, then answer in one short sentence.",
|
||||
prompt: "What is the weather in Paris?",
|
||||
cache: "none",
|
||||
generation:
|
||||
input.temperature === false
|
||||
? { maxTokens: input.maxTokens ?? 80 }
|
||||
|
||||
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
...metadata,
|
||||
}
|
||||
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
|
||||
const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
|
||||
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
const requestExecutor = RequestExecutor.layer.pipe(
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
|
||||
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { WebSocketExecutor } from "../src/route"
|
||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||
|
||||
const liveWebSocket = WebSocketExecutor.open
|
||||
type Mode = "record" | "replay" | "passthrough"
|
||||
|
||||
export const webSocketCassetteLayer = (
|
||||
cassette: string,
|
||||
input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
|
||||
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
|
||||
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
|
||||
Layer.effect(
|
||||
WebSocketExecutor.Service,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema"
|
||||
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema"
|
||||
import { ProviderShared } from "../src/protocols/shared"
|
||||
|
||||
const model = new ModelRef({
|
||||
id: ModelID.make("fake-model"),
|
||||
@@ -48,3 +49,30 @@ describe("llm schema", () => {
|
||||
expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("LLM.Usage", () => {
|
||||
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
|
||||
// Defense against a provider reporting cached_tokens > prompt_tokens or
|
||||
// reasoning_tokens > completion_tokens — the negative would otherwise
|
||||
// round-trip through the pipeline and crash strict downstream schemas.
|
||||
expect(ProviderShared.subtractTokens(5, 3)).toBe(2)
|
||||
expect(ProviderShared.subtractTokens(5, 10)).toBe(0)
|
||||
expect(ProviderShared.subtractTokens(5, undefined)).toBe(5)
|
||||
expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined()
|
||||
expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sumTokens returns undefined only when every input is undefined", () => {
|
||||
expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6)
|
||||
expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4)
|
||||
expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined()
|
||||
expect(ProviderShared.sumTokens()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
||||
expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0)
|
||||
expect(new Usage({}).visibleOutputTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||
|
||||
## Development server
|
||||
|
||||
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
|
||||
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
|
||||
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
|
||||
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
|
||||
|
||||
# Module shape
|
||||
|
||||
Do not use `export namespace Foo { ... }` for module organization. It is not
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
|
||||
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user