mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c20d070b9a | |||
| d048bd6f4b | |||
| ab9b79ef88 | |||
| d4ff331052 | |||
| f5d199db62 | |||
| 0d4f8d126f | |||
| 478f3ae50c | |||
| b9451175a6 | |||
| 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 |
@@ -9,6 +9,7 @@
|
|||||||
### General Principles
|
### General Principles
|
||||||
|
|
||||||
- Keep things in one function unless composable or reusable
|
- 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 `try`/`catch` where possible
|
||||||
- Avoid using the `any` type
|
- Avoid using the `any` type
|
||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -412,6 +412,7 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
@@ -495,7 +496,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -533,7 +534,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -548,7 +549,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -583,7 +584,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -632,7 +633,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -677,6 +678,7 @@
|
|||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"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",
|
"@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",
|
"@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": {
|
"overrides": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
@@ -2035,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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
|
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
|
||||||
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
|
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
|
||||||
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
|
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
|
||||||
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
|
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.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",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.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"
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
|||||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
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: {
|
export function SessionSidePanel(props: {
|
||||||
canReview: () => boolean
|
canReview: () => boolean
|
||||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||||
@@ -70,7 +76,8 @@ export function SessionSidePanel(props: {
|
|||||||
})
|
})
|
||||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
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 kinds = createMemo(() => {
|
||||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||||
if (!a) return b
|
if (!a) return b
|
||||||
@@ -81,7 +88,7 @@ export function SessionSidePanel(props: {
|
|||||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||||
|
|
||||||
const out = new Map<string, "add" | "del" | "mix">()
|
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 file = normalize(diff.file)
|
||||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Config } from "effect"
|
import { Config } from "effect"
|
||||||
|
import { InstallationChannel } from "../installation/version"
|
||||||
|
|
||||||
function truthy(key: string) {
|
function truthy(key: string) {
|
||||||
const value = process.env[key]?.toLowerCase()
|
const value = process.env[key]?.toLowerCase()
|
||||||
@@ -10,6 +11,13 @@ function falsy(key: string) {
|
|||||||
return value === "false" || value === "0"
|
return value === "false" || value === "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function number(key: string) {
|
||||||
const value = process.env[key]
|
const value = process.env[key]
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
@@ -48,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_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
||||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_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_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.14.43"
|
version = "1.14.46"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# @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
|
||||||
|
|
||||||
|
By default, requests match on canonicalized method, URL, headers, and JSON
|
||||||
|
body (object keys sorted). Two dispatch strategies are available:
|
||||||
|
|
||||||
|
- **`match`** (default) — find the first recorded interaction whose request
|
||||||
|
matches the incoming request. Same request twice returns the same response.
|
||||||
|
- **`sequential`** — return interactions in the order they were recorded,
|
||||||
|
validating each one matches as the cursor advances. Use for ordered flows
|
||||||
|
where the same URL is hit multiple times with meaningful state changes
|
||||||
|
(pagination, retries, polling).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
|
||||||
|
```
|
||||||
|
|
||||||
|
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()
|
||||||
|
dispatch?: "match" | "sequential" // default: "match"
|
||||||
|
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, dispatch strategies, mismatch diagnostics. |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"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 * as path from "node:path"
|
||||||
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
|
import { secretFindings, type SecretFinding } from "./redaction"
|
||||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||||
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
|
|
||||||
|
|
||||||
export interface Entry {
|
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||||
readonly name: string
|
|
||||||
readonly path: string
|
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 {
|
export interface Interface {
|
||||||
readonly path: (name: string) => string
|
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||||
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
|
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||||
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 exists: (name: string) => Effect.Effect<boolean>
|
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||||
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
|
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||||
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
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(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fileSystem = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
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* () {
|
Effect.gen(function* () {
|
||||||
const entries = yield* fileSystem
|
const dir = path.dirname(cassettePath(name))
|
||||||
.readDirectory(directory)
|
if (directoriesEnsured.has(dir)) return
|
||||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
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 nested = yield* Effect.forEach(entries, (entry) => {
|
||||||
const full = path.join(directory, entry)
|
const full = path.join(current, entry)
|
||||||
return fileSystem.stat(full).pipe(
|
return fs.stat(full).pipe(
|
||||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||||
Effect.catch(() => Effect.succeed([] as string[])),
|
Effect.catch(() => Effect.succeed([] as string[])),
|
||||||
)
|
)
|
||||||
@@ -56,53 +78,73 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
|||||||
return nested.flat()
|
return nested.flat()
|
||||||
})
|
})
|
||||||
|
|
||||||
const read = Effect.fn("Cassette.read")(function* (name: string) {
|
return Service.of({
|
||||||
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
|
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,37 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Effect, Layer, Option, Ref } from "effect"
|
import { Effect, Layer, Option } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
|
Headers,
|
||||||
|
HttpBody,
|
||||||
HttpClient,
|
HttpClient,
|
||||||
HttpClientError,
|
HttpClientError,
|
||||||
HttpClientRequest,
|
HttpClientRequest,
|
||||||
HttpClientResponse,
|
HttpClientResponse,
|
||||||
|
UrlParams,
|
||||||
} from "effect/unstable/http"
|
} 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 * as CassetteService from "./cassette"
|
||||||
|
import { defaultMatcher, selectMatch, 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"]
|
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
||||||
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
|
||||||
|
|
||||||
export type RecordReplayMode = "record" | "replay" | "passthrough"
|
|
||||||
|
|
||||||
export interface RecordReplayOptions {
|
export interface RecordReplayOptions {
|
||||||
readonly mode?: RecordReplayMode
|
readonly mode?: RecordReplayMode
|
||||||
readonly directory?: string
|
readonly directory?: string
|
||||||
readonly metadata?: CassetteMetadata
|
readonly metadata?: CassetteMetadata
|
||||||
readonly redact?: {
|
readonly redactor?: Redactor
|
||||||
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 dispatch?: "match" | "sequential"
|
||||||
readonly match?: RequestMatcher
|
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 BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
|
||||||
|
|
||||||
const isBinaryContentType = (contentType: string | undefined) => {
|
const isBinaryContentType = (contentType: string | undefined) =>
|
||||||
if (!contentType) return false
|
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
|
||||||
const lower = contentType.toLowerCase()
|
|
||||||
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
|
|
||||||
}
|
|
||||||
|
|
||||||
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
||||||
isBinaryContentType(contentType)
|
isBinaryContentType(contentType)
|
||||||
@@ -68,34 +43,19 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
|
|||||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||||
|
|
||||||
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
new HttpClientError.HttpClientError({
|
HttpClientRequest.makeWith(
|
||||||
reason: new HttpClientError.TransportError({
|
request.method,
|
||||||
request: redactedErrorRequest(request),
|
redactUrl(request.url),
|
||||||
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
|
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({
|
new HttpClientError.HttpClientError({
|
||||||
reason: new HttpClientError.TransportError({
|
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
||||||
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(", ")}`,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const recordingLayer = (
|
export const recordingLayer = (
|
||||||
@@ -107,61 +67,22 @@ export const recordingLayer = (
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const upstream = yield* HttpClient.HttpClient
|
const upstream = yield* HttpClient.HttpClient
|
||||||
const cassetteService = yield* CassetteService.Service
|
const cassetteService = yield* CassetteService.Service
|
||||||
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
|
const redactor = options.redactor ?? defaults()
|
||||||
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
|
|
||||||
const match = options.match ?? defaultMatcher
|
const match = options.match ?? defaultMatcher
|
||||||
const mode = options.mode ?? "replay"
|
const requested = options.mode ?? "auto"
|
||||||
|
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
||||||
const sequential = options.dispatch === "sequential"
|
const sequential = options.dispatch === "sequential"
|
||||||
const replay = yield* Ref.make<Cassette | undefined>(undefined)
|
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||||
const cursor = yield* Ref.make(0)
|
|
||||||
|
|
||||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||||
const raw = yield* Effect.promise(() => web.text())
|
return redactor.request({
|
||||||
const body = options.redactBody
|
|
||||||
? Option.match(decodeJson(raw), {
|
|
||||||
onNone: () => raw,
|
|
||||||
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
|
|
||||||
})
|
|
||||||
: raw
|
|
||||||
return {
|
|
||||||
method: web.method,
|
method: web.method,
|
||||||
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
|
url: web.url,
|
||||||
headers: redactHeaders(
|
headers: Object.fromEntries(web.headers.entries()),
|
||||||
Object.fromEntries(web.headers.entries()),
|
body: yield* Effect.promise(() => web.text()),
|
||||||
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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return HttpClient.make((request) => {
|
return HttpClient.make((request) => {
|
||||||
@@ -169,18 +90,21 @@ export const recordingLayer = (
|
|||||||
|
|
||||||
if (mode === "record") {
|
if (mode === "record") {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const currentRequest = yield* snapshotRequest(request)
|
const incoming = yield* snapshotRequest(request)
|
||||||
const response = yield* upstream.execute(request)
|
const response = yield* upstream.execute(request)
|
||||||
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
|
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
||||||
const captured = yield* captureResponseBody(response, headers["content-type"])
|
|
||||||
const interaction: HttpInteraction = {
|
const interaction: HttpInteraction = {
|
||||||
transport: "http",
|
transport: "http",
|
||||||
request: currentRequest,
|
request: incoming,
|
||||||
response: { status: response.status, headers, ...captured },
|
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)
|
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||||
const findings = result.findings
|
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||||
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
|
)
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||||
@@ -189,14 +113,23 @@ export const recordingLayer = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const cassette = yield* loadReplay(request)
|
|
||||||
const incoming = yield* snapshotRequest(request)
|
const incoming = yield* snapshotRequest(request)
|
||||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
const interactions = yield* replay.load.pipe(
|
||||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
Effect.mapError(() =>
|
||||||
|
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const result = sequential
|
||||||
|
? selectSequential(interactions, incoming, match, yield* replay.cursor)
|
||||||
|
: selectMatch(interactions, incoming, match)
|
||||||
|
if (!result.interaction)
|
||||||
|
return yield* Effect.fail(
|
||||||
|
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
|
||||||
|
)
|
||||||
|
if (sequential) yield* replay.advance
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -205,7 +138,7 @@ export const recordingLayer = (
|
|||||||
|
|
||||||
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||||
recordingLayer(name, options).pipe(
|
recordingLayer(name, options).pipe(
|
||||||
Layer.provide(CassetteService.layer({ directory: options.directory })),
|
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
export * from "./schema"
|
export type {
|
||||||
export * from "./redaction"
|
CassetteMetadata,
|
||||||
export * from "./matching"
|
HttpInteraction,
|
||||||
export * from "./diff"
|
Interaction,
|
||||||
export * from "./storage"
|
RequestSnapshot,
|
||||||
export * from "./websocket"
|
ResponseSnapshot,
|
||||||
export * from "./effect"
|
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 Cassette from "./cassette"
|
||||||
|
export * as Redactor from "./redactor"
|
||||||
|
|
||||||
export * as HttpRecorder from "."
|
export * as HttpRecorder from "."
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Option, Schema } from "effect"
|
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)
|
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||||
@@ -34,3 +35,90 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
|||||||
|
|
||||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||||
canonicalSnapshot(incoming) === canonicalSnapshot(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 mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
|
||||||
|
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 selectMatch = (
|
||||||
|
interactions: ReadonlyArray<HttpInteraction>,
|
||||||
|
incoming: RequestSnapshot,
|
||||||
|
match: RequestMatcher,
|
||||||
|
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||||
|
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
||||||
|
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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]"
|
export const REDACTED = "[REDACTED]"
|
||||||
|
|
||||||
const DEFAULT_REDACT_HEADERS = [
|
const DEFAULT_REDACT_HEADERS = [
|
||||||
@@ -97,10 +95,13 @@ export const redactHeaders = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SecretFinding = {
|
import { Schema } from "effect"
|
||||||
readonly path: string
|
|
||||||
readonly reason: string
|
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> =>
|
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||||
stringEntries(value).flatMap((entry) => [
|
stringEntries(value).flatMap((entry) => [
|
||||||
@@ -112,5 +113,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
|||||||
.filter((item) => entry.value.includes(item.value))
|
.filter((item) => entry.value.includes(item.value))
|
||||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
.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 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({
|
export const CassetteSchema = Schema.Struct({
|
||||||
version: Schema.Literal(1),
|
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 type { Headers } from "effect/unstable/http"
|
||||||
import * as CassetteService from "./cassette"
|
import * as CassetteService from "./cassette"
|
||||||
import { canonicalizeJson, decodeJson } from "./matching"
|
import { canonicalizeJson, decodeJson } from "./matching"
|
||||||
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
|
import type { RecordReplayMode } from "./effect"
|
||||||
|
import { defaults, type Redactor } from "./redactor"
|
||||||
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||||
|
|
||||||
export interface WebSocketRequest {
|
export interface WebSocketRequest {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -24,67 +24,36 @@ export interface WebSocketExecutor<E> {
|
|||||||
|
|
||||||
export interface WebSocketRecordReplayOptions<E> {
|
export interface WebSocketRecordReplayOptions<E> {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly mode?: "record" | "replay" | "passthrough"
|
readonly mode?: RecordReplayMode
|
||||||
readonly metadata?: CassetteMetadata
|
readonly metadata?: CassetteMetadata
|
||||||
readonly cassette: CassetteService.Interface
|
readonly cassette: CassetteService.Interface
|
||||||
readonly live: WebSocketExecutor<E>
|
readonly live: WebSocketExecutor<E>
|
||||||
readonly redact?: {
|
readonly redactor?: Redactor
|
||||||
readonly headers?: ReadonlyArray<string>
|
|
||||||
readonly query?: ReadonlyArray<string>
|
|
||||||
readonly url?: (url: string) => string
|
|
||||||
}
|
|
||||||
readonly requestHeaders?: ReadonlyArray<string>
|
|
||||||
readonly compareClientMessagesAsJson?: boolean
|
readonly compareClientMessagesAsJson?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const headersRecord = (headers: Headers.Headers) =>
|
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(headers as Record<string, unknown>)
|
Object.entries(headers as Record<string, unknown>).filter(
|
||||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const openSnapshot = (
|
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||||
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 =>
|
|
||||||
typeof message === "string"
|
typeof message === "string"
|
||||||
? textFrame(message)
|
? { kind: "text", body: message }
|
||||||
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||||
|
|
||||||
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
|
||||||
new Error(
|
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
||||||
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
|
|
||||||
.map((item) => `${item.path} (${item.reason})`)
|
|
||||||
.join(", ")}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const mismatch = (message: string, actual: unknown, expected: unknown) =>
|
const decodeFrameText = (frame: WebSocketFrame) =>
|
||||||
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||||
|
|
||||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
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 })
|
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(() => {
|
return Effect.sync(() => {
|
||||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
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)
|
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||||
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
|
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
|
||||||
}
|
}
|
||||||
@@ -103,7 +72,18 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
options: WebSocketRecordReplayOptions<E>,
|
options: WebSocketRecordReplayOptions<E>,
|
||||||
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
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
|
if (mode === "passthrough") return options.live
|
||||||
|
|
||||||
@@ -118,21 +98,21 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
const closeOnce = Effect.gen(function* () {
|
const closeOnce = Effect.gen(function* () {
|
||||||
if (yield* Ref.getAndSet(closed, true)) return
|
if (yield* Ref.getAndSet(closed, true)) return
|
||||||
yield* connection.close
|
yield* connection.close
|
||||||
const result = yield* options.cassette
|
yield* appendOrFail(
|
||||||
.append(
|
options.cassette,
|
||||||
options.name,
|
options.name,
|
||||||
{ transport: "websocket", open: openSnapshot(request, options), client, server },
|
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||||
options.metadata,
|
options.metadata,
|
||||||
)
|
).pipe(Effect.orDie)
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
|
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
sendText: (message: string) =>
|
sendText: (message) =>
|
||||||
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
|
connection
|
||||||
|
.sendText(message)
|
||||||
|
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||||
messages: connection.messages.pipe(
|
messages: connection.messages.pipe(
|
||||||
Stream.map((message) => {
|
Stream.map((message) => {
|
||||||
server.push(receivedFrame(message))
|
server.push(encodeFrame(message))
|
||||||
return message
|
return message
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -142,44 +122,20 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
|
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open: (request) => {
|
open: (request) =>
|
||||||
return Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const input = yield* loadReplay()
|
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
const index = yield* replay.cursor
|
||||||
const interaction = input.interactions[index]
|
const interaction = interactions[index]
|
||||||
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
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)
|
const messageIndex = yield* Ref.make(0)
|
||||||
return {
|
return {
|
||||||
sendText: (message: string) =>
|
sendText: (message) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||||
yield* compareClientMessage(
|
yield* compareClientMessage(
|
||||||
@@ -189,7 +145,7 @@ export const makeWebSocketExecutor = <E>(
|
|||||||
options.compareClientMessagesAsJson === true,
|
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* () {
|
close: Effect.gen(function* () {
|
||||||
yield* assertEqual(
|
yield* assertEqual(
|
||||||
`WebSocket client frame count for interaction ${index + 1}`,
|
`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 os from "node:os"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { HttpRecorder } from "../src"
|
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) =>
|
const post = (url: string, body: object) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -33,7 +42,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
|
|||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
Effect.provide(
|
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),
|
Effect.provide(NodeFileSystem.layer),
|
||||||
),
|
),
|
||||||
@@ -108,7 +117,7 @@ describe("http-recorder", () => {
|
|||||||
|
|
||||||
test("detects secret-looking values without returning the secret", () => {
|
test("detects secret-looking values without returning the secret", () => {
|
||||||
expect(
|
expect(
|
||||||
HttpRecorder.cassetteSecretFindings({
|
HttpRecorder.secretFindings({
|
||||||
version: 1,
|
version: 1,
|
||||||
interactions: [
|
interactions: [
|
||||||
{
|
{
|
||||||
@@ -136,7 +145,7 @@ describe("http-recorder", () => {
|
|||||||
|
|
||||||
test("detects secret-looking values inside metadata", () => {
|
test("detects secret-looking values inside metadata", () => {
|
||||||
expect(
|
expect(
|
||||||
HttpRecorder.cassetteSecretFindings({
|
HttpRecorder.secretFindings({
|
||||||
version: 1,
|
version: 1,
|
||||||
metadata: { token: "sk-123456789012345678901234" },
|
metadata: { token: "sk-123456789012345678901234" },
|
||||||
interactions: [],
|
interactions: [],
|
||||||
@@ -144,60 +153,42 @@ describe("http-recorder", () => {
|
|||||||
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("formats websocket cassettes with shared metadata", () => {
|
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
|
||||||
const cassette = HttpRecorder.cassetteFor(
|
await Effect.runPromise(
|
||||||
"websocket/basic",
|
Effect.scoped(
|
||||||
[
|
Effect.gen(function* () {
|
||||||
{
|
const cassette = yield* HttpRecorder.Cassette.Service
|
||||||
transport: "websocket",
|
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
name: "websocket/replay",
|
||||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
cassette,
|
||||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
compareClientMessagesAsJson: true,
|
||||||
},
|
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||||
],
|
})
|
||||||
{ provider: "openai" },
|
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(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||||
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
|
}).pipe(
|
||||||
})
|
Effect.provide(
|
||||||
|
HttpRecorder.Cassette.memory({
|
||||||
test("replays websocket interactions from the shared cassette service", async () => {
|
"websocket/replay": [
|
||||||
await runRecorder(
|
{
|
||||||
Effect.gen(function* () {
|
transport: "websocket",
|
||||||
const cassette = yield* HttpRecorder.Cassette.Service
|
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||||
yield* cassette.write(
|
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||||
"websocket/replay",
|
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
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,17 +218,14 @@ describe("http-recorder", () => {
|
|||||||
yield* connection.messages.pipe(Stream.runDrain)
|
yield* connection.messages.pipe(Stream.runDrain)
|
||||||
yield* connection.close
|
yield* connection.close
|
||||||
|
|
||||||
expect(yield* cassette.read("websocket/record")).toMatchObject({
|
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" } },
|
||||||
transport: "websocket",
|
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
},
|
||||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
])
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -300,6 +288,49 @@ describe("http-recorder", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 closest redacted request differences", async () => {
|
test("mismatch diagnostics show closest redacted request differences", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
|||||||
stream: {
|
stream: {
|
||||||
event: Schema.String,
|
event: Schema.String,
|
||||||
initial: () => undefined,
|
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" }],
|
onHalt: () => [{ type: "request-finish", reason: "stop" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
|
|||||||
import { Framing } from "../route/framing"
|
import { Framing } from "../route/framing"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
@@ -364,34 +364,56 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
return "unknown"
|
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 => {
|
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||||
if (!usage) return 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({
|
return new Usage({
|
||||||
inputTokens: usage.input_tokens,
|
inputTokens,
|
||||||
outputTokens: usage.output_tokens,
|
outputTokens: usage.output_tokens,
|
||||||
cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined,
|
cacheReadInputTokens: cacheRead,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined),
|
cacheWriteInputTokens: cacheWrite,
|
||||||
native: usage,
|
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||||
|
providerMetadata: { anthropic: usage },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anthropic emits usage on `message_start` and again on `message_delta` — the
|
// Anthropic emits usage on `message_start` and again on `message_delta` — the
|
||||||
// final delta carries the authoritative totals. Right-biased merge: each
|
// final delta carries the authoritative totals. Right-biased merge: each
|
||||||
// field prefers `right` when defined, falls back to `left`. `totalTokens` is
|
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
|
||||||
// recomputed from the merged input/output to stay consistent.
|
// recomputed from the merged breakdown so the inclusive total stays
|
||||||
|
// consistent with `nonCached + cacheRead + cacheWrite`.
|
||||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||||
if (!left) return right
|
if (!left) return right
|
||||||
if (!right) return left
|
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
|
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens,
|
nonCachedInputTokens,
|
||||||
cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens,
|
cacheReadInputTokens,
|
||||||
|
cacheWriteInputTokens,
|
||||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||||
native: { ...left.native, ...right.native },
|
providerMetadata: {
|
||||||
|
anthropic: {
|
||||||
|
...(left.providerMetadata?.["anthropic"] ?? {}),
|
||||||
|
...(right.providerMetadata?.["anthropic"] ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,14 +437,13 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
|||||||
? String((block.content as Record<string, unknown>).type)
|
? String((block.content as Record<string, unknown>).type)
|
||||||
: ""
|
: ""
|
||||||
const isError = errorPayload.endsWith("_tool_result_error")
|
const isError = errorPayload.endsWith("_tool_result_error")
|
||||||
return {
|
return LLMEvent.toolResult({
|
||||||
type: "tool-result",
|
|
||||||
id: block.tool_use_id ?? "",
|
id: block.tool_use_id ?? "",
|
||||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||||
@@ -453,18 +474,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "text" && block.text) {
|
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) {
|
if (block.type === "thinking" && block.thinking) {
|
||||||
return [
|
return [
|
||||||
state,
|
state,
|
||||||
[
|
[
|
||||||
{
|
LLMEvent.reasoningDelta({
|
||||||
type: "reasoning-delta",
|
id: `reasoning-${event.index ?? 0}`,
|
||||||
text: block.thinking,
|
text: block.thinking,
|
||||||
...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
|
}),
|
||||||
},
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -480,17 +500,25 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
|||||||
const delta = event.delta
|
const delta = event.delta
|
||||||
|
|
||||||
if (delta?.type === "text_delta" && delta.text) {
|
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) {
|
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) {
|
if (delta?.type === "signature_delta" && delta.signature) {
|
||||||
return [
|
return [
|
||||||
state,
|
state,
|
||||||
[{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
|
[
|
||||||
|
LLMEvent.reasoningEnd({
|
||||||
|
id: `reasoning-${event.index ?? 0}`,
|
||||||
|
providerMetadata: anthropicMetadata({ signature: delta.signature }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
] satisfies StepResult
|
] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,21 +552,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
|||||||
return [
|
return [
|
||||||
{ ...state, usage },
|
{ ...state, usage },
|
||||||
[
|
[
|
||||||
{
|
LLMEvent.requestFinish({
|
||||||
type: "request-finish",
|
|
||||||
reason: mapFinishReason(event.delta?.stop_reason),
|
reason: mapFinishReason(event.delta?.stop_reason),
|
||||||
usage,
|
usage,
|
||||||
...(event.delta?.stop_sequence
|
providerMetadata: event.delta?.stop_sequence
|
||||||
? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
|
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||||
: {}),
|
: undefined,
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||||
state,
|
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) => {
|
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
|
|||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
@@ -363,15 +363,22 @@ const mapFinishReason = (reason: string): FinishReason => {
|
|||||||
return "unknown"
|
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 => {
|
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
|
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
|
||||||
|
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.inputTokens,
|
inputTokens: usage.inputTokens,
|
||||||
outputTokens: usage.outputTokens,
|
outputTokens: usage.outputTokens,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||||
native: usage,
|
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||||
|
providerMetadata: { bedrock: usage },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,13 +407,26 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.contentBlockDelta?.delta?.text) {
|
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) {
|
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||||
return [
|
return [
|
||||||
state,
|
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
|
] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,16 +469,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
event.modelStreamErrorException?.message ??
|
event.modelStreamErrorException?.message ??
|
||||||
event.serviceUnavailableException?.message ??
|
event.serviceUnavailableException?.message ??
|
||||||
"Bedrock Converse stream error"
|
"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) {
|
if (event.validationException || event.throttlingException) {
|
||||||
const message =
|
const message =
|
||||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||||
return [
|
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const
|
||||||
state,
|
|
||||||
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
|
|
||||||
] as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [state, []] as const
|
return [state, []] as const
|
||||||
@@ -468,7 +485,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
|
|||||||
|
|
||||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||||
state.pendingFinish
|
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 { Framing } from "../route/framing"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
@@ -281,15 +281,28 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Stream Parsing
|
// 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.
|
||||||
|
// Output is left undefined when the visible component is missing, so we
|
||||||
|
// don't fabricate an inclusive number from a partial breakdown.
|
||||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
|
const cached = usage.cachedContentTokenCount
|
||||||
|
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||||
|
const outputTokens =
|
||||||
|
usage.candidatesTokenCount !== undefined
|
||||||
|
? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
|
||||||
|
: undefined
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.promptTokenCount,
|
inputTokens: usage.promptTokenCount,
|
||||||
outputTokens: usage.candidatesTokenCount,
|
outputTokens,
|
||||||
|
nonCachedInputTokens: nonCached,
|
||||||
|
cacheReadInputTokens: cached,
|
||||||
reasoningTokens: usage.thoughtsTokenCount,
|
reasoningTokens: usage.thoughtsTokenCount,
|
||||||
cacheReadInputTokens: usage.cachedContentTokenCount,
|
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
|
providerMetadata: { google: usage },
|
||||||
native: usage,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +324,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
|||||||
|
|
||||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||||
state.finishReason || state.usage
|
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) => {
|
const step = (state: ParserState, event: GeminiEvent) => {
|
||||||
@@ -332,14 +345,18 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
|
|
||||||
for (const part of candidate.content.parts) {
|
for (const part of candidate.content.parts) {
|
||||||
if ("text" in part && part.text.length > 0) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("functionCall" in part) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args
|
const input = part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
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
|
hasToolCalls = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
|||||||
import { HttpTransport } from "../route/transport"
|
import { HttpTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
@@ -290,15 +290,24 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
return "unknown"
|
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 => {
|
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||||
if (!usage) return 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({
|
return new Usage({
|
||||||
inputTokens: usage.prompt_tokens,
|
inputTokens: usage.prompt_tokens,
|
||||||
outputTokens: usage.completion_tokens,
|
outputTokens: usage.completion_tokens,
|
||||||
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens,
|
cacheReadInputTokens: cached,
|
||||||
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
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 ?? []
|
const toolDeltas = delta?.tool_calls ?? []
|
||||||
let tools = state.tools
|
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) {
|
for (const tool of toolDeltas) {
|
||||||
const result = ToolStream.appendOrStart(
|
const result = ToolStream.appendOrStart(
|
||||||
@@ -348,10 +357,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const hasToolCalls = state.toolCallEvents.length > 0
|
||||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||||
return [
|
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])]
|
||||||
...state.toolCallEvents,
|
|
||||||
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
|||||||
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMEvent,
|
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
@@ -276,15 +276,23 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Stream Parsing
|
// 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) => {
|
const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
|
||||||
if (!usage) return 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({
|
return new Usage({
|
||||||
inputTokens: usage.input_tokens,
|
inputTokens: usage.input_tokens,
|
||||||
outputTokens: usage.output_tokens,
|
outputTokens: usage.output_tokens,
|
||||||
reasoningTokens: usage.output_tokens_details?.reasoning_tokens,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: usage.input_tokens_details?.cached_tokens,
|
cacheReadInputTokens: cached,
|
||||||
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
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 tool = HOSTED_TOOLS[item.type]
|
||||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||||
return [
|
return [
|
||||||
{
|
LLMEvent.toolCall({
|
||||||
type: "tool-call",
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input: tool.input(item),
|
input: tool.input(item),
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
},
|
}),
|
||||||
{
|
LLMEvent.toolResult({
|
||||||
type: "tool-result",
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
result: hostedToolResult(item),
|
result: hostedToolResult(item),
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
},
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,17 +385,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
|||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
return [
|
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]]
|
||||||
state,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: "text-delta",
|
|
||||||
id: event.item_id,
|
|
||||||
text: event.delta,
|
|
||||||
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
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 => [
|
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||||
state,
|
state,
|
||||||
[
|
[
|
||||||
{
|
LLMEvent.requestFinish({
|
||||||
type: "request-finish",
|
|
||||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||||
usage: mapUsage(event.response?.usage),
|
usage: mapUsage(event.response?.usage),
|
||||||
...(event.response?.id || event.response?.service_tier
|
providerMetadata:
|
||||||
? {
|
event.response?.id || event.response?.service_tier
|
||||||
providerMetadata: openaiMetadata({
|
? openaiMetadata({
|
||||||
responseId: event.response.id,
|
responseId: event.response.id,
|
||||||
serviceTier: event.response.service_tier,
|
serviceTier: event.response.service_tier,
|
||||||
}),
|
})
|
||||||
}
|
: undefined,
|
||||||
: {}),
|
}),
|
||||||
},
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||||
state,
|
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 => [
|
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||||
state,
|
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) => {
|
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export interface ToolAccumulator {
|
|||||||
* supplied total; otherwise falls back to `inputTokens + outputTokens` only
|
* supplied total; otherwise falls back to `inputTokens + outputTokens` only
|
||||||
* when at least one is defined. Returns `undefined` when neither input nor
|
* when at least one is defined. Returns `undefined` when neither input nor
|
||||||
* output is known so routes don't publish a misleading `0`.
|
* output is known so routes don't publish a misleading `0`.
|
||||||
|
*
|
||||||
|
* Under the `LLM.Usage` contract, `inputTokens` and `outputTokens` are
|
||||||
|
* inclusive totals, so the computed fallback already covers cache reads /
|
||||||
|
* writes and reasoning — used mainly for Anthropic-style providers that
|
||||||
|
* don't surface a top-level total.
|
||||||
*/
|
*/
|
||||||
export const totalTokens = (
|
export const totalTokens = (
|
||||||
inputTokens: number | undefined,
|
inputTokens: number | undefined,
|
||||||
@@ -53,6 +58,39 @@ export const totalTokens = (
|
|||||||
return (inputTokens ?? 0) + (outputTokens ?? 0)
|
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.providerMetadata`
|
||||||
|
* 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) =>
|
export const eventError = (route: string, message: string, raw?: string) =>
|
||||||
new LLMError({
|
new LLMError({
|
||||||
module: "ProviderShared",
|
module: "ProviderShared",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
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"
|
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||||
|
|
||||||
type StreamKey = string | number
|
type StreamKey = string | number
|
||||||
@@ -49,34 +49,24 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
|
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
|
||||||
type: "tool-input-delta",
|
LLMEvent.toolInputDelta({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
text,
|
text,
|
||||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
||||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
||||||
Effect.map(
|
Effect.map(
|
||||||
(input): ToolCall =>
|
(input): ToolCall =>
|
||||||
tool.providerExecuted
|
LLMEvent.toolCall({
|
||||||
? {
|
id: tool.id,
|
||||||
type: "tool-call",
|
name: tool.name,
|
||||||
id: tool.id,
|
input,
|
||||||
name: tool.name,
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
input,
|
providerMetadata: tool.providerMetadata,
|
||||||
providerExecuted: true,
|
}),
|
||||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "tool-call",
|
|
||||||
id: tool.id,
|
|
||||||
name: tool.name,
|
|
||||||
input,
|
|
||||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,155 @@
|
|||||||
import { Schema } from "effect"
|
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 { ModelRef } from "./options"
|
||||||
import { ToolResultValue } from "./messages"
|
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")({
|
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
|
||||||
inputTokens: Schema.optional(Schema.Number),
|
inputTokens: Schema.optional(Schema.Number),
|
||||||
outputTokens: Schema.optional(Schema.Number),
|
outputTokens: Schema.optional(Schema.Number),
|
||||||
reasoningTokens: Schema.optional(Schema.Number),
|
nonCachedInputTokens: Schema.optional(Schema.Number),
|
||||||
cacheReadInputTokens: Schema.optional(Schema.Number),
|
cacheReadInputTokens: Schema.optional(Schema.Number),
|
||||||
cacheWriteInputTokens: Schema.optional(Schema.Number),
|
cacheWriteInputTokens: Schema.optional(Schema.Number),
|
||||||
|
reasoningTokens: Schema.optional(Schema.Number),
|
||||||
totalTokens: 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({
|
export const RequestStart = Schema.Struct({
|
||||||
type: Schema.Literal("request-start"),
|
type: Schema.tag("request-start"),
|
||||||
id: Schema.String,
|
id: ResponseID,
|
||||||
model: ModelRef,
|
model: ModelRef,
|
||||||
}).annotate({ identifier: "LLM.Event.RequestStart" })
|
}).annotate({ identifier: "LLM.Event.RequestStart" })
|
||||||
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
|
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
|
||||||
|
|
||||||
export const StepStart = Schema.Struct({
|
export const StepStart = Schema.Struct({
|
||||||
type: Schema.Literal("step-start"),
|
type: Schema.tag("step-start"),
|
||||||
index: Schema.Number,
|
index: Schema.Number,
|
||||||
}).annotate({ identifier: "LLM.Event.StepStart" })
|
}).annotate({ identifier: "LLM.Event.StepStart" })
|
||||||
export type StepStart = Schema.Schema.Type<typeof StepStart>
|
export type StepStart = Schema.Schema.Type<typeof StepStart>
|
||||||
|
|
||||||
export const TextStart = Schema.Struct({
|
export const TextStart = Schema.Struct({
|
||||||
type: Schema.Literal("text-start"),
|
type: Schema.tag("text-start"),
|
||||||
id: Schema.String,
|
id: ContentBlockID,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||||
|
|
||||||
export const TextDelta = Schema.Struct({
|
export const TextDelta = Schema.Struct({
|
||||||
type: Schema.Literal("text-delta"),
|
type: Schema.tag("text-delta"),
|
||||||
id: Schema.optional(Schema.String),
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||||
|
|
||||||
export const TextEnd = Schema.Struct({
|
export const TextEnd = Schema.Struct({
|
||||||
type: Schema.Literal("text-end"),
|
type: Schema.tag("text-end"),
|
||||||
id: Schema.String,
|
id: ContentBlockID,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||||
|
|
||||||
export const ReasoningDelta = Schema.Struct({
|
export const ReasoningStart = Schema.Struct({
|
||||||
type: Schema.Literal("reasoning-delta"),
|
type: Schema.tag("reasoning-start"),
|
||||||
id: Schema.optional(Schema.String),
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
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" })
|
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||||
export type ReasoningDelta = Schema.Schema.Type<typeof 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({
|
export const ToolInputDelta = Schema.Struct({
|
||||||
type: Schema.Literal("tool-input-delta"),
|
type: Schema.tag("tool-input-delta"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||||
export type ToolInputDelta = Schema.Schema.Type<typeof 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({
|
export const ToolCall = Schema.Struct({
|
||||||
type: Schema.Literal("tool-call"),
|
type: Schema.tag("tool-call"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
input: Schema.Unknown,
|
input: Schema.Unknown,
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
@@ -76,8 +158,8 @@ export const ToolCall = Schema.Struct({
|
|||||||
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||||
|
|
||||||
export const ToolResult = Schema.Struct({
|
export const ToolResult = Schema.Struct({
|
||||||
type: Schema.Literal("tool-result"),
|
type: Schema.tag("tool-result"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
result: ToolResultValue,
|
result: ToolResultValue,
|
||||||
providerExecuted: Schema.optional(Schema.Boolean),
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
@@ -86,8 +168,8 @@ export const ToolResult = Schema.Struct({
|
|||||||
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||||
|
|
||||||
export const ToolError = Schema.Struct({
|
export const ToolError = Schema.Struct({
|
||||||
type: Schema.Literal("tool-error"),
|
type: Schema.tag("tool-error"),
|
||||||
id: Schema.String,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
@@ -95,7 +177,7 @@ export const ToolError = Schema.Struct({
|
|||||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||||
|
|
||||||
export const StepFinish = Schema.Struct({
|
export const StepFinish = Schema.Struct({
|
||||||
type: Schema.Literal("step-finish"),
|
type: Schema.tag("step-finish"),
|
||||||
index: Schema.Number,
|
index: Schema.Number,
|
||||||
reason: FinishReason,
|
reason: FinishReason,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
@@ -104,7 +186,7 @@ export const StepFinish = Schema.Struct({
|
|||||||
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||||
|
|
||||||
export const RequestFinish = Schema.Struct({
|
export const RequestFinish = Schema.Struct({
|
||||||
type: Schema.Literal("request-finish"),
|
type: Schema.tag("request-finish"),
|
||||||
reason: FinishReason,
|
reason: FinishReason,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
@@ -112,7 +194,7 @@ export const RequestFinish = Schema.Struct({
|
|||||||
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
||||||
|
|
||||||
export const ProviderErrorEvent = Schema.Struct({
|
export const ProviderErrorEvent = Schema.Struct({
|
||||||
type: Schema.Literal("provider-error"),
|
type: Schema.tag("provider-error"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
retryable: Schema.optional(Schema.Boolean),
|
retryable: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
@@ -125,8 +207,12 @@ const llmEventTagged = Schema.Union([
|
|||||||
TextStart,
|
TextStart,
|
||||||
TextDelta,
|
TextDelta,
|
||||||
TextEnd,
|
TextEnd,
|
||||||
|
ReasoningStart,
|
||||||
ReasoningDelta,
|
ReasoningDelta,
|
||||||
|
ReasoningEnd,
|
||||||
|
ToolInputStart,
|
||||||
ToolInputDelta,
|
ToolInputDelta,
|
||||||
|
ToolInputEnd,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolError,
|
ToolError,
|
||||||
@@ -135,20 +221,52 @@ const llmEventTagged = Schema.Union([
|
|||||||
ProviderErrorEvent,
|
ProviderErrorEvent,
|
||||||
]).pipe(Schema.toTaggedUnion("type"))
|
]).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`).
|
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
|
||||||
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
|
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
|
||||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||||
*/
|
*/
|
||||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
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: {
|
is: {
|
||||||
requestStart: llmEventTagged.guards["request-start"],
|
requestStart: llmEventTagged.guards["request-start"],
|
||||||
stepStart: llmEventTagged.guards["step-start"],
|
stepStart: llmEventTagged.guards["step-start"],
|
||||||
textStart: llmEventTagged.guards["text-start"],
|
textStart: llmEventTagged.guards["text-start"],
|
||||||
textDelta: llmEventTagged.guards["text-delta"],
|
textDelta: llmEventTagged.guards["text-delta"],
|
||||||
textEnd: llmEventTagged.guards["text-end"],
|
textEnd: llmEventTagged.guards["text-end"],
|
||||||
|
reasoningStart: llmEventTagged.guards["reasoning-start"],
|
||||||
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
||||||
|
reasoningEnd: llmEventTagged.guards["reasoning-end"],
|
||||||
|
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||||
|
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||||
toolCall: llmEventTagged.guards["tool-call"],
|
toolCall: llmEventTagged.guards["tool-call"],
|
||||||
toolResult: llmEventTagged.guards["tool-result"],
|
toolResult: llmEventTagged.guards["tool-result"],
|
||||||
toolError: llmEventTagged.guards["tool-error"],
|
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 const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
|
||||||
export type ProviderID = typeof ProviderID.Type
|
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 ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type ContentPart,
|
type ContentPart,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMError,
|
type LLMError,
|
||||||
type LLMEvent,
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
Message,
|
Message,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
@@ -115,11 +115,19 @@ interface StepState {
|
|||||||
|
|
||||||
const accumulate = (state: StepState, event: LLMEvent) => {
|
const accumulate = (state: StepState, event: LLMEvent) => {
|
||||||
if (event.type === "text-delta") {
|
if (event.type === "text-delta") {
|
||||||
appendStreamingText(state, "text", event.text, event.providerMetadata)
|
appendStreamingText(state, "text", event.text, undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "reasoning-delta") {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "tool-call") {
|
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> =>
|
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
||||||
result.type === "error"
|
result.type === "error"
|
||||||
? [
|
? [
|
||||||
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
|
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
|
||||||
{ type: "tool-result", id: call.id, name: call.name, result },
|
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 = (
|
const followUpRequest = (
|
||||||
request: LLMRequest,
|
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 =>
|
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>({
|
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||||
id: "fake",
|
id: "fake",
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "anthropic-messages/anthropic-haiku-4-5-text",
|
||||||
|
"recordedAt": "2026-05-11T02:02:03.804Z",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"route": "anthropic-messages",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "claude-haiku-4-5-20251001",
|
||||||
|
"tags": [
|
||||||
|
"prefix:anthropic-messages",
|
||||||
|
"provider:anthropic",
|
||||||
|
"text",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SvRWwb75gDuhBpVMHjnFaf\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+39
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "anthropic-messages/anthropic-haiku-4-5-tool-call",
|
||||||
|
"recordedAt": "2026-05-11T02:02:04.363Z",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"route": "anthropic-messages",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "claude-haiku-4-5-20251001",
|
||||||
|
"tags": [
|
||||||
|
"prefix:anthropic-messages",
|
||||||
|
"provider:anthropic",
|
||||||
|
"tool",
|
||||||
|
"tool-call",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Lu38yDM3WD8QBQTcg3dBaF\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_017Dqk9SAAsHHfiLKsUyitaQ\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"cit\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"y\\\": \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "anthropic-messages/anthropic-opus-4-7-tool-loop",
|
||||||
|
"recordedAt": "2026-05-11T02:02:07.788Z",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"route": "anthropic-messages",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "claude-opus-4-7",
|
||||||
|
"tags": [
|
||||||
|
"prefix:anthropic-messages",
|
||||||
|
"provider:anthropic",
|
||||||
|
"flagship",
|
||||||
|
"tool",
|
||||||
|
"tool-loop",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01GK5kgi8AuVfRCnQFcXEfV8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":812,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01BnVDAp13NU8ZdJ9JeJ7byF\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"c\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ity\\\": \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Paris\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":812,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":66} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01BnVDAp13NU8ZdJ9JeJ7byF\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01BnVDAp13NU8ZdJ9JeJ7byF\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01237VTnjPeYSRh31UjXWaEa\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":909,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":8,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris is sunny.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":909,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":12} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order.json
Vendored
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "anthropic-messages/rejects-malformed-assistant-tool-order",
|
||||||
|
"recordedAt": "2026-05-11T02:01:44.544Z",
|
||||||
|
"tags": [
|
||||||
|
"prefix:anthropic-messages",
|
||||||
|
"provider:anthropic",
|
||||||
|
"protocol:anthropic-messages",
|
||||||
|
"tool",
|
||||||
|
"sad-path"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 400,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011CauxVdQf3N2PPFJ5aH8Bh\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "gemini/gemini-2-5-flash-text",
|
||||||
|
"recordedAt": "2026-05-11T02:02:08.410Z",
|
||||||
|
"provider": "google",
|
||||||
|
"route": "gemini",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "gemini-2.5-flash",
|
||||||
|
"tags": [
|
||||||
|
"prefix:gemini",
|
||||||
|
"provider:google",
|
||||||
|
"text",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply exactly with: Hello!\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are concise.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello!\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 11,\"candidatesTokenCount\": 2,\"totalTokenCount\": 13,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 11}],\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"nzgBatP3OZmW-8YP567bqQs\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "gemini/gemini-2-5-flash-tool-call",
|
||||||
|
"recordedAt": "2026-05-11T02:02:09.308Z",
|
||||||
|
"provider": "google",
|
||||||
|
"route": "gemini",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "gemini-2.5-flash",
|
||||||
|
"tags": [
|
||||||
|
"prefix:gemini",
|
||||||
|
"provider:google",
|
||||||
|
"tool",
|
||||||
|
"tool-call",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call tools exactly as requested.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"ANY\",\"allowedFunctionNames\":[\"get_weather\"]}},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"get_weather\",\"args\": {\"city\": \"Paris\"}},\"thoughtSignature\": \"CiQBDDnWx/X6sWeX2joSugyWO3L/lt0AgIPCvhpqf3845fj+H70KXwEMOdbHB/cnaqYCro0pU+yLWoA55jhuwoLmTcnYm4Qzcm5DuW/v0NUyz8RDx6DFh61juENveUztly6yc6/XiWJHtsgncd9YgcZhuQKqtp5KZTkGYpT3g6v3yP9GK4AoCoUBAQw51sePh3WuWovHnwIotKLVZiU9pwh34k4FY7ugPOxyDAG9j69cy7BYYzSchI10LEjLoLlCMZuNIPootBgI02QWY/4h2PIv33BAADrFPM2T3aE4cAuMoa3GCu2nztJ/95junDhIhuXZSQ/Mh9EVxpx7ml99Z7Hxb7OtDsSZZLeCBuGmSw==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 55,\"candidatesTokenCount\": 15,\"totalTokenCount\": 115,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 55}],\"thoughtsTokenCount\": 45,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"oDgBauj6HZ3B-8YPpaOc6QU\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "openai-chat/openai-chat-gpt-4o-mini-text",
|
||||||
|
"recordedAt": "2026-05-11T02:01:46.536Z",
|
||||||
|
"provider": "openai",
|
||||||
|
"route": "openai-chat",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-chat",
|
||||||
|
"provider:openai",
|
||||||
|
"text",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.openai.com/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"wHajUz1js\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lVHzGq\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3gaQRWEjE7\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"vuKPZ\"}\n\ndata: {\"id\":\"chatcmpl-DeAFmC9UiBnVu6dAjBvWVBVypm2gc\",\"object\":\"chat.completion.chunk\",\"created\":1778464906,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_3ef558a83f\",\"choices\":[],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":2,\"total_tokens\":23,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"kfFsssdujmM\"}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "openai-chat/openai-chat-gpt-4o-mini-tool-call",
|
||||||
|
"recordedAt": "2026-05-11T02:01:47.484Z",
|
||||||
|
"provider": "openai",
|
||||||
|
"route": "openai-chat",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-chat",
|
||||||
|
"provider:openai",
|
||||||
|
"tool",
|
||||||
|
"tool-call",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.openai.com/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_sH8T7MPdJXS5KJginKrzexL5\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"d0rJ\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hL82erd6bBoy91\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"e3aoKH4tvJlXW\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"T8EzFVNaUCLE\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lelRBxF08Zes\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"F1xO8sVMyZt4BU\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"YL3pl\"}\n\ndata: {\"id\":\"chatcmpl-DeAFnVMdELyuymMRlTqN5dBdM7eGd\",\"object\":\"chat.completion.chunk\",\"created\":1778464907,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d88f6e55bb\",\"choices\":[],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"7cov9qkofwo\"}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "openai-chat/openai-chat-gpt-4o-mini-tool-loop",
|
||||||
|
"recordedAt": "2026-05-11T02:01:50.433Z",
|
||||||
|
"provider": "openai",
|
||||||
|
"route": "openai-chat",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-chat",
|
||||||
|
"provider:openai",
|
||||||
|
"tool",
|
||||||
|
"tool-loop",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.openai.com/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_HrDZhrMUauvVddKWzVQFJ69Q\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"aAqC\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"haZ3pKk14oDay0\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ZUJHQytFyDezp\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3buqnStPteyG\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"7epPqHEU3OeA\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gSU5gyl9K7sUCv\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"usage\":null,\"obfuscation\":\"P3oSvByfy60JCsz\"}\n\ndata: {\"id\":\"chatcmpl-DeAFo2rbsuuMn0ftzxwfDpqnYANrT\",\"object\":\"chat.completion.chunk\",\"created\":1778464908,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[],\"usage\":{\"prompt_tokens\":71,\"completion_tokens\":14,\"total_tokens\":85,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"5AhQ5ToYra\"}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.openai.com/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_HrDZhrMUauvVddKWzVQFJ69Q\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_HrDZhrMUauvVddKWzVQFJ69Q\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"2QWjdWPSe\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sm5IpQ\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"cWsGxqHw\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"8hNCi\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4yKCNDRcwq\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"NvRgg\"}\n\ndata: {\"id\":\"chatcmpl-DeAFpaPsUwxt5hjwGgC4BBmlALxwR\",\"object\":\"chat.completion.chunk\",\"created\":1778464909,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c83829e916\",\"choices\":[],\"usage\":{\"prompt_tokens\":103,\"completion_tokens\":5,\"total_tokens\":108,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"4s7mLtNpZ\"}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "openai-compatible-chat/deepseek-chat-text",
|
||||||
|
"recordedAt": "2026-05-11T02:02:10.220Z",
|
||||||
|
"provider": "deepseek",
|
||||||
|
"route": "openai-compatible-chat",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-compatible-chat",
|
||||||
|
"provider:deepseek",
|
||||||
|
"text",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.deepseek.com/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"159019a5-f981-4103-8b3c-2be37cefc505\",\"object\":\"chat.completion.chunk\",\"created\":1778464929,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "openai-compatible-chat/togetherai-llama-3-3-70b-text",
|
||||||
|
"recordedAt": "2026-05-11T02:02:13.341Z",
|
||||||
|
"provider": "togetherai",
|
||||||
|
"route": "openai-compatible-chat",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-compatible-chat",
|
||||||
|
"provider:togetherai",
|
||||||
|
"text",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.together.xyz/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream;charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"id\":\"oibreET-3pDw3Z-9f9d9996ce37066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464931,\"choices\":[{\"index\":0,\"text\":\"Hello\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":9906,\"role\":\"assistant\",\"content\":\"Hello\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"oibreET-3pDw3Z-9f9d9996ce37066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464931,\"choices\":[{\"index\":0,\"text\":\"!\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"!\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"oibreET-3pDw3Z-9f9d9996ce37066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464931,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"stop\",\"seed\":12144769634208630000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":3,\"total_tokens\":48,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-llama-3-3-70b-tool-call.json
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "openai-compatible-chat/togetherai-llama-3-3-70b-tool-call",
|
||||||
|
"recordedAt": "2026-05-11T02:02:14.453Z",
|
||||||
|
"provider": "togetherai",
|
||||||
|
"route": "openai-compatible-chat",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-compatible-chat",
|
||||||
|
"provider:togetherai",
|
||||||
|
"tool",
|
||||||
|
"tool-call",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.together.xyz/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream;charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"role\":\"assistant\",\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"call_jue52dtu6iozr0ny9rq357u5\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oibreu3-6Ng1vN-9f9d99a9ba73066b\",\"object\":\"chat.completion.chunk\",\"created\":1778464933,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"seed\":17440360718047570000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":19,\"total_tokens\":213,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"name": "openai-responses-websocket/openai-responses-websocket-gpt-4-1-mini-tool-loop",
|
||||||
|
"recordedAt": "2026-05-11T02:02:03.284Z",
|
||||||
|
"provider": "openai",
|
||||||
|
"route": "openai-responses-websocket",
|
||||||
|
"transport": "websocket",
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-responses-websocket",
|
||||||
|
"provider:openai",
|
||||||
|
"transport:websocket",
|
||||||
|
"tool",
|
||||||
|
"tool-loop",
|
||||||
|
"golden"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"open": {
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"headers": {}
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"max_output_tokens\":80,\"temperature\":0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"server": [
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_0f8d81b5d3287513016a013897415481a28585571d6710366f\",\"object\":\"response\",\"created_at\":1778464919,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0f8d81b5d3287513016a013897415481a28585571d6710366f\",\"object\":\"response\",\"created_at\":1778464919,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"EAdvUfYaysnNd3\",\"output_index\":0,\"sequence_number\":3}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"b0QWJJlscLyl\",\"output_index\":0,\"sequence_number\":4}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"m5EV1wzceCkjb\",\"output_index\":0,\"sequence_number\":5}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"QM3FjPoN9Gi\",\"output_index\":0,\"sequence_number\":6}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"obfuscation\":\"lug74orAYXtg0e\",\"output_index\":0,\"sequence_number\":7}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"output_index\":0,\"sequence_number\":8}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0f8d81b5d3287513016a013897415481a28585571d6710366f\",\"object\":\"response\",\"created_at\":1778464919,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464920,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"fc_0f8d81b5d3287513016a01389815f081a2a68583ed9b19c405\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":69,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":15,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":84},\"user\":null,\"metadata\":{}},\"sequence_number\":10}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"open": {
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"headers": {}
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_wRJoUdcb6w5SIpieBFOKxF6z\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"max_output_tokens\":80,\"temperature\":0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"server": [
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_0767cfe3f5d98b2a016a0138994258819485d082e5c78849a4\",\"object\":\"response\",\"created_at\":1778464921,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0767cfe3f5d98b2a016a0138994258819485d082e5c78849a4\",\"object\":\"response\",\"created_at\":1778464921,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Paris\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"fWbkBKTZ5oG\",\"output_index\":0,\"sequence_number\":4}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"BGuPlDrPchXwG\",\"output_index\":0,\"sequence_number\":5}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"7THzde2pni\",\"output_index\":0,\"sequence_number\":6}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"obfuscation\":\"Eo38bSElfyNmljj\",\"output_index\":0,\"sequence_number\":7}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"Paris is sunny.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"},\"sequence_number\":9}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0767cfe3f5d98b2a016a0138994258819485d082e5c78849a4\",\"object\":\"response\",\"created_at\":1778464921,\"status\":\"completed\",\"background\":false,\"completed_at\":1778464923,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_0767cfe3f5d98b2a016a01389b1434819493f3d33e0ec6973c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":99,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":105},\"user\":null,\"metadata\":{}},\"sequence_number\":11}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+38
File diff suppressed because one or more lines are too long
Vendored
+39
File diff suppressed because one or more lines are too long
Vendored
+57
File diff suppressed because one or more lines are too long
@@ -126,7 +126,7 @@ describe("llm constructors", () => {
|
|||||||
expect(
|
expect(
|
||||||
LLMResponse.text({
|
LLMResponse.text({
|
||||||
events: [
|
events: [
|
||||||
{ type: "text-delta", text: "hi" },
|
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||||
{ type: "request-finish", reason: "stop" },
|
{ type: "request-finish", reason: "stop" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Redactor } from "@opencode-ai/http-recorder"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, LLMError } from "../../src"
|
import { LLM, LLMError } from "../../src"
|
||||||
@@ -30,7 +31,7 @@ const recorded = recordedTests({
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
protocol: "anthropic-messages",
|
protocol: "anthropic-messages",
|
||||||
requires: ["ANTHROPIC_API_KEY"],
|
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", () => {
|
describe("Anthropic Messages sad-path recorded", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { CacheHint, LLM, LLMError } from "../../src"
|
import { CacheHint, LLM, LLMError, Usage } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
@@ -110,12 +110,13 @@ describe("Anthropic Messages route", () => {
|
|||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.reasoning).toBe("thinking")
|
expect(response.reasoning).toBe("thinking")
|
||||||
expect(response.usage).toMatchObject({
|
expect(response.usage).toMatchObject({
|
||||||
inputTokens: 5,
|
inputTokens: 6,
|
||||||
outputTokens: 2,
|
outputTokens: 2,
|
||||||
|
nonCachedInputTokens: 5,
|
||||||
cacheReadInputTokens: 1,
|
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" } },
|
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||||
})
|
})
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({
|
||||||
@@ -152,7 +153,13 @@ describe("Anthropic Messages route", () => {
|
|||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "tool-calls",
|
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 } },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, LLMError } from "../../src"
|
import { LLM, LLMError, Usage } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import * as Gemini from "../../src/protocols/gemini"
|
import * as Gemini from "../../src/protocols/gemini"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
@@ -198,32 +198,36 @@ describe("Gemini route", () => {
|
|||||||
expect(response.reasoning).toBe("thinking")
|
expect(response.reasoning).toBe("thinking")
|
||||||
expect(response.usage).toMatchObject({
|
expect(response.usage).toMatchObject({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 2,
|
outputTokens: 3,
|
||||||
reasoningTokens: 1,
|
nonCachedInputTokens: 4,
|
||||||
cacheReadInputTokens: 1,
|
cacheReadInputTokens: 1,
|
||||||
|
reasoningTokens: 1,
|
||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
})
|
})
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "reasoning-delta", text: "thinking" },
|
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||||
{ type: "text-delta", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "text-delta", text: "!" },
|
{ type: "text-delta", id: "text-0", text: "!" },
|
||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
usage: {
|
usage: new Usage({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 2,
|
outputTokens: 3,
|
||||||
reasoningTokens: 1,
|
nonCachedInputTokens: 4,
|
||||||
cacheReadInputTokens: 1,
|
cacheReadInputTokens: 1,
|
||||||
|
reasoningTokens: 1,
|
||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
native: {
|
providerMetadata: {
|
||||||
promptTokenCount: 5,
|
google: {
|
||||||
candidatesTokenCount: 2,
|
promptTokenCount: 5,
|
||||||
totalTokenCount: 7,
|
candidatesTokenCount: 2,
|
||||||
thoughtsTokenCount: 1,
|
totalTokenCount: 7,
|
||||||
cachedContentTokenCount: 1,
|
thoughtsTokenCount: 1,
|
||||||
|
cachedContentTokenCount: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
@@ -257,12 +261,13 @@ describe("Gemini route", () => {
|
|||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "tool-calls",
|
reason: "tool-calls",
|
||||||
usage: {
|
usage: new Usage({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 1,
|
outputTokens: 1,
|
||||||
|
nonCachedInputTokens: 5,
|
||||||
totalTokens: 6,
|
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 AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||||
import * as Gemini from "../../src/protocols/gemini"
|
import * as Gemini from "../../src/protocols/gemini"
|
||||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||||
@@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) =>
|
|||||||
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
||||||
|
|
||||||
const cloudflareOptions = {
|
const cloudflareOptions = {
|
||||||
redact: { url: redactCloudflareURL },
|
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
describeRecordedGoldenScenarios([
|
describeRecordedGoldenScenarios([
|
||||||
@@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([
|
|||||||
prefix: "anthropic-messages",
|
prefix: "anthropic-messages",
|
||||||
model: anthropicHaiku,
|
model: anthropicHaiku,
|
||||||
requires: ["ANTHROPIC_API_KEY"],
|
requires: ["ANTHROPIC_API_KEY"],
|
||||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||||
scenarios: ["text", "tool-call"],
|
scenarios: ["text", "tool-call"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([
|
|||||||
model: anthropicOpus,
|
model: anthropicOpus,
|
||||||
requires: ["ANTHROPIC_API_KEY"],
|
requires: ["ANTHROPIC_API_KEY"],
|
||||||
tags: ["flagship"],
|
tags: ["flagship"],
|
||||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||||
scenarios: [{ id: "tool-loop", temperature: false }],
|
scenarios: [{ id: "tool-loop", temperature: false }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
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 Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||||
@@ -225,25 +225,28 @@ describe("OpenAI Chat route", () => {
|
|||||||
|
|
||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "text-delta", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "text-delta", text: "!" },
|
{ type: "text-delta", id: "text-0", text: "!" },
|
||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
usage: {
|
usage: new Usage({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 2,
|
outputTokens: 2,
|
||||||
reasoningTokens: 0,
|
nonCachedInputTokens: 4,
|
||||||
cacheReadInputTokens: 1,
|
cacheReadInputTokens: 1,
|
||||||
|
reasoningTokens: 0,
|
||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
native: {
|
providerMetadata: {
|
||||||
prompt_tokens: 5,
|
openai: {
|
||||||
completion_tokens: 2,
|
prompt_tokens: 5,
|
||||||
total_tokens: 7,
|
completion_tokens: 2,
|
||||||
prompt_tokens_details: { cached_tokens: 1 },
|
total_tokens: 7,
|
||||||
completion_tokens_details: { reasoning_tokens: 0 },
|
prompt_tokens_details: { cached_tokens: 1 },
|
||||||
|
completion_tokens_details: { reasoning_tokens: 0 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
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 { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
@@ -336,26 +336,29 @@ describe("OpenAI Responses route", () => {
|
|||||||
|
|
||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||||
{ type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } },
|
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||||
usage: {
|
usage: new Usage({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 2,
|
outputTokens: 2,
|
||||||
reasoningTokens: 0,
|
nonCachedInputTokens: 4,
|
||||||
cacheReadInputTokens: 1,
|
cacheReadInputTokens: 1,
|
||||||
|
reasoningTokens: 0,
|
||||||
totalTokens: 7,
|
totalTokens: 7,
|
||||||
native: {
|
providerMetadata: {
|
||||||
input_tokens: 5,
|
openai: {
|
||||||
output_tokens: 2,
|
input_tokens: 5,
|
||||||
total_tokens: 7,
|
output_tokens: 2,
|
||||||
input_tokens_details: { cached_tokens: 1 },
|
total_tokens: 7,
|
||||||
output_tokens_details: { reasoning_tokens: 0 },
|
input_tokens_details: { cached_tokens: 1 },
|
||||||
|
output_tokens_details: { reasoning_tokens: 0 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
@@ -394,14 +397,12 @@ describe("OpenAI Responses route", () => {
|
|||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
text: '{"query"',
|
text: '{"query"',
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-input-delta",
|
type: "tool-input-delta",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
text: ':"weather"}',
|
text: ':"weather"}',
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
@@ -413,7 +414,13 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "request-finish",
|
type: "request-finish",
|
||||||
reason: "tool-calls",
|
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 } },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
|||||||
...metadata,
|
...metadata,
|
||||||
}
|
}
|
||||||
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
|
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),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
const requestExecutor = RequestExecutor.layer.pipe(
|
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 { Effect, Layer } from "effect"
|
||||||
import { WebSocketExecutor } from "../src/route"
|
import { WebSocketExecutor } from "../src/route"
|
||||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||||
|
|
||||||
const liveWebSocket = WebSocketExecutor.open
|
const liveWebSocket = WebSocketExecutor.open
|
||||||
type Mode = "record" | "replay" | "passthrough"
|
|
||||||
|
|
||||||
export const webSocketCassetteLayer = (
|
export const webSocketCassetteLayer = (
|
||||||
cassette: string,
|
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.Layer<WebSocketExecutorService, never, Cassette.Service> =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
WebSocketExecutor.Service,
|
WebSocketExecutor.Service,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
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({
|
const model = new ModelRef({
|
||||||
id: ModelID.make("fake-model"),
|
id: ModelID.make("fake-model"),
|
||||||
@@ -48,3 +49,30 @@ describe("llm schema", () => {
|
|||||||
expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.43",
|
"version": "1.14.46",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -119,6 +119,7 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ Rules:
|
|||||||
|
|
||||||
## Schema → Zod interop
|
## Schema → Zod interop
|
||||||
|
|
||||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
|
|
||||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims.
|
|||||||
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
||||||
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
|
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
|
||||||
compatibility boundaries by exposing a `.zod` static derived from the Effect
|
compatibility boundaries by exposing a `.zod` static derived from the Effect
|
||||||
schema via `@/util/effect-zod`.
|
schema via `@opencode-ai/core/effect-zod`.
|
||||||
|
|
||||||
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
|
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
|
||||||
moves to `@effect/platform`, every Schema-first DTO can flow through
|
moves to `@effect/platform`, every Schema-first DTO can flow through
|
||||||
@@ -97,7 +97,7 @@ creating a parallel schema source of truth.
|
|||||||
|
|
||||||
## Escape hatches
|
## Escape hatches
|
||||||
|
|
||||||
The walker in `@/util/effect-zod` exposes two explicit escape hatches for
|
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
|
||||||
cases the pure-Schema path cannot express. Each one stays in the codebase
|
cases the pure-Schema path cannot express. Each one stays in the codebase
|
||||||
only as long as its upstream or local dependency requires it — inline
|
only as long as its upstream or local dependency requires it — inline
|
||||||
comments document when each can be deleted.
|
comments document when each can be deleted.
|
||||||
@@ -389,7 +389,7 @@ piecewise.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Use `@/util/effect-zod` for all Schema → Zod conversion.
|
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
|
||||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
||||||
Effect definitions for the same domain type.
|
Effect definitions for the same domain type.
|
||||||
- Keep the migration incremental. Converting the domain model first is more
|
- Keep the migration incremental. Converting the domain model first is more
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# OpenAPI Translation Cleanup Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
|
||||||
|
|
||||||
|
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
|
||||||
|
|
||||||
|
## Current Culprit
|
||||||
|
|
||||||
|
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
|
||||||
|
|
||||||
|
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
|
||||||
|
|
||||||
|
## Non-Negotiables
|
||||||
|
|
||||||
|
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
|
||||||
|
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
|
||||||
|
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
|
||||||
|
- Prefer endpoint or schema annotations over post-generation spec surgery.
|
||||||
|
- Remove one category of rewrite at a time, with focused compatibility checks.
|
||||||
|
|
||||||
|
## PR Checklist
|
||||||
|
|
||||||
|
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
|
||||||
|
|
||||||
|
Current combined PR scope:
|
||||||
|
|
||||||
|
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
|
||||||
|
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
|
||||||
|
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
|
||||||
|
|
||||||
|
### PR 1: Add OpenAPI/Runtime Query Drift Tests
|
||||||
|
|
||||||
|
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
|
||||||
|
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
|
||||||
|
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
|
||||||
|
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
|
||||||
|
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
|
||||||
|
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
|
||||||
|
- `[x]` Keep this part test-only.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||||
|
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 2: Delete Spec-Only Workspace Query Injection
|
||||||
|
|
||||||
|
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||||
|
- `[x]` Delete `InstanceQueryParameters`.
|
||||||
|
- `[x]` Delete the `isInstanceRoute` constant.
|
||||||
|
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
|
||||||
|
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
|
||||||
|
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
|
||||||
|
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
|
||||||
|
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
|
||||||
|
|
||||||
|
Expected code shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||||
|
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
|
||||||
|
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
|
||||||
|
|
||||||
|
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||||
|
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
|
||||||
|
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
|
||||||
|
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
|
||||||
|
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
|
||||||
|
- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
|
||||||
|
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Focused HTTP tests for changed query fields.
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated SDK request param types before deleting each override.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 4: Move Path Parameter Patterns Into ID Schemas
|
||||||
|
|
||||||
|
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
|
||||||
|
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
|
||||||
|
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||||
|
- Delete one path override only after generated OpenAPI is unchanged for that param.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- `[x]` `sessionID`
|
||||||
|
- `[x]` `messageID`
|
||||||
|
- `[x]` `partID`
|
||||||
|
- `[x]` `permissionID`
|
||||||
|
- `[x]` `ptyID`
|
||||||
|
|
||||||
|
- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated path param types and patterns.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
|
||||||
|
|
||||||
|
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
|
||||||
|
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
|
||||||
|
- Update handlers to fail with the declared API errors at the boundary.
|
||||||
|
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
|
||||||
|
- Do this group by group, starting with one small route group.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- `groups/config.ts` `PATCH /config` bad request.
|
||||||
|
- `groups/session.ts` endpoints that already translate domain not-found errors.
|
||||||
|
- `groups/file.ts` if any handler currently relies on built-in error shape.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Focused HTTP tests asserting response body shape for changed error paths.
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect SDK error union diff.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
|
|
||||||
|
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
|
||||||
|
|
||||||
|
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
|
||||||
|
- Decide whether SDK should expose auth in generated operation metadata.
|
||||||
|
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
|
||||||
|
- If removing it, update SDK generation expectations and docs in the same PR.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated client call signatures and error unions.
|
||||||
|
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
|
||||||
|
|
||||||
|
### PR 7: Tackle Component Shape Rewrites One At A Time
|
||||||
|
|
||||||
|
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
|
||||||
|
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
|
||||||
|
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
|
||||||
|
|
||||||
|
Concrete first targets:
|
||||||
|
|
||||||
|
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
|
||||||
|
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
|
||||||
|
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated SDK type-name and optionality diffs.
|
||||||
|
|
||||||
|
## Upstream Middleware Query Support
|
||||||
|
|
||||||
|
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
|
||||||
|
|
||||||
|
Target in `effect-smol`:
|
||||||
|
|
||||||
|
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
|
||||||
|
- Make runtime request decoding include middleware query schemas.
|
||||||
|
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
|
||||||
|
|
||||||
|
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
|
||||||
|
|
||||||
|
## Suggested PR Order
|
||||||
|
|
||||||
|
1. Add drift detection tests only.
|
||||||
|
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
|
||||||
|
3. Convert query type overrides into route/schema-level helpers where possible.
|
||||||
|
4. Convert path parameter overrides into schema annotations or upstream fixes.
|
||||||
|
5. Replace built-in error response rewrites with explicit declared API errors by route group.
|
||||||
|
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
|
||||||
|
|
||||||
|
## Verification Checklist Per PR
|
||||||
|
|
||||||
|
- Focused HTTP tests for changed routes.
|
||||||
|
- OpenAPI drift tests.
|
||||||
|
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||||
|
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||||
|
- Inspect generated SDK diff for public API churn.
|
||||||
|
- `bun typecheck` from `packages/opencode`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# TUI Command Shim Removal
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization
|
||||||
|
- v2 should expose only the keymap command API
|
||||||
|
- tests and fixtures should not encode legacy command behavior as expected behavior
|
||||||
|
|
||||||
|
## Remove Public Types
|
||||||
|
|
||||||
|
In `packages/plugin/src/tui.ts`, remove:
|
||||||
|
|
||||||
|
- `TuiCommand`
|
||||||
|
- `TuiCommandApi`
|
||||||
|
- `TuiPluginApi.command`
|
||||||
|
|
||||||
|
Keep `api.keymap` as the only TUI command registration and execution surface.
|
||||||
|
|
||||||
|
## Remove Runtime Shim
|
||||||
|
|
||||||
|
Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`.
|
||||||
|
|
||||||
|
In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove:
|
||||||
|
|
||||||
|
- the `createCommandShim` import
|
||||||
|
- the `command: createCommandShim(...)` field from `createTuiApi(...)`
|
||||||
|
|
||||||
|
In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove:
|
||||||
|
|
||||||
|
- the `createCommandShim` import
|
||||||
|
- the `command: createCommandShim(...)` field from `pluginApi(...)`
|
||||||
|
|
||||||
|
## Migration Target
|
||||||
|
|
||||||
|
Plugin authors should replace old calls with keymap calls:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
api.keymap.registerLayer({
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "plugin.command",
|
||||||
|
title: "Plugin Command",
|
||||||
|
namespace: "palette",
|
||||||
|
slashName: "plugin",
|
||||||
|
run() {
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct replacements:
|
||||||
|
|
||||||
|
- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })`
|
||||||
|
- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)`
|
||||||
|
- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")`
|
||||||
|
- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After removal, run from package directories:
|
||||||
|
|
||||||
|
- `bun typecheck` in `packages/plugin`
|
||||||
|
- `bun typecheck` in `packages/opencode`
|
||||||
|
- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed
|
||||||
@@ -24,11 +24,9 @@ import { Effect, Context, Layer, Schema } from "effect"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics, type DeepMutable } from "@/util/schema"
|
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
|
||||||
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
|
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
@@ -303,69 +301,70 @@ export const layer = Layer.effect(
|
|||||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function referencePath(value: string) {
|
function referencePrompt(reference: Reference.Resolved) {
|
||||||
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
|
|
||||||
return path.isAbsolute(value)
|
|
||||||
? value
|
|
||||||
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReference(reference: ReferenceEntry): ResolvedReference {
|
|
||||||
if (typeof reference === "string") {
|
|
||||||
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
|
|
||||||
return { kind: "local", path: referencePath(reference) }
|
|
||||||
}
|
|
||||||
return { kind: "git", repository: reference }
|
|
||||||
}
|
|
||||||
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
|
|
||||||
return { kind: "git", repository: reference.repository, branch: reference.branch }
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencePrompt(name: string, reference: ResolvedReference) {
|
|
||||||
if (reference.kind === "local") {
|
if (reference.kind === "local") {
|
||||||
return [
|
return [
|
||||||
PROMPT_SCOUT,
|
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||||
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
|
|
||||||
`Local directory: ${reference.path}`,
|
`Local directory: ${reference.path}`,
|
||||||
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
||||||
|
`Return exact absolute file paths for findings whenever possible.`,
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.kind === "invalid") {
|
||||||
|
return [
|
||||||
|
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
|
||||||
|
`Configured repository: ${reference.repository}`,
|
||||||
|
`Problem: ${reference.message}`,
|
||||||
|
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PROMPT_SCOUT,
|
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||||
`You are Scout reference @${name}. This reference points to a git repository.`,
|
|
||||||
`Repository: ${reference.repository}`,
|
`Repository: ${reference.repository}`,
|
||||||
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||||
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
|
`Cached directory: ${reference.path}`,
|
||||||
|
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
|
||||||
|
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
|
||||||
|
`Return exact absolute file paths for findings whenever possible.`,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function referenceDescription(reference: Reference.Resolved) {
|
||||||
|
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
|
||||||
|
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
|
||||||
|
return `Invalid Scout reference for repository ${reference.repository}`
|
||||||
|
}
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
||||||
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
|
const resolvedReferences = Reference.resolveAll({
|
||||||
if (agents[name]) continue
|
references: cfg.reference ?? {},
|
||||||
const resolved = resolveReference(reference)
|
directory: ctx.directory,
|
||||||
const localPath = resolved.kind === "local" ? resolved.path : undefined
|
worktree: ctx.worktree,
|
||||||
agents[name] = {
|
})
|
||||||
name,
|
for (const resolved of resolvedReferences) {
|
||||||
description:
|
if (agents[resolved.name]) continue
|
||||||
resolved.kind === "local"
|
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
|
||||||
? `Scout reference for local directory ${resolved.path}`
|
agents[resolved.name] = {
|
||||||
: `Scout reference for repository ${resolved.repository}`,
|
name: resolved.name,
|
||||||
|
description: referenceDescription(resolved),
|
||||||
permission: Permission.merge(
|
permission: Permission.merge(
|
||||||
agents.scout.permission,
|
agents.scout.permission,
|
||||||
Permission.fromConfig(
|
Permission.fromConfig({
|
||||||
localPath
|
repo_clone: "deny",
|
||||||
|
...(localPath
|
||||||
? {
|
? {
|
||||||
external_directory: {
|
external_directory: {
|
||||||
[localPath]: "allow",
|
[localPath]: "allow",
|
||||||
[path.join(localPath, "*")]: "allow",
|
[path.join(localPath, "*")]: "allow",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {},
|
: {}),
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
prompt: referencePrompt(name, resolved),
|
prompt: referencePrompt(resolved),
|
||||||
options: { reference },
|
options: { reference: cfg.reference?.[resolved.name], resolved },
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
native: false,
|
native: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Permission } from "../permission"
|
||||||
|
import type { Agent } from "./agent"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||||
|
* via the task tool. Combines:
|
||||||
|
*
|
||||||
|
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
|
||||||
|
* restrictions live on the agent ruleset, not on the session, so a
|
||||||
|
* subagent that only inherited the parent SESSION's permission would
|
||||||
|
* silently bypass them. (#26514)
|
||||||
|
* 2. The parent **session's** deny rules and external_directory rules —
|
||||||
|
* same forwarding the original code already did.
|
||||||
|
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||||
|
* doesn't already permit them.
|
||||||
|
*/
|
||||||
|
export function deriveSubagentSessionPermission(input: {
|
||||||
|
parentSessionPermission: Permission.Ruleset
|
||||||
|
parentAgent: Agent.Info | undefined
|
||||||
|
subagent: Agent.Info
|
||||||
|
}): Permission.Ruleset {
|
||||||
|
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||||
|
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||||
|
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
|
||||||
|
return [
|
||||||
|
...parentAgentDenies,
|
||||||
|
...input.parentSessionPermission.filter(
|
||||||
|
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||||
|
),
|
||||||
|
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||||
|
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+5
@@ -2,3 +2,8 @@ declare module "*.wav" {
|
|||||||
const file: string
|
const file: string
|
||||||
export default file
|
export default file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "*.wasm" {
|
||||||
|
const file: string
|
||||||
|
export default file
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { NonNegativeInt } from "@/util/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ function span(id: string, value: { value: string; start: number; end: number })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) {
|
function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) {
|
||||||
return diffs?.map((item, i) => ({
|
return diffs?.map((item, i) => ({
|
||||||
...item,
|
...item,
|
||||||
file: redact(`${kind}-file`, String(i), item.file),
|
file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file),
|
||||||
patch: redact(`${kind}-patch`, String(i), item.patch),
|
patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Aggregate Promise.allSettled results into a single Error that names every
|
||||||
|
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
|
||||||
|
* boundaries so a single 4xx doesn't drown its parallel siblings as
|
||||||
|
* unhandled rejections — every failure surfaces in one labeled message.
|
||||||
|
*/
|
||||||
|
export type LabeledSettled = {
|
||||||
|
name: string
|
||||||
|
result: PromiseSettledResult<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||||
|
const failed = labeled.filter(
|
||||||
|
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
|
||||||
|
)
|
||||||
|
if (failed.length === 0) return null
|
||||||
|
|
||||||
|
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
|
||||||
|
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
|
||||||
|
const err = new Error(summary)
|
||||||
|
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonMessage(reason: unknown): string {
|
||||||
|
if (reason instanceof Error) return reason.message
|
||||||
|
if (typeof reason === "string") return reason
|
||||||
|
if (reason && typeof reason === "object") {
|
||||||
|
const obj = reason as { message?: unknown; name?: unknown }
|
||||||
|
if (typeof obj.message === "string") return obj.message
|
||||||
|
if (typeof obj.name === "string") return obj.name
|
||||||
|
}
|
||||||
|
return String(reason)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
|
const context = createContext<{
|
||||||
|
path: () => string
|
||||||
|
format: (input?: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
|
||||||
|
return (
|
||||||
|
<context.Provider
|
||||||
|
value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePathFormatter() {
|
||||||
|
const value = useContext(context)
|
||||||
|
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPath(input: string | undefined, base: string | undefined) {
|
||||||
|
if (!input) return ""
|
||||||
|
|
||||||
|
const root = base || process.cwd()
|
||||||
|
const absolute = path.isAbsolute(input) ? input : path.resolve(root, input)
|
||||||
|
const relative = path.relative(root, absolute)
|
||||||
|
|
||||||
|
if (!relative) return "."
|
||||||
|
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
|
||||||
|
if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) {
|
||||||
|
return absolute.replace(Global.Path.home, "~")
|
||||||
|
}
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
|||||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useKV } from "./kv"
|
import { useKV } from "./kv"
|
||||||
|
import { aggregateFailures } from "./aggregate-failures"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
@@ -391,16 +392,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
.catch(() => emptyConsoleState)
|
.catch(() => emptyConsoleState)
|
||||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||||
const blockingRequests: Promise<unknown>[] = [
|
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
|
||||||
providersPromise,
|
{ name: "config.providers", promise: providersPromise },
|
||||||
providerListPromise,
|
{ name: "provider.list", promise: providerListPromise },
|
||||||
agentsPromise,
|
{ name: "app.agents", promise: agentsPromise },
|
||||||
configPromise,
|
{ name: "config.get", promise: configPromise },
|
||||||
projectPromise,
|
{ name: "project.sync", promise: projectPromise },
|
||||||
...(args.continue ? [sessionListPromise] : []),
|
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
await Promise.all(blockingRequests)
|
await Promise.allSettled(blockingRequests.map((r) => r.promise))
|
||||||
|
.then((settled) => {
|
||||||
|
// Surface every failed endpoint in one labeled message instead of
|
||||||
|
// letting the first rejection drown its siblings as unhandled
|
||||||
|
// rejections.
|
||||||
|
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
|
||||||
|
if (failure) throw failure
|
||||||
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const providersResponse = providersPromise.then((x) => x.data!)
|
const providersResponse = providersPromise.then((x) => x.data!)
|
||||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||||
@@ -526,10 +534,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (match.found) draft.session[match.index] = session.data!
|
if (match.found) draft.session[match.index] = session.data!
|
||||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||||
draft.todo[sessionID] = todo.data ?? []
|
draft.todo[sessionID] = todo.data ?? []
|
||||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
const infos: (typeof draft.message)[string] = []
|
||||||
for (const message of messages.data!) {
|
for (const message of messages.data ?? []) {
|
||||||
|
infos.push(message.info)
|
||||||
draft.part[message.info.id] = message.parts
|
draft.part[message.info.id] = message.parts
|
||||||
}
|
}
|
||||||
|
draft.message[sessionID] = infos
|
||||||
draft.session_diff[sessionID] = diff.data ?? []
|
draft.session_diff[sessionID] = diff.data ?? []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { PositiveInt } from "@/util/schema"
|
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
|
||||||
const DEFAULT_TOAST_DURATION = 5000
|
const DEFAULT_TOAST_DURATION = 5000
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots"
|
|||||||
import type { useToast } from "../ui/toast"
|
import type { useToast } from "../ui/toast"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import * as Keymap from "../keymap"
|
import * as Keymap from "../keymap"
|
||||||
|
import { createCommandShim } from "./command-shim"
|
||||||
|
|
||||||
type RouteEntry = {
|
type RouteEntry = {
|
||||||
key: symbol
|
key: symbol
|
||||||
@@ -147,7 +148,9 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
|||||||
return sync.data.session.length
|
return sync.data.session.length
|
||||||
},
|
},
|
||||||
diff(sessionID) {
|
diff(sessionID) {
|
||||||
return sync.data.session_diff[sessionID] ?? []
|
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
||||||
|
item.file === undefined ? [] : [{ ...item, file: item.file }],
|
||||||
|
)
|
||||||
},
|
},
|
||||||
todo(sessionID) {
|
todo(sessionID) {
|
||||||
return sync.data.todo[sessionID] ?? []
|
return sync.data.todo[sessionID] ?? []
|
||||||
@@ -200,6 +203,8 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
app: appApi(),
|
app: appApi(),
|
||||||
|
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||||
|
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
|
||||||
keys: {
|
keys: {
|
||||||
formatSequence(parts) {
|
formatSequence(parts) {
|
||||||
return Keymap.formatKeySequence(parts, input.tuiConfig)
|
return Keymap.formatKeySequence(parts, input.tuiConfig)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||||
|
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
|
import { TuiKeybind } from "../config/keybind"
|
||||||
|
import type { DialogContext } from "../ui/dialog"
|
||||||
|
|
||||||
|
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||||
|
const warned = new Set<string>()
|
||||||
|
|
||||||
|
type Warn = (api: string, replacement: string) => void
|
||||||
|
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
|
||||||
|
type CommandShimDialog = DialogContext | LegacyDialog
|
||||||
|
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
|
||||||
|
|
||||||
|
function warnCommandShim(api: string, replacement: string) {
|
||||||
|
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
|
||||||
|
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
|
||||||
|
if (!("stack" in dialog)) return dialog
|
||||||
|
return {
|
||||||
|
replace(render, onClose) {
|
||||||
|
dialog.replace(render, onClose)
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
setSize(size) {
|
||||||
|
dialog.setSize(size)
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return dialog.size
|
||||||
|
},
|
||||||
|
get depth() {
|
||||||
|
return dialog.stack.length
|
||||||
|
},
|
||||||
|
get open() {
|
||||||
|
return dialog.stack.length > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function warnOnce(api: string, replacement: string, warn: Warn) {
|
||||||
|
if (warned.has(api)) return
|
||||||
|
warned.add(api)
|
||||||
|
warn(api, replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
|
||||||
|
return {
|
||||||
|
namespace: "palette",
|
||||||
|
name: item.value,
|
||||||
|
title: item.title,
|
||||||
|
desc: item.description,
|
||||||
|
category: item.category,
|
||||||
|
suggested: item.suggested,
|
||||||
|
hidden: item.hidden,
|
||||||
|
enabled: item.enabled,
|
||||||
|
slashName: item.slash?.name,
|
||||||
|
slashAliases: item.slash?.aliases,
|
||||||
|
run() {
|
||||||
|
return item.onSelect?.(dialog)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
|
||||||
|
return commands.flatMap((item) =>
|
||||||
|
item.keybind
|
||||||
|
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||||
|
? keybinds
|
||||||
|
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||||
|
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: item.keybind,
|
||||||
|
cmd: item.value,
|
||||||
|
desc: item.title,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandShim(
|
||||||
|
keymap: TuiPluginApi["keymap"],
|
||||||
|
dialog: CommandShimDialog,
|
||||||
|
keybinds: LegacyKeybinds,
|
||||||
|
): TuiPluginApi["command"] {
|
||||||
|
const shimDialog = createCommandShimDialog(dialog)
|
||||||
|
return {
|
||||||
|
register(cb) {
|
||||||
|
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
|
||||||
|
const commands = cb()
|
||||||
|
return keymap.registerLayer({
|
||||||
|
commands: commands.map((item) => toCommand(item, shimDialog)),
|
||||||
|
bindings: toBindings(commands, keybinds),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
trigger(value) {
|
||||||
|
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
|
||||||
|
keymap.dispatchCommand(value)
|
||||||
|
},
|
||||||
|
show() {
|
||||||
|
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
|
||||||
|
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
|||||||
import { setupSlots, Slot as View } from "./slots"
|
import { setupSlots, Slot as View } from "./slots"
|
||||||
import type { HostPluginApi, HostSlots } from "./slots"
|
import type { HostPluginApi, HostSlots } from "./slots"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
import { createCommandShim } from "./command-shim"
|
||||||
|
|
||||||
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
|
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
|
||||||
|
|
||||||
@@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
app: api.app,
|
app: api.app,
|
||||||
|
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||||
|
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
|
||||||
keys: api.keys,
|
keys: api.keys,
|
||||||
keymap,
|
keymap,
|
||||||
route,
|
route,
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi"
|
|||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { PermissionPrompt } from "./permission"
|
import { PermissionPrompt } from "./permission"
|
||||||
import { QuestionPrompt } from "./question"
|
import { QuestionPrompt } from "./question"
|
||||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||||
@@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry"
|
|||||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||||
import { useCommandPalette } from "../../context/command-palette"
|
import { useCommandPalette } from "../../context/command-palette"
|
||||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
import { useBindings, useCommandShortcut } from "../../keymap"
|
||||||
|
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -1078,199 +1078,201 @@ export function Session() {
|
|||||||
createEffect(on(() => route.sessionID, toBottom))
|
createEffect(on(() => route.sessionID, toBottom))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<context.Provider
|
<PathFormatterProvider path={session()?.directory}>
|
||||||
value={{
|
<context.Provider
|
||||||
get width() {
|
value={{
|
||||||
return contentWidth()
|
get width() {
|
||||||
},
|
return contentWidth()
|
||||||
sessionID: route.sessionID,
|
},
|
||||||
conceal,
|
sessionID: route.sessionID,
|
||||||
showThinking,
|
conceal,
|
||||||
showTimestamps,
|
showThinking,
|
||||||
showDetails,
|
showTimestamps,
|
||||||
showGenericToolOutput,
|
showDetails,
|
||||||
diffWrapMode,
|
showGenericToolOutput,
|
||||||
providers,
|
diffWrapMode,
|
||||||
sync,
|
providers,
|
||||||
tui: tuiConfig,
|
sync,
|
||||||
}}
|
tui: tuiConfig,
|
||||||
>
|
}}
|
||||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
>
|
||||||
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||||
<Show when={session()}>
|
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<scrollbox
|
<Show when={session()}>
|
||||||
ref={(r) => (scroll = r)}
|
<scrollbox
|
||||||
viewportOptions={{
|
ref={(r) => (scroll = r)}
|
||||||
paddingRight: showScrollbar() ? 1 : 0,
|
viewportOptions={{
|
||||||
}}
|
paddingRight: showScrollbar() ? 1 : 0,
|
||||||
verticalScrollbarOptions={{
|
}}
|
||||||
paddingLeft: 1,
|
verticalScrollbarOptions={{
|
||||||
visible: showScrollbar(),
|
paddingLeft: 1,
|
||||||
trackOptions: {
|
visible: showScrollbar(),
|
||||||
backgroundColor: theme.backgroundElement,
|
trackOptions: {
|
||||||
foregroundColor: theme.border,
|
backgroundColor: theme.backgroundElement,
|
||||||
},
|
foregroundColor: theme.border,
|
||||||
}}
|
},
|
||||||
stickyScroll={true}
|
}}
|
||||||
stickyStart="bottom"
|
stickyScroll={true}
|
||||||
flexGrow={1}
|
stickyStart="bottom"
|
||||||
scrollAcceleration={scrollAcceleration()}
|
flexGrow={1}
|
||||||
>
|
scrollAcceleration={scrollAcceleration()}
|
||||||
<box height={1} />
|
|
||||||
<For each={messages()}>
|
|
||||||
{(message, index) => (
|
|
||||||
<Switch>
|
|
||||||
<Match when={message.id === revert()?.messageID}>
|
|
||||||
{(function () {
|
|
||||||
const command = useCommandPalette()
|
|
||||||
const redoShortcut = useCommandShortcut("session.redo")
|
|
||||||
const [hover, setHover] = createSignal(false)
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
const handleUnrevert = async () => {
|
|
||||||
const confirmed = await DialogConfirm.show(
|
|
||||||
dialog,
|
|
||||||
"Confirm Redo",
|
|
||||||
"Are you sure you want to restore the reverted messages?",
|
|
||||||
)
|
|
||||||
if (confirmed) {
|
|
||||||
command.run("session.redo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
onMouseOver={() => setHover(true)}
|
|
||||||
onMouseOut={() => setHover(false)}
|
|
||||||
onMouseUp={handleUnrevert}
|
|
||||||
marginTop={1}
|
|
||||||
flexShrink={0}
|
|
||||||
border={["left"]}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
borderColor={theme.backgroundPanel}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
|
||||||
>
|
|
||||||
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
|
|
||||||
</text>
|
|
||||||
<Show when={revert()!.diffFiles?.length}>
|
|
||||||
<box marginTop={1}>
|
|
||||||
<For each={revert()!.diffFiles}>
|
|
||||||
{(file) => (
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{file.filename}
|
|
||||||
<Show when={file.additions > 0}>
|
|
||||||
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={file.deletions > 0}>
|
|
||||||
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Match>
|
|
||||||
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
|
|
||||||
<></>
|
|
||||||
</Match>
|
|
||||||
<Match when={message.role === "user"}>
|
|
||||||
<UserMessage
|
|
||||||
index={index()}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
|
||||||
dialog.replace(() => (
|
|
||||||
<DialogMessage
|
|
||||||
messageID={message.id}
|
|
||||||
sessionID={route.sessionID}
|
|
||||||
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}}
|
|
||||||
message={message as UserMessage}
|
|
||||||
parts={sync.data.part[message.id] ?? []}
|
|
||||||
pending={pending()}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={message.role === "assistant"}>
|
|
||||||
<AssistantMessage
|
|
||||||
last={lastAssistant()?.id === message.id}
|
|
||||||
message={message as AssistantMessage}
|
|
||||||
parts={sync.data.part[message.id] ?? []}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
<box flexShrink={0}>
|
|
||||||
<Show when={permissions().length > 0}>
|
|
||||||
<PermissionPrompt request={permissions()[0]} />
|
|
||||||
</Show>
|
|
||||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
|
||||||
<QuestionPrompt request={questions()[0]} />
|
|
||||||
</Show>
|
|
||||||
<Show when={session()?.parentID}>
|
|
||||||
<SubagentFooter />
|
|
||||||
</Show>
|
|
||||||
<Show when={visible()}>
|
|
||||||
<TuiPluginRuntime.Slot
|
|
||||||
name="session_prompt"
|
|
||||||
mode="replace"
|
|
||||||
session_id={route.sessionID}
|
|
||||||
visible={visible()}
|
|
||||||
disabled={disabled()}
|
|
||||||
on_submit={toBottom}
|
|
||||||
ref={bind}
|
|
||||||
>
|
|
||||||
<Prompt
|
|
||||||
visible={visible()}
|
|
||||||
ref={bind}
|
|
||||||
disabled={disabled()}
|
|
||||||
onSubmit={() => {
|
|
||||||
toBottom()
|
|
||||||
}}
|
|
||||||
sessionID={route.sessionID}
|
|
||||||
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
|
||||||
/>
|
|
||||||
</TuiPluginRuntime.Slot>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<Toast />
|
|
||||||
</box>
|
|
||||||
<Show when={sidebarVisible()}>
|
|
||||||
<Switch>
|
|
||||||
<Match when={wide()}>
|
|
||||||
<Sidebar sessionID={route.sessionID} />
|
|
||||||
</Match>
|
|
||||||
<Match when={!wide()}>
|
|
||||||
<box
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left={0}
|
|
||||||
right={0}
|
|
||||||
bottom={0}
|
|
||||||
alignItems="flex-end"
|
|
||||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
|
||||||
>
|
>
|
||||||
<Sidebar sessionID={route.sessionID} />
|
<box height={1} />
|
||||||
|
<For each={messages()}>
|
||||||
|
{(message, index) => (
|
||||||
|
<Switch>
|
||||||
|
<Match when={message.id === revert()?.messageID}>
|
||||||
|
{(function () {
|
||||||
|
const command = useCommandPalette()
|
||||||
|
const redoShortcut = useCommandShortcut("session.redo")
|
||||||
|
const [hover, setHover] = createSignal(false)
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const handleUnrevert = async () => {
|
||||||
|
const confirmed = await DialogConfirm.show(
|
||||||
|
dialog,
|
||||||
|
"Confirm Redo",
|
||||||
|
"Are you sure you want to restore the reverted messages?",
|
||||||
|
)
|
||||||
|
if (confirmed) {
|
||||||
|
command.run("session.redo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
onMouseOver={() => setHover(true)}
|
||||||
|
onMouseOut={() => setHover(false)}
|
||||||
|
onMouseUp={handleUnrevert}
|
||||||
|
marginTop={1}
|
||||||
|
flexShrink={0}
|
||||||
|
border={["left"]}
|
||||||
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
|
borderColor={theme.backgroundPanel}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
|
>
|
||||||
|
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
|
||||||
|
</text>
|
||||||
|
<Show when={revert()!.diffFiles?.length}>
|
||||||
|
<box marginTop={1}>
|
||||||
|
<For each={revert()!.diffFiles}>
|
||||||
|
{(file) => (
|
||||||
|
<text fg={theme.text}>
|
||||||
|
{file.filename}
|
||||||
|
<Show when={file.additions > 0}>
|
||||||
|
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={file.deletions > 0}>
|
||||||
|
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
|
||||||
|
</Show>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Match>
|
||||||
|
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
|
||||||
|
<></>
|
||||||
|
</Match>
|
||||||
|
<Match when={message.role === "user"}>
|
||||||
|
<UserMessage
|
||||||
|
index={index()}
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogMessage
|
||||||
|
messageID={message.id}
|
||||||
|
sessionID={route.sessionID}
|
||||||
|
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}}
|
||||||
|
message={message as UserMessage}
|
||||||
|
parts={sync.data.part[message.id] ?? []}
|
||||||
|
pending={pending()}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={message.role === "assistant"}>
|
||||||
|
<AssistantMessage
|
||||||
|
last={lastAssistant()?.id === message.id}
|
||||||
|
message={message as AssistantMessage}
|
||||||
|
parts={sync.data.part[message.id] ?? []}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
<box flexShrink={0}>
|
||||||
|
<Show when={permissions().length > 0}>
|
||||||
|
<PermissionPrompt request={permissions()[0]} />
|
||||||
|
</Show>
|
||||||
|
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||||
|
<QuestionPrompt request={questions()[0]} />
|
||||||
|
</Show>
|
||||||
|
<Show when={session()?.parentID}>
|
||||||
|
<SubagentFooter />
|
||||||
|
</Show>
|
||||||
|
<Show when={visible()}>
|
||||||
|
<TuiPluginRuntime.Slot
|
||||||
|
name="session_prompt"
|
||||||
|
mode="replace"
|
||||||
|
session_id={route.sessionID}
|
||||||
|
visible={visible()}
|
||||||
|
disabled={disabled()}
|
||||||
|
on_submit={toBottom}
|
||||||
|
ref={bind}
|
||||||
|
>
|
||||||
|
<Prompt
|
||||||
|
visible={visible()}
|
||||||
|
ref={bind}
|
||||||
|
disabled={disabled()}
|
||||||
|
onSubmit={() => {
|
||||||
|
toBottom()
|
||||||
|
}}
|
||||||
|
sessionID={route.sessionID}
|
||||||
|
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
||||||
|
/>
|
||||||
|
</TuiPluginRuntime.Slot>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Show>
|
||||||
</Switch>
|
<Toast />
|
||||||
</Show>
|
</box>
|
||||||
</box>
|
<Show when={sidebarVisible()}>
|
||||||
</context.Provider>
|
<Switch>
|
||||||
|
<Match when={wide()}>
|
||||||
|
<Sidebar sessionID={route.sessionID} />
|
||||||
|
</Match>
|
||||||
|
<Match when={!wide()}>
|
||||||
|
<box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
alignItems="flex-end"
|
||||||
|
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||||
|
>
|
||||||
|
<Sidebar sessionID={route.sessionID} />
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</context.Provider>
|
||||||
|
</PathFormatterProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1827,7 +1829,7 @@ function BlockTool(props: {
|
|||||||
|
|
||||||
function Shell(props: ToolProps<typeof ShellTool>) {
|
function Shell(props: ToolProps<typeof ShellTool>) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const sync = useSync()
|
const pathFormatter = usePathFormatter()
|
||||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
@@ -1841,18 +1843,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
|||||||
const workdirDisplay = createMemo(() => {
|
const workdirDisplay = createMemo(() => {
|
||||||
const workdir = props.input.workdir
|
const workdir = props.input.workdir
|
||||||
if (!workdir || workdir === ".") return undefined
|
if (!workdir || workdir === ".") return undefined
|
||||||
|
return pathFormatter.format(workdir)
|
||||||
const base = sync.path.directory
|
|
||||||
if (!base) return undefined
|
|
||||||
|
|
||||||
const absolute = path.resolve(base, workdir)
|
|
||||||
if (absolute === base) return undefined
|
|
||||||
|
|
||||||
const home = Global.Path.home
|
|
||||||
if (!home) return absolute
|
|
||||||
|
|
||||||
const match = absolute === home || absolute.startsWith(home + path.sep)
|
|
||||||
return match ? absolute.replace(home, "~") : absolute
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => {
|
||||||
@@ -1894,6 +1885,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
|||||||
|
|
||||||
function Write(props: ToolProps<typeof WriteTool>) {
|
function Write(props: ToolProps<typeof WriteTool>) {
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
const code = createMemo(() => {
|
const code = createMemo(() => {
|
||||||
if (!props.input.content) return ""
|
if (!props.input.content) return ""
|
||||||
return props.input.content
|
return props.input.content
|
||||||
@@ -1902,7 +1894,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diagnostics !== undefined}>
|
<Match when={props.metadata.diagnostics !== undefined}>
|
||||||
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
|
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||||
<code
|
<code
|
||||||
conceal={false}
|
conceal={false}
|
||||||
@@ -1917,7 +1909,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
||||||
Write {normalizePath(props.input.filePath!)}
|
Write {pathFormatter.format(props.input.filePath)}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -1925,9 +1917,10 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Glob(props: ToolProps<typeof GlobTool>) {
|
function Glob(props: ToolProps<typeof GlobTool>) {
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
|
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
|
||||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
|
||||||
<Show when={props.metadata.count}>
|
<Show when={props.metadata.count}>
|
||||||
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
|
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1937,6 +1930,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
|
|||||||
|
|
||||||
function Read(props: ToolProps<typeof ReadTool>) {
|
function Read(props: ToolProps<typeof ReadTool>) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||||
const loaded = createMemo(() => {
|
const loaded = createMemo(() => {
|
||||||
if (props.part.state.status !== "completed") return []
|
if (props.part.state.status !== "completed") return []
|
||||||
@@ -1954,13 +1948,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
|||||||
spinner={isRunning()}
|
spinner={isRunning()}
|
||||||
part={props.part}
|
part={props.part}
|
||||||
>
|
>
|
||||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
<For each={loaded()}>
|
<For each={loaded()}>
|
||||||
{(filepath) => (
|
{(filepath) => (
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={3}>
|
||||||
<text paddingLeft={3} fg={theme.textMuted}>
|
<text paddingLeft={3} fg={theme.textMuted}>
|
||||||
↳ Loaded {normalizePath(filepath)}
|
↳ Loaded {pathFormatter.format(filepath)}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
@@ -1970,9 +1964,10 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Grep(props: ToolProps<typeof GrepTool>) {
|
function Grep(props: ToolProps<typeof GrepTool>) {
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
|
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
|
||||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
|
||||||
<Show when={props.metadata.matches}>
|
<Show when={props.metadata.matches}>
|
||||||
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
|
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
|
||||||
</Show>
|
</Show>
|
||||||
@@ -2071,6 +2066,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
function Edit(props: ToolProps<typeof EditTool>) {
|
function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = ctx.tui.diff_style
|
const diffStyle = ctx.tui.diff_style
|
||||||
@@ -2086,7 +2082,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diff !== undefined}>
|
<Match when={props.metadata.diff !== undefined}>
|
||||||
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
|
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<diff
|
<diff
|
||||||
diff={diffContent()}
|
diff={diffContent()}
|
||||||
@@ -2113,7 +2109,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -2123,6 +2119,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||||||
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
||||||
const files = createMemo(() => props.metadata.files ?? [])
|
const files = createMemo(() => props.metadata.files ?? [])
|
||||||
|
|
||||||
@@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
|||||||
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
||||||
if (file.type === "delete") return "# Deleted " + file.relativePath
|
if (file.type === "delete") return "# Deleted " + file.relativePath
|
||||||
if (file.type === "add") return "# Created " + file.relativePath
|
if (file.type === "add") return "# Created " + file.relativePath
|
||||||
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
|
||||||
return "← Patched " + file.relativePath
|
return "← Patched " + file.relativePath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2281,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePath(input?: string) {
|
|
||||||
if (!input) return ""
|
|
||||||
|
|
||||||
const cwd = process.cwd()
|
|
||||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
|
||||||
const relative = path.relative(cwd, absolute)
|
|
||||||
|
|
||||||
if (!relative) return "."
|
|
||||||
if (!relative.startsWith("..")) return relative
|
|
||||||
|
|
||||||
// outside cwd - use absolute
|
|
||||||
return absolute
|
|
||||||
}
|
|
||||||
|
|
||||||
function input(input: Record<string, any>, omit?: string[]): string {
|
function input(input: Record<string, any>, omit?: string[]): string {
|
||||||
const primitives = Object.entries(input).filter(([key, value]) => {
|
const primitives = Object.entries(input).filter(([key, value]) => {
|
||||||
if (omit?.includes(key)) return false
|
if (omit?.includes(key)) return false
|
||||||
|
|||||||
@@ -11,34 +11,16 @@ import { useProject } from "../../context/project"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { ShellID } from "@/tool/shell/id"
|
import { ShellID } from "@/tool/shell/id"
|
||||||
import { webSearchProviderLabel } from "@/tool/websearch"
|
import { webSearchProviderLabel } from "@/tool/websearch"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
import { useBindings, useCommandShortcut } from "../../keymap"
|
||||||
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
type PermissionStage = "permission" | "always" | "reject"
|
type PermissionStage = "permission" | "always" | "reject"
|
||||||
|
|
||||||
function normalizePath(input?: string) {
|
|
||||||
if (!input) return ""
|
|
||||||
|
|
||||||
const cwd = process.cwd()
|
|
||||||
const home = Global.Path.home
|
|
||||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
|
||||||
const relative = path.relative(cwd, absolute)
|
|
||||||
|
|
||||||
if (!relative) return "."
|
|
||||||
if (!relative.startsWith("..")) return relative
|
|
||||||
|
|
||||||
// outside cwd - use ~ or absolute
|
|
||||||
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
|
|
||||||
return absolute.replace(home, "~")
|
|
||||||
}
|
|
||||||
return absolute
|
|
||||||
}
|
|
||||||
|
|
||||||
function filetype(input?: string) {
|
function filetype(input?: string) {
|
||||||
if (!input) return "none"
|
if (!input) return "none"
|
||||||
const ext = path.extname(input)
|
const ext = path.extname(input)
|
||||||
@@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
stage: "permission" as PermissionStage,
|
stage: "permission" as PermissionStage,
|
||||||
})
|
})
|
||||||
|
const pathFormatter = usePathFormatter()
|
||||||
|
|
||||||
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
|
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
|
||||||
|
|
||||||
@@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const filepath = typeof raw === "string" ? raw : ""
|
const filepath = typeof raw === "string" ? raw : ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `Edit ${normalizePath(filepath)}`,
|
title: `Edit ${pathFormatter.format(filepath)}`,
|
||||||
body: <EditBody request={props.request} />,
|
body: <EditBody request={props.request} />,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const filePath = typeof raw === "string" ? raw : ""
|
const filePath = typeof raw === "string" ? raw : ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `Read ${normalizePath(filePath)}`,
|
title: `Read ${pathFormatter.format(filePath)}`,
|
||||||
body: (
|
body: (
|
||||||
<Show when={filePath}>
|
<Show when={filePath}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
|
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
),
|
),
|
||||||
@@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const dir = typeof raw === "string" ? raw : ""
|
const dir = typeof raw === "string" ? raw : ""
|
||||||
return {
|
return {
|
||||||
icon: "→",
|
icon: "→",
|
||||||
title: `List ${normalizePath(dir)}`,
|
title: `List ${pathFormatter.format(dir)}`,
|
||||||
body: (
|
body: (
|
||||||
<Show when={dir}>
|
<Show when={dir}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
|
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
),
|
),
|
||||||
@@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||||
|
|
||||||
const raw = parent ?? filepath ?? derived
|
const raw = parent ?? filepath ?? derived
|
||||||
const dir = normalizePath(raw)
|
const dir = pathFormatter.format(raw)
|
||||||
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance"
|
|||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Effect, Layer, Context, Schema } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@opencode-ai/core/schema"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent"
|
|||||||
|
|
||||||
import { Exit, Schema, SchemaGetter } from "effect"
|
import { Exit, Schema, SchemaGetter } from "effect"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { PositiveInt, withStatics } from "@/util/schema"
|
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
import { Glob } from "@opencode-ai/core/util/glob"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export * as ConfigAttachment from "./attachment"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
|
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
|
export const Image = Schema.Struct({
|
||||||
|
auto_resize: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
|
||||||
|
}),
|
||||||
|
max_width: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
|
||||||
|
}),
|
||||||
|
max_height: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||||
|
}),
|
||||||
|
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "ImageAttachmentConfig" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Image = Schema.Schema.Type<typeof Image>
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "AttachmentConfig" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
@@ -5,8 +5,8 @@ import { Schema } from "effect"
|
|||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
import { Glob } from "@opencode-ai/core/util/glob"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@opencode-ai/core/schema"
|
||||||
import { configEntryNameFromPath } from "./entry-name"
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
import { InvalidError } from "./error"
|
import { InvalidError } from "./error"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
import * as ConfigMarkdown from "./markdown"
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import { InstanceState } from "@/effect/instance-state"
|
|||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { containsPath } from "../project/instance-context"
|
import { containsPath } from "../project/instance-context"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
import { ConfigAgent } from "./agent"
|
import { ConfigAgent } from "./agent"
|
||||||
|
import { ConfigAttachment } from "./attachment"
|
||||||
import { ConfigCommand } from "./command"
|
import { ConfigCommand } from "./command"
|
||||||
import { ConfigFormatter } from "./formatter"
|
import { ConfigFormatter } from "./formatter"
|
||||||
import { ConfigLayout } from "./layout"
|
import { ConfigLayout } from "./layout"
|
||||||
@@ -241,6 +242,9 @@ export const Info = Schema.Struct({
|
|||||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||||
permission: Schema.optional(ConfigPermission.Info),
|
permission: Schema.optional(ConfigPermission.Info),
|
||||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||||
|
attachment: Schema.optional(ConfigAttachment.Info).annotate({
|
||||||
|
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||||
|
}),
|
||||||
enterprise: Schema.optional(
|
enterprise: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
||||||
@@ -307,7 +311,7 @@ export const Info = Schema.Struct({
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Uses the shared `DeepMutable` from `@/util/schema`. See the definition
|
// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition
|
||||||
// there for why the local variant is needed over `Types.DeepMutable` from
|
// there for why the local variant is needed over `Types.DeepMutable` from
|
||||||
// effect-smol (the upstream version collapses `unknown` to `{}`).
|
// effect-smol (the upstream version collapses `unknown` to `{}`).
|
||||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { NonNegativeInt } from "@/util/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export * as ConfigFormatter from "./formatter"
|
export * as ConfigFormatter from "./formatter"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
export const Entry = Schema.Struct({
|
export const Entry = Schema.Struct({
|
||||||
disabled: Schema.optional(Schema.Boolean),
|
disabled: Schema.optional(Schema.Boolean),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user