diff --git a/internal/net-mocks/.prettierrc b/internal/net-mocks/.prettierrc deleted file mode 100644 index ba08ff04f677..000000000000 --- a/internal/net-mocks/.prettierrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/prettierrc", - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "semi": true, - "singleQuote": false, - "quoteProps": "as-needed", - "jsxSingleQuote": false, - "trailingComma": "es5", - "bracketSpacing": true, - "arrowParens": "always", - "requirePragma": false, - "insertPragma": false, - "proseWrap": "preserve", - "htmlWhitespaceSensitivity": "css", - "vueIndentScriptAndStyle": false, - "endOfLine": "lf" -} diff --git a/internal/net-mocks/README.md b/internal/net-mocks/README.md deleted file mode 100644 index bd790c109398..000000000000 --- a/internal/net-mocks/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# @langchain/net-mocks - -This is an internal utility used within LangChain to record & mock network activity for use in tests. Here's how it works: - -1. **Record:** When running tests for the first time, `net-mocks` intercepts outgoing HTTP requests and records them, along with the corresponding responses, into a `.har` file. These files are stored in a `__snapshots__` directory (by default) alongside the tests. -2. **Replay:** On subsequent test runs, `net-mocks` intercepts the same HTTP requests but instead of hitting the actual API, it finds a matching request in the `.har` file and returns the recorded response. This makes tests deterministic and runnable offline. -3. **Refresh:** If the underlying API changes, or tests are slightly modified, then the matching function between the stored and actual request becomes falsy and data is automatically re-fetched (or will reject the test if configured) - - Mainstream model providers that use stainless as their sdk backing (openAI, anthropic) typically pin a `sdk-version` or `api-version` header that gets sent with requests. Once those change (like by an sdk upgrade), that means that the network activity stored with a test will automatically be refetched. This makes it easy to mocked tests up-to-date with the latest API changes. - - This tool also involves the concept of "stale" requests. If a request becomes older than a pre-determined interval, then we either refetch the request or reject the test based on the configuration. This just acts as an extra sanity-check to protect from sneaky API changes. - -## 📚 Usage guide - -This utility is meant to have an easy API so that it can work as a "drop-in" replacement for our existing test suites. - -```ts -import { describe, it, expect, beforeAll } from "vitest"; -import { net } from "@langchain/net-mocks"; -import { ChatAnthropic } from "../chat_models"; - -beforeAll(() => - net.setupVitest(...) -}); - -describe("ChatAnthropic", () => { - it("should work as expected", async () => { - // This binds the network listener to the current test context - await net.vcr(); - - const model = new ChatAnthropic({ - model: "claude-3-5-sonnet-20240620", - }); - const message = new HumanMessage("do you know the muffin man"); - const result = await model.invoke([message]); - - expect(result.content).toBeDefined(); - }); -}); -``` - -### Options - -Options are defined by collapsing a set of default options, the options provided in `net.setupVitest`, and the options provided in `net.vcr`. That resulting config object determines a couple of behaviors: - -
-Options config - -```ts -/** - * Strategy for handling stale cache entries: - * - "reject": Reject the request if the entry is stale. - * - "warn": Warn but allow the request. - * - "refetch": Refetch the request from the network. - * - "ignore": Ignore staleness and use the entry. - */ -type StaleStrategy = "reject" | "warn" | "refetch" | "ignore"; -/** - * Strategy for handling unmatched requests: - * - "reject": Reject unmatched requests. - * - "warn": Warn but allow unmatched requests. - * - "fetch": Fetch the request from the network. - */ -type NoMatchStrategy = "reject" | "warn" | "fetch"; - -/** - * Options for configuring network mocking and recording behavior. - */ -export type NetMockOptions = { - /** - /** - * Maximum age (in milliseconds) for cached network entries before considered stale. - * Can be set via the `MOCKS_MAX_AGE` environment variable. - * @default '60 days' - */ - maxAge: number; - /** - * Can be set via the `MOCKS_STALE` environment variable. - * @default reject - */ - stale: StaleStrategy; - /** - * Can be set via the `MOCKS_NO_MATCH` environment variable. - * @default reject - */ - noMatch: NoMatchStrategy; - /** - * Whether to mimick the timings of the original request. - * Can be set via the `MOCKS_USE_TIMINGS` environment variable. - * @default false - */ - useTimings: boolean; - /** - * Output file path for saving the archive or mock data. - * @default 'The current test name, or "archive" if no test name is available.' - */ - out?: string; - /** - * List of header or body keys to include in request archives. - * Can be set via the `MOCKS_INCLUDE_KEYS` environment variable. - * @default [] - */ - includeKeys: string[]; -}; -``` - -
- -## 🦄 Other goodies - -### Network timings - -As apart of the HAR spec, complete archives come with fields that describe the timing of a request/response pair. When `useTimings` is set to `true` in vcr options, the resulting response object returned internally is delayed according to those timings to simulate real network delay. - -Additionally when a response comes back with `Content-Type: text/event-stream` (a common format for streaming responses), we store the timings of each chunk as apart of the stored response body. On followup test runs (where `useTimings: true`), we replay the chunks as if we were streaming actual data over the network. This just better helps us model real world environments and detect race conditions if they exist. - -### Using dev tools - -HAR is a lesser known web standard that has its own place in modern web browser devtools, and its the format that we're using to retain network activity for later runs. This means that we can inspect all network transactions that occured in a test directly in the browser. - - diff --git a/internal/net-mocks/eslint.config.ts b/internal/net-mocks/eslint.config.ts deleted file mode 100644 index c38c15ed483f..000000000000 --- a/internal/net-mocks/eslint.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { langchainConfig } from "@langchain/eslint"; - -export default langchainConfig; \ No newline at end of file diff --git a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_ignore_network_delay_if_useTimings_false.har b/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_ignore_network_delay_if_useTimings_false.har deleted file mode 100644 index 345d3ebf4b3c..000000000000 --- a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_ignore_network_delay_if_useTimings_false.har +++ /dev/null @@ -1 +0,0 @@ -{"log":{"version":"1.2","creator":{"name":"langchain-net-mocks","version":"2025-06-23"},"pages":[],"entries":[{"startedDateTime":"2025-06-26T23:58:18.221Z","time":2039.3435410000002,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":188},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:17Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:18Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"48"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:18Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:17Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6d73a157e21-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"383"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:18 GMT"},{"name":"request-id","value":"req_011CQXjrMLZ8XnJ5dbuo4tzv"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_013zqyv4s8tsi8LrLAGypAov\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Hey! Just here to chat and help out. What's on your mind?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":383},"redirectURL":"","headersSize":2,"bodySize":383},"cache":{},"timings":{"send":0,"wait":2039.1298749999987,"receive":2042.6809159999993}},{"startedDateTime":"2025-06-26T23:58:19.353Z","time":1125.4754169999997,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"},{\"role\":\"assistant\",\"content\":\"Hey! Just here to chat and help out. What's on your mind?\"},{\"role\":\"user\",\"content\":\"ok man\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":314},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:19Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:19Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"49"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:19Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:19Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6e3fb89cce4-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"366"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:19 GMT"},{"name":"request-id","value":"req_011CQXjrW3vpJyVJ9kbbHNss"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_019cvArZHNfupvD8rfw4SyMc\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Cool. Let me know if you need anything!\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":33,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":13,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":366},"redirectURL":"","headersSize":2,"bodySize":366},"cache":{},"timings":{"send":0,"wait":1125.2219999999998,"receive":1127.5162500000006}}]}} \ No newline at end of file diff --git a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_keep_api_keys_out_of_archives_if_configured_properly.har b/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_keep_api_keys_out_of_archives_if_configured_properly.har deleted file mode 100644 index 393de7bcbd69..000000000000 --- a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_keep_api_keys_out_of_archives_if_configured_properly.har +++ /dev/null @@ -1 +0,0 @@ -{"log":{"version":"1.2","creator":{"name":"langchain-net-mocks","version":"2025-06-23"},"pages":[],"entries":[{"startedDateTime":"2025-06-26T23:58:15.353Z","time":1322.7176659999986,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":188},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:14Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:15Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"48"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:15Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:14Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6c9ba107e21-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"383"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:15 GMT"},{"name":"request-id","value":"req_011CQXjrC8fTREipqm3M2KdR"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_01E2LeGfu4k24mjoZdiUX9Gh\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Hey! Just here to chat and help out. What's on your mind?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":383},"redirectURL":"","headersSize":2,"bodySize":383},"cache":{},"timings":{"send":0,"wait":1322.4698329999992,"receive":1325.2938749999994}},{"startedDateTime":"2025-06-26T23:58:16.170Z","time":810.4415410000001,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"},{\"role\":\"assistant\",\"content\":\"Hey! Just here to chat and help out. What's on your mind?\"},{\"role\":\"user\",\"content\":\"ok man\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":314},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:15Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:16Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"48"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:17Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:15Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6d20b0fcce4-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"366"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:16 GMT"},{"name":"request-id","value":"req_011CQXjrHnjqVC4rXWF19HqU"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_0115gzXfXETZCV1XvDbkzkK5\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Cool. Let me know if you need anything!\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":33,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":13,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":366},"redirectURL":"","headersSize":2,"bodySize":366},"cache":{},"timings":{"send":0,"wait":810.2681659999998,"receive":813.7845410000009}}]}} \ No newline at end of file diff --git a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_reject_stale_responses_when_stale_reject.har b/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_reject_stale_responses_when_stale_reject.har deleted file mode 100644 index fa145238b7e5..000000000000 --- a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_reject_stale_responses_when_stale_reject.har +++ /dev/null @@ -1 +0,0 @@ -{"log":{"version":"1.2","creator":{"name":"langchain-net-mocks","version":"2025-06-23"},"pages":[],"entries":[{"startedDateTime":"2025-06-26T23:58:10.951Z","time":1794.3793750000004,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":188},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:10Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:10Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"48"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:10Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:10Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6ab4e3f7e21-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"399"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:10 GMT"},{"name":"request-id","value":"req_011CQXjqqGYwPSvhn8naEc1s"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_01Dqiwk2PrzTK5rpnmK8595B\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Hey! I'm doing well, just here to chat and help out. What's on your mind?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":24,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":399},"redirectURL":"","headersSize":2,"bodySize":399},"cache":{},"timings":{"send":0,"wait":1794.1777090000005,"receive":1796.2609170000005}},{"startedDateTime":"2025-06-26T23:58:11.945Z","time":988.6297919999997,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"},{\"role\":\"assistant\",\"content\":\"Hey! I'm doing well, just here to chat and help out. What's on your mind?\"},{\"role\":\"user\",\"content\":\"ok man\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":330},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:11Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:11Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"49"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:12Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:11Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6b68c51cce4-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"376"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:11 GMT"},{"name":"request-id","value":"req_011CQXjqxxuJ2NqBTRb4v1iB"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_01FH58Pbg7vJzV1KU9hyRD2D\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Cool. Let me know if you need help with anything!\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":38,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":15,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":376},"redirectURL":"","headersSize":2,"bodySize":376},"cache":{},"timings":{"send":0,"wait":988.5936670000001,"receive":989.0912079999998}}]}} \ No newline at end of file diff --git a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_reject_when_there_isn_t_a_match_when_noMatch_reject.har b/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_reject_when_there_isn_t_a_match_when_noMatch_reject.har deleted file mode 100644 index b6f21caf2238..000000000000 --- a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_will_reject_when_there_isn_t_a_match_when_noMatch_reject.har +++ /dev/null @@ -1 +0,0 @@ -{"log":{"version":"1.2","creator":{"name":"langchain-net-mocks","version":"2025-06-23"},"pages":[],"entries":[]}} \ No newline at end of file diff --git a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_works_as_expected.har b/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_works_as_expected.har deleted file mode 100644 index 7860bd955deb..000000000000 --- a/internal/net-mocks/example/__tests__/__snapshots__/ChatAnthropic_works_as_expected.har +++ /dev/null @@ -1 +0,0 @@ -{"log":{"version":"1.2","creator":{"name":"langchain-net-mocks","version":"2025-06-23"},"pages":[],"entries":[{"startedDateTime":"2025-06-26T23:58:08.091Z","time":2843.010291,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":188},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:07Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:07Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"49"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:06Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:07Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a69328647e21-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"383"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:07 GMT"},{"name":"request-id","value":"req_011CQXjqYnrXVrPTtPPZhbQ4"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_01Hnw3VGbAeynKvtAppjKQAV\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Hey! Just here to chat and help out. What's on your mind?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":383},"redirectURL":"","headersSize":2,"bodySize":383},"cache":{},"timings":{"send":0,"wait":2842.2465829999996,"receive":2845.993541}},{"startedDateTime":"2025-06-26T23:58:09.120Z","time":1021.5204170000002,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"},{\"role\":\"assistant\",\"content\":\"Hey! Just here to chat and help out. What's on your mind?\"},{\"role\":\"user\",\"content\":\"ok man\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":314},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:08Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:09Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"49"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:09Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:08Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6a4cdf5cce4-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"366"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:09 GMT"},{"name":"request-id","value":"req_011CQXjqkrPabiGZhuJrXXFw"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_01UMecK6nJGctpgFDSThaH1o\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Cool. Let me know if you need anything!\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":33,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":13,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":366},"redirectURL":"","headersSize":2,"bodySize":366},"cache":{},"timings":{"send":0,"wait":1021.245375,"receive":1028.524}}]}} \ No newline at end of file diff --git a/internal/net-mocks/example/__tests__/__snapshots__/my_special_archive.har b/internal/net-mocks/example/__tests__/__snapshots__/my_special_archive.har deleted file mode 100644 index 425cc8253e7a..000000000000 --- a/internal/net-mocks/example/__tests__/__snapshots__/my_special_archive.har +++ /dev/null @@ -1 +0,0 @@ -{"log":{"version":"1.2","creator":{"name":"langchain-net-mocks","version":"2025-06-23"},"pages":[],"entries":[{"startedDateTime":"2025-06-26T23:58:13.017Z","time":1070.7174579999992,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":188},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:12Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:13Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"48"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:13Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:12Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6bcbb497e21-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"393"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:13 GMT"},{"name":"request-id","value":"req_011CQXjr3CeRGhTVVhCwEEDf"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_015kE5BjUoS9qZCDFKjWaREg\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Hey! Not much, just here to chat and help out. What's on your mind?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":22,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":393},"redirectURL":"","headersSize":2,"bodySize":393},"cache":{},"timings":{"send":0,"wait":1070.6362919999992,"receive":1071.6924170000002}},{"startedDateTime":"2025-06-26T23:58:14.021Z","time":1001.2847920000004,"request":{"method":"POST","url":"https://api.anthropic.com/v1/messages","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"accept","value":"application/json"},{"name":"accept-encoding","value":"identity"},{"name":"anthropic-dangerous-direct-browser-access","value":""},{"name":"anthropic-version","value":""},{"name":"content-type","value":"application/json"},{"name":"user-agent","value":""},{"name":"x-api-key","value":""},{"name":"x-stainless-arch","value":""},{"name":"x-stainless-lang","value":""},{"name":"x-stainless-os","value":""},{"name":"x-stainless-package-version","value":""},{"name":"x-stainless-retry-count","value":""},{"name":"x-stainless-runtime","value":""},{"name":"x-stainless-runtime-version","value":""},{"name":"x-stainless-timeout","value":""}],"queryString":[],"postData":{"text":"{\"model\":\"claude-3-5-sonnet-20241022\",\"temperature\":1,\"top_k\":-1,\"top_p\":-1,\"stream\":false,\"max_tokens\":2048,\"thinking\":{\"type\":\"disabled\"},\"messages\":[{\"role\":\"user\",\"content\":\"wassup\"},{\"role\":\"assistant\",\"content\":\"Hey! Not much, just here to chat and help out. What's on your mind?\"},{\"role\":\"user\",\"content\":\"ok man\"}]}","mimeType":"application/json"},"headersSize":2,"bodySize":324},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"anthropic-organization-id","value":"5443403f-944a-4a92-95a4-6088f779a3f4"},{"name":"anthropic-ratelimit-input-tokens-limit","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-remaining","value":"40000"},{"name":"anthropic-ratelimit-input-tokens-reset","value":"2025-06-26T23:58:13Z"},{"name":"anthropic-ratelimit-output-tokens-limit","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-remaining","value":"8000"},{"name":"anthropic-ratelimit-output-tokens-reset","value":"2025-06-26T23:58:13Z"},{"name":"anthropic-ratelimit-requests-limit","value":"50"},{"name":"anthropic-ratelimit-requests-remaining","value":"48"},{"name":"anthropic-ratelimit-requests-reset","value":"2025-06-26T23:58:14Z"},{"name":"anthropic-ratelimit-tokens-limit","value":"48000"},{"name":"anthropic-ratelimit-tokens-remaining","value":"48000"},{"name":"anthropic-ratelimit-tokens-reset","value":"2025-06-26T23:58:13Z"},{"name":"cf-cache-status","value":"DYNAMIC"},{"name":"cf-ray","value":"9560a6c36c62cce4-SJC"},{"name":"connection","value":"keep-alive"},{"name":"content-length","value":"376"},{"name":"content-type","value":"application/json"},{"name":"date","value":"Thu, 26 Jun 2025 23:58:13 GMT"},{"name":"request-id","value":"req_011CQXjr7qhmNHivXsae3Pze"},{"name":"server","value":"cloudflare"},{"name":"strict-transport-security","value":"max-age=31536000; includeSubDomains; preload"},{"name":"via","value":"1.1 google"},{"name":"x-robots-tag","value":"none"}],"content":{"text":"{\"id\":\"msg_01LUeTZMnNtZaB57td13Tzod\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[{\"type\":\"text\",\"text\":\"Cool. Let me know if you need help with anything!\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":36,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":15,\"service_tier\":\"standard\"}}","mimeType":"application/json","size":376},"redirectURL":"","headersSize":2,"bodySize":376},"cache":{},"timings":{"send":0,"wait":1000.6383750000005,"receive":1003.3088750000006}}]}} \ No newline at end of file diff --git a/internal/net-mocks/example/__tests__/chat_models.test.ts b/internal/net-mocks/example/__tests__/chat_models.test.ts deleted file mode 100644 index 29cd7966536e..000000000000 --- a/internal/net-mocks/example/__tests__/chat_models.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { HumanMessage } from "@langchain/core/messages"; -import { ChatAnthropic } from "@langchain/anthropic"; -import { net } from "../../src"; - -const model = new ChatAnthropic({ - model: "claude-3-5-sonnet-20241022", -}); - -const multiTurn = async () => { - const firstMessage = new HumanMessage("wassup"); - - const result = await model.invoke([firstMessage]); - expect(result.content).toBeDefined(); - - const result2 = await model.invoke([ - firstMessage, - result, - new HumanMessage("ok man"), - ]); - expect(result2.content).toBeDefined(); -}; - -describe("ChatAnthropic", () => { - it("works as expected", async () => { - await net.vcr(); - await multiTurn(); - }); - - it.fails( - "will reject when there isn't a match when `noMatch: reject`", - async () => { - await net.vcr({ noMatch: "reject" }); - await multiTurn(); - } - ); - - it.fails("will reject stale responses when `stale: reject`", async () => { - await net.vcr({ stale: "reject", maxAge: 0 }); - await multiTurn(); - }); - - it("will keep archives in custom sources", async () => { - await net.vcr("my_special_archive"); - await multiTurn(); - }); - - it("will keep api keys out of archives (if configured properly)", async () => { - await net.vcr({ - /// this option will be what configures it, but "x-api-key" exists as a default in vitest.config.ts - // redactedKeys: [], - }); - await multiTurn(); - }); - - it("will ignore network delay if `useTimings: false`", async () => { - await net.vcr({ useTimings: false }); - await multiTurn(); - }); -}); diff --git a/internal/net-mocks/example/vitest.config.ts b/internal/net-mocks/example/vitest.config.ts deleted file mode 100644 index ddf3ef94bad3..000000000000 --- a/internal/net-mocks/example/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - setupFiles: ["./example/vitest.setup.ts"], - testTimeout: 30000, - }, -}); diff --git a/internal/net-mocks/example/vitest.setup.ts b/internal/net-mocks/example/vitest.setup.ts deleted file mode 100644 index aa09d18132af..000000000000 --- a/internal/net-mocks/example/vitest.setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { beforeAll } from "vitest"; -import { net } from "../src"; - -beforeAll(() => net.setupVitest()); diff --git a/internal/net-mocks/package.json b/internal/net-mocks/package.json deleted file mode 100644 index bc957225f0f7..000000000000 --- a/internal/net-mocks/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "private": true, - "name": "@langchain/net-mocks", - "version": "1.0.0", - "description": "", - "main": "src/index.ts", - "scripts": { - "test:int": "vitest run --config=example/vitest.config.ts", - "lint": "eslint --cache src/", - "lint:fix": "pnpm lint --fix", - "format": "prettier --write \"src\"", - "format:check": "prettier --check \"src\"" - }, - "type": "module", - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@mswjs/interceptors": "^0.39.2", - "cookie": "^1.0.2", - "deep-equal": "^2.2.3" - }, - "peerDependencies": { - "vitest": "^3.2.4" - }, - "peerDependenciesMeta": { - "vitest": { - "optional": true - } - }, - "devDependencies": { - "@langchain/anthropic": "workspace:*", - "@langchain/core": "workspace:*", - "@langchain/eslint": "workspace:*", - "@types/deep-equal": "^1.0.4", - "eslint": "^9.34.0", - "prettier": "^3.6.1", - "vitest": "^3.2.4" - } -} diff --git a/internal/net-mocks/src/env.ts b/internal/net-mocks/src/env.ts deleted file mode 100644 index 2d29397149a7..000000000000 --- a/internal/net-mocks/src/env.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { BatchInterceptor } from "@mswjs/interceptors"; -import { ArchiveStore, NetMockContextHooks } from "./mock.js"; -import { toFileSafeString } from "./utils.js"; -import { HARArchive } from "./spec.js"; - -declare global { - interface Window { - env?: Record; - } -} - -/** - * Creates and returns a BatchInterceptor appropriate for the current environment (browser or Node.js). - * - * This function dynamically imports the correct set of interceptors based on the detected global environment. - * - In a browser environment (`globalThis.window` is defined), it attempts to import browser interceptors. - * - In a Node.js environment (`globalThis.process` is defined), it imports Node.js interceptors. - * - If neither environment is detected, it throws an error. - * - * @returns {Promise} A promise that resolves to a configured BatchInterceptor instance. - * @throws {Error} If no suitable interceptor is found for the current environment. - */ -export async function getInterceptor() { - if (globalThis.window !== undefined) { - // FIXME: browser interceptors are awkward to import since ts auto assumes node types - // A no-op right now since we don't do integration tests directly in the browser - throw new Error("Not implemented"); - // Once a fix is merged for msw, syntax should look like this: - // const { default: browserInterceptors } = await import( - // "@mswjs/interceptors/presets/browser" - // ); - // const interceptor = new BatchInterceptor({ - // name: "langchain-net-mocks", - // interceptors: browserInterceptors, - // }); - // return interceptor; - } - if (globalThis.process !== undefined) { - const { default: nodeInterceptors } = await import( - "@mswjs/interceptors/presets/node" - ); - const interceptor = new BatchInterceptor({ - name: "langchain-node-net-mocks", - interceptors: nodeInterceptors, - }); - return interceptor; - } - throw new Error("No interceptor found for current environment"); -} - -export type EnvironmentBatchInterceptor = Awaited< - ReturnType ->; - -/** - * Returns an ArchiveStore implementation appropriate for the current environment. - * - * In a Node.js environment, this function provides an ArchiveStore that reads and writes HAR logs - * to the local filesystem using the `node:fs/promises` API. The store supports retrieving a single - * HAR log by key (filename), retrieving all HAR logs in the current directory, and saving a HAR log - * to a file. - * - * @returns {Promise} A promise that resolves to an ArchiveStore instance for the current environment. - */ -export async function getArchiveStore( - hooks: NetMockContextHooks | null -): Promise { - if (globalThis.window !== undefined) { - // We currently don't do integration tests directly in the browser, - // so we don't need to implement this for now. - throw new Error("Not implemented"); - } - if (globalThis.process !== undefined) { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - - const getOutputDir = () => { - const testPath = path.dirname(hooks?.getTestPath() ?? "./"); - return path.join(testPath, "__snapshots__"); - }; - - const getArchivePath = (key: string) => { - return path.join(getOutputDir(), `${toFileSafeString(key)}.har`); - }; - - return { - async get(key: string) { - try { - const file = await fs.readFile(getArchivePath(key), "utf-8"); - return JSON.parse(file); - } catch { - return undefined; - } - }, - async save(key: string, archive: HARArchive) { - const filePath = getArchivePath(key); - const dir = path.dirname(filePath); - try { - await fs.mkdir(dir, { recursive: true }); - } catch (err: unknown) { - // ignore error if it already exists - if ( - typeof err === "object" && - err !== null && - "code" in err && - err.code !== "EEXIST" - ) - throw err; - } - await fs.writeFile(filePath, JSON.stringify(archive)); - }, - }; - } - throw new Error("No archive store found for current environment"); -} - -/** - * Safely retrieves an environment variable value, supporting both Node.js and browser environments. - * Returns undefined if the variable is not set or not accessible in the current environment. - * - * @param {string} key - The name of the environment variable. - * @param {Function} [transform] - An optional function to transform the value. - * @returns {string | undefined} The value of the environment variable, or undefined if not found. - */ -export function getEnvironmentVariable(key: string): string | undefined; -export function getEnvironmentVariable( - key: string, - transform: (value: string | undefined) => T -): T; -export function getEnvironmentVariable( - key: string, - transform?: (value: string | undefined) => T -): T | string | undefined { - let value: string | undefined; - if ( - typeof globalThis.window !== "undefined" && - globalThis.window?.env && - typeof globalThis.window.env[key] === "string" - ) { - value = globalThis.window.env[key]; - } - if ( - typeof globalThis.process !== "undefined" && - globalThis.process?.env && - typeof globalThis.process.env[key] === "string" - ) { - value = globalThis.process.env[key]; - } - return transform ? transform(value) : value; -} diff --git a/internal/net-mocks/src/index.ts b/internal/net-mocks/src/index.ts deleted file mode 100644 index 22668e93cfe1..000000000000 --- a/internal/net-mocks/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./env.js"; -export * from "./mock.js"; -export * from "./spec.js"; diff --git a/internal/net-mocks/src/mock.ts b/internal/net-mocks/src/mock.ts deleted file mode 100644 index 4ca66c38c29b..000000000000 --- a/internal/net-mocks/src/mock.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { HARArchive, HAREntry } from "./spec.js"; -import { - encodeHARRequest, - encodeHARResponse, - entryIsStale, - matchRequestEntryPredicate, - readableHARResponseStream, -} from "./request.js"; -import { iife, PromiseOrValue } from "./utils.js"; -import { - EnvironmentBatchInterceptor, - getArchiveStore, - getEnvironmentVariable, - getInterceptor, -} from "./env.js"; - -/** - * Interface representing a storage mechanism for HAR archives. - * Provides methods to retrieve, list, and save HAR archives by key. - */ -export interface ArchiveStore { - /** - * Retrieves a HAR log by its key. - * @param {string} key - The identifier or filename of the HAR log to retrieve. - * @returns {PromiseOrValue} The HAR log associated with the given key. - */ - get(key: string): PromiseOrValue; - - /** - * Saves a HAR log to the store under the specified key. - * @param {string} key - The identifier or filename to save the HAR log under. - * @param {HARArchive} log - The HAR log object to save. - * @returns {PromiseOrValue} A promise that resolves when the save is complete. - */ - save(key: string, log: HARArchive): PromiseOrValue; -} - -/** - * Strategy for handling stale cache entries: - * - "reject": Reject the request if the entry is stale. - * - "warn": Warn but allow the request. - * - "refetch": Refetch the request from the network. - * - "ignore": Ignore staleness and use the entry. - */ -type StaleStrategy = "reject" | "warn" | "refetch" | "ignore"; -/** - * Strategy for handling unmatched requests: - * - "reject": Reject unmatched requests. - * - "warn": Warn but allow unmatched requests. - * - "fetch": Fetch the request from the network. - */ -type NoMatchStrategy = "reject" | "warn" | "fetch"; - -/** - * Options for configuring network mocking and recording behavior. - */ -export type NetMockOptions = { - /** - /** - * Maximum age (in milliseconds) for cached network entries before considered stale. - * Can be set via the `MOCKS_MAX_AGE` environment variable. - * @default '60 days' - */ - maxAge: number; - /** - * Can be set via the `MOCKS_STALE` environment variable. - * @default reject - */ - stale: StaleStrategy; - /** - * Can be set via the `MOCKS_NO_MATCH` environment variable. - * @default reject - */ - noMatch: NoMatchStrategy; - /** - * Whether to mimick the timings of the original request. - * Can be set via the `MOCKS_USE_TIMINGS` environment variable. - * @default false - */ - useTimings: boolean; - /** - * Output file path for saving the archive or mock data. - * @default 'The current test name, or "archive" if no test name is available.' - */ - out?: string; - /** - * List of header or body keys to include in request archives. - * Can be set via the `MOCKS_INCLUDE_KEYS` environment variable. - * @default [] - */ - includeKeys: string[]; -}; - -export interface NetMockContextHooks { - getTestPath(): string | undefined; - getDefaultSource(): string | undefined; - fail(message: string): void; - warn(message: string): void; - cleanup(fn: () => Promise): void; -} - -export class NetMockContext { - interceptor: EnvironmentBatchInterceptor | null = null; - - hooks: NetMockContextHooks | null = null; - - _archivePromises: Promise[] = []; - - _store: Promise | null = null; - - get store() { - if (this._store === null) this._store = getArchiveStore(this.hooks); - return this._store; - } - - /** @internal */ - private _defaultOptions: NetMockOptions = { - maxAge: getEnvironmentVariable("MOCKS_MAX_AGE", (value) => { - if (!value) return 1000 * 60 * 60 * 24 * 60; // 60 days - return Number(value); - }), - stale: getEnvironmentVariable("MOCKS_STALE", (value) => { - if (!value) return "reject"; - if (!["reject", "warn", "refetch", "ignore"].includes(value)) { - throw new Error(`Invalid stale strategy: ${value}`); - } - return value as StaleStrategy; - }), - noMatch: getEnvironmentVariable("MOCKS_NO_MATCH", (value) => { - if (!value) return "reject"; - if (!["reject", "warn", "fetch"].includes(value)) { - throw new Error(`Invalid no match strategy: ${value}`); - } - return value as NoMatchStrategy; - }), - useTimings: getEnvironmentVariable("MOCKS_USE_TIMINGS", (value) => { - if (value === undefined) return Boolean(false); - return Boolean(value); - }), - includeKeys: getEnvironmentVariable("MOCKS_INCLUDE_KEYS", (value) => { - if (!value) return []; - return value.split(","); - }), - }; - - /** @internal */ - private _mergeDefaultOptions( - options?: Partial - ): NetMockOptions { - return { - ...this._defaultOptions, - ...options, - }; - } - - async vcr(source: string, options?: Partial): Promise; - - async vcr(options?: Partial): Promise; - - async vcr( - sourceOrOptions?: string | Partial, - optionsArg?: Partial - ) { - const options = this._mergeDefaultOptions( - typeof sourceOrOptions === "object" ? sourceOrOptions : optionsArg - ); - const source = iife(() => { - if (typeof sourceOrOptions === "string") return sourceOrOptions; - else if (options.out) return options.out; - else return this.hooks?.getDefaultSource() ?? "archive"; - }); - - const store = await this.store; - const archive: HARArchive = (await store.get(source)) ?? { - log: { - version: "1.2", - creator: { - name: "langchain-net-mocks", - version: "2025-06-23", - }, - pages: [], - entries: [], - }, - }; - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.interceptor?.on("request", async ({ request, controller }) => { - // If the request has a passthrough header, we shouldn't try to intercept it - if (request.headers.get("x-mock-passthrough") !== null) { - request.headers.delete("x-mock-passthrough"); - return; - } - - // MSW has some shortcomings when it comes to dealing with gzip - // streams (teed readable streams don't take on the same semantics - // because http streams use an internal prototype that plays nice with gunzip) - // If you're getting 'incorrect header check' errors from node internals - // then it's probably because the remote being fetched doesn't support - // this header - request.headers.set("accept-encoding", "identity"); - - const encodedRequest = encodeHARRequest( - request.clone(), - options.includeKeys - ); - - const clonedRequest = request.clone(); - const clonedRequestBody = new Uint8Array( - await clonedRequest.arrayBuffer() - ); - const matchedEntry = archive.log.entries.find((entry) => - matchRequestEntryPredicate({ - request: clonedRequest, - requestBody: clonedRequestBody, - entry, - includeKeys: options.includeKeys.filter(Boolean) as string[], - }) - ); - - let shouldFetch = false; - - // If the request matches an entry, we'll handle it according to the `stale` strategy - if (matchedEntry) { - const isStale = entryIsStale(matchedEntry, options.maxAge); - const message = [ - `A stale entry was used to respond to a request:`, - ` - URL: ${clonedRequest.url}`, - ` - Request timestamp: ${matchedEntry.startedDateTime}`, - ].join("\n"); - if (isStale) { - if (options.stale === "reject") { - controller.respondWith(new Response(message, { status: 400 })); - this.hooks?.fail(message); - return; - } else if (options.stale === "warn") { - this.hooks?.warn(message); - } else if (options.stale === "refetch") { - shouldFetch = true; - } - } - } - // If the request doesn't match an entry, we'll handle it according to the `noMatch` strategy - else { - const message = [ - `A request was made that did not match any stored entry:`, - ` - URL: ${clonedRequest.url}`, - ].join("\n"); - if (options.noMatch === "reject") { - controller.respondWith(new Response(message, { status: 400 })); - this.hooks?.fail(message); - return; - } else if (options.noMatch === "warn") { - this.hooks?.warn(message); - shouldFetch = true; - } else if (options.noMatch === "fetch") { - shouldFetch = true; - } - } - - // If we have a matched entry and we're not fetching, we can respond with the cached entry - if (matchedEntry && !shouldFetch) { - const httpHeaders: [string, string][] = - matchedEntry.response.headers.map((header) => [ - header.name, - header.value, - ]); - const httpResponse = new Response( - readableHARResponseStream(matchedEntry, options.useTimings), - { - status: matchedEntry.response.status, - statusText: matchedEntry.response.statusText, - headers: httpHeaders, - } - ); - controller.respondWith(httpResponse); - return; - } - // If we need to fetch, we'll need to make an actual fetch request and record the response - else if (shouldFetch) { - // Pin a header so that calling `fetch` doesn't cause an infinite loop - request.headers.set("x-mock-passthrough", "1"); - - const startTime = performance.now(); - const response = await fetch(request); - const waitTime = performance.now() - startTime; - - const clonedResponse = response.clone(); - this._archivePromises.push( - iife(async () => { - const entry: HAREntry = { - startedDateTime: new Date().toISOString(), - time: performance.now() - startTime, - request: await encodedRequest, - response: await encodeHARResponse( - clonedResponse, - options.includeKeys - ), - cache: {}, - timings: { - // `send` is the time it takes to send the request to the server (0 since we're not in a browser) - send: 0, - // `wait` is the time it takes to start a response from the server - wait: waitTime, - // `receive` is the time it takes for the entire response to be received - receive: performance.now() - startTime, - }, - }; - archive.log.entries.push(entry); - }) - ); - - controller.respondWith(response); - return; - } - // All code paths should return, so we'll throw an error if we get here - throw new Error("Unhandled request"); - }); - - this.interceptor?.apply(); - - this.hooks?.cleanup(async () => { - this.interceptor?.removeAllListeners("request"); - await Promise.all(this._archivePromises); - await store.save(source, archive); - }); - } - - async setupVitest(options?: Partial) { - const { onTestFinished, expect } = await import("vitest"); - this.interceptor = await getInterceptor(); - this.interceptor.apply(); - this.hooks = { - getTestPath: () => expect.getState().testPath, - getDefaultSource: () => expect.getState().currentTestName, - cleanup: onTestFinished, - fail: (message) => { - // This will mark the test as failed, but we don't want to do an explicit - // assertion since that causes an error to be thrown, which if thrown within - // the msw handler causes a retry loop with async-caller - expect.soft(message).toBeUndefined(); - }, - // TODO: use vitest annotations - warn: console.warn, - }; - this._defaultOptions = this._mergeDefaultOptions(options); - } -} - -export const net = new NetMockContext(); diff --git a/internal/net-mocks/src/request.ts b/internal/net-mocks/src/request.ts deleted file mode 100644 index ab5e2692adde..000000000000 --- a/internal/net-mocks/src/request.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { parse as parseCookie } from "cookie"; -import { - EncodedEventStream, - HARContent, - HARCookie, - HAREntry, - HARHeader, - HARPostData, - HARQueryString, - HARRequest, - HARResponse, -} from "./spec.js"; -import { deepEqual, delay, iife } from "./utils.js"; - -const WELL_KNOWN_HEADERS = ["accept", "accept-encoding", "content-type"]; - -/** - * Options for matching an incoming HTTP request against a HAR entry. - */ -export type MatchRequestEntryOptions = { - /** The incoming Request object to be matched. */ - request: Request; - /** The body of the incoming request as a Uint8Array, or null if not present. */ - requestBody: Uint8Array | null; - /** The HAR entry to match against. */ - entry: HAREntry; - /** Optional array of header or query parameter names to include during matching. */ - includeKeys?: string[]; -}; - -/** - * Determines whether a given HTTP request matches a stored HAR entry. - * - * This function compares the method, URL (origin and pathname), content-type header, - * selected headers (excluding content-type and any redacted keys), query parameters - * (excluding any redacted keys), and optionally the request body (if provided). - * - * The request body is provided separately instead of being consumed directly in - * this predicate because it's assumed that this function is used where the request - * body is re-used multiple times, and we don't need to repeat the work of consuming - * the body. - * - * @param {Object} params - The parameters for matching. - * @param {Request} params.request - The incoming Request object to match against the HAR entry. - * @param {Uint8Array | null} params.requestBody - The body of the incoming request as a Uint8Array, or null if not present. - * @param {HAREntry} params.entry - The HAR entry to match against. - * @param {string[]} [params.includeKeys=[]] - An array of header or query parameter names to include during matching. - * @returns {boolean} True if the request matches the HAR entry, false otherwise. - */ -export function matchRequestEntryPredicate({ - request, - requestBody, - entry, - includeKeys = [], -}: MatchRequestEntryOptions): boolean { - const { request: storedRequest } = entry; - if (storedRequest.method !== request.method) return false; - - // Compare request URL - const storedRequestUrl = new URL(storedRequest.url); - const requestUrl = new URL(request.url); - if (storedRequestUrl.origin !== requestUrl.origin) return false; - if (storedRequestUrl.pathname !== requestUrl.pathname) return false; - - // Compare content-type header and mime type - const contentTypeHeader = storedRequest.headers.find( - (header) => header.name.toLowerCase() === "content-type" - ); - if (contentTypeHeader) { - const contentType = contentTypeHeader.value; - if (contentType !== request.headers.get("content-type")) { - return false; - } - } - - // Compare request headers with (excluding content-type and redacted keys) - const includedRequestHeaders = storedRequest.headers.filter((header) => - includeKeys.includes(header.name) - ); - for (const header of includedRequestHeaders) { - if (header.name.toLowerCase() === "content-type") continue; - if (header.value === request.headers.get(header.name)) return false; - } - - // Compare query params - const storedRequestQueryParams = new URLSearchParams(storedRequestUrl.search); - const requestQueryParams = new URLSearchParams(requestUrl.search); - for (const key of includeKeys) { - const storedValue = storedRequestQueryParams.get(key); - const requestValue = requestQueryParams.get(key); - if (storedValue !== requestValue) return false; - } - - // Compare request body if it's provided - if (requestBody) { - const requestBodyText = new TextDecoder().decode(requestBody); - return deepEqual(entry.request.postData?.text, requestBodyText); - } - - return true; -} - -/** - * Reads and returns the full body from a ReadableStream as a Uint8Array. - * @param {ReadableStream} bodyStream - * @returns {Promise} - */ -export async function consumeBodyStream( - bodyStream: ReadableStream -): Promise { - const reader = bodyStream.getReader(); - const chunks: Uint8Array[] = []; - let done = false; - while (!done) { - const { value, done: streamDone } = await reader.read(); - if (value) chunks.push(value); - done = streamDone; - } - // Concatenate all chunks into a single Uint8Array - const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} - -/** - * Creates a ReadableStream that emits the provided string content after an optional delay. - * - * This function is useful for simulating network latency or delayed responses in tests. - * - * @param {string | undefined} content - The string content to emit in the stream. If undefined or empty, nothing is emitted. - * @param {number} delayMs - The delay in milliseconds before emitting the content. - * @returns {ReadableStream} A ReadableStream that emits the encoded content after the specified delay. - */ -export function delayedReadableStream( - content: string | undefined, - delayMs: number -) { - return new ReadableStream({ - async start(controller) { - if (delayMs > 0) await delay(delayMs); - if (typeof content === "string" && content.length > 0) { - controller.enqueue(new TextEncoder().encode(content)); - } - controller.close(); - }, - }); -} - -/** - * Type guard to check if a value is an EncodedEventStream. - * - * @param {unknown} value - The value to check. - * @returns {value is EncodedEventStream} True if the value is an EncodedEventStream, false otherwise. - */ -export function isEncodedEventStream( - value: unknown -): value is EncodedEventStream { - return ( - typeof value === "object" && - value !== null && - "$type" in value && - value.$type === "event-stream" - ); -} - -/** - * Creates a ReadableStream that emits events from an encoded event stream, - * optionally delaying each event according to its specified timing. - * - * This function is intended to simulate a server-sent events (SSE) stream - * by emitting each event as a chunk, with optional delays between events - * to mimic real-time streaming behavior. - * - * @param {EncodedEventStream} encodedEventStream - The encoded event stream object containing events and their timings. - * @throws {Error} If the provided value is not a valid encoded event stream. - * @returns {ReadableStream} A ReadableStream emitting the encoded event data as Uint8Array chunks. - */ -export function readableEncodedEventStream( - encodedEventStream: EncodedEventStream, - initialDelayMs: number = 0 -) { - if (!isEncodedEventStream(encodedEventStream)) { - throw new Error("Provided value is not an encoded event stream"); - } - const encoder = new TextEncoder(); - return new ReadableStream({ - async start(controller) { - if (initialDelayMs > 0) await delay(initialDelayMs); - // Emit each event in the stream with the associated delay - await Promise.all( - encodedEventStream.events.map(async (event) => { - if (event.timing) await delay(event.timing); - const content: string[] = []; - if (event.data) content.push(event.data); - if (event.event) content.push(event.event); - if (event.id) content.push(event.id); - if (content.length > 0) { - controller.enqueue(encoder.encode(content.join("\n"))); - controller.enqueue(encoder.encode("\n")); - } - }) - ); - controller.close(); - }, - }); -} - -/** - * Encodes a ReadableStream of Uint8Array chunks representing an event stream - * into an EncodedEventStream object, capturing the timing and content of each event. - * - * This function reads from the provided ReadableStream, decodes each chunk, - * and attempts to parse it as JSON to extract event, id, and data fields. - * If parsing fails, the raw decoded string is used as the data. - * The timing for each event is measured relative to the start of the stream. - * - * @param {ReadableStream} readableStream - The stream of event data to encode. - * @returns {Promise} A promise that resolves to the encoded event stream object, - * containing the type and an array of events with their timing and content. - */ -export async function encodeEventStream( - readableStream: ReadableStream -): Promise { - const events: EncodedEventStream["events"] = []; - const decoder = new TextDecoder(); - const reader = readableStream.getReader(); - const startTime = performance.now(); - - let result: ReadableStreamReadResult; - do { - result = await reader.read(); - if (result.done) break; - - const eventTime = performance.now(); - const timing = eventTime - startTime; - - let event; - try { - const output = JSON.parse(decoder.decode(result.value)); - event = { - event: output.event, - id: output.id, - data: output.data, - }; - } catch { - event = { data: decoder.decode(result.value) }; - } - - events.push({ - timing: Math.round(timing), // Round to nearest millisecond - ...event, - }); - } while (!result.done); - return { $type: "event-stream", events }; -} - -/** - * Creates a ReadableStream for the HAR entry's response body, optionally delaying - * the emission of the body according to the HAR entry's `timings.receive` value. If the - * response body is a text/event-stream, has the proper encoding, and `useTimings` is true, - * the stream chunks will be delayed according to the timings provided in the response contents. - * - * This is useful for simulating network latency or response timing when replaying - * HTTP interactions from a HAR archive. - * - * @param {HAREntry} entry - The HAR entry containing the response and timing information. - * @returns {Promise>} A promise that resolves to a ReadableStream - * emitting the response body as a Uint8Array, after an optional delay. - */ -export function readableHARResponseStream( - entry: HAREntry, - useTimings: boolean -) { - const contentType = entry.response.headers.find( - (header) => header.name.toLowerCase() === "content-type" - ); - // If the response is a text/event-stream and has the proper encoding, we handle it differently - // to more closely model streaming responses. - if (contentType?.value.includes("text/event-stream") && useTimings) { - // Safely parse the contained event stream to valid JSON - const responseJson = iife(() => { - try { - return JSON.parse(entry.response.content.text ?? ""); - } catch { - return undefined; - } - }); - if (isEncodedEventStream(responseJson)) { - return readableEncodedEventStream(responseJson, entry.timings.wait); - } - } - // Otherwise, we default to using the timings contained within the HAR entry. - return delayedReadableStream( - entry.response.content.text, - useTimings ? entry.timings.receive : 0 - ); -} - -/** - * Encodes HTTP headers into the HAR (HTTP Archive) header format. - * - * Filters out headers that are not in the `includeKeys` list or are - * the special passthrough header ("x-mock-passthrough"), and returns - * an array of HARHeader objects suitable for inclusion in a HAR entry. - * - * @param {Headers} headers - The HTTP headers to encode. - * @param {string[]} [includeKeys=[]] - An array of header names to include in the output. - * @returns {HARHeader[]} The encoded HAR header objects. - */ -function encodeHARHeaders(headers: Headers, includeKeys?: string[]) { - const harHeaders: HARHeader[] = []; - for (const [key, value] of headers.entries()) { - // don't store the passthrough header in the archive - if (key === "x-mock-passthrough") continue; - else if ( - includeKeys && - !includeKeys.includes(key) && - !WELL_KNOWN_HEADERS.includes(key) - ) { - harHeaders.push({ name: key, value: "" }); - } else { - harHeaders.push({ name: key, value }); - } - } - return harHeaders; -} - -/** - * Encodes cookies from HTTP headers into the HAR (HTTP Archive) cookie format. - * - * Parses the "cookie" header, filters out cookies whose names are not in the - * `includeKeys` list or have empty values, and returns an array of HARCookie objects. - * - * @param {Headers} headers - The HTTP headers containing the "cookie" header. - * @param {string[]} [includeKeys=[]] - An array of cookie names to include in the output. - * @returns {HARCookie[]} The encoded HAR cookie objects. - */ -function encodeHARCookies(headers: Headers, includeKeys: string[] = []) { - const cookies: HARCookie[] = []; - const cookieMap = parseCookie(headers.get("cookie") ?? ""); - for (const [key, value] of Object.entries(cookieMap)) { - if (!value) continue; - if (!includeKeys.includes(key)) continue; - cookies.push({ name: key, value }); - } - return cookies; -} - -/** - * Encodes a request or response body into a HAR-compatible format, handling - * both text and binary content. - * @param {ReadableStream | null} body The body stream to encode. - * @param {string | null} mimeType The MIME type of the content. - * @returns {Promise} An object with the encoded text and an optional encoding property. - */ -async function encodeHARContent( - body: ReadableStream | null, - mimeType: string | null -): Promise { - const resolvedMimeType = mimeType ?? ""; - if (!body) { - return { text: "", mimeType: resolvedMimeType, size: 0 }; - } - const buffer = await consumeBodyStream(body); - - if (isTextMimeType(resolvedMimeType)) { - const text = buffer ? new TextDecoder("utf-8").decode(buffer) : ""; - return { text, mimeType: resolvedMimeType, size: text.length }; - } - let text = ""; - if (buffer) { - let binary = ""; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i += 1) { - binary += String.fromCharCode(bytes[i]); - } - text = btoa(binary); - } - return { - text, - encoding: "base64", - mimeType: resolvedMimeType, - size: text.length, - }; -} - -/** - * Encodes a Fetch API Request object into a HAR (HTTP Archive) request object. - * - * Extracts method, URL, HTTP version, headers, cookies, query parameters, - * and (if present) the request body. Includes any headers or cookies whose - * names are in the `includeKeys` list. - * - * @param {Request} request - The Fetch API Request to encode. - * @param {string[]} [includeKeys=[]] - An array of header or cookie names to include in the output. - * @returns {Promise} A promise that resolves to the encoded HAR request object. - */ -export async function encodeHARRequest( - request: Request, - includeKeys: string[] = [] -): Promise { - const requestUrl = new URL(request.url); - const queryParams = new URLSearchParams(requestUrl.search); - const queryString: HARQueryString[] = []; - for (const [key, value] of queryParams.entries()) { - queryString.push({ name: key, value }); - } - let postData: HARPostData | undefined; - if (request.body && request.method !== "GET") { - const mimeType = request.headers.get("content-type"); - postData = await encodeHARContent(request.body, mimeType); - } - return { - method: request.method, - url: request.url, - httpVersion: request.headers.get("version") ?? "HTTP/1.1", - cookies: encodeHARCookies(request.headers, includeKeys), - headers: encodeHARHeaders(request.headers, includeKeys), - queryString, - postData, - headersSize: JSON.stringify(request.headers.entries()).length, - bodySize: postData?.text?.length ?? 0, - }; -} - -/** - * Encodes a Fetch API Response object into a HAR (HTTP Archive) response object. - * - * Extracts status, status text, HTTP version, headers, cookies, and response body. - * If the response is a text/event-stream, encodes the event stream as JSON. - * Includes any headers or cookies whose names are in the `includeKeys` list. - * - * @param {Response} response - The Fetch API Response to encode. - * @param {string[]} [includeKeys=[]] - An array of header or cookie names to include in the output. - * @returns {Promise} A promise that resolves to the encoded HAR response object. - */ -export async function encodeHARResponse( - response: Response, - includeKeys: string[] = [] -): Promise { - const content = await iife(async () => { - const contentType = response.headers.get("content-type"); - if (!response.body) { - return { - size: 0, - mimeType: contentType ?? "", - text: "", - }; - } - if (contentType?.includes("text/event-stream")) { - const encodedEventStream = await encodeEventStream(response.body); - const text = JSON.stringify(encodedEventStream); - return { - size: text.length, - mimeType: contentType ?? "", - text, - }; - } - return encodeHARContent(response.body, contentType); - }); - return { - status: response.status, - statusText: response.statusText, - httpVersion: response.headers.get("version") ?? "HTTP/1.1", - cookies: encodeHARCookies(response.headers, includeKeys), - headers: encodeHARHeaders(response.headers), - content, - redirectURL: response.headers.get("location") ?? "", - headersSize: JSON.stringify(response.headers.entries()).length, - bodySize: content.size, - }; -} - -/** - * A helper function to determine if a mime type should be treated as text. - * @param {string | null | undefined} mimeType The mime type to check. - * @returns {boolean} True if the mime type is text-based, false otherwise. - */ -function isTextMimeType(mimeType: string | null | undefined): boolean { - if (!mimeType) { - return true; // Default to text if mime type is not provided - } - const textMimeTypes = [ - "application/json", - "application/xml", - "application/x-www-form-urlencoded", - "application/javascript", - "text/", - ]; - return textMimeTypes.some((textMimeType) => - mimeType.startsWith(textMimeType) - ); -} - -/** - * Determines whether a HAR entry is considered stale based on its start time and a maximum age. - * - * @param {HAREntry} entry - The HAR entry to check. - * @param {number} maxAge - The maximum allowed age in milliseconds. - * @returns {boolean} True if the entry is older than the allowed maxAge, false otherwise. - */ -export function entryIsStale(entry: HAREntry, maxAge: number) { - return new Date(entry.startedDateTime).getTime() < Date.now() - maxAge; -} diff --git a/internal/net-mocks/src/spec.ts b/internal/net-mocks/src/spec.ts deleted file mode 100644 index 0ec2ec5b1d31..000000000000 --- a/internal/net-mocks/src/spec.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * HTTP Archive (HAR) v1.2 TypeScript Interfaces - * Based on the HAR 1.2 specification derived from http://www.softwareishard.com/blog/har-12-spec/ - */ - -/** - * Root HAR object containing the log data. - */ -export interface HARArchive { - /** - * The main log object containing all HAR data. - */ - log: HARLog; -} - -/** - * Root object representing the exported data - */ -export interface HARLog { - /** Version number of the format */ - version: "1.2"; - /** Name and version info of the log creator application */ - creator: HARCreator; - /** Name and version info of used browser (optional) */ - browser?: HARBrowser; - /** List of all exported (tracked) pages (optional) */ - pages: HARPage[]; - /** List of all exported (tracked) requests */ - entries: HAREntry[]; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Name and version info of the application used to export the log - */ -export interface HARCreator { - /** Name of the application used to export the log */ - name: string; - /** Version of the application used to export the log */ - version: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Name and version info of the browser used to export the log - */ -export interface HARBrowser { - /** Name of the browser used to export the log */ - name: string; - /** Version of the browser used to export the log */ - version: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Represents an exported page - */ -export interface HARPage { - /** Date and time stamp for the beginning of the page load (ISO 8601) */ - startedDateTime: string; - /** Unique identifier of a page within the log. Entries use it to refer the parent page */ - id: string; - /** Page title */ - title: string; - /** Detailed timing info about page load */ - pageTimings: HARPageTimings; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Describes timings for various events (states) fired during the page load - * All times are specified in milliseconds. If a time info is not available appropriate field is set to -1 - */ -export interface HARPageTimings { - /** Content of the page loaded. Number of milliseconds since page load started (optional, default -1) */ - onContentLoad?: number; - /** Page is loaded (onLoad event fired). Number of milliseconds since page load started (optional, default -1) */ - onLoad?: number; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Represents an HTTP request entry - */ -export interface HAREntry { - /** Unique Reference to the parent page (optional) */ - pageref?: string; - /** Date and time stamp of the request start (ISO 8601) */ - startedDateTime: string; - /** Total elapsed time of the request in milliseconds. This is the sum of all timings available in the timings object (not including -1 values) */ - time: number; - /** Detailed info about the request */ - request: HARRequest; - /** Detailed info about the response */ - response: HARResponse; - /** Info about cache usage */ - cache: HARCache; - /** Detailed timing info about request/response round trip */ - timings: HARTimings; - /** IP address of the server that was connected (result of DNS resolution) (optional) */ - serverIPAddress?: string; - /** Unique ID of the parent TCP/IP connection, can be the client or server port number (optional) */ - connection?: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Contains detailed info about performed request - */ -export interface HARRequest { - /** Request method */ - method: string; - /** Absolute URL of the request (fragments are not included) */ - url: string; - /** Request HTTP Version */ - httpVersion: string; - /** List of cookie objects */ - cookies: HARCookie[]; - /** List of header objects */ - headers: HARHeader[]; - /** List of query parameter objects */ - queryString: HARQueryString[]; - /** Posted data info (optional) */ - postData?: HARPostData; - /** Total number of bytes from the start of the HTTP request message until (and including) the double CRLF before the body */ - headersSize: number; - /** Size of the request body in bytes (e.g. POST data payload) */ - bodySize: number; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Contains detailed info about the response - */ -export interface HARResponse { - /** Response status */ - status: number; - /** Response status description */ - statusText: string; - /** Response HTTP Version */ - httpVersion: string; - /** List of cookie objects */ - cookies: HARCookie[]; - /** List of header objects */ - headers: HARHeader[]; - /** Details about the response body */ - content: HARContent; - /** Redirection target URL from the Location response header */ - redirectURL: string; - /** Total number of bytes from the start of the HTTP response message until (and including) the double CRLF before the body */ - headersSize: number; - /** Size of the received response body in bytes. Set to 0 in case of responses coming from the cache (304) */ - bodySize: number; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Contains list of all cookies (used in request and response objects) - */ -export interface HARCookie { - /** The name of the cookie */ - name: string; - /** The cookie value */ - value: string; - /** The path pertaining to the cookie (optional) */ - path?: string; - /** The host of the cookie (optional) */ - domain?: string; - /** Cookie expiration time (ISO 8601) (optional) */ - expires?: string; - /** Set to true if the cookie is HTTP only, false otherwise (optional) */ - httpOnly?: boolean; - /** true if the cookie was transmitted over ssl, false otherwise (optional) */ - secure?: boolean; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Contains list of all headers (used in request and response objects) - */ -export interface HARHeader { - /** The name of the header */ - name: string; - /** The header value */ - value: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Contains list of all parameters & values parsed from a query string - */ -export interface HARQueryString { - /** The name of the query */ - name: string; - /** The query value */ - value: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Describes posted data - */ -export interface HARPostData { - /** Mime type of posted data */ - mimeType: string; - /** List of posted parameters (in case of URL encoded parameters) (optional, mutually exclusive with text) */ - params?: HARParam[]; - /** Plain text posted data (optional, mutually exclusive with params) */ - text?: string; - /** Encoding used for posted data e.g. "base64" (optional) */ - encoding?: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * List of posted parameters - */ -export interface HARParam { - /** name of a posted parameter */ - name: string; - /** value of a posted parameter or content of a posted file (optional) */ - value?: string; - /** name of a posted file (optional) */ - fileName?: string; - /** content type of a posted file (optional) */ - contentType?: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Describes details about response content - */ -export interface HARContent { - /** Length of the returned content in bytes. Should be equal to response.bodySize if there is no compression and bigger when the content has been compressed */ - size: number; - /** Number of bytes saved (optional) */ - compression?: number; - /** MIME type of the response text (value of the Content-Type response header). The charset attribute of the MIME type is included (if available) */ - mimeType: string; - /** Response body sent from the server or loaded from the browser cache (optional) */ - text?: string; - /** Encoding used for response text field e.g "base64" (optional) */ - encoding?: string; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Contains info about a request coming from browser cache - */ -export interface HARCache { - /** State of a cache entry before the request (optional) */ - beforeRequest?: HARCacheEntry | null; - /** State of a cache entry after the request (optional) */ - afterRequest?: HARCacheEntry | null; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Cache entry state (before or after request) - */ -export interface HARCacheEntry { - /** Expiration time of the cache entry (optional) */ - expires?: string; - /** The last time the cache entry was opened */ - lastAccess: string; - /** Etag */ - eTag: string; - /** The number of times the cache entry has been opened */ - hitCount: number; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * Describes various phases within request-response round trip - * All times are specified in milliseconds - */ -export interface HARTimings { - /** Time spent in a queue waiting for a network connection (optional, default -1) */ - blocked?: number; - /** DNS resolution time. The time required to resolve a host name (optional, default -1) */ - dns?: number; - /** Time required to create TCP connection (optional, default -1) */ - connect?: number; - /** Time required to send HTTP request to the server */ - send: number; - /** Waiting for a response from the server */ - wait: number; - /** Time required to read entire response from the server (or cache) */ - receive: number; - /** Time required for SSL/TLS negotiation (optional, default -1) */ - ssl?: number; - /** A comment provided by the user or the application (optional) */ - comment?: string; -} - -/** - * (NOT PART OF SPEC) - * Additional type for event streams that are encoded as JSON. - */ -export type EncodedEventStream = { - $type: "event-stream"; - events: { - /** The time in milliseconds since the start of the stream */ - timing?: number; - /** The ID of the event */ - id?: string; - /** The event type */ - event?: string; - /** The data of the event */ - data?: string; - }[]; -}; diff --git a/internal/net-mocks/src/utils.ts b/internal/net-mocks/src/utils.ts deleted file mode 100644 index eef73471a783..000000000000 --- a/internal/net-mocks/src/utils.ts +++ /dev/null @@ -1,69 +0,0 @@ -import _deepEqual from "deep-equal"; - -/** - * Immediately invokes the provided function and returns its result. - * Useful for creating immediately-invoked function expressions (IIFE) in a concise way. - * - * @template T The return type of the function. - * @param {() => T} fn - The function to invoke. - * @returns {T} The result of invoking the function. - */ -export const iife = (fn: () => T): T => fn(); - -/** - * Represents a value that can be either a direct value of type T or a Promise that resolves to T. - * - * @template T The type of the value. - */ -export type PromiseOrValue = T | Promise; - -/** - * Returns a Promise that resolves after a specified number of milliseconds. - * - * @param {number} ms - The number of milliseconds to delay. - * @returns {Promise} A Promise that resolves after the delay. - */ -export const delay = (ms: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -/** - * Converts a string into a file-safe string by replacing or removing - * characters that are not safe for filenames on most filesystems. - * - Replaces spaces and unsafe characters with underscores. - * - Removes or replaces reserved/special characters. - * - Trims leading/trailing underscores and dots. - */ -export function toFileSafeString(input: string): string { - return input - .normalize("NFKD") // Normalize unicode - .replace(/[\u0300-\u036f]/g, "") // Remove diacritics - .replace(/[^a-zA-Z0-9._-]/g, "_") // Replace unsafe chars with _ - .replace(/_+/g, "_") // Collapse multiple underscores - .replace(/^[_.]+|[_.]+$/g, "") // Trim leading/trailing _ or . - .slice(0, 255); // Limit to 255 chars (common FS limit) -} - -/** - * Performs a deep equality check between two values, with special handling for stringified JSON. - * - * If either value is a string that can be parsed as JSON, it will be parsed before comparison. - * This allows for deep equality checks between objects and their JSON string representations. - * - * @param {unknown} a - The first value to compare. Can be any type. - * @param {unknown} b - The second value to compare. Can be any type. - * @returns {boolean} True if the values are deeply equal (after normalization), false otherwise. - */ -export function deepEqual(a: unknown, b: unknown): boolean { - const normalize = (value: unknown) => { - if (typeof value !== "string") return value; - try { - return JSON.parse(value); - } catch { - return value; - } - }; - - return _deepEqual(normalize(a), normalize(b)); -} diff --git a/internal/net-mocks/tsconfig.json b/internal/net-mocks/tsconfig.json deleted file mode 100644 index 6ebc8ad22f4f..000000000000 --- a/internal/net-mocks/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "@tsconfig/recommended", - "compilerOptions": { - "target": "ES2021", - "lib": ["ES2021", "ES2022.Object", "DOM"], - "module": "ES2020", - "moduleResolution": "bundler", - "esModuleInterop": true, - "declaration": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "useDefineForClassFields": true, - "strictPropertyInitialization": false, - "allowJs": true, - "strict": true - }, - "include": ["src/*", "src/**/*"], - "exclude": ["__tests__", "node_modules"] -}