Compare commits

...
Author SHA1 Message Date
Kit Langton 7c09c2f17f refactor(project): apply simplify review
- Fix prunable parser to match 'prunable <reason>' lines, not just bare 'prunable'.
- Parallelize Effect.forEach in Worktree.list with concurrency: 'unbounded' to match the rest of the file.
- Simplify the experimental worktree GET handler to use Effect.fn(fn, Effect.map(...)).
- Drop unused isProjectList/isProjectUpdate type guards and the unknown-cast set/setBootStore wrappers in global-sync; expose setGlobalStore directly.
- Centralize project worktree mutations as globalSync.project.addWorktree / removeWorktree, replacing three near-duplicate produce blocks in pages/layout.tsx and prompt-input/submit.ts. Use pathKey for dedup in all sites.
- Cast project.updated event properties as Project (not ProjectInfo) and seed worktrees: [] on insert.
- Extract projectDirectories(project) helper in pages/layout/helpers.ts; use at six call sites that built [project.worktree, ...(project.worktrees ?? [])] inline.
2026-05-11 11:48:52 -04:00
Kit Langton c4132543c3 refactor(project): drop persisted Project.sandboxes column
The Project row stored a sandboxes string array that duplicated what git
already tracks. The /experimental/worktree GET handler now reads from
`Worktree.list()` (git worktree list --porcelain) instead, the write-side
addSandbox/removeSandbox bookkeeping is gone, and the column is dropped
from the schema with a Drizzle migration. The app maps the live list onto
project.worktrees client-side instead of reading project.sandboxes.
2026-05-11 11:48:52 -04:00
Kit LangtonandGitHub 52f7ba7d4d fix(llm): drop removed dispatch option from recorded cache tests (#26900) 2026-05-11 11:48:30 -04:00
opencode-agent[bot] 19fce2bc6f chore: generate 2026-05-11 15:21:20 +00:00
Shoubhit DashandGitHub 4bae84c8b0 feat(scout): autocomplete configured mentions (#26843) 2026-05-11 20:50:03 +05:30
Kit LangtonandGitHub f240bba8e7 chore(http-recorder): remove content-matching dispatch mode (#26792) 2026-05-11 11:10:18 -04:00
Kit LangtonandGitHub bcee247988 Define project update input with Effect Schema (#26803) 2026-05-11 15:05:31 +00:00
Kit LangtonandGitHub 64c504212a Parse command config with Effect Schema (#26801) 2026-05-11 10:22:45 -04:00
opencode-agent[bot] 6592d80460 chore: generate 2026-05-11 14:13:39 +00:00
SebastianandGitHub d821650b48 add default diff parser for markdown fenced code blocks (#26887) 2026-05-11 14:12:32 +00:00
Victor NavarroandGitHub 6c9f12bb2d chore: exclude status 429 from free model alerts (#26879) 2026-05-11 16:04:54 +02:00
opencode-agent[bot] fca89e5e4d chore: generate 2026-05-11 13:44:39 +00:00
Frank 1eff01b6a5 sync 2026-05-11 09:43:12 -04:00
opencode-agent[bot] e93b1f3a7f chore: generate 2026-05-11 13:38:44 +00:00
Frank 8874d4a42f zen: deekseek v4 flash free 2026-05-11 09:37:14 -04:00
Brendan AllanandGitHub c933504d9c fix(ui): better handle patch file support when rendering patch/edit tools (#26828) 2026-05-11 17:21:59 +08:00
Shoubhit DashandGitHub 2d0d3d596e feat(compaction): serialize compaction tail (#26830) 2026-05-11 13:40:36 +05:30
opencode-agent[bot] 3bd98ea055 chore: generate 2026-05-11 07:28:32 +00:00
Shoubhit DashandGitHub 7e997cfba4 refactor(scout): resolve configured reference mentions (#26701) 2026-05-11 12:57:26 +05:30
Brendan AllanandGitHub 5d6f2a1524 fix(ui): use part_text_accum_delta to prevent markdown cutoff during streaming (#26822) 2026-05-11 15:15:03 +08:00
opencode-agent[bot] b1cb71856e chore: generate 2026-05-11 05:27:40 +00:00
Aiden ClineandGitHub 518264fcd9 fix(opencode): fix full session fork (#26811) 2026-05-11 00:26:36 -05:00
DaxandGitHub 7235c9c9b8 Trace data migrations (#26809) 2026-05-11 03:23:01 +00:00
Kit LangtonandGitHub 274033cd52 Validate prompt messages with Effect Schema (#26796) 2026-05-11 02:59:20 +00:00
Kit LangtonandGitHub 9b369ee815 chore(llm): make cache: 'auto' the default (#26798) 2026-05-10 22:46:01 -04:00
SebastianandGitHub 721ff5121e fix prompt history behaviour and session line up/down commands (#26797) 2026-05-11 02:27:46 +00:00
opencode-agent[bot] cfbf5d1c6f chore: update nix node_modules hashes 2026-05-11 02:20:35 +00:00
opencode-agent[bot] 02cb7e7b71 chore: generate 2026-05-11 02:11:07 +00:00
Kit LangtonandGitHub 942630eb4a feat(llm): cache-policy auto-placement (#26786) 2026-05-10 22:09:55 -04:00
opencode ce66b191d1 sync release versions for v1.14.48 2026-05-11 02:07:48 +00:00
Kit LangtonandGitHub 6f1f5944ce Delete unused opencode Zod helpers (#26793) 2026-05-10 21:57:18 -04:00
opencode-agent[bot] 1c49b2ed67 chore: generate 2026-05-11 01:55:22 +00:00
opencode-agent[bot] 3c6be604ec chore: update nix node_modules hashes 2026-05-11 01:54:13 +00:00
Kit LangtonandGitHub ddc02c2893 Drop synchronous SyncEvent facades (#26789) 2026-05-11 01:53:50 +00:00
Kit LangtonandGitHub 2703eff2e2 refactor(llm): normalize Usage as inclusive total + non-overlapping breakdown (#26735) 2026-05-10 21:52:50 -04:00
opencode-agent[bot] 38e4540119 chore: generate 2026-05-11 01:41:26 +00:00
Dax Raad 4100fcbd17 disable image resizing 2026-05-10 21:40:12 -04:00
Kit LangtonandGitHub 83cb0f60ec Drop EventV2 run facade (#26783) 2026-05-10 21:26:28 -04:00
Kit LangtonandGitHub effd96755e Use SyncEvent service at event call sites (#26782) 2026-05-10 21:20:13 -04:00
opencode-agent[bot] 5801cce1b5 chore: generate 2026-05-11 01:18:39 +00:00
Kit LangtonandGitHub 77e6c0d329 feat(llm): cache hint TTL, breakpoint cap, and tool placement (#26779) 2026-05-10 21:17:38 -04:00
Kit LangtonandGitHub fed716ada5 Clarify compaction test harness (#26777) 2026-05-10 20:41:21 -04:00
opencode 426d92e352 sync release versions for v1.14.47 2026-05-11 00:23:17 +00:00
opencode-agent[bot] d0412a213b chore: generate 2026-05-11 00:18:03 +00:00
Kit LangtonandGitHub 16aa67086b Effectify remaining compaction process tests (#26776) 2026-05-10 20:17:01 -04:00
DaxandGitHub 7cea32ee08 Add background code migration service (#26652) 2026-05-11 00:16:42 +00:00
Kit LangtonandGitHub 128d10d9e9 Simplify compaction test helpers (#26742) 2026-05-10 20:03:11 -04:00
Kit LangtonandGitHub 5ef72e1101 Drop unused ID Zod statics (#26740) 2026-05-10 19:58:21 -04:00
Kit LangtonandGitHub d8060dc9ad Drop compaction create facade (#26739) 2026-05-10 19:54:16 -04:00
Kit LangtonandGitHub f71fb18d3d Replace compaction create test fixtures (#26738) 2026-05-10 19:53:22 -04:00
SebastianandGitHub 5654dd2aad restore managed textarea keymap handling (#26771) 2026-05-11 01:45:59 +02:00
Kit LangtonandGitHub 64dde0cb15 Migrate compaction tool-call test (#26737) 2026-05-10 19:44:42 -04:00
Kit LangtonandGitHub a658e43eeb Migrate compaction plugin test (#26736) 2026-05-10 19:42:44 -04:00
Kit LangtonandGitHub ca8a42c973 Migrate LLM compaction tail tests (#26734) 2026-05-10 19:41:28 -04:00
Kit LangtonandGitHub 8f5f75db12 Migrate compaction LLM test (#26733) 2026-05-10 19:20:44 -04:00
Kit LangtonandGitHub 4ff0d07b1d Migrate configurable compaction tests (#26732) 2026-05-10 19:18:02 -04:00
Kit LangtonandGitHub 2a571b3cee Migrate compaction overflow tests (#26731) 2026-05-10 19:12:49 -04:00
DaxandGitHub 0fffcdfe46 Persist session model switches outside event flag (#26765) 2026-05-10 18:53:14 -04:00
DaxandGitHub 2ba2ee52e8 docs: document bun dev tmux capture (#26764) 2026-05-10 17:41:20 -04:00
Frank 56d818fc34 zen: fix reasoning token for openai compatible endpoint 2026-05-10 13:36:02 -04:00
Kit LangtonandGitHub 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 LangtonandGitHub 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 LangtonandGitHub 2bd3d9a696 refactor(http-recorder): hide cassette format behind Cassette seam (#26725) 2026-05-10 12:29:55 -04:00
Kit LangtonandGitHub 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 LangtonandGitHub 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 LangtonandGitHub 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 LangtonandGitHub 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 LangtonandGitHub 4fb417d3b5 feat(http-recorder): default mode to "auto" (#26719) 2026-05-10 16:05:11 +00:00
Kit LangtonandGitHub 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 LangtonandGitHub 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 LangtonandGitHub 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 DashandGitHub 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 ClineandGitHub 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
JackandGitHub 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
DaxandGitHub 3753601f87 Format TUI paths relative to session directory (#26648) 2026-05-10 04:29:02 +00:00
Kit LangtonandGitHub 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 LangtonandGitHub 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 LangtonandGitHub 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 LangtonandGitHub 29250a0efb fix(session): loosen remaining stored numeric schemas to tolerate legacy data (#26622) 2026-05-09 22:10:48 -04:00
Kit LangtonandGitHub 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 LangtonandGitHub 10ea59066f feat(skill): built-in opencode-meta skill (#26617) 2026-05-09 22:05:37 -04:00
Kit LangtonandGitHub 79d6b10d7c fix(mcp): tolerate output schema ref failures (#26614) 2026-05-09 22:03:59 -04:00
Kit LangtonandGitHub 6e78f36a0f Narrow HTTP API numeric query overrides (#26618) 2026-05-09 22:02:51 -04:00
Kit LangtonandGitHub 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 LangtonandGitHub 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
b8ca71d309 fix(task): subagent inherits parent agent's deny rules (Plan Mode security bypass) (#26597)
Co-authored-by: Developer <temp@example.com>
2026-05-09 23:51:55 +00:00
6849f96825 refactor(provider): share model status schema (#26595)
Co-authored-by: Developer <temp@example.com>
2026-05-09 19:20:31 -04:00
00c3248295 fix(config): allow active provider model status (#26592)
Co-authored-by: Developer <temp@example.com>
2026-05-09 18:59:05 -04:00
opencode-agent[bot] 818b56dbd0 chore: generate 2026-05-09 22:47:54 +00:00
Kit LangtonandGitHub 29b5b24787 fix(tui): aggregate bootstrap request failures
TUI bootstrap now reports all parallel fetch failures together instead of losing sibling failures after the first rejection.
2026-05-09 18:46:52 -04:00
Kit LangtonandGitHub 11363170ca fix(sdk): wrap thrown error bodies in Error
SDK throwOnError paths now convert structured response bodies into real Error instances while preserving the original body and status in cause.
2026-05-09 18:46:43 -04:00
Kit LangtonandGitHub ba9e4b67ed fix(tool/read): match permission patterns against worktree-relative path
Read permission checks now use the same worktree-relative path basis as edit/write/apply_patch, so configured patterns apply consistently.
2026-05-09 18:46:29 -04:00
Kit LangtonandGitHub bd1029b19f test(server): cover HttpApi context inheritance
Adds regression coverage for request context inheritance in promptAsync and explicit context provisioning in stream bodies.
2026-05-09 18:46:10 -04:00
43b51f09d0 fix(httpapi): align runtime query schemas with workspace routing params (#26581)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:50:30 -04:00
opencode-agent[bot] c61ab51886 chore: generate 2026-05-09 20:45:29 +00:00
d373c562f2 fix(session): accept legacy summary diffs (#26579)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:44:24 -04:00
opencode-agent[bot] 5fa5d876fc chore: generate 2026-05-09 20:31:32 +00:00
805af011c9 test(session): regression test for #26574 + mirror loosening on Vcs.FileDiff (#26578)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:30:31 -04:00
opencode-agent[bot] 480aa8b23b chore: generate 2026-05-09 20:24:28 +00:00
OpeOginniandGitHub d62442bb5d fix(sessions): allow optional patch field in diff for migrated sessions (#26574) 2026-05-09 16:23:28 -04:00
Kit LangtonandGitHub 8602937a37 test(session): cover workspace-routed messages (#26576) 2026-05-09 16:19:06 -04:00
77da433e0a fix(session): accept routing params in message list (#26569)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:02:35 -04:00
opencode-agent[bot] ad79f3e0cf chore: generate 2026-05-09 19:59:00 +00:00
6c2dfd2f52 fix(tui): guard messages.data in session.sync against undefined (#26566)
Co-authored-by: Developer <temp@example.com>
2026-05-09 19:58:03 +00:00
SebastianandGitHub 9a8b54fe62 Plugin command API shim (#26564) 2026-05-09 21:56:49 +02:00
DaxandGitHub dcdbdb218f Move schema utilities into core (#26565) 2026-05-09 19:51:09 +00:00
5e49029e70 fix(provider): isolate plugin model mutations (#26561)
Co-authored-by: Developer <temp@example.com>
2026-05-09 15:47:07 -04:00
opencode 19abadaf27 sync release versions for v1.14.44 2026-05-09 19:33:26 +00:00
Kit Langton 6fea0178eb fix(server): defer validation error body change 2026-05-09 14:50:45 -04:00
Kit Langton ebe9dcf27e fix(server): return validation error bodies 2026-05-09 14:39:25 -04:00
Kit LangtonandGitHub 57efec4429 fix(storage): default workspace time migration (#26556) 2026-05-09 14:18:55 -04:00
opencode e22144806f sync release versions for v1.14.43 2026-05-09 18:06:16 +00:00
Kit LangtonandGitHub 27fa297a42 fix(server): keep provider lists JSON-safe (#26550) 2026-05-09 13:40:46 -04:00
Aiden ClineandGitHub b1cd25de3d ignore: fix typerrs on dev (#26544) 2026-05-09 12:08:00 -05:00
opencode 780bbb0f3b sync release versions for v1.14.42 2026-05-09 16:54:43 +00:00
Steffen DeuschandGitHub 817cb076a8 fix(acp): include tool image attachments in updates (#25128) 2026-05-09 11:45:44 -05:00
opencode-agent[bot] 347526e9fd chore: generate 2026-05-09 16:32:52 +00:00
Polo123456789andGitHub 092bc674a5 fix(sidebar): fix logic and missleading message #26469 (#26470) 2026-05-09 11:31:46 -05:00
Kit LangtonandGitHub b24a4e897e chore(server): clean up post-Hono-deletion scar tissue (#26542) 2026-05-09 12:30:18 -04:00
opencode-agent[bot] 3afa622eab chore: update nix node_modules hashes 2026-05-09 13:22:38 +00:00
opencode-agent[bot] d01cb7f013 chore: generate 2026-05-09 13:11:45 +00:00
Kit LangtonandGitHub 28b03595bf research: delete Hono backend (do not merge) (#25667) 2026-05-09 13:10:42 +00:00
Kit LangtonandGitHub 32684e70e6 test(server): expect null body from HTTP API authorize() with no redirect (#26515) 2026-05-09 08:50:00 -04:00
opencode-agent[bot] b2baddcd37 chore: generate 2026-05-09 04:31:07 +00:00
Kit LangtonandGitHub dbd48d423d fix(server): match Hono wire format for authorize undefined and share errors (#26474) 2026-05-09 00:30:00 -04:00
Kit LangtonandGitHub e7cc8259b5 test(server): drop flaky account error-mapping test (#26475) 2026-05-09 00:29:31 -04:00
opencode-agent[bot] a9ccb0804f chore: generate 2026-05-09 04:22:25 +00:00
Kit LangtonandGitHub ebe6087e8f fix(server): return structured validation errors (#26457) 2026-05-09 00:21:19 -04:00
Kit LangtonandGitHub dc978cb889 fix(server): validate permission and question ids (#26456) 2026-05-09 00:20:28 -04:00
Kit LangtonandGitHub 8cbc43fbb0 fix(server): include auth challenge on typed 401 (#26455) 2026-05-09 00:15:20 -04:00
opencode-agent[bot] 82359c4b1b chore: generate 2026-05-09 04:06:08 +00:00
Kit LangtonandGitHub 7f3e51453b test(server): use Layer.mock for partial Account service stub (#26472) 2026-05-09 00:05:05 -04:00
Kit LangtonandGitHub cbdb2d9825 test(server): expand workspace routing fixed-id coverage (#26458) 2026-05-09 00:00:18 -04:00
Kit LangtonandGitHub 96bde05f6e docs(server): explain why HTTP API PTY handler has no early-frame buffer (#26464) 2026-05-08 23:58:33 -04:00
Kit LangtonandGitHub dcb8ed8eb0 test(server): cover workspace sync fence protocol (#26441) 2026-05-08 23:55:47 -04:00
Kit LangtonandGitHub aab82cc1a7 test(project): rescue non-Hono InstanceBootstrap boundary tests (#26453) 2026-05-08 23:53:42 -04:00
Kit LangtonandGitHub cd1d1e81a6 test(server): run httpapi exercise effect mode in test:httpapi (#26452) 2026-05-08 23:48:29 -04:00
opencode-agent[bot] cff441909a chore: generate 2026-05-09 03:45:35 +00:00
Kit LangtonandGitHub 3615d5aab1 fix(server): map Account failures to typed 500 instead of defect (#26448) 2026-05-08 23:44:28 -04:00
opencode-agent[bot] 11d9e82eaf chore: generate 2026-05-09 03:42:23 +00:00
Kit LangtonandGitHub eadda11ec9 refactor(server): use JSON response for OpenAPI doc route (#26447) 2026-05-08 23:41:20 -04:00
Kit LangtonandGitHub f73a56c223 fix(server): log instance disposal failures from HTTP API lifecycle (#26446) 2026-05-08 23:40:06 -04:00
opencode-agent[bot] c0acf5c43f chore: generate 2026-05-09 03:18:57 +00:00
Kit LangtonandGitHub 4d585464f3 fix(server): include Origin in CORS preflight Vary header (#26445) 2026-05-08 23:17:47 -04:00
f0cb17a812 fix(tui): sort session picker by full updated timestamp (#24725)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-05-08 22:17:13 -05:00
Kit LangtonandGitHub 357a74714a fix(test): set OPENCODE_EXPERIMENTAL_WORKSPACES in fence header test (#26466) 2026-05-08 23:12:08 -04:00
Kit LangtonandGitHub ffea6c7974 feat(server): add HTTP API response compression (#26440) 2026-05-08 23:06:00 -04:00
Kit LangtonandGitHub 8e9550d90d fix(server): emit fixed workspace fence headers (#26443) 2026-05-08 22:45:54 -04:00
James LongandGitHub 9b7b6cb30f feat(core): be smarter about generating a worktree name (#26368) 2026-05-08 22:45:44 -04:00
Kit LangtonandGitHub cc68afb2de test(server): lock fixed workspace routing context (#26454) 2026-05-08 22:35:32 -04:00
Kit LangtonandGitHub 11c33d52a5 test(server): cover REST API project skills (#26451) 2026-05-09 02:23:21 +00:00
opencode-agent[bot] 2a487ea990 chore: generate 2026-05-09 02:21:54 +00:00
Kit LangtonandGitHub 9c05d4e2fd fix(server): serve HttpApi OpenAPI document (#26438) 2026-05-08 22:20:50 -04:00
430 changed files with 17162 additions and 14576 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 -27
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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": "0.0.0", "version": "1.14.48",
"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.25", "version": "1.14.48",
"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.41", "version": "1.14.48",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -393,10 +393,6 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
@@ -416,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",
@@ -437,8 +434,6 @@
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
@@ -465,7 +460,6 @@
"xdg-basedir": "5.1.0", "xdg-basedir": "5.1.0",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "catalog:", "zod": "catalog:",
"zod-to-json-schema": "3.24.5",
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
@@ -498,12 +492,11 @@
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2", "why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5",
}, },
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.14.41", "version": "1.14.48",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -541,7 +534,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.14.41", "version": "1.14.48",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -556,7 +549,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.14.41", "version": "1.14.48",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -591,7 +584,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.14.41", "version": "1.14.48",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -640,7 +633,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.14.41", "version": "1.14.48",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -685,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:",
@@ -1239,8 +1233,6 @@
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -2045,6 +2037,8 @@
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], "@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=="],
+38 -24
View File
@@ -1,6 +1,8 @@
import { SECRET } from "./secret" import { SECRET } from "./secret"
import { domain } from "./stage" import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)"
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
url: `https://${domain}/honeycomb/webhook`, url: `https://${domain}/honeycomb/webhook`,
@@ -25,6 +27,16 @@ const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
], ],
}) })
// Honeycomb can keep stale query-local calculated fields when the name is unchanged,
// so tie the field name to the expression while avoiding deploy-to-deploy churn.
// https://github.com/honeycombio/terraform-provider-honeycombio/issues/852
const calculatedField = (field: { name: string; expression: string }) => ({
...field,
name: `${field.name}_${(
Array.from(field.expression).reduce((result, char) => Math.imul(31, result) + char.charCodeAt(0), 0) >>> 0
).toString(36)}`,
})
const modelHttpErrorsQuery = (product: "go" | "zen") => { const modelHttpErrorsQuery = (product: "go" | "zen") => {
const filters = [ const filters = [
{ column: "model", op: "exists" }, { column: "model", op: "exists" },
@@ -32,21 +44,26 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
{ column: "user_agent", op: "contains", value: "opencode" }, { column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
] ]
const failedHttpStatus = calculatedField({
name: "is_failed_http_status",
expression:
product === "go"
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
: `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
})
return honeycomb.getQuerySpecificationOutput({ return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"], breakdowns: ["model"],
calculatedFields: [ calculatedFields: [failedHttpStatus],
{
name: "is_failed_http_status",
expression:
product === "go"
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
},
],
calculations: [ calculations: [
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
{ op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, {
op: "SUM",
name: "FAILED",
column: failedHttpStatus.name,
filterCombination: "AND",
filters,
},
], ],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }], formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900, timeRange: 900,
@@ -59,31 +76,30 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
{ column: "user_agent", op: "contains", value: "opencode" }, { column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
] ]
const successHttpStatus = calculatedField({
name: "is_success_http_status",
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
})
const failedProviderHttpStatus = calculatedField({
name: "is_failed_provider_http_status",
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
})
return honeycomb.getQuerySpecificationOutput({ return honeycomb.getQuerySpecificationOutput({
breakdowns: ["provider"], breakdowns: ["provider"],
calculatedFields: [ calculatedFields: [successHttpStatus, failedProviderHttpStatus],
{
name: "is_success_http_status",
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
},
{
name: "is_failed_provider_http_status",
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
},
],
calculations: [ calculations: [
{ {
op: "SUM", op: "SUM",
name: "SUCCESS", name: "SUCCESS",
column: "is_success_http_status", column: successHttpStatus.name,
filterCombination: "AND", filterCombination: "AND",
filters: [...filters, { column: "event_type", op: "=", value: "completions" }], filters: [...filters, { column: "event_type", op: "=", value: "completions" }],
}, },
{ {
op: "SUM", op: "SUM",
name: "FAILED", name: "FAILED",
column: "is_failed_provider_http_status", column: failedProviderHttpStatus.name,
filterCombination: "AND", filterCombination: "AND",
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }], filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
}, },
@@ -95,8 +111,6 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
}).json }).json
} }
const description = "Managed by SST (Don't edit in Honeycomb UI)"
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]", name: "Increased Model HTTP Errors [Go]",
description, description,
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-ZqTxXSIWZmrNpdIQICbesrMpoL24blahhQzAGTzupAc=", "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
"aarch64-linux": "sha256-YdZAkfJXBCnqPcYS7VUbQqT9AkYnyu/ONVG5v78Lmdw=", "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
"aarch64-darwin": "sha256-3Fdu1le89F53UHuaiHanHP0CB4F72fK9p1d9PC47Wsg=", "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
"x86_64-darwin": "sha256-Tc4WppnKZPzYn/ICIv1vtx+NiNqAHAzd/ogTRUCQK18=" "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.41", "version": "1.14.48",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -10,6 +10,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { projectDirectories } from "@/pages/layout/helpers"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
@@ -284,7 +285,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
for (const project of projects) { for (const project of projects) {
let at = 0 let at = 0
const dirs = [project.worktree, ...(project.sandboxes ?? [])] const dirs = projectDirectories(project)
for (const directory of dirs) { for (const directory of dirs) {
const sessions = sync.child(directory, { bootstrap: false })[0].session const sessions = sync.child(directory, { bootstrap: false })[0].session
for (const session of sessions) { for (const session of sessions) {
@@ -16,6 +16,7 @@ import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { projectDirectories } from "@/pages/layout/helpers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
@@ -280,14 +281,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const project = createMemo(() => { const project = createMemo(() => {
const directory = projectDirectory() const directory = projectDirectory()
if (!directory) return if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) return layout.projects.list().find((p) => p.worktree === directory || p.worktrees?.includes(directory))
}) })
const workspaces = createMemo(() => { const workspaces = createMemo(() => {
const directory = projectDirectory() const directory = projectDirectory()
const current = project() const current = project()
if (!current) return directory ? [directory] : [] if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])] const dirs = projectDirectories(current)
if (directory && !dirs.includes(directory)) return [...dirs, directory] if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs return dirs
}) })
@@ -342,6 +342,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
WorktreeState.pending(createdWorktree.directory) WorktreeState.pending(createdWorktree.directory)
globalSync.project.addWorktree(projectDirectory, createdWorktree.directory)
sessionDirectory = createdWorktree.directory sessionDirectory = createdWorktree.directory
} }
@@ -144,7 +144,7 @@ export function SessionHeader() {
const project = createMemo(() => { const project = createMemo(() => {
const directory = projectDirectory() const directory = projectDirectory()
if (!directory) return if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) return layout.projects.list().find((p) => p.worktree === directory || p.worktrees?.includes(directory))
}) })
const name = createMemo(() => { const name = createMemo(() => {
const current = project() const current = project()
@@ -20,8 +20,8 @@ export function NewSessionView(props: NewSessionViewProps) {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) const worktrees = createMemo(() => sync.project?.worktrees ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) const options = createMemo(() => [MAIN_WORKTREE, ...worktrees(), CREATE_WORKTREE])
const current = createMemo(() => { const current = createMemo(() => {
const selection = props.worktree const selection = props.worktree
if (options().includes(selection)) return selection if (options().includes(selection)) return selection
+32 -24
View File
@@ -2,7 +2,6 @@ import type {
Config, Config,
OpencodeClient, OpencodeClient,
Path, Path,
Project,
ProviderAuthResponse, ProviderAuthResponse,
ProviderListResponse, ProviderListResponse,
Todo, Todo,
@@ -27,18 +26,19 @@ import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } fr
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectInfo, ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { pathKey } from "@/utils/path-key"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
error?: InitError error?: InitError
path: Path path: Path
project: Project[] project: ProjectInfo[]
session_todo: { session_todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
} }
@@ -122,18 +122,6 @@ function createGlobalSync() {
if (eventTimer !== undefined) clearTimeout(eventTimer) if (eventTimer !== undefined) clearTimeout(eventTimer)
}) })
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({ const bootstrap = useQuery(() => ({
queryKey: ["bootstrap"], queryKey: ["bootstrap"],
queryFn: async () => { queryFn: async () => {
@@ -142,7 +130,7 @@ function createGlobalSync() {
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
translate: language.t, translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore, setGlobalStore,
queryClient, queryClient,
}) })
bootedAt = Date.now() bootedAt = Date.now()
@@ -150,13 +138,31 @@ function createGlobalSync() {
}, },
})) }))
const set = ((...input: unknown[]) => { const set = setGlobalStore
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) const addProjectWorktree = (projectWorktree: string, directory: string) => {
return input[1] setGlobalStore(
} "project",
return (setGlobalStore as (...args: unknown[]) => unknown)(...input) produce((draft) => {
}) as typeof setGlobalStore const project = draft.find((item) => item.worktree === projectWorktree)
if (!project) return
const key = pathKey(directory)
project.worktrees = [directory, ...(project.worktrees ?? []).filter((item) => pathKey(item) !== key)]
}),
)
}
const removeProjectWorktree = (projectWorktree: string, directory: string) => {
setGlobalStore(
"project",
produce((draft) => {
const project = draft.find((item) => item.worktree === projectWorktree)
if (!project) return
const key = pathKey(directory)
project.worktrees = (project.worktrees ?? []).filter((item) => pathKey(item) !== key)
}),
)
}
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return if (!sessionID) return
@@ -344,7 +350,7 @@ function createGlobalSync() {
if (recent) return if (recent) return
bootstrap.refetch() bootstrap.refetch()
}, },
setGlobalProject: setProjects, setGlobalProject: (next) => setGlobalStore("project", next),
}) })
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return if (recent) return
@@ -408,6 +414,8 @@ function createGlobalSync() {
icon(directory: string, value: string | undefined) { icon(directory: string, value: string | undefined) {
children.projectIcon(directory, value) children.projectIcon(directory, value)
}, },
addWorktree: addProjectWorktree,
removeWorktree: removeProjectWorktree,
} }
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
@@ -3,7 +3,6 @@ import type {
OpencodeClient, OpencodeClient,
Path, Path,
PermissionRequest, PermissionRequest,
Project,
ProviderAuthResponse, ProviderAuthResponse,
ProviderListResponse, ProviderListResponse,
QuestionRequest, QuestionRequest,
@@ -15,7 +14,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { ProjectInfo, State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
@@ -24,7 +23,7 @@ import { loadMcpQuery } from "../global-sync"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
path: Path path: Path
project: Project[] project: ProjectInfo[]
session_todo: { session_todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
} }
@@ -94,12 +93,21 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
queryKey: ["project"], queryKey: ["project"],
queryFn: () => queryFn: () =>
retry(() => retry(() =>
sdk.project.list().then((x) => { sdk.project.list().then(async (x) => {
return (x.data ?? []) return Promise.all(
.filter((p) => !!p?.id) (x.data ?? [])
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .filter((p) => !!p?.id)
.slice() .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.sort((a, b) => cmp(a.id, b.id)) .slice()
.sort((a, b) => cmp(a.id, b.id))
.map(async (project) => ({
...project,
worktrees: await sdk.worktree
.list({ directory: project.worktree })
.then((x) => x.data ?? [])
.catch(() => []),
})),
)
}), }),
), ),
}) })
@@ -140,8 +148,8 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {}) }, {})
} }
function projectID(directory: string, projects: Project[]) { function projectID(directory: string, projects: ProjectInfo[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id return projects.find((project) => project.worktree === directory || project.worktrees?.includes(directory))?.id
} }
function mergeSession(setStore: SetStoreFunction<State>, session: Session) { function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
@@ -207,7 +215,7 @@ export async function bootstrapDirectory(input: {
global: { global: {
config: Config config: Config
path: Path path: Path
project: Project[] project: ProjectInfo[]
provider: ProviderListResponse provider: ProviderListResponse
} }
queryClient: QueryClient queryClient: QueryClient
@@ -231,6 +231,7 @@ export function createChildStoreManager(input: {
limit: 5, limit: 5,
message: {}, message: {},
part: {}, part: {},
part_text_accum_delta: {},
}) })
children[key] = child children[key] = child
disposers.set(key, dispose) disposers.set(key, dispose)
@@ -81,6 +81,7 @@ const baseState = (input: Partial<State> = {}) =>
limit: 10, limit: 10,
message: {}, message: {},
part: {}, part: {},
part_text_accum_delta: {},
...input, ...input,
}) as State }) as State
@@ -11,7 +11,7 @@ import type {
SnapshotFileDiff, SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types" import type { ProjectInfo, State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
@@ -20,8 +20,8 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: { export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown } event: { type: string; properties?: unknown }
project: Project[] project: ProjectInfo[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void setGlobalProject: (next: ProjectInfo[] | ((draft: ProjectInfo[]) => ProjectInfo[])) => void
refresh: () => void refresh: () => void
}) { }) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") { if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
@@ -42,7 +42,7 @@ export function applyGlobalEvent(input: {
} }
input.setGlobalProject( input.setGlobalProject(
produce((draft) => { produce((draft) => {
draft.splice(result.index, 0, properties) draft.splice(result.index, 0, { ...properties, worktrees: [] })
}), }),
) )
} }
@@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: {
const result = Binary.search(messages, props.messageID, (m) => m.id) const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
} }
const parts = draft.part[props.messageID]
if (parts) {
for (const part of parts) {
delete draft.part_text_accum_delta[part.id]
}
}
delete draft.part[props.messageID] delete draft.part[props.messageID]
}), }),
) )
@@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: {
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break if (SKIP_PARTS.has(part.type)) break
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[part.id]
}),
)
const parts = input.store.part[part.messageID] const parts = input.store.part[part.messageID]
if (!parts) { if (!parts) {
input.setStore("part", part.messageID, [part]) input.setStore("part", part.messageID, [part])
@@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: {
} }
case "message.part.removed": { case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string } const props = event.properties as { messageID: string; partID: string }
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
}),
)
const parts = input.store.part[props.messageID] const parts = input.store.part[props.messageID]
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id) const result = Binary.search(parts, props.partID, (p) => p.id)
@@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: {
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id) const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break if (!result.found) break
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
input.setStore( input.setStore(
"part", "part",
props.messageID, props.messageID,
@@ -39,6 +39,7 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = { } = {
session_status: { ses_1: { type: "busy" } as SessionStatus }, session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] }, session_diff: { ses_1: [] },
@@ -47,12 +48,14 @@ describe("app session cache", () => {
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] }, permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] }, question: { ses_1: [] as QuestionRequest[] },
part_text_accum_delta: { prt_1: "streamed text" },
} }
dropSessionCaches(store, ["ses_1"]) dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined() expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined() expect(store.part.msg_1).toBeUndefined()
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined()
@@ -70,6 +73,7 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = { } = {
session_status: {}, session_status: {},
session_diff: {}, session_diff: {},
@@ -78,6 +82,7 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {}, permission: {},
question: {}, question: {},
part_text_accum_delta: {},
} }
dropSessionCaches(store, ["ses_1"]) dropSessionCaches(store, ["ses_1"])
@@ -18,6 +18,7 @@ type SessionCache = {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} }
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) { export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
@@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const key of Object.keys(store.part)) { for (const key of Object.keys(store.part)) {
const parts = store.part[key] const parts = store.part[key]
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
for (const part of parts) {
delete store.part_text_accum_delta[part.id]
}
delete store.part[key] delete store.part[key]
} }
@@ -8,6 +8,7 @@ import type {
Part, Part,
Path, Path,
PermissionRequest, PermissionRequest,
Project,
ProviderListResponse, ProviderListResponse,
QuestionRequest, QuestionRequest,
Session, Session,
@@ -30,6 +31,10 @@ export type ProjectMeta = {
} }
} }
export type ProjectInfo = Project & {
worktrees?: string[]
}
export type State = { export type State = {
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
agent: Agent[] agent: Agent[]
@@ -72,6 +77,9 @@ export type State = {
part: { part: {
[messageID: string]: Part[] [messageID: string]: Part[]
} }
part_text_accum_delta: {
[partID: string]: string
}
} }
export type VcsCache = { export type VcsCache = {
@@ -1,4 +1,5 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type { ProjectInfo } from "./types"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -27,7 +28,7 @@ export function normalizeProviderList(input: ProviderListResponse): ProviderList
} }
} }
export function sanitizeProject(project: Project) { export function sanitizeProject(project: ProjectInfo) {
if (!project.icon?.url && !project.icon?.override) return project if (!project.icon?.url && !project.icon?.override) return project
return { return {
...project, ...project,
+4 -5
View File
@@ -6,8 +6,8 @@ import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server" import { useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import type { ProjectInfo } from "./global-sync/types"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -51,7 +51,7 @@ type TabHandoff = {
at: number at: number
} }
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type LocalProject = Partial<ProjectInfo> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
@@ -404,9 +404,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const roots = createMemo(() => { const roots = createMemo(() => {
const map = new Map<string, string>() const map = new Map<string, string>()
for (const project of globalSync.data.project) { for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? [] for (const worktree of project.worktrees ?? []) {
for (const sandbox of sandboxes) { map.set(worktree, project.worktree)
map.set(sandbox, project.worktree)
} }
} }
return map return map
+10 -17
View File
@@ -70,6 +70,7 @@ import {
effectiveWorkspaceOrder, effectiveWorkspaceOrder,
errorMessage, errorMessage,
latestRootSession, latestRootSession,
projectDirectories,
sortedRootSessions, sortedRootSessions,
} from "./layout/helpers" } from "./layout/helpers"
import { import {
@@ -560,8 +561,8 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list() const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key)) const worktree = projects.find((p) => p.worktrees?.some((item) => pathKey(item) === key))
if (sandbox) return sandbox if (worktree) return worktree
const direct = projects.find((p) => pathKey(p.worktree) === key) const direct = projects.find((p) => pathKey(p.worktree) === key)
if (direct) return direct if (direct) return direct
@@ -646,7 +647,7 @@ export default function Layout(props: ParentProps) {
if (!expanded) continue if (!expanded) continue
const key = pathKey(directory) const key = pathKey(directory)
const project = projects.find( const project = projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), (item) => pathKey(item.worktree) === key || item.worktrees?.some((worktree) => pathKey(worktree) === key),
) )
if (!project) continue if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
@@ -1223,7 +1224,7 @@ export default function Layout(props: ParentProps) {
const key = pathKey(directory) const key = pathKey(directory)
const project = layout.projects const project = layout.projects
.list() .list()
.find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key)) .find((item) => pathKey(item.worktree) === key || item.worktrees?.some((worktree) => pathKey(worktree) === key))
if (project) return project.worktree if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find( const known = Object.entries(store.workspaceOrder).find(
@@ -1274,9 +1275,7 @@ export default function Layout(props: ParentProps) {
const root = projectRoot(directory) const root = projectRoot(directory)
server.projects.touch(root) server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root) const project = layout.projects.list().find((item) => item.worktree === root)
let dirs = project let dirs = project ? effectiveWorkspaceOrder(root, projectDirectories(project), store.workspaceOrder[root]) : [root]
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root]
const canOpen = (value: string | undefined) => { const canOpen = (value: string | undefined) => {
if (!value) return false if (!value) return false
return dirs.some((item) => pathKey(item) === pathKey(value)) return dirs.some((item) => pathKey(item) === pathKey(value))
@@ -1509,14 +1508,7 @@ export default function Layout(props: ParentProps) {
clearLastProjectSession(root) clearLastProjectSession(root)
} }
globalSync.set( globalSync.project.removeWorktree(root, directory)
"project",
produce((draft) => {
const project = draft.find((item) => item.worktree === root)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory)
}),
)
setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory)) setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory))
layout.projects.close(directory) layout.projects.close(directory)
@@ -1528,7 +1520,7 @@ export default function Layout(props: ParentProps) {
const nextKey = pathKey(nextCurrent) const nextKey = pathKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root) const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) ? effectiveWorkspaceOrder(root, projectDirectories(project), store.workspaceOrder[root])
: [root] : [root]
const valid = dirs.some((item) => pathKey(item) === nextKey) const valid = dirs.some((item) => pathKey(item) === nextKey)
@@ -1862,7 +1854,7 @@ export default function Layout(props: ParentProps) {
function workspaceIds(project: LocalProject | undefined) { function workspaceIds(project: LocalProject | undefined) {
if (!project) return [] if (!project) return []
const local = project.worktree const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])] const dirs = projectDirectories(project)
const active = currentProject() const active = currentProject()
const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined
const extra = const extra =
@@ -1954,6 +1946,7 @@ export default function Layout(props: ParentProps) {
}) })
return [created.directory, ...next] return [created.directory, ...next]
}) })
globalSync.project.addWorktree(project.worktree, created.directory)
globalSync.child(created.directory) globalSync.child(created.directory)
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`) navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
+5
View File
@@ -55,6 +55,11 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) project.name || getFilename(project.worktree)
export const projectDirectories = (project: { worktree: string; worktrees?: string[] }) => [
project.worktree,
...(project.worktrees ?? []),
]
export const errorMessage = (err: unknown, fallback: string) => { export const errorMessage = (err: unknown, fallback: string) => {
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data const data = (err as { data?: { message?: string } }).data
@@ -15,7 +15,7 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title" import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, hasProjectPermissions } from "./helpers" import { childSessionOnPath, hasProjectPermissions, projectDirectories } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -35,7 +35,7 @@ export const ProjectIcon = (props: {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const notification = useNotification() const notification = useNotification()
const permission = usePermission() const permission = usePermission()
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])]) const dirs = createMemo(() => projectDirectories(props.project))
const unseenCount = createMemo(() => const unseenCount = createMemo(() =>
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
) )
@@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
canReview: () => boolean canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[] diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
@@ -70,7 +76,8 @@ export function SessionSidePanel(props: {
}) })
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) const diffs = createMemo(() => props.diffs().filter(renderDiff))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => { const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b if (!a) return b
@@ -81,7 +88,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">() const out = new Map<string, "add" | "del" | "mix">()
for (const diff of props.diffs()) { for (const diff of diffs()) {
const file = normalize(diff.file) const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.41", "version": "1.14.48",
"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;
} }
@@ -53,7 +53,7 @@ export function UsageSection() {
} }
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => { const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.outputTokens + (u.reasoningTokens ?? 0) return u.outputTokens
} }
const goPrev = async () => { const goPrev = async () => {
@@ -889,10 +889,6 @@ export async function handler(
const inputCost = modelCost.input * inputTokens * 100 const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100 const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => { const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined if (!modelCost.cacheRead) return undefined
@@ -909,17 +905,11 @@ export async function handler(
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100 return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})() })()
const totalCostInCent = const totalCostInCent =
inputCost + inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
return { return {
totalCostInCent, totalCostInCent,
inputCost, inputCost,
outputCost, outputCost,
reasoningCost,
cacheReadCost, cacheReadCost,
cacheWrite5mCost, cacheWrite5mCost,
cacheWrite1hCost, cacheWrite1hCost,
@@ -941,8 +931,7 @@ export async function handler(
) { ) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
usageInfo usageInfo
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
costInfo
logger.metric({ logger.metric({
"tokens.input": inputTokens, "tokens.input": inputTokens,
@@ -953,14 +942,12 @@ export async function handler(
"tokens.cache_write_1h": cacheWrite1hTokens, "tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input.microcents": centsToMicroCents(inputCost), "cost.input.microcents": centsToMicroCents(inputCost),
"cost.output.microcents": centsToMicroCents(outputCost), "cost.output.microcents": centsToMicroCents(outputCost),
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
"cost.total.microcents": centsToMicroCents(totalCostInCent), "cost.total.microcents": centsToMicroCents(totalCostInCent),
// deprecated - remove after May 20, 2026 // deprecated - remove after May 20, 2026
"cost.input": Math.round(inputCost), "cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost), "cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined, "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined, "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined, "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
return { return {
inputTokens: inputTokens - (cacheReadTokens ?? 0), inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens: outputTokens - (reasoningTokens ?? 0), outputTokens,
reasoningTokens, reasoningTokens,
cacheReadTokens, cacheReadTokens,
cacheWrite5mTokens: undefined, cacheWrite5mTokens: undefined,
+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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"$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.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -36,7 +36,7 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
* mapped `.omit()` / `.extend()` surface triggers brand-intersection * mapped `.omit()` / `.extend()` surface triggers brand-intersection
* explosions for branded primitives (`string & Brand<"SessionID">` extends * explosions for branded primitives (`string & Brand<"SessionID">` extends
* `object` via the brand and gets walked into the prototype by `DeepPartial`, * `object` via the brand and gets walked into the prototype by `DeepPartial`,
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>` * mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the * wrappers also can't reconstruct `T` cleanly. Consumers that care about the
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
*/ */
+9 -11
View File
@@ -11,9 +11,12 @@ function falsy(key: string) {
return value === "false" || value === "0" return value === "false" || value === "0"
} }
// Channels that default to the new effect-httpapi server backend. The legacy // Channels where new experiments default to ON (unstable / internal users).
// hono backend remains the default for stable (`prod`/`latest`) installs. // Stable channels (`prod`, `latest`) stay opt-in.
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) 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]
@@ -53,6 +56,9 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_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"],
@@ -88,14 +94,6 @@ export const Flag = {
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
// Defaults to true on dev/beta/local channels so internal users exercise the
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
// on the legacy hono backend until the rollout is complete. An explicit env
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
// stable users and an escape hatch for dev/beta users.
OPENCODE_EXPERIMENTAL_HTTPAPI:
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.14.41", "version": "1.14.48",
"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.41", "version": "1.14.48",
"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.41" version = "1.14.48"
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.41/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/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.41/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/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.41/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/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.41/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/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.41/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/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.41", "version": "1.14.48",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+209
View File
@@ -0,0 +1,209 @@
# @opencode-ai/http-recorder
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
exercise real request shapes against deterministic, version-controlled
cassettes — no manual mocks, no flakes from upstream drift.
## Install
Internal package; depended on as `@opencode-ai/http-recorder` from another
workspace package.
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
```
## Quickstart
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
By default the layer records on first run and replays on subsequent runs —
no env-var ternary at the call site, and `CI=true` forces strict replay so
missing cassettes fail loudly in CI rather than silently re-recording.
```ts
import { Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { HttpRecorder } from "@opencode-ai/http-recorder"
const program = Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
return yield* response.json
})
// Records if the cassette is missing, replays if it exists.
// In CI (CI=true) always replays — fails loudly on missing fixtures.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
// Force a refresh — always hits upstream and overwrites.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
```
## Modes
| Mode | Behavior |
| ------------- | ----------------------------------------------------------------------------------- |
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
| `replay` | Strict — match the request to a recorded interaction; error if none. |
| `record` | Execute upstream, append the interaction, write the cassette. |
| `passthrough` | Bypass the recorder entirely — just call upstream. |
## Cassette format
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
```json
{
"version": 1,
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
"interactions": [
{
"transport": "http",
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
"response": { "status": 200, "headers": {...}, "body": "..." }
}
]
}
```
Cassettes are normal source files — review them, diff them, commit them.
## Request matching
Replay walks the cassette in record order via an internal cursor: the Nth
request executed at runtime is served by the Nth recorded interaction, and
each one is validated as the cursor advances. Request equality is computed
on canonicalized method, URL, headers, and JSON body (object keys sorted).
This is deliberately strict — content-based dispatch was removed because
it silently returns the first recorded response for repeated identical
requests, masking state changes that retry/polling/cache-hit tests need to
observe. If you reorder requests in a test, re-record the cassette.
Supply your own matcher via `match: (incoming, recorded) => boolean` for
custom equivalence (e.g. ignoring a timestamp field in the body).
## Redaction & secret safety
Cassettes get checked in, so the recorder is aggressive about not letting
secrets escape. Redaction is configured by composing a `Redactor`:
```ts
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
HttpRecorder.cassetteLayer("anthropic/messages", {
redactor: Redactor.defaults({
requestHeaders: { allow: ["content-type", "anthropic-version"] },
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
}),
})
```
`Redactor.defaults({ … })` composes the four built-in redactors with your
overrides. For full control, build the stack yourself:
```ts
const redactor = Redactor.compose(
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
Redactor.responseHeaders(),
Redactor.url({ query: ["session-id"] }),
Redactor.body((parsed) => /* … */),
)
```
What each layer does:
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
allow-list (request default: `content-type`, `accept`, `openai-beta`;
response default: `content-type`). Sensitive headers within the
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
…) are replaced with `[REDACTED]`.
- **`url`** — query parameters matching common secret names (`api_key`,
`token`, `signature`, AWS signing params, …) are replaced with
`[REDACTED]`. URL user/password are replaced. `transform` runs after
built-in redaction for path-level scrubbing.
- **`body`** — receives the parsed JSON request body and returns a redacted
version. No-op for non-JSON bodies.
After assembling the cassette, the recorder scans every string for known
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
environment variable named like a credential. If anything is found, the
cassette is **not written** and the request fails with `UnsafeCassetteError`
listing what was detected.
## WebSocket recording
WebSocket support records the open frame plus client/server message
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
can live in the same cassette.
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "ws/subscribe",
cassette,
live: liveExecutor,
})
// use executor.open(...)
})
```
## Inspecting cassettes programmatically
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
returns the recorded interactions for a name; the file format is hidden
behind the seam. Useful for CI checks:
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Effect } from "effect"
const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service
const entries = yield* cassettes.list()
const issues = yield* Effect.forEach(entries, (entry) =>
cassettes
.read(entry.name)
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
)
return issues.filter((i) => i.findings.length > 0)
})
```
`cassetteLayer` is the batteries-included entry point — it provides
`Cassette.fileSystem({ directory })` automatically. If you want to provide
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
`Cassette.memory` yourself.
## Options reference
```ts
type RecordReplayOptions = {
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
directory?: string // default: <cwd>/test/fixtures/recordings
metadata?: Record<string, unknown> // merged into cassette.metadata
redactor?: Redactor // default: Redactor.defaults()
match?: (incoming, recorded) => boolean // custom matcher
}
```
## Layout
| File | Purpose |
| -------------- | -------------------------------------------------------------------------------- |
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0", "version": "1.14.48",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+120 -78
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),
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
),
append: (name, interaction, metadata) =>
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
if (!recorded.has(name)) recorded.set(name, entry)
entry.interactions.push(interaction)
entry.findings.push(...secretFindings(interaction))
const cassette = buildCassette(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
if (findings.length === 0) {
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}
return { findings }
}),
exists: (name) =>
fs.access(cassettePath(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
list: () =>
walk(directory).pipe(
Effect.map((files) =>
files
.filter((file) => file.endsWith(".json"))
.map((file) =>
path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
)
.toSorted((a, b) => a.localeCompare(b)),
),
),
}) })
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
const append = Effect.fn("Cassette.append")(function* (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) {
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
)
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
const findings = cassetteSecretFindings(cassette)
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
const list = Effect.fn("Cassette.list")(function* () {
return (yield* walk(directory))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
name: path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
path: file,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
})
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}), }),
) )
export const defaultLayer = layer() export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
export * as Cassette from "./cassette" return Service.of({
read: (name) =>
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) =>
Effect.sync(() => {
const existing = stored.get(name)
if (existing) existing.push(interaction)
else stored.set(name, [interaction])
const findings = accumulatedFindings.get(name)
if (findings) findings.push(...secretFindings(interaction))
else accumulatedFindings.set(name, [...secretFindings(interaction)])
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
return { findings: accumulatedFindings.get(name) ?? [] }
}),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
})
-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,
)
+57 -128
View File
@@ -1,62 +1,36 @@
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, 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 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 +42,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 +66,21 @@ 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 sequential = options.dispatch === "sequential" const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
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 +88,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 +111,21 @@ 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 = selectSequential(interactions, incoming, match, yield* replay.cursor)
if (!result.interaction)
return yield* Effect.fail(
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
)
yield* replay.advance
return HttpClientResponse.fromWeb( return HttpClientResponse.fromWeb(
request, request,
new Response(decodeResponseBody(interaction.response), interaction.response), new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
) )
}) })
}) })
@@ -205,7 +134,7 @@ export const recordingLayer = (
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> => 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 "."
+71 -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,72 @@ 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 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")))
}
+52 -97
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 {}
+105 -99
View File
@@ -6,7 +6,16 @@ import * as fs from "node:fs"
import * as os from "node:os" import * as os from "node:os"
import * as path from "node:path" import * as path from "node:path"
import { HttpRecorder } from "../src" import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/diff" import { redactedErrorRequest } from "../src/effect"
import type { Interaction } from "../src/schema"
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
Effect.runPromise(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
)
const post = (url: string, body: object) => const post = (url: string, body: object) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -33,7 +42,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
Effect.scoped( Effect.scoped(
effect.pipe( effect.pipe(
Effect.provide( Effect.provide(
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }), HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
), ),
Effect.provide(NodeFileSystem.layer), Effect.provide(NodeFileSystem.layer),
), ),
@@ -108,7 +117,7 @@ describe("http-recorder", () => {
test("detects secret-looking values without returning the secret", () => { test("detects secret-looking values without returning the secret", () => {
expect( expect(
HttpRecorder.cassetteSecretFindings({ HttpRecorder.secretFindings({
version: 1, version: 1,
interactions: [ interactions: [
{ {
@@ -136,7 +145,7 @@ describe("http-recorder", () => {
test("detects secret-looking values inside metadata", () => { test("detects secret-looking values inside metadata", () => {
expect( expect(
HttpRecorder.cassetteSecretFindings({ HttpRecorder.secretFindings({
version: 1, version: 1,
metadata: { token: "sk-123456789012345678901234" }, metadata: { token: "sk-123456789012345678901234" },
interactions: [], interactions: [],
@@ -144,60 +153,42 @@ describe("http-recorder", () => {
).toEqual([{ path: "metadata.token", reason: "API key" }]) ).toEqual([{ path: "metadata.token", reason: "API key" }])
}) })
test("formats websocket cassettes with shared metadata", () => { test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
const cassette = HttpRecorder.cassetteFor( await Effect.runPromise(
"websocket/basic", Effect.scoped(
[ Effect.gen(function* () {
{ const cassette = yield* HttpRecorder.Cassette.Service
transport: "websocket", const executor = yield* HttpRecorder.makeWebSocketExecutor({
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, name: "websocket/replay",
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], cassette,
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], compareClientMessagesAsJson: true,
}, live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
], })
{ provider: "openai" }, const connection = yield* executor.open({
) url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" }) expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette) }).pipe(
}) Effect.provide(
HttpRecorder.Cassette.memory({
test("replays websocket interactions from the shared cassette service", async () => { "websocket/replay": [
await runRecorder( {
Effect.gen(function* () { transport: "websocket",
const cassette = yield* HttpRecorder.Cassette.Service open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
yield* cassette.write( client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
"websocket/replay", server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
HttpRecorder.cassetteFor( },
"websocket/replay", ],
[ }),
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
), ),
) ),
const executor = yield* HttpRecorder.makeWebSocketExecutor({ ),
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}),
) )
}) })
@@ -227,34 +218,22 @@ describe("http-recorder", () => {
yield* connection.messages.pipe(Stream.runDrain) yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject({ expect(yield* cassette.read("websocket/record")).toMatchObject([
metadata: { name: "websocket/record", provider: "test" }, {
interactions: [ transport: "websocket",
{ open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
transport: "websocket", client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], },
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], ])
},
],
})
}), }),
) )
}) })
test("default matcher dispatches multi-interaction cassettes by request shape", async () => { test("replay returns recorded responses in order for identical requests", async () => {
await run(
Effect.gen(function* () {
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
}),
)
})
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
await runWith( await runWith(
"record-replay/retry", "record-replay/retry",
{ dispatch: "sequential" }, {},
Effect.gen(function* () { Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
@@ -262,21 +241,8 @@ describe("http-recorder", () => {
) )
}) })
test("default matcher returns the first match for identical requests", async () => { test("replay reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith( await run(
"record-replay/retry",
{},
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
}),
)
})
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () { Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 }) yield* post("https://example.test/echo", { step: 1 })
yield* post("https://example.test/echo", { step: 2 }) yield* post("https://example.test/echo", { step: 2 })
@@ -286,10 +252,8 @@ describe("http-recorder", () => {
) )
}) })
test("sequential dispatch still validates each recorded request", async () => { test("replay validates each recorded request in order", async () => {
await runWith( await run(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () { Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 }) yield* post("https://example.test/echo", { step: 1 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
@@ -300,14 +264,56 @@ describe("http-recorder", () => {
) )
}) })
test("mismatch diagnostics show closest redacted request differences", async () => { test("auto mode replays when the cassette exists", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
await seedCassetteDirectory(directory, "auto-replay", [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/echo",
headers: { "content-type": "application/json" },
body: JSON.stringify({ step: 1 }),
},
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
},
])
const result = await runWith(
"auto-replay",
{ directory, mode: "auto" },
post("https://example.test/echo", { step: 1 }),
)
expect(result).toBe('{"reply":"hi"}')
})
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
const previous = process.env.CI
process.env.CI = "true"
try {
const exit = await Effect.runPromise(
Effect.exit(
post("https://example.test/echo", { step: 1 }).pipe(
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
} finally {
if (previous === undefined) delete process.env.CI
else process.env.CI = previous
}
})
test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Effect.exit( const exit = yield* Effect.exit(
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
) )
const message = failureText(exit) const message = failureText(exit)
expect(message).toContain("closest interaction: #1")
expect(message).toContain("url:") expect(message).toContain("url:")
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
expect(message).toContain("body:") expect(message).toContain("body:")
+1 -1
View File
@@ -289,6 +289,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re
**Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text. **Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text.
**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. **Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed.
+129
View File
@@ -0,0 +1,129 @@
# @opencode-ai/llm
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
import { LLM, LLMClient } from "@opencode-ai/llm"
import { OpenAI } from "@opencode-ai/llm/providers"
const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY })
const request = LLM.request({
model,
system: "You are concise.",
prompt: "Say hello in one short sentence.",
generation: { maxTokens: 40 },
})
const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
console.log(response.text)
})
```
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors.
- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams.
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
### Auto placement
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
### Opting out
```ts
LLM.request({
model,
system,
prompt: "one-off question",
cache: "none",
})
```
### Granular policy
```ts
cache: {
tools?: boolean,
system?: boolean,
messages?: "latest-user-message" | "latest-assistant" | { tail: number },
ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m
}
```
### Manual hints
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
```ts
LLM.request({
model,
system: [
{ type: "text", text: "stable system prompt", cache: { type: "ephemeral" } },
],
...
})
```
### Provider behavior table
| Protocol | `cache: "auto"` |
| ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider.
## Providers
Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults.
```ts
import { Anthropic } from "@opencode-ai/llm/providers"
const model = Anthropic.model("claude-sonnet-4-6", {
apiKey: process.env.ANTHROPIC_API_KEY,
})
```
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
## Provider options & HTTP overlays
Three escape hatches in order of stability:
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Model-level defaults are overridden by request-level values for each axis.
## Routes
Adding a new model or deployment is usually 515 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail.
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
- `AGENTS.md` — architecture, route construction, contributor guide
- `example/tutorial.ts` — runnable end-to-end walkthrough
- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes
+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.25", "version": "1.14.48",
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+111
View File
@@ -0,0 +1,111 @@
// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts
// the policy designates. Runs once at compile time, before the per-protocol
// body builder, so the existing inline-hint lowering path handles the rest.
//
// The default `"auto"` shape places one breakpoint at the last tool definition,
// one at the last system part, and one at the latest user message. This
// matches what production agent harnesses (LangChain's caching middleware,
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
// latest user message stays put while a single turn explodes into many
// assistant/tool round-trips, so caching at that boundary lets every
// intra-turn API call hit the prefix.
//
// Manual `cache: CacheHint` placements on individual parts are preserved —
// this function only fills gaps the caller left empty.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
const AUTO: CachePolicyObject = {
tools: true,
system: true,
messages: "latest-user-message",
}
const NONE: CachePolicyObject = {}
// Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it:
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// so a single reuse within 5 minutes already wins.
// - "auto" → tools + system + latest user msg.
// - "none" → no auto placement; manual `CacheHint`s still flow.
// - object form → exactly what the caller asked for.
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
if (policy === undefined || policy === "auto") return AUTO
if (policy === "none") return NONE
return policy
}
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache) return tools
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
}
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
if (system.length === 0) return system
const last = system.length - 1
if (system[last]!.cache) return system
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
}
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
messages.findLastIndex((m) => m.role === role)
// Mark the last text part of `messages[index]`. If no text part exists, mark
// the last content part regardless of type — that's the breakpoint position
// in tool-result-only messages too.
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
if (index < 0 || index >= messages.length) return messages
const target = messages[index]!
if (target.content.length === 0) return messages
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
const existing = target.content[markAt]!
if ("cache" in existing && existing.cache) return messages
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
const next = new Message({ ...target, content: nextContent })
// Single pass over `messages`, substituting the one updated entry. Long
// conversations call this on every request, so avoid `.map()` here — its
// closure dispatch and identity copies show up in profiling.
const result = messages.slice()
result[index] = next
return result
}
const markMessages = (
messages: ReadonlyArray<Message>,
strategy: NonNullable<CachePolicyObject["messages"]>,
hint: CacheHint,
): ReadonlyArray<Message> => {
if (messages.length === 0) return messages
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
return next
}
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
const system = policy.system ? markLastSystem(request.system, hint) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages })
}
+114 -47
View File
@@ -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,
@@ -16,6 +16,7 @@ import {
type ToolResultPart, type ToolResultPart,
} from "../schema" } from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import * as Cache from "./utils/cache"
import { ToolStream } from "./utils/tool-stream" import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages" const ADAPTER = "anthropic-messages"
@@ -25,7 +26,10 @@ export const PATH = "/messages"
// ============================================================================= // =============================================================================
// Request Body Schema // Request Body Schema
// ============================================================================= // =============================================================================
const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") }) const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicTextBlock = Schema.Struct({ const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"), type: Schema.tag("text"),
@@ -193,8 +197,24 @@ const invalid = ProviderShared.invalidRequest
// ============================================================================= // =============================================================================
// Request Lowering // Request Lowering
// ============================================================================= // =============================================================================
const cacheControl = (cache: CacheHint | undefined) => // Anthropic accepts at most 4 explicit cache_control breakpoints per request,
cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined // across `tools`, `system`, and `messages`. Beyond the cap the API returns a
// 400 — so the lowering layer counts emitted markers and silently drops any
// that exceed it.
const ANTHROPIC_BREAKPOINT_CAP = 4
const EPHEMERAL_5M = { type: "ephemeral" as const }
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata }) const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
@@ -204,10 +224,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined return typeof anthropic.signature === "string" ? anthropic.signature : undefined
} }
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
input_schema: tool.inputSchema, input_schema: tool.inputSchema,
cache_control: cacheControl(breakpoints, tool.cache),
}) })
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) => const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -249,7 +270,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
}) })
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) { const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
) {
const messages: AnthropicMessage[] = [] const messages: AnthropicMessage[] = []
for (const message of request.messages) { for (const message of request.messages) {
@@ -258,7 +282,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
for (const part of message.content) { for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text"])) if (!ProviderShared.supportsContent(part, ["text"]))
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"]) return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"])
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
} }
messages.push({ role: "user", content }) messages.push({ role: "user", content })
continue continue
@@ -268,7 +292,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
const content: AnthropicAssistantBlock[] = [] const content: AnthropicAssistantBlock[] = []
for (const part of message.content) { for (const part of message.content) {
if (part.type === "text") { if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue continue
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
@@ -304,6 +328,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
tool_use_id: part.id, tool_use_id: part.id,
content: ProviderShared.toolResultText(part), content: ProviderShared.toolResultText(part),
is_error: part.result.type === "error" ? true : undefined, is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
}) })
} }
messages.push({ role: "user", content }) messages.push({ role: "user", content })
@@ -330,18 +355,33 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation const generation = request.generation
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
? undefined
: request.tools.map((tool) => lowerTool(breakpoints, tool))
const system =
request.system.length === 0
? undefined
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
}))
const messages = yield* lowerMessages(request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
)
}
return { return {
model: request.model.id, model: request.model.id,
system: system,
request.system.length === 0 messages,
? undefined tools,
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
cache_control: cacheControl(part.cache),
})),
messages: yield* lowerMessages(request),
tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool),
tool_choice: toolChoice, tool_choice: toolChoice,
stream: true as const, stream: true as const,
max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096, max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096,
@@ -364,34 +404,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 +477,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 +514,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 +540,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 +592,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) => {
+75 -26
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,
@@ -108,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type<typeof BedrockMessage>
const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock]) const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock])
type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock> type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
const BedrockTool = Schema.Struct({ const BedrockToolSpec = Schema.Struct({
toolSpec: Schema.Struct({ toolSpec: Schema.Struct({
name: Schema.String, name: Schema.String,
description: Schema.String, description: Schema.String,
@@ -117,6 +117,9 @@ const BedrockTool = Schema.Struct({
}), }),
}), }),
}) })
type BedrockToolSpec = Schema.Schema.Type<typeof BedrockToolSpec>
const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock])
type BedrockTool = Schema.Schema.Type<typeof BedrockTool> type BedrockTool = Schema.Schema.Type<typeof BedrockTool>
const BedrockToolChoice = Schema.Union([ const BedrockToolChoice = Schema.Union([
@@ -214,7 +217,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
// ============================================================================= // =============================================================================
// Request Lowering // Request Lowering
// ============================================================================= // =============================================================================
const lowerTool = (tool: ToolDefinition): BedrockTool => ({ const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
toolSpec: { toolSpec: {
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
@@ -222,11 +225,22 @@ const lowerTool = (tool: ToolDefinition): BedrockTool => ({
}, },
}) })
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
const result: BedrockTool[] = []
for (const tool of tools) {
result.push(lowerToolSpec(tool))
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
if (cachePoint) result.push(cachePoint)
}
return result
}
const textWithCache = ( const textWithCache = (
breakpoints: BedrockCache.Breakpoints,
text: string, text: string,
cache: CacheHint | undefined, cache: CacheHint | undefined,
): Array<BedrockTextBlock | BedrockCache.CachePointBlock> => { ): Array<BedrockTextBlock | BedrockCache.CachePointBlock> => {
const cachePoint = BedrockCache.block(cache) const cachePoint = BedrockCache.block(breakpoints, cache)
return cachePoint ? [{ text }, cachePoint] : [{ text }] return cachePoint ? [{ text }, cachePoint] : [{ text }]
} }
@@ -257,7 +271,10 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({
}, },
}) })
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) { const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
request: LLMRequest,
breakpoints: BedrockCache.Breakpoints,
) {
const messages: BedrockMessage[] = [] const messages: BedrockMessage[] = []
for (const message of request.messages) { for (const message of request.messages) {
@@ -267,7 +284,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
if (!ProviderShared.supportsContent(part, ["text", "media"])) if (!ProviderShared.supportsContent(part, ["text", "media"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"]) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
if (part.type === "text") { if (part.type === "text") {
content.push(...textWithCache(part.text, part.cache)) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue continue
} }
if (part.type === "media") { if (part.type === "media") {
@@ -289,7 +306,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
"tool-call", "tool-call",
]) ])
if (part.type === "text") { if (part.type === "text") {
content.push(...textWithCache(part.text, part.cache)) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue continue
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
@@ -309,11 +326,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
continue continue
} }
const content: BedrockToolResultBlock[] = [] const content: BedrockUserBlock[] = []
for (const part of message.content) { for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"])) if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(lowerToolResult(part)) content.push(lowerToolResult(part))
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
} }
messages.push({ role: "user", content }) messages.push({ role: "user", content })
} }
@@ -323,16 +342,32 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
// System prompts share the cache-point convention: emit the text block, then // System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker. // optionally a positional `cachePoint` marker.
const lowerSystem = (system: ReadonlyArray<LLMRequest["system"][number]>): BedrockSystemBlock[] => const lowerSystem = (
system.flatMap((part) => textWithCache(part.text, part.cache)) breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig =
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: lowerTools(breakpoints, request.tools), toolChoice }
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
)
}
return { return {
modelId: request.model.id, modelId: request.model.id,
messages: yield* lowerMessages(request), messages,
system: request.system.length === 0 ? undefined : lowerSystem(request.system), system,
inferenceConfig: inferenceConfig:
generation?.maxTokens === undefined && generation?.maxTokens === undefined &&
generation?.temperature === undefined && generation?.temperature === undefined &&
@@ -345,10 +380,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
topP: generation?.topP, topP: generation?.topP,
stopSequences: generation?.stop, stopSequences: generation?.stop,
}, },
toolConfig: toolConfig,
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: request.tools.map(lowerTool), toolChoice }
: undefined,
} }
}) })
@@ -363,15 +395,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 +439,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 +501,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 +517,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.
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)
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
// inclusive `outputTokens` the contract expects. Only compute the total
// when the visible component is reported — otherwise we'd fabricate an
// inclusive number from a partial breakdown.
const outputTokens =
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
return new Usage({ 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>) : []),
]
} }
// ============================================================================= // =============================================================================
+26 -32
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) => {
+36
View File
@@ -42,6 +42,13 @@ 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 additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
* are the non-cached input and visible output only. The provider-supplied
* `total` is the source of truth when present; the computed fallback
* under-counts cache and reasoning by design and exists mainly so
* Anthropic-style providers (which don't surface a total) still get a
* sensible aggregate on the input + output axes.
*/ */
export const totalTokens = ( export const totalTokens = (
inputTokens: number | undefined, inputTokens: number | undefined,
@@ -53,6 +60,35 @@ 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.native` for debugging.
*/
export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
if (total === undefined) return undefined
if (subtrahend === undefined) return total
return Math.max(0, total - subtrahend)
}
/**
* Sum a list of optional token counts, returning `undefined` only when
* every value is `undefined` (so we don't fabricate a `0`). Used by
* protocol mappers to derive the inclusive `inputTokens` total from a
* provider that natively reports a non-overlapping breakdown
* (e.g. Anthropic, whose `input_tokens` is already non-cached only).
*/
export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
if (values.every((value) => value === undefined)) return undefined
return values.reduce<number>((acc, value) => acc + (value ?? 0), 0)
}
export const eventError = (route: string, message: string, raw?: string) => export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({ new LLMError({
module: "ProviderShared", module: "ProviderShared",
@@ -1,20 +1,37 @@
import { Schema } from "effect" import { Schema } from "effect"
import type { CacheHint } from "../../schema" import type { CacheHint } from "../../schema"
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately // Bedrock cache markers are positional: emit a `cachePoint` block immediately
// after the content the caller wants treated as a cacheable prefix. // after the content the caller wants treated as a cacheable prefix. Bedrock
// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic.
export const CachePointBlock = Schema.Struct({ export const CachePointBlock = Schema.Struct({
cachePoint: Schema.Struct({ type: Schema.tag("default") }), cachePoint: Schema.Struct({
type: Schema.tag("default"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
}),
}) })
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock> export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map // Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages
// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape. // API. Callers pass a shared counter through every `block()` call site so the
const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } } // budget is respected across `system`, `messages`, and `tools`.
export const BEDROCK_BREAKPOINT_CAP = 4
export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => { export type { Breakpoints } from "./cache"
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
return DEFAULT if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
} }
export * as BedrockCache from "./bedrock-cache" export * as BedrockCache from "./bedrock-cache"
+16
View File
@@ -0,0 +1,16 @@
// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock
// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h`
// TTL buckets, so the counter and TTL mapping live here.
export interface Breakpoints {
remaining: number
dropped: number
}
export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 })
// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the
// provider default 5m). Anthropic & Bedrock both treat anything shorter than
// an hour as 5m.
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
+14 -24
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared" import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number type StreamKey = string | number
@@ -49,34 +49,24 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
return next return next
} }
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({ const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
type: "tool-input-delta", LLMEvent.toolInputDelta({
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
text, text,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), })
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
Effect.map( Effect.map(
(input): ToolCall => (input): ToolCall =>
tool.providerExecuted LLMEvent.toolCall({
? { id: tool.id,
type: "tool-call", name: tool.name,
id: tool.id, input,
name: tool.name, providerExecuted: tool.providerExecuted ? true : undefined,
input, providerMetadata: tool.providerMetadata,
providerExecuted: true, }),
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
}
: {
type: "tool-call",
id: tool.id,
name: tool.name,
input,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
},
), ),
) )
+2 -1
View File
@@ -8,6 +8,7 @@ import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport" import { WebSocketExecutor } from "./transport"
import type { Service as WebSocketExecutorService } from "./transport/websocket" import type { Service as WebSocketExecutorService } from "./transport/websocket"
import type { Protocol } from "./protocol" import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared" import * as ProviderShared from "../protocols/shared"
import * as ToolRuntime from "../tool-runtime" import * as ToolRuntime from "../tool-runtime"
import type { Tools } from "../tool" import type { Tools } from "../tool"
@@ -400,7 +401,7 @@ export function make<Body, Prepared, Frame, Event, State>(
// validated provider body plus transport-private prepared data, but does not // validated provider body plus transport-private prepared data, but does not
// execute transport. // execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = resolveRequestOptions(request) const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = registeredRoute(resolved.model.route) const route = registeredRoute(resolved.model.route)
if (!route) return yield* noRoute(resolved.model) if (!route) return yield* noRoute(resolved.model)
+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>
+6 -1
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) typeof value === "object" && value !== null && !Array.isArray(value)
@@ -79,6 +79,7 @@ export const ToolResultPart = Object.assign(
name: Schema.String, name: Schema.String,
result: ToolResultValue, result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolResult" }), }).annotate({ identifier: "LLM.Content.ToolResult" }),
@@ -94,6 +95,7 @@ export const ToolResultPart = Object.assign(
name: input.name, name: input.name,
result: ToolResultValue.make(input.result, input.resultType), result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted, providerExecuted: input.providerExecuted,
cache: input.cache,
metadata: input.metadata, metadata: input.metadata,
providerMetadata: input.providerMetadata, providerMetadata: input.providerMetadata,
}), }),
@@ -151,6 +153,7 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
name: Schema.String, name: Schema.String,
description: Schema.String, description: Schema.String,
inputSchema: JsonSchema, inputSchema: JsonSchema,
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {} }) {}
@@ -203,6 +206,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
responseFormat: Schema.optional(ResponseFormat), responseFormat: Schema.optional(ResponseFormat),
cache: Schema.optional(CachePolicy),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {} }) {}
@@ -220,6 +224,7 @@ export namespace LLMRequest {
providerOptions: request.providerOptions, providerOptions: request.providerOptions,
http: request.http, http: request.http,
responseFormat: request.responseFormat, responseFormat: request.responseFormat,
cache: request.cache,
metadata: request.metadata, metadata: request.metadata,
}) })
+28
View File
@@ -200,3 +200,31 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]), type: Schema.Literals(["ephemeral", "persistent"]),
ttlSeconds: Schema.optional(Schema.Number), ttlSeconds: Schema.optional(Schema.Number),
}) {} }) {}
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places one
// breakpoint at the last tool definition, one at the last system part, and one
// at the latest user message. The combination of provider invalidation
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
// lookback means three trailing breakpoints reliably cover the static prefix.
//
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
// object form to override individual choices.
export const CachePolicyObject = Schema.Struct({
tools: Schema.optional(Schema.Boolean),
system: Schema.optional(Schema.Boolean),
messages: Schema.optional(
Schema.Union([
Schema.Literal("latest-user-message"),
Schema.Literal("latest-assistant"),
Schema.Struct({ tail: Schema.Number }),
]),
),
ttlSeconds: Schema.optional(Schema.Number),
})
export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>
+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",
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../src"
import { LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as BedrockConverse from "../src/protocols/bedrock-converse"
import * as Gemini from "../src/protocols/gemini"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { applyCachePolicy } from "../src/cache-policy"
import { it } from "./lib/effect"
const anthropicModel = AnthropicMessages.model({
id: "claude-sonnet-4-5",
baseURL: "https://api.anthropic.test/v1/",
headers: { "x-api-key": "test" },
})
const bedrockModel = BedrockConverse.model({
id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
})
const openaiModel = OpenAIChat.model({
id: "gpt-4o-mini",
baseURL: "https://api.openai.test/v1/",
headers: { authorization: "Bearer test" },
})
const geminiModel = Gemini.model({
id: "gemini-2.5-flash",
baseURL: "https://generativelanguage.test/v1beta/",
headers: { "x-goog-api-key": "test" },
})
describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "You are concise.",
prompt: "hi",
}),
)
// No explicit cache field → auto policy fires → last system part + latest
// user message both get cache_control markers.
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys A",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")],
cache: "auto",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
messages: [
{ role: "user", content: [{ type: "text", text: "first user" }] },
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
{
role: "user",
content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: openaiModel,
system: "Sys",
prompt: "hi",
cache: "auto",
}),
)
const body = prepared.body as { messages: Array<{ content: unknown }> }
// OpenAI doesn't accept cache_control on messages — policy must skip.
const flat = JSON.stringify(body)
expect(flat).not.toContain("cache_control")
expect(flat).not.toContain("cachePoint")
}),
)
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: geminiModel,
system: "Sys",
prompt: "hi",
cache: "auto",
}),
)
const flat = JSON.stringify(prepared.body)
expect(flat).not.toContain("cache_control")
expect(flat).not.toContain("cachePoint")
}),
)
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: bedrockModel,
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")],
cache: "auto",
}),
)
expect(prepared.body).toMatchObject({
toolConfig: {
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
},
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
messages: [
{ role: "user", content: [{ text: "first user" }] },
{ role: "assistant", content: [{ text: "reply" }] },
{ role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
],
})
}),
)
it.effect("'none' disables auto placement even when manual hints exist", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
cache: "none",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: undefined }],
system: [{ type: "text", text: "Sys", cache_control: undefined }],
})
}),
)
it.effect("granular object form: tools-only marks just tools", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
cache: { tools: true },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "Sys", cache_control: undefined }],
})
}),
)
it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: [
{ type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
{ type: "text", text: "last system" },
],
prompt: "hi",
cache: "auto",
}),
)
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
}),
)
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
prompt: "hi",
cache: { system: true, ttlSeconds: 3600 },
}),
)
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
})
}),
)
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")],
cache: { messages: { tail: 2 } },
}),
)
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
}),
)
it.effect("'latest-assistant' marks the last assistant message", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")],
cache: { messages: "latest-assistant" },
}),
)
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
}),
)
test("returns the same request reference when policy is a no-op (pure function)", () => {
const request = LLM.request({
model: anthropicModel,
prompt: "hi",
cache: "none",
})
expect(applyCachePolicy(request)).toBe(request)
})
})
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" },
], ],
}), }),
@@ -0,0 +1,55 @@
import { Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const model = AnthropicMessages.model({
id: "claude-haiku-4-5-20251001",
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
})
// Two identical generations in a row. The first call writes the prefix into
// Anthropic's cache; the second should report a cache read against the same
// prefix. Cassette captures both interactions in order.
const cacheRequest = LLM.request({
id: "recorded_anthropic_cache",
model,
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
prompt: "Say hi.",
// Manual hint on the system part is the only marker we want here — skip the
// auto-policy's latest-user-message breakpoint so the cassette body matches.
cache: "none",
generation: { maxTokens: 16, temperature: 0 },
})
const recorded = recordedTests({
prefix: "anthropic-messages-cache",
provider: "anthropic",
protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction.
options: {
redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }),
},
})
describe("Anthropic Messages cache recorded", () => {
recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
// The first call may write the cache (cacheWriteInputTokens > 0) or it
// may be a fresh miss (both fields 0) depending on whether the prefix is
// already warm on Anthropic's side. The assertion that matters is that
// the SECOND call reports a non-zero cache read.
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
const second = yield* LLMClient.generate(cacheRequest)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
}),
)
})
@@ -1,3 +1,4 @@
import { Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test" import { 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"
@@ -18,6 +18,9 @@ const request = LLM.request({
model, model,
system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) }, system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) },
prompt: "Say hello.", prompt: "Say hello.",
// This fixture predates the `cache: "auto"` default; pin the policy off so
// existing wire-shape assertions only see the manual hint on the system part.
cache: "none",
generation: { maxTokens: 20, temperature: 0 }, generation: { maxTokens: 20, temperature: 0 },
}) })
@@ -48,6 +51,7 @@ describe("Anthropic Messages route", () => {
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
], ],
cache: "none",
}), }),
) )
@@ -110,12 +114,13 @@ describe("Anthropic Messages route", () => {
expect(response.text).toBe("Hello!") expect(response.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 +157,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 } },
}),
}, },
]) ])
}), }),
@@ -374,4 +385,134 @@ describe("Anthropic Messages route", () => {
expect(error.message).toContain("Anthropic Messages user messages only support text content for now") expect(error.message).toContain("Anthropic Messages user messages only support text content for now")
}), }),
) )
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
prompt: "hi",
}),
)
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }],
})
}),
)
it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
tools: [
{
name: "lookup",
description: "lookup tool",
inputSchema: { type: "object", properties: {} },
cache: new CacheHint({ type: "ephemeral" }),
},
],
messages: [
LLM.user("What's the weather?"),
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]),
LLM.toolMessage({
id: "call_1",
name: "lookup",
result: { temp: 72 },
cache: new CacheHint({ type: "ephemeral" }),
}),
],
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
messages: [
{ role: "user", content: [{ type: "text", text: "What's the weather?" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] },
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: [
{ type: "text", text: "a", cache: hint },
{ type: "text", text: "b", cache: hint },
{ type: "text", text: "c", cache: hint },
{ type: "text", text: "d", cache: hint },
{ type: "text", text: "e", cache: hint },
{ type: "text", text: "f", cache: hint },
],
prompt: "hi",
}),
)
const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system
const marked = system.filter((part) => part.cache_control !== undefined)
expect(marked).toHaveLength(4)
expect(system[4]?.cache_control).toBeUndefined()
expect(system[5]?.cache_control).toBeUndefined()
}),
)
it.effect("spends breakpoint budget on tools before system before messages", () =>
Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
tools: [
{
name: "t1",
description: "t1",
inputSchema: { type: "object", properties: {} },
cache: hint,
},
{
name: "t2",
description: "t2",
inputSchema: { type: "object", properties: {} },
cache: hint,
},
{
name: "t3",
description: "t3",
inputSchema: { type: "object", properties: {} },
cache: hint,
},
{
name: "t4",
description: "t4",
inputSchema: { type: "object", properties: {} },
cache: hint,
},
],
system: [{ type: "text", text: "system-tail", cache: hint }],
messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])],
}),
)
const body = prepared.body as {
tools: Array<{ cache_control?: unknown }>
system: Array<{ cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true)
expect(body.system[0]?.cache_control).toBeUndefined()
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
}),
)
}) })
@@ -0,0 +1,55 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// Use a Claude model on Bedrock — Nova has automatic prefix caching that
// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second
// call wouldn't deterministically prove cache mapping works. Override with
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
const model = BedrockConverse.model({
id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0",
credentials: {
region: RECORDING_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
sessionToken: process.env.AWS_SESSION_TOKEN,
},
})
const cacheRequest = LLM.request({
id: "recorded_bedrock_cache",
model,
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
prompt: "Say hi.",
// Manual hint on the system part is the only marker we want here — skip the
// auto-policy's latest-user-message breakpoint so the cassette body matches.
cache: "none",
generation: { maxTokens: 16, temperature: 0 },
})
const recorded = recordedTests({
prefix: "bedrock-converse-cache",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction.
})
describe("Bedrock Converse cache recorded", () => {
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
const second = yield* LLMClient.generate(cacheRequest)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
}),
)
})
@@ -63,6 +63,9 @@ const baseRequest = LLM.request({
model, model,
system: "You are concise.", system: "You are concise.",
prompt: "Say hello.", prompt: "Say hello.",
// Wire-shape assertions in this file predate the `cache: "auto"` default;
// pin the policy off so they only exercise the lowering path itself.
cache: "none",
generation: { maxTokens: 64, temperature: 0 }, generation: { maxTokens: 64, temperature: 0 },
}) })
@@ -125,6 +128,7 @@ describe("Bedrock Converse route", () => {
LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
], ],
cache: "none",
}), }),
) )
@@ -339,6 +343,7 @@ describe("Bedrock Converse route", () => {
{ type: "media", mediaType: "image/webp", data: "DDDD" }, { type: "media", mediaType: "image/webp", data: "DDDD" },
]), ]),
], ],
cache: "none",
}), }),
) )
@@ -440,6 +445,78 @@ describe("Bedrock Converse route", () => {
expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar") expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar")
}), }),
) )
it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: [{ type: "text", text: "system", cache }],
prompt: "hi",
}),
)
expect(prepared.body).toMatchObject({
system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }],
})
}),
)
it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
messages: [
LLM.user("What's the weather?"),
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]),
LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }),
],
cache: "none",
}),
)
expect(prepared.body).toMatchObject({
toolConfig: {
tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }],
},
messages: [
{ role: "user", content: [{ text: "What's the weather?" }] },
{ role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] },
{
role: "user",
content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }],
},
],
})
}),
)
it.effect("drops cachePoint markers past the 4-per-request cap", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: [
{ type: "text", text: "a", cache },
{ type: "text", text: "b", cache },
{ type: "text", text: "c", cache },
{ type: "text", text: "d", cache },
{ type: "text", text: "e", cache },
{ type: "text", text: "f", cache },
],
prompt: "hi",
}),
)
const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system
expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4)
}),
)
}) })
// Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=... // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=...
@@ -484,6 +561,7 @@ describe("Bedrock Converse recorded", () => {
model: recordedModel(), model: recordedModel(),
system: "Reply with the single word 'Hello'.", system: "Reply with the single word 'Hello'.",
prompt: "Say hello.", prompt: "Say hello.",
cache: "none",
generation: { maxTokens: 16, temperature: 0 }, generation: { maxTokens: 16, temperature: 0 },
}), }),
) )
@@ -506,6 +584,7 @@ describe("Bedrock Converse recorded", () => {
prompt: "Call get_weather with city exactly Paris.", prompt: "Call get_weather with city exactly Paris.",
tools: [weatherTool], tools: [weatherTool],
toolChoice: LLM.toolChoice(weatherTool), toolChoice: LLM.toolChoice(weatherTool),
cache: "none",
generation: { maxTokens: 80, temperature: 0 }, generation: { maxTokens: 80, temperature: 0 },
}), }),
) )
@@ -0,0 +1,49 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const model = Gemini.model({
id: "gemini-2.5-flash",
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture",
})
// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The
// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent`
// API is out-of-band and intentionally not wired up). This test exists to
// pin the usage-parsing path: `cachedContentTokenCount` should surface as
// `cacheReadInputTokens` on the second identical call.
const cacheRequest = LLM.request({
id: "recorded_gemini_cache",
model,
system: LARGE_CACHEABLE_SYSTEM,
prompt: "Say hi.",
generation: { maxTokens: 16, temperature: 0 },
})
const recorded = recordedTests({
prefix: "gemini-cache",
provider: "google",
protocol: "gemini",
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction.
})
describe("Gemini cache recorded", () => {
recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
const second = yield* LLMClient.generate(cacheRequest)
// Implicit caching is best-effort on Gemini's side; we assert the field
// is at least populated and non-negative. When re-recording, verify the
// cassette shows > 0 in the second response's usage.
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
}),
)
})
+24 -19
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,32 +198,36 @@ describe("Gemini route", () => {
expect(response.reasoning).toBe("thinking") expect(response.reasoning).toBe("thinking")
expect(response.usage).toMatchObject({ expect(response.usage).toMatchObject({
inputTokens: 5, inputTokens: 5,
outputTokens: 2, outputTokens: 3,
reasoningTokens: 1, nonCachedInputTokens: 4,
cacheReadInputTokens: 1, cacheReadInputTokens: 1,
reasoningTokens: 1,
totalTokens: 7, totalTokens: 7,
}) })
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "reasoning-delta", text: "thinking" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "text-delta", text: "Hello" }, { type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", text: "!" }, { type: "text-delta", id: "text-0", text: "!" },
{ {
type: "request-finish", type: "request-finish",
reason: "stop", reason: "stop",
usage: { usage: new Usage({
inputTokens: 5, inputTokens: 5,
outputTokens: 2, outputTokens: 3,
reasoningTokens: 1, nonCachedInputTokens: 4,
cacheReadInputTokens: 1, cacheReadInputTokens: 1,
reasoningTokens: 1,
totalTokens: 7, totalTokens: 7,
native: { providerMetadata: {
promptTokenCount: 5, google: {
candidatesTokenCount: 2, promptTokenCount: 5,
totalTokenCount: 7, candidatesTokenCount: 2,
thoughtsTokenCount: 1, totalTokenCount: 7,
cachedContentTokenCount: 1, thoughtsTokenCount: 1,
cachedContentTokenCount: 1,
},
}, },
}, }),
}, },
]) ])
}), }),
@@ -257,12 +261,13 @@ describe("Gemini route", () => {
{ {
type: "request-finish", type: "request-finish",
reason: "tool-calls", reason: "tool-calls",
usage: { usage: new Usage({
inputTokens: 5, inputTokens: 5,
outputTokens: 1, outputTokens: 1,
nonCachedInputTokens: 5,
totalTokens: 6, totalTokens: 6,
native: { promptTokenCount: 5, candidatesTokenCount: 1 }, providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
}, }),
}, },
]) ])
}), }),
@@ -1,3 +1,4 @@
import { Redactor } from "@opencode-ai/http-recorder"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import * as Gemini from "../../src/protocols/gemini" import * as Gemini from "../../src/protocols/gemini"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) =>
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
const cloudflareOptions = { const cloudflareOptions = {
redact: { url: redactCloudflareURL }, redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
} }
describeRecordedGoldenScenarios([ describeRecordedGoldenScenarios([
@@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([
prefix: "anthropic-messages", prefix: "anthropic-messages",
model: anthropicHaiku, model: anthropicHaiku,
requires: ["ANTHROPIC_API_KEY"], requires: ["ANTHROPIC_API_KEY"],
options: { requestHeaders: ["content-type", "anthropic-version"] }, options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: ["text", "tool-call"], scenarios: ["text", "tool-call"],
}, },
{ {
@@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([
model: anthropicOpus, model: anthropicOpus,
requires: ["ANTHROPIC_API_KEY"], requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"], tags: ["flagship"],
options: { requestHeaders: ["content-type", "anthropic-version"] }, options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: [{ id: "tool-loop", temperature: false }], scenarios: [{ id: "tool-loop", temperature: false }],
}, },
{ {
+15 -12
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError } from "../../src" import { LLM, LLMError, Usage } from "../../src"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -225,25 +225,28 @@ describe("OpenAI Chat route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "text-delta", text: "Hello" }, { type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", text: "!" }, { type: "text-delta", id: "text-0", text: "!" },
{ {
type: "request-finish", type: "request-finish",
reason: "stop", reason: "stop",
usage: { usage: new Usage({
inputTokens: 5, inputTokens: 5,
outputTokens: 2, outputTokens: 2,
reasoningTokens: 0, nonCachedInputTokens: 4,
cacheReadInputTokens: 1, cacheReadInputTokens: 1,
reasoningTokens: 0,
totalTokens: 7, totalTokens: 7,
native: { providerMetadata: {
prompt_tokens: 5, openai: {
completion_tokens: 2, prompt_tokens: 5,
total_tokens: 7, completion_tokens: 2,
prompt_tokens_details: { cached_tokens: 1 }, total_tokens: 7,
completion_tokens_details: { reasoning_tokens: 0 }, prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 },
},
}, },
}, }),
}, },
]) ])
}), }),
@@ -0,0 +1,47 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const model = OpenAIResponses.model({
id: "gpt-4.1-mini",
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
})
// OpenAI caches prefixes automatically once they cross the 1024-token threshold;
// `CacheHint` is a no-op for the wire body. The stable signal is the
// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard
// so cache hits are observable.
const cacheRequest = LLM.request({
id: "recorded_openai_responses_cache",
model,
system: LARGE_CACHEABLE_SYSTEM,
prompt: "Say hi.",
generation: { maxTokens: 16, temperature: 0 },
providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
})
const recorded = recordedTests({
prefix: "openai-responses-cache",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction,
// not the cold-miss one.
})
describe("OpenAI Responses cache recorded", () => {
recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
const second = yield* LLMClient.generate(cacheRequest)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect" import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError } from "../../src" import { LLM, LLMError, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
@@ -336,26 +336,29 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } }, { type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } }, { type: "text-delta", id: "msg_1", text: "!" },
{ {
type: "request-finish", type: "request-finish",
reason: "stop", reason: "stop",
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage: { usage: new Usage({
inputTokens: 5, inputTokens: 5,
outputTokens: 2, outputTokens: 2,
reasoningTokens: 0, nonCachedInputTokens: 4,
cacheReadInputTokens: 1, cacheReadInputTokens: 1,
reasoningTokens: 0,
totalTokens: 7, totalTokens: 7,
native: { providerMetadata: {
input_tokens: 5, openai: {
output_tokens: 2, input_tokens: 5,
total_tokens: 7, output_tokens: 2,
input_tokens_details: { cached_tokens: 1 }, total_tokens: 7,
output_tokens_details: { reasoning_tokens: 0 }, input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
},
}, },
}, }),
}, },
]) ])
}), }),
@@ -394,14 +397,12 @@ describe("OpenAI Responses route", () => {
id: "call_1", id: "call_1",
name: "lookup", name: "lookup",
text: '{"query"', text: '{"query"',
providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-input-delta", type: "tool-input-delta",
id: "call_1", id: "call_1",
name: "lookup", name: "lookup",
text: ':"weather"}', text: ':"weather"}',
providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-call", type: "tool-call",
@@ -413,7 +414,13 @@ describe("OpenAI Responses route", () => {
{ {
type: "request-finish", type: "request-finish",
reason: "tool-calls", reason: "tool-calls",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }, usage: new Usage({
inputTokens: 5,
outputTokens: 1,
nonCachedInputTokens: 5,
totalTokens: 6,
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
}),
}, },
]) ])
}), }),
+15
View File
@@ -6,6 +6,18 @@ import { tool } from "../src/tool"
export const weatherToolName = "get_weather" export const weatherToolName = "get_weather"
// A deterministic system prompt long enough to clear every supported provider's
// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic
// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating
// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters
// only when re-recording with `RECORD=true`.
export const LARGE_CACHEABLE_SYSTEM = (() => {
const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. "
// ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely
// above every provider's threshold.
return sentence.repeat(250)
})()
export const weatherTool = LLM.toolDefinition({ export const weatherTool = LLM.toolDefinition({
name: weatherToolName, name: weatherToolName,
description: "Get current weather for a city.", description: "Get current weather for a city.",
@@ -39,6 +51,7 @@ export const textRequest = (input: {
model: input.model, model: input.model,
system: "You are concise.", system: "You are concise.",
prompt: input.prompt ?? "Reply with exactly: Hello!", prompt: input.prompt ?? "Reply with exactly: Hello!",
cache: "none",
generation: generation:
input.temperature === false input.temperature === false
? { maxTokens: input.maxTokens ?? 20 } ? { maxTokens: input.maxTokens ?? 20 }
@@ -58,6 +71,7 @@ export const weatherToolRequest = (input: {
prompt: "Call get_weather with city exactly Paris.", prompt: "Call get_weather with city exactly Paris.",
tools: [weatherTool], tools: [weatherTool],
toolChoice: LLM.toolChoice(weatherTool), toolChoice: LLM.toolChoice(weatherTool),
cache: "none",
generation: generation:
input.temperature === false input.temperature === false
? { maxTokens: input.maxTokens ?? 80 } ? { maxTokens: input.maxTokens ?? 80 }
@@ -76,6 +90,7 @@ export const weatherToolLoopRequest = (input: {
model: input.model, model: input.model,
system: input.system ?? "Use the get_weather tool, then answer in one short sentence.", system: input.system ?? "Use the get_weather tool, then answer in one short sentence.",
prompt: "What is the weather in Paris?", prompt: "What is the weather in Paris?",
cache: "none",
generation: generation:
input.temperature === false input.temperature === false
? { maxTokens: input.maxTokens ?? 80 } ? { maxTokens: input.maxTokens ?? 80 }
+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)
})
})
+7
View File
@@ -9,6 +9,13 @@
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`. - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`). - **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
## Development server
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
# Module shape # Module shape
Do not use `export namespace Foo { ... }` for module organization. It is not Do not use `export namespace Foo { ... }` for module organization. It is not
@@ -1 +1 @@
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0;

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