feat(llm): support multi-interaction cassettes with sequential matcher
The cassette layer already stored interactions in an array, but replay always used find-first structural matching and cassettes were written as one minified JSON line. That makes tool-loop and retry recordings unworkable: identical requests collapse to one response, and large recordings are unreadable on review. - Add `sequentialMatcher` for position-based dispatch so identical retries map to recorded responses in order via an internal cursor. - Pretty-print cassette JSON on write and reformat existing fixtures so multi-interaction diffs stay reviewable. - Add deterministic `record-replay.test.ts` covering default vs sequential dispatch and cursor exhaustion. - Add an OpenAI Chat tool-loop recorded test scaffold gated behind `OPENAI_API_KEY` so a single `RECORD=true` run captures every model round of the loop into one cassette file. - Update AGENTS.md to document multi-interaction cassettes and the matcher options, and mark the cassette ergonomics TODO complete.
This commit is contained in:
+21
-3
@@ -137,7 +137,7 @@ Provider-defined tools (e.g. OpenAI built-in `web_search`) should go directly in
|
||||
|
||||
### Recording Tests
|
||||
|
||||
Recorded tests use one cassette per scenario. Use `recordedTests({ prefix, requires })` and let the helper derive cassette names from test names:
|
||||
Recorded tests use one cassette file per scenario. A cassette holds an ordered array of `{ request, response }` interactions, so multi-step flows (tool loops, retries, polling) record into a single file. Use `recordedTests({ prefix, requires })` and let the helper derive cassette names from test names:
|
||||
|
||||
```ts
|
||||
const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] })
|
||||
@@ -147,7 +147,9 @@ recorded.effect("streams text", () => Effect.gen(function* () {
|
||||
}))
|
||||
```
|
||||
|
||||
Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -199,6 +201,22 @@ Do not blanket re-record an entire test file when adding one cassette. `RECORD=t
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] Improve cassette ergonomics if more providers need custom matching, redaction, or multi-interaction flows.
|
||||
- [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.
|
||||
|
||||
### Recorded Cassette Backlog
|
||||
|
||||
- [x] DeepSeek OpenAI-compatible Chat basic streaming text.
|
||||
- [ ] DeepSeek OpenAI-compatible Chat tool call and tool-result follow-up.
|
||||
- [ ] DeepSeek reasoning output, including any interleaved reasoning fields the live API emits.
|
||||
- [x] TogetherAI OpenAI-compatible Chat basic streaming text and tool-call flow.
|
||||
- [ ] Cerebras OpenAI-compatible Chat basic streaming text and tool-call flow.
|
||||
- [ ] Baseten OpenAI-compatible Chat basic streaming text and deployed-model request shape.
|
||||
- [ ] Fireworks OpenAI-compatible Chat basic streaming text and tool-call flow.
|
||||
- [ ] DeepInfra OpenAI-compatible Chat basic streaming text and tool-call flow.
|
||||
- [ ] Provider-error cassettes for stable, non-secret error bodies where the provider returns deterministic 4xx/5xx payloads.
|
||||
- [ ] Mistral, Groq, xAI, Perplexity, and Cohere basic/tool cassettes after deciding whether each stays generic OpenAI-compatible or gets a thin wrapper.
|
||||
- [ ] Bedrock Converse basic text, tool use/result, and cache-hint cassettes after Bedrock support lands.
|
||||
- [ ] Vertex Gemini and Vertex Anthropic basic/tool cassettes after the Vertex adapter/patch shape is decided.
|
||||
- [ ] Gateway/OpenRouter routing-header cassettes after routing support lands.
|
||||
|
||||
+23
-1
@@ -1 +1,23 @@
|
||||
{"version":1,"interactions":[{"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","headers":{"anthropic-version":"2023-06-01","content-type":"application/json"},"body":"{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}"},"response":{"status":200,"headers":{"content-type":"text/event-stream; charset=utf-8"},"body":"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01KoNnF4BwRtd6tnJMPxZ9cP\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"}}]}
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01KoNnF4BwRtd6tnJMPxZ9cP\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+23
-1
@@ -1 +1,23 @@
|
||||
{"version":1,"interactions":[{"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","headers":{"anthropic-version":"2023-06-01","content-type":"application/json"},"body":"{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"},"response":{"status":200,"headers":{"content-type":"text/event-stream; charset=utf-8"},"body":"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01WHRa8Ez2u3AHvd3iBZUY9B\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01NGuJD7Pku4wqQzegRiBDyH\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\": \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"}}]}
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01WHRa8Ez2u3AHvd3iBZUY9B\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01NGuJD7Pku4wqQzegRiBDyH\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\": \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
{"version":1,"interactions":[{"request":{"method":"POST","url":"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse","headers":{"content-type":"application/json"},"body":"{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly: Hello!\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are concise.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}"},"response":{"status":200,"headers":{"content-type":"text/event-stream"},"body":"data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello!\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 11,\"candidatesTokenCount\": 2,\"totalTokenCount\": 29,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 11}],\"thoughtsTokenCount\": 16},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"6yPuacHYOpaM_PUPjuPS-QY\"}\r\n\r\n"}}]}
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly: Hello!\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are concise.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello!\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 11,\"candidatesTokenCount\": 2,\"totalTokenCount\": 29,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 11}],\"thoughtsTokenCount\": 16},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"6yPuacHYOpaM_PUPjuPS-QY\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
{"version":1,"interactions":[{"request":{"method":"POST","url":"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse","headers":{"content-type":"application/json"},"body":"{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call tools exactly as requested.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"ANY\",\"allowedFunctionNames\":[\"get_weather\"]}},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}"},"response":{"status":200,"headers":{"content-type":"text/event-stream"},"body":"data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"get_weather\",\"args\": {\"city\": \"Paris\"}},\"thoughtSignature\": \"CiQBDDnWx1Wa8wdQFKg1mZSiIXMV8OQoLRuNAVBvIEbTnK+JZIYKXwEMOdbHNwv+GOeRqLuxSIVGQHFQm6H+rKKHGHPmy6UaloPEMTQXwVbkxF14kkGw7cMkSil8QtVPYFpn5ifMfU52Jiu2Vwtg2kP3oslPe3S/AhVryZrAq76GW3PwQfANCoUBAQw51sfnPZfKTxeqadxnkqPhYfFkyzsKQkBC9SslWD1P2MqINxvw7umCCNp/rktAZ6tuS+lOQk5TwueD2nWT/saJgGyYheQZ8eZob8wrPF8jWLPeSemdymAujF4EDeuNPRxxz3ToWy2xv66NiTWpgQeJ1Rvy01S2RPHG4W5uYcPXx82nig==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 55,\"candidatesTokenCount\": 15,\"totalTokenCount\": 115,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 55}],\"thoughtsTokenCount\": 45},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"7CPuaa_aIcWb_uMP1Ia0wQ8\"}\r\n\r\n"}}]}
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call tools exactly as requested.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"ANY\",\"allowedFunctionNames\":[\"get_weather\"]}},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"get_weather\",\"args\": {\"city\": \"Paris\"}},\"thoughtSignature\": \"CiQBDDnWx1Wa8wdQFKg1mZSiIXMV8OQoLRuNAVBvIEbTnK+JZIYKXwEMOdbHNwv+GOeRqLuxSIVGQHFQm6H+rKKHGHPmy6UaloPEMTQXwVbkxF14kkGw7cMkSil8QtVPYFpn5ifMfU52Jiu2Vwtg2kP3oslPe3S/AhVryZrAq76GW3PwQfANCoUBAQw51sfnPZfKTxeqadxnkqPhYfFkyzsKQkBC9SslWD1P2MqINxvw7umCCNp/rktAZ6tuS+lOQk5TwueD2nWT/saJgGyYheQZ8eZob8wrPF8jWLPeSemdymAujF4EDeuNPRxxz3ToWy2xv66NiTWpgQeJ1Rvy01S2RPHG4W5uYcPXx82nig==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 55,\"candidatesTokenCount\": 15,\"totalTokenCount\": 115,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 55}],\"thoughtsTokenCount\": 45},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"7CPuaa_aIcWb_uMP1Ia0wQ8\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+22
-1
File diff suppressed because one or more lines are too long
@@ -1 +1,22 @@
|
||||
{"version":1,"interactions":[{"request":{"method":"POST","url":"https://api.openai.com/v1/chat/completions","headers":{"content-type":"application/json"},"body":"{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"},"response":{"status":200,"headers":{"content-type":"text/event-stream; charset=utf-8"},"body":"data: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"8eW5zjxaM\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gN5i1d\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"RTB2IEbEwD\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"tDwfN\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"1WjSJTjm2Ro\"}\n\ndata: [DONE]\n\n"}}]}
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"8eW5zjxaM\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gN5i1d\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"RTB2IEbEwD\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"tDwfN\"}\n\ndata: {\"id\":\"chatcmpl-DYg89O0X3aMclG677PAt0DbFpu1tu\",\"object\":\"chat.completion.chunk\",\"created\":1777157473,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c42fec8f39\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"1WjSJTjm2Ro\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
{"version":1,"interactions":[{"request":{"method":"POST","url":"https://api.openai.com/v1/chat/completions","headers":{"content-type":"application/json"},"body":"{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"},"response":{"status":200,"headers":{"content-type":"text/event-stream; charset=utf-8"},"body":"data: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_FmHTQJayvEiTFwo0Y0jRnzpP\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"1\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"TQpWHk4roxU\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"Z50IeXDYRD\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"hW3KExoOm\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"xnmdjMOFx\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"EOlCmc9C5M0\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"obfuscation\":\"Ut\"}\n\ndata: [DONE]\n\n"}}]}
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_FmHTQJayvEiTFwo0Y0jRnzpP\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"1\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"TQpWHk4roxU\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"Z50IeXDYRD\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"hW3KExoOm\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"xnmdjMOFx\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"obfuscation\":\"EOlCmc9C5M0\"}\n\ndata: {\"id\":\"chatcmpl-DYg8AOugiXHZ3HGLJNcYz34gycj0u\",\"object\":\"chat.completion.chunk\",\"created\":1777157474,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_c7625e91ee\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"obfuscation\":\"Ut\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+22
-1
@@ -1 +1,22 @@
|
||||
{"version":1,"interactions":[{"request":{"method":"POST","url":"https://api.deepseek.com/v1/chat/completions","headers":{"content-type":"application/json"},"body":"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}"},"response":{"status":200,"headers":{"content-type":"text/event-stream; charset=utf-8"},"body":"data: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n"}}]}
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepseek.com/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.together.xyz/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"oghmHZ6-3pDw3Z-9f26c91b8ff96c99\",\"object\":\"chat.completion.chunk\",\"created\":1777219071,\"choices\":[{\"index\":0,\"text\":\"Hello\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":9906,\"role\":\"assistant\",\"content\":\"Hello\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"oghmHZ6-3pDw3Z-9f26c91b8ff96c99\",\"object\":\"chat.completion.chunk\",\"created\":1777219071,\"choices\":[{\"index\":0,\"text\":\"!\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"!\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"oghmHZ6-3pDw3Z-9f26c91b8ff96c99\",\"object\":\"chat.completion.chunk\",\"created\":1777219071,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"stop\",\"seed\":14312525371252398000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":3,\"total_tokens\":48,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.together.xyz/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"oghmHZ9-6Ng1vN-9f26c91b8f6a4231\",\"object\":\"chat.completion.chunk\",\"created\":1777219071,\"choices\":[{\"index\":0,\"role\":\"assistant\",\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oghmHZ9-6Ng1vN-9f26c91b8f6a4231\",\"object\":\"chat.completion.chunk\",\"created\":1777219071,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"call_vq1o4qjcezbee9rwpnnrr8jc\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oghmHZ9-6Ng1vN-9f26c91b8f6a4231\",\"object\":\"chat.completion.chunk\",\"created\":1777219071,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"oghmHZ9-6Ng1vN-9f26c91b8f6a4231\",\"object\":\"chat.completion.chunk\",\"created\":1777219071,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"seed\":13362809677919627000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":19,\"total_tokens\":213,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://example.test/echo",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"step\":1}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"reply\":\"first\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://example.test/echo",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"step\":2}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"reply\":\"second\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": 1,
|
||||
"interactions": [
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://example.test/poll",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"id\":\"job_1\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"status\":\"pending\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://example.test/poll",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"id\":\"job_1\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"status\":\"complete\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { client } from "../../src/adapter"
|
||||
import { OpenAIChat } from "../../src/provider/openai-chat"
|
||||
import { tool } from "../../src/tool"
|
||||
import { ToolRuntime } from "../../src/tool-runtime"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
// Multi-interaction recorded test: drives the typed `ToolRuntime` against a
|
||||
// live OpenAI Chat endpoint so the cassette captures every model round in
|
||||
// order (model -> tool dispatch -> model). The cassette is only created with
|
||||
// `RECORD=true OPENAI_API_KEY=...`. In replay mode the test is skipped if the
|
||||
// cassette is missing — see `recordedTests` for the gate.
|
||||
|
||||
const model = OpenAIChat.model({
|
||||
id: "gpt-4o-mini",
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
const get_weather = tool({
|
||||
description: "Get current weather for a city.",
|
||||
parameters: Schema.Struct({ city: Schema.String }),
|
||||
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
|
||||
execute: ({ city }) =>
|
||||
Effect.succeed(
|
||||
city === "Paris"
|
||||
? { temperature: 22, condition: "sunny" }
|
||||
: { temperature: 0, condition: "unknown" },
|
||||
),
|
||||
})
|
||||
|
||||
const request = LLM.request({
|
||||
id: "recorded_openai_chat_tool_loop",
|
||||
model,
|
||||
system: "Use the get_weather tool, then answer in one short sentence.",
|
||||
prompt: "What is the weather in Paris?",
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] })
|
||||
const openai = client({ adapters: [OpenAIChat.adapter] })
|
||||
|
||||
describe("OpenAI Chat tool-loop recorded", () => {
|
||||
recorded.effect("drives a tool loop end-to-end", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = Array.from(
|
||||
yield* ToolRuntime.run(openai, { request, tools: { get_weather } }).pipe(Stream.runCollect),
|
||||
)
|
||||
|
||||
// Two model rounds: tool-call + tool-result + final answer. Two
|
||||
// `request-finish` events confirm both interactions in the cassette
|
||||
// were dispatched in order.
|
||||
const finishes = events.filter(LLMEvent.guards["request-finish"])
|
||||
expect(finishes).toHaveLength(2)
|
||||
expect(finishes[0]?.reason).toBe("tool-calls")
|
||||
expect(finishes.at(-1)?.reason).toBe("stop")
|
||||
|
||||
const toolResult = events.find(LLMEvent.guards["tool-result"])
|
||||
expect(toolResult).toMatchObject({
|
||||
type: "tool-result",
|
||||
name: "get_weather",
|
||||
result: { type: "json", value: { temperature: 22, condition: "sunny" } },
|
||||
})
|
||||
|
||||
expect(LLM.outputText({ events })).toContain("Paris")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -18,6 +18,42 @@ const deepseekRequest = LLM.request({
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
|
||||
const togetherModel = OpenAICompatibleChat.togetherai({
|
||||
id: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
const togetherRequest = LLM.request({
|
||||
id: "recorded_togetherai_text",
|
||||
model: togetherModel,
|
||||
system: "You are concise.",
|
||||
prompt: "Reply with exactly: Hello!",
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
|
||||
const getWeather = LLM.tool({
|
||||
name: "get_weather",
|
||||
description: "Get current weather for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
const togetherToolRequest = LLM.request({
|
||||
id: "recorded_togetherai_tool_call",
|
||||
model: togetherModel,
|
||||
system: "Call tools exactly as requested.",
|
||||
prompt: "Call get_weather with city exactly Paris.",
|
||||
tools: [getWeather],
|
||||
toolChoice: LLM.toolChoice(getWeather),
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({ prefix: "openai-compatible-chat" })
|
||||
const llm = client({ adapters: [OpenAICompatibleChat.adapter] })
|
||||
|
||||
@@ -30,4 +66,25 @@ describe("OpenAI-compatible Chat recorded", () => {
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("togetherai streams text", { requires: ["TOGETHER_AI_API_KEY"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* llm.generate(togetherRequest)
|
||||
|
||||
expect(LLM.outputText(response)).toMatch(/^Hello!?$/)
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("togetherai streams tool call", { requires: ["TOGETHER_AI_API_KEY"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* llm.generate(togetherToolRequest)
|
||||
|
||||
expect(response.events.some((event) => event.type === "tool-input-delta")).toBe(true)
|
||||
expect(LLM.outputToolCalls(response)).toEqual([
|
||||
{ type: "tool-call", id: expect.any(String), name: "get_weather", input: { city: "Paris" } },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -39,9 +39,8 @@ const Cassette = Schema.Struct({
|
||||
interactions: Schema.Array(Interaction),
|
||||
})
|
||||
|
||||
const CassetteJson = Schema.fromJsonString(Cassette)
|
||||
const decodeCassetteJson = Schema.decodeUnknownSync(CassetteJson)
|
||||
const encodeCassetteJson = Schema.encodeSync(CassetteJson)
|
||||
const decodeCassette = Schema.decodeUnknownSync(Cassette)
|
||||
const encodeCassette = Schema.encodeSync(Cassette)
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
@@ -84,7 +83,10 @@ export interface RecordReplayOptions {
|
||||
/**
|
||||
* Custom request matcher. Defaults to `defaultMatcher`, which compares
|
||||
* method, url, structurally-canonical JSON body, and the allow-listed
|
||||
* headers.
|
||||
* 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
|
||||
}
|
||||
@@ -122,6 +124,15 @@ const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
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(
|
||||
@@ -149,21 +160,30 @@ const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: stri
|
||||
}),
|
||||
})
|
||||
|
||||
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
||||
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. Run with RECORD=true to update it.`,
|
||||
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) => {
|
||||
try {
|
||||
decodeCassetteJson(fs.readFileSync(fixturePath(name), "utf8"))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!fs.existsSync(fixturePath(name))) return false
|
||||
return Option.isSome(
|
||||
Option.liftThrowable(parseCassette)(fs.readFileSync(fixturePath(name), "utf8")),
|
||||
)
|
||||
}
|
||||
|
||||
export const layer = (
|
||||
@@ -180,7 +200,9 @@ export const layer = (
|
||||
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* () {
|
||||
@@ -201,6 +223,29 @@ export const layer = (
|
||||
}
|
||||
})
|
||||
|
||||
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* () {
|
||||
@@ -217,24 +262,18 @@ export const layer = (
|
||||
}
|
||||
const interactions = yield* Ref.updateAndGet(recorded, (prev) => [...prev, interaction])
|
||||
yield* fileSystem.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* fileSystem
|
||||
.writeFileString(file, encodeCassetteJson({ version: 1, interactions }))
|
||||
.pipe(Effect.orDie)
|
||||
yield* fileSystem.writeFileString(file, formatCassette(interactions)).pipe(Effect.orDie)
|
||||
return HttpClientResponse.fromWeb(request, new Response(body, interaction.response))
|
||||
})
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const cassette = decodeCassetteJson(
|
||||
const cassette = parseCassette(
|
||||
yield* fileSystem.readFileString(file).pipe(Effect.mapError(() => fixtureMissing(request, name))),
|
||||
)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const incomingCanonical = canonicalSnapshot(incoming)
|
||||
const interaction =
|
||||
match === defaultMatcher
|
||||
? cassette.interactions.find((candidate) => canonicalSnapshot(candidate.request) === incomingCanonical)
|
||||
: cassette.interactions.find((candidate) => match(incoming, candidate.request))
|
||||
if (!interaction) return yield* fixtureMismatch(request, name)
|
||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
||||
|
||||
return HttpClientResponse.fromWeb(request, new Response(interaction.response.body, interaction.response))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user