feat(http-recorder): prepare public beta release (#31018)

This commit is contained in:
Kit Langton 2026-06-05 23:01:26 -04:00 committed by GitHub
commit 54f4974546
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2254 additions and 690 deletions

View file

@ -314,7 +314,7 @@ Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `reco
Filters apply in replay and record mode. Combine them with `RECORD=true` when refreshing only one provider or scenario.
**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). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip.
**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.

View file

@ -1,4 +1,3 @@
import { Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../../src"
@ -33,7 +32,7 @@ const recorded = recordedTests({
// 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"] } }),
redact: { allowRequestHeaders: ["anthropic-version"] },
},
})

View file

@ -1,4 +1,3 @@
import { Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
@ -30,7 +29,7 @@ const recorded = recordedTests({
provider: "anthropic",
protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
})
describe("Anthropic Messages sad-path recorded", () => {

View file

@ -1,4 +1,3 @@
import { Redactor } from "@opencode-ai/http-recorder"
import * as Anthropic from "../../src/providers/anthropic"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import * as Google from "../../src/providers/google"
@ -64,7 +63,7 @@ const redactCloudflareURL = (url: string) =>
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
const cloudflareOptions = {
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
redact: { url: redactCloudflareURL },
}
describeRecordedGoldenScenarios([
@ -103,7 +102,7 @@ describeRecordedGoldenScenarios([
prefix: "anthropic-messages",
model: anthropicHaiku,
requires: ["ANTHROPIC_API_KEY"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
scenarios: ["text", "tool-call"],
},
{
@ -112,7 +111,7 @@ describeRecordedGoldenScenarios([
model: anthropicOpus,
requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
scenarios: [
{ id: "tool-loop", temperature: false },
{ id: "image-tool-result", temperature: false, maxTokens: 40 },

View file

@ -29,7 +29,7 @@ type TargetInput = {
readonly prefix?: string
readonly tags?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly options?: HttpRecorder.RecordReplayOptions
readonly options?: HttpRecorder.RecorderOptions
readonly scenarios: ReadonlyArray<ScenarioInput>
}

View file

@ -1,5 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as path from "node:path"
@ -21,16 +22,16 @@ const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecordReplayOptions
readonly options?: HttpRecorder.RecorderOptions
}
type RecordedCaseOptions = RunnerCaseOptions & {
readonly options?: HttpRecorder.RecordReplayOptions
readonly options?: HttpRecorder.RecorderOptions
}
const mergeOptions = (
base: HttpRecorder.RecordReplayOptions | undefined,
override: HttpRecorder.RecordReplayOptions | undefined,
base: HttpRecorder.RecorderOptions | undefined,
override: HttpRecorder.RecorderOptions | undefined,
) => {
if (!base) return override
if (!override) return base
@ -38,6 +39,24 @@ const mergeOptions = (
...base,
...override,
metadata: base.metadata || override.metadata ? { ...base.metadata, ...override.metadata } : undefined,
redact:
base.redact || override.redact
? {
...base.redact,
...override.redact,
headers: [...(base.redact?.headers ?? []), ...(override.redact?.headers ?? [])],
allowRequestHeaders: [
...(base.redact?.allowRequestHeaders ?? []),
...(override.redact?.allowRequestHeaders ?? []),
],
allowResponseHeaders: [
...(base.redact?.allowResponseHeaders ?? []),
...(override.redact?.allowResponseHeaders ?? []),
],
queryParameters: [...(base.redact?.queryParameters ?? []), ...(override.redact?.queryParameters ?? [])],
jsonFields: [...(base.redact?.jsonFields ?? []), ...(override.redact?.jsonFields ?? [])],
}
: undefined,
}
}
@ -45,23 +64,24 @@ export const recordedTests = (options: RecordedTestsOptions) =>
recordedEffectGroup<RecordedEnv, never, RecordedTestsOptions, RecordedCaseOptions>({
duplicateLabel: "recorded cassette",
options,
cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }),
cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }),
layer: ({ cassette, metadata, options, caseOptions, recording }) => {
const recorderOptions = mergeOptions(options.options, caseOptions.options)
const recorderMetadata = {
...recorderOptions?.metadata,
...metadata,
}
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
const mode = recording ? "record" : "replay"
const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
Layer.provide(NodeFileSystem.layer),
)
const requestExecutor = RequestExecutor.layer.pipe(
Layer.provide(
HttpRecorder.recordingLayer(cassette, {
...recorderOptions,
HttpRecorderInternal.recordingLayer(cassette, {
mode,
metadata: recorderMetadata,
redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact),
match: recorderOptions?.match,
}).pipe(Layer.provide(FetchHttpClient.layer)),
),
)

View file

@ -1,4 +1,4 @@
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import { Effect, Layer } from "effect"
import { WebSocketExecutor } from "../src/route"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@ -7,13 +7,13 @@ const liveWebSocket = WebSocketExecutor.open
export const webSocketCassetteLayer = (
cassette: string,
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
input: { readonly metadata?: Record<string, unknown>; readonly mode: HttpRecorderInternal.RecordReplayMode },
): Layer.Layer<WebSocketExecutorService, never, HttpRecorderInternal.Cassette.Service> =>
Layer.effect(
WebSocketExecutor.Service,
Effect.gen(function* () {
const cassetteService = yield* Cassette.Service
const executor = yield* makeWebSocketExecutor({
const cassetteService = yield* HttpRecorderInternal.Cassette.Service
const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({
name: cassette,
mode: input.mode,
metadata: input.metadata,