Compare commits

...
Author SHA1 Message Date
Kit Langton c20d070b9a docs(llm): fix stale references in protocols/shared.ts and gemini.ts
- subtractTokens JSDoc said the raw payload lives on Usage.native, but
  that field was renamed to providerMetadata earlier in this PR.
- totalTokens JSDoc still described the abandoned "additive" first-pass
  contract where inputTokens/outputTokens were non-cached / visible only.
  We landed on inclusive totals; the fallback already covers cache and
  reasoning.
- Removed a duplicate inline comment in Gemini's mapUsage — the
  function-level comment already explains the visible/reasoning sum and
  the undefined-when-incomplete rule.
2026-05-10 22:08:12 -04:00
Kit Langton d048bd6f4b test(llm): re-record golden scenarios against live providers
Verifies the new Usage mapper code against live provider responses for
OpenAI Chat, OpenAI Responses, Anthropic, Gemini, DeepSeek, and
TogetherAI — 16 fresh recordings, all assertions pass. No existing
cassettes were modified; these populate test slots that were previously
skipped in replay mode.

Recorded via:
    set -a; source .env.recorded.local; set +a
    RECORD=true bun test test/provider/*.recorded.test.ts

Redactor stripped all auth headers; no secrets in the cassettes.
2026-05-10 22:03:02 -04:00
Kit Langton ab9b79ef88 refactor(llm): rename Usage.native to providerMetadata
Aligns the escape-hatch field name with `LLMEvent.providerMetadata` used
elsewhere in this package (and with AI SDK / pydantic-ai / LangChain
conventions for the same idea). Two parallel escape hatches having
different names was a wart.

The raw payload is now wrapped under the provider key — `{ openai: ... }`,
`{ anthropic: ... }`, `{ google: ... }`, `{ bedrock: ... }` — using the
existing `ProviderMetadata = Record<string, Record<string, unknown>>`
schema rather than a flat record. Same shape as
`LLMEvent.providerMetadata`, so consumers downstream can read both with
the same code.

Anthropic's `mergeUsage` merges the per-provider sub-record across
`message_start` and `message_delta` instead of spreading at the top level.
2026-05-10 21:42:09 -04:00
Kit Langton d4ff331052 refactor(llm): inclusive total + non-overlapping breakdown for Usage
Final shape after considering ecosystem conventions:

  inputTokens             — inclusive total (matches AI SDK / OpenAI / LangChain)
  outputTokens            — inclusive total (includes reasoning)
  nonCachedInputTokens    — breakdown: fresh prompt
  cacheReadInputTokens    — breakdown: cache hit
  cacheWriteInputTokens   — breakdown: cache write
  reasoningTokens         — subset of outputTokens

Invariant:
  nonCached + cacheRead + cacheWrite = inputTokens
  reasoningTokens <= outputTokens

Why this shape:

- `inputTokens` keeps its AI-SDK / OpenAI semantics, so a reader from any
  major ecosystem sees the number they expect.
- The non-overlapping breakdown fields are populated alongside the
  inclusive totals — consumers read whichever they need without
  subtracting. This eliminates the underflow bug class (opencode#26620)
  structurally without diverging on naming.
- Aligns with the AI SDK v3 spec proposal (vercel/ai#9921), which adds
  exactly this kind of non-overlapping breakdown to address the active
  ecosystem bugs around cache token double-counting and underflow
  (pydantic-ai#4364, langfuse#12306/#11979, vercel/ai#8349,
  langchain#32818, langchainjs#10249).

Mappers:

- OpenAI Chat / Responses / Bedrock: provider reports inclusive totals
  natively; mapper derives `nonCachedInputTokens` via
  `ProviderShared.subtractTokens`.
- Gemini: `promptTokenCount` is inclusive; `candidatesTokenCount` is
  *exclusive* of `thoughtsTokenCount`, so mapper sums those to produce
  the inclusive `outputTokens`. Only computes the total when the visible
  component is reported (avoids fabricating an inclusive number from a
  partial breakdown).
- Anthropic: `input_tokens` is *non-cached* natively; mapper sums it with
  cache reads/writes to produce the inclusive `inputTokens`.
  `output_tokens` is inclusive (Anthropic doesn't break thinking out, so
  `reasoningTokens` stays undefined).

Added a `visibleOutputTokens` getter (clamped `outputTokens - reasoningTokens`)
as the one safe escape hatch for consumers wanting the non-reasoning view.

Added `ProviderShared.sumTokens` to derive an inclusive total from a
non-overlapping breakdown, returning `undefined` when every input is
undefined (so we don't fabricate a 0).
2026-05-10 20:39:22 -04:00
Kit Langton f5d199db62 feat(llm): add Usage.totalInputTokens / totalOutputTokens getters
Match the `LLMResponse.text` / `reasoning` / `toolCalls` getter pattern
in the same file — `usage.totalInputTokens` reads naturally and lives
where the Usage data does. Both sums are monotonic under the additive
contract, so callers no longer need to remember which fields are
non-overlapping.

Test fixtures that previously asserted with `usage: { ... }` plain
literals are now wrapped with `new Usage({...})` to match the runtime
shape the mappers actually produce (an instance, not a struct).
2026-05-10 19:29:41 -04:00
Kit Langton 0d4f8d126f refactor(llm): drop Usage.totalInput / totalOutput helpers
The additive contract delivers value at the mapper boundary — every
field is non-overlapping and non-negative, so any caller summing
arbitrary subsets is correct by construction. Two-line helpers that
just sum three or two known fields add API surface without paying for
themselves, and there are no in-tree consumers today. If v2 wants them
at integration time, the right place is a getter on the `Schema.Class`
(matching the `LLMResponse.text` / `reasoning` / `toolCalls` pattern in
the same file), not a static namespace helper.
2026-05-10 19:15:46 -04:00
Kit Langton 478f3ae50c refactor(llm): trim Usage helpers + Bedrock subtraction
Review pass:
- Drop `Pick<>` type aliases on `Usage.totalInput` / `Usage.totalOutput`
  — the helpers can take `Usage` directly since every field is optional.
- Collapse Bedrock's nested `subtractTokens(subtractTokens(...))` into a
  single subtraction against the summed cache subtotals.
- Drop arithmetic-walkthrough comments in test fixtures (the raw
  fixture values are right next to the expected outputs).
- Generalize the comment on `mapUsage` in `openai-chat.ts` so the
  rationale outlives the PR reference.
2026-05-10 13:22:49 -04:00
Kit Langton b9451175a6 refactor(llm): make LLM.Usage a fully-additive contract
Defines a single invariant for `LLM.Usage`: every field is non-negative
and every meaningful aggregate is a *sum*, never a difference. Total
billable input = inputTokens + cacheReadInputTokens + cacheWriteInputTokens.
Total billable output = outputTokens + reasoningTokens. Adding two
non-negatives cannot underflow, so consumers can no longer reproduce the
underflow-then-clamp bug class fixed by #26620.

Each protocol mapper now enforces the contract at the provider boundary
via `ProviderShared.subtractTokens`, which clamps with `Math.max(0, …)`
for defense against provider bugs:

- OpenAI Chat / Responses: pull `cached_tokens` out of `prompt_tokens` /
  `input_tokens`; pull `reasoning_tokens` out of `completion_tokens` /
  `output_tokens`. The provider's `total_tokens` is preserved verbatim.
- Gemini: pull `cachedContentTokenCount` out of `promptTokenCount`.
  Gemini already split visible candidates from thoughts.
- Bedrock: pull `cacheReadInputTokens` and `cacheWriteInputTokens` out of
  `inputTokens`, matching AWS prompt-caching docs.
- Anthropic: already non-overlapping per the Messages API; pass through.

Adds `Usage.totalInput` / `Usage.totalOutput` helpers for callers that
want the merged view, and a regression test covering the clamp behavior.

The reasoning underflow fixed in #26620 was the most visible symptom of
a broader semantic inconsistency in this package: providers also disagreed
on whether `inputTokens` includes cache reads (Anthropic excluded;
OpenAI/Gemini/Bedrock included), which would silently double-subtract
the moment v2 wired LLM.Usage into Session.getUsage. Normalizing now,
pre-integration, closes both holes in one move.
2026-05-10 13:07:58 -04:00
Kit Langton 9c8da69196 Use Effect timeout in compaction test (#26728) 2026-05-10 12:45:54 -04:00
opencode-agent[bot] a78018697c chore: generate 2026-05-10 16:44:40 +00:00
Kit Langton e45b6ef1de refactor(http-recorder): use Schema.TaggedErrorClass for cassette errors (#26729) 2026-05-10 16:43:33 +00:00
opencode-agent[bot] b616543ac2 chore: generate 2026-05-10 16:30:55 +00:00
Kit Langton 2bd3d9a696 refactor(http-recorder): hide cassette format behind Cassette seam (#26725) 2026-05-10 12:29:55 -04:00
Kit Langton fa15dbc5ec Migrate compaction process tests (#26723) 2026-05-10 12:25:44 -04:00
opencode-agent[bot] 312e5c7a7c chore: generate 2026-05-10 16:22:29 +00:00
Kit Langton 049502fac6 fix(server): return diagnosable body for schema rejections (#26631) 2026-05-10 16:21:32 +00:00
opencode-agent[bot] cc2915be16 chore: generate 2026-05-10 16:20:16 +00:00
Kit Langton ce061bf661 Add explicit LLM stream lifecycle events (#26722) 2026-05-10 12:19:13 -04:00
Frank 3b8790e034 zen: fix usage css on mobile 2026-05-10 12:14:11 -04:00
Kit Langton a4f3cedcdf Start effect-style compaction tests 2026-05-10 16:12:00 +00:00
opencode-agent[bot] 1c9a2eb239 chore: generate 2026-05-10 16:06:18 +00:00
Kit Langton 4fb417d3b5 feat(http-recorder): default mode to "auto" (#26719) 2026-05-10 16:05:11 +00:00
Kit Langton 11030c627b Scope boolean query overrides 2026-05-10 11:57:52 -04:00
opencode-agent[bot] c104098a66 chore: generate 2026-05-10 15:55:49 +00:00
Kit Langton 49ee3ba85a Source diff message query pattern (#26638) 2026-05-10 11:54:54 -04:00
opencode-agent[bot] 4fc538378d chore: generate 2026-05-10 14:50:21 +00:00
Kit Langton d28b5ad2f4 refactor(http-recorder): Redactor + Recorder seams, README (#26636) 2026-05-10 10:49:22 -04:00
opencode-agent[bot] 6589a66822 chore: generate 2026-05-10 12:28:11 +00:00
Shoubhit Dash 5cf9abe743 feat(scout): materialize configured reference repos (#26692) 2026-05-10 17:57:11 +05:30
Frank 903d81819d Zen: add Ring 2.6 1T 2026-05-10 03:51:34 -04:00
opencode-agent[bot] 472f9e64a6 chore: update nix node_modules hashes 2026-05-10 07:06:30 +00:00
Frank c04fa9e253 sync: revert
This reverts commit 3a7f617098.
2026-05-10 02:58:46 -04:00
opencode-agent[bot] 3a78fb1f42 chore: generate 2026-05-10 06:49:21 +00:00
Aiden Cline 85ce6a5f95 feat: better image handling (auto resize & max size constraints) (#26401) 2026-05-10 01:48:19 -05:00
opencode-agent[bot] 5217e6c1af chore: generate 2026-05-10 06:39:09 +00:00
Frank 3a7f617098 go: add tencent icon 2026-05-10 02:37:50 -04:00
opencode-agent[bot] d9150413cb chore: generate 2026-05-10 06:24:35 +00:00
Jack bcbc1dba22 Go add hy3 preview (#26533) 2026-05-10 02:23:34 -04:00
Frank ce3235e115 sync 2026-05-10 02:17:32 -04:00
opencode-agent[bot] a9a2a597d5 chore: generate 2026-05-10 04:30:04 +00:00
Dax 3753601f87 Format TUI paths relative to session directory (#26648) 2026-05-10 04:29:02 +00:00
Kit Langton fb4bab8a66 Remove redundant ID Zod overrides (#26633) 2026-05-09 23:12:21 -04:00
opencode-agent[bot] b3526f6ce9 chore: generate 2026-05-10 03:03:37 +00:00
Kit Langton f220f02a2f Source workspace path pattern (#26632) 2026-05-09 23:02:31 -04:00
opencode-agent[bot] 235a86fb60 chore: generate 2026-05-10 02:59:46 +00:00
Kit Langton 67b9c9c027 Source HTTP API ID path patterns (#26623) 2026-05-09 22:58:47 -04:00
opencode 2f11c9f7ed sync release versions for v1.14.46 2026-05-10 02:34:36 +00:00
opencode-agent[bot] e1c1193f3e chore: generate 2026-05-10 02:11:45 +00:00
Kit Langton 29250a0efb fix(session): loosen remaining stored numeric schemas to tolerate legacy data (#26622) 2026-05-09 22:10:48 -04:00
Kit Langton c6e6bdf59f fix(session): tolerate negative token counts in stored parts (#26620) 2026-05-09 22:10:44 -04:00
opencode-agent[bot] d80e1199ca chore: generate 2026-05-10 02:06:39 +00:00
Kit Langton 10ea59066f feat(skill): built-in opencode-meta skill (#26617) 2026-05-09 22:05:37 -04:00
Kit Langton 79d6b10d7c fix(mcp): tolerate output schema ref failures (#26614) 2026-05-09 22:03:59 -04:00
Kit Langton 6e78f36a0f Narrow HTTP API numeric query overrides (#26618) 2026-05-09 22:02:51 -04:00
Kit Langton 16866e1180 Share HTTP API boolean query schema (#26615) 2026-05-09 21:41:15 -04:00
opencode-agent[bot] 6d130e5deb chore: generate 2026-05-10 01:13:41 +00:00
Kit Langton e30d8173c1 Fix OpenAPI workspace query drift (#26609) 2026-05-09 21:12:34 -04:00
opencode 7a79f3a5ea sync release versions for v1.14.45 2026-05-10 00:07:24 +00:00
203 changed files with 7327 additions and 3227 deletions
+1
View File
@@ -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()`
+21 -17
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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.44", "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
View File
@@ -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="
} }
} }
+1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.44", "version": "1.14.46",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.44", "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 -1
View File
@@ -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.44", "version": "1.14.46",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.14.44", "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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.14.44", "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 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44", "version": "1.14.46",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+11
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.14.44", "version": "1.14.46",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.14.44", "version": "1.14.46",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -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.44" 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.44/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.44/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.44/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.44/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.44/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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.14.44", "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",
+214
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44", "version": "1.14.46",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+114 -72
View File
@@ -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),
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) { Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true }) ),
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette)) append: (name, interaction, metadata) =>
}) Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
const append = Effect.fn("Cassette.append")(function* ( if (!recorded.has(name)) recorded.set(name, entry)
name: string, entry.interactions.push(interaction)
interaction: Interaction, entry.findings.push(...secretFindings(interaction))
metadata: CassetteMetadata | undefined, const cassette = buildCassette(name, entry.interactions, metadata)
) { const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
const interactions = yield* Ref.updateAndGet(recorded, (previous) => if (findings.length === 0) {
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]), yield* ensureDirectory(name)
) yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata) }
const findings = cassetteSecretFindings(cassette) return { findings }
if (findings.length === 0) yield* write(name, cassette) }),
return { cassette, findings } exists: (name) =>
}) fs.access(cassettePath(name)).pipe(
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true), Effect.as(true),
Effect.catch(() => Effect.succeed(false)), Effect.catch(() => Effect.succeed(false)),
) ),
}) list: () =>
walk(directory).pipe(
const list = Effect.fn("Cassette.list")(function* () { Effect.map((files) =>
return (yield* walk(directory)) files
.filter((file) => file.endsWith(".json")) .filter((file) => file.endsWith(".json"))
.map((file) => ({ .map((file) =>
name: path path
.relative(directory, file) .relative(directory, file)
.replace(/\\/g, "/") .replace(/\\/g, "/")
.replace(/\.json$/, ""), .replace(/\.json$/, ""),
path: file, )
})) .toSorted((a, b) => a.localeCompare(b)),
.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()),
})
})
-95
View File
@@ -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,
)
+58 -125
View File
@@ -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),
) )
+23 -7
View File
@@ -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 "."
+89 -1
View File
@@ -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: "" }
}
+73
View File
@@ -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) }
})
+7 -8
View File
@@ -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)
+76
View File
@@ -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)] : []),
)
+3 -2
View File
@@ -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),
-34
View File
@@ -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")))
}
+50 -95
View File
@@ -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>(
) )
}), }),
} }
}) }),
},
} }
}) })
+10
View File
@@ -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,43 +153,11 @@ 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(
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
{ provider: "openai" },
)
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () { Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
HttpRecorder.cassetteFor(
"websocket/replay",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({ const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay", name: "websocket/replay",
cassette, cassette,
@@ -197,7 +174,21 @@ describe("http-recorder", () => {
yield* connection.close yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}).pipe(
Effect.provide(
HttpRecorder.Cassette.memory({
"websocket/replay": [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
}), }),
),
),
),
) )
}) })
@@ -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", transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], 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* () {
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44", "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) => {
+28 -11
View File
@@ -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 })]
: [] : []
// ============================================================================= // =============================================================================
+25 -8
View File
@@ -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
} }
} }
+15 -9
View File
@@ -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>) : []),
]
} }
// ============================================================================= // =============================================================================
+25 -31
View File
@@ -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) => {
+38
View File
@@ -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({
? {
type: "tool-call",
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
input, input,
providerExecuted: true, providerExecuted: tool.providerExecuted ? true : undefined,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), providerMetadata: tool.providerMetadata,
} }),
: {
type: "tool-call",
id: tool.id,
name: tool.name,
input,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
},
), ),
) )
+148 -30
View File
@@ -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"],
+9
View File
@@ -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>
+14 -6
View File
@@ -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,
+10
View File
@@ -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 {}
+3 -1
View File
@@ -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",
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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}"
}
]
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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 } },
}),
}, },
]) ])
}), }),
+18 -13
View File
@@ -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,25 +198,28 @@ 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: {
google: {
promptTokenCount: 5, promptTokenCount: 5,
candidatesTokenCount: 2, candidatesTokenCount: 2,
totalTokenCount: 7, totalTokenCount: 7,
@@ -224,6 +227,7 @@ describe("Gemini route", () => {
cachedContentTokenCount: 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,18 +225,20 @@ 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: {
openai: {
prompt_tokens: 5, prompt_tokens: 5,
completion_tokens: 2, completion_tokens: 2,
total_tokens: 7, total_tokens: 7,
@@ -244,6 +246,7 @@ describe("OpenAI Chat route", () => {
completion_tokens_details: { reasoning_tokens: 0 }, 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,19 +336,21 @@ 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: {
openai: {
input_tokens: 5, input_tokens: 5,
output_tokens: 2, output_tokens: 2,
total_tokens: 7, total_tokens: 7,
@@ -356,6 +358,7 @@ describe("OpenAI Responses route", () => {
output_tokens_details: { reasoning_tokens: 0 }, 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 } },
}),
}, },
]) ])
}), }),
+1 -1
View File
@@ -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(
+2 -3
View File
@@ -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,
+29 -1
View File
@@ -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)
})
})
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44", "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",
@@ -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`.
+43 -44
View File
@@ -26,9 +26,7 @@ import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer" import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics, type DeepMutable } from "@opencode-ai/core/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(resolved),
prompt: referencePrompt(name, resolved), options: { reference: cfg.reference?.[resolved.name], resolved },
options: { reference },
mode: "subagent", mode: "subagent",
native: false, native: false,
} }
+5
View File
@@ -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
}
@@ -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
}
@@ -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,6 +1078,7 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom)) createEffect(on(() => route.sessionID, toBottom))
return ( return (
<PathFormatterProvider path={session()?.directory}>
<context.Provider <context.Provider
value={{ value={{
get width() { get width() {
@@ -1271,6 +1272,7 @@ export function Session() {
</Show> </Show>
</box> </box>
</context.Provider> </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 {
@@ -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>
+4
View File
@@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/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" }),
@@ -1,12 +1,10 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@opencode-ai/core/schema"
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
Schema.brand("WorkspaceID"),
)
export type WorkspaceID = typeof workspaceIdSchema.Type export type WorkspaceID = typeof workspaceIdSchema.Type
@@ -43,6 +43,7 @@ import { Format } from "@/format"
import { InstanceLayer } from "@/project/instance-layer" import { InstanceLayer } from "@/project/instance-layer"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { Reference } from "@/reference/reference"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { Pty } from "@/pty" import { Pty } from "@/pty"
@@ -96,6 +97,7 @@ export const AppLayer = Layer.mergeAll(
Format.defaultLayer, Format.defaultLayer,
Project.defaultLayer, Project.defaultLayer,
Vcs.defaultLayer, Vcs.defaultLayer,
Reference.defaultLayer,
Workspace.defaultLayer, Workspace.defaultLayer,
Worktree.appLayer, Worktree.appLayer,
Pty.defaultLayer, Pty.defaultLayer,
-8
View File
@@ -1,4 +1,3 @@
import z from "zod"
import { randomBytes } from "crypto" import { randomBytes } from "crypto"
const prefixes = { const prefixes = {
@@ -7,19 +6,12 @@ const prefixes = {
message: "msg", message: "msg",
permission: "per", permission: "per",
question: "que", question: "que",
user: "usr",
part: "prt", part: "prt",
pty: "pty", pty: "pty",
tool: "tool", tool: "tool",
workspace: "wrk", workspace: "wrk",
entry: "ent",
account: "act",
} as const } as const
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
}
const LENGTH = 26 const LENGTH = 26
// State for monotonic ID generation // State for monotonic ID generation
+180
View File
@@ -0,0 +1,180 @@
import { Config } from "@/config/config"
import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Schema } from "effect"
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
"ImagePhotonUnavailableError",
{},
) {
override get message() {
return "Photon image processor is unavailable"
}
}
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
url: Schema.String,
}) {
override get message() {
return "Image URL must be a base64 data URL"
}
}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
override get message() {
return "Image could not be decoded"
}
}
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
bytes: Schema.Number,
max: Schema.Number,
width: Schema.Number,
height: Schema.Number,
max_width: Schema.Number,
max_height: Schema.Number,
}) {
override get message() {
return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes`
}
}
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
export interface Interface {
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const loadPhoton = yield* Effect.cached(
Effect.promise(async () => {
try {
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
.default
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
photonWasm
return await import("@silvia-odwyer/photon-node")
} catch {
return null
}
}),
)
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
const image = (yield* config.get()).attachment?.image
const info = {
autoResize: image?.auto_resize ?? AUTO_RESIZE,
maxWidth: image?.max_width ?? MAX_WIDTH,
maxHeight: image?.max_height ?? MAX_HEIGHT,
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
}
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
return yield* new InvalidDataUrlError({ url: input.url })
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
const photon = yield* loadPhoton
if (!photon) return yield* new PhotonUnavailableError()
const decoded = yield* Effect.sync(() => {
try {
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
} catch {
return undefined
}
})
if (!decoded) return yield* new DecodeError()
try {
const originalWidth = decoded.get_width()
const originalHeight = decoded.get_height()
if (
originalWidth <= info.maxWidth &&
originalHeight <= info.maxHeight &&
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
)
return input
if (!info.autoResize)
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
max_width: info.maxWidth,
max_height: info.maxHeight,
})
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(originalWidth * scale)),
height: Math.max(1, Math.round(originalHeight * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
}, [])) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
const candidate = [
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
]
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
.find((item) => item.bytes <= info.maxBase64Bytes)
resized.free()
if (candidate) {
log.info("using resized image", {
from_mime: input.mime,
to_mime: candidate.mime,
from: `${originalWidth}x${originalHeight}`,
to: `${size.width}x${size.height}`,
})
return {
...input,
mime: candidate.mime,
url: `data:${candidate.mime};base64,${candidate.data}`,
}
}
}
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
max_width: info.maxWidth,
max_height: info.maxHeight,
})
} finally {
decoded.free()
}
})
return Service.of({ normalize })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export * as Image from "./image"
+4
View File
@@ -0,0 +1,4 @@
declare module "*.md" {
const content: string
export default content
}
+43 -5
View File
@@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { import {
CallToolResultSchema, CallToolResultSchema,
ToolSchema,
type Tool as MCPToolDef, type Tool as MCPToolDef,
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
@@ -36,6 +37,15 @@ import { withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "mcp" }) const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000 const DEFAULT_TIMEOUT = 30_000
const TolerantToolSchema = ToolSchema.extend({
outputSchema: z.unknown().optional(),
})
const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
})
export const Resource = Schema.Struct({ export const Resource = Schema.Struct({
name: Schema.String, name: Schema.String,
uri: Schema.String, uri: Schema.String,
@@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) {
log.warn("invalid remote mcp url", { key }) log.warn("invalid remote mcp url", { key })
} }
function isOutputSchemaValidationError(error: Error) {
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)
}
function listTools(key: string, client: MCPClient, timeout: number) {
return Effect.tryPromise({
try: () => client.listTools(undefined, { timeout }),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
Effect.catch((error) => {
if (!isOutputSchemaValidationError(error)) return Effect.fail(error)
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
return Effect.tryPromise({
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) =>
result.tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
),
)
}),
)
}
// Convert MCP tool definition to AI SDK Tool type // Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
const inputSchema = mcpTool.inputSchema const inputSchema = mcpTool.inputSchema
@@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
} }
function defs(key: string, client: MCPClient, timeout?: number) { function defs(key: string, client: MCPClient, timeout?: number) {
return Effect.tryPromise({ return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
Effect.catch((err) => { Effect.catch((err) => {
log.error("failed to get tools from client", { key, error: err }) log.error("failed to get tools from client", { key, error: err })
return Effect.succeed(undefined) return Effect.succeed(undefined)
+2 -2
View File
@@ -1,12 +1,12 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { zod } from "@opencode-ai/core/effect-zod"
import { Newtype } from "@opencode-ai/core/schema" import { Newtype } from "@opencode-ai/core/schema"
export class PermissionID extends Newtype<PermissionID>()( export class PermissionID extends Newtype<PermissionID>()(
"PermissionID", "PermissionID",
Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }), Schema.String.check(Schema.isStartsWith("per")),
) { ) {
static ascending(id?: string): PermissionID { static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id)) return this.make(Identifier.ascending("permission", id))
+4 -1
View File
@@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Service } from "./bootstrap-service" import { Service } from "./bootstrap-service"
import { Reference } from "@/reference/reference"
export { Service } from "./bootstrap-service" export { Service } from "./bootstrap-service"
export type { Interface } from "./bootstrap-service" export type { Interface } from "./bootstrap-service"
@@ -29,6 +30,7 @@ export const layer = Layer.effect(
const lsp = yield* LSP.Service const lsp = yield* LSP.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const project = yield* Project.Service const project = yield* Project.Service
const reference = yield* Reference.Service
const shareNext = yield* ShareNext.Service const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service const snapshot = yield* Snapshot.Service
const vcs = yield* Vcs.Service const vcs = yield* Vcs.Service
@@ -43,7 +45,7 @@ export const layer = Layer.effect(
// Each service self-manages its own slow work via Effect.forkScoped against // Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here. // its per-instance state scope. We just await materialization here.
yield* Effect.forEach( yield* Effect.forEach(
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], [reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
{ concurrency: "unbounded", discard: true }, { concurrency: "unbounded", discard: true },
).pipe(Effect.withSpan("InstanceBootstrap.init")) ).pipe(Effect.withSpan("InstanceBootstrap.init"))
@@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
LSP.defaultLayer, LSP.defaultLayer,
Plugin.defaultLayer, Plugin.defaultLayer,
Project.defaultLayer, Project.defaultLayer,
Reference.defaultLayer,
ShareNext.defaultLayer, ShareNext.defaultLayer,
Snapshot.defaultLayer, Snapshot.defaultLayer,
Vcs.defaultLayer, Vcs.defaultLayer,
+4 -4
View File
@@ -234,8 +234,8 @@ export const FileDiff = Schema.Struct({
// populates patch, but loosening matches the sibling schema so a // populates patch, but loosening matches the sibling schema so a
// future code path that omits it can't crash /instance/vcs/diff. // future code path that omits it can't crash /instance/vcs/diff.
patch: Schema.optional(Schema.String), patch: Schema.optional(Schema.String),
additions: NonNegativeInt, additions: Schema.Finite,
deletions: NonNegativeInt, deletions: Schema.Finite,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
}) })
.annotate({ identifier: "VcsFileDiff" }) .annotate({ identifier: "VcsFileDiff" })
@@ -244,8 +244,8 @@ export type FileDiff = Schema.Schema.Type<typeof FileDiff>
export const FileStatus = Schema.Struct({ export const FileStatus = Schema.Struct({
file: Schema.String, file: Schema.String,
additions: NonNegativeInt, additions: Schema.Finite,
deletions: NonNegativeInt, deletions: Schema.Finite,
status: Schema.Literals(["added", "deleted", "modified"]), status: Schema.Literals(["added", "deleted", "modified"]),
}) })
.annotate({ identifier: "VcsFileStatus" }) .annotate({ identifier: "VcsFileStatus" })
+2 -2
View File
@@ -1,10 +1,10 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@opencode-ai/core/schema"
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type export type PtyID = typeof ptyIdSchema.Type
+2 -5
View File
@@ -1,13 +1,10 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { zod } from "@opencode-ai/core/effect-zod"
import { Newtype } from "@opencode-ai/core/schema" import { Newtype } from "@opencode-ai/core/schema"
export class QuestionID extends Newtype<QuestionID>()( export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) {
"QuestionID",
Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }),
) {
static ascending(id?: string): QuestionID { static ascending(id?: string): QuestionID {
return this.make(Identifier.ascending("question", id)) return this.make(Identifier.ascending("question", id))
} }
@@ -0,0 +1,237 @@
import path from "path"
import { Effect, Context, Layer, Scope } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { Git } from "@/git"
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
import { RepositoryCache } from "./repository-cache"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
export type Resolved =
| {
name: string
kind: "local"
path: string
}
| {
name: string
kind: "git"
repository: string
reference: RepositoryReference
path: string
branch?: string
}
| {
name: string
kind: "invalid"
repository: string
message: string
}
type State = {
references: Resolved[]
materializeAll: Effect.Effect<void>
materializeByPath: { path: string; run: Effect.Effect<void> }[]
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly list: () => Effect.Effect<Resolved[]>
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
readonly ensure: (target?: string) => Effect.Effect<void>
readonly contains: (target?: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
export function referencePath(input: { directory: string; worktree: string; value: string }) {
if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2))
return path.isAbsolute(input.value)
? input.value
: path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value)
}
function resolveGit(
input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined },
): Resolved {
const parsed = parseRepositoryReference(input.repository)
if (!parsed || parsed.protocol === "file:") {
return {
name: input.name,
kind: "invalid",
repository: input.repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
}
}
return {
name: input.name,
kind: "git",
repository: input.repository,
reference: parsed,
path: repositoryCachePath(parsed),
...("branch" in input ? { branch: input.branch } : {}),
}
}
function branchLabel(branch: string | undefined) {
return branch ?? "default branch"
}
function normalizedTarget(target?: string) {
if (!target) return
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
}
function containsReferencePath(referencePath: string, target: string) {
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
}
export function resolve(input: {
name: string
reference: ReferenceEntry
directory: string
worktree: string
}): Resolved {
if (typeof input.reference === "string") {
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
}
return resolveGit({ name: input.name, repository: input.reference })
}
if ("path" in input.reference) {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
}
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
}
export function resolveAll(input: {
references: NonNullable<Config.Info["reference"]>
directory: string
worktree: string
}) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]) => {
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
if (resolved.kind !== "git") return resolved
const existing = seen.get(resolved.path)
if (!existing) {
seen.set(resolved.path, { name, branch: resolved.branch })
return resolved
}
if (existing.branch === resolved.branch) return resolved
return {
name,
kind: "invalid" as const,
repository: resolved.repository,
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`,
}
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const scope = yield* Scope.Scope
const state = yield* InstanceState.make<State>(
Effect.fn("Reference.state")(function* (ctx) {
const cfg = yield* config.get()
const references = resolveAll({
references: cfg.reference ?? {},
directory: ctx.directory,
worktree: ctx.worktree,
})
const seenPath = new Set<string>()
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git") return false
if (seenPath.has(reference.path)) return false
seenPath.add(reference.path)
return true
})
const materializeByPath = yield* Effect.forEach(
gitReferences,
Effect.fnUntraced(function* (reference) {
const run = yield* Effect.cached(
RepositoryCache.ensure(
{ reference: reference.reference, branch: reference.branch, refresh: true },
{ fs, git },
).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
),
),
)
return { path: reference.path, run }
}),
{ concurrency: "unbounded" },
)
const materializeAll = yield* Effect.cached(
Flag.OPENCODE_EXPERIMENTAL_SCOUT
? Effect.gen(function* () {
yield* Effect.forEach(
materializeByPath,
Effect.fnUntraced(function* (item) {
yield* item.run
}),
{ concurrency: 4, discard: true },
)
})
: Effect.void,
)
return { references, materializeAll, materializeByPath }
}),
)
return Service.of({
init: Effect.fn("Reference.init")(function* () {
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
}),
list: Effect.fn("Reference.list")(function* () {
return yield* InstanceState.use(state, (s) => s.references)
}),
get: Effect.fn("Reference.get")(function* (name: string) {
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
}),
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
const full = normalizedTarget(target)
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
return yield* InstanceState.useEffect(
state,
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
)
}),
contains: Effect.fn("Reference.contains")(function* (target?: string) {
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false
const full = normalizedTarget(target)
if (!full) return false
return yield* InstanceState.use(state, (s) =>
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
)
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
export * as Reference from "./reference"
@@ -0,0 +1,147 @@
import path from "path"
import { Effect } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { Git } from "@/git"
import {
repositoryCachePath,
sameRepositoryReference,
parseRepositoryReference,
validateRepositoryBranch,
type Reference as RepositoryReference,
} from "@/util/repository"
export type Result = {
repository: string
host: string
remote: string
localPath: string
status: "cached" | "cloned" | "refreshed"
head?: string
branch?: string
}
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false) return "refreshed" as const
if (input.refresh) return "refreshed" as const
return "cached" as const
}
function resetTarget(input: {
requestedBranch?: string
remoteHead: { code: number; stdout: string }
branch: { code: number; stdout: string }
}) {
if (input.requestedBranch) return `origin/${input.requestedBranch}`
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
}
if (input.branch.code === 0 && input.branch.stdout) {
return `origin/${input.branch.stdout}`
}
return "HEAD"
}
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
input: {
reference: RepositoryReference
refresh?: boolean
branch?: string
},
services: {
fs: AppFileSystem.Interface
git: Git.Interface
},
) {
if (input.branch) validateRepositoryBranch(input.branch)
const repository = input.reference.label
const remote = input.reference.remote
const localPath = repositoryCachePath(input.reference)
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
return yield* Effect.acquireUseRelease(
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
() =>
Effect.gen(function* () {
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
const exists = yield* services.fs.existsSafe(localPath)
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir
? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
: undefined
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
if (exists && !reuse) {
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
}
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
branchMatches: input.branch ? currentBranch === input.branch : undefined,
})
if (status === "cloned") {
const clone = yield* services.git.run(
["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath],
{ cwd: path.dirname(localPath) },
)
if (clone.exitCode !== 0) {
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
}
}
if (status === "refreshed") {
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
if (fetch.exitCode !== 0) {
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
}
if (input.branch) {
const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], {
cwd: localPath,
})
if (checkout.exitCode !== 0) {
throw new Error(
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
)
}
}
const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
const target = resetTarget({
requestedBranch: input.branch,
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
branch: { code: branch.exitCode, stdout: branch.text().trim() },
})
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
if (reset.exitCode !== 0) {
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
}
}
const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath })
const branch = yield* services.git.branch(localPath)
const headText = head.exitCode === 0 ? head.text().trim() : undefined
return {
repository,
host: input.reference.host,
remote,
localPath,
status,
head: headText,
branch,
} satisfies Result
}),
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
)
})
export * as RepositoryCache from "./repository-cache"
@@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace" import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "./groups/v2" import { V2Api } from "./groups/v2"
import { Authorization } from "./middleware/authorization" import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
// SSE event schemas built from the BusEvent/SyncEvent registries. // SSE event schemas built from the BusEvent/SyncEvent registries.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
@@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
export const RootHttpApi = HttpApi.make("opencode-root") export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi) .addHttpApi(ControlApi)
.addHttpApi(GlobalApi) .addHttpApi(GlobalApi)
.middleware(SchemaErrorMiddleware)
.middleware(Authorization) .middleware(Authorization)
export const InstanceHttpApi = HttpApi.make("opencode-instance") export const InstanceHttpApi = HttpApi.make("opencode-instance")
@@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(V2Api) .addHttpApi(V2Api)
.addHttpApi(TuiApi) .addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi) .addHttpApi(WorkspaceApi)
.middleware(SchemaErrorMiddleware)
export const OpenCodeHttpApi = HttpApi.make("opencode") export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi) .addHttpApi(RootHttpApi)
@@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http" import { HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event") HttpApiGroup.make("event")
.add( .add(
HttpApiEndpoint.get("subscribe", EventPaths.event, { HttpApiEndpoint.get("subscribe", EventPaths.event, {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/config" const root = "/config"
@@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config") HttpApiGroup.make("config")
.add( .add(
HttpApiEndpoint.get("get", root, { HttpApiEndpoint.get("get", root, {
query: WorkspaceRoutingQuery,
success: described(Config.Info, "Get config info"), success: described(Config.Info, "Get config info"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config")
}), }),
), ),
HttpApiEndpoint.patch("update", root, { HttpApiEndpoint.patch("update", root, {
query: WorkspaceRoutingQuery,
payload: Config.Info, payload: Config.Info,
success: described(Config.Info, "Successfully updated config"), success: described(Config.Info, "Successfully updated config"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config")
}), }),
), ),
HttpApiEndpoint.get("providers", `${root}/providers`, { HttpApiEndpoint.get("providers", `${root}/providers`, {
query: WorkspaceRoutingQuery,
success: described(Provider.ConfigProvidersResult, "List of providers"), success: described(Provider.ConfigProvidersResult, "List of providers"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -4,12 +4,17 @@ import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema" import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Schema, SchemaGetter } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
import { QueryBoolean } from "./query"
const ConsoleStateResponse = Schema.Struct({ const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@@ -48,12 +53,6 @@ export const ToolListQuery = Schema.Struct({
model: ModelID, model: ModelID,
}) })
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const WorktreeList = Schema.Array(Schema.String) const WorktreeList = Schema.Array(Schema.String)
export const SessionListQuery = Schema.Struct({ export const SessionListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields, ...WorkspaceRoutingQueryFields,
@@ -82,6 +81,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiGroup.make("experimental") HttpApiGroup.make("experimental")
.add( .add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, { HttpApiEndpoint.get("console", ExperimentalPaths.console, {
query: WorkspaceRoutingQuery,
success: described(ConsoleStateResponse, "Active Console provider metadata"), success: described(ConsoleStateResponse, "Active Console provider metadata"),
error: HttpApiError.InternalServerError, error: HttpApiError.InternalServerError,
}).annotateMerge( }).annotateMerge(
@@ -92,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
query: WorkspaceRoutingQuery,
success: described(ConsoleOrgList, "Switchable Console orgs"), success: described(ConsoleOrgList, "Switchable Console orgs"),
error: HttpApiError.InternalServerError, error: HttpApiError.InternalServerError,
}).annotateMerge( }).annotateMerge(
@@ -102,6 +103,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
query: WorkspaceRoutingQuery,
payload: ConsoleSwitchPayload, payload: ConsoleSwitchPayload,
success: described(Schema.Boolean, "Switch success"), success: described(Schema.Boolean, "Switch success"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -125,6 +127,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
query: WorkspaceRoutingQuery,
success: described(ToolIDs, "Tool IDs"), success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
@@ -136,6 +139,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
success: described(WorktreeList, "List of worktree directories"), success: described(WorktreeList, "List of worktree directories"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -145,6 +149,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Schema.optional(Worktree.CreateInput), payload: Schema.optional(Worktree.CreateInput),
success: described(Worktree.Info, "Worktree created"), success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -156,6 +161,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Worktree.RemoveInput, payload: Worktree.RemoveInput,
success: described(Schema.Boolean, "Worktree removed"), success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -167,6 +173,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
query: WorkspaceRoutingQuery,
payload: Worktree.ResetInput, payload: Worktree.ResetInput,
success: described(Schema.Boolean, "Worktree reset"), success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -189,6 +196,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -5,7 +5,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
export const FileQuery = Schema.Struct({ export const FileQuery = Schema.Struct({
@@ -97,6 +101,7 @@ export const FileApi = HttpApi.make("file")
}), }),
), ),
HttpApiEndpoint.get("status", FilePaths.status, { HttpApiEndpoint.get("status", FilePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(File.Info), "File status"), success: described(Schema.Array(File.Info), "File status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -8,7 +8,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const PathInfo = Schema.Struct({ const PathInfo = Schema.Struct({
@@ -55,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance")
HttpApiGroup.make("instance") HttpApiGroup.make("instance")
.add( .add(
HttpApiEndpoint.post("dispose", InstancePaths.dispose, { HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Instance disposed"), success: described(Schema.Boolean, "Instance disposed"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("path", InstancePaths.path, { HttpApiEndpoint.get("path", InstancePaths.path, {
query: WorkspaceRoutingQuery,
success: PathInfo, success: PathInfo,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, { HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
query: WorkspaceRoutingQuery,
success: described(Vcs.Info, "VCS info"), success: described(Vcs.Info, "VCS info"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Vcs.FileStatus), "VCS status"), success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -103,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
query: WorkspaceRoutingQuery,
success: described( success: described(
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
"Raw VCS diff", "Raw VCS diff",
@@ -115,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
query: WorkspaceRoutingQuery,
payload: Vcs.ApplyInput, payload: Vcs.ApplyInput,
success: described(Vcs.ApplyResult, "VCS patch applied"), success: described(Vcs.ApplyResult, "VCS patch applied"),
error: ApiVcsApplyError, error: ApiVcsApplyError,
@@ -126,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("command", InstancePaths.command, { HttpApiEndpoint.get("command", InstancePaths.command, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Command.Info), "List of commands"), success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -135,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("agent", InstancePaths.agent, { HttpApiEndpoint.get("agent", InstancePaths.agent, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Agent.Info), "List of agents"), success: described(Schema.Array(Agent.Info), "List of agents"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -144,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("skill", InstancePaths.skill, { HttpApiEndpoint.get("skill", InstancePaths.skill, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Skill.Info), "List of skills"), success: described(Schema.Array(Skill.Info), "List of skills"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -153,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, { HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(LSP.Status), "LSP server status"), success: described(Schema.Array(LSP.Status), "LSP server status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -162,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, { HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Format.Status), "Formatter status"), success: described(Schema.Array(Format.Status), "Formatter status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization" import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context" import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
export const AddPayload = Schema.Struct({ export const AddPayload = Schema.Struct({
@@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp")
HttpApiGroup.make("mcp") HttpApiGroup.make("mcp")
.add( .add(
HttpApiEndpoint.get("status", McpPaths.status, { HttpApiEndpoint.get("status", McpPaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp")
}), }),
), ),
HttpApiEndpoint.post("add", McpPaths.status, { HttpApiEndpoint.post("add", McpPaths.status, {
query: WorkspaceRoutingQuery,
payload: AddPayload, payload: AddPayload,
success: described(StatusMap, "MCP server added successfully"), success: described(StatusMap, "MCP server added successfully"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
@@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authStart", McpPaths.auth, { HttpApiEndpoint.post("authStart", McpPaths.auth, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthStartResponse, "OAuth flow started"), success: described(AuthStartResponse, "OAuth flow started"),
error: [UnsupportedOAuthError, HttpApiError.NotFound], error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
payload: AuthCallbackPayload, payload: AuthCallbackPayload,
success: described(MCP.Status, "OAuth authentication completed"), success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound], error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(MCP.Status, "OAuth authentication completed"), success: described(MCP.Status, "OAuth authentication completed"),
error: [UnsupportedOAuthError, HttpApiError.NotFound], error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
@@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, { HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthRemoveResponse, "OAuth credentials removed"), success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: HttpApiError.NotFound, error: HttpApiError.NotFound,
}).annotateMerge( }).annotateMerge(
@@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("connect", McpPaths.connect, { HttpApiEndpoint.post("connect", McpPaths.connect, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server connected successfully"), success: described(Schema.Boolean, "MCP server connected successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
params: { name: Schema.String }, params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server disconnected successfully"), success: described(Schema.Boolean, "MCP server disconnected successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({

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