refactor(llm): extract HTTP recorder package
This commit is contained in:
@@ -352,6 +352,19 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.25",
|
||||
@@ -363,6 +376,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -1593,6 +1607,8 @@
|
||||
|
||||
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||
|
||||
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
|
||||
|
||||
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$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",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { decodeJson } from "./matching"
|
||||
import { REDACTED, redactUrl, secretFindings } from "./redaction"
|
||||
import type { Cassette, RequestSnapshot } from "./schema"
|
||||
import { Option } from "effect"
|
||||
|
||||
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) => {
|
||||
if (cassette.interactions.length === 0) return "cassette has no recorded interactions"
|
||||
const ranked = cassette.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.modify(request, { url: redactUrl(request.url) })
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, FileSystem, Layer, Option, Ref } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as path from "node:path"
|
||||
import { redactedErrorRequest, mismatchDetail } from "./diff"
|
||||
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
|
||||
import { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
||||
import type { Cassette, CassetteMetadata, Interaction, ResponseSnapshot } from "./schema"
|
||||
import { cassetteFor, cassettePath, formatCassette, parseCassette } from "./storage"
|
||||
|
||||
const isRecordMode = process.env.RECORD === "true"
|
||||
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
export interface RecordReplayOptions {
|
||||
readonly directory?: string
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly redact?: {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly query?: ReadonlyArray<string>
|
||||
}
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
readonly responseHeaders?: ReadonlyArray<string>
|
||||
readonly redactBody?: (body: unknown) => unknown
|
||||
readonly dispatch?: "match" | "sequential"
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
|
||||
const responseHeaders = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
allow: ReadonlyArray<string>,
|
||||
redact: ReadonlyArray<string> | undefined,
|
||||
) => {
|
||||
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
|
||||
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
|
||||
return merged
|
||||
}
|
||||
|
||||
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
|
||||
|
||||
const isBinaryContentType = (contentType: string | undefined) => {
|
||||
if (!contentType) return false
|
||||
const lower = contentType.toLowerCase()
|
||||
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
|
||||
}
|
||||
|
||||
const captureResponseBody = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
contentType: string | undefined,
|
||||
) =>
|
||||
isBinaryContentType(contentType)
|
||||
? response.arrayBuffer.pipe(
|
||||
Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })),
|
||||
)
|
||||
: response.text.pipe(Effect.map((body) => ({ body })))
|
||||
|
||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||
|
||||
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request,
|
||||
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
const unsafeCassette = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
name: string,
|
||||
findings: ReadonlyArray<SecretFinding>,
|
||||
) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request,
|
||||
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
|
||||
.map((item) => `${item.path} (${item.reason})`)
|
||||
.join(", ")}`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const cassetteLayer = (
|
||||
name: string,
|
||||
options: RecordReplayOptions = {},
|
||||
): Layer.Layer<HttpClient.HttpClient> =>
|
||||
Layer.effect(
|
||||
HttpClient.HttpClient,
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* HttpClient.HttpClient
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const file = cassettePath(name, options.directory)
|
||||
const dir = path.dirname(file)
|
||||
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
|
||||
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
|
||||
const match = options.match ?? defaultMatcher
|
||||
const sequential = options.dispatch === "sequential"
|
||||
const recorded = yield* Ref.make<ReadonlyArray<Interaction>>([])
|
||||
const cursor = yield* Ref.make(0)
|
||||
|
||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
const raw = yield* Effect.promise(() => web.text())
|
||||
const body = options.redactBody
|
||||
? Option.match(decodeJson(raw), {
|
||||
onNone: () => raw,
|
||||
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
|
||||
})
|
||||
: raw
|
||||
return {
|
||||
method: web.method,
|
||||
url: redactUrl(web.url, options.redact?.query),
|
||||
headers: redactHeaders(Object.fromEntries(web.headers.entries()), requestHeadersAllow, options.redact?.headers),
|
||||
body,
|
||||
}
|
||||
})
|
||||
|
||||
const selectInteraction = (cassette: Cassette, incoming: Interaction["request"]) =>
|
||||
Effect.gen(function* () {
|
||||
if (sequential) {
|
||||
const index = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
|
||||
const interaction = cassette.interactions[index]
|
||||
return { interaction, detail: `interaction ${index + 1} of ${cassette.interactions.length} not recorded` }
|
||||
}
|
||||
const interaction = cassette.interactions.find((candidate) => match(incoming, candidate.request))
|
||||
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
|
||||
})
|
||||
|
||||
return HttpClient.make((request) => {
|
||||
if (isRecordMode) {
|
||||
return Effect.gen(function* () {
|
||||
const currentRequest = yield* snapshotRequest(request)
|
||||
const response = yield* upstream.execute(request)
|
||||
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
|
||||
const captured = yield* captureResponseBody(response, headers["content-type"])
|
||||
const interaction: Interaction = {
|
||||
request: currentRequest,
|
||||
response: { status: response.status, headers, ...captured },
|
||||
}
|
||||
const interactions = yield* Ref.updateAndGet(recorded, (prev) => [...prev, interaction])
|
||||
const cassette = cassetteFor(name, interactions, options.metadata)
|
||||
const findings = cassetteSecretFindings(cassette)
|
||||
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
|
||||
yield* fileSystem.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* fileSystem.writeFileString(file, formatCassette(cassette)).pipe(Effect.orDie)
|
||||
return HttpClientResponse.fromWeb(request, new Response(decodeResponseBody(interaction.response), interaction.response))
|
||||
})
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const cassette = parseCassette(
|
||||
yield* fileSystem.readFileString(file).pipe(Effect.mapError(() => fixtureMissing(request, name))),
|
||||
)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
||||
|
||||
return HttpClientResponse.fromWeb(request, new Response(decodeResponseBody(interaction.response), interaction.response))
|
||||
})
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer))
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./schema"
|
||||
export * from "./redaction"
|
||||
export * from "./matching"
|
||||
export * from "./diff"
|
||||
export * from "./storage"
|
||||
export * from "./effect"
|
||||
|
||||
export * as HttpRecorder from "."
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import type { RequestSnapshot } from "./schema"
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalize)
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value as Record<string, unknown>)
|
||||
.toSorted()
|
||||
.map((key) => [key, canonicalize((value as Record<string, unknown>)[key])]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
|
||||
|
||||
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
JSON.stringify({
|
||||
method: snapshot.method,
|
||||
url: snapshot.url,
|
||||
headers: canonicalize(snapshot.headers),
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: canonicalize,
|
||||
}),
|
||||
})
|
||||
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Cassette } from "./schema"
|
||||
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-amz-security-token",
|
||||
"x-goog-api-key",
|
||||
]
|
||||
|
||||
const DEFAULT_REDACT_QUERY = [
|
||||
"access_token",
|
||||
"api-key",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"code",
|
||||
"key",
|
||||
"signature",
|
||||
"sig",
|
||||
"token",
|
||||
"x-amz-credential",
|
||||
"x-amz-security-token",
|
||||
"x-amz-signature",
|
||||
]
|
||||
|
||||
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
|
||||
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
|
||||
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
|
||||
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
||||
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
|
||||
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
||||
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
|
||||
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
||||
]
|
||||
|
||||
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
|
||||
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
|
||||
|
||||
const envSecrets = () =>
|
||||
Object.entries(process.env).flatMap(([name, value]) => {
|
||||
if (!value) return []
|
||||
if (!ENV_SECRET_NAMES.test(name)) return []
|
||||
if (value.length < 12) return []
|
||||
if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
|
||||
return [{ name, value }]
|
||||
})
|
||||
|
||||
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
|
||||
|
||||
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
|
||||
if (typeof value === "string") return [{ path: base, value }]
|
||||
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
|
||||
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
|
||||
|
||||
export const redactUrl = (raw: string, query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY) => {
|
||||
if (!URL.canParse(raw)) return raw
|
||||
const url = new URL(raw)
|
||||
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const redactHeaders = (
|
||||
headers: Record<string, string>,
|
||||
allow: ReadonlyArray<string>,
|
||||
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
|
||||
) => {
|
||||
const allowed = new Set(allow.map((name) => name.toLowerCase()))
|
||||
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([name, value]) => [name.toLowerCase(), value] as const)
|
||||
.filter(([name]) => allowed.has(name))
|
||||
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
}
|
||||
|
||||
export type SecretFinding = {
|
||||
readonly path: string
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||
stringEntries(value).flatMap((entry) => [
|
||||
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
|
||||
path: entry.path,
|
||||
reason: item.label,
|
||||
})),
|
||||
...envSecrets()
|
||||
.filter((item) => entry.value.includes(item.value))
|
||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
||||
])
|
||||
|
||||
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const RequestSnapshotSchema = Schema.Struct({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
})
|
||||
export type RequestSnapshot = Schema.Schema.Type<typeof RequestSnapshotSchema>
|
||||
|
||||
export const ResponseSnapshotSchema = Schema.Struct({
|
||||
status: Schema.Number,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
|
||||
})
|
||||
export type ResponseSnapshot = Schema.Schema.Type<typeof ResponseSnapshotSchema>
|
||||
|
||||
export const InteractionSchema = Schema.Struct({
|
||||
request: RequestSnapshotSchema,
|
||||
response: ResponseSnapshotSchema,
|
||||
})
|
||||
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
|
||||
|
||||
export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export type CassetteMetadata = Schema.Schema.Type<typeof CassetteMetadataSchema>
|
||||
|
||||
export const CassetteSchema = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
metadata: Schema.optional(CassetteMetadataSchema),
|
||||
interactions: Schema.Array(InteractionSchema),
|
||||
})
|
||||
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
|
||||
|
||||
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
|
||||
export const encodeCassette = Schema.encodeSync(CassetteSchema)
|
||||
@@ -0,0 +1,34 @@
|
||||
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`)
|
||||
|
||||
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")))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { HttpRecorder } from "../src"
|
||||
|
||||
const post = (url: string, body: object) =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const request = HttpClientRequest.post(url, {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: HttpBody.text(JSON.stringify(body), "application/json"),
|
||||
})
|
||||
const response = yield* http.execute(request)
|
||||
return yield* response.text
|
||||
})
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step"))))
|
||||
|
||||
const runWith = <A, E>(name: string, options: HttpRecorder.RecordReplayOptions, effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options))))
|
||||
|
||||
const failureText = (exit: Exit.Exit<unknown, unknown>) => {
|
||||
if (Exit.isSuccess(exit)) return ""
|
||||
return Cause.prettyErrors(exit.cause).join("\n")
|
||||
}
|
||||
|
||||
describe("http-recorder", () => {
|
||||
test("redacts sensitive URL query parameters", () => {
|
||||
expect(
|
||||
HttpRecorder.redactUrl(
|
||||
"https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
|
||||
),
|
||||
).toBe(
|
||||
"https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
|
||||
)
|
||||
})
|
||||
|
||||
test("redacts sensitive headers when allow-listed", () => {
|
||||
expect(
|
||||
HttpRecorder.redactHeaders(
|
||||
{
|
||||
authorization: "Bearer secret-token",
|
||||
"content-type": "application/json",
|
||||
"x-custom-token": "custom-secret",
|
||||
"x-api-key": "secret-key",
|
||||
"x-goog-api-key": "secret-google-key",
|
||||
},
|
||||
["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
|
||||
["x-custom-token"],
|
||||
),
|
||||
).toEqual({
|
||||
authorization: "[REDACTED]",
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "[REDACTED]",
|
||||
"x-custom-token": "[REDACTED]",
|
||||
"x-goog-api-key": "[REDACTED]",
|
||||
})
|
||||
})
|
||||
|
||||
test("detects secret-looking values without returning the secret", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
version: 1,
|
||||
interactions: [
|
||||
{
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://example.test/path?key=sk-123456789012345678901234",
|
||||
headers: {},
|
||||
body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
|
||||
},
|
||||
response: {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: "Bearer abcdefghijklmnopqrstuvwxyz",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ path: "interactions[0].request.url", reason: "API key" },
|
||||
{ path: "interactions[0].request.body", reason: "Google API key" },
|
||||
{ path: "interactions[0].response.body", reason: "bearer token" },
|
||||
])
|
||||
})
|
||||
|
||||
test("detects secret-looking values inside metadata", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
version: 1,
|
||||
metadata: { token: "sk-123456789012345678901234" },
|
||||
interactions: [],
|
||||
}),
|
||||
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
||||
})
|
||||
|
||||
test("default matcher dispatches multi-interaction cassettes by request shape", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
|
||||
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
|
||||
await runWith(
|
||||
"record-replay/retry",
|
||||
{ dispatch: "sequential" },
|
||||
Effect.gen(function* () {
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("default matcher returns the first match for identical requests", async () => {
|
||||
await runWith(
|
||||
"record-replay/retry",
|
||||
{},
|
||||
Effect.gen(function* () {
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
|
||||
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
|
||||
await runWith(
|
||||
"record-replay/multi-step",
|
||||
{ dispatch: "sequential" },
|
||||
Effect.gen(function* () {
|
||||
yield* post("https://example.test/echo", { step: 1 })
|
||||
yield* post("https://example.test/echo", { step: 2 })
|
||||
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("mismatch diagnostics show closest redacted request differences", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
|
||||
)
|
||||
const message = failureText(exit)
|
||||
expect(message).toContain("closest interaction: #1")
|
||||
expect(message).toContain("url:")
|
||||
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
|
||||
expect(message).toContain("body:")
|
||||
expect(message).toContain('$.step expected 1, received 3')
|
||||
expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
|
||||
expect(message).not.toContain("sk-123456789012345678901234")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@effect/language-service",
|
||||
"transform": "@effect/language-service/transform",
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+34
-14
@@ -169,16 +169,18 @@ Recorded tests use one cassette file per scenario. A cassette holds an ordered a
|
||||
```ts
|
||||
const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] })
|
||||
|
||||
recorded.effect("streams text", () => Effect.gen(function* () {
|
||||
// test body
|
||||
}))
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
// test body
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable.
|
||||
|
||||
**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` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON adapters omit the field and decode as text. To support a new binary content type, extend `BINARY_CONTENT_TYPES` in `test/record-replay.ts`.
|
||||
**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 adapters omit the field and decode as text.
|
||||
|
||||
**Matching strategies.** Replay defaults to `defaultMatcher`, which finds an interaction by structurally 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 `match: sequentialMatcher` 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 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -186,7 +188,7 @@ Do not blanket re-record an entire test file when adding one cassette. `RECORD=t
|
||||
|
||||
### Completed Foundation
|
||||
|
||||
- [x] Add an adapter registry so `client(...)` can choose an adapter by `request.model.protocol` instead of requiring a single adapter.
|
||||
- [x] Add an adapter registry so `LLMClient.make(...)` can choose an adapter by provider/protocol instead of requiring a single adapter.
|
||||
- [x] Add request/response convenience helpers where callsites still expose schema internals, but keep constructors returning canonical Schema class instances.
|
||||
- [x] Expand OpenAI Chat support for assistant tool-call messages followed by tool-result messages.
|
||||
- [x] Add OpenAI Chat recorded tests for tool-result follow-up and usage chunks.
|
||||
@@ -199,11 +201,11 @@ Do not blanket re-record an entire test file when adding one cassette. `RECORD=t
|
||||
|
||||
### Provider Coverage
|
||||
|
||||
- [x] Add a generic OpenAI-compatible Chat adapter for non-OpenAI providers that expose `/chat/completions`; use `../ai/packages/openai-compatible` as the behavior reference.
|
||||
- [ ] Keep OpenAI Responses as a separate first-class protocol for providers that actually implement `/responses`; do not treat generic OpenAI-compatible providers as Responses-capable by default.
|
||||
- [x] Add a generic OpenAI-compatible Chat adapter for non-OpenAI providers that expose `/chat/completions`.
|
||||
- [x] Keep OpenAI Responses as a separate first-class protocol for providers that actually implement `/responses`; do not treat generic OpenAI-compatible providers as Responses-capable by default.
|
||||
- [x] Cover OpenAI-compatible provider families that can share the generic adapter first: DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, DeepInfra, and similar providers.
|
||||
- [ ] Decide which providers need thin dedicated wrappers over OpenAI-compatible Chat because they have custom parsing/options: Mistral, Groq, xAI, Perplexity, and Cohere.
|
||||
- [x] Add Bedrock Converse support: wire format (messages / system / inferenceConfig / toolConfig), AWS event stream binary framing via `@smithy/eventstream-codec`, SigV4 signing via `aws4fetch` (or Bearer API key path), text/reasoning/tool/usage/finish decoding, deterministic + recorded integration tests. Cache hints, image/document content, and additional model-specific fields are still TODO.
|
||||
- [x] Add Bedrock Converse support: wire format (messages / system / inferenceConfig / toolConfig), AWS event stream binary framing via `@smithy/eventstream-codec`, SigV4 signing via `aws4fetch` (or Bearer API key path), text/reasoning/tool/usage/finish decoding, cache hints, image/document content, deterministic tests, and recorded basic text/tool cassettes. Additional model-specific fields are still TODO.
|
||||
- [ ] Decide Vertex shape after Bedrock/OpenAI-compatible are stable: Vertex Gemini as Gemini target/http patch vs adapter, and Vertex Anthropic as Anthropic target/http patch vs adapter.
|
||||
- [ ] Add Gateway/OpenRouter-style routing support only after the generic OpenAI-compatible adapter and provider option patch model are stable.
|
||||
|
||||
@@ -220,20 +222,38 @@ Do not blanket re-record an entire test file when adding one cassette. `RECORD=t
|
||||
|
||||
### OpenCode Bridge
|
||||
|
||||
- [ ] Build a `Provider.Model` -> `LLM.ModelRef` bridge for OpenCode, including protocol selection, base URLs, headers, limits, capabilities, native provider metadata, and OpenAI-compatible provider family detection.
|
||||
- [ ] Build a `session.llm` -> `LLM.request(...)` bridge for system prompts, message history, tools, tool choice, generation options, reasoning variants, cache hints, and attachments.
|
||||
- [x] Build a `Provider.Model` -> `LLM.ModelRef` bridge for OpenCode, including protocol selection, base URLs, headers, limits, capabilities, native provider metadata, and OpenAI-compatible provider family detection.
|
||||
- [x] Build a pure `session.llm` -> `LLM.request(...)` bridge for system prompts, message history, tool definitions, tool choice, generation options, reasoning variants, cache hints, and attachments.
|
||||
- [x] Add a typed `ToolRuntime` that drives the tool loop with Schema-typed parameters/success per tool, single-`ToolFailure` error channel, and `maxSteps`/`stopWhen` controls.
|
||||
- [x] Provider-defined tool pass-through: `providerExecuted` flag on `tool-call`/`tool-result` events; Anthropic `server_tool_use` / `web_search_tool_result` / `code_execution_tool_result` / `web_fetch_tool_result` round-trip; OpenAI Responses hosted-tool items decoded as `tool-call` + `tool-result` pairs; runtime skips client dispatch when `providerExecuted: true`.
|
||||
- [ ] Keep auth and deployment concerns in the OpenCode bridge where possible: Bedrock credentials/region/profile, Vertex project/location/token, Azure deployment/API version, and Gateway/OpenRouter routing headers.
|
||||
- [ ] Keep initial OpenCode integration behind a local flag/path until request payload parity and stream event parity are proven against the existing `session/llm.test.ts` cases.
|
||||
|
||||
### Native OpenCode Rollout
|
||||
|
||||
- [x] Add a native event bridge that maps `LLMEvent` streams into the existing `SessionProcessor` event contract without creating a second processor.
|
||||
- [ ] Extract runtime-neutral OpenCode tool resolution from `SessionPrompt.resolveTools`, then build both existing-stream and native `@opencode-ai/llm` tool adapters from the same resolved shape.
|
||||
- [ ] Map `Permission.RejectedError`, `Permission.CorrectedError`, validation failures, thrown tool failures, and aborts into model-visible native tool error/results.
|
||||
- [ ] Wire a native stream producer behind an explicit local flag and provider allowlist; the producer should consume `nativeMessages`, call `LLMNative.request(...)`, stream through `LLMClient.make(...)`, and feed `LLMNativeEvents.mapper()` into `SessionProcessor`.
|
||||
- [ ] Add end-to-end native stream tests through the actual session loop for text, reasoning, tool-call streaming, tool success, rejected permission, corrected permission, thrown tool error, abort, and provider-executed tool history.
|
||||
- [ ] Dogfood native streaming with the flag enabled for OpenAI first, then Anthropic, Gemini, OpenAI-compatible providers, Bedrock, and Copilot provider-by-provider.
|
||||
- [ ] Flip native streaming to default only after request parity, stream parity, tool execution, typecheck, focused provider tests, recorded cassettes, and manual dogfood pass for the enabled provider set.
|
||||
- [ ] Keep the existing stream path as an opt-out fallback during soak; remove it only after native default has proven stable.
|
||||
|
||||
### Test And Recording Gaps
|
||||
|
||||
- [x] Harden the generic HTTP recorder before adding more live cassettes: secret scanning before writes, sensitive header/query redaction, response/body secret scanning, and clear failure messages that identify the unsafe field without printing the secret.
|
||||
- [x] Refactor the recorder toward extractable library boundaries: core HTTP cassette schema/matching/redaction/diffing should stay LLM-agnostic; LLM tests should supply metadata and semantic assertions from a thin wrapper.
|
||||
- [x] Add cassette metadata support: recorder schema version, recorded timestamp, scenario name, tags, and caller-provided subject metadata such as provider/protocol/model/capabilities without making the core recorder depend on LLM concepts.
|
||||
- [x] Improve replay mismatch diagnostics: show method/URL/header/body diffs and closest recorded interaction while keeping secrets redacted. Unused-interaction reporting is still TODO if a test needs it.
|
||||
- [ ] Add a cassette doctor command/test helper that validates schema versions, detects secrets, checks duplicate or unused interactions where possible, and reports cassette coverage by provider/protocol/scenario.
|
||||
- [ ] Add semantic replay assertions for LLM cassettes: replay raw HTTP, parse provider streams, and compare normalized `LLMEvent[]` or `LLMResponse` snapshots in addition to request matching.
|
||||
- [ ] Add stream chunk-boundary fuzzing for text/SSE cassettes so parser tests prove correctness independent of provider chunk boundaries.
|
||||
- [ ] Keep deterministic coverage for malformed chunks and tool arguments that arrive in the first chunk unless a live provider reliably produces those shapes.
|
||||
- [x] Cover provider-error and HTTP-status sad paths with deterministic fixtures across adapters (Anthropic mid-stream + 4xx; OpenAI Responses mid-stream + 4xx; OpenAI Chat 4xx). Live recordings of provider errors are still TODO when stable cassettes can be captured.
|
||||
- [x] Improve cassette ergonomics for multi-interaction flows: pretty-printed JSON for diff-friendly cassettes, `sequentialMatcher` for ordered dispatch, and a recorded tool-loop scaffold (`openai-chat-tool-loop.recorded.test.ts`).
|
||||
- [ ] Mirror OpenCode request-body parity tests through the new LLM path for OpenAI Responses, Anthropic Messages, Gemini, OpenAI-compatible Chat, and Bedrock once supported.
|
||||
- [x] Add adapter parity fixtures against `../ai` behavior for generic OpenAI-compatible Chat before adding provider-specific wrappers.
|
||||
- [x] Improve cassette ergonomics for multi-interaction flows: pretty-printed JSON for diff-friendly cassettes, explicit sequential dispatch, and a recorded tool-loop scaffold (`openai-chat-tool-loop.recorded.test.ts`).
|
||||
- [x] Mirror OpenCode request-body parity tests through the new LLM path for OpenAI Responses, Anthropic Messages, Gemini, OpenAI-compatible Chat, and Bedrock once supported.
|
||||
- [x] Add adapter parity fixtures for generic OpenAI-compatible Chat before adding provider-specific wrappers.
|
||||
|
||||
### Recorded Cassette Backlog
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { layer as recordReplayLayer, sequentialMatcher } from "./record-replay"
|
||||
|
||||
const post = (url: string, body: object) =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const request = HttpClientRequest.post(url, {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: HttpBody.text(JSON.stringify(body), "application/json"),
|
||||
})
|
||||
const response = yield* http.execute(request)
|
||||
return yield* response.text
|
||||
})
|
||||
|
||||
describe("record-replay", () => {
|
||||
testEffect(recordReplayLayer("record-replay/multi-step")).effect(
|
||||
"default matcher dispatches multi-interaction cassettes by request shape",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// Out-of-order requests still resolve to their matching recorded
|
||||
// interactions because the default matcher is structural.
|
||||
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
|
||||
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(recordReplayLayer("record-replay/retry", { match: sequentialMatcher })).effect(
|
||||
"sequential matcher returns recorded responses in order for identical requests",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// Both requests are byte-identical; the cursor advances so each call
|
||||
// gets its own recorded response.
|
||||
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"}')
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(recordReplayLayer("record-replay/retry")).effect(
|
||||
"default matcher returns the first match for identical requests (find-first)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// With the default structural matcher, identical requests collapse to
|
||||
// the first recorded response — sequentialMatcher is required to walk
|
||||
// the cassette in order.
|
||||
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"}')
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(recordReplayLayer("record-replay/multi-step", { match: sequentialMatcher })).effect(
|
||||
"sequential matcher reports cursor exhaustion when more requests are made than recorded",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* post("https://example.test/echo", { step: 1 })
|
||||
yield* post("https://example.test/echo", { step: 2 })
|
||||
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,311 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
const RequestSnapshot = Schema.Struct({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
})
|
||||
type RequestSnapshot = Schema.Schema.Type<typeof RequestSnapshot>
|
||||
|
||||
const ResponseSnapshot = Schema.Struct({
|
||||
status: Schema.Number,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
// Most provider responses are text (SSE, JSON). AWS Bedrock streams are
|
||||
// binary AWS event-stream frames whose CRC32 fields would mangle through a
|
||||
// UTF-8 round-trip — store those as base64. Older cassettes omit this field
|
||||
// and decode as text by default.
|
||||
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
|
||||
})
|
||||
|
||||
const Interaction = Schema.Struct({
|
||||
request: RequestSnapshot,
|
||||
response: ResponseSnapshot,
|
||||
})
|
||||
type Interaction = Schema.Schema.Type<typeof Interaction>
|
||||
|
||||
const Cassette = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
interactions: Schema.Array(Interaction),
|
||||
})
|
||||
|
||||
const decodeCassette = Schema.decodeUnknownSync(Cassette)
|
||||
const encodeCassette = Schema.encodeSync(Cassette)
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
|
||||
const isRecordMode = process.env.RECORD === "true"
|
||||
|
||||
const fixturePath = (name: string) => path.join(FIXTURES_DIR, `${name}.json`)
|
||||
|
||||
/**
|
||||
* Default request header allow-list. Provider adapters with custom auth
|
||||
* (Anthropic `x-api-key`, Bedrock SigV4, etc.) should extend this via the
|
||||
* `requestHeaders` option so cassette matching uses the right keys.
|
||||
*/
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = [
|
||||
"content-type",
|
||||
"accept",
|
||||
"openai-beta",
|
||||
]
|
||||
|
||||
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
export interface RecordReplayOptions {
|
||||
/**
|
||||
* Lower-cased request header names that participate in cassette matching and
|
||||
* are persisted to disk. Anything not in this list is dropped.
|
||||
*/
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
/**
|
||||
* Lower-cased response header names persisted to disk. Defaults to
|
||||
* `content-type` only. Add `x-request-id`, rate-limit headers, etc. when a
|
||||
* test depends on them.
|
||||
*/
|
||||
readonly responseHeaders?: ReadonlyArray<string>
|
||||
/**
|
||||
* Hook to redact secrets from request bodies before they are written. Runs
|
||||
* on the parsed JSON value when the body decodes as JSON; non-JSON bodies
|
||||
* pass through untouched.
|
||||
*/
|
||||
readonly redactBody?: (body: unknown) => unknown
|
||||
/**
|
||||
* Custom request matcher. Defaults to `defaultMatcher`, which compares
|
||||
* method, url, structurally-canonical JSON body, and the allow-listed
|
||||
* headers against any recorded interaction. Use `sequentialMatcher` for
|
||||
* multi-interaction cassettes where two requests in a row may be
|
||||
* structurally identical (retry / repeated polling) and should map to
|
||||
* recorded responses by position.
|
||||
*/
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
|
||||
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
|
||||
|
||||
/**
|
||||
* Sort object keys recursively so two semantically equal JSON values produce
|
||||
* the same string. Arrays preserve order — provider request bodies care about
|
||||
* `messages` ordering.
|
||||
*/
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalize)
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value as Record<string, unknown>)
|
||||
.toSorted()
|
||||
.map((key) => [key, canonicalize((value as Record<string, unknown>)[key])]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
JSON.stringify({
|
||||
method: snapshot.method,
|
||||
url: snapshot.url,
|
||||
headers: canonicalize(snapshot.headers),
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: canonicalize,
|
||||
}),
|
||||
})
|
||||
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
|
||||
/**
|
||||
* Sentinel matcher that signals position-based dispatch. The replay layer
|
||||
* detects this matcher by reference identity and consumes interactions in
|
||||
* recorded order, regardless of whether two requests produce the same
|
||||
* canonical snapshot. Use for retries or repeated polling that expect
|
||||
* different responses to identical requests.
|
||||
*/
|
||||
export const sequentialMatcher: RequestMatcher = () => true
|
||||
|
||||
const lowerHeaders = (headers: Record<string, string>, allow: ReadonlyArray<string>) => {
|
||||
const allowed = new Set(allow.map((name) => name.toLowerCase()))
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([name, value]) => [name.toLowerCase(), value] as const)
|
||||
.filter(([name]) => allowed.has(name))
|
||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
}
|
||||
|
||||
const responseHeaders = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
allow: ReadonlyArray<string>,
|
||||
) => {
|
||||
const merged = lowerHeaders(response.headers as Record<string, string>, allow)
|
||||
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
|
||||
return merged
|
||||
}
|
||||
|
||||
// Content types whose payloads are binary frames or arbitrary bytes — they
|
||||
// would not survive a UTF-8 text round-trip. The list intentionally matches
|
||||
// the substrings that appear in `Content-Type` headers, not full values.
|
||||
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = [
|
||||
"vnd.amazon.eventstream",
|
||||
"octet-stream",
|
||||
]
|
||||
|
||||
const isBinaryContentType = (contentType: string | undefined) => {
|
||||
if (!contentType) return false
|
||||
const lower = contentType.toLowerCase()
|
||||
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
|
||||
}
|
||||
|
||||
const captureResponseBody = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
contentType: string | undefined,
|
||||
) =>
|
||||
isBinaryContentType(contentType)
|
||||
? response.arrayBuffer.pipe(
|
||||
Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })),
|
||||
)
|
||||
: response.text.pipe(Effect.map((body) => ({ body })))
|
||||
|
||||
const decodeResponseBody = (snapshot: Schema.Schema.Type<typeof ResponseSnapshot>) =>
|
||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||
|
||||
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request,
|
||||
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request,
|
||||
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Cassettes are JSON edited by humans. Pretty-print with two-space indent so
|
||||
* multi-interaction cassettes diff cleanly. `Schema.encodeSync` returns a
|
||||
* JSON-compatible value; `JSON.stringify` is used here only to control
|
||||
* formatting, not for schema serialization.
|
||||
*/
|
||||
const formatCassette = (interactions: ReadonlyArray<Interaction>) =>
|
||||
`${JSON.stringify(encodeCassette({ version: 1, interactions }), null, 2)}\n`
|
||||
|
||||
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const hasFixtureSync = (name: string) => {
|
||||
if (!fs.existsSync(fixturePath(name))) return false
|
||||
return Option.isSome(
|
||||
Option.liftThrowable(parseCassette)(fs.readFileSync(fixturePath(name), "utf8")),
|
||||
)
|
||||
}
|
||||
|
||||
export const layer = (
|
||||
name: string,
|
||||
options: RecordReplayOptions = {},
|
||||
): Layer.Layer<HttpClient.HttpClient> =>
|
||||
Layer.effect(
|
||||
HttpClient.HttpClient,
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* HttpClient.HttpClient
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const file = fixturePath(name)
|
||||
const dir = path.dirname(file)
|
||||
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
|
||||
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
|
||||
const match = options.match ?? defaultMatcher
|
||||
const sequential = match === sequentialMatcher
|
||||
const recorded = yield* Ref.make<ReadonlyArray<Interaction>>([])
|
||||
const cursor = yield* Ref.make(0)
|
||||
|
||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
const raw = yield* Effect.promise(() => web.text())
|
||||
const redact = options.redactBody
|
||||
const body = redact
|
||||
? Option.match(decodeJson(raw), {
|
||||
onNone: () => raw,
|
||||
onSome: (parsed) => JSON.stringify(redact(parsed)),
|
||||
})
|
||||
: raw
|
||||
return {
|
||||
method: web.method,
|
||||
url: web.url,
|
||||
headers: lowerHeaders(Object.fromEntries(web.headers.entries()), requestHeadersAllow),
|
||||
body,
|
||||
}
|
||||
})
|
||||
|
||||
const selectInteraction = (
|
||||
cassette: Schema.Schema.Type<typeof Cassette>,
|
||||
incoming: RequestSnapshot,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
if (sequential) {
|
||||
const index = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
|
||||
const interaction = cassette.interactions[index]
|
||||
return {
|
||||
interaction,
|
||||
detail: `interaction ${index + 1} of ${cassette.interactions.length} not recorded`,
|
||||
}
|
||||
}
|
||||
const incomingCanonical = canonicalSnapshot(incoming)
|
||||
const interaction =
|
||||
match === defaultMatcher
|
||||
? cassette.interactions.find(
|
||||
(candidate) => canonicalSnapshot(candidate.request) === incomingCanonical,
|
||||
)
|
||||
: cassette.interactions.find((candidate) => match(incoming, candidate.request))
|
||||
return { interaction, detail: "no recorded interaction matched" }
|
||||
})
|
||||
|
||||
return HttpClient.make((request) => {
|
||||
if (isRecordMode) {
|
||||
return Effect.gen(function* () {
|
||||
const currentRequest = yield* snapshotRequest(request)
|
||||
const response = yield* upstream.execute(request)
|
||||
const headers = responseHeaders(response, responseHeadersAllow)
|
||||
const captured = yield* captureResponseBody(response, headers["content-type"])
|
||||
const interaction: Interaction = {
|
||||
request: currentRequest,
|
||||
response: { status: response.status, headers, ...captured },
|
||||
}
|
||||
const interactions = yield* Ref.updateAndGet(recorded, (prev) => [...prev, interaction])
|
||||
yield* fileSystem.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* fileSystem.writeFileString(file, formatCassette(interactions)).pipe(Effect.orDie)
|
||||
return HttpClientResponse.fromWeb(request, new Response(decodeResponseBody(interaction.response), interaction.response))
|
||||
})
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const cassette = parseCassette(
|
||||
yield* fileSystem.readFileString(file).pipe(Effect.mapError(() => fixtureMissing(request, name))),
|
||||
)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
||||
|
||||
return HttpClientResponse.fromWeb(request, new Response(decodeResponseBody(interaction.response), interaction.response))
|
||||
})
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer))
|
||||
@@ -1,25 +1,27 @@
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { RequestExecutor } from "../src/executor"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import {
|
||||
hasFixtureSync,
|
||||
layer as recordReplayLayer,
|
||||
type RecordReplayOptions,
|
||||
} from "./record-replay"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
|
||||
type RecordedTestsOptions = {
|
||||
readonly prefix: string
|
||||
readonly requires?: ReadonlyArray<string>
|
||||
readonly options?: RecordReplayOptions
|
||||
readonly options?: HttpRecorder.RecordReplayOptions
|
||||
}
|
||||
|
||||
type RecordedCaseOptions = {
|
||||
readonly cassette?: string
|
||||
readonly id?: string
|
||||
readonly requires?: ReadonlyArray<string>
|
||||
readonly options?: RecordReplayOptions
|
||||
readonly options?: HttpRecorder.RecordReplayOptions
|
||||
}
|
||||
|
||||
const kebab = (value: string) =>
|
||||
@@ -33,7 +35,20 @@ const kebab = (value: string) =>
|
||||
const missingEnv = (names: ReadonlyArray<string>) => names.filter((name) => !process.env[name])
|
||||
|
||||
const cassetteName = (prefix: string, name: string, options: RecordedCaseOptions) =>
|
||||
options.cassette ?? `${prefix}/${kebab(name)}`
|
||||
options.cassette ?? `${prefix}/${options.id ?? kebab(name)}`
|
||||
|
||||
const mergeOptions = (
|
||||
base: HttpRecorder.RecordReplayOptions | undefined,
|
||||
override: HttpRecorder.RecordReplayOptions | undefined,
|
||||
) => {
|
||||
if (!base) return override
|
||||
if (!override) return base
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
metadata: base.metadata || override.metadata ? { ...(base.metadata ?? {}), ...(override.metadata ?? {}) } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const recordedTests = (options: RecordedTestsOptions) => {
|
||||
// Scoped to this `recordedTests` group rather than module-global so two
|
||||
@@ -51,17 +66,21 @@ export const recordedTests = (options: RecordedTestsOptions) => {
|
||||
if (cassettes.has(cassette)) throw new Error(`Duplicate recorded cassette "${cassette}"`)
|
||||
cassettes.add(cassette)
|
||||
|
||||
const layerOptions = {
|
||||
directory: FIXTURES_DIR,
|
||||
...mergeOptions(options.options, caseOptions.options),
|
||||
}
|
||||
|
||||
if (process.env.RECORD === "true") {
|
||||
if (missingEnv([...(options.requires ?? []), ...(caseOptions.requires ?? [])]).length > 0) {
|
||||
return test.skip(name, () => {}, testOptions)
|
||||
}
|
||||
} else if (!hasFixtureSync(cassette)) {
|
||||
} else if (!HttpRecorder.hasCassetteSync(cassette, layerOptions)) {
|
||||
return test.skip(name, () => {}, testOptions)
|
||||
}
|
||||
|
||||
const layerOptions = caseOptions.options ?? options.options
|
||||
return testEffect(
|
||||
RequestExecutor.layer.pipe(Layer.provide(recordReplayLayer(cassette, layerOptions))),
|
||||
RequestExecutor.layer.pipe(Layer.provide(HttpRecorder.cassetteLayer(cassette, layerOptions))),
|
||||
).live(name, body, testOptions)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user