feat(http-recorder): prepare public beta release (#31018)
This commit is contained in:
parent
ba57718b05
commit
54f4974546
36 changed files with 2254 additions and 690 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue