Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
199 commits
Select commit Hold shift + click to select a range
edd176c
feat(llm): add initial patch API
kitlangton Apr 25, 2026
7968371
feat(llm): move core to package
kitlangton Apr 25, 2026
d96bf0d
feat(llm): add OpenAI Chat adapter
kitlangton Apr 25, 2026
36ab9fa
docs(llm): add package todo list
kitlangton Apr 25, 2026
1e0f6ee
feat(llm): add adapter registry ergonomics
kitlangton Apr 25, 2026
f026523
test(llm): add provider patch coverage
kitlangton Apr 25, 2026
ca9e0cf
test(llm): record OpenAI tool result flow
kitlangton Apr 25, 2026
0446830
refactor(llm): simplify adapter execution API
kitlangton Apr 26, 2026
412a1be
test(llm): clean Effect test utilities
kitlangton Apr 26, 2026
18d618d
test(llm): harden cassette matching and add streaming edge-case coverage
kitlangton Apr 26, 2026
aec6c59
feat(llm): add OpenAI Responses adapter
kitlangton Apr 26, 2026
0f4e54d
feat(llm): add Anthropic Messages adapter
kitlangton Apr 26, 2026
9a05675
refactor(llm): share provider stream parsing
kitlangton Apr 26, 2026
8d97b38
feat(llm): add Gemini adapter
kitlangton Apr 26, 2026
850eeae
test(llm): cover Gemini stream edge cases
kitlangton Apr 26, 2026
e476b63
refactor(llm): yieldable parser errors and linear runFold
kitlangton Apr 26, 2026
3561938
feat(llm): port Gemini tool-schema sanitizer as a patch
kitlangton Apr 26, 2026
6573673
docs(llm): mark Responses/Anthropic/Gemini done and outline OpenCode …
kitlangton Apr 26, 2026
74b2e57
refactor(llm): remove unused SSE invalid chunk option
kitlangton Apr 26, 2026
8a4699e
refactor(llm): drop vestigial Chunk type and raise step
kitlangton Apr 26, 2026
afe3990
refactor(llm): convert lowerToolChoice helpers to yieldable form
kitlangton Apr 26, 2026
ca29f8a
test(llm): cover provider-error events and HTTP sad paths
kitlangton Apr 26, 2026
0cc992f
feat(llm): add OpenAI-compatible Chat adapter
kitlangton Apr 26, 2026
b4a7cf6
feat(llm): add OpenAI-compatible provider helpers
kitlangton Apr 26, 2026
3a2cb7f
feat(llm): add typed ToolRuntime
kitlangton Apr 26, 2026
6a7735e
test(llm): cover OpenAI-compatible Chat parity
kitlangton Apr 26, 2026
ca198f7
refactor(llm): cache tool codecs and tighten ToolRuntime types
kitlangton Apr 26, 2026
ca8d700
feat(llm): support multi-interaction cassettes with sequential matcher
kitlangton Apr 26, 2026
b5ca62d
test(llm): record OpenAI Chat tool-loop cassette
kitlangton Apr 26, 2026
e1c6bf9
feat(llm): provider-executed tool pass-through
kitlangton Apr 26, 2026
4e3f678
feat(llm): add provider-routed adapter composition
kitlangton Apr 26, 2026
6c887b0
refactor(llm): brand provider and model identifiers
kitlangton Apr 26, 2026
769d612
feat(llm): add Bedrock Converse adapter
kitlangton Apr 26, 2026
0da7d8a
feat(opencode): add native LLM request builder
kitlangton Apr 26, 2026
bab2fbc
refactor(llm): simplify Bedrock Converse adapter after review
kitlangton Apr 26, 2026
778b176
feat(opencode): convert native LLM tool definitions
kitlangton Apr 26, 2026
3a94622
refactor(llm): dedupe adapter scaffolding into ProviderShared
kitlangton Apr 26, 2026
fa2a5d1
feat(opencode): convert native LLM message history
kitlangton Apr 27, 2026
339db0e
docs(llm): document ProviderShared helpers and framing seam
kitlangton Apr 27, 2026
c69f2bb
refactor(llm): centralize InvalidRequestError, validate, and JSON POST
kitlangton Apr 27, 2026
1a839c6
refactor(opencode): tighten native LLM bridge boundaries
kitlangton Apr 27, 2026
ecd73f2
refactor(llm): simplify adapter shared logic
kitlangton Apr 27, 2026
096c305
feat(llm): Bedrock Converse cache hints, image, and document blocks
kitlangton Apr 27, 2026
03a97a6
chore(llm): fix low-hanging lint warnings
kitlangton Apr 27, 2026
a26f2c9
test(opencode): cover native OpenAI-compatible parity
kitlangton Apr 27, 2026
33ef3b0
test(opencode): cover native Gemini parity
kitlangton Apr 27, 2026
653a830
refactor(llm): clarify tool definition API
kitlangton Apr 27, 2026
3cd13c8
refactor(llm): standardize native request APIs
kitlangton Apr 27, 2026
5f08d6c
feat(llm): cachePromptHints patch with first-2 system / last-2 messag…
kitlangton Apr 27, 2026
b653261
feat(opencode): bridge user FilePart to LLM MediaPart for vision input
kitlangton Apr 27, 2026
f599963
feat(opencode): round-trip encrypted reasoning content through the br…
kitlangton Apr 27, 2026
d00db17
feat(opencode): add native LLM event bridge
kitlangton Apr 27, 2026
8bbbcee
fix(llm): unify apiKey precedence and consolidate Gemini schema conve…
kitlangton Apr 27, 2026
38af0dc
refactor(llm): centralize codec scaffolding, ToolAccumulator, and tot…
kitlangton Apr 27, 2026
0ba8ca6
refactor(llm): Bedrock JSON-codec compliance, signing-headers cleanup…
kitlangton Apr 27, 2026
fc3a1bf
feat(opencode): wire LLM-native stream path behind opt-in flag (audit…
kitlangton Apr 27, 2026
afba37d
test(opencode): smoke test for LLM-native stream wire-up (audit gap #…
kitlangton Apr 27, 2026
fa8f7a1
feat(opencode): plumb nativeTools through StreamInput (audit gap #4 p…
kitlangton Apr 27, 2026
189161e
feat(opencode): streaming tool dispatch and multi-round loop on the n…
kitlangton Apr 27, 2026
afa57ac
refactor(llm): extract HTTP recorder package
kitlangton Apr 27, 2026
0e558e1
feat(opencode): populate nativeTools from prompt.ts so production ses…
kitlangton Apr 27, 2026
7fba0ef
fix(opencode): update native LLM imports after rebase
kitlangton Apr 27, 2026
59f39a9
chore(opencode): drop local LLM adapter spec from branch
kitlangton Apr 27, 2026
1cd53b2
chore(llm): clean up PR docs
kitlangton Apr 28, 2026
b0be03f
refactor(llm): clarify provider resolution
kitlangton Apr 28, 2026
7141036
refactor(llm): simplify provider resolver defaults
kitlangton Apr 28, 2026
f2f7a33
feat(llm): resolve Azure provider natively
kitlangton Apr 28, 2026
a921eb8
test(opencode): cover Azure native request mapping
kitlangton Apr 28, 2026
cd7487a
test(llm): add focused recorded test filters
kitlangton Apr 28, 2026
20bab34
test(llm): share recorded provider scenarios
kitlangton Apr 28, 2026
6099b3d
refactor(llm): rename Protocol type to ProtocolID
kitlangton Apr 28, 2026
7505da9
feat(llm): add Protocol, Endpoint, Auth, Framing primitives
kitlangton Apr 28, 2026
6ed160a
refactor(llm): migrate OpenAI Chat adapters to fromProtocol
kitlangton Apr 28, 2026
bdd01ca
refactor(llm): migrate remaining adapters to fromProtocol
kitlangton Apr 28, 2026
98cb886
docs(llm): document Protocol/Endpoint/Auth/Framing architecture
kitlangton Apr 28, 2026
9928917
simplify(llm): remove dead ProviderShared.sse and withQuery helpers
kitlangton Apr 28, 2026
31740c1
simplify(llm): inline single-use DEFAULT_BASE_URL / defaultBaseURL co…
kitlangton Apr 28, 2026
a676b12
fix(llm): keep adapters provider-less by default
kitlangton Apr 28, 2026
4f29485
simplify(llm): share core between Auth.bearer and Auth.apiKeyHeader
kitlangton Apr 28, 2026
5d08e28
refactor(llm): move auth secret from headers onto ModelRef.apiKey
kitlangton Apr 28, 2026
042bf6c
simplify(llm): default Adapter.fromProtocol auth to Auth.bearer
kitlangton Apr 28, 2026
d8b9672
simplify(llm): split Bedrock auth into bearer fast path + sigv4 gen
kitlangton Apr 28, 2026
f86a679
refactor(llm): move queryParams off model.native to typed field
kitlangton Apr 28, 2026
61a18bd
simplify(llm): fix stale model.native.queryParams references in docs
kitlangton Apr 28, 2026
bb859e2
simplify(llm): remove redundant queryParams from OpenAICompatibleChat…
kitlangton Apr 28, 2026
e7ff19b
simplify(llm): stringify endpoint URL once in Adapter.fromProtocol
kitlangton Apr 28, 2026
bb7f52b
refactor(llm): remove ambiguous Adapter provider scoping field
kitlangton Apr 29, 2026
49913ff
refactor(llm): rename Adapter.define -> Adapter.unsafe; drop Adapter.…
kitlangton Apr 29, 2026
8b414cd
refactor(llm): collapse ProviderAuth to 'key' | 'none'
kitlangton Apr 29, 2026
5ec2673
simplify(llm): inline resolveAdapter into compile
kitlangton Apr 29, 2026
9363c70
simplify(llm): drop redundant auth: "key" from resolvers (it is the d…
kitlangton Apr 29, 2026
a0165b2
docs(llm): fix stale field names in ProtocolID comment and AGENTS.md …
kitlangton Apr 29, 2026
f4de3e8
feat(llm): add LLMEvent.is.* camelCase narrowing helpers
kitlangton Apr 29, 2026
75f467b
feat(llm): expose PreparedRequestOf<Target> on LLMClient.prepare
kitlangton Apr 29, 2026
8f338ef
simplify(llm): inline single-use llmEventIs const and drop redundant …
kitlangton Apr 29, 2026
116a5c2
docs(llm): document prepare<Target>, PreparedRequestOf, and LLMEvent.…
kitlangton Apr 29, 2026
e9d84c6
fix(llm): preserve native stream fallback parity
kitlangton May 1, 2026
652ef9c
fix(llm): use Azure api-key auth for OpenAI adapters
kitlangton May 1, 2026
046e459
fix(llm): map Responses tool calls finish reason
kitlangton May 1, 2026
9065d79
fix(llm): preserve native protocol state
kitlangton May 3, 2026
1caeaf7
Merge remote-tracking branch 'origin/dev' into HEAD
kitlangton May 3, 2026
de79fd7
fix(test): use instance helper in native fallback test
kitlangton May 3, 2026
195e0bb
simplify(llm): define events with schema tags
kitlangton May 3, 2026
b7930c7
simplify(llm): share tagged schema helper
kitlangton May 3, 2026
6e23e56
simplify(llm): trim native runtime overhead
kitlangton May 3, 2026
eea8764
simplify(llm): extract conversation semantics
kitlangton May 3, 2026
6736923
fix(llm): preserve provider-native continuation state
kitlangton May 3, 2026
c519ff2
feat(llm): add consistent tagged checks
kitlangton May 3, 2026
6224ce8
fix(llm): align provider tool semantics
kitlangton May 3, 2026
fea9649
fix(llm): expand compatible provider bridge
kitlangton May 3, 2026
cd866f3
chore(llm): add recording env setup
kitlangton May 3, 2026
b560f98
chore(llm): improve recording env setup ux
kitlangton May 3, 2026
a1c1d07
chore(llm): use effect for recording env setup
kitlangton May 3, 2026
9e3868f
test(llm): add openrouter recorded coverage
kitlangton May 3, 2026
3cae82f
docs(llm): document provider recording strategy
kitlangton May 3, 2026
db74983
test(llm): add openrouter golden tool loops
kitlangton May 3, 2026
49340f5
test(llm): simplify golden tool loop scenarios
kitlangton May 3, 2026
039c050
test(llm): expand flagship recorded coverage
kitlangton May 3, 2026
bc20399
test(llm): add groq recordings and cost report
kitlangton May 3, 2026
73a9372
test(llm): add gemini golden loop coverage
kitlangton May 3, 2026
4b54bc3
refactor(http-recorder): make record mode explicit
kitlangton May 3, 2026
40819e1
refactor(http-recorder): extract cassette service
kitlangton May 4, 2026
3765dde
refactor(llm): simplify protocol composition API
kitlangton May 5, 2026
6242048
refactor(llm): streamline adapter model handles
kitlangton May 5, 2026
89922be
refactor(llm): consolidate compatible provider profiles
kitlangton May 5, 2026
b6fb6ac
test(llm): summarize recorded stream events
kitlangton May 5, 2026
d84da53
test(llm): tighten recorded test assertions
kitlangton May 5, 2026
fce1c45
test(llm): split recorded provider and prefix filters
kitlangton May 5, 2026
e25906c
refactor(llm): tighten adapter construction API
kitlangton May 5, 2026
b8523da
refactor(llm): simplify adapter patch extension
kitlangton May 5, 2026
908cb82
fix(llm): set xai responses base url
kitlangton May 5, 2026
f2d7fe3
chore(llm): checkpoint provider patch work
kitlangton May 5, 2026
96aace5
refactor(llm): extract patch pipeline
kitlangton May 5, 2026
125b8b2
refactor(llm): validate patched targets from schema
kitlangton May 5, 2026
6e2dc33
docs(llm): remove resolver references
kitlangton May 5, 2026
678c405
test(llm): restore recorded provider coverage
kitlangton May 5, 2026
11b892d
refactor(llm): simplify patch pipeline
kitlangton May 5, 2026
c4e0972
refactor(llm): share provider schema helpers
kitlangton May 5, 2026
8523299
refactor(llm): simplify provider payload routing
kitlangton May 5, 2026
e8d7031
refactor(llm): simplify provider protocol wiring
kitlangton May 5, 2026
4daac79
refactor(llm): split providers and protocols
kitlangton May 5, 2026
172c382
refactor(llm): clarify adapter payload flow
kitlangton May 5, 2026
b3568ab
refactor(llm): replace patches with transforms
kitlangton May 5, 2026
d62bede
test(llm): cover public export surface
kitlangton May 5, 2026
89c6594
refactor(llm): remove transform pipeline
kitlangton May 6, 2026
cfe8fdb
refactor(llm): resolve adapters from registry
kitlangton May 6, 2026
1808fe8
refactor(llm): clarify public module surface
kitlangton May 6, 2026
7b4f436
refactor(llm): collapse client adapter injection
kitlangton May 6, 2026
9fc1d15
refactor(llm): tighten runtime service boundaries
kitlangton May 6, 2026
c99e278
refactor(llm): tighten helper and diagnostic surfaces
kitlangton May 6, 2026
0b8040a
refactor(llm): move system helpers onto schema
kitlangton May 6, 2026
8b8a09c
Merge remote-tracking branch 'origin/llm-core-patch-api' into llm-cor…
kitlangton May 6, 2026
9c93840
fix(llm): keep local refactor coherent after merge
kitlangton May 6, 2026
02b1d68
refactor(llm): centralize adapter policy helpers
kitlangton May 6, 2026
fe9b5cf
feat(llm): add composable auth
kitlangton May 6, 2026
376c78f
refactor(llm): remove trivial model input aliases
kitlangton May 6, 2026
780a5d6
test(llm): cover provider auth option types
kitlangton May 6, 2026
73326f5
feat(llm): preserve provider diagnostics
kitlangton May 6, 2026
56ebef0
feat(llm): add provider definitions
kitlangton May 6, 2026
0e3d2ef
refactor(llm): keep adapters out of provider definitions
kitlangton May 6, 2026
9d89d66
refactor(llm): standardize provider ids
kitlangton May 6, 2026
b6b3d2d
test(llm): tighten provider definition types
kitlangton May 6, 2026
7df7583
docs(llm): document provider definitions
kitlangton May 6, 2026
67f9769
refactor(llm): make xai responses canonical
kitlangton May 6, 2026
8cdb723
feat(llm): expose xai api choices
kitlangton May 6, 2026
2fe2122
refactor(llm): share tool choice lowering
kitlangton May 7, 2026
76140b3
refactor(llm): move tool execution onto client
kitlangton May 7, 2026
25173bf
refactor(llm): inline auth secret types
kitlangton May 7, 2026
74052f8
core: standardize content validation errors across LLM providers
kitlangton May 7, 2026
a0adbfc
feat(llm): add responses websocket transport
kitlangton May 7, 2026
edda03a
refactor(llm): rename adapters to routes
kitlangton May 7, 2026
9980dd6
refactor(llm): add route derivation
kitlangton May 7, 2026
9a29f07
refactor(llm): rename payload→body and chunk→event in protocol shape
kitlangton May 7, 2026
24425f2
refactor(llm): split schema, share provider auth, tighten openrouter
kitlangton May 7, 2026
1a77519
refactor(llm): tighten transport, redaction, and protocol parsers
kitlangton May 7, 2026
454e087
fix(opencode): catch up to llm route/protocol rename
kitlangton May 7, 2026
32ad9cb
refactor(llm): unify recorded cassettes
kitlangton May 7, 2026
c5e2003
refactor(llm): collapse Endpoint to path-only; require ModelRef.baseURL
kitlangton May 7, 2026
d0b9345
refactor(llm): move websocket recorder into http-recorder
kitlangton May 7, 2026
797e203
docs(llm): refresh AGENTS.md, retire stale designs
kitlangton May 7, 2026
c4c60e7
refactor(llm): polish openai-responses hosted tools and body schemas
kitlangton May 7, 2026
15527ce
docs(opencode): add AI SDK → @opencode-ai/llm migration plan
kitlangton May 7, 2026
7734339
refactor(llm): align schema discriminator helpers
kitlangton May 7, 2026
b068917
docs(opencode): rewrite migration plan as outline, lead with model ha…
kitlangton May 7, 2026
48e11a3
docs(opencode): expand Phase 2 with concrete data models per import
kitlangton May 7, 2026
7266cb2
docs(opencode): show before/after TS blocks for each Phase 2 sub-step
kitlangton May 7, 2026
4c5ad78
feat(llm): add structured object generation
kitlangton May 8, 2026
bbbd0d7
refactor(llm): align object generation responses
kitlangton May 8, 2026
d02e496
feat(llm): add Cloudflare provider helpers
kitlangton May 8, 2026
25b06b5
refactor(llm): remove model capabilities metadata
kitlangton May 8, 2026
6fb79c8
refactor(llm): simplify Cloudflare recording helpers
kitlangton May 8, 2026
cf9024b
fix(llm): align Cloudflare auth handling
kitlangton May 8, 2026
6618133
test(llm): add Cloudflare tool call recordings
kitlangton May 8, 2026
663eea8
chore: narrow LLM package PR scope
kitlangton May 8, 2026
439cb76
fix(llm): include DOM types for standalone packages
kitlangton May 8, 2026
bfdd2cd
Merge remote-tracking branch 'origin/dev' into llm-core-patch-api
kitlangton May 8, 2026
9553479
fix(enterprise): include Bun test types
kitlangton May 8, 2026
bf82d4a
fix(console): include Bun test types
kitlangton May 8, 2026
9262570
fix(http-recorder): normalize cassette paths on Windows
kitlangton May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules
.worktrees
.sst
.env
.env.local
.idea
.vscode
.codex
Expand Down
5 changes: 5 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Fake secret-looking strings used by HTTP recorder redaction tests.
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71
43 changes: 43 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/console/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"zod": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"@webgpu/types": "0.1.54",
"typescript": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion packages/console/app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vite/client", "@webgpu/types"],
"types": ["vite/client", "@webgpu/types", "bun"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
Expand Down
1 change: 1 addition & 0 deletions packages/enterprise/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@cloudflare/workers-types": "catalog:",
"@tailwindcss/vite": "catalog:",
"@typescript/native-preview": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion packages/enterprise/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"allowJs": true,
"noEmit": true,
"strict": true,
"types": ["@cloudflare/workers-types", "vite/client"],
"types": ["@cloudflare/workers-types", "vite/client", "bun"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
Expand Down
26 changes: 26 additions & 0 deletions packages/http-recorder/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:"
}
}
105 changes: 105 additions & 0 deletions packages/http-recorder/src/cassette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
import * as path from "node:path"
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"

export interface Entry {
readonly name: string
readonly path: string
}

export interface Interface {
readonly path: (name: string) => string
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
}

export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}

export const layer = (options: { readonly directory?: string } = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())

const pathFor = (name: string) => cassettePath(name, directory)

const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fileSystem
.readDirectory(directory)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(directory, entry)
return fileSystem.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
})
return nested.flat()
})

const read = Effect.fn("Cassette.read")(function* (name: string) {
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
})

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 * as Cassette from "./cassette"
95 changes: 95 additions & 0 deletions packages/http-recorder/src/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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,
)
Loading
Loading