From b2dad21f768e186b53c47c5417ae71e7dd1447e9 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 23:05:15 +0530 Subject: [PATCH 01/22] chore: rename token concept to "proxy token", archive v1 proxies, prep v2.1 - doppelganger -> "proxy token" across docs/README/tests/comments; token prefix dgk_ -> ptk_ (cosmetic only; tokens validate by hash, existing ones unaffected) - move the three v1 per-provider passthrough workers + wrangler configs to _legacy/v1/ (git renames) with a README; reference-only, not deployed - document Gemini as built-but-unproven (no key yet; mock-tested only) - align lefthook pre-commit biome flag with the lint script (--unsafe) --- README.md | 12 ++++--- _legacy/v1/README.md | 35 +++++++++++++++++++ {src => _legacy/v1}/claude.ts | 0 {src => _legacy/v1}/gemini.ts | 0 {src => _legacy/v1}/openai.ts | 0 .../v1/wrangler.claude.toml | 2 +- .../v1/wrangler.gemini.toml | 2 +- .../v1/wrangler.openai.toml | 2 +- docs/learnings/README.md | 2 +- .../provider-routing-by-auth-header.md | 2 +- ...en-security.md => proxy-token-security.md} | 6 ++-- ... => 2026-06-22-api-proxy-tokens-design.md} | 18 +++++----- lefthook.yml | 2 +- src/proxy.ts | 2 +- src/tokens.ts | 4 +-- test/proxy-handler.test.ts | 2 +- test/proxy.test.ts | 2 +- test/sdk-compat/setup.ts | 2 +- test/tokens.test.ts | 6 ++-- 19 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 _legacy/v1/README.md rename {src => _legacy/v1}/claude.ts (100%) rename {src => _legacy/v1}/gemini.ts (100%) rename {src => _legacy/v1}/openai.ts (100%) rename wrangler.claude.toml => _legacy/v1/wrangler.claude.toml (80%) rename wrangler.gemini.toml => _legacy/v1/wrangler.gemini.toml (80%) rename wrangler.openai.toml => _legacy/v1/wrangler.openai.toml (80%) rename docs/learnings/{doppelganger-token-security.md => proxy-token-security.md} (90%) rename docs/superpowers/specs/{2026-06-22-api-proxy-doppelganger-tokens-design.md => 2026-06-22-api-proxy-tokens-design.md} (92%) diff --git a/README.md b/README.md index 7bd58a9..24b0452 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # api-proxy -A single Cloudflare Worker that reverse-proxies the OpenAI, Anthropic, and Google Gemini APIs behind **revocable "doppelganger" tokens**. You issue tokens from an admin dashboard and hand them out; each token is validated server-side and swapped for the real provider key before the request is forwarded. Consumers never see your real keys, and you can scope or revoke any token at any time. +A single Cloudflare Worker that reverse-proxies the OpenAI, Anthropic, and Google Gemini APIs behind **revocable proxy tokens**. You issue tokens from an admin dashboard and hand them out; each token is validated server-side and swapped for the real provider key before the request is forwarded. Consumers never see your real keys, and you can scope or revoke any token at any time. -The consumer changes only **two things** in their normal SDK: the base URL (point at your worker) and the API key (use a doppelganger token). +The consumer changes only **two things** in their normal SDK: the base URL (point at your worker) and the API key (use a proxy token). ## How it works -The doppelganger token rides in the SDK's normal auth slot. The worker reads it, validates it against KV, checks the token is scoped to the requested provider, strips every inbound auth header, sets the one real key, and forwards the request (path + query verbatim, streaming included). +The proxy token rides in the SDK's normal auth slot. The worker reads it, validates it against KV, checks the token is scoped to the requested provider, strips every inbound auth header, sets the one real key, and forwards the request (path + query verbatim, streaming included). | Token arrives in | Provider | Upstream | Real key set as | |---|---|---|---| @@ -17,7 +17,7 @@ The doppelganger token rides in the SDK's normal auth slot. The worker reads it, ## Client setup -Point the SDK's base URL at the worker and use a doppelganger token as the key: +Point the SDK's base URL at the worker and use a proxy token as the key: | SDK | base URL | key | |---|---|---| @@ -61,7 +61,7 @@ Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens: - Real provider keys are Cloudflare secrets, injected only into outbound requests — never in KV, never returned to callers. - Tokens are stored as SHA-256 hashes; a KV/dashboard dump yields unusable hashes, not live tokens. -- The worker strips all inbound auth headers before setting the real key, so a doppelganger token is never forwarded upstream. +- The worker strips all inbound auth headers before setting the real key, so a proxy token is never forwarded upstream. - Do not host the worker on a `*.openai.azure.com` / `*.cognitiveservices.azure.com` domain (the OpenAI SDK switches to Azure auth on those hostnames). ## Testing @@ -76,6 +76,8 @@ nub run test # both Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, then drives each real SDK and asserts the forwarded request carries the real key (and never the token). +> **Gemini is mock-tested only.** No test hits a live provider API — all three run against the mock upstream. OpenAI and Anthropic are additionally verified live in deployment; Gemini is **not**, because `GEMINI_API_KEY` isn't set yet. The Gemini route has never run against the real Google Generative Language API, so treat it as built-but-unproven until a key is added. + ## Disable / Enable `schedule.sh` toggles the worker's `workers_dev` URL without deleting it: diff --git a/_legacy/v1/README.md b/_legacy/v1/README.md new file mode 100644 index 0000000..ecf38bb --- /dev/null +++ b/_legacy/v1/README.md @@ -0,0 +1,35 @@ +# v1 - one worker per provider (archived) + +The original proxy: **three separate Workers**, one per provider, each a thin pass-through +that swaps the hostname and injects the real key from its own secret. + +``` +client ──▶ openai-proxy ──(Bearer OPENAI_API_KEY)──▶ api.openai.com +client ──▶ claude-proxy ──(x-api-key ANTHROPIC_KEY)─▶ api.anthropic.com +client ──▶ gemini-proxy ──(x-goog-api-key GEMINI)──▶ generativelanguage.googleapis.com +``` + +| File | Worker | Upstream | Key slot it sets | +|---|---|---|---| +| `openai.ts` / `wrangler.openai.toml` | `openai-proxy` | api.openai.com | `Authorization: Bearer` | +| `claude.ts` / `wrangler.claude.toml` | `claude-proxy` | api.anthropic.com | `x-api-key` | +| `gemini.ts` / `wrangler.gemini.toml` | `gemini-proxy` | generativelanguage.googleapis.com | `x-goog-api-key` (and strips `?key=`) | + +## Why it was replaced + +- **No auth.** Each worker injected the real upstream key for *any* caller. Anyone who knew + the URL spent the key. There were no shareable, revocable tokens. +- **Three deploys, three URLs.** Clients had to know which worker maps to which provider, and + each needed its own secret and deploy. + +v2 (the active root worker) collapses all three into **one** worker that routes by auth header, +gates every request behind a hashed [proxy token](../../docs/learnings/proxy-token-security.md), +and adds the [OpenAI geo-403 egress fix](../../docs/learnings/openai-egress-geo-block.md). See +[provider routing by auth header](../../docs/learnings/provider-routing-by-auth-header.md) for how +one base URL serves all three. + +## Status + +Reference only - **not deployed, not built, not tested.** Kept to document where the project +started. The `main` paths in these tomls point at files in this folder, so each could still be +deployed standalone (`wrangler deploy -c _legacy/v1/wrangler.openai.toml`) if ever needed. diff --git a/src/claude.ts b/_legacy/v1/claude.ts similarity index 100% rename from src/claude.ts rename to _legacy/v1/claude.ts diff --git a/src/gemini.ts b/_legacy/v1/gemini.ts similarity index 100% rename from src/gemini.ts rename to _legacy/v1/gemini.ts diff --git a/src/openai.ts b/_legacy/v1/openai.ts similarity index 100% rename from src/openai.ts rename to _legacy/v1/openai.ts diff --git a/wrangler.claude.toml b/_legacy/v1/wrangler.claude.toml similarity index 80% rename from wrangler.claude.toml rename to _legacy/v1/wrangler.claude.toml index 591c989..1ce3c1f 100644 --- a/wrangler.claude.toml +++ b/_legacy/v1/wrangler.claude.toml @@ -1,5 +1,5 @@ name = "claude-proxy" -main = "src/claude.ts" +main = "claude.ts" compatibility_date = "2025-01-01" workers_dev = true preview_urls = false diff --git a/wrangler.gemini.toml b/_legacy/v1/wrangler.gemini.toml similarity index 80% rename from wrangler.gemini.toml rename to _legacy/v1/wrangler.gemini.toml index 6091d99..d698d4b 100644 --- a/wrangler.gemini.toml +++ b/_legacy/v1/wrangler.gemini.toml @@ -1,5 +1,5 @@ name = "gemini-proxy" -main = "src/gemini.ts" +main = "gemini.ts" compatibility_date = "2025-01-01" workers_dev = true preview_urls = false diff --git a/wrangler.openai.toml b/_legacy/v1/wrangler.openai.toml similarity index 80% rename from wrangler.openai.toml rename to _legacy/v1/wrangler.openai.toml index b5eb2ab..60bf453 100644 --- a/wrangler.openai.toml +++ b/_legacy/v1/wrangler.openai.toml @@ -1,5 +1,5 @@ name = "openai-proxy" -main = "src/openai.ts" +main = "openai.ts" compatibility_date = "2025-01-01" workers_dev = true preview_urls = false diff --git a/docs/learnings/README.md b/docs/learnings/README.md index 760468f..cb8c0a6 100644 --- a/docs/learnings/README.md +++ b/docs/learnings/README.md @@ -10,4 +10,4 @@ Each file: the problem, what we found, and the decision we keep. - [openai-egress-geo-block.md](openai-egress-geo-block.md) - why OpenAI 403'd ~40% of the time, and the North-America-pinned Durable Object that fixes it - [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md) - one base URL, no path prefix; route by which auth slot the SDK used -- [doppelganger-token-security.md](doppelganger-token-security.md) - how a shareable token rides the SDK's auth slot without ever leaking the real key +- [proxy-token-security.md](proxy-token-security.md) - how a shareable token rides the SDK's auth slot without ever leaking the real key diff --git a/docs/learnings/provider-routing-by-auth-header.md b/docs/learnings/provider-routing-by-auth-header.md index d56c11f..557b576 100644 --- a/docs/learnings/provider-routing-by-auth-header.md +++ b/docs/learnings/provider-routing-by-auth-header.md @@ -44,4 +44,4 @@ Auth-slot routing keeps each SDK's native path intact, so it stays a true drop-i Route by auth header. The token is extracted from the same slot, validated, and then **all** inbound auth headers are stripped and exactly one real key is set for the chosen provider (see -[doppelganger-token-security.md](doppelganger-token-security.md)). +[proxy-token-security.md](proxy-token-security.md)). diff --git a/docs/learnings/doppelganger-token-security.md b/docs/learnings/proxy-token-security.md similarity index 90% rename from docs/learnings/doppelganger-token-security.md rename to docs/learnings/proxy-token-security.md index 9222f81..760e1c7 100644 --- a/docs/learnings/doppelganger-token-security.md +++ b/docs/learnings/proxy-token-security.md @@ -1,8 +1,8 @@ -# Doppelganger token security +# Proxy token security ## Idea -A "doppelganger" token is a shareable, revocable stand-in for a real provider key. The holder puts it +A proxy token is a shareable, revocable stand-in for a real provider key. The holder puts it in the normal SDK auth slot; the proxy validates it, then swaps in the real key. You can hand someone access without exposing your OpenAI/Anthropic/Gemini key, and revoke it any time. @@ -29,7 +29,7 @@ auth slot = ──▶ extract token from auth slot - **Strip-all-then-set-one.** Before forwarding, delete *every* inbound auth header (`authorization`, `x-api-key`, `x-goog-api-key`) and set exactly one with the real key - (`src/proxy.ts` `swapAuth`). This guarantees the doppelganger token is never forwarded upstream, + (`src/proxy.ts` `swapAuth`). This guarantees the proxy token is never forwarded upstream, even if a client sends it in an unexpected slot. A test asserts the token never appears in any outbound auth header. diff --git a/docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.md b/docs/superpowers/specs/2026-06-22-api-proxy-tokens-design.md similarity index 92% rename from docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.md rename to docs/superpowers/specs/2026-06-22-api-proxy-tokens-design.md index 69a4b46..616a725 100644 --- a/docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.md +++ b/docs/superpowers/specs/2026-06-22-api-proxy-tokens-design.md @@ -1,8 +1,8 @@ -# api-proxy — Doppelganger Tokens Design +# api-proxy — Proxy Tokens Design - **Date:** 2026-06-22 - **Status:** Approved; implementation in progress -- **Scope:** Replace the three transparent reverse-proxy workers with one token-gated worker plus an embedded admin dashboard. Issue shareable, revocable "doppelganger" API-key tokens that map to real provider keys server-side. +- **Scope:** Replace the three transparent reverse-proxy workers with one token-gated worker plus an embedded admin dashboard. Issue shareable, revocable proxy API-key tokens that map to real provider keys server-side. > Naming: there is no "v1/v2" product split. This is the `api-proxy` project evolving. The new token-gated worker deploys under its own worker name so it can run alongside the existing transparent proxies during validation; the old `src/{claude,openai,gemini}.ts` files and their tomls are deleted once the new worker is reliable. @@ -16,7 +16,7 @@ We want: hand someone a token they plug into their normal SDK (changing only the ## 2. Goals -- A consumer uses their existing SDK by changing **two things**: the base URL (point at the worker) and the API key (use a doppelganger token). +- A consumer uses their existing SDK by changing **two things**: the base URL (point at the worker) and the API key (use a proxy token). - The owner mints, scopes (per provider), disables, and deletes tokens from an admin dashboard. - Real provider keys never leave the worker and never live in KV. - Support OpenAI, Anthropic, and Google Gemini, including streaming, for server-side SDK usage. @@ -29,7 +29,7 @@ Rate limits, spend/token caps, expiry dates, per-token usage analytics, browser/ | Decision | Choice | Why | |---|---|---| -| Mechanism | Doppelganger token = the API key the SDK already sends. Worker reads it from the auth header, validates against KV, swaps in the real key. | Unifies "shareable key" and "common-ground SDK config"; client changes only base URL + key. | +| Mechanism | Proxy token = the API key the SDK already sends. Worker reads it from the auth header, validates against KV, swaps in the real key. | Unifies "shareable key" and "common-ground SDK config"; client changes only base URL + key. | | Topology | One worker, one base URL, **no provider path prefix**. Provider routing by which auth header the token arrives in (+ path for Gemini OpenAI-compat). | A `/openai` `/anthropic` `/gemini` prefix breaks Gemini native file uploads. The auth header already identifies the provider. | | Architecture | **Single worker, embedded.** Top-level dispatch: `/admin/*` → Hono admin sub-app (wrapped in try/catch); everything else → framework-free proxy hot-path. | Proxy requests pay zero routing/SSR cost. Avoids two deploys / two secret sets / broken `schedule.sh`. Escape hatch: move `src/admin/*` to a second worker on the same KV namespace if it ever outgrows CRUD. | | Token storage | Store **SHA-256(token)** in KV; show plaintext once at creation; dashboard shows label + last-4. | Foundational, hard to retrofit. A KV/dashboard dump yields unusable hashes. Standard practice. | @@ -56,7 +56,7 @@ fetch(req, env, ctx): src/ index.ts # fetch entry + dispatch proxy.ts # ZERO framework deps: extractToken, routeProvider, swapAuth, stream passthrough. MUST NOT import Hono. - tokens.ts # KV helpers: sha256hex, generateToken (dgk_ + 32 base64url), create, list, getValidated, update, delete, touchLastUsed + tokens.ts # KV helpers: sha256hex, generateToken (ptk_ + 32 base64url), create, list, getValidated, update, delete, touchLastUsed upstreams.ts # UPSTREAM resolver: reads *_UPSTREAM env with real-host defaults; parses protocol+hostname+port (the test seam) types.ts # shared TokenMetadata, Provider types (imported by proxy + admin) admin/ @@ -124,7 +124,7 @@ switch (provider) { } ``` -Stripping-all-then-setting-one prevents the doppelganger token leaking upstream and closes the Anthropic dual-header leak and the duplicate-`x-goog-api-key` 401. +Stripping-all-then-setting-one prevents the proxy token leaking upstream and closes the Anthropic dual-header leak and the duplicate-`x-goog-api-key` 401. ## 7. Token model & lifecycle @@ -142,7 +142,7 @@ type TokenMetadata = { }; ``` -- **Create:** admin supplies label + provider scopes, types a token or clicks generate (`dgk_` + 32 random base64url from `crypto.getRandomValues`). Worker stores `sha256hex(token) -> metadata`, returns the **plaintext once**. Never retrievable again. +- **Create:** admin supplies label + provider scopes, types a token or clicks generate (`ptk_` + 32 random base64url from `crypto.getRandomValues`). Worker stores `sha256hex(token) -> metadata`, returns the **plaintext once**. Never retrievable again. - **List/Update/Delete:** by hash. Update edits label, providers, status (`active` ⇄ `disabled`). - **last-used:** fire-and-forget KV write on each successful proxied request. @@ -179,14 +179,14 @@ Do not test proxy logic and real-SDK HTTP behavior with one tool — conflating **Tier 1 — proxy logic (always-on CI gate, ~1s):** `@cloudflare/vitest-pool-workers` inside workerd (`vitest.config.ts`). - Seed KV directly: `env.TOKENS.put(sha256hex(token), JSON.stringify(meta))`. -- Capture the outbound call with `vi.spyOn(globalThis, "fetch")`; assert: (a) right upstream host, (b) real key swapped in AND doppelganger token absent (`.not.toContain(token)` on all three header slots), (c) path+query verbatim, (d) 401 on missing/invalid/revoked, 403 on provider-scope mismatch. +- Capture the outbound call with `vi.spyOn(globalThis, "fetch")`; assert: (a) right upstream host, (b) real key swapped in AND proxy token absent (`.not.toContain(token)` on all three header slots), (c) path+query verbatim, (d) 401 on missing/invalid/revoked, 403 on provider-scope mismatch. - SSE: mocked fetch returns a `ReadableStream` `text/event-stream`; drive via `createExecutionContext()`/`waitOnExecutionContext()`; read `response.body.getReader()` chunk-by-chunk; assert content-type preserved and chunks un-buffered. Tier 1 does not use the upstream env seam (it mocks fetch entirely). **Tier 2 — real-SDK compatibility (feature-branch + pre-deploy, ~10-20s):** `vitest.compat.config.ts`, Node pool, `--pool=forks`, serial. - `wrangler unstable_startWorker` starts a real HTTP listener (not the deprecated `unstable_dev`). - A `node:http` mock upstream captures the raw inbound request; point the worker's `*_UPSTREAM` env at it (the seam earns its keep). - Seed the token via the worker's own `POST /admin/api/tokens` (also exercises create). -- Run the real `openai`, `@anthropic-ai/sdk`, `@google/genai` packages with `baseURL` = local worker and `apiKey` = doppelganger token. Assert on the captured request: real key present, doppelganger absent, exact path the SDK constructed (catches `:generateContent`, `/v1beta/openai`). +- Run the real `openai`, `@anthropic-ai/sdk`, `@google/genai` packages with `baseURL` = local worker and `apiKey` = proxy token. Assert on the captured request: real key present, proxy token absent, exact path the SDK constructed (catches `:generateContent`, `/v1beta/openai`). - SSE: mock writes `text/event-stream` chunks; consume via the SDK's own stream iterator; assert on connection-start headers, not the buffered body (avoids mid-stream-disconnect races). **Flakiness guards:** Tier 2 serial (shared mutable capture state + port); never `await body.text()/json()` in the proxy path; use the SDK's stream iterator for completion, not `setTimeout`. diff --git a/lefthook.yml b/lefthook.yml index 27a28f6..fa4918b 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,5 +4,5 @@ pre-commit: commands: check: glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,css}" - run: nubx biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} + run: nubx biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} stage_fixed: true diff --git a/src/proxy.ts b/src/proxy.ts index b143a11..0155f7f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -104,7 +104,7 @@ function egressStub(env: Env): DurableObjectStub { return env.US_EGRESS.get(id, { locationHint: "wnam" }); } -/** Validate the doppelganger token, swap in the real key, forward to the upstream, stream back. */ +/** Validate the proxy token, swap in the real key, forward to the upstream, stream back. */ export async function handleProxy( req: Request, env: Env, diff --git a/src/tokens.ts b/src/tokens.ts index 29c830f..a8dd701 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -18,9 +18,9 @@ function base64url(bytes: Uint8Array): string { return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } -/** A fresh opaque token: dgk_ + 32 url-safe chars (24 random bytes). */ +/** A fresh opaque token: ptk_ + 32 url-safe chars (24 random bytes). */ export function generateToken(): string { - return `dgk_${base64url(crypto.getRandomValues(new Uint8Array(24)))}`; + return `ptk_${base64url(crypto.getRandomValues(new Uint8Array(24)))}`; } export interface CreateInput { diff --git a/test/proxy-handler.test.ts b/test/proxy-handler.test.ts index afaf740..b109e16 100644 --- a/test/proxy-handler.test.ts +++ b/test/proxy-handler.test.ts @@ -174,7 +174,7 @@ describe("auth failures (upstream never called)", () => { }); describe("security invariant", () => { - it("never forwards the doppelganger token upstream", async () => { + it("never forwards the proxy token upstream", async () => { await createToken(env.TOKENS, { label: "sec", providers: ["openai"], diff --git a/test/proxy.test.ts b/test/proxy.test.ts index 87d0f38..848ae83 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -132,7 +132,7 @@ describe("swapAuth", () => { swapAuth(h, "gemini-openai", "REALKEY"); expect(h.get("authorization")).toBe("Bearer REALKEY"); }); - it("never leaves the doppelganger token in any auth header", () => { + it("never leaves the proxy token in any auth header", () => { const h = new Headers({ "x-api-key": "DOPPEL", authorization: "Bearer DOPPEL", diff --git a/test/sdk-compat/setup.ts b/test/sdk-compat/setup.ts index f078e11..7704fb0 100644 --- a/test/sdk-compat/setup.ts +++ b/test/sdk-compat/setup.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net"; import { type Unstable_DevWorker, unstable_dev } from "wrangler"; // Fake real-keys injected as the worker's bindings. Tests assert these reach the mock -// upstream (proving the swap) and that the doppelganger token never does. +// upstream (proving the swap) and that the proxy token never does. export const FAKE = { openai: "FAKE-OPENAI-KEY", anthropic: "FAKE-ANTHROPIC-KEY", diff --git a/test/tokens.test.ts b/test/tokens.test.ts index ebd7356..179f5ed 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -21,8 +21,8 @@ describe("sha256hex", () => { }); describe("generateToken", () => { - it("has the dgk_ prefix and a url-safe body", () => { - expect(generateToken()).toMatch(/^dgk_[A-Za-z0-9_-]{32,}$/); + it("has the ptk_ prefix and a url-safe body", () => { + expect(generateToken()).toMatch(/^ptk_[A-Za-z0-9_-]{32,}$/); }); it("is unique across calls", () => { expect(generateToken()).not.toBe(generateToken()); @@ -35,7 +35,7 @@ describe("createToken + getValidated", () => { label: "alice", providers: ["openai"], }); - expect(token).toMatch(/^dgk_/); + expect(token).toMatch(/^ptk_/); expect(meta.last4).toBe(token.slice(-4)); const got = await getValidated(env.TOKENS, token); expect(got?.label).toBe("alice"); From 60445bd9bf5b3264f3b87e363699b8277b939a1c Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 23:19:02 +0530 Subject: [PATCH 02/22] feat: per-token expiry dates (check-at-validate) - optional expiresAt (UTC ISO) on TokenMetadata + CreateInput - getValidatedByHash rejects past/malformed expiry; fail-closed on NaN - admin: "Expires (optional)" datetime field + "Expires" column; expired tokens show "expired" and dim the row - not KV expirationTtl (60s floor, deletes record, orphans the :lu key) - tests: absent / future / past / malformed expiry --- src/admin/index.ts | 8 ++++++++ src/admin/views.ts | 20 ++++++++++++++------ src/tokens.ts | 9 ++++++++- src/types.ts | 3 ++- test/tokens.test.ts | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/admin/index.ts b/src/admin/index.ts index 83c2990..a6ab8c1 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -114,10 +114,18 @@ app.post("/api/tokens", async (c) => { const fd = await c.req.formData(); const providers = parseProviders(fd); const custom = fd.get("token"); + const rawExp = String(fd.get("expiresAt") || "").trim(); + let expiresAt: string | undefined; + if (rawExp) { + const d = new Date(rawExp); // datetime-local is local time; toISOString normalizes to UTC + if (Number.isNaN(d.getTime())) return c.text("invalid expiry", 400); + expiresAt = d.toISOString(); + } const { token } = await createToken(c.env.TOKENS, { label: String(fd.get("label") || ""), providers: providers.length ? providers : ["openai"], token: custom ? String(custom) : undefined, + expiresAt, }); return c.html(createdNotice(token), 200, { "HX-Trigger": "tokens-changed" }); }); diff --git a/src/admin/views.ts b/src/admin/views.ts index 0e16237..7674631 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -14,7 +14,7 @@ h1{font-size:20px;font-weight:600;margin:0 0 20px} .card{background:#15151c;border:1px solid #24242e;border-radius:12px;padding:18px;margin:0 0 18px} .card h2{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#9a9aa6;margin:0 0 14px} label{display:block;font-size:12px;color:#9a9aa6;margin:0 0 4px} -input[type=text],input[type=password]{width:100%;background:#0e0e14;border:1px solid #2a2a36;border-radius:8px;color:#e7e7ea;padding:9px 11px;font:inherit} +input[type=text],input[type=password],input[type=datetime-local]{width:100%;background:#0e0e14;border:1px solid #2a2a36;border-radius:8px;color:#e7e7ea;padding:9px 11px;font:inherit} .row{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end} .row>div{flex:1;min-width:160px} .checks{display:flex;gap:14px;margin:12px 0} @@ -42,12 +42,15 @@ const timeAgo = (iso?: string) => { return iso.slice(0, 10); }; -export const tokenRow = (r: Row) => html` - +export const tokenRow = (r: Row) => { + const expired = !!r.expiresAt && Date.parse(r.expiresAt) <= Date.now(); + return html` + ${r.label || "(no label)"} …${r.last4} ${providerPills(r.providers)} ${r.status} + ${expired ? html`expired` : html`${r.expiresAt ? r.expiresAt.slice(0, 10) : "never"}`} ${timeAgo(r.lastUsed)} - -`; + `; +}; export const tokenTable = (rows: Row[]) => html` @@ -80,12 +83,13 @@ export const tokenTable = (rows: Row[]) => html` + - ${rows.length ? rows.map(tokenRow) : html``} + ${rows.length ? rows.map(tokenRow) : html``}
Token Providers StatusExpires Last used
No tokens yet.
No tokens yet.
`; @@ -154,6 +158,10 @@ export const dashboardPage = () => html` +
+ + +
diff --git a/src/tokens.ts b/src/tokens.ts index a8dd701..ac42187 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -27,6 +27,7 @@ export interface CreateInput { label: string; providers: CoarseProvider[]; token?: string; // admin-typed; otherwise generated + expiresAt?: string; // ISO (UTC); absent = never expires } export async function createToken( @@ -41,6 +42,7 @@ export async function createToken( providers: input.providers, status: "active", createdAt: new Date().toISOString(), + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), }; await kv.put(hash, JSON.stringify(meta)); return { token, hash, meta }; @@ -67,7 +69,12 @@ export async function getValidatedByHash( hash: string, ): Promise { const meta = parseMeta(await kv.get(hash)); - return meta?.status === "active" ? meta : null; + if (meta?.status !== "active") return null; + if (meta.expiresAt) { + const t = Date.parse(meta.expiresAt); + if (Number.isNaN(t) || t <= Date.now()) return null; // fail-closed on bad/past + } + return meta; } /** Resolve a plaintext token to its metadata, only if it exists and is active. */ diff --git a/src/types.ts b/src/types.ts index e16c3b8..2eddf04 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,8 +7,9 @@ export interface TokenMetadata { providers: CoarseProvider[]; status: "active" | "disabled"; createdAt: string; // ISO + expiresAt?: string; // ISO (UTC); absent = never expires // lastUsed is stored in a separate `:lu` key (see tokens.ts), not here. - // reserved for Later (absent in v1): expiresAt, limits, spend + // reserved for Later: limits, spend } export interface Env { diff --git a/test/tokens.test.ts b/test/tokens.test.ts index 179f5ed..47b81fa 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -130,3 +130,36 @@ describe("touchLastUsed", () => { expect(await getValidated(env.TOKENS, token)).toBeNull(); }); }); + +describe("expiry (getValidatedByHash via getValidated)", () => { + const mk = (token: string, expiresAt?: string) => + createToken(env.TOKENS, { + label: token, + providers: ["openai"], + token, + expiresAt, + }); + + it("absent expiresAt stays valid", async () => { + const { token } = await mk("exp-none"); + expect(await getValidated(env.TOKENS, token)).not.toBeNull(); + }); + it("future expiresAt is valid", async () => { + const { token } = await mk( + "exp-future", + new Date(Date.now() + 3_600_000).toISOString(), + ); + expect(await getValidated(env.TOKENS, token)).not.toBeNull(); + }); + it("past expiresAt is rejected", async () => { + const { token } = await mk( + "exp-past", + new Date(Date.now() - 1000).toISOString(), + ); + expect(await getValidated(env.TOKENS, token)).toBeNull(); + }); + it("malformed expiresAt is rejected (fail-closed)", async () => { + const { token } = await mk("exp-bad", "not-a-date"); + expect(await getValidated(env.TOKENS, token)).toBeNull(); + }); +}); From 7d05d45ec7ad3a7e1620512864895eaee8c5055d Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 23:24:31 +0530 Subject: [PATCH 03/22] feat: CORS preflight + reflect-Origin so browser SDKs work - handleProxy answers the OPTIONS preflight (204) before auth checks, reflecting Origin + the requested headers; previously every browser preflight 401'd - reflect Origin on every response and expose the Gemini resumable-upload headers (x-goog-upload-url etc.) so browser clients can read them - Gemini upload URL still passes through verbatim (bytes go client->Google direct; the real key never rides that leg). Browser callers set the SDK's own opt-in. - tests: preflight, reflected-Origin + expose-headers, no-Origin no-op --- src/proxy.ts | 45 +++++++++++++++++++++++++++- test/proxy-handler.test.ts | 61 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 0155f7f..821e317 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -104,11 +104,54 @@ function egressStub(env: Env): DurableObjectStub { return env.US_EGRESS.get(id, { locationHint: "wnam" }); } -/** Validate the proxy token, swap in the real key, forward to the upstream, stream back. */ +// Headers a browser must be told to expose so the Gemini resumable-upload flow works +// (the client reads x-goog-upload-url, then uploads bytes straight to Google). +const EXPOSE_HEADERS = + "x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity"; + +/** Reflect the caller's Origin so browser SDKs can read the response. No-op for + * non-browser callers (no Origin). The real key never rides on any CORS path. */ +function withCors(res: Response, req: Request): Response { + const origin = req.headers.get("origin"); + if (origin) { + res.headers.set("access-control-allow-origin", origin); + res.headers.append("vary", "Origin"); + res.headers.set("access-control-expose-headers", EXPOSE_HEADERS); + } + return res; +} + +/** Answer the browser preflight. OPTIONS carries no auth header, so it must be handled + * before the token checks - otherwise every browser SDK's preflight 401s. */ +function corsPreflight(req: Request): Response { + const res = new Response(null, { status: 204 }); + res.headers.set( + "access-control-allow-methods", + "GET, POST, PUT, DELETE, OPTIONS", + ); + res.headers.set( + "access-control-allow-headers", + req.headers.get("access-control-request-headers") || "*", + ); + res.headers.set("access-control-max-age", "86400"); + return withCors(res, req); +} + +/** Top-level proxy entry: CORS preflight, then reflect Origin onto every response. */ export async function handleProxy( req: Request, env: Env, ctx: ExecutionContext, +): Promise { + if (req.method === "OPTIONS") return corsPreflight(req); + return withCors(await proxyRequest(req, env, ctx), req); +} + +/** Validate the proxy token, swap in the real key, forward to the upstream, stream back. */ +async function proxyRequest( + req: Request, + env: Env, + ctx: ExecutionContext, ): Promise { const url = new URL(req.url); const token = extractToken(req, url); diff --git a/test/proxy-handler.test.ts b/test/proxy-handler.test.ts index b109e16..3600845 100644 --- a/test/proxy-handler.test.ts +++ b/test/proxy-handler.test.ts @@ -178,12 +178,12 @@ describe("security invariant", () => { await createToken(env.TOKENS, { label: "sec", providers: ["openai"], - token: "SECRET-DOPPEL", + token: "SECRET-TOKEN", }); await call( new Request("https://proxy.example/v1/chat/completions", { method: "POST", - headers: { authorization: "Bearer SECRET-DOPPEL" }, + headers: { authorization: "Bearer SECRET-TOKEN" }, body: "{}", }), ); @@ -192,7 +192,7 @@ describe("security invariant", () => { captured!.headers.get("x-api-key"), captured!.headers.get("x-goog-api-key"), ].join("|"); - expect(slots).not.toContain("SECRET-DOPPEL"); + expect(slots).not.toContain("SECRET-TOKEN"); }); }); @@ -332,3 +332,58 @@ describe("SSE passthrough", () => { expect(text).toContain("[DONE]"); }); }); + +describe("CORS", () => { + it("answers the preflight OPTIONS without auth and never calls upstream", async () => { + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "OPTIONS", + headers: { + origin: "https://app.example", + "access-control-request-headers": "x-api-key, content-type", + }, + }), + ); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe( + "https://app.example", + ); + expect(res.headers.get("access-control-allow-methods")).toContain("POST"); + expect(res.headers.get("access-control-allow-headers")).toBe( + "x-api-key, content-type", + ); + expect(res.headers.get("access-control-max-age")).toBe("86400"); + expect(captured).toBeNull(); + }); + + it("reflects Origin and exposes the Gemini upload headers on a proxied response", async () => { + await seed("tk-cors", ["anthropic"]); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-cors", origin: "https://app.example" }, + body: "{}", + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe( + "https://app.example", + ); + expect(res.headers.get("access-control-expose-headers")).toContain( + "x-goog-upload-url", + ); + }); + + it("omits CORS headers when no Origin is sent (server-side callers)", async () => { + await seed("tk-nocors", ["anthropic"]); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-nocors" }, + body: "{}", + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); +}); From 4c928a3eeaa6d41672ff886c4ca19b14800d62af Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 23:33:01 +0530 Subject: [PATCH 04/22] feat: per-token RPM rate limiting (Workers Rate Limiting binding) - after token validation, limit() keyed on the SHA-256 hash; 429 + Retry-After: 60 on deny. Fail-open on a missing/erroring binding so it never bricks the proxy. - wrangler [[ratelimits]] RATE_LIMITER at 100 req / 60s (one shared ceiling, KISS; tune freely). Verified live on the Free plan: the binding deploys and limit() enforces. - per-colo + eventually-consistent: a loose ceiling that stops sustained abuse, not a strict gate (documented). - tests: deny -> 429, allow -> forward, throw -> fail-open --- src/proxy.ts | 15 +++++++++++ src/types.ts | 1 + test/proxy-handler.test.ts | 55 ++++++++++++++++++++++++++++++++++++++ wrangler.toml | 11 ++++++++ 4 files changed, 82 insertions(+) diff --git a/src/proxy.ts b/src/proxy.ts index 821e317..4a47554 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -164,6 +164,21 @@ async function proxyRequest( if (!meta.providers.includes(coarse(provider))) return errorResponse(403, "token not allowed for provider"); + // Per-token rate limit, keyed on the hash (in-process binding, not a subrequest). + // Fail-open: a missing or erroring limiter must never brick the proxy. The binding + // counts per-colo, so it is a loose ceiling, not strict abuse prevention. + let allowed = true; + try { + allowed = (await env.RATE_LIMITER.limit({ key: hash })).success; + } catch { + allowed = true; + } + if (!allowed) { + const res = errorResponse(429, "rate limit exceeded"); + res.headers.set("retry-after", "60"); + return res; + } + const realKey = realKeyFor(provider, env); rewriteToUpstream(url, provider, env); if (provider === "gemini" || provider === "gemini-openai") diff --git a/src/types.ts b/src/types.ts index 2eddf04..6671480 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface TokenMetadata { export interface Env { TOKENS: KVNamespace; US_EGRESS: DurableObjectNamespace; // North-America-pinned egress relay (see egress.ts) + RATE_LIMITER: RateLimit; // per-token RPM limiter (Workers Rate Limiting binding) OPENAI_API_KEY: string; ANTHROPIC_API_KEY: string; GEMINI_API_KEY: string; diff --git a/test/proxy-handler.test.ts b/test/proxy-handler.test.ts index 3600845..60ca4b7 100644 --- a/test/proxy-handler.test.ts +++ b/test/proxy-handler.test.ts @@ -387,3 +387,58 @@ describe("CORS", () => { expect(res.headers.get("access-control-allow-origin")).toBeNull(); }); }); + +describe("rate limiting", () => { + const real = (env as { RATE_LIMITER?: unknown }).RATE_LIMITER; + afterEach(() => { + (env as { RATE_LIMITER?: unknown }).RATE_LIMITER = real; + }); + const setLimiter = ( + limit: (o: { key: string }) => Promise<{ success: boolean }>, + ) => { + (env as { RATE_LIMITER: unknown }).RATE_LIMITER = { limit }; + }; + + it("429s with Retry-After when the limiter denies, without calling upstream", async () => { + await seed("tk-rl", ["anthropic"]); + setLimiter(async () => ({ success: false })); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-rl" }, + body: "{}", + }), + ); + expect(res.status).toBe(429); + expect(res.headers.get("retry-after")).toBe("60"); + expect(captured).toBeNull(); + }); + + it("forwards when the limiter allows", async () => { + await seed("tk-rl-ok", ["anthropic"]); + setLimiter(async () => ({ success: true })); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-rl-ok" }, + body: "{}", + }), + ); + expect(res.status).toBe(200); + }); + + it("fails open (forwards) when the limiter throws", async () => { + await seed("tk-rl-err", ["anthropic"]); + setLimiter(async () => { + throw new Error("limiter down"); + }); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-rl-err" }, + body: "{}", + }), + ); + expect(res.status).toBe(200); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index b953999..1e596a8 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -21,6 +21,17 @@ class_name = "UsEgress" tag = "v1" new_sqlite_classes = ["UsEgress"] +# Per-token request-rate limit (Workers Rate Limiting binding; in-process, not a subrequest). +# Keyed on the token hash; one shared ceiling for all tokens (KISS). Tune `limit` freely; +# `period` must be 10 or 60. namespace_id is an arbitrary string-integer, unique per ruleset. +[[ratelimits]] +name = "RATE_LIMITER" +namespace_id = "1001" + + [ratelimits.simple] + limit = 100 + period = 60 + # Secrets (set with `nubx wrangler secret put `), never committed: # OPENAI_API_KEY ANTHROPIC_API_KEY GEMINI_API_KEY ADMIN_SECRET # Optional upstream overrides (plain vars, NOT secrets) default to the real hosts: From bfe4d3a7f9a57830cb3683d45d315a7c4bf18ae4 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 23:39:58 +0530 Subject: [PATCH 05/22] chore: address handoff items - archive schedule.sh, dashboard auto-refresh, docs - move schedule.sh -> _legacy/ (archived helper; paths resolve from repo root, provider flags point at the archived _legacy/v1/ workers) - dashboard: poll the token list every 10s so new tokens / lastUsed surface despite KV list() eventual consistency (~60s) - README: document per-token controls (expiry, rate limit) + browser/CORS support, mark Gemini "untested with the actual API", point disable/enable at _legacy/ --- README.md | 20 ++++++++++++++------ schedule.sh => _legacy/schedule.sh | 8 ++++---- src/admin/views.ts | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) rename schedule.sh => _legacy/schedule.sh (83%) diff --git a/README.md b/README.md index 24b0452..a958b83 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ curl https:///v1/chat/completions \ -d '{"model":"gpt-5.4","messages":[{"role":"user","content":"Hello"}]}' ``` +Browser SDKs work too — the worker answers the CORS preflight and reflects the request Origin. Provider browser opt-ins still apply (e.g. Anthropic's `dangerouslyAllowBrowser`). + ## Setup ```bash @@ -55,7 +57,13 @@ Optional plain vars (NOT secrets) override the upstreams; they default to the re ## Admin dashboard -Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens: give each a label, the providers it may use (OpenAI / Anthropic / Gemini), and either type a token or generate one. The token is shown **once** at creation — copy it then; only its SHA-256 hash is stored. Disable or delete any token instantly. +Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens: give each a label, the providers it may use (OpenAI / Anthropic / Gemini), an optional expiry, and either type a token or generate one. The token is shown **once** at creation — copy it then; only its SHA-256 hash is stored. Disable or delete any token instantly. + +## Per-token controls + +- **Expiry** — optionally set an expiry at creation; past it the token is rejected and the dashboard shows it as `expired`. +- **Rate limit** — each token is capped at 100 requests / 60s (`429` + `Retry-After` over the limit). Tune `[[ratelimits]]` in `wrangler.toml`. It is a per-colo, loose ceiling for abuse protection, not a strict quota. +- **Scope & revoke** — a token only reaches the providers you check; disable or delete to revoke (KV propagation is up to ~60s). ## Security @@ -76,16 +84,16 @@ nub run test # both Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, then drives each real SDK and asserts the forwarded request carries the real key (and never the token). -> **Gemini is mock-tested only.** No test hits a live provider API — all three run against the mock upstream. OpenAI and Anthropic are additionally verified live in deployment; Gemini is **not**, because `GEMINI_API_KEY` isn't set yet. The Gemini route has never run against the real Google Generative Language API, so treat it as built-but-unproven until a key is added. +> **Gemini is untested with the actual API.** No test hits a live provider — all three run against a mock upstream. OpenAI and Anthropic are additionally verified live in deployment; Gemini is **not**, because `GEMINI_API_KEY` isn't set yet, so the Gemini route has never run against the real Google Generative Language API. Treat it as built-but-unproven until a key is added. ## Disable / Enable -`schedule.sh` toggles the worker's `workers_dev` URL without deleting it: +`_legacy/schedule.sh` (a kept-aside helper) toggles the worker's `workers_dev` URL without deleting it: ```bash -./schedule.sh disable # now -./schedule.sh disable +30m # in 30 minutes -./schedule.sh enable 22:00 # at 10pm +_legacy/schedule.sh disable # now +_legacy/schedule.sh disable +30m # in 30 minutes +_legacy/schedule.sh enable 22:00 # at 10pm ``` ## Cost diff --git a/schedule.sh b/_legacy/schedule.sh similarity index 83% rename from schedule.sh rename to _legacy/schedule.sh index 2a3cdd4..4c1bd90 100755 --- a/schedule.sh +++ b/_legacy/schedule.sh @@ -10,9 +10,9 @@ for arg in "$@"; do *) time_args+=("$arg") ;; esac done -# Default target is the single token-gated worker (wrangler.toml). The provider flags -# still target the legacy per-provider workers during the transition. -config="${label:+wrangler.${label}.toml}"; config="${config:-wrangler.toml}" +# Archived helper (kept aside in _legacy/). Default target is the active token-gated worker at the +# repo root (wrangler.toml); the provider flags target the archived v1 workers in _legacy/v1/. +config="${label:+_legacy/v1/wrangler.${label}.toml}"; config="${config:-wrangler.toml}" name="${label:-api-proxy}" time_arg="${time_args[*]:-}" @@ -21,7 +21,7 @@ time_arg="${time_args[*]:-}" } [[ "$action" == "enable" ]] && from=false to=true || from=true to=false -dir=$(cd "$(dirname "$0")" && pwd) +dir=$(cd "$(dirname "$0")/.." && pwd) # repo root (this script lives in _legacy/) if [[ -z "$time_arg" ]]; then sed -i '' "s/workers_dev = $from/workers_dev = $to/" "$dir/$config" diff --git a/src/admin/views.ts b/src/admin/views.ts index 7674631..f5a033c 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -175,7 +175,7 @@ export const dashboardPage = () => html`

Tokens

-
+
Loading…
From bdd0f02e975b5b7867a8444ec87e73e6427315cb Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 23:45:23 +0530 Subject: [PATCH 06/22] test: rename leftover DOPPEL fixtures to PROXY-TOKEN --- test/proxy.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/proxy.test.ts b/test/proxy.test.ts index 848ae83..0dc9ff6 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -103,9 +103,9 @@ describe("coarse", () => { describe("swapAuth", () => { it("sets bearer for openai and strips the other slots", () => { const h = new Headers({ - authorization: "Bearer DOPPEL", - "x-api-key": "DOPPEL", - "x-goog-api-key": "DOPPEL", + authorization: "Bearer PROXY-TOKEN", + "x-api-key": "PROXY-TOKEN", + "x-goog-api-key": "PROXY-TOKEN", }); swapAuth(h, "openai", "REALKEY"); expect(h.get("authorization")).toBe("Bearer REALKEY"); @@ -114,8 +114,8 @@ describe("swapAuth", () => { }); it("sets x-api-key for anthropic and strips the other slots", () => { const h = new Headers({ - authorization: "Bearer DOPPEL", - "x-api-key": "DOPPEL", + authorization: "Bearer PROXY-TOKEN", + "x-api-key": "PROXY-TOKEN", }); swapAuth(h, "anthropic", "REALKEY"); expect(h.get("x-api-key")).toBe("REALKEY"); @@ -123,20 +123,20 @@ describe("swapAuth", () => { expect(h.get("x-goog-api-key")).toBeNull(); }); it("sets x-goog-api-key for gemini", () => { - const h = new Headers({ "x-goog-api-key": "DOPPEL" }); + const h = new Headers({ "x-goog-api-key": "PROXY-TOKEN" }); swapAuth(h, "gemini", "REALKEY"); expect(h.get("x-goog-api-key")).toBe("REALKEY"); }); it("sets bearer for gemini-openai", () => { - const h = new Headers({ authorization: "Bearer DOPPEL" }); + const h = new Headers({ authorization: "Bearer PROXY-TOKEN" }); swapAuth(h, "gemini-openai", "REALKEY"); expect(h.get("authorization")).toBe("Bearer REALKEY"); }); it("never leaves the proxy token in any auth header", () => { const h = new Headers({ - "x-api-key": "DOPPEL", - authorization: "Bearer DOPPEL", - "x-goog-api-key": "DOPPEL", + "x-api-key": "PROXY-TOKEN", + authorization: "Bearer PROXY-TOKEN", + "x-goog-api-key": "PROXY-TOKEN", }); swapAuth(h, "anthropic", "REALKEY"); const all = [ @@ -144,7 +144,7 @@ describe("swapAuth", () => { h.get("x-api-key"), h.get("x-goog-api-key"), ].join("|"); - expect(all).not.toContain("DOPPEL"); + expect(all).not.toContain("PROXY-TOKEN"); }); }); From 1074a1c35aa01206d576998f2258525195fb547a Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 00:05:42 +0530 Subject: [PATCH 07/22] docs: add docs/architecture.md, capture v2.1 learnings, drop legacy from README - docs/architecture.md: the full current design (topology, request flow, routing, auth swap, token model, rate limiting, CORS, OpenAI egress DO, admin, testing, security) - replaces the superpowers design spec - learnings: rate-limit binding (free on Free plan, loose per-colo) and token expiry (check-at-validate, fail-closed) - README: link to docs/architecture.md; drop the legacy schedule.sh section --- README.md | 14 +- docs/architecture.md | 287 ++++++++++++++++++ docs/learnings/README.md | 2 + .../rate-limit-binding-free-and-loose.md | 31 ++ .../token-expiry-check-at-validate.md | 30 ++ .../2026-06-22-api-proxy-tokens-design.md | 221 -------------- 6 files changed, 353 insertions(+), 232 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/learnings/rate-limit-binding-free-and-loose.md create mode 100644 docs/learnings/token-expiry-check-at-validate.md delete mode 100644 docs/superpowers/specs/2026-06-22-api-proxy-tokens-design.md diff --git a/README.md b/README.md index a958b83..67187a8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ The proxy token rides in the SDK's normal auth slot. The worker reads it, valida | `x-goog-api-key` / `?key=` | Gemini | `generativelanguage.googleapis.com` | `x-goog-api-key` | | `Authorization: Bearer` + path `/v1beta/openai/*` | Gemini (OpenAI-compat) | `generativelanguage.googleapis.com` | `Authorization: Bearer` | +For the full design — request flow, token model, rate limiting, the OpenAI egress fix, and the admin dashboard — see [docs/architecture.md](docs/architecture.md). + ## Client setup Point the SDK's base URL at the worker and use a proxy token as the key: @@ -86,20 +88,10 @@ Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `n > **Gemini is untested with the actual API.** No test hits a live provider — all three run against a mock upstream. OpenAI and Anthropic are additionally verified live in deployment; Gemini is **not**, because `GEMINI_API_KEY` isn't set yet, so the Gemini route has never run against the real Google Generative Language API. Treat it as built-but-unproven until a key is added. -## Disable / Enable - -`_legacy/schedule.sh` (a kept-aside helper) toggles the worker's `workers_dev` URL without deleting it: - -```bash -_legacy/schedule.sh disable # now -_legacy/schedule.sh disable +30m # in 30 minutes -_legacy/schedule.sh enable 22:00 # at 10pm -``` - ## Cost Cloudflare Workers free tier covers this (100k requests/day). You only pay upstream providers for API usage. ## Contributing -Issues are welcome. PRs are not accepted and will be auto-closed. +Issues are welcome. External PRs are not accepted and will be auto-closed. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5a8206a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,287 @@ +# api-proxy — Architecture + +A single Cloudflare Worker (Free plan) that reverse-proxies **OpenAI, Anthropic, and Google +Gemini** behind shareable, revocable **proxy tokens**. A client changes only its base URL and API +key; the worker validates the token, swaps in the real provider key server-side, and forwards the +request verbatim. The real key never leaves Cloudflare. + +This document is the current design. Topic deep-dives with the "why" live in +[`docs/learnings/`](learnings/); the retired one-worker-per-provider v1 lives in +[`_legacy/v1/`](../_legacy/v1/). + +--- + +## 1. Problem + +v1 was three unauthenticated workers (one per provider), each injecting a shared real key for +**anyone** who knew the URL — no per-user access, no revocation. v2 collapses them into one +token-gated worker. + +## 2. Topology + +One worker, dispatched by path (`src/index.ts`): + +``` + ┌──────────────────────── api-proxy worker ────────────────────────┐ +request ──▶ │ /admin/* ──▶ Hono admin sub-app (wrapped in try/catch → 500) │ + │ else ──▶ handleProxy() (framework-free hot path) │ + └───────────────────────────────────────────────────────────────────┘ +``` + +The admin sub-app is isolated in a `try/catch` so an admin bug can never crash the proxy branch. +The proxy hot path imports **no framework** — it is pure functions plus a `fetch` handler +(`src/proxy.ts` must never import Hono). + +## 3. Request flow (proxy hot path) + +`handleProxy` is a thin wrapper; `proxyRequest` does the work: + +``` +handleProxy(req): + OPTIONS? ──▶ corsPreflight() → 204 (no auth, no upstream) §8 + else ──▶ withCors( proxyRequest(req) ) §8 + +proxyRequest(req): + 1. token = extractToken(req) auth slot → token §4 + 2. provider = routeProvider(req) auth slot (+path) → provider §4 + no token / no provider ───────────────────────────▶ 401 + 3. hash = SHA-256(token) + 4. meta = getValidatedByHash(hash) active? not expired? §6 + miss / disabled / expired ─────────────────────────▶ 401 + 5. coarse(provider) ∉ meta.providers ──────────────────▶ 403 + 6. RATE_LIMITER.limit({ key: hash }) per-token, fail-open §7 + denied ────────────────────────────────────────────▶ 429 + Retry-After + 7. rewriteToUpstream(url) protocol/host/port only §12 + gemini* → strip ?key= + 8. swapAuth(headers) strip ALL auth, set ONE key §5 + 9. fetch(upstream) (OpenAI: geo-403 fallback) §9 + 10. ctx.waitUntil(touchLastUsed(hash)) fire-and-forget §6 + 11. return Response(upstream.body, upstream) stream straight through +``` + +Responses stream through unbuffered, preserving SSE. + +## 4. Provider routing (by auth header) + +The client adds no path prefix and no custom header — routing reads **which auth slot the SDK +populated** (`routeProvider`, `extractToken`): + +| Inbound signal | Provider | Upstream | +|---|---|---| +| `x-api-key` | `anthropic` | api.anthropic.com | +| `x-goog-api-key` or `?key=` | `gemini` | generativelanguage.googleapis.com | +| `Authorization: Bearer` + path `/v1beta/openai/*` | `gemini-openai` | generativelanguage.googleapis.com | +| `Authorization: Bearer` (else) | `openai` | api.openai.com | +| none | — | 401 | + +`gemini-openai` (the OpenAI-compatible Gemini endpoint) collapses to the `gemini` scope via +`coarse()`; the distinction only selects the auth-swap branch. **Why no `/openai` `/anthropic` +path prefix:** it would break Gemini's file-upload flow (absolute `x-goog-upload-url` round trip) +and force every client to rewrite the SDK's own base path. See +[`provider-routing-by-auth-header.md`](learnings/provider-routing-by-auth-header.md). + +## 5. Auth swap (security linchpin) + +Before forwarding, `swapAuth` deletes **every** inbound auth header and sets exactly one with the +real key: + +```ts +headers.delete("x-api-key"); headers.delete("x-goog-api-key"); headers.delete("authorization"); +switch (provider) { + case "openai": case "gemini-openai": headers.set("authorization", `Bearer ${realKey}`); break; + case "anthropic": headers.set("x-api-key", realKey); break; + case "gemini": headers.set("x-goog-api-key", realKey); break; +} +``` + +Strip-all-then-set-one guarantees the proxy token is never forwarded upstream even if a client sends +it in an unexpected slot, and closes dual-header leaks. A test asserts the token never appears in any +outbound auth header. See [`proxy-token-security.md`](learnings/proxy-token-security.md). + +## 6. Token model & lifecycle + +KV namespace `TOKENS`, keyed by `SHA-256(token)` (hex). The plaintext is shown **once** at creation +and never persisted (`src/tokens.ts`, `src/types.ts`): + +```ts +type TokenMetadata = { + label: string; + last4: string; // for display + providers: ("openai"|"anthropic"|"gemini")[]; // coarse scope + status: "active" | "disabled"; + createdAt: string; // ISO + expiresAt?: string; // ISO (UTC); absent = never expires +}; +``` + +- **Tokens** are opaque: `ptk_` + 32 url-safe chars (24 random bytes). Custom admin-typed tokens are + allowed; validation is by hash of the full string. +- **Validation** (`getValidatedByHash`): returns the record only if `status === "active"` AND, when + `expiresAt` is set, it parses to a future timestamp — malformed or past expiry is rejected + **fail-closed**. Not KV `expirationTtl` (60s floor, silently deletes the record, orphans the `:lu` + key) — see + [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). +- **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget on each proxied + request. Keeping it out of the token record means stamping it can never resurrect or re-enable a + token the admin just disabled or deleted. +- **Lifecycle:** `createToken`, `listTokens` (paginates KV, skips `:lu` keys), `updateToken` + (label / providers / status), `deleteToken` (record + `:lu`). KV is eventually consistent + (~60s), so revoke and new-token visibility can lag. + +## 7. Per-token rate limiting + +After validation, `RATE_LIMITER.limit({ key: hash })` (the Workers Rate Limiting binding) caps each +token. Over the limit → `429` + `Retry-After: 60`. Wrapped in try/catch and **fail-open**: a missing +or erroring binding must never brick the proxy. + +```toml +[[ratelimits]] +name = "RATE_LIMITER" +namespace_id = "1001" + [ratelimits.simple] + limit = 100 # one shared ceiling for all tokens; tune freely + period = 60 # must be 10 or 60 +``` + +It is in-process (not a subrequest), keyed on the hash, and **per-colo + eventually consistent** — a +loose ceiling for abuse protection, not a strict quota. Verified to run on the Free plan. See +[`rate-limit-binding-free-and-loose.md`](learnings/rate-limit-binding-free-and-loose.md). + +## 8. CORS & browser support + +`handleProxy` short-circuits `OPTIONS` to a `204` preflight **before** the token checks (a preflight +carries no auth header, so it would otherwise 401 and block every browser SDK). The preflight +reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, and sets +`Access-Control-Max-Age: 86400`. Every real response is then passed through `withCors`, which +reflects `Origin` and exposes the Gemini resumable-upload headers +(`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). No `Origin` → no CORS +headers (server-side callers are unaffected). Credentials mode is never enabled (SDKs send keys as +headers, not cookies). Provider browser opt-ins still apply (e.g. Anthropic's +`dangerouslyAllowBrowser`, which the SDK forwards as a header). + +**Gemini file uploads** pass through verbatim: the start call routes normally, Google returns an +absolute, self-authenticating `x-goog-upload-url`, and the client uploads bytes **directly to +Google** — that leg never transits the worker, so the 100 MB body cap is sidestepped and the real +key is never on it. + +## 9. OpenAI geo-403 egress (North-America-pinned Durable Object) + +OpenAI 403s `unsupported_country_region_territory` when a request egresses from an unsupported colo +(e.g. Hong Kong). A Worker's `fetch()` egresses from the colo the invocation runs in, fixed per +invocation, so an in-invocation retry can't escape a bad colo. Fix: try the fast edge fetch first, +and **only on the geo-403** re-issue through a SQLite Durable Object (`UsEgress`) pinned to North +America with `locationHint: "wnam"`; its `fetch()` then egresses from a supported region. + +``` +OpenAI request → direct edge fetch ─ 200 ─▶ return (fast path, ~60%) + │ geo-403 + ▼ + US-pinned DO (wnam) ─▶ egress US ─▶ OpenAI 200 ─▶ return +``` + +Only the OpenAI branch buffers the body (so it can be replayed to the DO); a pool of DO ids spreads +load. Anthropic and Gemini are untouched, and the real key never leaves Cloudflare. See +[`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). + +## 10. Admin dashboard + +Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered JSX-ish HTML via `hono/html` +plus **HTMX 2.x** loaded from a CDN (zero client JS we author; nothing in the worker bundle but +markup + attributes). + +- **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` sets an HMAC-SHA256-signed cookie + `cm_admin=.` (`HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards + every `/admin/*` route except login; signature check uses constant-time `crypto.subtle.verify`. +- **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; parses label, + provider checkboxes, optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated + token), edit/enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. +- **UI:** add-token card (label, token, **Expires (optional)**, provider checkboxes) and a token + table (label, last-4, provider pills, status, **Expires**, last-used, disable/delete). The created + plaintext is shown once. Expired tokens render `expired` and dim the row. The token list refreshes + on load, on the `tokens-changed` event, and **every 10 s** (a poll to surface new tokens / + last-used despite KV's ~60 s list propagation). + +## 11. Real key handling + +`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `ADMIN_SECRET` are Cloudflare **secrets**, +read at request time and injected only into the outbound request. Never logged, never stored in KV, +never returned in a response body. The CORS and rate-limit paths never touch a real key. + +## 12. Storage, bindings & config (`wrangler.toml`) + +| Binding | Kind | Purpose | +|---|---|---| +| `TOKENS` | KV namespace | token store (by `SHA-256`) + `:lu` last-used keys | +| `US_EGRESS` | SQLite Durable Object (`UsEgress`) | NA-pinned egress fallback for OpenAI | +| `RATE_LIMITER` | Rate Limit | per-token RPM ceiling | + +Plus `[[migrations]] tag="v1" new_sqlite_classes=["UsEgress"]`. Upstreams resolve through +`upstreamBase()`: `*_UPSTREAM` env vars (plain vars, not secrets) default to the real hosts and are +overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites just protocol/host/port. + +## 13. Testing (two tiers) + +| Tier | Runner | Scope | +|---|---|---| +| 1 — proxy logic | `@cloudflare/vitest-pool-workers` (workerd) | routing, auth swap, expiry, CORS, rate limit, geo-403 fallback, SSE passthrough; mocks `fetch`, seeds KV directly | +| 2 — real SDKs | `unstable_dev` worker + `node:http` mock upstream | the official `openai`, `@anthropic-ai/sdk`, `@google/genai` SDKs end-to-end | + +Tier 2 covers all four routing modes — OpenAI (Bearer), Anthropic (`x-api-key`), Gemini native +(`x-goog-api-key`), and Gemini OpenAI-compat (the OpenAI SDK at `/v1beta/openai`) — plus streaming +for the first three; OpenAI-compat streaming rides the same SSE passthrough, so it has no dedicated +test. Each asserts the real key reaches the mock and the token never does. Routing is by auth header +alone, so any standard-auth SDK behaves identically — these four are representative. **No test hits a +live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but +**Gemini has never run against the real Google API** (no key yet). + +## 14. Deployment + +```bash +nub install +nubx wrangler kv namespace create api-proxy-tokens # paste id into wrangler.toml +nubx wrangler secret put OPENAI_API_KEY # + ANTHROPIC / GEMINI / ADMIN_SECRET +nubx wrangler deploy +``` + +Free Workers plan covers it (100k req/day); you only pay upstream providers for usage. + +## 15. Security model + +Invariants, detailed above: real keys are secrets injected only outbound (§11); tokens stored as +`SHA-256` (§6); strip-all-then-set-one auth swap (§5); revoke-safe `lastUsed` (§6); admin behind a +constant-time HMAC cookie, isolated from the proxy branch (§10). + +Caveats: + +- KV is ~60s eventually consistent, so a revoke / expiry-flip is not instant — for an immediate + cutoff, rotate the provider secret (instant, and the key stays in Cloudflare). +- Do not host on `*.openai.azure.com` / `*.cognitiveservices.azure.com` (the OpenAI SDK switches to + Azure auth on those hostnames). + +## 16. Deferred / future + +The token data model leaves room (`limits`, `spend`) without carrying the weight now: +spend / token-count caps + per-token usage analytics (needs a metering Durable Object and SSE usage +parsing), multiple real keys per provider (key pools), concurrency limits and longer rate-limit +windows, and instant (sub-minute) revocation via a DO allow/deny list. + +## 17. Repository layout + +``` +src/ + index.ts # fetch entry + /admin vs proxy dispatch + proxy.ts # hot path: extractToken, routeProvider, swapAuth, CORS, rate limit, geo-403 egress + tokens.ts # KV token store: hashing, create/list/update/delete, expiry, lastUsed + types.ts # Env + TokenMetadata + Provider types + upstreams.ts # *_UPSTREAM resolver (test seam) + rewriteToUpstream + egress.ts # UsEgress Durable Object (NA-pinned egress relay) + admin/ + index.ts # Hono app: cookie auth + token CRUD routes + views.ts # HTML + HTMX views (dashboard, login, token table) +test/ + *.test.ts # tier-1 (workerd) + sdk-compat/ # tier-2 (real SDKs vs mock upstream) +docs/learnings/ # topic deep-dives (the "why") +_legacy/ # retired v1 per-provider proxies + schedule.sh helper +wrangler.toml # bindings: TOKENS (KV), US_EGRESS (DO), RATE_LIMITER (rate limit) +``` diff --git a/docs/learnings/README.md b/docs/learnings/README.md index cb8c0a6..d2f3f41 100644 --- a/docs/learnings/README.md +++ b/docs/learnings/README.md @@ -11,3 +11,5 @@ Each file: the problem, what we found, and the decision we keep. - [openai-egress-geo-block.md](openai-egress-geo-block.md) - why OpenAI 403'd ~40% of the time, and the North-America-pinned Durable Object that fixes it - [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md) - one base URL, no path prefix; route by which auth slot the SDK used - [proxy-token-security.md](proxy-token-security.md) - how a shareable token rides the SDK's auth slot without ever leaking the real key +- [token-expiry-check-at-validate.md](token-expiry-check-at-validate.md) - why expiry is checked at read time, not via KV TTL, and fail-closed on bad input +- [rate-limit-binding-free-and-loose.md](rate-limit-binding-free-and-loose.md) - the Workers Rate Limiting binding is free on the Free plan but a loose, per-colo ceiling diff --git a/docs/learnings/rate-limit-binding-free-and-loose.md b/docs/learnings/rate-limit-binding-free-and-loose.md new file mode 100644 index 0000000..9076041 --- /dev/null +++ b/docs/learnings/rate-limit-binding-free-and-loose.md @@ -0,0 +1,31 @@ +# Rate Limiting binding: free on Workers Free, but a loose per-colo ceiling + +## What we needed + +A per-token request-rate cap that costs nothing, needs no new storage, and never lets the real key +leave Cloudflare. + +## What we found + +- The Workers **Rate Limiting binding** (`[[ratelimits]]` in `wrangler.toml` + + `env.RATE_LIMITER.limit({ key })`) is an **in-process call, not a subrequest** - no subrequest + budget hit, no storage, microseconds of CPU. +- **Free-plan eligibility is undocumented** (a research pass even fabricated a "no additional charge" + quote). Verified empirically: `wrangler deploy` on the Free account accepts the binding (the summary + lists `env.RATE_LIMITER (N requests/60s) — Rate Limit`) and `limit()` enforces — treat undocumented + platform claims as "verify by deploying," not fact. +- It is **per-colo and eventually consistent**. With `limit = 2 / 60s`, ~13 rapid requests slipped + through before denials began, and a client spread across two colos can get up to ~2× the limit. + Cloudflare describes it as a "loose filter, not suited for strict abuse prevention." +- `period` must be exactly **10 or 60**. The limit is fixed per namespace at deploy time - no + per-token-variable limit without tiered namespaces or a Durable Object counter. + +## Decisions we keep + +- One shared per-token ceiling (KISS), keyed on the token's **SHA-256 hash** (never the plaintext). +- **Fail-open:** wrap `limit()` in try/catch and allow on any error - a missing or flaky limiter must + never brick the proxy. The real abuse defense is revoke + scope, not this loose ceiling. +- Return `429` + a static `Retry-After: 60` (the binding returns no reset time; the static value + matches `period`). + +Related: [[proxy-token-security]]. diff --git a/docs/learnings/token-expiry-check-at-validate.md b/docs/learnings/token-expiry-check-at-validate.md new file mode 100644 index 0000000..4710959 --- /dev/null +++ b/docs/learnings/token-expiry-check-at-validate.md @@ -0,0 +1,30 @@ +# Token expiry: check-at-validate, not KV TTL + +## Problem + +Optional per-token expiry, enforced cheaply, without a second storage backend. + +## What we found + +- **KV `expirationTtl` is the wrong tool:** 60s floor, it *deletes* the record on expiry (so the + dashboard can't show an "expired" row), and it orphans the separate `:lu` last-used key. +- `expiresAt` is set once and never mutates, so a check at read time has no consistency window - it + is exact and adds zero extra reads (the field rides in the JSON already fetched to validate). + +## Decision we keep + +Store optional `expiresAt` (UTC ISO); enforce in `getValidatedByHash`: + +```ts +if (meta.expiresAt) { + const t = Date.parse(meta.expiresAt); + if (Number.isNaN(t) || t <= Date.now()) return null; // fail-closed +} +``` + +**Fail-closed on malformed input:** `NaN <= Date.now()` is `false`, which fails *open* (a garbage +`expiresAt` stays valid) - so `Number.isNaN(t)` is checked explicitly. The admin form converts its +local `datetime-local` value to UTC ISO and rejects unparseable input at creation. Instant cutoff +for a leak is still provider-key rotation, not expiry (KV revoke lags ~60s). + +Related: [[proxy-token-security]]. diff --git a/docs/superpowers/specs/2026-06-22-api-proxy-tokens-design.md b/docs/superpowers/specs/2026-06-22-api-proxy-tokens-design.md deleted file mode 100644 index 616a725..0000000 --- a/docs/superpowers/specs/2026-06-22-api-proxy-tokens-design.md +++ /dev/null @@ -1,221 +0,0 @@ -# api-proxy — Proxy Tokens Design - -- **Date:** 2026-06-22 -- **Status:** Approved; implementation in progress -- **Scope:** Replace the three transparent reverse-proxy workers with one token-gated worker plus an embedded admin dashboard. Issue shareable, revocable proxy API-key tokens that map to real provider keys server-side. - -> Naming: there is no "v1/v2" product split. This is the `api-proxy` project evolving. The new token-gated worker deploys under its own worker name so it can run alongside the existing transparent proxies during validation; the old `src/{claude,openai,gemini}.ts` files and their tomls are deleted once the new worker is reliable. - ---- - -## 1. Problem - -The current proxy is three minimal workers (`src/openai.ts`, `src/claude.ts`, `src/gemini.ts`). Each host-rewrites the request to one upstream and injects a single shared real key. The worker URLs are **unauthenticated** — anyone with a URL uses the owner's real key, with no per-user access control and no revocation. - -We want: hand someone a token they plug into their normal SDK (changing only the base URL + the key), have it work, and be able to revoke or scope that token at any time from a dashboard — all without exposing the real provider keys. - -## 2. Goals - -- A consumer uses their existing SDK by changing **two things**: the base URL (point at the worker) and the API key (use a proxy token). -- The owner mints, scopes (per provider), disables, and deletes tokens from an admin dashboard. -- Real provider keys never leave the worker and never live in KV. -- Support OpenAI, Anthropic, and Google Gemini, including streaming, for server-side SDK usage. - -## 3. Non-goals (deferred — see §11) - -Rate limits, spend/token caps, expiry dates, per-token usage analytics, browser/CORS support, Gemini file uploads, multiple real keys per provider, instant (sub-minute) revocation. - -## 4. Locked decisions - -| Decision | Choice | Why | -|---|---|---| -| Mechanism | Proxy token = the API key the SDK already sends. Worker reads it from the auth header, validates against KV, swaps in the real key. | Unifies "shareable key" and "common-ground SDK config"; client changes only base URL + key. | -| Topology | One worker, one base URL, **no provider path prefix**. Provider routing by which auth header the token arrives in (+ path for Gemini OpenAI-compat). | A `/openai` `/anthropic` `/gemini` prefix breaks Gemini native file uploads. The auth header already identifies the provider. | -| Architecture | **Single worker, embedded.** Top-level dispatch: `/admin/*` → Hono admin sub-app (wrapped in try/catch); everything else → framework-free proxy hot-path. | Proxy requests pay zero routing/SSR cost. Avoids two deploys / two secret sets / broken `schedule.sh`. Escape hatch: move `src/admin/*` to a second worker on the same KV namespace if it ever outgrows CRUD. | -| Token storage | Store **SHA-256(token)** in KV; show plaintext once at creation; dashboard shows label + last-4. | Foundational, hard to retrofit. A KV/dashboard dump yields unusable hashes. Standard practice. | -| Real keys | One real key per provider, env **secret**, shared by all tokens for that provider. | Token is an access/revocation handle, not a routing key to different accounts. | -| Admin stack | **Hono + JSX fragments + HTMX 2.x** (HTMX loaded from CDN, browser-side only — zero bytes in the Worker bundle). Pin HTMX 2.x (4.0 is alpha; do not adopt). | Concise, embeds as a one-line sub-app, ~14KB. Heavy frameworks (SvelteKit/Astro/React Router) own the entrypoint, force a 2nd worker, and add 50-500KB+. | -| Test runner | **Vitest `^4.1.0`** + `@cloudflare/vitest-pool-workers` (0.16.x). nub has no built-in runner; `nub run test` invokes vitest. | Cloudflare-supported path; runs inside workerd. | -| Package manager | **nub** (`nubjs.com`); lockfile `lock.yaml` (pnpm v9 format). | Project standard. | - -## 5. Architecture & module layout - -One worker, dispatched by path: - -``` -fetch(req, env, ctx): - if pathname startsWith "/admin": try { adminApp.fetch(req, env, ctx) } catch { 500 } - else: proxyHandler(req, env, ctx) -``` - -- **State:** one KV namespace, `TOKENS`. Key = `SHA-256(token)` (hex). Value = `TokenMetadata` (§7). -- **Secrets** (only these four): `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `ADMIN_SECRET`. Never in KV, never returned to callers. -- **Plain vars (NOT secrets):** `OPENAI_UPSTREAM`, `ANTHROPIC_UPSTREAM`, `GEMINI_UPSTREAM` — default to the real hosts; overridable for tests (§6, §Testing). - -``` -src/ - index.ts # fetch entry + dispatch - proxy.ts # ZERO framework deps: extractToken, routeProvider, swapAuth, stream passthrough. MUST NOT import Hono. - tokens.ts # KV helpers: sha256hex, generateToken (ptk_ + 32 base64url), create, list, getValidated, update, delete, touchLastUsed - upstreams.ts # UPSTREAM resolver: reads *_UPSTREAM env with real-host defaults; parses protocol+hostname+port (the test seam) - types.ts # shared TokenMetadata, Provider types (imported by proxy + admin) - admin/ - index.ts # new Hono(); mounts routes; HMAC cookie auth middleware - routes.tsx # GET/POST/PUT/DELETE /admin/api/tokens (HTML fragments); GET /admin; /admin/login, /admin/logout - layout.tsx # shell; loads HTMX 2.x from CDN; minimal inline CSS (no Tailwind JIT) - components.tsx # , , , -test/ - proxy.test.ts # TIER 1 (vitest-pool-workers) - sdk-compat/ - setup.ts # TIER 2 harness: mock upstream (node:http) + unstable_startWorker + capture helpers - openai.test.ts - anthropic.test.ts - gemini.test.ts -vitest.config.ts # cloudflare pool (tier 1) -vitest.compat.config.ts # node pool, --pool=forks (tier 2) -wrangler.toml # ONE config: name='api-proxy', [[kv_namespaces]] binding='TOKENS' -schedule.sh # kept -``` - -Deleted once the new worker is verified reliable: `src/openai.ts`, `src/claude.ts`, `src/gemini.ts`, `wrangler.openai.toml`, `wrangler.claude.toml`, `wrangler.gemini.toml`. - -## 6. Request flow (proxy path) - -``` -1. extractToken(req, url) - x-api-key || x-goog-api-key || Authorization: "Bearer X" -> X || ?key= - none -> 401 "missing token" -2. provider = routeProvider(req, url) // §6.1 -3. rec = getValidated(KV, sha256hex(token)) - miss || status != "active" -> 401 "invalid or revoked token" -4. coarse(provider) not in rec.providers -> 403 "token not allowed for provider" -5. swapAuth: delete x-api-key, x-goog-api-key, authorization; set the ONE real key -6. resolve upstream: u = parse(UPSTREAM[provider]); url.protocol=u.protocol; url.hostname=u.hostname; url.port=u.port - if provider startsWith "gemini": url.searchParams.delete("key") -7. fetch(new Request(url, { method, headers, body })) // path + query verbatim -8. return new Response(upstream.body, upstream) // stream straight through, no buffering -9. ctx.waitUntil(touchLastUsed(KV, hash)) // fire-and-forget -``` - -### 6.1 Provider routing table - -| Token arrives in | + path signal | Provider | Upstream (default, overridable) | Real key set as | -|---|---|---|---|---| -| `x-api-key` | — | `anthropic` | `ANTHROPIC_UPSTREAM` = `api.anthropic.com` | `x-api-key` | -| `x-goog-api-key` or `?key=` | — | `gemini` | `GEMINI_UPSTREAM` = `generativelanguage.googleapis.com` | `x-goog-api-key` | -| `Authorization: Bearer` | path starts `/v1beta/openai/` | `gemini-openai` | `GEMINI_UPSTREAM` | `Authorization: Bearer` | -| `Authorization: Bearer` | else | `openai` | `OPENAI_UPSTREAM` = `api.openai.com` | `Authorization: Bearer` | - -`routeProvider` returns one of `openai | anthropic | gemini | gemini-openai`. The provider-scope check (step 4) and the token's `providers` array use the coarse set `openai | anthropic | gemini`, so `gemini-openai` maps to the `gemini` scope. The `gemini` vs `gemini-openai` distinction only selects the swap branch in §6.2. - -### 6.2 Auth swap (security linchpin) - -Always **strip all three** inbound auth headers, then set exactly one: - -```ts -headers.delete("x-api-key"); -headers.delete("x-goog-api-key"); -headers.delete("authorization"); -switch (provider) { - case "openai": headers.set("authorization", `Bearer ${realKey}`); break; - case "anthropic": headers.set("x-api-key", realKey); break; - case "gemini": headers.set("x-goog-api-key", realKey); break; - case "gemini-openai": headers.set("authorization", `Bearer ${realKey}`); break; -} -``` - -Stripping-all-then-setting-one prevents the proxy token leaking upstream and closes the Anthropic dual-header leak and the duplicate-`x-goog-api-key` 401. - -## 7. Token model & lifecycle - -KV: `SHA-256(token)` (hex) → - -```ts -type TokenMetadata = { - label: string; - last4: string; - providers: ("openai" | "anthropic" | "gemini")[]; - status: "active" | "disabled"; - createdAt: string; // ISO - lastUsed?: string; // ISO - // reserved for Later (absent in v1): expiresAt, limits, spend -}; -``` - -- **Create:** admin supplies label + provider scopes, types a token or clicks generate (`ptk_` + 32 random base64url from `crypto.getRandomValues`). Worker stores `sha256hex(token) -> metadata`, returns the **plaintext once**. Never retrievable again. -- **List/Update/Delete:** by hash. Update edits label, providers, status (`active` ⇄ `disabled`). -- **last-used:** fire-and-forget KV write on each successful proxied request. - -> Revocation latency: KV is eventually consistent (~up to 60s). Acceptable for v1; instant revocation via Durable Object is a Later item. - -## 8. Admin dashboard - -Embedded **Hono** sub-app, server-rendered **JSX fragments + HTMX 2.x** (HTMX from CDN; no client JS we write; no UI/charting libs in the bundle). - -- **Auth:** single `ADMIN_SECRET` password → `POST /admin/login` sets an HMAC-SHA256-signed cookie `cm_admin=.`, `HttpOnly; SameSite=Strict; Max-Age=86400`. Middleware guards all `/admin/*` except login. -- **Routes:** HTMX-driven CRUD — `hx-get/post/put/delete` on `/admin/api/tokens`, `hx-swap="outerHTML"` on rows for create/edit/delete/enable-disable. `GET /admin` dashboard, `GET /admin/logout`. -- **UI:** add-token card (token field + generate, label, provider checkboxes), token table (label, last-4, provider pills, created, last-used, edit/delete). Plaintext token shown once after creation. -- **Blast-radius:** the dispatcher wraps `adminApp.fetch` in try/catch returning a plain 500, so an admin bug can never crash the proxy branch. - -## 9. Real key handling - -`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` are Cloudflare secrets resolved at request time and injected only into the outbound request. Never logged, never in KV, never in a response body. - -## 10. Gotchas handled in v1 - -1. **SSE streaming** — pass `upstream.body` straight through; never `await response.text()`. Preserve upstream `cache-control: no-transform`; no response-buffering features; ensure the zone isn't re-compressing `text/event-stream`. -2. **Verbatim path + query forwarding** — only protocol/host/port are rewritten; path and query (incl. Gemini `?alt=sse`) stay intact. -3. **Strip-all-then-set-one auth** (§6.2). -4. **Gemini `?key=` hygiene** — keep `url.searchParams.delete("key")` for raw-REST/curl callers. -5. **Hostname** — do not issue worker hostnames ending in `.openai.azure.com`, `.services.ai.azure.com`, `.cognitiveservices.azure.com`. - -## 11. Deferred (Later) - -KV value shape + module boundaries leave room; none built now: rate limits, spend/token caps (parse `usage`; OpenAI needs `stream_options.include_usage`), expiry, per-token analytics, browser/CORS preflight, Gemini file uploads (forward `x-goog-upload-url`), multiple real keys per provider, instant revocation + atomic counters via Durable Object. - -## 12. Testing (two-tier harness) - -Do not test proxy logic and real-SDK HTTP behavior with one tool — conflating them is the main flakiness source. - -**Tier 1 — proxy logic (always-on CI gate, ~1s):** `@cloudflare/vitest-pool-workers` inside workerd (`vitest.config.ts`). -- Seed KV directly: `env.TOKENS.put(sha256hex(token), JSON.stringify(meta))`. -- Capture the outbound call with `vi.spyOn(globalThis, "fetch")`; assert: (a) right upstream host, (b) real key swapped in AND proxy token absent (`.not.toContain(token)` on all three header slots), (c) path+query verbatim, (d) 401 on missing/invalid/revoked, 403 on provider-scope mismatch. -- SSE: mocked fetch returns a `ReadableStream` `text/event-stream`; drive via `createExecutionContext()`/`waitOnExecutionContext()`; read `response.body.getReader()` chunk-by-chunk; assert content-type preserved and chunks un-buffered. Tier 1 does not use the upstream env seam (it mocks fetch entirely). - -**Tier 2 — real-SDK compatibility (feature-branch + pre-deploy, ~10-20s):** `vitest.compat.config.ts`, Node pool, `--pool=forks`, serial. -- `wrangler unstable_startWorker` starts a real HTTP listener (not the deprecated `unstable_dev`). -- A `node:http` mock upstream captures the raw inbound request; point the worker's `*_UPSTREAM` env at it (the seam earns its keep). -- Seed the token via the worker's own `POST /admin/api/tokens` (also exercises create). -- Run the real `openai`, `@anthropic-ai/sdk`, `@google/genai` packages with `baseURL` = local worker and `apiKey` = proxy token. Assert on the captured request: real key present, proxy token absent, exact path the SDK constructed (catches `:generateContent`, `/v1beta/openai`). -- SSE: mock writes `text/event-stream` chunks; consume via the SDK's own stream iterator; assert on connection-start headers, not the buffered body (avoids mid-stream-disconnect races). - -**Flakiness guards:** Tier 2 serial (shared mutable capture state + port); never `await body.text()/json()` in the proxy path; use the SDK's stream iterator for completion, not `setTimeout`. - -## 13. Dev commands (nub) & rollout - -``` -nub install # install all deps (CI: nub ci) -nub add hono # runtime dep -nub add -E -D vitest@^4.1.0 @cloudflare/vitest-pool-workers @cloudflare/workers-types -nub add -E -D openai @anthropic-ai/sdk @google/genai # tier-2 SDKs -nub run dev # "dev": wrangler dev (or: nubx wrangler dev) -nub run test:unit # vitest run --config vitest.config.ts -nub run test:compat # vitest run --config vitest.compat.config.ts --pool=forks -nub run test # test:unit && test:compat -``` - -Rollout: (1) build under `name = "api-proxy"` (distinct from the old `*-proxy` workers); (2) create KV namespace, set the four secrets, mint a test token, verify each SDK end-to-end incl. streaming; (3) once reliable, delete the three old `src/*.ts` + tomls. **README is stale** (shows `bun`/`bunx`) — update to nub + `lock.yaml`. - -## 14. Bloat-watch (single-worker discipline) - -The single-worker choice is only safe with discipline: -- `proxy.ts` MUST NOT import Hono or admin code — the hot-path stays framework-free pure functions. -- No npm UI/component libraries, charting libs, or Tailwind JIT output in the admin — server-rendered HTML + HTMX attributes + minimal inline CSS only (these bundle into the same worker and inflate cold-start). -- Keep the upstream seam to exactly three vars with real-host defaults; do not generalize into a routing/rewrite config. -- Pin HTMX 2.x; pin vitest `^4.1.0` with current `@cloudflare/vitest-pool-workers` 0.16.x. - -## 15. Open items to verify during implementation - -- Exact base-URL strings per SDK (esp. Vercel AI SDK Anthropic, whose provider default includes `/v1`) — confirmed at test time; the worker forwards verbatim so it's robust either way. -- Gemini OpenAI-compat coverage (`/v1beta/openai/chat/completions`) before documenting it as the recommended Python-Gemini path. -- KV propagation delay — measure to decide whether a short in-worker cache + bust-on-revoke is worth adding before the Durable-Object Later item. From 5df93ab8fa94fe47b2fb2b3e23fff1530dbbc74a Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 00:14:37 +0530 Subject: [PATCH 08/22] docs: replace broken ASCII diagrams in architecture.md with clean markdown --- docs/architecture.md | 125 +++++++++++++++++-------------------------- 1 file changed, 50 insertions(+), 75 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5a8206a..5d28369 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,45 +21,27 @@ token-gated worker. One worker, dispatched by path (`src/index.ts`): -``` - ┌──────────────────────── api-proxy worker ────────────────────────┐ -request ──▶ │ /admin/* ──▶ Hono admin sub-app (wrapped in try/catch → 500) │ - │ else ──▶ handleProxy() (framework-free hot path) │ - └───────────────────────────────────────────────────────────────────┘ -``` - -The admin sub-app is isolated in a `try/catch` so an admin bug can never crash the proxy branch. -The proxy hot path imports **no framework** — it is pure functions plus a `fetch` handler -(`src/proxy.ts` must never import Hono). +- **`/admin/*`** → the Hono admin sub-app, wrapped in `try/catch` (→ 500) so an admin bug can never + crash the proxy branch. +- **everything else** → `handleProxy`, the framework-free hot path (`src/proxy.ts` must never import + Hono — it is pure functions plus a `fetch` handler). ## 3. Request flow (proxy hot path) -`handleProxy` is a thin wrapper; `proxyRequest` does the work: +`handleProxy` is a thin wrapper: it answers an `OPTIONS` preflight directly, otherwise runs +`proxyRequest` and reflects CORS headers onto the result (§8). `proxyRequest`: -``` -handleProxy(req): - OPTIONS? ──▶ corsPreflight() → 204 (no auth, no upstream) §8 - else ──▶ withCors( proxyRequest(req) ) §8 - -proxyRequest(req): - 1. token = extractToken(req) auth slot → token §4 - 2. provider = routeProvider(req) auth slot (+path) → provider §4 - no token / no provider ───────────────────────────▶ 401 - 3. hash = SHA-256(token) - 4. meta = getValidatedByHash(hash) active? not expired? §6 - miss / disabled / expired ─────────────────────────▶ 401 - 5. coarse(provider) ∉ meta.providers ──────────────────▶ 403 - 6. RATE_LIMITER.limit({ key: hash }) per-token, fail-open §7 - denied ────────────────────────────────────────────▶ 429 + Retry-After - 7. rewriteToUpstream(url) protocol/host/port only §12 - gemini* → strip ?key= - 8. swapAuth(headers) strip ALL auth, set ONE key §5 - 9. fetch(upstream) (OpenAI: geo-403 fallback) §9 - 10. ctx.waitUntil(touchLastUsed(hash)) fire-and-forget §6 - 11. return Response(upstream.body, upstream) stream straight through -``` +1. **Extract** the token from whichever auth slot it arrived in and **route** the provider from that + slot (+ path); missing either → **401**. (§4) +2. **Validate** `SHA-256(token)` against KV — a miss, a disabled token, or an expired one → **401**. (§6) +3. Requested provider not in the token's scope → **403**. (§4) +4. **Rate-limit** on the hash — over the cap → **429** + `Retry-After` (fail-open). (§7) +5. **Rewrite** the URL to the upstream — protocol/host/port only; strip `?key=` for Gemini. (§12) +6. **Swap auth** — strip every inbound auth header, set the one real key. (§5) +7. **Fetch** the upstream (OpenAI adds a geo-403 fallback, §9), stream the response back unbuffered, + and stamp `lastUsed` fire-and-forget. (§6, §9) -Responses stream through unbuffered, preserving SSE. +Path and query forward verbatim; only protocol/host/port change. ## 4. Provider routing (by auth header) @@ -75,9 +57,9 @@ populated** (`routeProvider`, `extractToken`): | none | — | 401 | `gemini-openai` (the OpenAI-compatible Gemini endpoint) collapses to the `gemini` scope via -`coarse()`; the distinction only selects the auth-swap branch. **Why no `/openai` `/anthropic` -path prefix:** it would break Gemini's file-upload flow (absolute `x-goog-upload-url` round trip) -and force every client to rewrite the SDK's own base path. See +`coarse()`; the distinction only selects the auth-swap branch. **Why no `/openai` `/anthropic` path +prefix:** it would break Gemini's file-upload flow (absolute `x-goog-upload-url` round trip) and +force every client to rewrite the SDK's own base path. See [`provider-routing-by-auth-header.md`](learnings/provider-routing-by-auth-header.md). ## 5. Auth swap (security linchpin) @@ -119,14 +101,13 @@ type TokenMetadata = { - **Validation** (`getValidatedByHash`): returns the record only if `status === "active"` AND, when `expiresAt` is set, it parses to a future timestamp — malformed or past expiry is rejected **fail-closed**. Not KV `expirationTtl` (60s floor, silently deletes the record, orphans the `:lu` - key) — see - [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). + key) — see [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). - **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget on each proxied request. Keeping it out of the token record means stamping it can never resurrect or re-enable a token the admin just disabled or deleted. - **Lifecycle:** `createToken`, `listTokens` (paginates KV, skips `:lu` keys), `updateToken` - (label / providers / status), `deleteToken` (record + `:lu`). KV is eventually consistent - (~60s), so revoke and new-token visibility can lag. + (label / providers / status), `deleteToken` (record + `:lu`). KV is eventually consistent (~60s), + so revoke and new-token visibility can lag. ## 7. Per-token rate limiting @@ -152,8 +133,8 @@ loose ceiling for abuse protection, not a strict quota. Verified to run on the F `handleProxy` short-circuits `OPTIONS` to a `204` preflight **before** the token checks (a preflight carries no auth header, so it would otherwise 401 and block every browser SDK). The preflight reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, and sets -`Access-Control-Max-Age: 86400`. Every real response is then passed through `withCors`, which -reflects `Origin` and exposes the Gemini resumable-upload headers +`Access-Control-Max-Age: 86400`. Every real response then passes through `withCors`, which reflects +`Origin` and exposes the Gemini resumable-upload headers (`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). No `Origin` → no CORS headers (server-side callers are unaffected). Credentials mode is never enabled (SDKs send keys as headers, not cookies). Provider browser opt-ins still apply (e.g. Anthropic's @@ -168,38 +149,32 @@ key is never on it. OpenAI 403s `unsupported_country_region_territory` when a request egresses from an unsupported colo (e.g. Hong Kong). A Worker's `fetch()` egresses from the colo the invocation runs in, fixed per -invocation, so an in-invocation retry can't escape a bad colo. Fix: try the fast edge fetch first, -and **only on the geo-403** re-issue through a SQLite Durable Object (`UsEgress`) pinned to North -America with `locationHint: "wnam"`; its `fetch()` then egresses from a supported region. - -``` -OpenAI request → direct edge fetch ─ 200 ─▶ return (fast path, ~60%) - │ geo-403 - ▼ - US-pinned DO (wnam) ─▶ egress US ─▶ OpenAI 200 ─▶ return -``` +invocation, so an in-invocation retry can't escape a bad colo. -Only the OpenAI branch buffers the body (so it can be replayed to the DO); a pool of DO ids spreads -load. Anthropic and Gemini are untouched, and the real key never leaves Cloudflare. See -[`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). +The fix is a fallback: try the fast edge `fetch()` first (the ~60% that egress from a good colo +return immediately); **only on the geo-403**, re-issue the same request through the `UsEgress` SQLite +Durable Object pinned to North America (`locationHint: "wnam"`). Running in a US colo, its `fetch()` +egresses from a supported region and succeeds. Only the OpenAI branch buffers the body (to replay it +to the DO); a pool of DO ids spreads load. Anthropic and Gemini are untouched, and the real key never +leaves Cloudflare. See [`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). ## 10. Admin dashboard -Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered JSX-ish HTML via `hono/html` -plus **HTMX 2.x** loaded from a CDN (zero client JS we author; nothing in the worker bundle but -markup + attributes). +Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus +**HTMX 2.x** loaded from a CDN (zero client JS we author; nothing in the worker bundle but markup + +attributes). - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` sets an HMAC-SHA256-signed cookie `cm_admin=.` (`HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards - every `/admin/*` route except login; signature check uses constant-time `crypto.subtle.verify`. + every `/admin/*` route except login; the signature check uses constant-time `crypto.subtle.verify`. - **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; parses label, - provider checkboxes, optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated - token), edit/enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. -- **UI:** add-token card (label, token, **Expires (optional)**, provider checkboxes) and a token + provider checkboxes, an optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated + token), edit / enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. +- **UI:** an add-token card (label, token, **Expires (optional)**, provider checkboxes) and a token table (label, last-4, provider pills, status, **Expires**, last-used, disable/delete). The created - plaintext is shown once. Expired tokens render `expired` and dim the row. The token list refreshes - on load, on the `tokens-changed` event, and **every 10 s** (a poll to surface new tokens / - last-used despite KV's ~60 s list propagation). + plaintext is shown once; expired tokens render `expired` and dim the row. The list refreshes on + load, on the `tokens-changed` event, and **every 10 s** (to surface new tokens / last-used despite + KV's ~60 s list propagation). ## 11. Real key handling @@ -216,8 +191,8 @@ never returned in a response body. The CORS and rate-limit paths never touch a r | `RATE_LIMITER` | Rate Limit | per-token RPM ceiling | Plus `[[migrations]] tag="v1" new_sqlite_classes=["UsEgress"]`. Upstreams resolve through -`upstreamBase()`: `*_UPSTREAM` env vars (plain vars, not secrets) default to the real hosts and are -overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites just protocol/host/port. +`upstreamBase()`: the `*_UPSTREAM` env vars (plain vars, not secrets) default to the real hosts and +are overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites just protocol/host/port. ## 13. Testing (two tiers) @@ -231,15 +206,15 @@ Tier 2 covers all four routing modes — OpenAI (Bearer), Anthropic (`x-api-key` for the first three; OpenAI-compat streaming rides the same SSE passthrough, so it has no dedicated test. Each asserts the real key reaches the mock and the token never does. Routing is by auth header alone, so any standard-auth SDK behaves identically — these four are representative. **No test hits a -live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but -**Gemini has never run against the real Google API** (no key yet). +live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but **Gemini +has never run against the real Google API** (no key yet). ## 14. Deployment ```bash nub install nubx wrangler kv namespace create api-proxy-tokens # paste id into wrangler.toml -nubx wrangler secret put OPENAI_API_KEY # + ANTHROPIC / GEMINI / ADMIN_SECRET +nubx wrangler secret put OPENAI_API_KEY # + ANTHROPIC / GEMINI / ADMIN_SECRET nubx wrangler deploy ``` @@ -260,10 +235,10 @@ Caveats: ## 16. Deferred / future -The token data model leaves room (`limits`, `spend`) without carrying the weight now: -spend / token-count caps + per-token usage analytics (needs a metering Durable Object and SSE usage -parsing), multiple real keys per provider (key pools), concurrency limits and longer rate-limit -windows, and instant (sub-minute) revocation via a DO allow/deny list. +The token data model leaves room (`limits`, `spend`) without carrying the weight now: spend / +token-count caps + per-token usage analytics (needs a metering Durable Object and SSE usage parsing), +multiple real keys per provider (key pools), concurrency limits and longer rate-limit windows, and +instant (sub-minute) revocation via a DO allow/deny list. ## 17. Repository layout From 862c4043a228aed861bdbbf25043f4f8e71ec265 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 00:27:01 +0530 Subject: [PATCH 09/22] docs: consumer-first README + cleaner architecture.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: lead with "Use it" — the libraries it works with (official OpenAI/Anthropic/ GenAI SDKs + any standard-auth client) and how a client points at the worker; trim "How it works" to a brief mechanism + link to architecture.md - architecture.md: replace ASCII pseudocode/box diagrams with numbered lists + a simple dispatch snippet; drop the repo-layout section (it just rots) --- README.md | 48 ++++++++--------- docs/architecture.md | 121 ++++++++++++++++++------------------------- 2 files changed, 72 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 67187a8..d55c23d 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,38 @@ A single Cloudflare Worker that reverse-proxies the OpenAI, Anthropic, and Google Gemini APIs behind **revocable proxy tokens**. You issue tokens from an admin dashboard and hand them out; each token is validated server-side and swapped for the real provider key before the request is forwarded. Consumers never see your real keys, and you can scope or revoke any token at any time. -The consumer changes only **two things** in their normal SDK: the base URL (point at your worker) and the API key (use a proxy token). +## Use it -## How it works - -The proxy token rides in the SDK's normal auth slot. The worker reads it, validates it against KV, checks the token is scoped to the requested provider, strips every inbound auth header, sets the one real key, and forwards the request (path + query verbatim, streaming included). - -| Token arrives in | Provider | Upstream | Real key set as | -|---|---|---|---| -| `Authorization: Bearer` | OpenAI | `api.openai.com` | `Authorization: Bearer` | -| `x-api-key` | Anthropic | `api.anthropic.com` | `x-api-key` | -| `x-goog-api-key` / `?key=` | Gemini | `generativelanguage.googleapis.com` | `x-goog-api-key` | -| `Authorization: Bearer` + path `/v1beta/openai/*` | Gemini (OpenAI-compat) | `generativelanguage.googleapis.com` | `Authorization: Bearer` | - -For the full design — request flow, token model, rate limiting, the OpenAI egress fix, and the admin dashboard — see [docs/architecture.md](docs/architecture.md). - -## Client setup - -Point the SDK's base URL at the worker and use a proxy token as the key: +Works with the official **OpenAI**, **Anthropic**, and **Google GenAI** SDKs (Python and Node) — and, since the worker routes by auth header and forwards verbatim, with anything that speaks those APIs: the Vercel AI SDK, LangChain, LiteLLM, OpenAI-compatible tools, or raw `curl`. A client changes only **two things**: the base URL and the API key (a proxy token). -| SDK | base URL | key | +| Client | base URL | API key | |---|---|---| -| OpenAI (Python / Node) | `https:///v1` | token | -| Anthropic (Python / Node) | `https://` (no `/v1`) | token | -| Google `@google/genai` (Node) | `httpOptions.baseUrl = https://` | token | -| Gemini from Python | point the **OpenAI** SDK at `https:///v1beta/openai` | token | +| OpenAI SDK (Python / Node) | `https:///v1` | proxy token | +| Anthropic SDK (Python / Node) | `https://` (no `/v1`) | proxy token | +| Google `@google/genai` (Node) | `httpOptions.baseUrl = https://` | proxy token | +| Gemini via the OpenAI SDK | `https:///v1beta/openai` | proxy token | + +```python +# OpenAI SDK (Python); Node is identical +from openai import OpenAI +client = OpenAI(base_url="https:///v1", api_key="") +client.chat.completions.create( + model="gpt-5.4", messages=[{"role": "user", "content": "Hello"}]) +``` + +Or raw HTTP: ```bash -# OpenAI-style curl https:///v1/chat/completions \ - -H "authorization: Bearer " -H "content-type: application/json" \ + -H "authorization: Bearer " -H "content-type: application/json" \ -d '{"model":"gpt-5.4","messages":[{"role":"user","content":"Hello"}]}' ``` -Browser SDKs work too — the worker answers the CORS preflight and reflects the request Origin. Provider browser opt-ins still apply (e.g. Anthropic's `dangerouslyAllowBrowser`). +Browser apps work too — the worker answers the CORS preflight and reflects the request Origin (provider browser opt-ins still apply, e.g. Anthropic's `dangerouslyAllowBrowser`). + +## How it works + +The proxy token rides in the SDK's normal auth slot. The worker validates it, checks it's scoped to the requested provider, strips every inbound auth header, sets the one real key, and forwards the request (path + query verbatim, streaming included). Routing is by which auth header the token arrives in — see [docs/architecture.md](docs/architecture.md) for the routing table and full design. ## Setup diff --git a/docs/architecture.md b/docs/architecture.md index 5d28369..3f08d3b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,27 +21,28 @@ token-gated worker. One worker, dispatched by path (`src/index.ts`): -- **`/admin/*`** → the Hono admin sub-app, wrapped in `try/catch` (→ 500) so an admin bug can never - crash the proxy branch. -- **everything else** → `handleProxy`, the framework-free hot path (`src/proxy.ts` must never import - Hono — it is pure functions plus a `fetch` handler). +``` +/admin/* → Hono admin sub-app (wrapped in try/catch → 500) +* → handleProxy() (framework-free hot path) +``` -## 3. Request flow (proxy hot path) +The admin sub-app is isolated in a `try/catch` so an admin bug can never crash the proxy branch. The +proxy hot path imports **no framework** — pure functions plus a `fetch` handler (`src/proxy.ts` must +never import Hono). -`handleProxy` is a thin wrapper: it answers an `OPTIONS` preflight directly, otherwise runs -`proxyRequest` and reflects CORS headers onto the result (§8). `proxyRequest`: +## 3. Request flow (proxy hot path) -1. **Extract** the token from whichever auth slot it arrived in and **route** the provider from that - slot (+ path); missing either → **401**. (§4) -2. **Validate** `SHA-256(token)` against KV — a miss, a disabled token, or an expired one → **401**. (§6) -3. Requested provider not in the token's scope → **403**. (§4) -4. **Rate-limit** on the hash — over the cap → **429** + `Retry-After` (fail-open). (§7) -5. **Rewrite** the URL to the upstream — protocol/host/port only; strip `?key=` for Gemini. (§12) -6. **Swap auth** — strip every inbound auth header, set the one real key. (§5) -7. **Fetch** the upstream (OpenAI adds a geo-403 fallback, §9), stream the response back unbuffered, - and stamp `lastUsed` fire-and-forget. (§6, §9) +`handleProxy` is a thin wrapper: it answers `OPTIONS` preflights (§8) and reflects `Origin` on every +response. Everything else runs `proxyRequest`: -Path and query forward verbatim; only protocol/host/port change. +1. **Extract + route** — read the token from its auth slot, pick the provider from that slot (+ path) (§4). No token or provider → **401**. +2. **Validate** — `getValidatedByHash(SHA-256(token))`; must be active and unexpired (§6). Else → **401**. +3. **Scope** — `coarse(provider)` must be in the token's `providers`. Else → **403**. +4. **Rate limit** — `RATE_LIMITER.limit({ key: hash })`, per-token, fail-open (§7). Over the limit → **429** + `Retry-After`. +5. **Rewrite** — swap protocol/host/port only; strip `?key=` for Gemini (§12). +6. **Swap auth** — strip every inbound auth header, set the one real key (§5). +7. **Forward** — `fetch` the upstream; OpenAI retries via the egress DO on a geo-403 (§9). +8. **Return** — stream the response back unbuffered (SSE preserved); `ctx.waitUntil(touchLastUsed)` (§6). ## 4. Provider routing (by auth header) @@ -102,9 +103,9 @@ type TokenMetadata = { `expiresAt` is set, it parses to a future timestamp — malformed or past expiry is rejected **fail-closed**. Not KV `expirationTtl` (60s floor, silently deletes the record, orphans the `:lu` key) — see [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). -- **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget on each proxied - request. Keeping it out of the token record means stamping it can never resurrect or re-enable a - token the admin just disabled or deleted. +- **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget per proxied request. + Keeping it out of the token record means stamping it can never resurrect or re-enable a token the + admin just disabled or deleted. - **Lifecycle:** `createToken`, `listTokens` (paginates KV, skips `:lu` keys), `updateToken` (label / providers / status), `deleteToken` (record + `:lu`). KV is eventually consistent (~60s), so revoke and new-token visibility can lag. @@ -112,8 +113,8 @@ type TokenMetadata = { ## 7. Per-token rate limiting After validation, `RATE_LIMITER.limit({ key: hash })` (the Workers Rate Limiting binding) caps each -token. Over the limit → `429` + `Retry-After: 60`. Wrapped in try/catch and **fail-open**: a missing -or erroring binding must never brick the proxy. +token. Over the limit → `429` + `Retry-After: 60`, wrapped in try/catch and **fail-open** so a +missing or erroring binding can never brick the proxy. ```toml [[ratelimits]] @@ -132,49 +133,46 @@ loose ceiling for abuse protection, not a strict quota. Verified to run on the F `handleProxy` short-circuits `OPTIONS` to a `204` preflight **before** the token checks (a preflight carries no auth header, so it would otherwise 401 and block every browser SDK). The preflight -reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, and sets +reflects the request `Origin` and the requested `Access-Control-Request-Headers`, and sets `Access-Control-Max-Age: 86400`. Every real response then passes through `withCors`, which reflects -`Origin` and exposes the Gemini resumable-upload headers -(`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). No `Origin` → no CORS -headers (server-side callers are unaffected). Credentials mode is never enabled (SDKs send keys as -headers, not cookies). Provider browser opt-ins still apply (e.g. Anthropic's -`dangerouslyAllowBrowser`, which the SDK forwards as a header). +`Origin` and exposes the Gemini resumable-upload headers (`x-goog-upload-url`, `x-goog-upload-status`, +`x-goog-upload-chunk-granularity`). No `Origin` → no CORS headers (server-side callers unaffected). +Credentials mode is never enabled (SDKs send keys as headers, not cookies). Provider browser opt-ins +still apply (e.g. Anthropic's `dangerouslyAllowBrowser`, which the SDK forwards as a header). **Gemini file uploads** pass through verbatim: the start call routes normally, Google returns an absolute, self-authenticating `x-goog-upload-url`, and the client uploads bytes **directly to -Google** — that leg never transits the worker, so the 100 MB body cap is sidestepped and the real -key is never on it. +Google** — that leg never transits the worker, so the 100 MB body cap is sidestepped and the real key +is never on it. ## 9. OpenAI geo-403 egress (North-America-pinned Durable Object) OpenAI 403s `unsupported_country_region_territory` when a request egresses from an unsupported colo (e.g. Hong Kong). A Worker's `fetch()` egresses from the colo the invocation runs in, fixed per -invocation, so an in-invocation retry can't escape a bad colo. +invocation, so an in-invocation retry can't escape a bad colo. The fix: + +1. Direct edge `fetch` → `200` → return (the fast path, ~60% of calls). +2. On a geo-403 **only** → re-issue through the `wnam`-pinned `UsEgress` DO — a SQLite Durable Object that runs in North America, so its `fetch()` egresses from a supported region → `200` → return. -The fix is a fallback: try the fast edge `fetch()` first (the ~60% that egress from a good colo -return immediately); **only on the geo-403**, re-issue the same request through the `UsEgress` SQLite -Durable Object pinned to North America (`locationHint: "wnam"`). Running in a US colo, its `fetch()` -egresses from a supported region and succeeds. Only the OpenAI branch buffers the body (to replay it -to the DO); a pool of DO ids spreads load. Anthropic and Gemini are untouched, and the real key never -leaves Cloudflare. See [`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). +Only the OpenAI branch buffers the body (so it can be replayed to the DO); a pool of DO ids spreads +load. Anthropic and Gemini are untouched, and the real key never leaves Cloudflare. See +[`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). ## 10. Admin dashboard Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus -**HTMX 2.x** loaded from a CDN (zero client JS we author; nothing in the worker bundle but markup + -attributes). +**HTMX 2.x** from a CDN (no client JS we author; nothing in the worker bundle but markup + attributes). - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` sets an HMAC-SHA256-signed cookie `cm_admin=.` (`HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards - every `/admin/*` route except login; the signature check uses constant-time `crypto.subtle.verify`. -- **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; parses label, - provider checkboxes, an optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated - token), edit / enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. -- **UI:** an add-token card (label, token, **Expires (optional)**, provider checkboxes) and a token - table (label, last-4, provider pills, status, **Expires**, last-used, disable/delete). The created - plaintext is shown once; expired tokens render `expired` and dim the row. The list refreshes on - load, on the `tokens-changed` event, and **every 10 s** (to surface new tokens / last-used despite - KV's ~60 s list propagation). + every `/admin/*` route except login; the signature is checked with constant-time `crypto.subtle.verify`. +- **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; label, provider + checkboxes, optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated token), + edit / enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. +- **UI:** an add-token card and a token table (label, last-4, provider pills, status, expires, + last-used, disable/delete). The plaintext is shown once; expired tokens render `expired` and dim + the row. The list refreshes on load, on `tokens-changed`, and **every 10 s** (to surface new + tokens / last-used despite KV's ~60 s list propagation). ## 11. Real key handling @@ -236,27 +234,6 @@ Caveats: ## 16. Deferred / future The token data model leaves room (`limits`, `spend`) without carrying the weight now: spend / -token-count caps + per-token usage analytics (needs a metering Durable Object and SSE usage parsing), -multiple real keys per provider (key pools), concurrency limits and longer rate-limit windows, and -instant (sub-minute) revocation via a DO allow/deny list. - -## 17. Repository layout - -``` -src/ - index.ts # fetch entry + /admin vs proxy dispatch - proxy.ts # hot path: extractToken, routeProvider, swapAuth, CORS, rate limit, geo-403 egress - tokens.ts # KV token store: hashing, create/list/update/delete, expiry, lastUsed - types.ts # Env + TokenMetadata + Provider types - upstreams.ts # *_UPSTREAM resolver (test seam) + rewriteToUpstream - egress.ts # UsEgress Durable Object (NA-pinned egress relay) - admin/ - index.ts # Hono app: cookie auth + token CRUD routes - views.ts # HTML + HTMX views (dashboard, login, token table) -test/ - *.test.ts # tier-1 (workerd) - sdk-compat/ # tier-2 (real SDKs vs mock upstream) -docs/learnings/ # topic deep-dives (the "why") -_legacy/ # retired v1 per-provider proxies + schedule.sh helper -wrangler.toml # bindings: TOKENS (KV), US_EGRESS (DO), RATE_LIMITER (rate limit) -``` +token-count caps + per-token usage analytics (a metering Durable Object + SSE usage parsing), multiple +real keys per provider (key pools), concurrency limits and longer rate-limit windows, and instant +(sub-minute) revocation via a DO allow/deny list. From e084ce2e3902dcac62b87c28f4119f6c4c455137 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 00:30:57 +0530 Subject: [PATCH 10/22] =?UTF-8?q?docs:=20drop=20=C2=A717=20repo=20layout,?= =?UTF-8?q?=20keep=20the=205df93ab=20diagram=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the request-flow/topology formatting from 5df93ab (a prior full-file rewrite had overwritten it) and remove the repo-layout section, which only rots. --- docs/architecture.md | 100 ++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 3f08d3b..c41f76d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,28 +21,27 @@ token-gated worker. One worker, dispatched by path (`src/index.ts`): -``` -/admin/* → Hono admin sub-app (wrapped in try/catch → 500) -* → handleProxy() (framework-free hot path) -``` - -The admin sub-app is isolated in a `try/catch` so an admin bug can never crash the proxy branch. The -proxy hot path imports **no framework** — pure functions plus a `fetch` handler (`src/proxy.ts` must -never import Hono). +- **`/admin/*`** → the Hono admin sub-app, wrapped in `try/catch` (→ 500) so an admin bug can never + crash the proxy branch. +- **everything else** → `handleProxy`, the framework-free hot path (`src/proxy.ts` must never import + Hono — it is pure functions plus a `fetch` handler). ## 3. Request flow (proxy hot path) -`handleProxy` is a thin wrapper: it answers `OPTIONS` preflights (§8) and reflects `Origin` on every -response. Everything else runs `proxyRequest`: +`handleProxy` is a thin wrapper: it answers an `OPTIONS` preflight directly, otherwise runs +`proxyRequest` and reflects CORS headers onto the result (§8). `proxyRequest`: -1. **Extract + route** — read the token from its auth slot, pick the provider from that slot (+ path) (§4). No token or provider → **401**. -2. **Validate** — `getValidatedByHash(SHA-256(token))`; must be active and unexpired (§6). Else → **401**. -3. **Scope** — `coarse(provider)` must be in the token's `providers`. Else → **403**. -4. **Rate limit** — `RATE_LIMITER.limit({ key: hash })`, per-token, fail-open (§7). Over the limit → **429** + `Retry-After`. -5. **Rewrite** — swap protocol/host/port only; strip `?key=` for Gemini (§12). -6. **Swap auth** — strip every inbound auth header, set the one real key (§5). -7. **Forward** — `fetch` the upstream; OpenAI retries via the egress DO on a geo-403 (§9). -8. **Return** — stream the response back unbuffered (SSE preserved); `ctx.waitUntil(touchLastUsed)` (§6). +1. **Extract** the token from whichever auth slot it arrived in and **route** the provider from that + slot (+ path); missing either → **401**. (§4) +2. **Validate** `SHA-256(token)` against KV — a miss, a disabled token, or an expired one → **401**. (§6) +3. Requested provider not in the token's scope → **403**. (§4) +4. **Rate-limit** on the hash — over the cap → **429** + `Retry-After` (fail-open). (§7) +5. **Rewrite** the URL to the upstream — protocol/host/port only; strip `?key=` for Gemini. (§12) +6. **Swap auth** — strip every inbound auth header, set the one real key. (§5) +7. **Fetch** the upstream (OpenAI adds a geo-403 fallback, §9), stream the response back unbuffered, + and stamp `lastUsed` fire-and-forget. (§6, §9) + +Path and query forward verbatim; only protocol/host/port change. ## 4. Provider routing (by auth header) @@ -103,9 +102,9 @@ type TokenMetadata = { `expiresAt` is set, it parses to a future timestamp — malformed or past expiry is rejected **fail-closed**. Not KV `expirationTtl` (60s floor, silently deletes the record, orphans the `:lu` key) — see [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). -- **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget per proxied request. - Keeping it out of the token record means stamping it can never resurrect or re-enable a token the - admin just disabled or deleted. +- **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget on each proxied + request. Keeping it out of the token record means stamping it can never resurrect or re-enable a + token the admin just disabled or deleted. - **Lifecycle:** `createToken`, `listTokens` (paginates KV, skips `:lu` keys), `updateToken` (label / providers / status), `deleteToken` (record + `:lu`). KV is eventually consistent (~60s), so revoke and new-token visibility can lag. @@ -113,8 +112,8 @@ type TokenMetadata = { ## 7. Per-token rate limiting After validation, `RATE_LIMITER.limit({ key: hash })` (the Workers Rate Limiting binding) caps each -token. Over the limit → `429` + `Retry-After: 60`, wrapped in try/catch and **fail-open** so a -missing or erroring binding can never brick the proxy. +token. Over the limit → `429` + `Retry-After: 60`. Wrapped in try/catch and **fail-open**: a missing +or erroring binding must never brick the proxy. ```toml [[ratelimits]] @@ -133,46 +132,49 @@ loose ceiling for abuse protection, not a strict quota. Verified to run on the F `handleProxy` short-circuits `OPTIONS` to a `204` preflight **before** the token checks (a preflight carries no auth header, so it would otherwise 401 and block every browser SDK). The preflight -reflects the request `Origin` and the requested `Access-Control-Request-Headers`, and sets +reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, and sets `Access-Control-Max-Age: 86400`. Every real response then passes through `withCors`, which reflects -`Origin` and exposes the Gemini resumable-upload headers (`x-goog-upload-url`, `x-goog-upload-status`, -`x-goog-upload-chunk-granularity`). No `Origin` → no CORS headers (server-side callers unaffected). -Credentials mode is never enabled (SDKs send keys as headers, not cookies). Provider browser opt-ins -still apply (e.g. Anthropic's `dangerouslyAllowBrowser`, which the SDK forwards as a header). +`Origin` and exposes the Gemini resumable-upload headers +(`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). No `Origin` → no CORS +headers (server-side callers are unaffected). Credentials mode is never enabled (SDKs send keys as +headers, not cookies). Provider browser opt-ins still apply (e.g. Anthropic's +`dangerouslyAllowBrowser`, which the SDK forwards as a header). **Gemini file uploads** pass through verbatim: the start call routes normally, Google returns an absolute, self-authenticating `x-goog-upload-url`, and the client uploads bytes **directly to -Google** — that leg never transits the worker, so the 100 MB body cap is sidestepped and the real key -is never on it. +Google** — that leg never transits the worker, so the 100 MB body cap is sidestepped and the real +key is never on it. ## 9. OpenAI geo-403 egress (North-America-pinned Durable Object) OpenAI 403s `unsupported_country_region_territory` when a request egresses from an unsupported colo (e.g. Hong Kong). A Worker's `fetch()` egresses from the colo the invocation runs in, fixed per -invocation, so an in-invocation retry can't escape a bad colo. The fix: - -1. Direct edge `fetch` → `200` → return (the fast path, ~60% of calls). -2. On a geo-403 **only** → re-issue through the `wnam`-pinned `UsEgress` DO — a SQLite Durable Object that runs in North America, so its `fetch()` egresses from a supported region → `200` → return. +invocation, so an in-invocation retry can't escape a bad colo. -Only the OpenAI branch buffers the body (so it can be replayed to the DO); a pool of DO ids spreads -load. Anthropic and Gemini are untouched, and the real key never leaves Cloudflare. See -[`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). +The fix is a fallback: try the fast edge `fetch()` first (the ~60% that egress from a good colo +return immediately); **only on the geo-403**, re-issue the same request through the `UsEgress` SQLite +Durable Object pinned to North America (`locationHint: "wnam"`). Running in a US colo, its `fetch()` +egresses from a supported region and succeeds. Only the OpenAI branch buffers the body (to replay it +to the DO); a pool of DO ids spreads load. Anthropic and Gemini are untouched, and the real key never +leaves Cloudflare. See [`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). ## 10. Admin dashboard Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus -**HTMX 2.x** from a CDN (no client JS we author; nothing in the worker bundle but markup + attributes). +**HTMX 2.x** loaded from a CDN (zero client JS we author; nothing in the worker bundle but markup + +attributes). - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` sets an HMAC-SHA256-signed cookie `cm_admin=.` (`HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards - every `/admin/*` route except login; the signature is checked with constant-time `crypto.subtle.verify`. -- **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; label, provider - checkboxes, optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated token), - edit / enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. -- **UI:** an add-token card and a token table (label, last-4, provider pills, status, expires, - last-used, disable/delete). The plaintext is shown once; expired tokens render `expired` and dim - the row. The list refreshes on load, on `tokens-changed`, and **every 10 s** (to surface new - tokens / last-used despite KV's ~60 s list propagation). + every `/admin/*` route except login; the signature check uses constant-time `crypto.subtle.verify`. +- **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; parses label, + provider checkboxes, an optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated + token), edit / enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. +- **UI:** an add-token card (label, token, **Expires (optional)**, provider checkboxes) and a token + table (label, last-4, provider pills, status, **Expires**, last-used, disable/delete). The created + plaintext is shown once; expired tokens render `expired` and dim the row. The list refreshes on + load, on the `tokens-changed` event, and **every 10 s** (to surface new tokens / last-used despite + KV's ~60 s list propagation). ## 11. Real key handling @@ -234,6 +236,6 @@ Caveats: ## 16. Deferred / future The token data model leaves room (`limits`, `spend`) without carrying the weight now: spend / -token-count caps + per-token usage analytics (a metering Durable Object + SSE usage parsing), multiple -real keys per provider (key pools), concurrency limits and longer rate-limit windows, and instant -(sub-minute) revocation via a DO allow/deny list. +token-count caps + per-token usage analytics (needs a metering Durable Object and SSE usage parsing), +multiple real keys per provider (key pools), concurrency limits and longer rate-limit windows, and +instant (sub-minute) revocation via a DO allow/deny list. From 39f4076e848eceb13374b27d92e77a47dd928930 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 10:50:15 +0530 Subject: [PATCH 11/22] test: add raw-fetch + LiteLLM compat coverage; name sdk-compat files after the client - fetch.ts: raw HTTP - covers the Gemini ?key= auth slot (no SDK exercises it), verbatim request-body forwarding, and an end-to-end CORS preflight - litellm.py: separate Python runner (local .venv) driving LiteLLM through the worker; run via `nub run test:py`, also chained into `nub run test` - rename openai/anthropic/gemini/fetch .test.ts -> .ts (file = the client it drives); compat config globs test/sdk-compat/*.ts and excludes the setup.ts harness - README documents each test as a per-client usage example + the venv setup --- .gitignore | 4 + README.md | 18 +- package.json | 3 +- test/run-py.mjs | 29 ++++ .../{anthropic.test.ts => anthropic.ts} | 0 test/sdk-compat/fetch.ts | 70 ++++++++ test/sdk-compat/{gemini.test.ts => gemini.ts} | 0 test/sdk-compat/litellm.py | 160 ++++++++++++++++++ test/sdk-compat/{openai.test.ts => openai.ts} | 0 test/sdk-compat/requirements.txt | 3 + vitest.compat.config.ts | 8 +- 11 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 test/run-py.mjs rename test/sdk-compat/{anthropic.test.ts => anthropic.ts} (100%) create mode 100644 test/sdk-compat/fetch.ts rename test/sdk-compat/{gemini.test.ts => gemini.ts} (100%) create mode 100644 test/sdk-compat/litellm.py rename test/sdk-compat/{openai.test.ts => openai.ts} (100%) create mode 100644 test/sdk-compat/requirements.txt diff --git a/.gitignore b/.gitignore index 44732d4..a260b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ CLAUDE.md # claude local settings .claude/settings.local.json + +# python venv for the separate-runner compat tests + caches +.venv/ +__pycache__/ diff --git a/README.md b/README.md index d55c23d..e7cc80f 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,23 @@ Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens: ## Testing -Two tiers (Vitest): - ```bash nub run test:unit # tier 1: proxy logic in workerd (vitest-pool-workers), fast CI gate -nub run test:compat # tier 2: real openai / @anthropic-ai/sdk / @google/genai SDKs vs a local worker + mock upstream -nub run test # both +nub run test:compat # tier 2: real SDKs (openai, @anthropic-ai/sdk, @google/genai) + raw fetch vs a mock upstream +nub run test:py # tier 2 (Python): LiteLLM through the worker (needs the venv below) +nub run test # all of the above ``` -Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, then drives each real SDK and asserts the forwarded request carries the real key (and never the token). +Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). **Each file in `test/sdk-compat/` is named after the client it drives and doubles as a usage example** — copy the `baseURL` + key wiring from `openai.ts`, `anthropic.ts`, `gemini.ts` (incl. the Gemini-OpenAI-compat block), `fetch.ts` (raw HTTP, incl. Gemini `?key=`), or `litellm.py`. + +Wrapper libraries (Vercel AI SDK, LangChain, LlamaIndex, instructor, ...) need no separate test: each sends one of the same four auth slots to the same base URL, so it routes identically to the official SDK it wraps — point its `baseURL`/`apiKey` at the worker and it works. + +The Python runner uses a local venv (one-time setup): + +```bash +python -m venv .venv +.venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt # *nix: .venv/bin/python +``` > **Gemini is untested with the actual API.** No test hits a live provider — all three run against a mock upstream. OpenAI and Anthropic are additionally verified live in deployment; Gemini is **not**, because `GEMINI_API_KEY` isn't set yet, so the Gemini route has never run against the real Google Generative Language API. Treat it as built-but-unproven until a key is added. diff --git a/package.json b/package.json index ff831f6..6432557 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "wrangler dev", "test:unit": "vitest run --config vitest.config.ts", "test:compat": "vitest run --config vitest.compat.config.ts", - "test": "nub run test:unit && nub run test:compat", + "test:py": "node test/run-py.mjs", + "test": "nub run test:unit && nub run test:compat && nub run test:py", "lint": "tsc --noEmit && biome check --write --unsafe .", "prepare": "lefthook install" }, diff --git a/test/run-py.mjs b/test/run-py.mjs new file mode 100644 index 0000000..69215a9 --- /dev/null +++ b/test/run-py.mjs @@ -0,0 +1,29 @@ +// Runs every Python compat test in test/sdk-compat/ with the repo's .venv (wired into `nub run test` +// as `test:py`). Skips - non-fatal - if the venv isn't set up, so `nub run test` works without Python. +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = join(dirname(fileURLToPath(import.meta.url)), ".."); +const py = + process.platform === "win32" + ? join(repo, ".venv", "Scripts", "python.exe") + : join(repo, ".venv", "bin", "python"); +const dir = join(repo, "test", "sdk-compat"); + +if (!existsSync(py)) { + console.log( + "[py] .venv not found - skipping. Setup: python -m venv .venv && " + + ".venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt", + ); + process.exit(0); +} + +for (const file of readdirSync(dir) + .filter((f) => f.endsWith(".py")) + .sort()) { + console.log(`[py] ${file}`); + const r = spawnSync(py, [join(dir, file)], { stdio: "inherit" }); + if (r.status !== 0) process.exit(r.status ?? 1); +} diff --git a/test/sdk-compat/anthropic.test.ts b/test/sdk-compat/anthropic.ts similarity index 100% rename from test/sdk-compat/anthropic.test.ts rename to test/sdk-compat/anthropic.ts diff --git a/test/sdk-compat/fetch.ts b/test/sdk-compat/fetch.ts new file mode 100644 index 0000000..adaa29d --- /dev/null +++ b/test/sdk-compat/fetch.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +// Raw fetch (no SDK) - covers the two things no official SDK exercises: the Gemini +// `?key=` query-param auth slot, verbatim request-body forwarding, and the CORS preflight. +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let url: string; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + url = w.url; + await seedToken(url, { token: "tk-fetch", providers: ["gemini"] }); +}); +afterAll(async () => { + await worker.stop(); + await mock.close(); +}); +beforeEach(() => mock.reset()); + +describe("raw fetch (no SDK)", () => { + it("routes the Gemini ?key= slot, strips the token, swaps the real key, forwards body verbatim", async () => { + const body = JSON.stringify({ + contents: [{ parts: [{ text: "ping-verbatim-42" }] }], + }); + const res = await fetch( + `${url}/v1beta/models/gemini-x:generateContent?key=tk-fetch&foo=bar`, + { method: "POST", headers: { "content-type": "application/json" }, body }, + ); + expect(res.status).toBe(200); + + const cap = mock.last(); + expect(cap).not.toBeNull(); + // real key swapped into the header slot + expect(cap?.headers["x-goog-api-key"]).toBe(FAKE.gemini); + // the ?key= token is stripped from the forwarded query; other params survive + expect(cap?.path).not.toContain("tk-fetch"); + expect(cap?.path).not.toContain("key="); + expect(cap?.path).toContain("foo=bar"); + // the token never appears in any outbound header + expect(JSON.stringify(cap?.headers)).not.toContain("tk-fetch"); + // request body forwarded byte-for-byte + expect(cap?.body).toBe(body); + }); + + it("answers a CORS preflight (OPTIONS) at the edge without a token or upstream call", async () => { + const res = await fetch(`${url}/v1/messages`, { + method: "OPTIONS", + headers: { + origin: "https://app.example", + "access-control-request-headers": "x-api-key, content-type", + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe( + "https://app.example", + ); + expect(res.headers.get("access-control-allow-methods")).toContain("POST"); + expect(mock.last()).toBeNull(); + }); +}); diff --git a/test/sdk-compat/gemini.test.ts b/test/sdk-compat/gemini.ts similarity index 100% rename from test/sdk-compat/gemini.test.ts rename to test/sdk-compat/gemini.ts diff --git a/test/sdk-compat/litellm.py b/test/sdk-compat/litellm.py new file mode 100644 index 0000000..5dcec8f --- /dev/null +++ b/test/sdk-compat/litellm.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""LiteLLM compatibility smoke test (separate Python runner - the Node compat tier can't host it). + +Brings up the worker locally (`wrangler dev`) with its OpenAI upstream pointed at a Python mock, +seeds a proxy token via the admin API, drives LiteLLM against the worker, and asserts the mock saw +the real key swapped in and the proxy token nowhere. + +One-time setup (from the repo root): + python -m venv .venv + .venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt # *nix: .venv/bin/python +Run it (also runs as part of `nub run test`): + nub run test:py +""" + +import sys + +# This file is named after the package (per the sdk-compat naming convention), so its own +# directory would shadow `import litellm`; drop the script dir from sys.path before importing it. +sys.path.pop(0) + +import http.server +import json +import os +import socket +import subprocess +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +FAKE_OPENAI_KEY = "FAKE-OPENAI-KEY" +ADMIN_SECRET = "litellm-admin-secret" +TOKEN = "tk-litellm" + +captured = {} + + +class MockHandler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + n = int(self.headers.get("content-length", 0)) + captured["path"] = self.path + captured["headers"] = {k.lower(): v for k, v in self.headers.items()} + captured["body"] = self.rfile.read(n).decode() if n else "" + body = json.dumps( + { + "id": "chatcmpl_1", + "object": "chat.completion", + "created": 0, + "model": "x", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_): + pass + + +def free_port(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +def wait_ready(base, timeout=120): + deadline = time.time() + timeout + while time.time() < deadline: + try: + urllib.request.urlopen(base + "/admin", timeout=2) + return True + except urllib.error.HTTPError: + return True # any HTTP response means the worker is serving + except Exception: + time.sleep(0.7) + return False + + +def post(url, fields, cookie=None): + headers = {"content-type": "application/x-www-form-urlencoded"} + if cookie: + headers["cookie"] = cookie + data = urllib.parse.urlencode(fields).encode() + return urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers)) + + +def main(): + mock_port, worker_port = free_port(), free_port() + mock = http.server.HTTPServer(("127.0.0.1", mock_port), MockHandler) + threading.Thread(target=mock.serve_forever, daemon=True).start() + + env = dict(os.environ, CI="1") + cmd = [ + "npx", "wrangler", "dev", + "--port", str(worker_port), "--ip", "127.0.0.1", + "--var", f"OPENAI_API_KEY:{FAKE_OPENAI_KEY}", + "--var", "ANTHROPIC_API_KEY:FAKE-ANTHROPIC-KEY", + "--var", "GEMINI_API_KEY:FAKE-GEMINI-KEY", + "--var", f"ADMIN_SECRET:{ADMIN_SECRET}", + "--var", f"OPENAI_UPSTREAM:http://127.0.0.1:{mock_port}", + ] + worker = subprocess.Popen( + cmd, cwd=REPO, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + shell=(os.name == "nt"), + ) + try: + base = f"http://127.0.0.1:{worker_port}" + if not wait_ready(base): + raise SystemExit("worker did not become ready") + + login = post(f"{base}/admin/login", {"password": ADMIN_SECRET}) + cookie = login.headers.get("set-cookie", "").split(";")[0] + post( + f"{base}/admin/api/tokens", + [("label", TOKEN), ("token", TOKEN), ("providers", "openai")], + cookie, + ) + + import litellm + + litellm.completion( + model="openai/gpt-x", + api_base=f"{base}/v1", + api_key=TOKEN, + messages=[{"role": "user", "content": "ping-from-litellm"}], + max_tokens=5, + ) + + h = captured.get("headers", {}) + assert captured.get("path") == "/v1/chat/completions", captured.get("path") + assert h.get("authorization") == f"Bearer {FAKE_OPENAI_KEY}", h.get("authorization") + assert TOKEN not in json.dumps(h), "proxy token leaked into upstream headers" + assert "ping-from-litellm" in captured.get("body", ""), "request body not forwarded" + print("PASS: litellm -> proxy swapped the real key, token never egressed, body forwarded verbatim") + finally: + worker.terminate() + try: + worker.wait(timeout=10) + except Exception: + worker.kill() + mock.shutdown() + + +if __name__ == "__main__": + main() diff --git a/test/sdk-compat/openai.test.ts b/test/sdk-compat/openai.ts similarity index 100% rename from test/sdk-compat/openai.test.ts rename to test/sdk-compat/openai.ts diff --git a/test/sdk-compat/requirements.txt b/test/sdk-compat/requirements.txt new file mode 100644 index 0000000..b2cbdd6 --- /dev/null +++ b/test/sdk-compat/requirements.txt @@ -0,0 +1,3 @@ +# Python deps for the separate-runner compat tests (litellm.test.py). Not part of the Node toolchain. +# Setup: python -m venv .venv && .venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt +litellm diff --git a/vitest.compat.config.ts b/vitest.compat.config.ts index 3b08105..7896bba 100644 --- a/vitest.compat.config.ts +++ b/vitest.compat.config.ts @@ -1,11 +1,13 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; // Tier 2: real-SDK compatibility. Runs in Node (the SDKs run here), hits a locally // started worker whose *_UPSTREAM env points at a node:http mock. Serial to avoid -// shared-capture-state and port races. +// shared-capture-state and port races. Each test file is named after the client it drives; +// setup.ts is the shared harness (excluded). Python clients run separately (`nub run test:py`). export default defineConfig({ test: { - include: ["test/sdk-compat/**/*.test.ts"], + include: ["test/sdk-compat/*.ts"], + exclude: [...configDefaults.exclude, "test/sdk-compat/setup.ts"], pool: "forks", fileParallelism: false, // serial: each file owns a mock upstream + worker on its own port testTimeout: 30_000, From 3f9abf8cbf60b8c77e88b768fb642f45c4dfcb98 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 10:50:21 +0530 Subject: [PATCH 12/22] docs: tighten architecture.md; add CORS-preflight/upload-passthrough learning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - architecture.md: note auth-slot precedence over ?key=, the CORS method allow-list + Vary: Origin, Path=/admin on the admin cookie; drop the unsourced "~60%" figure; trim the §15 recap; fix the ratelimits TOML spacing - learnings: new cors-preflight-and-upload-passthrough.md (preflight before auth; the Gemini upload URL is passed through, not rewritten) + note the 8-way egress DO pool --- docs/architecture.md | 22 ++++--- docs/learnings/README.md | 1 + .../cors-preflight-and-upload-passthrough.md | 60 +++++++++++++++++++ docs/learnings/openai-egress-geo-block.md | 3 + 4 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 docs/learnings/cors-preflight-and-upload-passthrough.md diff --git a/docs/architecture.md b/docs/architecture.md index c41f76d..7e66a5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,8 +41,6 @@ One worker, dispatched by path (`src/index.ts`): 7. **Fetch** the upstream (OpenAI adds a geo-403 fallback, §9), stream the response back unbuffered, and stamp `lastUsed` fire-and-forget. (§6, §9) -Path and query forward verbatim; only protocol/host/port change. - ## 4. Provider routing (by auth header) The client adds no path prefix and no custom header — routing reads **which auth slot the SDK @@ -56,6 +54,10 @@ populated** (`routeProvider`, `extractToken`): | `Authorization: Bearer` (else) | `openai` | api.openai.com | | none | — | 401 | +Auth slots are checked **before** `?key=`, so a request carrying `Authorization: Bearer` routes to +openai / gemini-openai even when it also has `?key=`; the `x-goog-api-key or ?key=` equivalence holds +only when no Bearer header is present. + `gemini-openai` (the OpenAI-compatible Gemini endpoint) collapses to the `gemini` scope via `coarse()`; the distinction only selects the auth-swap branch. **Why no `/openai` `/anthropic` path prefix:** it would break Gemini's file-upload flow (absolute `x-goog-upload-url` round trip) and @@ -119,6 +121,7 @@ or erroring binding must never brick the proxy. [[ratelimits]] name = "RATE_LIMITER" namespace_id = "1001" + [ratelimits.simple] limit = 100 # one shared ceiling for all tokens; tune freely period = 60 # must be 10 or 60 @@ -132,9 +135,11 @@ loose ceiling for abuse protection, not a strict quota. Verified to run on the F `handleProxy` short-circuits `OPTIONS` to a `204` preflight **before** the token checks (a preflight carries no auth header, so it would otherwise 401 and block every browser SDK). The preflight -reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, and sets +reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, advertises a +fixed method allow-list (`GET, POST, PUT, DELETE, OPTIONS`), and sets `Access-Control-Max-Age: 86400`. Every real response then passes through `withCors`, which reflects -`Origin` and exposes the Gemini resumable-upload headers +`Origin`, appends `Vary: Origin` (so per-Origin reflection is cache-safe), and exposes the Gemini +resumable-upload headers (`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). No `Origin` → no CORS headers (server-side callers are unaffected). Credentials mode is never enabled (SDKs send keys as headers, not cookies). Provider browser opt-ins still apply (e.g. Anthropic's @@ -151,7 +156,7 @@ OpenAI 403s `unsupported_country_region_territory` when a request egresses from (e.g. Hong Kong). A Worker's `fetch()` egresses from the colo the invocation runs in, fixed per invocation, so an in-invocation retry can't escape a bad colo. -The fix is a fallback: try the fast edge `fetch()` first (the ~60% that egress from a good colo +The fix is a fallback: try the fast edge `fetch()` first (requests that egress from a good colo return immediately); **only on the geo-403**, re-issue the same request through the `UsEgress` SQLite Durable Object pinned to North America (`locationHint: "wnam"`). Running in a US colo, its `fetch()` egresses from a supported region and succeeds. Only the OpenAI branch buffers the body (to replay it @@ -165,7 +170,7 @@ Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via ` attributes). - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` sets an HMAC-SHA256-signed cookie - `cm_admin=.` (`HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards + `cm_admin=.` (`Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; the signature check uses constant-time `crypto.subtle.verify`. - **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; parses label, provider checkboxes, an optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated @@ -222,9 +227,8 @@ Free Workers plan covers it (100k req/day); you only pay upstream providers for ## 15. Security model -Invariants, detailed above: real keys are secrets injected only outbound (§11); tokens stored as -`SHA-256` (§6); strip-all-then-set-one auth swap (§5); revoke-safe `lastUsed` (§6); admin behind a -constant-time HMAC cookie, isolated from the proxy branch (§10). +Invariants are detailed in §5 (auth swap), §6 (token hashing, revoke-safe `lastUsed`), §10 (admin +HMAC cookie), and §11 (real-key handling). Caveats: diff --git a/docs/learnings/README.md b/docs/learnings/README.md index d2f3f41..bc57162 100644 --- a/docs/learnings/README.md +++ b/docs/learnings/README.md @@ -13,3 +13,4 @@ Each file: the problem, what we found, and the decision we keep. - [proxy-token-security.md](proxy-token-security.md) - how a shareable token rides the SDK's auth slot without ever leaking the real key - [token-expiry-check-at-validate.md](token-expiry-check-at-validate.md) - why expiry is checked at read time, not via KV TTL, and fail-closed on bad input - [rate-limit-binding-free-and-loose.md](rate-limit-binding-free-and-loose.md) - the Workers Rate Limiting binding is free on the Free plan but a loose, per-colo ceiling +- [cors-preflight-and-upload-passthrough.md](cors-preflight-and-upload-passthrough.md) - why the browser preflight is answered before auth, and why the Gemini upload URL is passed through untouched diff --git a/docs/learnings/cors-preflight-and-upload-passthrough.md b/docs/learnings/cors-preflight-and-upload-passthrough.md new file mode 100644 index 0000000..343560e --- /dev/null +++ b/docs/learnings/cors-preflight-and-upload-passthrough.md @@ -0,0 +1,60 @@ +# CORS preflight and upload passthrough + +Two browser-facing quirks from v2.1, both about what the proxy must *not* touch. + +## A. CORS preflight must run before token auth + +### Problem + +Browser SDK callers trigger a CORS preflight: the browser sends an `OPTIONS` request *first*, and that +preflight carries **no auth header** (no `x-api-key`, no `Authorization`, no `?key=`). Run auth first +and `extractToken`/`routeProvider` see nothing, so every preflight 401s - the browser then never fires +the real request. All browser SDK callers break silently. + +### What we found + +`handleProxy` short-circuits `OPTIONS` to a `204` **before** any token work +(`src/proxy.ts`, the `if (req.method === "OPTIONS")` at the top, ahead of `proxyRequest`): + +``` +inbound request + │ + ├─ method === OPTIONS? ──▶ corsPreflight ──▶ 204 (no token check) + └─ else ──▶ extractToken / routeProvider / validate / forward +``` + +The `204` reflects the caller's `Origin` and echoes the requested headers back +(`access-control-allow-headers` = the inbound `access-control-request-headers`, else `*`). +`withCors` also `append`s `Vary: Origin` to every response so caches don't mix per-origin replies. + +### Decision we keep + +Preflight is answered before auth, and the real key never rides any CORS path (`withCors`/`corsPreflight` +only set `access-control-*` and `Vary`). Auth-first would be the natural instinct and it is wrong here. + +## B. Gemini resumable-upload URL is passed through, not rewritten + +### Problem + +Gemini's resumable upload returns an **absolute** `x-goog-upload-url` pointing straight at Google. If the +proxy tried to own that flow, large uploads would hit the Worker's 100MB request-body cap. + +### What we found + +`rewriteToUpstream` (`src/upstreams.ts`) only swaps `protocol`/`hostname`/`port` on the *request* URL - it +never rewrites the absolute `x-goog-upload-url` Google returns. So the client uploads bytes **directly to +Google**, bypassing the Worker and its body cap. The proxy's only job is to let the browser *read* that +header: `withCors` sets `access-control-expose-headers` to the `EXPOSE_HEADERS` constant +(`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). + +``` +client ──▶ proxy ──▶ Google : returns absolute x-goog-upload-url (not rewritten) +client ───────────▶ Google : uploads bytes straight to that URL (skips Worker + 100MB cap) +``` + +### Decision we keep + +Pass the upload URL through untouched; only expose the headers. This is also **why a path-prefix routing +scheme would break Gemini** - the absolute upload URL can't carry a `/gemini/` prefix - which is the +upload half of the case in [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md). +Token security on the normal path is unchanged (see [proxy-token-security.md](proxy-token-security.md)). diff --git a/docs/learnings/openai-egress-geo-block.md b/docs/learnings/openai-egress-geo-block.md index 405757d..e09ede5 100644 --- a/docs/learnings/openai-egress-geo-block.md +++ b/docs/learnings/openai-egress-geo-block.md @@ -53,6 +53,9 @@ OpenAI-supported region. It is wired as a **fallback**, not the default path: tr first and re-issue through the DO **only on the geo-403** (`src/proxy.ts`). The request body is buffered for OpenAI so it can be replayed to the DO. +The egress DO is **pooled across 8 named instances** (`EGRESS_POOL=8`, `idFromName('oa-egress-N')` with a +random `N`), so all OpenAI traffic isn't funneled through one DO. + ``` OpenAI request │ From f7a3af6701d650b9a0bcff34cd53067549c77d2c Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 10:55:06 +0530 Subject: [PATCH 13/22] refactor(test): name sdk-compat files after their packages; move requirements to test/ - gemini.ts -> google-genai.ts (@google/genai), anthropic.ts -> anthropic-ai-sdk.ts (@anthropic-ai/sdk); openai.ts already matches its package. Avoids collision with the future Vercel AI SDK (@ai-sdk/*) per-provider tests (see HANDOFF). - move requirements.txt to test/ (beside the run-py.mjs runner); update the path in run-py.mjs, litellm.py, requirements.txt, and the README --- README.md | 4 ++-- test/requirements.txt | 3 +++ test/run-py.mjs | 2 +- test/sdk-compat/{anthropic.ts => anthropic-ai-sdk.ts} | 0 test/sdk-compat/{gemini.ts => google-genai.ts} | 0 test/sdk-compat/litellm.py | 2 +- test/sdk-compat/requirements.txt | 3 --- 7 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 test/requirements.txt rename test/sdk-compat/{anthropic.ts => anthropic-ai-sdk.ts} (100%) rename test/sdk-compat/{gemini.ts => google-genai.ts} (100%) delete mode 100644 test/sdk-compat/requirements.txt diff --git a/README.md b/README.md index e7cc80f..a174950 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ nub run test:py # tier 2 (Python): LiteLLM through the worker (needs the v nub run test # all of the above ``` -Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). **Each file in `test/sdk-compat/` is named after the client it drives and doubles as a usage example** — copy the `baseURL` + key wiring from `openai.ts`, `anthropic.ts`, `gemini.ts` (incl. the Gemini-OpenAI-compat block), `fetch.ts` (raw HTTP, incl. Gemini `?key=`), or `litellm.py`. +Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). **Each file in `test/sdk-compat/` is named after the client it drives and doubles as a usage example** — copy the `baseURL` + key wiring from `openai.ts`, `anthropic-ai-sdk.ts`, `google-genai.ts` (incl. the Gemini-OpenAI-compat block), `fetch.ts` (raw HTTP, incl. Gemini `?key=`), or `litellm.py`. Wrapper libraries (Vercel AI SDK, LangChain, LlamaIndex, instructor, ...) need no separate test: each sends one of the same four auth slots to the same base URL, so it routes identically to the official SDK it wraps — point its `baseURL`/`apiKey` at the worker and it works. @@ -89,7 +89,7 @@ The Python runner uses a local venv (one-time setup): ```bash python -m venv .venv -.venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt # *nix: .venv/bin/python +.venv/Scripts/python -m pip install -r test/requirements.txt # *nix: .venv/bin/python ``` > **Gemini is untested with the actual API.** No test hits a live provider — all three run against a mock upstream. OpenAI and Anthropic are additionally verified live in deployment; Gemini is **not**, because `GEMINI_API_KEY` isn't set yet, so the Gemini route has never run against the real Google Generative Language API. Treat it as built-but-unproven until a key is added. diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..3f30350 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,3 @@ +# Python deps for the separate-runner compat tests in test/sdk-compat/*.py. Not part of the Node toolchain. +# Setup: python -m venv .venv && .venv/Scripts/python -m pip install -r test/requirements.txt +litellm diff --git a/test/run-py.mjs b/test/run-py.mjs index 69215a9..00c94b3 100644 --- a/test/run-py.mjs +++ b/test/run-py.mjs @@ -15,7 +15,7 @@ const dir = join(repo, "test", "sdk-compat"); if (!existsSync(py)) { console.log( "[py] .venv not found - skipping. Setup: python -m venv .venv && " + - ".venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt", + ".venv/Scripts/python -m pip install -r test/requirements.txt", ); process.exit(0); } diff --git a/test/sdk-compat/anthropic.ts b/test/sdk-compat/anthropic-ai-sdk.ts similarity index 100% rename from test/sdk-compat/anthropic.ts rename to test/sdk-compat/anthropic-ai-sdk.ts diff --git a/test/sdk-compat/gemini.ts b/test/sdk-compat/google-genai.ts similarity index 100% rename from test/sdk-compat/gemini.ts rename to test/sdk-compat/google-genai.ts diff --git a/test/sdk-compat/litellm.py b/test/sdk-compat/litellm.py index 5dcec8f..2b8c8da 100644 --- a/test/sdk-compat/litellm.py +++ b/test/sdk-compat/litellm.py @@ -7,7 +7,7 @@ One-time setup (from the repo root): python -m venv .venv - .venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt # *nix: .venv/bin/python + .venv/Scripts/python -m pip install -r test/requirements.txt # *nix: .venv/bin/python Run it (also runs as part of `nub run test`): nub run test:py """ diff --git a/test/sdk-compat/requirements.txt b/test/sdk-compat/requirements.txt deleted file mode 100644 index b2cbdd6..0000000 --- a/test/sdk-compat/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Python deps for the separate-runner compat tests (litellm.test.py). Not part of the Node toolchain. -# Setup: python -m venv .venv && .venv/Scripts/python -m pip install -r test/sdk-compat/requirements.txt -litellm From b3ea890e5d3fb7eae16464245bcd40052f6951ca Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 11:18:34 +0530 Subject: [PATCH 14/22] docs: prove SDK compat is the auth slot, not the SDK/language An 8-agent source-level survey (official SDKs in Python/Node/Go/Java/Ruby/.NET, Vercel AI SDK incl. @ai-sdk/google, LangChain JS+Py, LiteLLM, LlamaIndex, instructor, Aider/Cline/Continue/Open WebUI) confirms every client collapses onto one of the 4 auth slots already tested; none hits a new slot or path. The decisive case @ai-sdk/google uses the x-goog-api-key header at source (not ?key=, not Bearer), mapping to the existing gemini slot. So per-SDK / per-language compat tests would be redundant by the proxy routing logic - none added. Instead: new learnings doc with the proof matrix + caveats (Anthropic OAuth Bearer mode, legacy google-generativeai gRPC default, OpenAI /v1/responses verbatim forward), and README + architecture.md tightened to the auth-slot-not-SDK claim. --- README.md | 2 +- docs/architecture.md | 19 ++++--- docs/learnings/README.md | 1 + .../compat-is-the-auth-slot-not-the-sdk.md | 57 +++++++++++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 docs/learnings/compat-is-the-auth-slot-not-the-sdk.md diff --git a/README.md b/README.md index a174950..d85277e 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ nub run test # all of the above Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). **Each file in `test/sdk-compat/` is named after the client it drives and doubles as a usage example** — copy the `baseURL` + key wiring from `openai.ts`, `anthropic-ai-sdk.ts`, `google-genai.ts` (incl. the Gemini-OpenAI-compat block), `fetch.ts` (raw HTTP, incl. Gemini `?key=`), or `litellm.py`. -Wrapper libraries (Vercel AI SDK, LangChain, LlamaIndex, instructor, ...) need no separate test: each sends one of the same four auth slots to the same base URL, so it routes identically to the official SDK it wraps — point its `baseURL`/`apiKey` at the worker and it works. +**Why these few prove the rest.** The worker routes by *which auth slot a request uses*, not by SDK — so compatibility is fixed by the slot, not the language or wrapper. A source-level survey ([docs/learnings/compat-is-the-auth-slot-not-the-sdk.md](docs/learnings/compat-is-the-auth-slot-not-the-sdk.md)) confirms every official SDK of a provider shares one slot across **all** languages (OpenAI Python/Node/Go/Java/Ruby/.NET → `Authorization: Bearer`; Anthropic → `x-api-key`; Gemini → `x-goog-api-key`), and the major wrappers (Vercel AI SDK incl. `@ai-sdk/google`, LangChain JS+Py, LiteLLM, LlamaIndex, instructor, Aider/Cline/Continue/Open WebUI) just forward through that same slot. So one test per slot proves them all — point any client's `baseURL`/`apiKey` at the worker and it works; a new test is only warranted if a client ever hits a *new* slot or path, which none in the current ecosystem does. Two gotchas: use Anthropic's normal API-key mode (its OAuth `authToken` mode sends `Bearer`, which would route to OpenAI), and the legacy `google-generativeai` Python SDK needs `transport="rest"` (it defaults to gRPC and won't traverse an HTTP proxy otherwise). The Python runner uses a local venv (one-time setup): diff --git a/docs/architecture.md b/docs/architecture.md index 7e66a5b..c94f674 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -206,13 +206,18 @@ are overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites ju | 1 — proxy logic | `@cloudflare/vitest-pool-workers` (workerd) | routing, auth swap, expiry, CORS, rate limit, geo-403 fallback, SSE passthrough; mocks `fetch`, seeds KV directly | | 2 — real SDKs | `unstable_dev` worker + `node:http` mock upstream | the official `openai`, `@anthropic-ai/sdk`, `@google/genai` SDKs end-to-end | -Tier 2 covers all four routing modes — OpenAI (Bearer), Anthropic (`x-api-key`), Gemini native -(`x-goog-api-key`), and Gemini OpenAI-compat (the OpenAI SDK at `/v1beta/openai`) — plus streaming -for the first three; OpenAI-compat streaming rides the same SSE passthrough, so it has no dedicated -test. Each asserts the real key reaches the mock and the token never does. Routing is by auth header -alone, so any standard-auth SDK behaves identically — these four are representative. **No test hits a -live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but **Gemini -has never run against the real Google API** (no key yet). +Tier 2 covers every auth slot — OpenAI (`Bearer`, `openai.ts` + `litellm.py`), Anthropic +(`x-api-key`, `anthropic-ai-sdk.ts`), Gemini native (`x-goog-api-key`, `google-genai.ts`), Gemini +OpenAI-compat (the OpenAI SDK at `/v1beta/openai`, `google-genai.ts`), and the Gemini `?key=` query +slot plus verbatim path/query/body forwarding (`fetch.ts`) — plus streaming for the main three; +OpenAI-compat streaming rides the same SSE passthrough, so it has no dedicated test. Each asserts the +real key reaches the mock and the token never does. Compatibility is fixed by the **auth slot, not the +SDK or language**: a source-level survey of the official SDKs (6 languages), the Vercel AI SDK, +LangChain (JS+Py), LiteLLM, LlamaIndex, and the agent tools confirms every client collapses onto one +of these slots, so a per-SDK test would be redundant — see +[`compat-is-the-auth-slot-not-the-sdk.md`](learnings/compat-is-the-auth-slot-not-the-sdk.md). **No +test hits a live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but +**Gemini has never run against the real Google API** (no key yet). ## 14. Deployment diff --git a/docs/learnings/README.md b/docs/learnings/README.md index bc57162..379e04b 100644 --- a/docs/learnings/README.md +++ b/docs/learnings/README.md @@ -14,3 +14,4 @@ Each file: the problem, what we found, and the decision we keep. - [token-expiry-check-at-validate.md](token-expiry-check-at-validate.md) - why expiry is checked at read time, not via KV TTL, and fail-closed on bad input - [rate-limit-binding-free-and-loose.md](rate-limit-binding-free-and-loose.md) - the Workers Rate Limiting binding is free on the Free plan but a loose, per-colo ceiling - [cors-preflight-and-upload-passthrough.md](cors-preflight-and-upload-passthrough.md) - why the browser preflight is answered before auth, and why the Gemini upload URL is passed through untouched +- [compat-is-the-auth-slot-not-the-sdk.md](compat-is-the-auth-slot-not-the-sdk.md) - why one test per auth slot proves every SDK/language/wrapper, so we don't add per-client tests diff --git a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md new file mode 100644 index 0000000..a3f7379 --- /dev/null +++ b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md @@ -0,0 +1,57 @@ +# Compatibility is the auth slot, not the SDK + +## Problem + +Which clients do we need a compat test for? The candidates are endless: the official SDKs in six +languages, the Vercel AI SDK, LangChain (JS + Python), LiteLLM, LlamaIndex, instructor, and every +agent tool (Aider, Cline, Continue, Open WebUI, ...). Writing a test per client would never end, and +most would be copies of each other. + +## What we found + +The proxy routes and authenticates **purely by which auth slot a request arrives in** plus one path +check; it rewrites only host/port and forwards path + query + body verbatim (`src/proxy.ts` +`routeProvider` / `extractToken`, `src/upstreams.ts` `rewriteToUpstream`). So a client's compatibility +is decided by exactly two things: (1) which of four auth slots it puts the key in, and (2) whether it +lets you point its base URL at an arbitrary host. The SDK, the language, and the wrapper are +irrelevant once those two are fixed. + +A source-level survey (official SDK source, provider docs, and wrapper source) confirms every client +collapses onto one of the four already-tested slots. **None hits a new slot or an unhandled path.** + +| Provider | Slot the proxy keys on | Clients verified to use it | Wire proof | +|---|---|---|---| +| OpenAI | `Authorization: Bearer` | official SDKs: Python, Node, Go, Java, Ruby, .NET; `@ai-sdk/openai`; `@langchain/openai` (JS+Py); LiteLLM `openai/*`; LlamaIndex OpenAI; instructor; Aider; Cline; Continue; Open WebUI | Python `auth_headers → {"Authorization": f"Bearer {api_key}"}`; Go `Header.Set("authorization", "Bearer "+key)`; Ruby `bearer_auth`; Node sets the same `Authorization: Bearer` header | +| Anthropic | `x-api-key` (+ `anthropic-version`) | official SDKs: Python, Node, Go, Java, Ruby; `@ai-sdk/anthropic`; `@langchain/anthropic` (JS+Py); LlamaIndex Anthropic | every SDK's `auth_headers` sets `x-api-key` and auto-adds `anthropic-version: 2023-06-01` (forwarded verbatim) | +| Gemini (native) | `x-goog-api-key` | `@google/genai` (JS+Py), legacy `@google/generative-ai` / `google-generativeai`; `@ai-sdk/google`; `@langchain/google-genai` (JS+Py); LlamaIndex GoogleGenAI | `@ai-sdk/google` source: `'x-goog-api-key': loadApiKey(...)` — **not** `?key=`, **not** Bearer | +| Gemini (OpenAI-compat) | `Authorization: Bearer` + path `/v1beta/openai/` | any OpenAI SDK pointed at `…/v1beta/openai/` | Google's documented OpenAI-compat surface: `Authorization: Bearer `, `/v1beta/openai/chat/completions` | + +Every client also exposes a first-class base-URL override (`base_url` / `baseURL` / `WithBaseURL` / +`OPENAI_BASE_URL` / `httpOptions.baseUrl` / `createX({ baseURL })` / `configuration.baseURL` / ...), so +all can be aimed at the worker. + +The existing tier-2 tests already cover all four slots: `openai.ts` + `litellm.py` (Bearer), +`anthropic-ai-sdk.ts` (x-api-key), `google-genai.ts` (x-goog-api-key **and** the `/v1beta/openai/` +Bearer case), `fetch.ts` (the `?key=` query slot **and** verbatim path/query/body forwarding). + +## Caveats worth knowing (real divergences, not new slots) + +- **Anthropic OAuth/token mode.** Every Anthropic SDK can alternatively authenticate with + `authToken` / `ANTHROPIC_AUTH_TOKEN`, which sends `Authorization: Bearer` instead of `x-api-key` — + that would route to the **openai** slot here. Use the normal API-key (`x-api-key`) mode. +- **Legacy `google-generativeai` (Python) defaults to gRPC**, not HTTP, so it won't transit an HTTP + proxy at all unless you set `transport="rest"`. The current `google-genai` SDK is HTTP by default. +- **OpenAI Responses API path.** Modern OpenAI clients (and the AI SDK 5 default) call `/v1/responses` + rather than `/v1/chat/completions`. Both are `Authorization: Bearer` and forwarded verbatim, so both + stay in the openai slot — different upstream endpoint, same proxy behavior. +- **Base-URL `/v1` convention differs per client.** The OpenAI SDK and `@ai-sdk/anthropic` want the + `/v1` in the base URL; the official `@anthropic-ai/sdk` does **not** (it appends `/v1/messages` + itself). Set each client's base URL the way that client documents it. + +## Decision we keep + +Test **one representative client per auth slot** (done) and treat everything else as +compatible-by-construction, documented rather than re-tested. Adding a Vercel AI SDK or per-language +test would only re-exercise an already-covered slot — redundant by the routing logic above. Add a new +test only if a future client hits a genuinely new slot or path (`hitsNewProxyPath`), which nothing in +the current ecosystem does. From 71c1fdfd85d42a49bbcf759d535056ea5a470166 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 11:25:18 +0530 Subject: [PATCH 15/22] docs: fix review findings in compat learning - Drop fabricated `hitsNewProxyPath` symbol reference (it was a research schema field, not a codebase symbol; a reader would grep and find nothing). - Resolve the "four slots" conflation: the table lists the four provider routes SDKs use (3 distinct header slots + the /v1beta/openai/ path split), and the `?key=` query slot (no SDK uses it, only raw HTTP) is now called out explicitly as the fourth slot the proxy reads. --- .../compat-is-the-auth-slot-not-the-sdk.md | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md index a3f7379..08f891f 100644 --- a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md +++ b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md @@ -12,14 +12,17 @@ most would be copies of each other. The proxy routes and authenticates **purely by which auth slot a request arrives in** plus one path check; it rewrites only host/port and forwards path + query + body verbatim (`src/proxy.ts` `routeProvider` / `extractToken`, `src/upstreams.ts` `rewriteToUpstream`). So a client's compatibility -is decided by exactly two things: (1) which of four auth slots it puts the key in, and (2) whether it -lets you point its base URL at an arbitrary host. The SDK, the language, and the wrapper are -irrelevant once those two are fixed. +is decided by exactly two things: (1) which auth slot it puts the key in — one of the four the proxy +reads (`x-api-key`, `x-goog-api-key`, `Authorization: Bearer`, or the `?key=` query param), with a +single path check (`/v1beta/openai/`) splitting Bearer into the openai vs gemini-openai upstream — and +(2) whether it lets you point its base URL at an arbitrary host. The SDK, the language, and the +wrapper are irrelevant once those two are fixed. A source-level survey (official SDK source, provider docs, and wrapper source) confirms every client -collapses onto one of the four already-tested slots. **None hits a new slot or an unhandled path.** +collapses onto one of these already-handled routes. **None hits a new slot or an unhandled path.** The +table below lists the four provider routes SDKs actually use (`?key=` is covered after it): -| Provider | Slot the proxy keys on | Clients verified to use it | Wire proof | +| Provider route | Slot the proxy keys on | Clients verified to use it | Wire proof | |---|---|---|---| | OpenAI | `Authorization: Bearer` | official SDKs: Python, Node, Go, Java, Ruby, .NET; `@ai-sdk/openai`; `@langchain/openai` (JS+Py); LiteLLM `openai/*`; LlamaIndex OpenAI; instructor; Aider; Cline; Continue; Open WebUI | Python `auth_headers → {"Authorization": f"Bearer {api_key}"}`; Go `Header.Set("authorization", "Bearer "+key)`; Ruby `bearer_auth`; Node sets the same `Authorization: Bearer` header | | Anthropic | `x-api-key` (+ `anthropic-version`) | official SDKs: Python, Node, Go, Java, Ruby; `@ai-sdk/anthropic`; `@langchain/anthropic` (JS+Py); LlamaIndex Anthropic | every SDK's `auth_headers` sets `x-api-key` and auto-adds `anthropic-version: 2023-06-01` (forwarded verbatim) | @@ -30,7 +33,8 @@ Every client also exposes a first-class base-URL override (`base_url` / `baseURL `OPENAI_BASE_URL` / `httpOptions.baseUrl` / `createX({ baseURL })` / `configuration.baseURL` / ...), so all can be aimed at the worker. -The existing tier-2 tests already cover all four slots: `openai.ts` + `litellm.py` (Bearer), +The existing tier-2 tests cover all four slots the proxy reads — the three header slots in the table +plus the `?key=` query param (which no SDK uses, only raw HTTP): `openai.ts` + `litellm.py` (Bearer), `anthropic-ai-sdk.ts` (x-api-key), `google-genai.ts` (x-goog-api-key **and** the `/v1beta/openai/` Bearer case), `fetch.ts` (the `?key=` query slot **and** verbatim path/query/body forwarding). @@ -53,5 +57,5 @@ Bearer case), `fetch.ts` (the `?key=` query slot **and** verbatim path/query/bod Test **one representative client per auth slot** (done) and treat everything else as compatible-by-construction, documented rather than re-tested. Adding a Vercel AI SDK or per-language test would only re-exercise an already-covered slot — redundant by the routing logic above. Add a new -test only if a future client hits a genuinely new slot or path (`hitsNewProxyPath`), which nothing in -the current ecosystem does. +test only if a future client hits a genuinely new auth slot or routing path, which nothing in the +current ecosystem does. From c86f4996e204e30d35561b055e7e0c89f002604e Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 13:18:06 +0530 Subject: [PATCH 16/22] docs: make the per-provider anchor test explicit in the compat learning State plainly that each provider has one real-SDK anchor test (openai.ts/ litellm.py, anthropic-ai-sdk.ts, google-genai.ts/fetch.ts) and that the by-construction claim is only valid because it extends those verified anchors - without an anchor it proves nothing. --- .../compat-is-the-auth-slot-not-the-sdk.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md index 08f891f..06ce987 100644 --- a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md +++ b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md @@ -54,8 +54,11 @@ Bearer case), `fetch.ts` (the `?key=` query slot **and** verbatim path/query/bod ## Decision we keep -Test **one representative client per auth slot** (done) and treat everything else as -compatible-by-construction, documented rather than re-tested. Adding a Vercel AI SDK or per-language -test would only re-exercise an already-covered slot — redundant by the routing logic above. Add a new -test only if a future client hits a genuinely new auth slot or routing path, which nothing in the -current ecosystem does. +Anchor every provider with **one real-SDK test in at least one language** — `openai.ts` (+ +`litellm.py`), `anthropic-ai-sdk.ts`, `google-genai.ts` (+ `fetch.ts`) — then treat that provider's +other languages and wrappers as compatible-by-construction, documented rather than re-tested. The +anchors are load-bearing: by-construction extends a *verified* anchor to clients that share its slot, +so without an anchor it would prove nothing. Adding a Vercel AI SDK or per-language test would only +re-exercise an already-anchored slot — redundant by the routing logic above. Add a new test only if a +future client hits a genuinely new auth slot or routing path, which nothing in the current ecosystem +does. From 8369fd163ad08df1fb649830be6376275a60c8eb Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 14:14:51 +0530 Subject: [PATCH 17/22] test: add per-library compat tests; fix test:py harness (no orphaned workerd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the clarified rule - dedup across LANGUAGES of a package, not across packages - each distinct client library now gets one end-to-end test: - Node: Vercel AI SDK (@ai-sdk/openai|anthropic|google), LangChain (@langchain/openai|anthropic|google-genai), Genkit - Python: LlamaIndex (openai+anthropic+gemini), instructor, Pydantic AI Each drives the real library at the worker and asserts the mock saw the real key swapped into the right slot with the proxy token absent. Mastra is EXCLUDED: nub flagged @mastra/core 1.x as malicious (advisory MAL-2026-6011, embedded malicious code); not bypassed. Other-language packages of a tested SDK, end-user apps (Aider/Cline/Continue/OpenWebUI), and JVM/.NET frameworks stay documented as compatible-by-construction. Fix test:py hang + workerd leak: litellm.py spawned `npx wrangler dev` with shell=True, so terminate() orphaned workerd on Windows. Rebuilt test/run-py.mjs to own ONE worker (unstable_dev, clean teardown) + ONE mock with /__captured + /__reset endpoints; Python files are thin clients reading PROXY_* env. Async spawn (spawnSync froze the mock event loop) + a hard per-file timeout so nothing hangs. Docs: README, architecture.md §13, and the compat learning updated to the test-each-library / dedup-across-languages framing. --- README.md | 8 +- docs/architecture.md | 9 +- .../compat-is-the-auth-slot-not-the-sdk.md | 47 +- lock.yaml | 4922 +++++++++++++++-- package.json | 27 +- test/requirements.txt | 9 + test/run-py.mjs | 247 +- test/sdk-compat/ai-sdk-anthropic.ts | 54 + test/sdk-compat/ai-sdk-google.ts | 55 + test/sdk-compat/ai-sdk-openai.ts | 50 + test/sdk-compat/genkit.ts | 56 + test/sdk-compat/instructor.py | 69 + test/sdk-compat/langchain-anthropic.ts | 55 + test/sdk-compat/langchain-google-genai.ts | 54 + test/sdk-compat/langchain-openai.ts | 55 + test/sdk-compat/litellm.py | 171 +- test/sdk-compat/llama-index.py | 81 + test/sdk-compat/pydantic-ai.py | 45 + 18 files changed, 5336 insertions(+), 678 deletions(-) create mode 100644 test/sdk-compat/ai-sdk-anthropic.ts create mode 100644 test/sdk-compat/ai-sdk-google.ts create mode 100644 test/sdk-compat/ai-sdk-openai.ts create mode 100644 test/sdk-compat/genkit.ts create mode 100644 test/sdk-compat/instructor.py create mode 100644 test/sdk-compat/langchain-anthropic.ts create mode 100644 test/sdk-compat/langchain-google-genai.ts create mode 100644 test/sdk-compat/langchain-openai.ts create mode 100644 test/sdk-compat/llama-index.py create mode 100644 test/sdk-compat/pydantic-ai.py diff --git a/README.md b/README.md index d85277e..674dbaa 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,14 @@ Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens: ```bash nub run test:unit # tier 1: proxy logic in workerd (vitest-pool-workers), fast CI gate -nub run test:compat # tier 2: real SDKs (openai, @anthropic-ai/sdk, @google/genai) + raw fetch vs a mock upstream -nub run test:py # tier 2 (Python): LiteLLM through the worker (needs the venv below) +nub run test:compat # tier 2: real client libs (official SDKs, Vercel AI SDK, LangChain, Genkit) + raw fetch vs a mock upstream +nub run test:py # tier 2 (Python): LiteLLM, LlamaIndex, instructor, Pydantic AI through the worker (needs the venv below) nub run test # all of the above ``` -Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). **Each file in `test/sdk-compat/` is named after the client it drives and doubles as a usage example** — copy the `baseURL` + key wiring from `openai.ts`, `anthropic-ai-sdk.ts`, `google-genai.ts` (incl. the Gemini-OpenAI-compat block), `fetch.ts` (raw HTTP, incl. Gemini `?key=`), or `litellm.py`. +Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). The Python runner (`test/run-py.mjs`) owns the same worker + mock and runs each `*.py` as a thin client. **Each file in `test/sdk-compat/` is named after the package it drives and doubles as a usage example** — copy the `baseURL`/`apiKey` wiring from the file matching your client (e.g. `ai-sdk-openai.ts`, `langchain-anthropic.ts`, `genkit.ts`, `pydantic-ai.py`), or from `fetch.ts` for raw HTTP. -**Why these few prove the rest.** The worker routes by *which auth slot a request uses*, not by SDK — so compatibility is fixed by the slot, not the language or wrapper. A source-level survey ([docs/learnings/compat-is-the-auth-slot-not-the-sdk.md](docs/learnings/compat-is-the-auth-slot-not-the-sdk.md)) confirms every official SDK of a provider shares one slot across **all** languages (OpenAI Python/Node/Go/Java/Ruby/.NET → `Authorization: Bearer`; Anthropic → `x-api-key`; Gemini → `x-goog-api-key`), and the major wrappers (Vercel AI SDK incl. `@ai-sdk/google`, LangChain JS+Py, LiteLLM, LlamaIndex, instructor, Aider/Cline/Continue/Open WebUI) just forward through that same slot. So one test per slot proves them all — point any client's `baseURL`/`apiKey` at the worker and it works; a new test is only warranted if a client ever hits a *new* slot or path, which none in the current ecosystem does. Two gotchas: use Anthropic's normal API-key mode (its OAuth `authToken` mode sends `Bearer`, which would route to OpenAI), and the legacy `google-generativeai` Python SDK needs `transport="rest"` (it defaults to gRPC and won't traverse an HTTP proxy otherwise). +**What's tested, and what's by-construction.** The worker routes by *which auth slot a request uses*, not by SDK — so a provider's packages behave identically once the slot is fixed. We therefore test **each distinct library once, in one language** — the official `openai` / `@anthropic-ai/sdk` / `@google/genai` SDKs, the Vercel AI SDK, LangChain, Genkit, LiteLLM, LlamaIndex, instructor, and Pydantic AI (see `test/sdk-compat/`) — and treat the rest as compatible-by-construction: a tested SDK's other-language packages (`openai-python`/`-go`/`-java`/...), end-user apps (Aider, Cline, Continue, Open WebUI), and JVM/.NET frameworks (Spring AI, Semantic Kernel) each reuse a slot already proven. The per-provider proof matrix is in [docs/learnings/compat-is-the-auth-slot-not-the-sdk.md](docs/learnings/compat-is-the-auth-slot-not-the-sdk.md). Two gotchas: use Anthropic's normal API-key mode (its OAuth `authToken` mode sends `Bearer`, which would route to OpenAI), and the legacy `google-generativeai` Python SDK needs `transport="rest"` (it defaults to gRPC and won't traverse an HTTP proxy otherwise). The Python runner uses a local venv (one-time setup): diff --git a/docs/architecture.md b/docs/architecture.md index c94f674..2e75da5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -204,7 +204,7 @@ are overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites ju | Tier | Runner | Scope | |---|---|---| | 1 — proxy logic | `@cloudflare/vitest-pool-workers` (workerd) | routing, auth swap, expiry, CORS, rate limit, geo-403 fallback, SSE passthrough; mocks `fetch`, seeds KV directly | -| 2 — real SDKs | `unstable_dev` worker + `node:http` mock upstream | the official `openai`, `@anthropic-ai/sdk`, `@google/genai` SDKs end-to-end | +| 2 — real client libs | `unstable_dev` worker + `node:http` mock upstream (Node via vitest; Python via `test/run-py.mjs`) | official `openai`/`@anthropic-ai/sdk`/`@google/genai` SDKs, Vercel AI SDK, LangChain, Genkit, LiteLLM, LlamaIndex, instructor, Pydantic AI — end-to-end | Tier 2 covers every auth slot — OpenAI (`Bearer`, `openai.ts` + `litellm.py`), Anthropic (`x-api-key`, `anthropic-ai-sdk.ts`), Gemini native (`x-goog-api-key`, `google-genai.ts`), Gemini @@ -212,9 +212,10 @@ OpenAI-compat (the OpenAI SDK at `/v1beta/openai`, `google-genai.ts`), and the G slot plus verbatim path/query/body forwarding (`fetch.ts`) — plus streaming for the main three; OpenAI-compat streaming rides the same SSE passthrough, so it has no dedicated test. Each asserts the real key reaches the mock and the token never does. Compatibility is fixed by the **auth slot, not the -SDK or language**: a source-level survey of the official SDKs (6 languages), the Vercel AI SDK, -LangChain (JS+Py), LiteLLM, LlamaIndex, and the agent tools confirms every client collapses onto one -of these slots, so a per-SDK test would be redundant — see +SDK or language**, so each distinct library gets **one** end-to-end test in one language; a provider's +other-language packages (`openai-python`/`-go`/...), end-user apps (Aider, Cline, Continue, Open WebUI), +and JVM/.NET frameworks (Spring AI, Semantic Kernel) reuse a slot already proven and are documented +rather than re-tested — see [`compat-is-the-auth-slot-not-the-sdk.md`](learnings/compat-is-the-auth-slot-not-the-sdk.md). **No test hits a live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but **Gemini has never run against the real Google API** (no key yet). diff --git a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md index 06ce987..b370e0a 100644 --- a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md +++ b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md @@ -33,10 +33,9 @@ Every client also exposes a first-class base-URL override (`base_url` / `baseURL `OPENAI_BASE_URL` / `httpOptions.baseUrl` / `createX({ baseURL })` / `configuration.baseURL` / ...), so all can be aimed at the worker. -The existing tier-2 tests cover all four slots the proxy reads — the three header slots in the table -plus the `?key=` query param (which no SDK uses, only raw HTTP): `openai.ts` + `litellm.py` (Bearer), -`anthropic-ai-sdk.ts` (x-api-key), `google-genai.ts` (x-goog-api-key **and** the `/v1beta/openai/` -Bearer case), `fetch.ts` (the `?key=` query slot **and** verbatim path/query/body forwarding). +All four slots the proxy reads are exercised end-to-end — the three header slots in the table plus +the `?key=` query param (which no SDK uses, only raw HTTP via `fetch.ts`). The full list of tested +libraries is in "What we test" below. ## Caveats worth knowing (real divergences, not new slots) @@ -52,13 +51,35 @@ Bearer case), `fetch.ts` (the `?key=` query slot **and** verbatim path/query/bod `/v1` in the base URL; the official `@anthropic-ai/sdk` does **not** (it appends `/v1/messages` itself). Set each client's base URL the way that client documents it. -## Decision we keep +## What we test, and what we document -Anchor every provider with **one real-SDK test in at least one language** — `openai.ts` (+ -`litellm.py`), `anthropic-ai-sdk.ts`, `google-genai.ts` (+ `fetch.ts`) — then treat that provider's -other languages and wrappers as compatible-by-construction, documented rather than re-tested. The -anchors are load-bearing: by-construction extends a *verified* anchor to clients that share its slot, -so without an anchor it would prove nothing. Adding a Vercel AI SDK or per-language test would only -re-exercise an already-anchored slot — redundant by the routing logic above. Add a new test only if a -future client hits a genuinely new auth slot or routing path, which nothing in the current ecosystem -does. +Compatibility is the slot, not the language — but a *library* is its own client with its own wiring +(base-URL option, default endpoint, extra headers), so each distinct library gets one end-to-end test +as a living usage example. We do **not** re-test the same library in every language: a provider's +packages share one auth slot (the matrix above), so one language proves them all. + +**Tested end-to-end** (`test/sdk-compat/`, each file named after its package): + +- Node (`nub run test:compat`): the official `openai`, `@anthropic-ai/sdk`, `@google/genai`; the + Vercel AI SDK (`@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`); LangChain + (`@langchain/openai`, `@langchain/anthropic`, `@langchain/google-genai`); Genkit + (`@genkit-ai/google-genai`); raw `fetch`. +- Python (`nub run test:py`): LiteLLM, LlamaIndex (openai + anthropic + google-genai), instructor, + Pydantic AI. + +**Documented as compatible-by-construction** (not separately tested) — each collapses onto a slot +already proven above: + +- **Other-language packages of a tested SDK** — `openai-python` / `-go` / `-java` / `-ruby` / + `-dotnet`, `anthropic` (py/go/java/ruby), `google-genai` (py). Same package family, same slot as the + JS package already tested; re-testing each language is the redundancy we skip. +- **End-user apps, not importable libraries** — Aider, Cline, Continue, Open WebUI. Each speaks the + OpenAI-compatible surface (Bearer slot) with a user-set base URL. +- **JVM / .NET frameworks** — Spring AI, Semantic Kernel. Same slots; no JVM/.NET toolchain in this + repo to drive them. +- **Mastra** — `@mastra/core` 1.x is flagged by security advisory MAL-2026-6011 (embedded malicious + code), so it is deliberately **not** pulled into the toolchain. It builds on the Vercel AI SDK, so + by construction it uses the same Bearer slot already covered by `@ai-sdk/openai`. + +A new test is warranted only if a future client hits a genuinely new auth slot or routing path — +which nothing in the current ecosystem does. diff --git a/lock.yaml b/lock.yaml index 44d06a9..a48732b 100644 --- a/lock.yaml +++ b/lock.yaml @@ -9,44 +9,120 @@ importers: .: dependencies: hono: - specifier: ^4.12.25 - version: 4.12.25 + specifier: ^4.12.27 + version: 4.12.27 typescript: specifier: ^6.0.3 version: 6.0.3 wrangler: - specifier: ^4.100.0 - version: 4.100.0(@cloudflare/workers-types@4.20260615.1) + specifier: ^4.103.0 + version: 4.103.0(@cloudflare/workers-types@4.20260623.1) devDependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.85 + version: 3.0.85(zod@4.4.3) + '@ai-sdk/google': + specifier: ^3.0.83 + version: 3.0.83(zod@4.4.3) + '@ai-sdk/openai': + specifier: ^3.0.74 + version: 3.0.74(zod@4.4.3) '@anthropic-ai/sdk': - specifier: ^0.104.1 - version: 0.104.1(zod@3.25.76) + specifier: ^0.105.0 + version: 0.105.0(zod@4.4.3) '@biomejs/biome': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.5.1 + version: 2.5.1 '@cloudflare/vitest-pool-workers': specifier: ^0.16.18 - version: 0.16.18(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3))) + version: 0.16.18(@cloudflare/workers-types@4.20260623.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3))) '@cloudflare/workers-types': - specifier: ^4.20260615.1 - version: 4.20260615.1 + specifier: ^4.20260623.1 + version: 4.20260623.1 + '@genkit-ai/google-genai': + specifier: ^1.37.0 + version: 1.37.0(genkit@1.37.0) '@google/genai': - specifier: ^2.8.0 - version: 2.8.0 + specifier: ^2.9.0 + version: 2.9.0 + '@langchain/anthropic': + specifier: ^1.5.1 + version: 1.5.1(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/core': + specifier: ^1.2.1 + version: 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/google-genai': + specifier: ^2.2.0 + version: 2.2.0(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/openai': + specifier: ^1.5.2 + version: 1.5.2(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(ws@8.21.0) + ai: + specifier: ^6.0.208 + version: 6.0.208(zod@4.4.3) + genkit: + specifier: ^1.37.0 + version: 1.37.0 lefthook: specifier: ^2.1.9 version: 2.1.9 openai: - specifier: ^6.42.0 - version: 6.42.0(ws@8.21.0)(zod@3.25.76) + specifier: ^6.44.0 + version: 6.44.0(ws@8.21.0)(zod@4.4.3) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) + zod: + specifier: ^4.4.3 + version: 4.4.3 packages: - '@anthropic-ai/sdk@0.104.1': - resolution: {integrity: sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==} + '@ai-sdk/anthropic@3.0.85': + resolution: {integrity: sha512-fNeDB644l5wbRNQU0FnI+F7UTtOenMnPtACfMPUJaS2zJfuBlseEa1TMg+otHkETZgaJB+6Na51NQEv0+m7czw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@3.0.133': + resolution: {integrity: sha512-Ebs+7iS9zUgJu5B0RlxM2JmDWzq79Cpd6YdiqcCzB5qFdpfQJPUDiXutqlQP89F2XGjOdDeidulBTXUdXWzOxw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/google@3.0.83': + resolution: {integrity: sha512-Pz7aCX0dy+5x+r4K/37HbLZNaPtPL4q2NduzJW64VffLv5sI9Nb478wAd7PlH2r2asiypJsz/Jerf9draTciUA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@3.0.74': + resolution: {integrity: sha512-LPDBWd2WCv0GQs29K2pHcNrGx24hm4D8QEP386HwUAUPr1URho6bNVXHNmIv0FxaW+xDkLpNMTen+mFCUBp2LA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.30': + resolution: {integrity: sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + + '@anthropic-ai/sdk@0.103.0': + resolution: {integrity: sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@anthropic-ai/sdk@0.105.0': + resolution: {integrity: sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -58,63 +134,62 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.0': - resolution: {integrity: sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==} + '@biomejs/biome@2.5.1': + resolution: {integrity: sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.0': - resolution: {integrity: sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==} + '@biomejs/cli-darwin-arm64@2.5.1': + resolution: {integrity: sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.0': - resolution: {integrity: sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==} + '@biomejs/cli-darwin-x64@2.5.1': + resolution: {integrity: sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.0': - resolution: {integrity: sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==} + '@biomejs/cli-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] - '@biomejs/cli-linux-arm64@2.5.0': - resolution: {integrity: sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==} + '@biomejs/cli-linux-arm64@2.5.1': + resolution: {integrity: sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.0': - resolution: {integrity: sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==} + '@biomejs/cli-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] - '@biomejs/cli-linux-x64@2.5.0': - resolution: {integrity: sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==} + '@biomejs/cli-linux-x64@2.5.1': + resolution: {integrity: sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.0': - resolution: {integrity: sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==} + '@biomejs/cli-win32-arm64@2.5.1': + resolution: {integrity: sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.0': - resolution: {integrity: sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==} + '@biomejs/cli-win32-x64@2.5.1': + resolution: {integrity: sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -135,73 +210,50 @@ packages: '@vitest/snapshot': ^4.1.0 vitest: ^4.1.0 - '@cloudflare/workerd-darwin-64@1.20260611.1': - resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - '@cloudflare/workerd-darwin-64@1.20260617.1': resolution: {integrity: sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260611.1': - resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260617.1': resolution: {integrity: sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260611.1': - resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - '@cloudflare/workerd-linux-64@1.20260617.1': resolution: {integrity: sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260611.1': - resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260617.1': resolution: {integrity: sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260611.1': - resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - '@cloudflare/workerd-windows-64@1.20260617.1': resolution: {integrity: sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260615.1': - resolution: {integrity: sha512-fGOiTwoLj/8bU8mj3VAfa1EULx4ceZhDwnjvY+afDBlSXI9pvY7PE9t62rGEhJjbAOGd7i5WUDun0eZCWBDrzg==} + '@cloudflare/workers-types@4.20260623.1': + resolution: {integrity: sha512-J/0POl0HeLepbwDE5Yx5c7jQrHFkvCEFu3TS+TQsDDlg/vTs5og7wdGP6eNGXOAntgWUrjcvvKTmVLTP7OrnAg==} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -214,320 +266,296 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@google/genai@2.8.0': - resolution: {integrity: sha512-pc2ayxqO5+O7AvnHBqpNHIk7PAZkHZgL31tbyx0gJZBSS9qPYiQoqwK7oYOw/ePmG6QY4EMSu+304vD5QlhXAw==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@firebase/app-check-interop-types@0.3.4': + resolution: {integrity: sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==} + + '@firebase/app-types@0.9.5': + resolution: {integrity: sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==} + + '@firebase/auth-interop-types@0.2.5': + resolution: {integrity: sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==} + + '@firebase/component@0.7.3': + resolution: {integrity: sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==} + engines: {node: '>=20.0.0'} + + '@firebase/database-compat@2.1.4': + resolution: {integrity: sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==} + engines: {node: '>=20.0.0'} + + '@firebase/database-types@1.0.20': + resolution: {integrity: sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==} + + '@firebase/database@1.1.3': + resolution: {integrity: sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==} + engines: {node: '>=20.0.0'} + + '@firebase/logger@0.5.1': + resolution: {integrity: sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==} + engines: {node: '>=20.0.0'} + + '@firebase/util@1.15.1': + resolution: {integrity: sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==} + engines: {node: '>=20.0.0'} + + '@genkit-ai/ai@1.37.0': + resolution: {integrity: sha512-SQnis1/NJeaGOiFnstmCJ7iGxVj/lwsQpwCXgG+JQrspAvGEIpSHCVBkreUpw6Dy7r0mVCHKup7L1NqBJbvCZg==} + + '@genkit-ai/core@1.37.0': + resolution: {integrity: sha512-Bq/HTwRhFoWjn4bbN0gnegsO/0dSYRUtbkWj/xwYfTycSCzVTqQJt3eNJXL6Dd5qSlGa3sWjYTsksiLqfroDzg==} + + '@genkit-ai/firebase@1.37.0': + resolution: {integrity: sha512-yutXazOCGqGeauiShCaPj9fjcS3nn3FUTj7/0nbHXx8mLVhddBEwBIH96MqVGz9pqt3Fl57TCROPguLOmIZaQQ==} + peerDependencies: + '@google-cloud/firestore': ^7.11.0 + firebase: '>=11.5.0' + firebase-admin: '>=12.2' + genkit: ^1.37.0 + peerDependenciesMeta: + firebase: + optional: true + + '@genkit-ai/google-cloud@1.37.0': + resolution: {integrity: sha512-THLcekr0kxzTyZksFVQ/gCIcprlQlenG7aGdGWjAokqWdtX28CU+2pmQF64m++IxOsuuqISIABjpyp0/fSPgjQ==} + peerDependencies: + genkit: ^1.37.0 + + '@genkit-ai/google-genai@1.37.0': + resolution: {integrity: sha512-T22JHU9YuZL7FxxohAYZ4YBWL2M7SKDGW0AoBEciaT59uIIGuWC8bliaAbS9zxj2cQnvEG3hKQRIBOd9wh6z+Q==} + peerDependencies: + genkit: ^1.37.0 + + '@google-cloud/common@5.0.2': + resolution: {integrity: sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/firestore@7.11.6': + resolution: {integrity: sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==} + engines: {node: '>=14.0.0'} + + '@google-cloud/firestore@8.6.0': + resolution: {integrity: sha512-TdvZHfwQj5B5CSDEgDqyrhdVqtOSupmBXDQPasMAJiC64tjsGvyMooNiC43fdk1TsUHeklyoZ6/vQ1TjWKVMbg==} + engines: {node: '>=18'} + + '@google-cloud/logging-winston@6.0.2': + resolution: {integrity: sha512-rrNs4XXLtk0BAnL6kTjfAH26a/zVGsuZHd6Vve3Ip1fT4/Irz2QJtqI0XiPbT/yfmKqFmYa44GWPXtPPAi56ug==} + engines: {node: '>=18'} + peerDependencies: + winston: '>=3.2.1' + + '@google-cloud/logging@11.2.3': + resolution: {integrity: sha512-iUIJ+3Bi6aw9whahT4JmS5Ccd1Eej2Ss9RjOSW2+qcVaWcleepJtFUBuyU2CpewtniVibBUV/731MQw9SWxQ3Q==} + engines: {node: '>=14.0.0'} + + '@google-cloud/modelarmor@0.4.1': + resolution: {integrity: sha512-CT9TpQF443aatjhRRvazrYNOvUot26HnFP3hhgmV89QYygNPB6owWvGFFOTsKK4zSvTDfkeeb+E6diVXxn9t4g==} + engines: {node: '>=14.0.0'} + + '@google-cloud/opentelemetry-cloud-monitoring-exporter@0.19.0': + resolution: {integrity: sha512-5SOPXwC6RET4ZvXxw5D97dp8fWpqWEunHrzrUUGXhG4UAeedQe1KvYV8CK+fnaAbN2l2ha6QDYspT6z40TVY0g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/core': ^1.0.0 + '@opentelemetry/resources': ^1.0.0 + '@opentelemetry/sdk-metrics': ^1.0.0 + + '@google-cloud/opentelemetry-cloud-trace-exporter@2.4.1': + resolution: {integrity: sha512-Dq2IyAyA9PCjbjLOn86i2byjkYPC59b5ic8k/L4q5bBWH0Jro8lzMs8C0G5pJfqh2druj8HF+oAIAlSdWQ+Z9Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/core': ^1.0.0 + '@opentelemetry/resources': ^1.0.0 + '@opentelemetry/sdk-trace-base': ^1.0.0 + + '@google-cloud/opentelemetry-resource-util@2.4.0': + resolution: {integrity: sha512-/7ujlMoKtDtrbQlJihCjQnm31n2s2RTlvJqcSbt2jV3OkCzPAdo3u31Q13HNugqtIRUSk7bUoLx6AzhURkhW4w==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/resources': ^1.0.0 + + '@google-cloud/paginator@5.0.2': + resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} + engines: {node: '>=14.0.0'} + + '@google-cloud/precise-date@4.0.0': + resolution: {integrity: sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/projectify@4.0.0': + resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/promisify@4.0.0': + resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} + engines: {node: '>=14'} + + '@google-cloud/storage@7.21.0': + resolution: {integrity: sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==} + engines: {node: '>=14'} + + '@google/genai@2.9.0': + resolution: {integrity: sha512-3DRdSJ0LaKFig3FNGeRDn9BQxtjZm2qr0hNH2d771LKcG0HvUYddlN0LPdSp8XU7Ekb04i9q3+tt64uYqavUdw==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -535,6 +563,24 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@google/generative-ai@0.24.1': + resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==} + engines: {node: '>=18.0.0'} + + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -685,6 +731,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -695,32 +745,502 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@jsep-plugin/assignment@1.3.0': + resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@jsep-plugin/regex@1.0.4': + resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@langchain/anthropic@1.5.1': + resolution: {integrity: sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.2.1 + + '@langchain/core@1.2.1': + resolution: {integrity: sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==} + engines: {node: '>=20'} + + '@langchain/google-genai@2.2.0': + resolution: {integrity: sha512-1mDqbmB6+iC6ZBQY15r5xJg9wPErnQ774inpKh6qi6BrrjadDwaPHoklJW5IXU94edKiDpm1akIzJCrQDWe6yA==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.2.0 + + '@langchain/openai@1.5.2': + resolution: {integrity: sha512-En/QzXO3YFuaaZWQiGx0ZBNJMK3ipL/tz8F/PReG/63oV3wk2nz906QA8drYnd8r2/3NtSkbf3x/8qms5o6qTg==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.2.1 + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + '@opentelemetry/api-logs@0.52.1': + resolution: {integrity: sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==} + engines: {node: '>=14'} - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@opentelemetry/auto-instrumentations-node@0.49.2': + resolution: {integrity: sha512-xtETEPmAby/3MMmedv8Z/873sdLTWg+Vq98rtm4wbwvAiXBB/ao8qRyzRlvR2MR6puEr+vIB/CXeyJnzNA3cyw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.4.1 - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + '@opentelemetry/context-async-hooks@1.25.1': + resolution: {integrity: sha512-UW/ge9zjvAEmRWVapOP0qyCvPulWU6cQxGxDbWEFfGOj1VBBZAuOqTo3X6yWmDTD3Xe15ysCZChHncr2xFMIfQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + '@opentelemetry/core@1.25.1': + resolution: {integrity: sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@protobufjs/codegen@2.0.5': - resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-grpc@0.52.1': + resolution: {integrity: sha512-pVkSH20crBwMTqB3nIN4jpQKUEoB0Z94drIHpYyEqs7UBr+I0cpYyOR3bqjA/UasQUMROb3GX8ZX4/9cVRqGBQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/exporter-trace-otlp-http@0.52.1': + resolution: {integrity: sha512-05HcNizx0BxcFKKnS5rwOV+2GevLTVIRA0tRgWYyw4yCgR53Ic/xk83toYKts7kbzcI+dswInUg/4s8oyA+tqg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.52.1': + resolution: {integrity: sha512-pt6uX0noTQReHXNeEslQv7x311/F1gJzMnp1HD2qgypLRPbXDeMzzeTngRTUaUbP6hqWNtPxuLr4DEoZG+TcEQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/exporter-zipkin@1.25.1': + resolution: {integrity: sha512-RmOwSvkimg7ETwJbUOPTMhJm9A9bG1U8s7Zo3ajDh4zM7eYcycQ0dM7FbLD6NXWbI2yj7UY4q8BKinKYBQksyw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.41.0': + resolution: {integrity: sha512-00Oi6N20BxJVcqETjgNzCmVKN+I5bJH/61IlHiIWd00snj1FdgiIKlpE4hYVacTB2sjIBB3nTbHskttdZEE2eg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.43.0': + resolution: {integrity: sha512-pSxcWlsE/pCWQRIw92sV2C+LmKXelYkjkA7C5s39iPUi4pZ2lA1nIiw+1R/y2pDEhUHcaKkNyljQr3cx9ZpVlQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.43.1': + resolution: {integrity: sha512-qLT2cCniJ5W+6PFzKbksnoIQuq9pS83nmgaExfUwXVvlwi0ILc50dea0tWBHZMkdIDa/zZdcuFrJ7+fUcSnRow==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.40.0': + resolution: {integrity: sha512-aZ4cXaGWwj79ZXSYrgFVsrDlE4mmf2wfvP9bViwRc0j75A6eN6GaHYHqufFGMTCqASQn5pIjjP+Bx+PWTGiofw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.40.0': + resolution: {integrity: sha512-JxbM39JU7HxE9MTKKwi6y5Z3mokjZB2BjwfqYi4B3Y29YO3I42Z7eopG6qq06yWZc+nQli386UDQe0d9xKmw0A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.38.0': + resolution: {integrity: sha512-2/nRnx3pjYEmdPIaBwtgtSviTKHWnDZN3R+TkRUnhIVrvBKVcq+I5B2rtd6mr6Fe9cHlZ9Ojcuh7pkNh/xdWWg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.8.0': + resolution: {integrity: sha512-ieTm4RBIlZt2brPwtX5aEZYtYnkyqhAVXJI9RIohiBVMe5DxiwCwt+2Exep/nDVqGPX8zRBZUl4AEw423OxJig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.11.0': + resolution: {integrity: sha512-27urJmwkH4KDaMJtEv1uy2S7Apk4XbN4AgWMdfMJbi7DnOduJmeuA+DpJCwXB72tEWXo89z5T3hUVJIDiSNmNw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.38.0': + resolution: {integrity: sha512-Um07I0TQXDWa+ZbEAKDFUxFH40dLtejtExDOMLNJ1CL8VmOmA71qx93Qi/QG4tGkiI1XWqr7gF/oiMCJ4m8buQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.41.1': + resolution: {integrity: sha512-uRx0V3LPGzjn2bxAnV8eUsDT82vT7NTwI0ezEuPMBOTOsnPpGhWdhcdNdhH80sM4TrWrOfXm9HGEdfWE3TRIww==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.38.0': + resolution: {integrity: sha512-HBVLpTSYpkQZ87/Df3N0gAw7VzYZV3n28THIBrJWfuqw3Or7UqdhnjeuMIPQ04BKk3aZc0cWn2naSQObbh5vXw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.14.0': + resolution: {integrity: sha512-pVc8P5AgliC1DphyyBUgsxXlm2XaPH4BpYvt7rAZDMIqUpRk8gs19SioABtKqqxvFzg5jPtgJfJsdxq0Y+maLw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.38.1': + resolution: {integrity: sha512-WvssuKCuavu/hlq661u82UWkc248cyI/sT+c2dEIj6yCk0BUkErY1D+9XOO+PmHdJNE+76i2NdcvQX5rJrOe/w==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.42.0': + resolution: {integrity: sha512-N8SOwoKL9KQSX7z3gOaw5UaTeVQcfDO1c21csVHnmnmGUoqsXbArK2B8VuwPWcv6/BC/i3io+xTo7QGRZ/z28Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.52.1': + resolution: {integrity: sha512-EdSDiDSAO+XRXk/ZN128qQpBo1I51+Uay/LUPcPQhSRGf7fBPIEUBeOLQiItguGsug5MGOYjql2w/1wCQF3fdQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.40.0': + resolution: {integrity: sha512-8U/w7Ifumtd2bSN1OLaSwAAFhb9FyqWUki3lMMB0ds+1+HdSxYBe9aspEJEgvxAqOkrQnVniAPTEGf1pGM7SOw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.52.1': + resolution: {integrity: sha512-dG/aevWhaP+7OLv4BQQSEKMJv8GyeOp3Wxl31NHqE8xo9/fYMfEljiZphUHIfyg4gnZ9swMyWjfOQs5GUQe54Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.42.0': + resolution: {integrity: sha512-P11H168EKvBB9TUSasNDOGJCSkpT44XgoM6d3gRIWAa9ghLpYhl0uRkS8//MqPzcJVHr3h3RmfXIpiYLjyIZTw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.2.0': + resolution: {integrity: sha512-uKKmhEFd0zR280tJovuiBG7cfnNZT4kvVTvqtHPxQP7nOmRbJstCYHFH13YzjVcKjkmoArmxiSulmZmF7SLIlg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.39.0': + resolution: {integrity: sha512-lRwTqIKQecPWDkH1KEcAUcFhCaNssbKSpxf4sxRTAROCwrCEnYkjOuqJHV+q1/CApjMTaKu0Er4LBv/6bDpoxA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.42.0': + resolution: {integrity: sha512-H1BEmnMhho8o8HuNRq5zEI4+SIHDIglNB7BPKohZyWG4fWNuR7yM4GTlR01Syq21vODAS7z5omblScJD/eZdKw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.39.0': + resolution: {integrity: sha512-eU1Wx1RRTR/2wYXFzH9gcpB8EPmhYlNDIUHzUXjyUE0CAXEJhBLkYNlzdaVCoQDw2neDqS+Woshiia6+emWK9A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.38.0': + resolution: {integrity: sha512-tPmyqQEZNyrvg6G+iItdlguQEcGzfE+bJkpQifmBXmWBnoS5oU3UxqtyYuXGL2zI9qQM5yMBHH4nRXWALzy7WA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.46.0': + resolution: {integrity: sha512-VF/MicZ5UOBiXrqBslzwxhN7TVqzu1/LN/QDpkskqM0Zm0aZ4CVRbUygL8d7lrjLn15x5kGIe8VsSphMfPJzlA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.41.0': + resolution: {integrity: sha512-ivJg4QnnabFxxoI7K8D+in7hfikjte38sYzJB9v1641xJk9Esa7jM3hmbPB7lxwcgWJLVEDvfPwobt1if0tXxA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.40.0': + resolution: {integrity: sha512-0xfS1xcqUmY7WE1uWjlmI67Xg3QsSUlNT+AcXHeA4BDUPwZtWqF4ezIwLgpVZfHOnkAEheqGfNSWd1PIu3Wnfg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.40.0': + resolution: {integrity: sha512-d7ja8yizsOCNMYIJt5PH/fKZXjb/mS48zLROO4BzZTtDfhNCl2UM/9VIomP2qkGIFVouSJrGr/T00EzY7bPtKA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.39.0': + resolution: {integrity: sha512-mewVhEXdikyvIZoMIUry8eb8l3HUjuQjSjVbmLVTt4NQi35tkpnHQrG9bTRBrl3403LoWZ2njMPJyg4l6HfKvA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.38.0': + resolution: {integrity: sha512-stjow1PijcmUquSmRD/fSihm/H61DbjPlJuJhWUe7P22LFPjFhsrSeiB5vGj3vn+QGceNAs+kioUTzMGPbNxtg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.43.0': + resolution: {integrity: sha512-og23KLyoxdnAeFs1UWqzSonuCkePUzCX30keSYigIzJe/6WSYA8rnEI5lobcxPEzg+GcU06J7jzokuEHbjVJNw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.41.0': + resolution: {integrity: sha512-Kpv0fJRk/8iMzMk5Ue5BsUJfHkBJ2wQoIi/qduU1a1Wjx9GLj6J2G17PHjPK5mnZjPNzkFOXFADZMfgDioliQw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis-4@0.41.1': + resolution: {integrity: sha512-UqJAbxraBk7s7pQTlFi5ND4sAUs4r/Ai7gsAVZTQDbHl2kSsOp7gpHcpIuN5dpcI2xnuhM2tkH4SmEhbrv2S6Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.41.0': + resolution: {integrity: sha512-RJ1pwI3btykp67ts+5qZbaFSAAzacucwBet5/5EsKYtWBpHbWwV/qbGN/kIBzXg5WEZBhXLrR/RUq0EpEUpL3A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.40.0': + resolution: {integrity: sha512-sm/rH/GysY/KOEvZqYBZSLYFeXlBkHCgqPDgWc07tz+bHCN6mPs9P3otGOSTe7o3KAIM8Nc6ncCO59vL+jb2cA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.39.0': + resolution: {integrity: sha512-LaXnVmD69WPC4hNeLzKexCCS19hRLrUw3xicneAMkzJSzNJvPyk7G6I7lz7VjQh1cooObPBt9gNyd3hhTCUrag==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.41.0': + resolution: {integrity: sha512-7fzDe9/FpO6NFizC/wnzXXX7bF9oRchsD//wFqy5g5hVEgXZCQ70IhxjrKdBvgjyIejR9T9zTvfQ6PfVKfkCAw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.13.0': + resolution: {integrity: sha512-Pob0+0R62AqXH50pjazTeGBy/1+SK4CYpFUBV5t7xpbpeuQezkkgVGvLca84QqjBqQizcXedjpUJLgHQDixPQg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.5.0': + resolution: {integrity: sha512-aNTeSrFAVcM9qco5DfZ9DNXu6hpMRe8Kt8nCDHfMWDB3pwgGVUE76jTdohc+H/7eLRqh4L7jqs5NSQoHw7S6ww==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.39.0': + resolution: {integrity: sha512-v/1xziLJ9CyB3YDjBSBzbB70Qd0JwWTo36EqWK5m3AR0CzsyMQQmf3ZIZM6sgx7hXMcRQ0pnEYhg6nhrUQPm9A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.52.1': + resolution: {integrity: sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.52.1': + resolution: {integrity: sha512-z175NXOtX5ihdlshtYBe5RpGeBoTXVCKPPLiQlD6FHvpM4Ch+p2B0yWKYSrBfLH24H9zjJiBdTrtD+hLlfnXEQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.52.1': + resolution: {integrity: sha512-zo/YrSDmKMjG+vPeA9aBBrsQM9Q/f2zo6N04WMB3yNldJRsgpRBeLLwvAt/Ba7dpehDLOEFBd1i2JCoaFtpCoQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/otlp-transformer@0.52.1': + resolution: {integrity: sha512-I88uCZSZZtVa0XniRqQWKbjAUm73I8tpEy/uJYPPYw5d7BRdVk0RfTBQw8kSUl01oVWEuqxLDa802222MYyWHg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/propagation-utils@0.30.16': + resolution: {integrity: sha512-ZVQ3Z/PQ+2GQlrBfbMMMT0U7MzvYZLCPP800+ooyaBqm4hMvuQHfP028gB9/db0mwkmyEAMad9houukUVxhwcw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/propagator-aws-xray@1.26.2': + resolution: {integrity: sha512-k43wxTjKYvwfce9L4eT8fFYy/ATmCfPHZPZsyT/6ABimf2KE1HafoOsIcxLOtmNSZt6dCvBIYCrXaOWta20xJg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-b3@1.25.1': + resolution: {integrity: sha512-p6HFscpjrv7//kE+7L+3Vn00VEDUJB0n6ZrjkTYHrJ58QZ8B3ajSJhRbCcY6guQ3PDjTbxWklyvIN2ojVbIb1A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@1.25.1': + resolution: {integrity: sha512-nBprRf0+jlgxks78G/xq72PipVK+4or9Ypntw0gVZYNTCSK8rg5SeaGV19tV920CMqBD/9UIOiFr23Li/Q8tiA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.36.2': + resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==} + engines: {node: '>=14'} + + '@opentelemetry/resource-detector-alibaba-cloud@0.29.7': + resolution: {integrity: sha512-PExUl/R+reSQI6Y/eNtgAsk6RHk1ElYSzOa8/FHfdc/nLmx9sqMasBEpLMkETkzDP7t27ORuXe4F9vwkV2uwwg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@1.12.0': + resolution: {integrity: sha512-Cvi7ckOqiiuWlHBdA1IjS0ufr3sltex2Uws2RK6loVp4gzIJyOijsddAI6IZ5kiO8h/LgCWe8gxPmwkTKImd+Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.2.12': + resolution: {integrity: sha512-iIarQu6MiCjEEp8dOzmBvCSlRITPFTinFB2oNKAjU6xhx8d7eUcjNOKhBGQTvuCriZrxrEvDaEEY9NfrPQ6uYQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.4.4': + resolution: {integrity: sha512-ZEN2mq7lIjQWJ8NTt1umtr6oT/Kb89856BOmESLSvgSHbIwOFYs7cSfSRH5bfiVw6dXTQAVbZA/wLgCHKrebJA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.29.13': + resolution: {integrity: sha512-vdotx+l3Q+89PeyXMgKEGnZ/CwzwMtuMi/ddgD9/5tKZ08DfDGB2Npz9m2oXPHRCjc4Ro6ifMqFlRyzIvgOjhg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@1.25.1': + resolution: {integrity: sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.52.1': + resolution: {integrity: sha512-MBYh+WcPPsN8YpRHRmK1Hsca9pVlyyKd4BxOC4SsgHACnl/bPp4Cri9hWhVm5+2tiQ9Zf4qSc1Jshw9tOLGWQA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.25.1': + resolution: {integrity: sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-node@0.52.1': + resolution: {integrity: sha512-uEG+gtEr6eKd8CVWeKMhH2olcCHM9dEK68pe0qE0be32BcCRsvYURhHaD1Srngh1SQcnQzZ4TP324euxqtBOJA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.25.1': + resolution: {integrity: sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@1.25.1': + resolution: {integrity: sha512-nMcjFIKxnFqoez4gUmihdBrbpsEnAX/Xj16sGvZm+guceYE0NE00vLhpDVK6f3q8Q4VFI5xG8JjlXKMB/SkTTQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.25.1': + resolution: {integrity: sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.40.1': + resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} '@protobufjs/eventemitter@1.1.1': resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} @@ -842,6 +1362,9 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@speed-highlight/core@1.2.17': resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} @@ -851,24 +1374,86 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aws-lambda@8.10.122': + resolution: {integrity: sha512-vBkIh9AY22kVOCEKo5CJlyCgmSWvasC+SWUxL/x/vOwRobMpI/HG1xp/Ae3AqmSiZeLUbOhW0FCD3ZjqqUxmXw==} + + '@types/bunyan@1.8.9': + resolution: {integrity: sha512-ZqS9JGpBxVOvsawzmVt30sP++gSQMTejCkIAQ3VdadOcRE8izTyW66hufvwLeH+YEGP6Js2AW7Gz+RMyvrEbmw==} + + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.36': + resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/mysql@2.15.22': + resolution: {integrity: sha512-wK1pzsJVVAjYCSZWQoWHziQZbNggXFDUEIGf54g4ZM/ERuP86uGdWeKZWMYlqTPMZfHJJvLPyogXGvCOg87yLQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/pg-pool@2.0.4': + resolution: {integrity: sha512-qZAvkv1K3QbmHHFYSNRYPkRjOWRLBYrL4B9c+wG0GSVGBw0NtJwPcgx/DSddeDJvRGMHCEQ4VMEVfuJ/0gZ3XQ==} + + '@types/pg@8.6.1': + resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} + + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/shimmer@1.2.0': + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -898,15 +1483,95 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} + ai@6.0.208: + resolution: {integrity: sha512-STz+AaZqJ4ZjH7UkpXkbHx+bjgIDOsE8fIUoZjkZ2whoZcfVmG9K/TqEKouJZ03SuZuD7lagntlU3zBhAEkRpQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 - base64-js@1.5.1: + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} bignumber.js@9.3.1: @@ -915,9 +1580,28 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -925,17 +1609,77 @@ packages: cjs-module-lexer@1.2.3: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -945,42 +1689,152 @@ packages: supports-color: optional: true + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dotprompt@1.1.2: + resolution: {integrity: sha512-24EU+eORQbPywBicIP44BiqykzEXFwZq1ZQKO5TEr9KrrENyDA7I1NzqhtmmEdQVfAXka0DEbSLPN5nerCqJ8A==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventid@2.0.1: + resolution: {integrity: sha512-sPNTqiMokAvV048P2c9+foqVJzk49o6d4e0D/sq5jog3pw+4kBgyR0gaM1FM7Mx6Kzd9dztesh9oYz1LWWOpzw==} + engines: {node: '>=10'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + farmhash-modern@1.1.0: + resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} + engines: {node: '>=18.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.9.3: + resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==} + hasBin: true + + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -990,43 +1844,239 @@ packages: picomatch: optional: true + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + firebase-admin@14.0.0: + resolution: {integrity: sha512-U88/r6VWiBQ05+UlLaF1A1AN4Y3SAGQKcQWawzafEAnXVaCZ21+2KclMPdlIQAAF5pUtN+FkXCSQnJEpc6QDZA==} + engines: {node: '>=22'} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@2.5.6: + resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} + engines: {node: '>= 0.12'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + gaxios@7.1.5: resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} engines: {node: '>=18'} + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + gcp-metadata@8.1.2: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + genkit@1.37.0: + resolution: {integrity: sha512-CkQc8Pf9XYbg13O8RCY6bXxxGypTUNL+A/nm5iLfB/IDni1P5lS8cATFdtvgs0OeCgIr9mD69O5J5EK6Ndiziw==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + google-auth-library@10.7.0: resolution: {integrity: sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==} engines: {node: '>=18'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-gax@4.6.1: + resolution: {integrity: sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==} + engines: {node: '>=14'} + + google-gax@5.0.7: + resolution: {integrity: sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA==} + engines: {node: '>=18'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + googleapis-common@7.2.0: + resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} + engines: {node: '>=14.0.0'} + + googleapis@137.1.0: + resolution: {integrity: sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==} + engines: {node: '>=14.0.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unsafe@1.0.1: + resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + + jsep@1.4.0: + resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} + engines: {node: '>= 10.16.0'} + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -1034,9 +2084,33 @@ packages: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonpath-plus@10.4.0: + resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} + engines: {node: '>=18.0.0'} + hasBin: true + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@4.1.0: + resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -1044,6 +2118,29 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + langsmith@0.7.10: + resolution: {integrity: sha512-3EjJx9zGMzqF60eT9JADHF+Hn/T5ayTgEVp4d3M5yvJIJi3q6seX0p5jT8ecBCWBi1kIvvssWrcDxfwgSier7Q==} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + lefthook-darwin-arm64@2.1.9: resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} cpu: [arm64] @@ -1172,15 +2269,93 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.mapvalues@4.6.0: + resolution: {integrity: sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-memoizer@3.0.0: + resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - miniflare@4.20260611.0: - resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} - engines: {node: '>=22.0.0'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} hasBin: true miniflare@4.20260617.1: @@ -1188,29 +2363,88 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + nanoid@3.3.14: resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} - openai@6.42.0: - resolution: {integrity: sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + openai@6.44.0: + resolution: {integrity: sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA==} peerDependencies: ws: ^8.18.0 zod: ^3.25 || ^4.0 @@ -1220,16 +2454,71 @@ packages: zod: optional: true + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + + path-expression-matcher@1.6.0: + resolution: {integrity: sha512-e5y7RCLHKjemsgQ4eqGJtPyr10ILz25HO7flzxhTV8bgvd5yHx98DGtCAtbVW9f2TqnYI/gEVZd+vz7snrdPTw==} + engines: {node: '>=14.0.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1241,13 +2530,92 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - protobufjs@7.6.4: - resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} - engines: {node: '>=12.0.0'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + proto3-json-serializer@2.0.2: + resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} + engines: {node: '>=14.0.0'} + + proto3-json-serializer@3.0.4: + resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} + engines: {node: '>=18'} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pumpify@2.0.1: + resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + retry-request@7.0.2: + resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} + engines: {node: '>=14'} + + retry-request@8.0.3: + resolution: {integrity: sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ==} + engines: {node: '>=18'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} @@ -1257,35 +2625,151 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} hasBin: true + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width-cjs@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + aliasOf: string-width + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi-cjs@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + aliasOf: strip-ansi + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + teeny-request@10.1.3: + resolution: {integrity: sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug==} + engines: {node: '>=18'} + + teeny-request@9.0.0: + resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} + engines: {node: '>=14'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1301,24 +2785,43 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.24.8: - resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} - engines: {node: '>=20.18.1'} - undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -1326,6 +2829,42 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-templates@0.2.0: + resolution: {integrity: sha512-EWkjYEN0L6KOfEoOH6Wj4ghQqU7eBZMJqRHQnxQAq+dSEzRPClkWjf8557HkWQXF6BrAUoLSAyy9i3RVTliaNg==} + + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1415,31 +2954,46 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + websocket-driver@0.7.5: + resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - workerd@1.20260611.1: - resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} - engines: {node: '>=16'} - hasBin: true + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} workerd@1.20260617.1: resolution: {integrity: sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==} engines: {node: '>=16'} hasBin: true - wrangler@4.100.0: - resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} - engines: {node: '>=22.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20260611.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - wrangler@4.103.0: resolution: {integrity: sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==} engines: {node: '>=22.0.0'} @@ -1450,6 +3004,22 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi-cjs@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + aliasOf: wrap-ansi + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.1: resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} @@ -1474,123 +3044,193 @@ packages: utf-8-validate: optional: true + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: - '@anthropic-ai/sdk@0.104.1(zod@3.25.76)': + '@ai-sdk/anthropic@3.0.85(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + zod: 4.4.3 + + '@ai-sdk/gateway@3.0.133(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/google@3.0.83(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + zod: 4.4.3 + + '@ai-sdk/openai@3.0.74(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + zod: 4.4.3 + + '@ai-sdk/provider-utils@4.0.30(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + + '@anthropic-ai/sdk@0.103.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 - zod: 3.25.76 + zod: 4.4.3 + + '@anthropic-ai/sdk@0.105.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + zod: 4.4.3 '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.5.0': + '@biomejs/biome@2.5.1': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.0 - '@biomejs/cli-darwin-x64': 2.5.0 - '@biomejs/cli-linux-arm64': 2.5.0 - '@biomejs/cli-linux-arm64-musl': 2.5.0 - '@biomejs/cli-linux-x64': 2.5.0 - '@biomejs/cli-linux-x64-musl': 2.5.0 - '@biomejs/cli-win32-arm64': 2.5.0 - '@biomejs/cli-win32-x64': 2.5.0 + '@biomejs/cli-darwin-arm64': 2.5.1 + '@biomejs/cli-darwin-x64': 2.5.1 + '@biomejs/cli-linux-arm64': 2.5.1 + '@biomejs/cli-linux-arm64-musl': 2.5.1 + '@biomejs/cli-linux-x64': 2.5.1 + '@biomejs/cli-linux-x64-musl': 2.5.1 + '@biomejs/cli-win32-arm64': 2.5.1 + '@biomejs/cli-win32-x64': 2.5.1 - '@biomejs/cli-darwin-arm64@2.5.0': + '@biomejs/cli-darwin-arm64@2.5.1': optional: true - '@biomejs/cli-darwin-x64@2.5.0': + '@biomejs/cli-darwin-x64@2.5.1': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.0': + '@biomejs/cli-linux-arm64-musl@2.5.1': optional: true - '@biomejs/cli-linux-arm64@2.5.0': + '@biomejs/cli-linux-arm64@2.5.1': optional: true - '@biomejs/cli-linux-x64-musl@2.5.0': + '@biomejs/cli-linux-x64-musl@2.5.1': optional: true - '@biomejs/cli-linux-x64@2.5.0': + '@biomejs/cli-linux-x64@2.5.1': optional: true - '@biomejs/cli-win32-arm64@2.5.0': + '@biomejs/cli-win32-arm64@2.5.1': optional: true - '@biomejs/cli-win32-x64@2.5.0': + '@biomejs/cli-win32-x64@2.5.1': optional: true - '@cloudflare/kv-asset-handler@0.5.0': {} + '@cfworker/json-schema@4.1.1': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': - dependencies: - unenv: 2.0.0-rc.24 - workerd: 1.20260611.1 + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1)': dependencies: unenv: 2.0.0-rc.24 workerd: 1.20260617.1 - '@cloudflare/vitest-pool-workers@0.16.18(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)))': + '@cloudflare/vitest-pool-workers@0.16.18(@cloudflare/workers-types@4.20260623.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)))': dependencies: '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 cjs-module-lexer: 1.2.3 esbuild: 0.28.1 miniflare: 4.20260617.1 - vitest: 4.1.9(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) - wrangler: 4.103.0 + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) + wrangler: 4.103.0(@cloudflare/workers-types@4.20260623.1) zod: 3.25.76 transitivePeerDependencies: - - '@cloudflare/workers-types' - bufferutil - utf-8-validate - '@cloudflare/workerd-darwin-64@1.20260611.1': - optional: true - '@cloudflare/workerd-darwin-64@1.20260617.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260611.1': - optional: true - '@cloudflare/workerd-darwin-arm64@1.20260617.1': optional: true - '@cloudflare/workerd-linux-64@1.20260611.1': - optional: true - '@cloudflare/workerd-linux-64@1.20260617.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260611.1': - optional: true - '@cloudflare/workerd-linux-arm64@1.20260617.1': optional: true - '@cloudflare/workerd-windows-64@1.20260611.1': - optional: true - '@cloudflare/workerd-windows-64@1.20260617.1': optional: true - '@cloudflare/workers-types@4.20260615.1': {} + '@cloudflare/workers-types@4.20260623.1': {} + + '@colors/colors@1.6.0': + optional: true '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + optional: true + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1612,176 +3252,433 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.3': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.3': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.3': - optional: true - '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.3': - optional: true - '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.3': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.3': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.3': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.3': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.3': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.3': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.3': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.3': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.3': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.3': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.3': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.3': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/win32-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@fastify/busboy@3.2.0': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@firebase/app-check-interop-types@0.3.4': optional: true - '@esbuild/sunos-x64@0.27.3': + '@firebase/app-types@0.9.5': + dependencies: + '@firebase/logger': 0.5.1 optional: true - '@esbuild/sunos-x64@0.28.1': + '@firebase/auth-interop-types@0.2.5': optional: true - '@esbuild/win32-arm64@0.27.3': + '@firebase/component@0.7.3': + dependencies: + '@firebase/util': 1.15.1 + tslib: 2.8.1 optional: true - '@esbuild/win32-arm64@0.28.1': + '@firebase/database-compat@2.1.4': + dependencies: + '@firebase/component': 0.7.3 + '@firebase/database': 1.1.3 + '@firebase/database-types': 1.0.20 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 optional: true - '@esbuild/win32-ia32@0.27.3': + '@firebase/database-types@1.0.20': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 optional: true - '@esbuild/win32-ia32@0.28.1': + '@firebase/database@1.1.3': + dependencies: + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + faye-websocket: 0.11.4 + tslib: 2.8.1 optional: true - '@esbuild/win32-x64@0.27.3': + '@firebase/logger@0.5.1': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/win32-x64@0.28.1': + '@firebase/util@1.15.1': + dependencies: + tslib: 2.8.1 optional: true - '@google/genai@2.8.0': + '@genkit-ai/ai@1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0)': dependencies: - google-auth-library: 10.7.0 - p-retry: 4.6.2 - protobufjs: 7.6.4 - ws: 8.20.1 + '@genkit-ai/core': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) + '@opentelemetry/api': 1.9.1 + '@types/node': 20.19.43 + colorette: 2.0.20 + dotprompt: 1.1.2 + handlebars: 4.7.9 + json5: 2.2.3 + node-fetch: 3.3.2 + partial-json: 0.1.7 + uri-templates: 0.2.0 + uuid: 10.0.0 transitivePeerDependencies: - bufferutil + - encoding + - firebase - supports-color - utf-8-validate - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': + '@genkit-ai/core@1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/context-async-hooks': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + async-mutex: 0.5.0 + cors: 2.8.6 + dotprompt: 1.1.2 + express: 4.22.2 + get-port: 5.1.1 + json-schema: 0.4.0 + ws: 8.20.1 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + '@genkit-ai/firebase': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) + transitivePeerDependencies: + - bufferutil + - encoding + - firebase + - supports-color + - utf-8-validate + + '@genkit-ai/firebase@1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0)': + dependencies: + '@genkit-ai/google-cloud': 1.37.0(genkit@1.37.0) + '@google-cloud/firestore': 7.11.6 + firebase-admin: 14.0.0 + genkit: 1.37.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@genkit-ai/google-cloud@1.37.0(genkit@1.37.0)': + dependencies: + '@google-cloud/logging-winston': 6.0.2(winston@3.19.0) + '@google-cloud/modelarmor': 0.4.1 + '@google-cloud/opentelemetry-cloud-monitoring-exporter': 0.19.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@1.25.1(@opentelemetry/api@1.9.1)) + '@google-cloud/opentelemetry-cloud-trace-exporter': 2.4.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.1)) + '@google-cloud/opentelemetry-resource-util': 2.4.0(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1)) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/auto-instrumentations-node': 0.49.2(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.39.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + genkit: 1.37.0 + google-auth-library: 9.15.1 + node-fetch: 3.3.2 + winston: 3.19.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@genkit-ai/google-genai@1.37.0(genkit@1.37.0)': + dependencies: + genkit: 1.37.0 + google-auth-library: 9.15.1 + jsonpath-plus: 10.4.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@google-cloud/common@5.0.2': + dependencies: + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + arrify: 2.0.1 + duplexify: 4.1.3 + extend: 3.0.2 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/firestore@7.11.6': + dependencies: + '@opentelemetry/api': 1.9.1 + fast-deep-equal: 3.1.3 + functional-red-black-tree: 1.0.1 + google-gax: 4.6.1 + protobufjs: 7.6.4 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/firestore@8.6.0': + dependencies: + '@opentelemetry/api': 1.9.1 + fast-deep-equal: 3.1.3 + functional-red-black-tree: 1.0.1 + google-gax: 5.0.7 + protobufjs: 7.6.4 + transitivePeerDependencies: + - supports-color + optional: true + + '@google-cloud/logging-winston@6.0.2(winston@3.19.0)': + dependencies: + '@google-cloud/logging': 11.2.3 + google-auth-library: 10.7.0 + lodash.mapvalues: 4.6.0 + winston: 3.19.0 + winston-transport: 4.9.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/logging@11.2.3': + dependencies: + '@google-cloud/common': 5.0.2 + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + arrify: 2.0.1 + dot-prop: 6.0.1 + eventid: 2.0.1 + extend: 3.0.2 + gcp-metadata: 6.1.1 + google-auth-library: 9.15.1 + google-gax: 4.6.1 + long: 5.3.2 + on-finished: 2.4.1 + pumpify: 2.0.1 + stream-events: 1.0.5 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/modelarmor@0.4.1': + dependencies: + google-gax: 5.0.7 + transitivePeerDependencies: + - supports-color + optional: true + + '@google-cloud/opentelemetry-cloud-monitoring-exporter@0.19.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@1.25.1(@opentelemetry/api@1.9.1))': + dependencies: + '@google-cloud/opentelemetry-resource-util': 2.4.0(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1)) + '@google-cloud/precise-date': 4.0.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.1) + google-auth-library: 9.15.1 + googleapis: 137.1.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/opentelemetry-cloud-trace-exporter@2.4.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.1))': + dependencies: + '@google-cloud/opentelemetry-resource-util': 2.4.0(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1)) + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.7.15 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + google-auth-library: 9.15.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/opentelemetry-resource-util@2.4.0(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + gcp-metadata: 6.1.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/paginator@5.0.2': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + optional: true + + '@google-cloud/precise-date@4.0.0': + optional: true + + '@google-cloud/projectify@4.0.0': + optional: true + + '@google-cloud/promisify@4.0.0': + optional: true + + '@google-cloud/storage@7.21.0': + dependencies: + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + abort-controller: 3.0.0 + async-retry: 1.3.3 + duplexify: 4.1.3 + fast-xml-parser: 5.9.3 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + mime: 3.0.0 + p-limit: 3.1.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google/genai@2.9.0': + dependencies: + google-auth-library: 10.5.0 + p-retry: 4.6.2 + protobufjs: 7.6.4 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/generative-ai@0.24.1': {} + + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.3 + optional: true + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.3 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true @@ -1846,53 +3743,772 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: 4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: 6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: 7.0.0 + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@jsep-plugin/regex@1.0.4(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@langchain/anthropic@1.5.1(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': + dependencies: + '@anthropic-ai/sdk': 0.103.0(zod@4.4.3) + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + zod: 4.4.3 + + '@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 + js-tiktoken: 1.0.21 + langsmith: 0.7.10(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + mustache: 4.2.0 + p-queue: 6.6.2 + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + + '@langchain/google-genai@2.2.0(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': + dependencies: + '@google/generative-ai': 0.24.1 + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + + '@langchain/openai@1.5.2(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(ws@8.21.0)': + dependencies: + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + js-tiktoken: 1.0.21 + openai: 6.44.0(ws@8.21.0)(zod@4.4.3) + zod: 4.4.3 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nodable/entities@2.2.0': + optional: true + + '@opentelemetry/api-logs@0.52.1': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.49.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.43.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.43.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.41.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fastify': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.14.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.38.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.42.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.42.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.39.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.42.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.39.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.46.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.39.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.43.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis-4': 0.41.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.39.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.13.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.5.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.39.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.29.7(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 1.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.2.12(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.4.4(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.29.13(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@opentelemetry/context-async-hooks@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.25.1 + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + optional: true + + '@opentelemetry/exporter-trace-otlp-grpc@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + + '@opentelemetry/instrumentation-amqplib@0.41.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-aws-lambda@0.43.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-aws-xray': 1.26.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + '@types/aws-lambda': 8.10.122 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-aws-sdk@0.43.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagation-utils': 0.30.16(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-bunyan@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@types/bunyan': 1.8.9 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-cassandra-driver@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-connect@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + '@types/connect': 3.4.36 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-cucumber@0.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-dataloader@0.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-dns@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + semver: 7.8.4 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-express@0.41.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-fastify@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-fs@0.14.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-generic-pool@0.38.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-graphql@0.42.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-grpc@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-hapi@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-http@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + semver: 7.8.4 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-ioredis@0.42.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-kafkajs@0.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-knex@0.39.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-koa@0.42.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-lru-memoizer@0.39.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-memcached@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-mongodb@0.46.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-mongoose@0.41.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-mysql2@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-mysql@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + '@types/mysql': 2.15.22 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-nestjs-core@0.39.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-net@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-pg@0.43.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.1) + '@types/pg': 8.6.1 + '@types/pg-pool': 2.0.4 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-pino@0.41.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-redis-4@0.41.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-redis@0.41.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-restify@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-router@0.39.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-socket.io@0.41.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-tedious@0.13.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-undici@0.5.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation-winston@0.39.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + optional: true + + '@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.8.4 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.52.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.52.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + protobufjs: 7.6.4 + + '@opentelemetry/propagation-utils@0.30.16(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + optional: true + + '@opentelemetry/propagator-aws-xray@1.26.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@opentelemetry/propagator-b3@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.36.2': optional: true - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@opentelemetry/resource-detector-alibaba-cloud@0.29.7(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 optional: true - '@img/sharp-wasm32@0.34.5': + '@opentelemetry/resource-detector-aws@1.12.0(@opentelemetry/api@1.9.1)': dependencies: - '@emnapi/runtime': 1.11.1 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@opentelemetry/resource-detector-azure@0.2.12(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@opentelemetry/resource-detector-container@0.4.4(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 optional: true - '@img/sharp-win32-x64@0.34.5': + '@opentelemetry/resource-detector-gcp@0.29.13(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + gcp-metadata: 6.1.1 + transitivePeerDependencies: + - encoding + - supports-color optional: true - '@jridgewell/resolve-uri@3.1.2': {} + '@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 - '@jridgewell/sourcemap-codec@1.5.5': {} + '@opentelemetry/sdk-logs@0.52.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) - '@jridgewell/trace-mapping@0.3.9': + '@opentelemetry/sdk-metrics@1.25.1(@opentelemetry/api@1.9.1)': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + lodash.merge: 4.6.2 - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@opentelemetry/sdk-node@0.52.1(@opentelemetry/api@1.9.1)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.52.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.52.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.25.1 + + '@opentelemetry/sdk-trace-node@1.25.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 1.25.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) + semver: 7.8.4 + + '@opentelemetry/semantic-conventions@1.25.1': {} + + '@opentelemetry/semantic-conventions@1.28.0': + optional: true + + '@opentelemetry/semantic-conventions@1.41.1': + optional: true + + '@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.1) optional: true '@oxc-project/types@0.133.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -1978,32 +4594,120 @@ snapshots: '@sindresorhus/is@7.2.0': {} + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + optional: true + '@speed-highlight/core@1.2.17': {} '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} + '@tootallnate/once@2.0.1': + optional: true + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/aws-lambda@8.10.122': + optional: true + + '@types/bunyan@1.8.9': + dependencies: + '@types/node': 25.9.3 + optional: true + + '@types/caseless@0.12.5': + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.36': + dependencies: + '@types/node': 25.9.3 + optional: true + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.9.3 + optional: true + + '@types/long@4.0.2': + optional: true + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 25.9.3 + optional: true + + '@types/ms@2.1.0': + optional: true + + '@types/mysql@2.15.22': + dependencies: + '@types/node': 25.9.3 + optional: true + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@types/node@25.9.3': dependencies: undici-types: 7.24.6 + '@types/pg-pool@2.0.4': + dependencies: + '@types/pg': 8.6.1 + optional: true + + '@types/pg@8.6.1': + dependencies: + '@types/node': 25.9.3 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + optional: true + + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 25.9.3 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.6 + optional: true + '@types/retry@0.12.0': {} + '@types/shimmer@1.2.0': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 25.9.3 + optional: true + + '@types/tough-cookie@4.0.5': + optional: true + + '@types/triple-beam@1.3.5': + optional: true + + '@vercel/oidc@3.2.0': {} + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -2044,70 +4748,284 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + optional: true + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + agent-base@7.1.4: {} + ai@6.0.208(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 3.0.133(zod@4.4.3) + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + '@opentelemetry/api': 1.9.1 + zod: 4.4.3 + + ajv-formats@3.0.1(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: + optional: true + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: + optional: true + + anynum@1.0.1: + optional: true + + array-flatten@1.1.1: {} + + arrify@2.0.1: + optional: true + assertion-error@2.0.1: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + optional: true + + async@3.2.6: + optional: true + + asynckit@0.4.0: + optional: true + + balanced-match@1.0.2: + optional: true + base64-js@1.5.1: {} bignumber.js@9.3.1: {} blake3-wasm@2.1.5: {} + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + optional: true + buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@6.2.2: {} cjs-module-lexer@1.2.3: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + optional: true + + color-name@1.1.4: {} + + color-name@2.1.0: + optional: true + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + optional: true + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + optional: true + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + optional: true + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + optional: true + data-uri-to-buffer@4.0.1: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 + delayed-stream@1.0.0: + optional: true + + depd@2.0.0: {} + + destroy@1.2.0: {} + detect-libc@2.1.2: {} + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + optional: true + + dotprompt@1.1.2: + dependencies: + handlebars: 4.7.9 + yaml: 2.9.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + optional: true + + eastasianwidth@0.2.0: + optional: true + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: + optional: true + + enabled@2.0.0: + optional: true + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + optional: true esbuild@0.28.1: optionalDependencies: @@ -2138,32 +5056,182 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + event-target-shim@5.0.1: + optional: true + + eventemitter3@4.0.7: {} + + eventid@2.0.1: + dependencies: + uuid: 8.3.2 + optional: true + + eventsource-parser@3.1.0: {} + + expect-type@1.3.0: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + + extend@3.0.2: {} + + farmhash-modern@1.1.0: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.2: {} - expect-type@1.3.0: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.6.0 + xml-naming: 0.1.0 + optional: true - extend@3.0.2: {} + fast-xml-parser@5.9.3: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + is-unsafe: 1.0.1 + path-expression-matcher: 1.6.0 + strnum: 2.4.1 + xml-naming: 0.1.0 + optional: true - fast-sha256@1.3.0: {} + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.5 + optional: true fdir@6.5.0(picomatch@4.0.4): dependencies: picomatch: 4.0.4 + fecha@4.2.3: + optional: true + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + + firebase-admin@14.0.0: + dependencies: + '@fastify/busboy': 3.2.0 + '@firebase/database-compat': 2.1.4 + '@firebase/database-types': 1.0.20 + farmhash-modern: 1.1.0 + fast-deep-equal: 3.1.3 + google-auth-library: 10.7.0 + jsonwebtoken: 9.0.3 + jwks-rsa: 4.1.0 + optionalDependencies: + '@google-cloud/firestore': 8.6.0 + '@google-cloud/storage': 7.21.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + fn.name@1.1.0: + optional: true + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + optional: true + + form-data@2.5.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + optional: true + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: {} + + fresh@0.5.2: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + functional-red-black-tree@1.0.1: + optional: true + + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + gaxios@7.1.5: dependencies: extend: 3.0.2 @@ -2172,6 +5240,15 @@ snapshots: transitivePeerDependencies: - supports-color + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gcp-metadata@8.1.2: dependencies: gaxios: 7.1.5 @@ -2180,6 +5257,62 @@ snapshots: transitivePeerDependencies: - supports-color + genkit@1.37.0: + dependencies: + '@genkit-ai/ai': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) + '@genkit-ai/core': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) + uuid: 10.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - firebase + - supports-color + - utf-8-validate + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-port@5.1.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + optional: true + + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.5 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + google-auth-library@10.7.0: dependencies: base64-js: 1.5.1 @@ -2190,10 +5323,159 @@ snapshots: jws: 4.0.1 transitivePeerDependencies: - supports-color + optional: true + + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-gax@4.6.1: + dependencies: + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.7.15 + '@types/long': 4.0.2 + abort-controller: 3.0.0 + duplexify: 4.1.3 + google-auth-library: 9.15.1 + node-fetch: 2.7.0 + object-hash: 3.0.0 + proto3-json-serializer: 2.0.2 + protobufjs: 7.6.4 + retry-request: 7.0.2 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-gax@5.0.7: + dependencies: + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.8.1 + duplexify: 4.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 3.0.4 + protobufjs: 7.6.4 + retry-request: 8.0.3 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + optional: true + + google-logging-utils@0.0.2: {} google-logging-utils@1.1.3: {} - hono@4.12.25: {} + googleapis-common@7.2.0: + dependencies: + extend: 3.0.2 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + qs: 6.15.2 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + googleapis@137.1.0: + dependencies: + google-auth-library: 9.15.1 + googleapis-common: 7.2.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gopd@1.2.0: {} + + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gtoken@8.0.0: + dependencies: + gaxios: 7.1.5 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + optional: true + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.27: {} + + html-entities@2.6.0: + optional: true + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-parser-js@0.5.10: + optional: true + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -2202,6 +5484,54 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 1.2.3 + module-details-from-path: 1.0.4 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-obj@2.0.0: + optional: true + + is-stream@2.0.1: {} + + is-unsafe@1.0.1: + optional: true + + isexe@2.0.0: + optional: true + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + optional: true + + jose@6.2.3: + optional: true + + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + + jsep@1.4.0: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -2211,12 +5541,50 @@ snapshots: '@babel/runtime': 7.29.7 ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} + + json-schema@0.4.0: {} + + json5@2.2.3: {} + + jsonpath-plus@10.4.0: + dependencies: + '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) + '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) + jsep: 1.4.0 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.4 + optional: true + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@4.1.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 6.2.3 + limiter: 1.1.5 + lru-cache: 11.5.1 + lru-memoizer: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -2224,6 +5592,16 @@ snapshots: kleur@4.1.5: {} + kuler@2.0.0: + optional: true + + langsmith@0.7.10(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): + dependencies: + '@opentelemetry/api': 1.9.1 + openai: 6.44.0(ws@8.21.0)(zod@4.4.3) + p-queue: 6.6.2 + ws: 8.21.0 + lefthook-darwin-arm64@2.1.9: optional: true @@ -2316,23 +5694,86 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + limiter@1.1.5: + optional: true + + lodash.camelcase@4.3.0: {} + + lodash.clonedeep@4.5.0: + optional: true + + lodash.includes@4.3.0: + optional: true + + lodash.isboolean@3.0.3: + optional: true + + lodash.isinteger@4.0.4: + optional: true + + lodash.isnumber@3.0.3: + optional: true + + lodash.isplainobject@4.0.6: + optional: true + + lodash.isstring@4.0.1: + optional: true + + lodash.mapvalues@4.6.0: + optional: true + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: + optional: true + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + optional: true + long@5.3.2: {} + lru-cache@10.4.3: + optional: true + + lru-cache@11.5.1: + optional: true + + lru-memoizer@3.0.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 11.5.1 + optional: true + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - miniflare@4.20260611.0: + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.24.8 - workerd: 1.20260611.1 - ws: 8.20.1 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@3.0.0: + optional: true miniflare@4.20260617.1: dependencies: @@ -2346,34 +5787,133 @@ snapshots: - bufferutil - utf-8-validate + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + optional: true + + minimist@1.2.8: {} + + minipass@7.1.3: + optional: true + + module-details-from-path@1.0.4: {} + + ms@2.0.0: {} + ms@2.1.3: {} + mustache@4.2.0: {} + nanoid@3.3.14: {} + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + node-domexception@1.0.0: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + object-assign@4.1.1: {} + + object-hash@3.0.0: + optional: true + + object-inspect@1.13.4: {} + obug@2.1.3: {} - openai@6.42.0(ws@8.21.0)(zod@3.25.76): + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optional: true + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + optional: true + + openai@6.44.0(ws@8.21.0)(zod@4.4.3): dependencies: ws: 8.21.0 - zod: 3.25.76 + zod: 4.4.3 + + p-finally@1.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + optional: true + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 retry: 0.13.1 + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + package-json-from-dist@1.0.1: + optional: true + + parseurl@1.3.3: {} + + partial-json@0.1.7: {} + + path-expression-matcher@1.6.0: + optional: true + + path-key@3.1.1: + optional: true + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + optional: true + + path-to-regexp@0.1.13: {} + path-to-regexp@6.3.0: {} pathe@2.0.3: {} + pg-int8@1.0.1: + optional: true + + pg-protocol@1.15.0: + optional: true + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + optional: true + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -2384,6 +5924,30 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: + optional: true + + postgres-bytea@1.0.1: + optional: true + + postgres-date@1.0.7: + optional: true + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + optional: true + + proto3-json-serializer@2.0.2: + dependencies: + protobufjs: 7.6.4 + optional: true + + proto3-json-serializer@3.0.4: + dependencies: + protobufjs: 7.6.4 + optional: true + protobufjs@7.6.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -2398,8 +5962,88 @@ snapshots: '@types/node': 25.9.3 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + pumpify@2.0.1: + dependencies: + duplexify: 4.1.3 + inherits: 2.0.4 + pump: 3.0.4 + optional: true + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + retry-request@7.0.2: + dependencies: + '@types/request': 2.48.13 + extend: 3.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + retry-request@8.0.3: + dependencies: + extend: 3.0.2 + teeny-request: 10.1.3 + transitivePeerDependencies: + - supports-color + optional: true + retry@0.13.1: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + optional: true + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -2423,8 +6067,38 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: + optional: true + + safer-buffer@2.1.2: {} + semver@7.8.4: {} + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -2456,10 +6130,56 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + optional: true + + shebang-regex@3.0.0: + optional: true + + shimmer@1.2.1: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: + optional: true + source-map-js@1.2.1: {} + source-map@0.6.1: {} + + stack-trace@0.0.10: + optional: true + stackback@0.0.2: {} standardwebhooks@1.0.0: @@ -2467,10 +6187,94 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + statuses@2.0.2: {} + std-env@4.1.0: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + optional: true + + stream-shift@1.0.3: + optional: true + + string-width-cjs@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + optional: true + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + optional: true + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + strip-ansi-cjs@6.0.1: + dependencies: + ansi-regex: 5.0.1 + optional: true + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + optional: true + + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + optional: true + + stubs@3.0.0: + optional: true + supports-color@10.2.2: {} + supports-preserve-symlinks-flag@1.0.0: {} + + teeny-request@10.1.3: + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + optional: true + + teeny-request@9.0.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + stream-events: 1.0.5 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + text-hex@1.0.0: + optional: true + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -2482,16 +6286,30 @@ snapshots: tinyrainbow@3.1.0: {} - ts-algebra@2.0.0: {} + toidentifier@1.0.1: {} - tslib@2.8.1: + tr46@0.0.3: {} + + triple-beam@1.4.1: optional: true + ts-algebra@2.0.0: {} + + tslib@2.8.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typescript@6.0.3: {} - undici-types@7.24.6: {} + uglify-js@3.19.3: + optional: true - undici@7.24.8: {} + undici-types@6.21.0: {} + + undici-types@7.24.6: {} undici@7.28.0: {} @@ -2499,6 +6317,27 @@ snapshots: dependencies: pathe: 2.0.3 + unpipe@1.0.0: {} + + uri-templates@0.2.0: {} + + url-template@2.0.8: + optional: true + + util-deprecate@1.0.2: + optional: true + + utils-merge@1.0.1: {} + + uuid@10.0.0: {} + + uuid@8.3.2: + optional: true + + uuid@9.0.1: {} + + vary@1.1.2: {} + vite@8.0.16(@types/node@25.9.3): dependencies: '@types/node': 25.9.3 @@ -2523,8 +6362,9 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vitest@4.1.9(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)): dependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.9.3 '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) @@ -2551,18 +6391,56 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} + + websocket-driver@0.7.5: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + optional: true + + websocket-extensions@0.1.4: + optional: true + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + optional: true + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - workerd@1.20260611.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260611.1 - '@cloudflare/workerd-darwin-arm64': 1.20260611.1 - '@cloudflare/workerd-linux-64': 1.20260611.1 - '@cloudflare/workerd-linux-arm64': 1.20260611.1 - '@cloudflare/workerd-windows-64': 1.20260611.1 + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + optional: true + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + optional: true + + wordwrap@1.0.0: {} workerd@1.20260617.1: optionalDependencies: @@ -2572,27 +6450,11 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260617.1 '@cloudflare/workerd-windows-64': 1.20260617.1 - wrangler@4.100.0(@cloudflare/workers-types@4.20260615.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) - '@cloudflare/workers-types': 4.20260615.1 - blake3-wasm: 2.1.5 - esbuild: 0.27.3 - miniflare: 4.20260611.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20260611.1 - optionalDependencies: - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - wrangler@4.103.0: + wrangler@4.103.0(@cloudflare/workers-types@4.20260623.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) + '@cloudflare/workers-types': 4.20260623.1 blake3-wasm: 2.1.5 esbuild: 0.28.1 miniflare: 4.20260617.1 @@ -2605,10 +6467,58 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi-cjs@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + optional: true + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + optional: true + + wrappy@1.0.2: + optional: true + ws@8.20.1: {} ws@8.21.0: {} + xml-naming@0.1.0: + optional: true + + xtend@4.0.2: + optional: true + + y18n@5.0.8: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: + optional: true + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -2622,4 +6532,10 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/package.json b/package.json index 6432557..96c5c4d 100644 --- a/package.json +++ b/package.json @@ -13,20 +13,31 @@ "prepare": "lefthook install" }, "devDependencies": { - "@anthropic-ai/sdk": "^0.104.1", - "@biomejs/biome": "^2.5.0", + "@ai-sdk/anthropic": "^3.0.85", + "@ai-sdk/google": "^3.0.83", + "@ai-sdk/openai": "^3.0.74", + "@anthropic-ai/sdk": "^0.105.0", + "@biomejs/biome": "^2.5.1", "@cloudflare/vitest-pool-workers": "^0.16.18", - "@cloudflare/workers-types": "^4.20260615.1", - "@google/genai": "^2.8.0", + "@cloudflare/workers-types": "^4.20260623.1", + "@genkit-ai/google-genai": "^1.37.0", + "@google/genai": "^2.9.0", + "@langchain/anthropic": "^1.5.1", + "@langchain/core": "^1.2.1", + "@langchain/google-genai": "^2.2.0", + "@langchain/openai": "^1.5.2", + "ai": "^6.0.208", + "genkit": "^1.37.0", "lefthook": "^2.1.9", - "openai": "^6.42.0", - "vitest": "^4.1.9" + "openai": "^6.44.0", + "vitest": "^4.1.9", + "zod": "^4.4.3" }, "peerDependencies": { "typescript": "^6.0.3" }, "dependencies": { - "hono": "^4.12.25", - "wrangler": "^4.100.0" + "hono": "^4.12.27", + "wrangler": "^4.103.0" } } diff --git a/test/requirements.txt b/test/requirements.txt index 3f30350..0bb3a1b 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,3 +1,12 @@ # Python deps for the separate-runner compat tests in test/sdk-compat/*.py. Not part of the Node toolchain. # Setup: python -m venv .venv && .venv/Scripts/python -m pip install -r test/requirements.txt litellm +instructor +pydantic +openai +pydantic-ai-slim[openai] +llama-index-core +llama-index-llms-openai +llama-index-llms-openai-like +llama-index-llms-anthropic +llama-index-llms-google-genai diff --git a/test/run-py.mjs b/test/run-py.mjs index 00c94b3..cb492e1 100644 --- a/test/run-py.mjs +++ b/test/run-py.mjs @@ -1,9 +1,9 @@ -// Runs every Python compat test in test/sdk-compat/ with the repo's .venv (wired into `nub run test` -// as `test:py`). Skips - non-fatal - if the venv isn't set up, so `nub run test` works without Python. -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; +import http from "node:http"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { unstable_dev } from "wrangler"; const repo = join(dirname(fileURLToPath(import.meta.url)), ".."); const py = @@ -20,10 +20,241 @@ if (!existsSync(py)) { process.exit(0); } -for (const file of readdirSync(dir) +const pyFiles = readdirSync(dir) .filter((f) => f.endsWith(".py")) - .sort()) { - console.log(`[py] ${file}`); - const r = spawnSync(py, [join(dir, file)], { stdio: "inherit" }); - if (r.status !== 0) process.exit(r.status ?? 1); + .sort(); +if (pyFiles.length === 0) process.exit(0); + +const FAKE = { + openai: "FAKE-OPENAI-KEY", + anthropic: "FAKE-ANTHROPIC-KEY", + gemini: "FAKE-GEMINI-KEY", +}; +const ADMIN_SECRET = "compat-admin-secret"; +const TOKEN = "tk-py"; +const PER_FILE_TIMEOUT_MS = 90_000; + +// --- mock upstream (mirrors test/sdk-compat/setup.ts, plus control endpoints) --- +let captured = null; + +function providerFromPath(path) { + if (path.includes("/v1beta/openai/")) return "openai"; + if ( + path.includes(":generateContent") || + path.includes(":streamGenerateContent") + ) + return "gemini"; + if (path.includes("/v1/messages")) return "anthropic"; + return "openai"; +} + +function bodyFor(provider) { + if (provider === "anthropic") + return { + id: "msg_1", + type: "message", + role: "assistant", + model: "x", + content: [{ type: "text", text: "hi" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + if (provider === "gemini") + return { + candidates: [ + { + content: { parts: [{ text: "hi" }], role: "model" }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }; + return { + id: "chatcmpl_1", + object: "chat.completion", + created: 0, + model: "x", + choices: [ + { + index: 0, + message: { role: "assistant", content: "hi" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; +} + +function writeSse(res, provider) { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + if (provider === "anthropic") { + res.write( + 'event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"x","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}\n\n', + ); + res.write( + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', + ); + res.write( + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}\n\n', + ); + res.write( + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', + ); + res.write( + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', + ); + res.write('event: message_stop\ndata: {"type":"message_stop"}\n\n'); + } else if (provider === "gemini") { + res.write( + 'data: {"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}]}\n\n', + ); + } else { + res.write( + 'data: {"id":"x","object":"chat.completion.chunk","created":0,"model":"x","choices":[{"index":0,"delta":{"role":"assistant","content":"hi"},"finish_reason":null}]}\n\n', + ); + res.write( + 'data: {"id":"x","object":"chat.completion.chunk","created":0,"model":"x","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + ); + res.write("data: [DONE]\n\n"); + } + res.end(); } + +function startMock() { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + const path = req.url ?? ""; + // control endpoints - the Python test reads/clears the capture over HTTP + if (path === "/__captured") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(captured)); + return; + } + if (path === "/__reset") { + captured = null; + res.writeHead(204); + res.end(); + return; + } + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString(); + captured = { + method: req.method ?? "", + path, + headers: req.headers, + body, + }; + const provider = providerFromPath(path); + const stream = + path.includes("streamGenerateContent") || + /[?&]alt=sse/.test(path) || + /"stream"\s*:\s*true/.test(body); + if (stream) return writeSse(res, provider); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(bodyFor(provider))); + }); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() }); + }); + }); +} + +async function seedToken(workerUrl) { + const login = await fetch(`${workerUrl}/admin/login`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password: ADMIN_SECRET }).toString(), + }); + if (login.status !== 200) + throw new Error(`admin login failed: ${login.status}`); + const cookie = (login.headers.get("set-cookie") ?? "").split(";")[0]; + const form = new URLSearchParams({ label: TOKEN, token: TOKEN }); + for (const p of ["openai", "anthropic", "gemini"]) + form.append("providers", p); + const res = await fetch(`${workerUrl}/admin/api/tokens`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + body: form.toString(), + }); + if (res.status !== 200) throw new Error(`seed token failed: ${res.status}`); +} + +const mock = await startMock(); +const worker = await unstable_dev("src/index.ts", { + config: "wrangler.toml", + local: true, + vars: { + OPENAI_API_KEY: FAKE.openai, + ANTHROPIC_API_KEY: FAKE.anthropic, + GEMINI_API_KEY: FAKE.gemini, + ADMIN_SECRET, + OPENAI_UPSTREAM: mock.url, + ANTHROPIC_UPSTREAM: mock.url, + GEMINI_UPSTREAM: mock.url, + }, + experimental: { disableExperimentalWarning: true }, +}); +// Normalize the bind address: unstable_dev can report 0.0.0.0 / :: (which Node's fetch +// tolerates but Python's HTTP clients do not, hanging on their long default timeout). +const host = + !worker.address || worker.address === "0.0.0.0" || worker.address === "::" + ? "127.0.0.1" + : worker.address; +const workerUrl = `http://${host}:${worker.port}`; +console.log(`[py] worker ${workerUrl} mock ${mock.url}`); + +let failed = 0; +try { + await seedToken(workerUrl); + const env = { + ...process.env, + PROXY_WORKER_URL: workerUrl, + PROXY_MOCK_URL: mock.url, + PROXY_TOKEN: TOKEN, + PROXY_FAKE_OPENAI: FAKE.openai, + PROXY_FAKE_ANTHROPIC: FAKE.anthropic, + PROXY_FAKE_GEMINI: FAKE.gemini, + }; + for (const file of pyFiles) { + captured = null; + console.log(`[py] ${file}`); + // Async spawn (NOT spawnSync): the mock lives in this event loop, and spawnSync would freeze + // it for the whole child run - so the worker could never reach the mock and every test would + // hang. Await exit via a Promise, with a hard timeout that kills a stuck child. + const code = await new Promise((resolve) => { + const child = spawn(py, [join(dir, file)], { stdio: "inherit", env }); + const timer = setTimeout(() => { + console.error( + `[py] ${file} TIMED OUT after ${PER_FILE_TIMEOUT_MS / 1000}s`, + ); + child.kill("SIGKILL"); + }, PER_FILE_TIMEOUT_MS); + child.on("exit", (c) => { + clearTimeout(timer); + resolve(c ?? 1); + }); + child.on("error", (e) => { + clearTimeout(timer); + console.error(`[py] ${file} spawn error: ${e.message}`); + resolve(1); + }); + }); + if (code !== 0) failed++; + } +} finally { + await worker.stop(); + mock.close(); +} + +process.exit(failed ? 1 : 0); diff --git a/test/sdk-compat/ai-sdk-anthropic.ts b/test/sdk-compat/ai-sdk-anthropic.ts new file mode 100644 index 0000000..63ddc84 --- /dev/null +++ b/test/sdk-compat/ai-sdk-anthropic.ts @@ -0,0 +1,54 @@ +import { createAnthropic } from "@ai-sdk/anthropic"; +import { generateText } from "ai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-ai-sdk-anthropic-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["anthropic"], + label: "ai-sdk-anthropic", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// @ai-sdk/anthropic appends only `/messages`, so baseURL must include /v1. +const model = () => + createAnthropic({ baseURL: `${baseURL}/v1`, apiKey: TOKEN })("claude-x"); + +describe("@ai-sdk/anthropic (Vercel AI SDK) compatibility", () => { + it("forwards generateText with x-api-key swapped to the real key and the token absent", async () => { + const r = await generateText({ + model: model(), + prompt: "hi", + maxOutputTokens: 16, + }); + expect(r.text).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toBe("/v1/messages"); + expect(cap?.headers["x-api-key"]).toBe(FAKE.anthropic); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/ai-sdk-google.ts b/test/sdk-compat/ai-sdk-google.ts new file mode 100644 index 0000000..e281eba --- /dev/null +++ b/test/sdk-compat/ai-sdk-google.ts @@ -0,0 +1,55 @@ +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { generateText } from "ai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-ai-sdk-google-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["gemini"], + label: "ai-sdk-google", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// @ai-sdk/google appends /models/:generateContent, so baseURL must include /v1beta. +// It sends the key in the x-goog-api-key header (not ?key=), so it routes to the gemini slot. +const model = () => + createGoogleGenerativeAI({ baseURL: `${baseURL}/v1beta`, apiKey: TOKEN })( + "gemini-2.5-flash", + ); + +describe("@ai-sdk/google (Vercel AI SDK) compatibility", () => { + it("forwards generateText with x-goog-api-key swapped and the token absent", async () => { + const r = await generateText({ model: model(), prompt: "hi" }); + expect(r.text).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toContain( + "/v1beta/models/gemini-2.5-flash:generateContent", + ); + expect(cap?.headers["x-goog-api-key"]).toBe(FAKE.gemini); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/ai-sdk-openai.ts b/test/sdk-compat/ai-sdk-openai.ts new file mode 100644 index 0000000..846afb5 --- /dev/null +++ b/test/sdk-compat/ai-sdk-openai.ts @@ -0,0 +1,50 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { generateText } from "ai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-ai-sdk-openai-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["openai"], + label: "ai-sdk-openai", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// `.chat()` forces Chat Completions; the bare factory would hit /v1/responses (AI SDK 5+ default). +const model = () => + createOpenAI({ baseURL: `${baseURL}/v1`, apiKey: TOKEN }).chat("gpt-x"); + +describe("@ai-sdk/openai (Vercel AI SDK) compatibility", () => { + it("forwards generateText with the real key swapped in and the token absent", async () => { + const r = await generateText({ model: model(), prompt: "hi" }); + expect(r.text).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toBe("/v1/chat/completions"); + expect(cap?.headers.authorization).toBe(`Bearer ${FAKE.openai}`); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/genkit.ts b/test/sdk-compat/genkit.ts new file mode 100644 index 0000000..d639ef5 --- /dev/null +++ b/test/sdk-compat/genkit.ts @@ -0,0 +1,56 @@ +import { googleAI } from "@genkit-ai/google-genai"; +import { genkit } from "genkit"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-genkit-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["gemini"], + label: "genkit", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// googleAI({ baseUrl }) is the bare host; the plugin builds `${baseUrl}/v1beta/models/:...` +// and sends the key in the x-goog-api-key header. +const ai = () => + genkit({ plugins: [googleAI({ apiKey: TOKEN, baseUrl: baseURL })] }); + +describe("genkit (@genkit-ai/google-genai) compatibility", () => { + it("forwards ai.generate with x-goog-api-key swapped and the token absent", async () => { + const r = await ai().generate({ + model: googleAI.model("gemini-2.5-flash"), + prompt: "hi", + }); + expect(r.text).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toContain( + "/v1beta/models/gemini-2.5-flash:generateContent", + ); + expect(cap?.headers["x-goog-api-key"]).toBe(FAKE.gemini); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/instructor.py b/test/sdk-compat/instructor.py new file mode 100644 index 0000000..529ed19 --- /dev/null +++ b/test/sdk-compat/instructor.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""instructor compatibility smoke test (thin client; the Node runner owns the worker + mock). + +instructor wraps the official `openai` client, so it hits /v1/chat/completions with Authorization: +Bearer. The mock returns plain text "hi", which can't satisfy the Pydantic response_model, so the +structured parse fails by design - but the request already forwarded, so we assert on the mock +capture (real key swapped in, token absent), not on the return value. + +Run it (also part of `nub run test`): nub run test:py +""" + +import json +import logging +import os +import sys +import urllib.request + +# This file is named after its package, so its own dir (sys.path[0]) would shadow +# `import instructor`; drop it before importing the package. +sys.path.pop(0) + +import instructor +import openai +from instructor import Mode +from pydantic import BaseModel + +# The mock's plain "hi" can't satisfy the schema, so instructor logs a retry-exhausted warning. +# That failure is expected and asserted-around below; mute the log so the test output stays clean. +logging.getLogger("instructor").setLevel(logging.CRITICAL) + +W = os.environ["PROXY_WORKER_URL"] +M = os.environ["PROXY_MOCK_URL"] +TOKEN = os.environ["PROXY_TOKEN"] +REAL = os.environ["PROXY_FAKE_OPENAI"] + + +class Hello(BaseModel): + greeting: str + + +def captured(): + with urllib.request.urlopen(f"{M}/__captured") as r: + return json.load(r) + + +def main(): + client = instructor.from_openai( + openai.OpenAI(base_url=f"{W}/v1", api_key=TOKEN), mode=Mode.TOOLS + ) + try: + client.create( + model="gpt-4o-mini", + response_model=Hello, + max_retries=0, + messages=[{"role": "user", "content": "say hi"}], + ) + except Exception: + # Expected: the mock's plain "hi" can't be coerced into Hello. The forward already happened. + pass + + cap = captured() + assert cap["path"] == "/v1/chat/completions", cap["path"] + assert cap["headers"].get("authorization") == f"Bearer {REAL}", cap["headers"].get("authorization") + assert TOKEN not in json.dumps(cap["headers"]), "proxy token leaked into upstream headers" + print("PASS: instructor -> proxy swapped the real key, token never egressed") + + +if __name__ == "__main__": + main() diff --git a/test/sdk-compat/langchain-anthropic.ts b/test/sdk-compat/langchain-anthropic.ts new file mode 100644 index 0000000..7bd14ac --- /dev/null +++ b/test/sdk-compat/langchain-anthropic.ts @@ -0,0 +1,55 @@ +import { ChatAnthropic } from "@langchain/anthropic"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-langchain-anthropic-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["anthropic"], + label: "langchain-anthropic", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// anthropicApiUrl is the bare host; the @anthropic-ai/sdk underneath appends /v1/messages. +const client = () => + new ChatAnthropic({ + model: "claude-x", + apiKey: TOKEN, + anthropicApiUrl: baseURL, + maxTokens: 16, + maxRetries: 0, + }); + +describe("@langchain/anthropic (ChatAnthropic) compatibility", () => { + it("forwards invoke() with x-api-key swapped to the real key and the token absent", async () => { + const r = await client().invoke("hi"); + expect(String(r.content)).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toBe("/v1/messages"); + expect(cap?.headers["x-api-key"]).toBe(FAKE.anthropic); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/langchain-google-genai.ts b/test/sdk-compat/langchain-google-genai.ts new file mode 100644 index 0000000..15a4cb1 --- /dev/null +++ b/test/sdk-compat/langchain-google-genai.ts @@ -0,0 +1,54 @@ +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-langchain-google-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["gemini"], + label: "langchain-google-genai", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// baseUrl is the bare host; the SDK builds `${baseUrl}/v1beta/models/:generateContent` +// and sends the key in the x-goog-api-key header. +const client = () => + new ChatGoogleGenerativeAI({ + model: "gemini-x", + apiKey: TOKEN, + baseUrl: baseURL, + }); + +describe("@langchain/google-genai (ChatGoogleGenerativeAI) compatibility", () => { + it("forwards invoke() with x-goog-api-key swapped and the token absent", async () => { + const r = await client().invoke("hi"); + expect(String(r.content)).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toContain("/v1beta/models/gemini-x:generateContent"); + expect(cap?.headers["x-goog-api-key"]).toBe(FAKE.gemini); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/langchain-openai.ts b/test/sdk-compat/langchain-openai.ts new file mode 100644 index 0000000..7727047 --- /dev/null +++ b/test/sdk-compat/langchain-openai.ts @@ -0,0 +1,55 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-langchain-openai-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["openai"], + label: "langchain-openai", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// `configuration` is passed straight to the underlying `openai` SDK; a plain model name +// (not gpt-5.x-pro) with no tools stays on /v1/chat/completions, not /v1/responses. +const client = () => + new ChatOpenAI({ + model: "gpt-x", + apiKey: TOKEN, + configuration: { baseURL: `${baseURL}/v1` }, + maxRetries: 0, + }); + +describe("@langchain/openai (ChatOpenAI) compatibility", () => { + it("forwards invoke() with the real key swapped in and the token absent", async () => { + const r = await client().invoke("hi"); + expect(String(r.content)).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toBe("/v1/chat/completions"); + expect(cap?.headers.authorization).toBe(`Bearer ${FAKE.openai}`); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/litellm.py b/test/sdk-compat/litellm.py index 2b8c8da..c8897b2 100644 --- a/test/sdk-compat/litellm.py +++ b/test/sdk-compat/litellm.py @@ -1,159 +1,54 @@ #!/usr/bin/env python3 -"""LiteLLM compatibility smoke test (separate Python runner - the Node compat tier can't host it). +"""LiteLLM compatibility smoke test (thin client; the Node runner owns the worker + mock). -Brings up the worker locally (`wrangler dev`) with its OpenAI upstream pointed at a Python mock, -seeds a proxy token via the admin API, drives LiteLLM against the worker, and asserts the mock saw -the real key swapped in and the proxy token nowhere. +`test/run-py.mjs` starts the worker and a mock upstream, seeds a proxy token, and exports +PROXY_WORKER_URL / PROXY_MOCK_URL / PROXY_TOKEN / PROXY_FAKE_OPENAI. This file just drives LiteLLM +at the worker and asserts the mock saw the real key swapped in (and the token nowhere). -One-time setup (from the repo root): - python -m venv .venv - .venv/Scripts/python -m pip install -r test/requirements.txt # *nix: .venv/bin/python -Run it (also runs as part of `nub run test`): - nub run test:py +Run it (also part of `nub run test`): nub run test:py """ -import sys - -# This file is named after the package (per the sdk-compat naming convention), so its own -# directory would shadow `import litellm`; drop the script dir from sys.path before importing it. -sys.path.pop(0) - -import http.server import json import os -import socket -import subprocess -import threading -import time -import urllib.error -import urllib.parse +import sys import urllib.request -REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -FAKE_OPENAI_KEY = "FAKE-OPENAI-KEY" -ADMIN_SECRET = "litellm-admin-secret" -TOKEN = "tk-litellm" - -captured = {} - - -class MockHandler(http.server.BaseHTTPRequestHandler): - def do_POST(self): - n = int(self.headers.get("content-length", 0)) - captured["path"] = self.path - captured["headers"] = {k.lower(): v for k, v in self.headers.items()} - captured["body"] = self.rfile.read(n).decode() if n else "" - body = json.dumps( - { - "id": "chatcmpl_1", - "object": "chat.completion", - "created": 0, - "model": "x", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "hi"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - } - ).encode() - self.send_response(200) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *_): - pass - +# This file is named after its package, so its own dir (sys.path[0]) would shadow +# `import litellm`; drop it before importing the package. +sys.path.pop(0) -def free_port(): - s = socket.socket() - s.bind(("127.0.0.1", 0)) - port = s.getsockname()[1] - s.close() - return port +import litellm +litellm.telemetry = False -def wait_ready(base, timeout=120): - deadline = time.time() + timeout - while time.time() < deadline: - try: - urllib.request.urlopen(base + "/admin", timeout=2) - return True - except urllib.error.HTTPError: - return True # any HTTP response means the worker is serving - except Exception: - time.sleep(0.7) - return False +W = os.environ["PROXY_WORKER_URL"] +M = os.environ["PROXY_MOCK_URL"] +TOKEN = os.environ["PROXY_TOKEN"] +REAL = os.environ["PROXY_FAKE_OPENAI"] -def post(url, fields, cookie=None): - headers = {"content-type": "application/x-www-form-urlencoded"} - if cookie: - headers["cookie"] = cookie - data = urllib.parse.urlencode(fields).encode() - return urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers)) +def captured(): + with urllib.request.urlopen(f"{M}/__captured") as r: + return json.load(r) def main(): - mock_port, worker_port = free_port(), free_port() - mock = http.server.HTTPServer(("127.0.0.1", mock_port), MockHandler) - threading.Thread(target=mock.serve_forever, daemon=True).start() - - env = dict(os.environ, CI="1") - cmd = [ - "npx", "wrangler", "dev", - "--port", str(worker_port), "--ip", "127.0.0.1", - "--var", f"OPENAI_API_KEY:{FAKE_OPENAI_KEY}", - "--var", "ANTHROPIC_API_KEY:FAKE-ANTHROPIC-KEY", - "--var", "GEMINI_API_KEY:FAKE-GEMINI-KEY", - "--var", f"ADMIN_SECRET:{ADMIN_SECRET}", - "--var", f"OPENAI_UPSTREAM:http://127.0.0.1:{mock_port}", - ] - worker = subprocess.Popen( - cmd, cwd=REPO, env=env, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - shell=(os.name == "nt"), + litellm.completion( + model="openai/gpt-x", + api_base=f"{W}/v1", + api_key=TOKEN, + messages=[{"role": "user", "content": "ping-from-litellm"}], + max_tokens=5, + timeout=20, + num_retries=0, ) - try: - base = f"http://127.0.0.1:{worker_port}" - if not wait_ready(base): - raise SystemExit("worker did not become ready") - - login = post(f"{base}/admin/login", {"password": ADMIN_SECRET}) - cookie = login.headers.get("set-cookie", "").split(";")[0] - post( - f"{base}/admin/api/tokens", - [("label", TOKEN), ("token", TOKEN), ("providers", "openai")], - cookie, - ) - - import litellm - - litellm.completion( - model="openai/gpt-x", - api_base=f"{base}/v1", - api_key=TOKEN, - messages=[{"role": "user", "content": "ping-from-litellm"}], - max_tokens=5, - ) - - h = captured.get("headers", {}) - assert captured.get("path") == "/v1/chat/completions", captured.get("path") - assert h.get("authorization") == f"Bearer {FAKE_OPENAI_KEY}", h.get("authorization") - assert TOKEN not in json.dumps(h), "proxy token leaked into upstream headers" - assert "ping-from-litellm" in captured.get("body", ""), "request body not forwarded" - print("PASS: litellm -> proxy swapped the real key, token never egressed, body forwarded verbatim") - finally: - worker.terminate() - try: - worker.wait(timeout=10) - except Exception: - worker.kill() - mock.shutdown() + cap = captured() + h = cap["headers"] + assert cap["path"] == "/v1/chat/completions", cap["path"] + assert h.get("authorization") == f"Bearer {REAL}", h.get("authorization") + assert TOKEN not in json.dumps(h), "proxy token leaked into upstream headers" + assert "ping-from-litellm" in cap["body"], "request body not forwarded" + print("PASS: litellm -> proxy swapped the real key, token never egressed, body forwarded verbatim") if __name__ == "__main__": diff --git a/test/sdk-compat/llama-index.py b/test/sdk-compat/llama-index.py new file mode 100644 index 0000000..34be9a9 --- /dev/null +++ b/test/sdk-compat/llama-index.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""LlamaIndex compatibility smoke test (thin client; the Node runner owns the worker + mock). + +Drives all three LlamaIndex LLM integrations (OpenAI, Anthropic, GoogleGenAI) through the worker and +asserts the mock saw the real key swapped into the right slot, with the proxy token nowhere. Each +integration just wraps the official provider SDK, so this proves LlamaIndex forwards base_url + key +through cleanly for every provider. + +Run it (also part of `nub run test`): nub run test:py +""" + +import json +import os +import urllib.request + +from google.genai import types +from llama_index.core.llms import ChatMessage +from llama_index.llms.anthropic import Anthropic +from llama_index.llms.google_genai import GoogleGenAI +from llama_index.llms.openai import OpenAI + +W = os.environ["PROXY_WORKER_URL"] +M = os.environ["PROXY_MOCK_URL"] +TOKEN = os.environ["PROXY_TOKEN"] + + +def captured(): + with urllib.request.urlopen(f"{M}/__captured") as r: + return json.load(r) + + +def reset(): + urllib.request.urlopen(f"{M}/__reset").read() + + +def msg(): + return [ChatMessage(role="user", content="hi")] + + +def main(): + # Real model ids are required: the OpenAI/Anthropic classes look up the context window from + # the model name and raise on an unknown id (the request still goes to the worker regardless). + + # 1) OpenAI -> Authorization: Bearer -> /v1/chat/completions + reset() + OpenAI(api_base=f"{W}/v1", api_key=TOKEN, model="gpt-4o").chat(msg()) + cap = captured() + assert cap["path"] == "/v1/chat/completions", cap["path"] + assert cap["headers"].get("authorization") == f"Bearer {os.environ['PROXY_FAKE_OPENAI']}" + assert TOKEN not in json.dumps(cap["headers"]), "token leaked (openai)" + + # 2) Anthropic -> x-api-key -> /v1/messages (base_url is the bare host) + reset() + Anthropic( + base_url=W, api_key=TOKEN, model="claude-3-5-sonnet-20241022", max_tokens=16 + ).chat(msg()) + cap = captured() + assert cap["path"] == "/v1/messages", cap["path"] + assert cap["headers"].get("x-api-key") == os.environ["PROXY_FAKE_ANTHROPIC"] + assert TOKEN not in json.dumps(cap["headers"]), "token leaked (anthropic)" + + # 3) GoogleGenAI -> x-goog-api-key -> /v1beta/models/:generateContent + # Pass max_tokens AND context_window so __init__ skips a live models.get() validation call. + reset() + GoogleGenAI( + api_key=TOKEN, + model="gemini-2.5-flash", + max_tokens=16, + context_window=1000000, + http_options=types.HttpOptions(base_url=W), + ).chat(msg()) + cap = captured() + assert "/v1beta/models/gemini-2.5-flash:generateContent" in cap["path"], cap["path"] + assert cap["headers"].get("x-goog-api-key") == os.environ["PROXY_FAKE_GEMINI"] + assert TOKEN not in json.dumps(cap["headers"]), "token leaked (gemini)" + + print("PASS: llama-index (openai + anthropic + google-genai) -> proxy swapped each real key, token never egressed") + + +if __name__ == "__main__": + main() diff --git a/test/sdk-compat/pydantic-ai.py b/test/sdk-compat/pydantic-ai.py new file mode 100644 index 0000000..67ca09c --- /dev/null +++ b/test/sdk-compat/pydantic-ai.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Pydantic AI compatibility smoke test (thin client; the Node runner owns the worker + mock). + +Pydantic AI's OpenAIChatModel wraps the official `openai` client, so it hits /v1/chat/completions +with Authorization: Bearer. We assert the mock saw the real key swapped in and the token nowhere. +(OpenAIChatModel = Chat Completions; OpenAIResponsesModel would hit /v1/responses, which we avoid.) + +Run it (also part of `nub run test`): nub run test:py +""" + +import json +import os +import urllib.request + +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +W = os.environ["PROXY_WORKER_URL"] +M = os.environ["PROXY_MOCK_URL"] +TOKEN = os.environ["PROXY_TOKEN"] +REAL = os.environ["PROXY_FAKE_OPENAI"] + + +def captured(): + with urllib.request.urlopen(f"{M}/__captured") as r: + return json.load(r) + + +def main(): + model = OpenAIChatModel( + "gpt-4o", provider=OpenAIProvider(base_url=f"{W}/v1", api_key=TOKEN) + ) + result = Agent(model).run_sync("hi") + assert "hi" in str(result.output), result.output + + cap = captured() + assert cap["path"] == "/v1/chat/completions", cap["path"] + assert cap["headers"].get("authorization") == f"Bearer {REAL}", cap["headers"].get("authorization") + assert TOKEN not in json.dumps(cap["headers"]), "proxy token leaked into upstream headers" + print("PASS: pydantic-ai -> proxy swapped the real key, token never egressed") + + +if __name__ == "__main__": + main() From c2c334e37938ee11711c8726d6745241fa881871 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 14:21:19 +0530 Subject: [PATCH 18/22] test: address review - pin python deps, harden py harness - Pin test/requirements.txt with ~= (lock minor): these libs shift default endpoints across minors, which the compat tests encode. Drop unused llama-index-llms-openai-like. - run-py.mjs: match setup.ts providerFromPath (/v1beta/ fallback) so the two mocks do not drift; strip real provider keys (OPENAI/ANTHROPIC/GEMINI/GOOGLE) from the child env so the seeded proxy token is the only key in play. --- test/requirements.txt | 21 +++++++++++---------- test/run-py.mjs | 13 ++++++++++++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/test/requirements.txt b/test/requirements.txt index 0bb3a1b..7097a2b 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,12 +1,13 @@ # Python deps for the separate-runner compat tests in test/sdk-compat/*.py. Not part of the Node toolchain. +# Pinned with ~= to lock the minor version: these libs shift default endpoints / base-URL handling +# across minors, which is exactly what the compat tests encode. Bump deliberately, then re-run. # Setup: python -m venv .venv && .venv/Scripts/python -m pip install -r test/requirements.txt -litellm -instructor -pydantic -openai -pydantic-ai-slim[openai] -llama-index-core -llama-index-llms-openai -llama-index-llms-openai-like -llama-index-llms-anthropic -llama-index-llms-google-genai +litellm~=1.83.7 +instructor~=1.15.3 +pydantic~=2.12.5 +openai~=2.30.0 +pydantic-ai-slim[openai]~=1.107.0 +llama-index-core~=0.14.22 +llama-index-llms-openai~=0.7.9 +llama-index-llms-anthropic~=0.11.6 +llama-index-llms-google-genai~=0.9.5 diff --git a/test/run-py.mjs b/test/run-py.mjs index cb492e1..e250afb 100644 --- a/test/run-py.mjs +++ b/test/run-py.mjs @@ -41,7 +41,8 @@ function providerFromPath(path) { if (path.includes("/v1beta/openai/")) return "openai"; if ( path.includes(":generateContent") || - path.includes(":streamGenerateContent") + path.includes(":streamGenerateContent") || + path.startsWith("/v1beta/") ) return "gemini"; if (path.includes("/v1/messages")) return "anthropic"; @@ -226,6 +227,16 @@ try { PROXY_FAKE_ANTHROPIC: FAKE.anthropic, PROXY_FAKE_GEMINI: FAKE.gemini, }; + // The seeded proxy token is the ONLY key a client should use; strip any real provider key from + // the child env so a client that reads a key from the environment can't bypass the token path. + for (const k of [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", + ]) + delete env[k]; for (const file of pyFiles) { captured = null; console.log(`[py] ${file}`); From 48bc5d072c2f02a4ce2691ada23cdefb8d270090 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 14:55:37 +0530 Subject: [PATCH 19/22] docs: use uv for the Python test venv setup (replaces pip) uv venv + uv pip install -r is the fast drop-in for python -m venv + pip: uv pip install auto-targets the .venv, no activation needed. The runner's .venv/Scripts/python path is unchanged (uv creates a standard venv). Verified: recreated .venv with uv (Python 3.14.5), test:py all green. Updated README, requirements.txt header, and the run-py.mjs skip hint. --- README.md | 6 +++--- biome.json | 2 +- test/requirements.txt | 2 +- test/run-py.mjs | 3 +-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 674dbaa..41b3fd6 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,11 @@ Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `n **What's tested, and what's by-construction.** The worker routes by *which auth slot a request uses*, not by SDK — so a provider's packages behave identically once the slot is fixed. We therefore test **each distinct library once, in one language** — the official `openai` / `@anthropic-ai/sdk` / `@google/genai` SDKs, the Vercel AI SDK, LangChain, Genkit, LiteLLM, LlamaIndex, instructor, and Pydantic AI (see `test/sdk-compat/`) — and treat the rest as compatible-by-construction: a tested SDK's other-language packages (`openai-python`/`-go`/`-java`/...), end-user apps (Aider, Cline, Continue, Open WebUI), and JVM/.NET frameworks (Spring AI, Semantic Kernel) each reuse a slot already proven. The per-provider proof matrix is in [docs/learnings/compat-is-the-auth-slot-not-the-sdk.md](docs/learnings/compat-is-the-auth-slot-not-the-sdk.md). Two gotchas: use Anthropic's normal API-key mode (its OAuth `authToken` mode sends `Bearer`, which would route to OpenAI), and the legacy `google-generativeai` Python SDK needs `transport="rest"` (it defaults to gRPC and won't traverse an HTTP proxy otherwise). -The Python runner uses a local venv (one-time setup): +The Python runner uses a local venv. One-time setup with [uv](https://docs.astral.sh/uv/): ```bash -python -m venv .venv -.venv/Scripts/python -m pip install -r test/requirements.txt # *nix: .venv/bin/python +uv venv +uv pip install -r test/requirements.txt ``` > **Gemini is untested with the actual API.** No test hits a live provider — all three run against a mock upstream. OpenAI and Anthropic are additionally verified live in deployment; Gemini is **not**, because `GEMINI_API_KEY` isn't set yet, so the Gemini route has never run against the real Google Generative Language API. Treat it as built-but-unproven until a key is added. diff --git a/biome.json b/biome.json index fd151d6..190f5b6 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/test/requirements.txt b/test/requirements.txt index 7097a2b..268f055 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,7 +1,7 @@ # Python deps for the separate-runner compat tests in test/sdk-compat/*.py. Not part of the Node toolchain. # Pinned with ~= to lock the minor version: these libs shift default endpoints / base-URL handling # across minors, which is exactly what the compat tests encode. Bump deliberately, then re-run. -# Setup: python -m venv .venv && .venv/Scripts/python -m pip install -r test/requirements.txt +# Setup (uv): uv venv && uv pip install -r test/requirements.txt litellm~=1.83.7 instructor~=1.15.3 pydantic~=2.12.5 diff --git a/test/run-py.mjs b/test/run-py.mjs index e250afb..e2d449a 100644 --- a/test/run-py.mjs +++ b/test/run-py.mjs @@ -14,8 +14,7 @@ const dir = join(repo, "test", "sdk-compat"); if (!existsSync(py)) { console.log( - "[py] .venv not found - skipping. Setup: python -m venv .venv && " + - ".venv/Scripts/python -m pip install -r test/requirements.txt", + "[py] .venv not found - skipping. Setup: uv venv && uv pip install -r test/requirements.txt", ); process.exit(0); } From 01b8c6ff942effa62b8d1c9588d08925d93eea2f Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Tue, 23 Jun 2026 15:06:41 +0530 Subject: [PATCH 20/22] chore(test): bump python compat deps to latest litellm 1.83.7->1.89.3, openai 2.30.0->2.43.0, pydantic 2.12.5->2.13.4 (others already latest). Recreated .venv with uv, full suite green: 72 unit + 16 compat + 4 python. --- test/requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/requirements.txt b/test/requirements.txt index 268f055..f4f713f 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -2,10 +2,10 @@ # Pinned with ~= to lock the minor version: these libs shift default endpoints / base-URL handling # across minors, which is exactly what the compat tests encode. Bump deliberately, then re-run. # Setup (uv): uv venv && uv pip install -r test/requirements.txt -litellm~=1.83.7 +litellm~=1.89.3 instructor~=1.15.3 -pydantic~=2.12.5 -openai~=2.30.0 +pydantic~=2.13.4 +openai~=2.43.0 pydantic-ai-slim[openai]~=1.107.0 llama-index-core~=0.14.22 llama-index-llms-openai~=0.7.9 From dcfb71c2a7ae6a4f520b3a57914ba080060e895e Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Fri, 3 Jul 2026 15:58:55 +0530 Subject: [PATCH 21/22] feat: proxy WebSocket (wss) upgrades with the same token swap A WebSocket upgrade now rides the proxy: src/ws.ts validates the token (hash -> KV -> scope -> rate limit) exactly like the HTTP path, swaps the real key into the slot the provider's wss API expects, opens the upstream socket with fetch(Upgrade: websocket), and pumps frames both ways through a WebSocketPair. - Auth slots: Authorization Bearer (OpenAI /v1/realtime + /v1/responses WebSocket mode), Sec-WebSocket-Protocol openai-insecure-api-key. (browser Realtime - rewritten to a Bearer header upstream, browsers can't set headers), and ?key= (Gemini Live). Anthropic has no wss API. - Reuses the OpenAI geo-403 fallback: a blocked upgrade retries through the NA-pinned egress DO. - Manual pipe (not pass-through) so the negotiated subprotocol echoes back deterministically; binaryType pinned to arraybuffer so binary realtime audio survives future compatibility_date bumps. - Tests: tier-1 test/ws.test.ts (slot extraction, swap, security invariant, rate limit, geo-403, 502, close-code sanitization) and tier-2 test/sdk-compat/websocket.ts (real ws client -> worker -> ws mock upstream round-trip). New dev deps: ws, @types/ws. UNTESTED AGAINST REAL APIS: all WebSocket coverage runs against a mock ws upstream only. No test has connected to a live OpenAI Realtime / Responses or Gemini Live endpoint (no valid provider keys available), and the geo-blocked WS-over-DO hop is exercised with a faked DO only. Treat the wss path as built-but-unproven live. Also rides along (pre-staged housekeeping): dev-dep minor bumps and the llama-index compat test's Claude model id. --- README.md | 19 +- docs/learnings/README.md | 1 + docs/learnings/websocket-proxy-auth-slots.md | 41 +++ lock.yaml | 369 ++++++++++--------- package.json | 28 +- src/index.ts | 8 +- src/proxy.ts | 4 +- src/ws.ts | 218 +++++++++++ test/sdk-compat/llama-index.py | 2 +- test/sdk-compat/websocket.ts | 135 +++++++ test/ws.test.ts | 363 ++++++++++++++++++ 11 files changed, 991 insertions(+), 197 deletions(-) create mode 100644 docs/learnings/websocket-proxy-auth-slots.md create mode 100644 src/ws.ts create mode 100644 test/sdk-compat/websocket.ts create mode 100644 test/ws.test.ts diff --git a/README.md b/README.md index 41b3fd6..9fb10ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # api-proxy -A single Cloudflare Worker that reverse-proxies the OpenAI, Anthropic, and Google Gemini APIs behind **revocable proxy tokens**. You issue tokens from an admin dashboard and hand them out; each token is validated server-side and swapped for the real provider key before the request is forwarded. Consumers never see your real keys, and you can scope or revoke any token at any time. +A single Cloudflare Worker that reverse-proxies the OpenAI, Anthropic, and Google Gemini APIs — over **HTTP and WebSocket** — behind **revocable proxy tokens**. You issue tokens from an admin dashboard and hand them out; each token is validated server-side and swapped for the real provider key before the request is forwarded. Consumers never see your real keys, and you can scope or revoke any token at any time. ## Use it @@ -31,6 +31,19 @@ curl https:///v1/chat/completions \ Browser apps work too — the worker answers the CORS preflight and reflects the request Origin (provider browser opt-ins still apply, e.g. Anthropic's `dangerouslyAllowBrowser`). +### WebSocket / realtime + +Realtime sockets proxy the same way — point the WebSocket at the worker and use a proxy token. The worker swaps the token for the real key on the upgrade handshake. + +| WebSocket API | URL | token slot | +|---|---|---| +| OpenAI Realtime (server) | `wss:///v1/realtime?model=…` | `Authorization: Bearer ` | +| OpenAI Realtime (browser) | `wss:///v1/realtime?model=…` | `Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.` | +| OpenAI Responses (WebSocket mode) | `wss:///v1/responses` | `Authorization: Bearer ` | +| Gemini Live | `wss:///ws/…BidiGenerateContent?key=` | `?key=` query | + +A browser can't set the `Authorization` header on a WebSocket, so OpenAI smuggles the key in the `openai-insecure-api-key.` subprotocol — the worker reads it there and re-presents it as a Bearer header upstream. Anthropic has no WebSocket API. A long-lived socket is rate-limited and validated **once at connect**, so a revoke applies to the next connection, not an open stream. + ## How it works The proxy token rides in the SDK's normal auth slot. The worker validates it, checks it's scoped to the requested provider, strips every inbound auth header, sets the one real key, and forwards the request (path + query verbatim, streaming included). Routing is by which auth header the token arrives in — see [docs/architecture.md](docs/architecture.md) for the routing table and full design. @@ -76,12 +89,12 @@ Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens: ```bash nub run test:unit # tier 1: proxy logic in workerd (vitest-pool-workers), fast CI gate -nub run test:compat # tier 2: real client libs (official SDKs, Vercel AI SDK, LangChain, Genkit) + raw fetch vs a mock upstream +nub run test:compat # tier 2: real client libs (official SDKs, Vercel AI SDK, LangChain, Genkit) + raw fetch + a real wss round-trip vs a mock upstream nub run test:py # tier 2 (Python): LiteLLM, LlamaIndex, instructor, Pydantic AI through the worker (needs the venv below) nub run test # all of the above ``` -Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). The Python runner (`test/run-py.mjs`) owns the same worker + mock and runs each `*.py` as a thin client. **Each file in `test/sdk-compat/` is named after the package it drives and doubles as a usage example** — copy the `baseURL`/`apiKey` wiring from the file matching your client (e.g. `ai-sdk-openai.ts`, `langchain-anthropic.ts`, `genkit.ts`, `pydantic-ai.py`), or from `fetch.ts` for raw HTTP. +Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, drives each real client, and asserts the forwarded request carries the real key (and never the token). The Python runner (`test/run-py.mjs`) owns the same worker + mock and runs each `*.py` as a thin client. **Each file in `test/sdk-compat/` is named after the package it drives and doubles as a usage example** — copy the `baseURL`/`apiKey` wiring from the file matching your client (e.g. `ai-sdk-openai.ts`, `langchain-anthropic.ts`, `genkit.ts`, `pydantic-ai.py`), from `fetch.ts` for raw HTTP, or from `websocket.ts` for a wss client. **What's tested, and what's by-construction.** The worker routes by *which auth slot a request uses*, not by SDK — so a provider's packages behave identically once the slot is fixed. We therefore test **each distinct library once, in one language** — the official `openai` / `@anthropic-ai/sdk` / `@google/genai` SDKs, the Vercel AI SDK, LangChain, Genkit, LiteLLM, LlamaIndex, instructor, and Pydantic AI (see `test/sdk-compat/`) — and treat the rest as compatible-by-construction: a tested SDK's other-language packages (`openai-python`/`-go`/`-java`/...), end-user apps (Aider, Cline, Continue, Open WebUI), and JVM/.NET frameworks (Spring AI, Semantic Kernel) each reuse a slot already proven. The per-provider proof matrix is in [docs/learnings/compat-is-the-auth-slot-not-the-sdk.md](docs/learnings/compat-is-the-auth-slot-not-the-sdk.md). Two gotchas: use Anthropic's normal API-key mode (its OAuth `authToken` mode sends `Bearer`, which would route to OpenAI), and the legacy `google-generativeai` Python SDK needs `transport="rest"` (it defaults to gRPC and won't traverse an HTTP proxy otherwise). diff --git a/docs/learnings/README.md b/docs/learnings/README.md index 379e04b..af443e4 100644 --- a/docs/learnings/README.md +++ b/docs/learnings/README.md @@ -15,3 +15,4 @@ Each file: the problem, what we found, and the decision we keep. - [rate-limit-binding-free-and-loose.md](rate-limit-binding-free-and-loose.md) - the Workers Rate Limiting binding is free on the Free plan but a loose, per-colo ceiling - [cors-preflight-and-upload-passthrough.md](cors-preflight-and-upload-passthrough.md) - why the browser preflight is answered before auth, and why the Gemini upload URL is passed through untouched - [compat-is-the-auth-slot-not-the-sdk.md](compat-is-the-auth-slot-not-the-sdk.md) - why one test per auth slot proves every SDK/language/wrapper, so we don't add per-client tests +- [websocket-proxy-auth-slots.md](websocket-proxy-auth-slots.md) - proxying wss (OpenAI Realtime/Responses, Gemini Live), and why a browser smuggles the key in a subprotocol the worker must rewrite diff --git a/docs/learnings/websocket-proxy-auth-slots.md b/docs/learnings/websocket-proxy-auth-slots.md new file mode 100644 index 0000000..c4488a2 --- /dev/null +++ b/docs/learnings/websocket-proxy-auth-slots.md @@ -0,0 +1,41 @@ +# WebSocket proxying, and the wider auth-slot set + +## Problem + +The proxy only forwarded HTTP. A user's realtime client hit `wss://api.openai.com/v1/responses` directly with a raw key (a dead one → `invalid_api_key`), bypassing the proxy entirely because the proxy couldn't carry a WebSocket. We wanted the same token-swap for wss, for any provider that has a wss API, not just OpenAI. + +## What we found + +**The endpoint in the bug is real.** `wss://api.openai.com/v1/responses` is OpenAI's *Responses API WebSocket Mode* — distinct from the Realtime API (`/v1/realtime`). It authenticates **only** via the `Authorization: Bearer` header. So a proxy that handled only the Realtime subprotocol would pass the token straight through on `/v1/responses`. Both endpoints share `api.openai.com`, routed on path. + +**A plain Worker is enough — no new Durable Object.** Cloudflare Workers open an upstream socket with `fetch(url, { headers: { Upgrade: "websocket" } })` and read `resp.webSocket`; the scheme stays `http(s):`, the header drives the upgrade (a `ws://`/`wss://` URL is *not* how you do it in a Worker). Inbound, `new WebSocketPair()` + `return new Response(null, { status: 101, webSocket: client })`. Works on the Free plan; idle sockets don't burn CPU time. (A first research pass wrongly concluded a DO was required — it had reached for the Node `new WebSocket()` constructor, which doesn't exist in Workers.) + +**Manual pipe over transparent pass-through.** You *can* return the upstream's 101 response directly and let CF pipe both ends, but whether it faithfully echoes the negotiated `Sec-WebSocket-Protocol` back to the client is undocumented (and a browser handshake fails if the server doesn't pick one of the client's offered subprotocols). So we accept both ends in JS (`WebSocketPair` + frame pump) and set the echoed subprotocol explicitly. wrangler ≥ those old dev-WS echo bugs (workers-sdk #1767, fixed by PR #1930) — we're on 4.x, fine. + +**The WS auth-slot set is wider than HTTP**, because a browser `WebSocket` cannot set request headers: + +| Inbound slot | Provider | Why | +|---|---|---| +| `Authorization: Bearer ` | openai | server-side Realtime + all Responses-mode | +| `Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.` | openai | browser Realtime — the only slot a browser can use | +| `?key=` query | gemini | Gemini Live (`…BidiGenerateContent`) reads the key in the query | + +Anthropic has no wss API (Messages is SSE-over-HTTP only) — naturally excluded. (Other providers surveyed — Deepgram, AssemblyAI, ElevenLabs, Azure — add yet more slots: `Authorization: Token`, `?token=`, `xi-api-key`, `Sec-WebSocket-Protocol: token, `. We don't proxy those, but the pattern holds: a wss proxy must read credentials from query params and subprotocols, not just headers.) + +## The decision we keep + +`src/ws.ts` mirrors the HTTP path (validate hash → scope → rate-limit) then `prepareWsUpstream` strips every inbound auth slot and sets exactly one upstream — the WS analogue of `swapAuth`: + +- OpenAI: real key as `Authorization: Bearer` (even when the client smuggled it in the subprotocol — the worker *can* set headers; we drop the `openai-insecure-api-key.*` entry and keep `realtime` + org/project/beta so negotiation still picks `realtime`). +- Gemini: real key in `?key=` (not a header — that's where Live reads it). + +The proxy token therefore never reaches the upstream in any slot (header, query, or subprotocol); a test asserts it. The OpenAI geo-403 fallback is reused as-is: a 403 from a bad colo re-issues the upgrade through the `UsEgress` DO, which carries a WebSocket exactly like a plain `fetch`. + +## Caveats + +- **Rate limit and token validation are per-connection, not per-frame.** One upgrade = one limiter hit; a revoke takes effect on the next connection, not on an open stream. For an immediate cutoff, rotate the provider secret. +- **Idle timeout.** Cloudflare closes a socket after a quiet period in both directions; a silent client should keep-alive (realtime audio/text traffic keeps it warm on its own). +- **The geo-blocked WS-over-DO hop is not locally testable.** The trigger logic is unit-tested with a faked DO; the live DO-carries-a-WebSocket hop reuses HTTP's (proven-in-prod) egress path but isn't separately exercised. Treat it as built-but-unproven for wss until a geo-blocked colo hits it live. +- **Live realtime is untested end-to-end.** Tests prove the upgrade, swap, and bidirectional frames against a mock `ws` upstream; no test connects to a real OpenAI/Gemini realtime endpoint. + +Sources: [CF Workers WebSockets](https://developers.cloudflare.com/workers/runtime-apis/websockets/), [Using the WebSockets API](https://developers.cloudflare.com/workers/examples/websockets/), [OpenAI Realtime (WebSocket)](https://developers.openai.com/api/docs/guides/realtime-websocket), [OpenAI Responses WebSocket Mode](https://developers.openai.com/api/docs/guides/websocket-mode), [Gemini Live API](https://ai.google.dev/gemini-api/docs/live-api/get-started-websocket). diff --git a/lock.yaml b/lock.yaml index a48732b..49705f6 100644 --- a/lock.yaml +++ b/lock.yaml @@ -15,102 +15,108 @@ importers: specifier: ^6.0.3 version: 6.0.3 wrangler: - specifier: ^4.103.0 - version: 4.103.0(@cloudflare/workers-types@4.20260623.1) + specifier: ^4.105.0 + version: 4.105.0(@cloudflare/workers-types@4.20260627.1) devDependencies: '@ai-sdk/anthropic': - specifier: ^3.0.85 - version: 3.0.85(zod@4.4.3) + specifier: ^4.0.0 + version: 4.0.0(zod@4.4.3) '@ai-sdk/google': - specifier: ^3.0.83 - version: 3.0.83(zod@4.4.3) + specifier: ^4.0.1 + version: 4.0.1(zod@4.4.3) '@ai-sdk/openai': - specifier: ^3.0.74 - version: 3.0.74(zod@4.4.3) + specifier: ^4.0.1 + version: 4.0.1(zod@4.4.3) '@anthropic-ai/sdk': - specifier: ^0.105.0 - version: 0.105.0(zod@4.4.3) + specifier: ^0.106.0 + version: 0.106.0(zod@4.4.3) '@biomejs/biome': specifier: ^2.5.1 version: 2.5.1 '@cloudflare/vitest-pool-workers': - specifier: ^0.16.18 - version: 0.16.18(@cloudflare/workers-types@4.20260623.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3))) + specifier: ^0.16.20 + version: 0.16.20(@cloudflare/workers-types@4.20260627.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3))) '@cloudflare/workers-types': - specifier: ^4.20260623.1 - version: 4.20260623.1 + specifier: ^4.20260627.1 + version: 4.20260627.1 '@genkit-ai/google-genai': - specifier: ^1.37.0 - version: 1.37.0(genkit@1.37.0) + specifier: ^1.39.0 + version: 1.39.0(genkit@1.39.0) '@google/genai': - specifier: ^2.9.0 - version: 2.9.0 + specifier: ^2.10.0 + version: 2.10.0 '@langchain/anthropic': specifier: ^1.5.1 - version: 1.5.1(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + version: 1.5.1(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) '@langchain/core': specifier: ^1.2.1 - version: 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + version: 1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) '@langchain/google-genai': specifier: ^2.2.0 - version: 2.2.0(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + version: 2.2.0(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) '@langchain/openai': - specifier: ^1.5.2 - version: 1.5.2(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(ws@8.21.0) + specifier: ^1.5.3 + version: 1.5.3(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(ws@8.21.0) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 ai: - specifier: ^6.0.208 - version: 6.0.208(zod@4.4.3) + specifier: ^7.0.3 + version: 7.0.3(zod@4.4.3) genkit: - specifier: ^1.37.0 - version: 1.37.0 + specifier: ^1.39.0 + version: 1.39.0 lefthook: specifier: ^2.1.9 version: 2.1.9 openai: - specifier: ^6.44.0 - version: 6.44.0(ws@8.21.0)(zod@4.4.3) + specifier: ^6.45.0 + version: 6.45.0(ws@8.21.0)(zod@4.4.3) vitest: specifier: ^4.1.9 version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) + ws: + specifier: ^8.21.0 + version: 8.21.0 zod: specifier: ^4.4.3 version: 4.4.3 packages: - '@ai-sdk/anthropic@3.0.85': - resolution: {integrity: sha512-fNeDB644l5wbRNQU0FnI+F7UTtOenMnPtACfMPUJaS2zJfuBlseEa1TMg+otHkETZgaJB+6Na51NQEv0+m7czw==} - engines: {node: '>=18'} + '@ai-sdk/anthropic@4.0.0': + resolution: {integrity: sha512-N0lT1g6/5DEIZvalpkpwYRCdu7n5qb8qPN3PcTem6k4VkPBLC2+T2LAAyx1GS0eNOxavVa0CP7n2kCiye0yyfw==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.133': - resolution: {integrity: sha512-Ebs+7iS9zUgJu5B0RlxM2JmDWzq79Cpd6YdiqcCzB5qFdpfQJPUDiXutqlQP89F2XGjOdDeidulBTXUdXWzOxw==} - engines: {node: '>=18'} + '@ai-sdk/gateway@4.0.3': + resolution: {integrity: sha512-V8p0skqA9I5ZvZ4JDrvdh+TrZ0OVvkoP4CPEMxy777yBcVO5febhtLUJ+t0V0ROvSIkWIm5R9nvyOsyZRtO9sQ==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google@3.0.83': - resolution: {integrity: sha512-Pz7aCX0dy+5x+r4K/37HbLZNaPtPL4q2NduzJW64VffLv5sI9Nb478wAd7PlH2r2asiypJsz/Jerf9draTciUA==} - engines: {node: '>=18'} + '@ai-sdk/google@4.0.1': + resolution: {integrity: sha512-FZAliYDNgpe96H8fh8ccW2PrPYMf7fRoT/oySfRVdkqQUAO+DI5jIouYbF1D9xl1+qGb6s0zaRZKD8Ig+zDO4g==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.74': - resolution: {integrity: sha512-LPDBWd2WCv0GQs29K2pHcNrGx24hm4D8QEP386HwUAUPr1URho6bNVXHNmIv0FxaW+xDkLpNMTen+mFCUBp2LA==} - engines: {node: '>=18'} + '@ai-sdk/openai@4.0.1': + resolution: {integrity: sha512-SZ036CbBdQcf8Q8EoGKU7kYBJpX+KPChGXUO8IPXDKMYvo/CDjBpPLWOm4DMa++s9skxuxTZtmNLqTeaY2GjTg==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@4.0.30': - resolution: {integrity: sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw==} - engines: {node: '>=18'} + '@ai-sdk/provider-utils@5.0.0': + resolution: {integrity: sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@3.0.10': - resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} - engines: {node: '>=18'} + '@ai-sdk/provider@4.0.0': + resolution: {integrity: sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==} + engines: {node: '>=22'} '@anthropic-ai/sdk@0.103.0': resolution: {integrity: sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==} @@ -121,8 +127,8 @@ packages: zod: optional: true - '@anthropic-ai/sdk@0.105.0': - resolution: {integrity: sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==} + '@anthropic-ai/sdk@0.106.0': + resolution: {integrity: sha512-ufwVvYNDBj2dzOGupBCTaNzBLxqcTnGOzI4z8Wouxlt+mT3J3HuOmatgCy1VmwCHOUueqZ41ERhm0O99OUcbWA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -203,45 +209,45 @@ packages: workerd: optional: true - '@cloudflare/vitest-pool-workers@0.16.18': - resolution: {integrity: sha512-TEktXyevK9lkTWouElbIcDPK3YEfV+Szqgnlq5sNk+KYZR3LiDdYDaGNmUYgiT2LiiFeGU2yzCrcgmN8mJhqWQ==} + '@cloudflare/vitest-pool-workers@0.16.20': + resolution: {integrity: sha512-buw0YgsAMT7s60wcmyxbtciEJjMJzKcWzayDMPhWaqMqfQzW+0WPLV67Lobn4C80nkNQhYocEJPnrEhLWnOf+A==} peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 vitest: ^4.1.0 - '@cloudflare/workerd-darwin-64@1.20260617.1': - resolution: {integrity: sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==} + '@cloudflare/workerd-darwin-64@1.20260625.1': + resolution: {integrity: sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260617.1': - resolution: {integrity: sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==} + '@cloudflare/workerd-darwin-arm64@1.20260625.1': + resolution: {integrity: sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260617.1': - resolution: {integrity: sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==} + '@cloudflare/workerd-linux-64@1.20260625.1': + resolution: {integrity: sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260617.1': - resolution: {integrity: sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==} + '@cloudflare/workerd-linux-arm64@1.20260625.1': + resolution: {integrity: sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260617.1': - resolution: {integrity: sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==} + '@cloudflare/workerd-windows-64@1.20260625.1': + resolution: {integrity: sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260623.1': - resolution: {integrity: sha512-J/0POl0HeLepbwDE5Yx5c7jQrHFkvCEFu3TS+TQsDDlg/vTs5og7wdGP6eNGXOAntgWUrjcvvKTmVLTP7OrnAg==} + '@cloudflare/workers-types@4.20260627.1': + resolution: {integrity: sha512-QhVvier1eW9Ydw96N/Ez79i5CSGy7h39JucrUejQmAWQw9qBdlB1aOTjaD93EGYrV0t9U81YWetI3LcaxxILNw==} '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} @@ -457,11 +463,11 @@ packages: resolution: {integrity: sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==} engines: {node: '>=20.0.0'} - '@genkit-ai/ai@1.37.0': - resolution: {integrity: sha512-SQnis1/NJeaGOiFnstmCJ7iGxVj/lwsQpwCXgG+JQrspAvGEIpSHCVBkreUpw6Dy7r0mVCHKup7L1NqBJbvCZg==} + '@genkit-ai/ai@1.39.0': + resolution: {integrity: sha512-e1ZI2ib4Y8ha2Zj/rN7Uioxd16rWMhalPe3krypLw8B4G36X8KZCqj2UhucKbCZJIffU0cxEvag7BP+JVQnsyQ==} - '@genkit-ai/core@1.37.0': - resolution: {integrity: sha512-Bq/HTwRhFoWjn4bbN0gnegsO/0dSYRUtbkWj/xwYfTycSCzVTqQJt3eNJXL6Dd5qSlGa3sWjYTsksiLqfroDzg==} + '@genkit-ai/core@1.39.0': + resolution: {integrity: sha512-MI0cpmwc4K/KPVsXFAcHviOybt3T3IsVSsN37cEcOzo7mmv+s0JSdooxlQgZn68B6e3I8BNLL3Vk1vyc6qIlCw==} '@genkit-ai/firebase@1.37.0': resolution: {integrity: sha512-yutXazOCGqGeauiShCaPj9fjcS3nn3FUTj7/0nbHXx8mLVhddBEwBIH96MqVGz9pqt3Fl57TCROPguLOmIZaQQ==} @@ -479,10 +485,10 @@ packages: peerDependencies: genkit: ^1.37.0 - '@genkit-ai/google-genai@1.37.0': - resolution: {integrity: sha512-T22JHU9YuZL7FxxohAYZ4YBWL2M7SKDGW0AoBEciaT59uIIGuWC8bliaAbS9zxj2cQnvEG3hKQRIBOd9wh6z+Q==} + '@genkit-ai/google-genai@1.39.0': + resolution: {integrity: sha512-ZP9nA6TlBJ1ZHCmSkz56v0lrRS/Wfh6L09oHgeOIZyD/Pq76uX3W+Uly8dPAbDtNax63l64TsEYZH1HHt8rC8A==} peerDependencies: - genkit: ^1.37.0 + genkit: ^1.39.0 '@google-cloud/common@5.0.2': resolution: {integrity: sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==} @@ -554,8 +560,8 @@ packages: resolution: {integrity: sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==} engines: {node: '>=14'} - '@google/genai@2.9.0': - resolution: {integrity: sha512-3DRdSJ0LaKFig3FNGeRDn9BQxtjZm2qr0hNH2d771LKcG0HvUYddlN0LPdSp8XU7Ekb04i9q3+tt64uYqavUdw==} + '@google/genai@2.10.0': + resolution: {integrity: sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -776,8 +782,8 @@ packages: peerDependencies: '@langchain/core': ^1.2.0 - '@langchain/openai@1.5.2': - resolution: {integrity: sha512-En/QzXO3YFuaaZWQiGx0ZBNJMK3ipL/tz8F/PReG/63oV3wk2nz906QA8drYnd8r2/3NtSkbf3x/8qms5o6qTg==} + '@langchain/openai@1.5.3': + resolution: {integrity: sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==} engines: {node: '>=20'} peerDependencies: '@langchain/core': ^1.2.1 @@ -1450,6 +1456,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} @@ -1483,6 +1492,9 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1509,9 +1521,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ai@6.0.208: - resolution: {integrity: sha512-STz+AaZqJ4ZjH7UkpXkbHx+bjgIDOsE8fIUoZjkZ2whoZcfVmG9K/TqEKouJZ03SuZuD7lagntlU3zBhAEkRpQ==} - engines: {node: '>=18'} + ai@7.0.3: + resolution: {integrity: sha512-ze2nTtoNW1hYhkjAVUVoLQGC/vDmNv+nIo3n8aCv2QQS8WCuC8o3CF6ywnUlui/i7lfGU4V8aPClVRH62WUHeA==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -1909,8 +1921,8 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - genkit@1.37.0: - resolution: {integrity: sha512-CkQc8Pf9XYbg13O8RCY6bXxxGypTUNL+A/nm5iLfB/IDni1P5lS8cATFdtvgs0OeCgIr9mD69O5J5EK6Ndiziw==} + genkit@1.39.0: + resolution: {integrity: sha512-Qz9ef/LfAEDMzO1+Nd6l6dMYK/V4ZfWDIp79rz/1V6xDM5VuJMkoqOTCDwXAviuAnjoO2noJug2wIK81ilMhWA==} get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} @@ -2358,8 +2370,8 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - miniflare@4.20260617.1: - resolution: {integrity: sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==} + miniflare@4.20260625.0: + resolution: {integrity: sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==} engines: {node: '>=22.0.0'} hasBin: true @@ -2443,12 +2455,21 @@ packages: one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} - openai@6.44.0: - resolution: {integrity: sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA==} + openai@6.45.0: + resolution: {integrity: sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==} peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' ws: ^8.18.0 zod: ^3.25 || ^4.0 peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true ws: optional: true zod: @@ -2989,17 +3010,17 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - workerd@1.20260617.1: - resolution: {integrity: sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==} + workerd@1.20260625.1: + resolution: {integrity: sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==} engines: {node: '>=16'} hasBin: true - wrangler@4.103.0: - resolution: {integrity: sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==} + wrangler@4.105.0: + resolution: {integrity: sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260617.1 + '@cloudflare/workers-types': ^4.20260625.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -3020,18 +3041,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -3092,39 +3101,40 @@ packages: snapshots: - '@ai-sdk/anthropic@3.0.85(zod@4.4.3)': + '@ai-sdk/anthropic@4.0.0(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + '@ai-sdk/provider': 4.0.0 + '@ai-sdk/provider-utils': 5.0.0(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/gateway@3.0.133(zod@4.4.3)': + '@ai-sdk/gateway@4.0.3(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + '@ai-sdk/provider': 4.0.0 + '@ai-sdk/provider-utils': 5.0.0(zod@4.4.3) '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/google@3.0.83(zod@4.4.3)': + '@ai-sdk/google@4.0.1(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + '@ai-sdk/provider': 4.0.0 + '@ai-sdk/provider-utils': 5.0.0(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/openai@3.0.74(zod@4.4.3)': + '@ai-sdk/openai@4.0.1(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + '@ai-sdk/provider': 4.0.0 + '@ai-sdk/provider-utils': 5.0.0(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/provider-utils@4.0.30(zod@4.4.3)': + '@ai-sdk/provider-utils@5.0.0(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider': 4.0.0 '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 eventsource-parser: 3.1.0 zod: 4.4.3 - '@ai-sdk/provider@3.0.10': + '@ai-sdk/provider@4.0.0': dependencies: json-schema: 0.4.0 @@ -3134,7 +3144,7 @@ snapshots: standardwebhooks: 1.0.0 zod: 4.4.3 - '@anthropic-ai/sdk@0.105.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.106.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -3181,41 +3191,41 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1)': dependencies: unenv: 2.0.0-rc.24 - workerd: 1.20260617.1 + workerd: 1.20260625.1 - '@cloudflare/vitest-pool-workers@0.16.18(@cloudflare/workers-types@4.20260623.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)))': + '@cloudflare/vitest-pool-workers@0.16.20(@cloudflare/workers-types@4.20260627.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)))': dependencies: '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 cjs-module-lexer: 1.2.3 esbuild: 0.28.1 - miniflare: 4.20260617.1 + miniflare: 4.20260625.0 vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) - wrangler: 4.103.0(@cloudflare/workers-types@4.20260623.1) + wrangler: 4.105.0(@cloudflare/workers-types@4.20260627.1) zod: 3.25.76 transitivePeerDependencies: - bufferutil - utf-8-validate - '@cloudflare/workerd-darwin-64@1.20260617.1': + '@cloudflare/workerd-darwin-64@1.20260625.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260617.1': + '@cloudflare/workerd-darwin-arm64@1.20260625.1': optional: true - '@cloudflare/workerd-linux-64@1.20260617.1': + '@cloudflare/workerd-linux-64@1.20260625.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260617.1': + '@cloudflare/workerd-linux-arm64@1.20260625.1': optional: true - '@cloudflare/workerd-windows-64@1.20260617.1': + '@cloudflare/workerd-windows-64@1.20260625.1': optional: true - '@cloudflare/workers-types@4.20260623.1': {} + '@cloudflare/workers-types@4.20260627.1': {} '@colors/colors@1.6.0': optional: true @@ -3387,9 +3397,9 @@ snapshots: tslib: 2.8.1 optional: true - '@genkit-ai/ai@1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0)': + '@genkit-ai/ai@1.39.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.39.0)': dependencies: - '@genkit-ai/core': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) + '@genkit-ai/core': 1.39.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.39.0) '@opentelemetry/api': 1.9.1 '@types/node': 20.19.43 colorette: 2.0.20 @@ -3407,7 +3417,7 @@ snapshots: - supports-color - utf-8-validate - '@genkit-ai/core@1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0)': + '@genkit-ai/core@1.39.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.39.0)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.52.1 @@ -3426,12 +3436,12 @@ snapshots: express: 4.22.2 get-port: 5.1.1 json-schema: 0.4.0 - ws: 8.20.1 + ws: 8.21.0 zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) optionalDependencies: '@cfworker/json-schema': 4.1.1 - '@genkit-ai/firebase': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) + '@genkit-ai/firebase': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.39.0) transitivePeerDependencies: - bufferutil - encoding @@ -3439,18 +3449,18 @@ snapshots: - supports-color - utf-8-validate - '@genkit-ai/firebase@1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0)': + '@genkit-ai/firebase@1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.39.0)': dependencies: - '@genkit-ai/google-cloud': 1.37.0(genkit@1.37.0) + '@genkit-ai/google-cloud': 1.37.0(genkit@1.39.0) '@google-cloud/firestore': 7.11.6 firebase-admin: 14.0.0 - genkit: 1.37.0 + genkit: 1.39.0 transitivePeerDependencies: - encoding - supports-color optional: true - '@genkit-ai/google-cloud@1.37.0(genkit@1.37.0)': + '@genkit-ai/google-cloud@1.37.0(genkit@1.39.0)': dependencies: '@google-cloud/logging-winston': 6.0.2(winston@3.19.0) '@google-cloud/modelarmor': 0.4.1 @@ -3467,7 +3477,7 @@ snapshots: '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': 0.52.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.1) - genkit: 1.37.0 + genkit: 1.39.0 google-auth-library: 9.15.1 node-fetch: 3.3.2 winston: 3.19.0 @@ -3476,9 +3486,9 @@ snapshots: - supports-color optional: true - '@genkit-ai/google-genai@1.37.0(genkit@1.37.0)': + '@genkit-ai/google-genai@1.39.0(genkit@1.39.0)': dependencies: - genkit: 1.37.0 + genkit: 1.39.0 google-auth-library: 9.15.1 jsonpath-plus: 10.4.0 transitivePeerDependencies: @@ -3643,12 +3653,12 @@ snapshots: - supports-color optional: true - '@google/genai@2.9.0': + '@google/genai@2.10.0': dependencies: google-auth-library: 10.5.0 p-retry: 4.6.2 protobufjs: 7.6.4 - ws: 8.20.1 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - supports-color @@ -3801,18 +3811,18 @@ snapshots: dependencies: jsep: 1.4.0 - '@langchain/anthropic@1.5.1(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': + '@langchain/anthropic@1.5.1(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': dependencies: '@anthropic-ai/sdk': 0.103.0(zod@4.4.3) - '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) zod: 4.4.3 - '@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': + '@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.7.10(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + langsmith: 0.7.10(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) mustache: 4.2.0 p-queue: 6.6.2 zod: 4.4.3 @@ -3820,17 +3830,21 @@ snapshots: - '@opentelemetry/exporter-trace-otlp-proto' - '@opentelemetry/sdk-trace-base' - '@langchain/google-genai@2.2.0(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': + '@langchain/google-genai@2.2.0(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': dependencies: '@google/generative-ai': 0.24.1 - '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) - '@langchain/openai@1.5.2(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(ws@8.21.0)': + '@langchain/openai@1.5.3(@langchain/core@1.2.1)(@opentelemetry/api@1.9.1)(ws@8.21.0)': dependencies: - '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) js-tiktoken: 1.0.21 - openai: 6.44.0(ws@8.21.0)(zod@4.4.3) + openai: 6.45.0(ws@8.21.0)(zod@4.4.3) zod: 4.4.3 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: @@ -4706,6 +4720,10 @@ snapshots: '@types/triple-beam@1.3.5': optional: true + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.43 + '@vercel/oidc@3.2.0': {} '@vitest/expect@4.1.9': @@ -4748,6 +4766,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@workflow/serde@4.1.0': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -4773,12 +4793,11 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.208(zod@4.4.3): + ai@7.0.3(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 3.0.133(zod@4.4.3) - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) - '@opentelemetry/api': 1.9.1 + '@ai-sdk/gateway': 4.0.3(zod@4.4.3) + '@ai-sdk/provider': 4.0.0 + '@ai-sdk/provider-utils': 5.0.0(zod@4.4.3) zod: 4.4.3 ajv-formats@3.0.1(ajv@8.20.0): @@ -5257,10 +5276,10 @@ snapshots: transitivePeerDependencies: - supports-color - genkit@1.37.0: + genkit@1.39.0: dependencies: - '@genkit-ai/ai': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) - '@genkit-ai/core': 1.37.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.37.0) + '@genkit-ai/ai': 1.39.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.39.0) + '@genkit-ai/core': 1.39.0(@google-cloud/firestore@7.11.6)(firebase-admin@14.0.0)(genkit@1.39.0) uuid: 10.0.0 transitivePeerDependencies: - bufferutil @@ -5595,10 +5614,10 @@ snapshots: kuler@2.0.0: optional: true - langsmith@0.7.10(@opentelemetry/api@1.9.1)(openai@6.44.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): + langsmith@0.7.10(@opentelemetry/api@1.9.1)(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): dependencies: '@opentelemetry/api': 1.9.1 - openai: 6.44.0(ws@8.21.0)(zod@4.4.3) + openai: 6.45.0(ws@8.21.0)(zod@4.4.3) p-queue: 6.6.2 ws: 8.21.0 @@ -5775,12 +5794,12 @@ snapshots: mime@3.0.0: optional: true - miniflare@4.20260617.1: + miniflare@4.20260625.0: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 undici: 7.28.0 - workerd: 1.20260617.1 + workerd: 1.20260625.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: @@ -5846,7 +5865,7 @@ snapshots: fn.name: 1.1.0 optional: true - openai@6.44.0(ws@8.21.0)(zod@4.4.3): + openai@6.45.0(ws@8.21.0)(zod@4.4.3): dependencies: ws: 8.21.0 zod: 4.4.3 @@ -6442,25 +6461,25 @@ snapshots: wordwrap@1.0.0: {} - workerd@1.20260617.1: + workerd@1.20260625.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260617.1 - '@cloudflare/workerd-darwin-arm64': 1.20260617.1 - '@cloudflare/workerd-linux-64': 1.20260617.1 - '@cloudflare/workerd-linux-arm64': 1.20260617.1 - '@cloudflare/workerd-windows-64': 1.20260617.1 + '@cloudflare/workerd-darwin-64': 1.20260625.1 + '@cloudflare/workerd-darwin-arm64': 1.20260625.1 + '@cloudflare/workerd-linux-64': 1.20260625.1 + '@cloudflare/workerd-linux-arm64': 1.20260625.1 + '@cloudflare/workerd-windows-64': 1.20260625.1 - wrangler@4.103.0(@cloudflare/workers-types@4.20260623.1): + wrangler@4.105.0(@cloudflare/workers-types@4.20260627.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) - '@cloudflare/workers-types': 4.20260623.1 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1) + '@cloudflare/workers-types': 4.20260627.1 blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 4.20260617.1 + miniflare: 4.20260625.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260617.1 + workerd: 1.20260625.1 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: @@ -6490,8 +6509,6 @@ snapshots: wrappy@1.0.2: optional: true - ws@8.20.1: {} - ws@8.21.0: {} xml-naming@0.1.0: diff --git a/package.json b/package.json index 96c5c4d..c8fb101 100644 --- a/package.json +++ b/package.json @@ -13,24 +13,26 @@ "prepare": "lefthook install" }, "devDependencies": { - "@ai-sdk/anthropic": "^3.0.85", - "@ai-sdk/google": "^3.0.83", - "@ai-sdk/openai": "^3.0.74", - "@anthropic-ai/sdk": "^0.105.0", + "@ai-sdk/anthropic": "^4.0.0", + "@ai-sdk/google": "^4.0.1", + "@ai-sdk/openai": "^4.0.1", + "@anthropic-ai/sdk": "^0.106.0", "@biomejs/biome": "^2.5.1", - "@cloudflare/vitest-pool-workers": "^0.16.18", - "@cloudflare/workers-types": "^4.20260623.1", - "@genkit-ai/google-genai": "^1.37.0", - "@google/genai": "^2.9.0", + "@cloudflare/vitest-pool-workers": "^0.16.20", + "@cloudflare/workers-types": "^4.20260627.1", + "@genkit-ai/google-genai": "^1.39.0", + "@google/genai": "^2.10.0", "@langchain/anthropic": "^1.5.1", "@langchain/core": "^1.2.1", "@langchain/google-genai": "^2.2.0", - "@langchain/openai": "^1.5.2", - "ai": "^6.0.208", - "genkit": "^1.37.0", + "@langchain/openai": "^1.5.3", + "@types/ws": "^8.18.1", + "ai": "^7.0.3", + "genkit": "^1.39.0", "lefthook": "^2.1.9", - "openai": "^6.44.0", + "openai": "^6.45.0", "vitest": "^4.1.9", + "ws": "^8.21.0", "zod": "^4.4.3" }, "peerDependencies": { @@ -38,6 +40,6 @@ }, "dependencies": { "hono": "^4.12.27", - "wrangler": "^4.103.0" + "wrangler": "^4.105.0" } } diff --git a/src/index.ts b/src/index.ts index 0e9092a..42db63a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,21 @@ import adminApp from "./admin"; import { handleProxy } from "./proxy"; import type { Env } from "./types"; +import { handleWsProxy } from "./ws"; export { UsEgress } from "./egress"; -// Top-level dispatch: /admin/* -> admin sub-app (isolated in try/catch so an admin -// bug can never crash the proxy branch), everything else -> the proxy hot-path. +// Top-level dispatch: a WebSocket upgrade -> the wss proxy; /admin/* -> admin sub-app (isolated +// in try/catch so an admin bug can never crash the proxy branch); everything else -> the proxy +// hot-path. export default { async fetch( req: Request, env: Env, ctx: ExecutionContext, ): Promise { + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") + return handleWsProxy(req, env, ctx); const url = new URL(req.url); if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) { try { diff --git a/src/proxy.ts b/src/proxy.ts index 4a47554..f8cb360 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -85,7 +85,7 @@ function errorResponse(status: number, error: string): Response { const EGRESS_POOL = 8; /** OpenAI 403s requests that egress from an unsupported region (e.g. the Hong Kong colo). */ -async function isGeoBlock(res: Response): Promise { +export async function isGeoBlock(res: Response): Promise { if (res.status !== 403) return false; try { return (await res.clone().text()).includes( @@ -97,7 +97,7 @@ async function isGeoBlock(res: Response): Promise { } /** A North-America-pinned egress stub, so its fetch() leaves from an OpenAI-supported region. */ -function egressStub(env: Env): DurableObjectStub { +export function egressStub(env: Env): DurableObjectStub { const id = env.US_EGRESS.idFromName( `oa-egress-${Math.floor(Math.random() * EGRESS_POOL)}`, ); diff --git a/src/ws.ts b/src/ws.ts new file mode 100644 index 0000000..d5bfeea --- /dev/null +++ b/src/ws.ts @@ -0,0 +1,218 @@ +// The WebSocket proxy hot-path. Mirrors proxy.ts (validate the token -> swap in the real key -> +// forward), but for a WS upgrade handshake plus a bidirectional frame pump. Anthropic has no wss +// API today; OpenAI (`/v1/realtime`, `/v1/responses`) and Gemini Live (`...BidiGenerateContent`) +// do. Auth on a WS handshake can sit in a header, a `?key=` query param, or - because a browser +// WebSocket cannot set headers - the `Sec-WebSocket-Protocol` subprotocol. + +import { + coarse, + egressStub, + extractToken, + isGeoBlock, + realKeyFor, + routeProvider, +} from "./proxy"; +import { getValidatedByHash, sha256hex, touchLastUsed } from "./tokens"; +import type { Env, Provider } from "./types"; +import { rewriteToUpstream } from "./upstreams"; + +// OpenAI browser clients smuggle the key as a Sec-WebSocket-Protocol entry, since a browser +// WebSocket cannot set the Authorization header. Offered shape: ["realtime", +// "openai-insecure-api-key.", "openai-organization."?, "openai-project."?, +// "openai-beta.realtime-v1"?]. We read the key here and re-present it as a Bearer header upstream +// (the worker CAN set headers), keeping the remaining subprotocols so the handshake still +// negotiates "realtime". +const OPENAI_KEY_SUBPROTOCOL = "openai-insecure-api-key."; + +// Close codes a peer is not allowed to send back via close(); forward as a bare close() instead. +const CLOSE_FORBIDDEN = new Set([1004, 1005, 1006, 1015]); + +/** The proxy token from the subprotocol list, if a browser smuggled it there. */ +function subprotocolToken(header: string | null): string | null { + if (!header) return null; + for (const part of header.split(",")) { + const v = part.trim(); + if (v.startsWith(OPENAI_KEY_SUBPROTOCOL)) + return v.slice(OPENAI_KEY_SUBPROTOCOL.length) || null; + } + return null; +} + +/** Proxy token from any WS auth slot: the HTTP slots (header/query) plus the subprotocol. */ +export function extractWsToken(req: Request, url: URL): string | null { + return ( + extractToken(req, url) ?? + subprotocolToken(req.headers.get("sec-websocket-protocol")) + ); +} + +/** Provider from any WS auth slot. A subprotocol-smuggled key is always OpenAI. */ +export function routeWsProvider(req: Request, url: URL): Provider | null { + return ( + routeProvider(req, url) ?? + (subprotocolToken(req.headers.get("sec-websocket-protocol")) + ? "openai" + : null) + ); +} + +/** Rewrite the URL to the upstream and build the upgrade headers with the real key in the slot + * that provider's WS API expects. The proxy token leaves no slot. Pure + exported for tests. */ +export function prepareWsUpstream( + req: Request, + url: URL, + provider: Provider, + realKey: string, + env: Env, +): { target: string; headers: Headers } { + // Rewrite host/port (path + query kept). The scheme stays http(s): a Worker opens an upstream + // socket by fetching the http(s) URL with `Upgrade: websocket`, not by using a ws:// URL. + rewriteToUpstream(url, provider, env); + + const headers = new Headers(req.headers); + // Strip every inbound auth slot, then set exactly one upstream (the WS analogue of swapAuth). + headers.delete("x-api-key"); + headers.delete("x-goog-api-key"); + headers.delete("authorization"); + // The handshake headers are runtime-owned; drop the client's so CF regenerates them for the + // upstream leg, and signal upgrade intent explicitly. + headers.delete("sec-websocket-key"); + headers.delete("sec-websocket-version"); + headers.delete("sec-websocket-accept"); + headers.set("upgrade", "websocket"); + + if (provider === "gemini") { + url.searchParams.set("key", realKey); // Gemini Live takes the key in the query + } else { + url.searchParams.delete("key"); + if (provider === "anthropic") headers.set("x-api-key", realKey); + else headers.set("authorization", `Bearer ${realKey}`); // openai + gemini-openai + } + + // Drop the smuggled key entry from the subprotocol offer, keep the rest (realtime, org, + // project, beta). Remove the header entirely if nothing else remains. + const proto = headers.get("sec-websocket-protocol"); + if (proto) { + const kept = proto + .split(",") + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith(OPENAI_KEY_SUBPROTOCOL)); + if (kept.length) headers.set("sec-websocket-protocol", kept.join(", ")); + else headers.delete("sec-websocket-protocol"); + } + + return { target: url.toString(), headers }; +} + +/** The close code to forward to the peer, or null to send a bare close(): a peer may not send the + * reserved/abnormal codes in CLOSE_FORBIDDEN or anything outside 1000-4999. */ +export function forwardCloseCode(code: number): number | null { + return code >= 1000 && code <= 4999 && !CLOSE_FORBIDDEN.has(code) + ? code + : null; +} + +/** Forward frames + a sanitized close/error from one socket to the other. */ +function pump(from: WebSocket, to: WebSocket): void { + from.addEventListener("message", (e: MessageEvent) => { + try { + to.send(e.data as string | ArrayBuffer); + } catch {} + }); + from.addEventListener("close", (e: CloseEvent) => { + try { + const code = forwardCloseCode(e.code); + if (code !== null) to.close(code, e.reason); + else to.close(); + } catch {} + }); + from.addEventListener("error", () => { + try { + to.close(1011, "upstream error"); + } catch {} + }); +} + +/** Validate the token, swap in the real key, open the upstream socket, pipe both ends. */ +export async function handleWsProxy( + req: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + const url = new URL(req.url); + const token = extractWsToken(req, url); + const provider = routeWsProvider(req, url); + if (!token || !provider) + return new Response("missing token", { status: 401 }); + + const hash = await sha256hex(token); + const meta = await getValidatedByHash(env.TOKENS, hash); + if (!meta) return new Response("invalid or revoked token", { status: 401 }); + if (!meta.providers.includes(coarse(provider))) + return new Response("token not allowed for provider", { status: 403 }); + + // Per-token rate limit, same fail-open binding as the HTTP path. Gates the connection, not each + // frame: one upgrade = one limiter hit. + let allowed = true; + try { + allowed = (await env.RATE_LIMITER.limit({ key: hash })).success; + } catch { + allowed = true; + } + if (!allowed) + return new Response("rate limit exceeded", { + status: 429, + headers: { "retry-after": "60" }, + }); + + const realKey = realKeyFor(provider, env); + const { target, headers } = prepareWsUpstream( + req, + url, + provider, + realKey, + env, + ); + + let upstreamRes: Response; + try { + upstreamRes = await fetch(target, { headers }); + // Same OpenAI geo-403 escape hatch as HTTP: a 403 from a bad colo is retried from the + // NA-pinned egress DO, which carries the WS upgrade just like a plain fetch. + if (coarse(provider) === "openai" && (await isGeoBlock(upstreamRes))) + upstreamRes = await egressStub(env).fetch( + new Request(target, { headers }), + ); + } catch { + return new Response("upstream connect failed", { status: 502 }); + } + + const upstream = upstreamRes.webSocket; + if (!upstream) + // Upstream refused the upgrade (401/403/426/...). Surface its handshake response so the + // client sees the real error rather than a generic 502. + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: upstreamRes.headers, + }); + + const [client, server] = Object.values(new WebSocketPair()); + // Keep binary frames as ArrayBuffer regardless of compatibility_date: newer dates deliver them as + // Blob, which workerd's send() rejects - a forwarded realtime audio frame would silently drop. + upstream.binaryType = "arraybuffer"; + server.binaryType = "arraybuffer"; + upstream.accept(); + server.accept(); + pump(server, upstream); + pump(upstream, server); + + ctx.waitUntil(touchLastUsed(env.TOKENS, hash)); + + const res = new Response(null, { status: 101, webSocket: client }); + // Echo the subprotocol the upstream actually chose (e.g. "realtime"); a browser handshake + // fails if the server doesn't pick one of the client's offered subprotocols. + const negotiated = upstreamRes.headers.get("sec-websocket-protocol"); + if (negotiated) res.headers.set("sec-websocket-protocol", negotiated); + return res; +} diff --git a/test/sdk-compat/llama-index.py b/test/sdk-compat/llama-index.py index 34be9a9..dc8597a 100644 --- a/test/sdk-compat/llama-index.py +++ b/test/sdk-compat/llama-index.py @@ -52,7 +52,7 @@ def main(): # 2) Anthropic -> x-api-key -> /v1/messages (base_url is the bare host) reset() Anthropic( - base_url=W, api_key=TOKEN, model="claude-3-5-sonnet-20241022", max_tokens=16 + base_url=W, api_key=TOKEN, model="claude-sonnet-4-6", max_tokens=16 ).chat(msg()) cap = captured() assert cap["path"] == "/v1/messages", cap["path"] diff --git a/test/sdk-compat/websocket.ts b/test/sdk-compat/websocket.ts new file mode 100644 index 0000000..d82bae6 --- /dev/null +++ b/test/sdk-compat/websocket.ts @@ -0,0 +1,135 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { WebSocket, WebSocketServer } from "ws"; +import { FAKE, seedToken, startWorker } from "./setup"; + +// Real end-to-end wss proxy: a `ws` client -> the worker (real upgrade) -> a `ws` mock upstream. +// Proves the outbound WS upgrade works in workerd, the real key reaches the upstream handshake, +// the proxy token never does, and frames flow both ways. The per-slot swap detail (subprotocol +// stripping etc.) is covered fast in the tier-1 test/ws.test.ts; here we prove the live socket. + +interface Handshake { + headers: http.IncomingHttpHeaders; + url: string; +} + +interface WsMock { + url: string; + last(): Handshake | null; + reset(): void; + close(): Promise; +} + +async function startWsMockUpstream(): Promise { + let last: Handshake | null = null; + const server = http.createServer(); + const wss = new WebSocketServer({ server }); + wss.on("connection", (socket, req) => { + last = { headers: req.headers, url: req.url ?? "" }; + // Echo every frame straight back (binary-preserving). + socket.on("message", (data, isBinary) => + socket.send(data, { binary: isBinary }), + ); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + last: () => last, + reset: () => { + last = null; + }, + close: () => + new Promise((res) => wss.close(() => server.close(() => res()))), + }; +} + +let mock: WsMock; +let worker: Unstable_DevWorker; +let wsBase: string; +const TOKEN = "compat-ws-token"; + +beforeAll(async () => { + mock = await startWsMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + // unstable_dev can report 0.0.0.0 / :: which a raw ws client cannot dial. + const host = + !worker.address || worker.address === "0.0.0.0" || worker.address === "::" + ? "127.0.0.1" + : worker.address; + wsBase = `ws://${host}:${worker.port}`; + await seedToken(w.url, { + token: TOKEN, + providers: ["openai", "gemini"], + label: "ws", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +/** Open a proxied socket, send one frame, resolve with the echoed frame. */ +function roundtrip( + path: string, + opts?: ConstructorParameters[2], +): Promise { + return new Promise((resolve, reject) => { + const c = new WebSocket(`${wsBase}${path}`, opts); + const timer = setTimeout(() => { + c.terminate(); + reject(new Error("ws round-trip timed out")); + }, 15_000); + c.on("open", () => c.send(JSON.stringify({ type: "ping" }))); + c.on("message", (data) => { + clearTimeout(timer); + c.close(); + resolve(JSON.parse(data.toString())); + }); + c.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); +} + +describe("WebSocket proxy (end-to-end)", () => { + it("OpenAI Bearer: upgrades, swaps the real key into the handshake, echoes frames", async () => { + mock.reset(); + const echo = await roundtrip("/v1/responses", { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(echo).toEqual({ type: "ping" }); + const hs = mock.last(); + expect(hs).not.toBeNull(); + expect(hs!.headers.authorization).toBe(`Bearer ${FAKE.openai}`); + expect(JSON.stringify(hs)).not.toContain(TOKEN); // proxy token never reaches upstream + }); + + it("Gemini ?key=: upgrades with the query key swapped to the real key", async () => { + mock.reset(); + const echo = await roundtrip( + `/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=${TOKEN}`, + ); + expect(echo).toEqual({ type: "ping" }); + const hs = mock.last(); + expect(hs).not.toBeNull(); + expect(hs!.url).toContain(`key=${FAKE.gemini}`); + expect(hs!.url).not.toContain(TOKEN); + expect(hs!.headers.authorization).toBeUndefined(); + }); + + it("rejects an unknown token at the handshake (upstream never opened)", async () => { + mock.reset(); + await expect( + roundtrip("/v1/responses", { + headers: { authorization: "Bearer ghost" }, + }), + ).rejects.toThrow(); + expect(mock.last()).toBeNull(); + }); +}); diff --git a/test/ws.test.ts b/test/ws.test.ts new file mode 100644 index 0000000..82afd50 --- /dev/null +++ b/test/ws.test.ts @@ -0,0 +1,363 @@ +import { + createExecutionContext, + env, + waitOnExecutionContext, +} from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createToken } from "../src/tokens"; +import { + extractWsToken, + forwardCloseCode, + handleWsProxy, + prepareWsUpstream, + routeWsProvider, +} from "../src/ws"; + +// A fake upstream 101 carrying a live WebSocket, so handleWsProxy can accept + pipe it. +function ws101(): Response { + const [upstream] = Object.values(new WebSocketPair()); + return new Response(null, { status: 101, webSocket: upstream }); +} +const geo403 = () => + new Response( + JSON.stringify({ error: { code: "unsupported_country_region_territory" } }), + { status: 403 }, + ); + +let captured: Request | null; +let upstreamReply: () => Response; + +beforeEach(() => { + captured = null; + upstreamReply = ws101; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + captured = input instanceof Request ? input : new Request(input, init); + return upstreamReply(); + }, + ); +}); +afterEach(() => vi.restoreAllMocks()); + +async function callWs(req: Request): Promise { + const ctx = createExecutionContext(); + const res = await handleWsProxy(req, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +const seed = ( + token: string, + providers: ("openai" | "anthropic" | "gemini")[], +) => createToken(env.TOKENS, { label: token, providers, token }); + +describe("WS auth-slot extraction", () => { + it("reads the token smuggled in the openai-insecure-api-key subprotocol", () => { + const req = new Request("https://proxy.example/v1/realtime?model=m", { + headers: { + "sec-websocket-protocol": + "realtime, openai-insecure-api-key.tk-123, openai-beta.realtime-v1", + }, + }); + const url = new URL(req.url); + expect(extractWsToken(req, url)).toBe("tk-123"); + expect(routeWsProvider(req, url)).toBe("openai"); + }); + + it("falls back to the header and query slots", () => { + const bearer = new Request("https://p/v1/responses", { + headers: { authorization: "Bearer tk-h" }, + }); + expect(extractWsToken(bearer, new URL(bearer.url))).toBe("tk-h"); + expect(routeWsProvider(bearer, new URL(bearer.url))).toBe("openai"); + + const query = new Request( + "https://p/ws/Service.BidiGenerateContent?key=tk-q", + ); + expect(extractWsToken(query, new URL(query.url))).toBe("tk-q"); + expect(routeWsProvider(query, new URL(query.url))).toBe("gemini"); + }); +}); + +describe("prepareWsUpstream (pure auth swap)", () => { + it("openai: real key as Bearer, host rewritten, path + query kept, scheme stays https", () => { + const req = new Request( + "https://proxy.example/v1/realtime?model=gpt-realtime-2", + { headers: { authorization: "Bearer tk" } }, + ); + const url = new URL(req.url); + const { target, headers } = prepareWsUpstream( + req, + url, + "openai", + "REAL", + env, + ); + const u = new URL(target); + expect(u.protocol).toBe("https:"); + expect(u.hostname).toBe("api.openai.com"); + expect(u.pathname).toBe("/v1/realtime"); + expect(u.searchParams.get("model")).toBe("gpt-realtime-2"); + expect(headers.get("authorization")).toBe("Bearer REAL"); + }); + + it("openai subprotocol: drops the key entry, keeps realtime, sets Bearer", () => { + const req = new Request("https://proxy.example/v1/realtime", { + headers: { + "sec-websocket-protocol": + "realtime, openai-insecure-api-key.tk, openai-beta.realtime-v1", + }, + }); + const { headers } = prepareWsUpstream( + req, + new URL(req.url), + "openai", + "REAL", + env, + ); + expect(headers.get("authorization")).toBe("Bearer REAL"); + const proto = headers.get("sec-websocket-protocol") ?? ""; + expect(proto).toContain("realtime"); + expect(proto).toContain("openai-beta.realtime-v1"); + expect(proto).not.toContain("openai-insecure-api-key"); + expect(proto).not.toContain("tk"); + }); + + it("gemini: real key in ?key=, no Authorization header", () => { + const req = new Request( + "https://proxy.example/ws/Service.BidiGenerateContent?key=tk", + ); + const url = new URL(req.url); + const { target, headers } = prepareWsUpstream( + req, + url, + "gemini", + "REAL", + env, + ); + const u = new URL(target); + expect(u.hostname).toBe("generativelanguage.googleapis.com"); + expect(u.searchParams.get("key")).toBe("REAL"); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("x-goog-api-key")).toBeNull(); + }); +}); + +describe("handleWsProxy: validation (upstream never opened)", () => { + it("401 when no token is present", async () => { + const res = await callWs(new Request("https://proxy.example/v1/realtime")); + expect(res.status).toBe(401); + expect(captured).toBeNull(); + }); + it("401 for an unknown token", async () => { + const res = await callWs( + new Request("https://proxy.example/v1/realtime", { + headers: { authorization: "Bearer ghost" }, + }), + ); + expect(res.status).toBe(401); + expect(captured).toBeNull(); + }); + it("403 when the token is not scoped to the provider", async () => { + await seed("tk-gem-only", ["gemini"]); + const res = await callWs( + new Request("https://proxy.example/v1/realtime", { + headers: { authorization: "Bearer tk-gem-only" }, + }), + ); + expect(res.status).toBe(403); + expect(captured).toBeNull(); + }); +}); + +describe("handleWsProxy: auth swap + upgrade", () => { + it("openai Bearer (/v1/responses): swaps in the real key and returns 101", async () => { + await seed("tk-oai", ["openai"]); + const res = await callWs( + new Request("https://proxy.example/v1/responses", { + headers: { authorization: "Bearer tk-oai" }, + }), + ); + expect(res.status).toBe(101); + const u = new URL(captured!.url); + expect(u.hostname).toBe("api.openai.com"); + expect(u.pathname).toBe("/v1/responses"); + expect(captured!.headers.get("authorization")).toBe( + "Bearer real-openai-key-FAKE", + ); + }); + + it("openai subprotocol smuggling: swaps to Bearer, strips the key subprotocol", async () => { + await seed("tk-rt", ["openai"]); + const res = await callWs( + new Request("https://proxy.example/v1/realtime?model=gpt-realtime-2", { + headers: { + "sec-websocket-protocol": "realtime, openai-insecure-api-key.tk-rt", + }, + }), + ); + expect(res.status).toBe(101); + expect(captured!.headers.get("authorization")).toBe( + "Bearer real-openai-key-FAKE", + ); + const proto = captured!.headers.get("sec-websocket-protocol") ?? ""; + expect(proto).toContain("realtime"); + expect(proto).not.toContain("openai-insecure-api-key"); + }); + + it("gemini ?key=: swaps the query key, sets no Authorization header", async () => { + await seed("tk-gem", ["gemini"]); + const res = await callWs( + new Request( + "https://proxy.example/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=tk-gem", + ), + ); + expect(res.status).toBe(101); + const u = new URL(captured!.url); + expect(u.hostname).toBe("generativelanguage.googleapis.com"); + expect(u.searchParams.get("key")).toBe("real-gemini-key-FAKE"); + expect(captured!.headers.get("authorization")).toBeNull(); + }); +}); + +describe("handleWsProxy: security invariant", () => { + it("never forwards the proxy token upstream in any slot", async () => { + await seed("SECRET-WS", ["openai"]); + await callWs( + new Request("https://proxy.example/v1/realtime", { + headers: { + "sec-websocket-protocol": + "realtime, openai-insecure-api-key.SECRET-WS", + }, + }), + ); + const blob = [ + captured!.url, + captured!.headers.get("authorization"), + captured!.headers.get("sec-websocket-protocol"), + ].join("|"); + expect(blob).not.toContain("SECRET-WS"); + }); +}); + +describe("handleWsProxy: OpenAI geo-403 fallback via the egress DO", () => { + const realEgress = env.US_EGRESS; + let egressCalls: Request[]; + afterEach(() => { + (env as { US_EGRESS: typeof realEgress }).US_EGRESS = realEgress; + }); + function fakeEgress(reply: () => Response) { + egressCalls = []; + const stub = { + fetch: async (r: Request) => { + egressCalls.push(r); + return reply(); + }, + }; + (env as { US_EGRESS: unknown }).US_EGRESS = { + idFromName: () => ({}), + get: () => stub, + }; + } + + it("retries the upgrade through the egress DO on a geo-403, with the real key", async () => { + await seed("tk-geo", ["openai"]); + fakeEgress(ws101); + upstreamReply = geo403; + const res = await callWs( + new Request("https://proxy.example/v1/responses", { + headers: { authorization: "Bearer tk-geo" }, + }), + ); + expect(res.status).toBe(101); + expect(egressCalls.length).toBe(1); + expect(new URL(egressCalls[0].url).hostname).toBe("api.openai.com"); + expect(egressCalls[0].headers.get("authorization")).toBe( + "Bearer real-openai-key-FAKE", + ); + }); + + it("never routes gemini through the egress DO (403 surfaces straight through)", async () => { + await seed("tk-gem2", ["gemini"]); + fakeEgress(ws101); + upstreamReply = geo403; + const res = await callWs( + new Request( + "https://proxy.example/ws/Service.BidiGenerateContent?key=tk-gem2", + ), + ); + expect(res.status).toBe(403); + expect(egressCalls.length).toBe(0); + }); +}); + +describe("handleWsProxy: rate limiting", () => { + const real = (env as { RATE_LIMITER?: unknown }).RATE_LIMITER; + afterEach(() => { + (env as { RATE_LIMITER?: unknown }).RATE_LIMITER = real; + }); + const setLimiter = ( + limit: (o: { key: string }) => Promise<{ success: boolean }>, + ) => { + (env as { RATE_LIMITER: unknown }).RATE_LIMITER = { limit }; + }; + + it("429s with Retry-After when denied, without opening the upstream", async () => { + await seed("tk-ws-rl", ["openai"]); + setLimiter(async () => ({ success: false })); + const res = await callWs( + new Request("https://proxy.example/v1/responses", { + headers: { authorization: "Bearer tk-ws-rl" }, + }), + ); + expect(res.status).toBe(429); + expect(res.headers.get("retry-after")).toBe("60"); + expect(captured).toBeNull(); + }); + + it("fails open (101) when the limiter throws", async () => { + await seed("tk-ws-err", ["openai"]); + setLimiter(async () => { + throw new Error("limiter down"); + }); + const res = await callWs( + new Request("https://proxy.example/v1/responses", { + headers: { authorization: "Bearer tk-ws-err" }, + }), + ); + expect(res.status).toBe(101); + }); +}); + +describe("handleWsProxy: upstream connect failure", () => { + it("returns 502 when the upstream connect throws (no propagation)", async () => { + await seed("tk-boom", ["openai"]); + upstreamReply = () => { + throw new Error("connect refused"); + }; + const res = await callWs( + new Request("https://proxy.example/v1/responses", { + headers: { authorization: "Bearer tk-boom" }, + }), + ); + expect(res.status).toBe(502); + }); +}); + +describe("forwardCloseCode (close-code sanitization)", () => { + it("passes through normal application codes (1000-4999, not reserved)", () => { + expect(forwardCloseCode(1000)).toBe(1000); + expect(forwardCloseCode(1011)).toBe(1011); + expect(forwardCloseCode(3000)).toBe(3000); + expect(forwardCloseCode(4999)).toBe(4999); + }); + it("downgrades reserved/abnormal codes to a bare close (null)", () => { + for (const c of [1004, 1005, 1006, 1015]) + expect(forwardCloseCode(c)).toBeNull(); + }); + it("downgrades out-of-range codes to a bare close (null)", () => { + expect(forwardCloseCode(999)).toBeNull(); + expect(forwardCloseCode(5000)).toBeNull(); + expect(forwardCloseCode(0)).toBeNull(); + }); +}); From ab529b3b9f21f8bd8c0520a7ca41e494e140bdf2 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Fri, 3 Jul 2026 16:19:44 +0530 Subject: [PATCH 22/22] docs: rm 100 col limitation --- docs/architecture.md | 206 ++++++------------ docs/learnings/README.md | 4 +- .../compat-is-the-auth-slot-not-the-sdk.md | 80 ++----- .../cors-preflight-and-upload-passthrough.md | 29 +-- docs/learnings/openai-egress-geo-block.md | 38 +--- .../provider-routing-by-auth-header.md | 14 +- docs/learnings/proxy-token-security.md | 25 +-- .../rate-limit-binding-free-and-loose.md | 25 +-- .../token-expiry-check-at-validate.md | 11 +- 9 files changed, 128 insertions(+), 304 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2e75da5..909db2a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,50 +1,38 @@ # api-proxy — Architecture -A single Cloudflare Worker (Free plan) that reverse-proxies **OpenAI, Anthropic, and Google -Gemini** behind shareable, revocable **proxy tokens**. A client changes only its base URL and API -key; the worker validates the token, swaps in the real provider key server-side, and forwards the -request verbatim. The real key never leaves Cloudflare. +A single Cloudflare Worker (Free plan) that reverse-proxies **OpenAI, Anthropic, and Google Gemini** behind shareable, revocable **proxy tokens**. A client changes only its base URL and API key; the worker validates the token, swaps in the real provider key server-side, and forwards the request verbatim. The real key never leaves Cloudflare. -This document is the current design. Topic deep-dives with the "why" live in -[`docs/learnings/`](learnings/); the retired one-worker-per-provider v1 lives in -[`_legacy/v1/`](../_legacy/v1/). +This document is the current design. Topic deep-dives with the "why" live in [`docs/learnings/`](learnings/); the retired one-worker-per-provider v1 lives in [`_legacy/v1/`](../_legacy/v1/). --- ## 1. Problem -v1 was three unauthenticated workers (one per provider), each injecting a shared real key for -**anyone** who knew the URL — no per-user access, no revocation. v2 collapses them into one -token-gated worker. +v1 was three unauthenticated workers (one per provider), each injecting a shared real key for **anyone** who knew the URL — no per-user access, no revocation. v2 collapses them into one token-gated worker. ## 2. Topology -One worker, dispatched by path (`src/index.ts`): +One worker, dispatched in `src/index.ts`: -- **`/admin/*`** → the Hono admin sub-app, wrapped in `try/catch` (→ 500) so an admin bug can never - crash the proxy branch. -- **everything else** → `handleProxy`, the framework-free hot path (`src/proxy.ts` must never import - Hono — it is pure functions plus a `fetch` handler). +- **WebSocket upgrade** (`Upgrade: websocket`) → `handleWsProxy`, the wss hot path (§10), checked first so a realtime client never falls through to the HTTP branch. +- **`/admin/*`** → the Hono admin sub-app, wrapped in `try/catch` (→ 500) so an admin bug can never crash the proxy branch. +- **everything else** → `handleProxy`, the framework-free hot path (`src/proxy.ts` must never import Hono — it is pure functions plus a `fetch` handler). ## 3. Request flow (proxy hot path) -`handleProxy` is a thin wrapper: it answers an `OPTIONS` preflight directly, otherwise runs -`proxyRequest` and reflects CORS headers onto the result (§8). `proxyRequest`: +`handleProxy` is a thin wrapper: it answers an `OPTIONS` preflight directly, otherwise runs `proxyRequest` and reflects CORS headers onto the result (§8). `proxyRequest`: -1. **Extract** the token from whichever auth slot it arrived in and **route** the provider from that - slot (+ path); missing either → **401**. (§4) +1. **Extract** the token from whichever auth slot it arrived in and **route** the provider from that slot (+ path); missing either → **401**. (§4) 2. **Validate** `SHA-256(token)` against KV — a miss, a disabled token, or an expired one → **401**. (§6) 3. Requested provider not in the token's scope → **403**. (§4) 4. **Rate-limit** on the hash — over the cap → **429** + `Retry-After` (fail-open). (§7) -5. **Rewrite** the URL to the upstream — protocol/host/port only; strip `?key=` for Gemini. (§12) +5. **Rewrite** the URL to the upstream — protocol/host/port only; strip `?key=` for Gemini. (§13) 6. **Swap auth** — strip every inbound auth header, set the one real key. (§5) -7. **Fetch** the upstream (OpenAI adds a geo-403 fallback, §9), stream the response back unbuffered, - and stamp `lastUsed` fire-and-forget. (§6, §9) +7. **Fetch** the upstream (OpenAI adds a geo-403 fallback, §9), stream the response back unbuffered, and stamp `lastUsed` fire-and-forget. (§6, §9) ## 4. Provider routing (by auth header) -The client adds no path prefix and no custom header — routing reads **which auth slot the SDK -populated** (`routeProvider`, `extractToken`): +The client adds no path prefix and no custom header — routing reads **which auth slot the SDK populated** (`routeProvider`, `extractToken`): | Inbound signal | Provider | Upstream | |---|---|---| @@ -54,20 +42,13 @@ populated** (`routeProvider`, `extractToken`): | `Authorization: Bearer` (else) | `openai` | api.openai.com | | none | — | 401 | -Auth slots are checked **before** `?key=`, so a request carrying `Authorization: Bearer` routes to -openai / gemini-openai even when it also has `?key=`; the `x-goog-api-key or ?key=` equivalence holds -only when no Bearer header is present. +Auth slots are checked **before** `?key=`, so a request carrying `Authorization: Bearer` routes to openai / gemini-openai even when it also has `?key=`; the `x-goog-api-key or ?key=` equivalence holds only when no Bearer header is present. -`gemini-openai` (the OpenAI-compatible Gemini endpoint) collapses to the `gemini` scope via -`coarse()`; the distinction only selects the auth-swap branch. **Why no `/openai` `/anthropic` path -prefix:** it would break Gemini's file-upload flow (absolute `x-goog-upload-url` round trip) and -force every client to rewrite the SDK's own base path. See -[`provider-routing-by-auth-header.md`](learnings/provider-routing-by-auth-header.md). +`gemini-openai` (the OpenAI-compatible Gemini endpoint) collapses to the `gemini` scope via `coarse()`; the distinction only selects the auth-swap branch. **Why no `/openai` `/anthropic` path prefix:** it would break Gemini's file-upload flow (absolute `x-goog-upload-url` round trip) and force every client to rewrite the SDK's own base path. See [`provider-routing-by-auth-header.md`](learnings/provider-routing-by-auth-header.md). ## 5. Auth swap (security linchpin) -Before forwarding, `swapAuth` deletes **every** inbound auth header and sets exactly one with the -real key: +Before forwarding, `swapAuth` deletes **every** inbound auth header and sets exactly one with the real key: ```ts headers.delete("x-api-key"); headers.delete("x-goog-api-key"); headers.delete("authorization"); @@ -78,14 +59,11 @@ switch (provider) { } ``` -Strip-all-then-set-one guarantees the proxy token is never forwarded upstream even if a client sends -it in an unexpected slot, and closes dual-header leaks. A test asserts the token never appears in any -outbound auth header. See [`proxy-token-security.md`](learnings/proxy-token-security.md). +Strip-all-then-set-one guarantees the proxy token is never forwarded upstream even if a client sends it in an unexpected slot, and closes dual-header leaks. A test asserts the token never appears in any outbound auth header. See [`proxy-token-security.md`](learnings/proxy-token-security.md). ## 6. Token model & lifecycle -KV namespace `TOKENS`, keyed by `SHA-256(token)` (hex). The plaintext is shown **once** at creation -and never persisted (`src/tokens.ts`, `src/types.ts`): +KV namespace `TOKENS`, keyed by `SHA-256(token)` (hex). The plaintext is shown **once** at creation and never persisted (`src/tokens.ts`, `src/types.ts`): ```ts type TokenMetadata = { @@ -98,24 +76,14 @@ type TokenMetadata = { }; ``` -- **Tokens** are opaque: `ptk_` + 32 url-safe chars (24 random bytes). Custom admin-typed tokens are - allowed; validation is by hash of the full string. -- **Validation** (`getValidatedByHash`): returns the record only if `status === "active"` AND, when - `expiresAt` is set, it parses to a future timestamp — malformed or past expiry is rejected - **fail-closed**. Not KV `expirationTtl` (60s floor, silently deletes the record, orphans the `:lu` - key) — see [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). -- **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget on each proxied - request. Keeping it out of the token record means stamping it can never resurrect or re-enable a - token the admin just disabled or deleted. -- **Lifecycle:** `createToken`, `listTokens` (paginates KV, skips `:lu` keys), `updateToken` - (label / providers / status), `deleteToken` (record + `:lu`). KV is eventually consistent (~60s), - so revoke and new-token visibility can lag. +- **Tokens** are opaque: `ptk_` + 32 url-safe chars (24 random bytes). Custom admin-typed tokens are allowed; validation is by hash of the full string. +- **Validation** (`getValidatedByHash`): returns the record only if `status === "active"` AND, when `expiresAt` is set, it parses to a future timestamp — malformed or past expiry is rejected **fail-closed**. Not KV `expirationTtl` (60s floor, silently deletes the record, orphans the `:lu` key) — see [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). +- **`lastUsed`** lives in a separate `:lu` key, written fire-and-forget on each proxied request. Keeping it out of the token record means stamping it can never resurrect or re-enable a token the admin just disabled or deleted. +- **Lifecycle:** `createToken`, `listTokens` (paginates KV, skips `:lu` keys), `updateToken` (label / providers / status), `deleteToken` (record + `:lu`). KV is eventually consistent (~60s), so revoke and new-token visibility can lag. ## 7. Per-token rate limiting -After validation, `RATE_LIMITER.limit({ key: hash })` (the Workers Rate Limiting binding) caps each -token. Over the limit → `429` + `Retry-After: 60`. Wrapped in try/catch and **fail-open**: a missing -or erroring binding must never brick the proxy. +After validation, `RATE_LIMITER.limit({ key: hash })` (the Workers Rate Limiting binding) caps each token. Over the limit → `429` + `Retry-After: 60`. Wrapped in try/catch and **fail-open**: a missing or erroring binding must never brick the proxy. ```toml [[ratelimits]] @@ -127,100 +95,70 @@ namespace_id = "1001" period = 60 # must be 10 or 60 ``` -It is in-process (not a subrequest), keyed on the hash, and **per-colo + eventually consistent** — a -loose ceiling for abuse protection, not a strict quota. Verified to run on the Free plan. See -[`rate-limit-binding-free-and-loose.md`](learnings/rate-limit-binding-free-and-loose.md). +It is in-process (not a subrequest), keyed on the hash, and **per-colo + eventually consistent** — a loose ceiling for abuse protection, not a strict quota. Verified to run on the Free plan. See [`rate-limit-binding-free-and-loose.md`](learnings/rate-limit-binding-free-and-loose.md). ## 8. CORS & browser support -`handleProxy` short-circuits `OPTIONS` to a `204` preflight **before** the token checks (a preflight -carries no auth header, so it would otherwise 401 and block every browser SDK). The preflight -reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, advertises a -fixed method allow-list (`GET, POST, PUT, DELETE, OPTIONS`), and sets -`Access-Control-Max-Age: 86400`. Every real response then passes through `withCors`, which reflects -`Origin`, appends `Vary: Origin` (so per-Origin reflection is cache-safe), and exposes the Gemini -resumable-upload headers -(`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). No `Origin` → no CORS -headers (server-side callers are unaffected). Credentials mode is never enabled (SDKs send keys as -headers, not cookies). Provider browser opt-ins still apply (e.g. Anthropic's -`dangerouslyAllowBrowser`, which the SDK forwards as a header). - -**Gemini file uploads** pass through verbatim: the start call routes normally, Google returns an -absolute, self-authenticating `x-goog-upload-url`, and the client uploads bytes **directly to -Google** — that leg never transits the worker, so the 100 MB body cap is sidestepped and the real -key is never on it. +`handleProxy` short-circuits `OPTIONS` to a `204` preflight **before** the token checks (a preflight carries no auth header, so it would otherwise 401 and block every browser SDK). The preflight reflects the request `Origin`, reflects the requested `Access-Control-Request-Headers`, advertises a fixed method allow-list (`GET, POST, PUT, DELETE, OPTIONS`), and sets `Access-Control-Max-Age: 86400`. Every real response then passes through `withCors`, which reflects `Origin`, appends `Vary: Origin` (so per-Origin reflection is cache-safe), and exposes the Gemini resumable-upload headers (`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). No `Origin` → no CORS headers (server-side callers are unaffected). Credentials mode is never enabled (SDKs send keys as headers, not cookies). Provider browser opt-ins still apply (e.g. Anthropic's `dangerouslyAllowBrowser`, which the SDK forwards as a header). + +**Gemini file uploads** pass through verbatim: the start call routes normally, Google returns an absolute, self-authenticating `x-goog-upload-url`, and the client uploads bytes **directly to Google** — that leg never transits the worker, so the 100 MB body cap is sidestepped and the real key is never on it. ## 9. OpenAI geo-403 egress (North-America-pinned Durable Object) -OpenAI 403s `unsupported_country_region_territory` when a request egresses from an unsupported colo -(e.g. Hong Kong). A Worker's `fetch()` egresses from the colo the invocation runs in, fixed per -invocation, so an in-invocation retry can't escape a bad colo. +OpenAI 403s `unsupported_country_region_territory` when a request egresses from an unsupported colo (e.g. Hong Kong). A Worker's `fetch()` egresses from the colo the invocation runs in, fixed per invocation, so an in-invocation retry can't escape a bad colo. + +The fix is a fallback: try the fast edge `fetch()` first (requests that egress from a good colo return immediately); **only on the geo-403**, re-issue the same request through the `UsEgress` SQLite Durable Object pinned to North America (`locationHint: "wnam"`). Running in a US colo, its `fetch()` egresses from a supported region and succeeds. Only the OpenAI branch buffers the body (to replay it to the DO); a pool of DO ids spreads load. Anthropic and Gemini are untouched, and the real key never leaves Cloudflare. See [`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). + +## 10. WebSocket (wss) proxying -The fix is a fallback: try the fast edge `fetch()` first (requests that egress from a good colo -return immediately); **only on the geo-403**, re-issue the same request through the `UsEgress` SQLite -Durable Object pinned to North America (`locationHint: "wnam"`). Running in a US colo, its `fetch()` -egresses from a supported region and succeeds. Only the OpenAI branch buffers the body (to replay it -to the DO); a pool of DO ids spreads load. Anthropic and Gemini are untouched, and the real key never -leaves Cloudflare. See [`openai-egress-geo-block.md`](learnings/openai-egress-geo-block.md). +The token-swap model extends to WebSocket upgrades (`src/ws.ts`, `handleWsProxy`), dispatched before the HTTP branch on `Upgrade: websocket`. It serves the realtime/streaming sockets the HTTP APIs don't cover: **OpenAI** `/v1/realtime` and `/v1/responses` (WebSocket mode), and **Gemini Live** (`...BidiGenerateContent`). Anthropic has no wss API, so it is naturally excluded. -## 10. Admin dashboard +The flow mirrors the HTTP path — extract token → validate hash → scope check → rate-limit → swap auth — then opens the upstream socket with `fetch(target, { Upgrade: websocket })` (the scheme stays `http(s):`; the header drives the upgrade), reads `resp.webSocket`, and pumps frames both ways through a `WebSocketPair`. It's a **manual pipe** (not a transparent pass-through) so the subprotocol the upstream negotiates is echoed back to the client deterministically — a browser handshake fails if the server picks none of the offered subprotocols. A non-101 upstream handshake (401/403/426) is surfaced to the client verbatim rather than as a generic error. + +**Auth on a WS handshake has a wider slot set than HTTP**, because a browser `WebSocket` cannot set request headers, so providers smuggle the key elsewhere: + +| Inbound slot | Provider | Swapped to (upstream) | +|---|---|---| +| `Authorization: Bearer ` | openai | `Authorization: Bearer ` | +| `Sec-WebSocket-Protocol: openai-insecure-api-key.` | openai | key entry dropped; real key set as `Authorization: Bearer` (the worker *can* set headers); `realtime` + org/project/beta subprotocols kept | +| `?key=` | gemini | `?key=` in the query (Gemini Live reads the key there, not a header) | -Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus -**HTMX 2.x** loaded from a CDN (zero client JS we author; nothing in the worker bundle but markup + -attributes). +`prepareWsUpstream` strips every inbound auth slot then sets exactly one — the WS analogue of `swapAuth` (§5) — so the proxy token never reaches the upstream in any slot (header, query, or subprotocol); a test asserts this. The OpenAI **geo-403 fallback (§9) applies here too**: a 403 from a bad colo re-issues the upgrade through the `UsEgress` DO. (The DO carries a WebSocket like a plain `fetch`; this reuses HTTP's egress path, though the geo-blocked WS hop itself isn't locally testable.) -- **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` sets an HMAC-SHA256-signed cookie - `cm_admin=.` (`Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards - every `/admin/*` route except login; the signature check uses constant-time `crypto.subtle.verify`. -- **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; parses label, - provider checkboxes, an optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated - token), edit / enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. -- **UI:** an add-token card (label, token, **Expires (optional)**, provider checkboxes) and a token - table (label, last-4, provider pills, status, **Expires**, last-used, disable/delete). The created - plaintext is shown once; expired tokens render `expired` and dim the row. The list refreshes on - load, on the `tokens-changed` event, and **every 10 s** (to surface new tokens / last-used despite - KV's ~60 s list propagation). +Caveats: the rate limit gates the **connection**, not each frame (one upgrade = one hit); a revoke takes effect on the next connection, not mid-stream (a long-lived socket is validated once, at upgrade time); and Cloudflare closes an idle socket after a quiet period, so a silent client should keep-alive. See [`websocket-proxy-auth-slots.md`](learnings/websocket-proxy-auth-slots.md). -## 11. Real key handling +## 11. Admin dashboard -`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `ADMIN_SECRET` are Cloudflare **secrets**, -read at request time and injected only into the outbound request. Never logged, never stored in KV, -never returned in a response body. The CORS and rate-limit paths never touch a real key. +Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus **HTMX 2.x** loaded from a CDN (zero client JS we author; nothing in the worker bundle but markup + attributes). -## 12. Storage, bindings & config (`wrangler.toml`) +- **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` sets an HMAC-SHA256-signed cookie `cm_admin=.` (`Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; the signature check uses constant-time `crypto.subtle.verify`. +- **CRUD:** HTMX-driven over `/admin/api/tokens` — list (`GET`), create (`POST`; parses label, provider checkboxes, an optional `datetime-local` expiry normalized to UTC ISO, custom-or-generated token), edit / enable-disable (`PUT`), delete (`DELETE`). `:hash` params are validated as 64-hex. +- **UI:** an add-token card (label, token, **Expires (optional)**, provider checkboxes) and a token table (label, last-4, provider pills, status, **Expires**, last-used, disable/delete). The created plaintext is shown once; expired tokens render `expired` and dim the row. The list refreshes on load, on the `tokens-changed` event, and **every 10 s** (to surface new tokens / last-used despite KV's ~60 s list propagation). + +## 12. Real key handling + +`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `ADMIN_SECRET` are Cloudflare **secrets**, read at request time and injected only into the outbound request. Never logged, never stored in KV, never returned in a response body. The CORS and rate-limit paths never touch a real key. + +## 13. Storage, bindings & config (`wrangler.toml`) | Binding | Kind | Purpose | |---|---|---| | `TOKENS` | KV namespace | token store (by `SHA-256`) + `:lu` last-used keys | -| `US_EGRESS` | SQLite Durable Object (`UsEgress`) | NA-pinned egress fallback for OpenAI | +| `US_EGRESS` | SQLite Durable Object (`UsEgress`) | NA-pinned egress fallback for OpenAI (HTTP + wss) | | `RATE_LIMITER` | Rate Limit | per-token RPM ceiling | -Plus `[[migrations]] tag="v1" new_sqlite_classes=["UsEgress"]`. Upstreams resolve through -`upstreamBase()`: the `*_UPSTREAM` env vars (plain vars, not secrets) default to the real hosts and -are overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites just protocol/host/port. +Plus `[[migrations]] tag="v1" new_sqlite_classes=["UsEgress"]`. Upstreams resolve through `upstreamBase()`: the `*_UPSTREAM` env vars (plain vars, not secrets) default to the real hosts and are overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites just protocol/host/port. -## 13. Testing (two tiers) +## 14. Testing (two tiers) | Tier | Runner | Scope | |---|---|---| -| 1 — proxy logic | `@cloudflare/vitest-pool-workers` (workerd) | routing, auth swap, expiry, CORS, rate limit, geo-403 fallback, SSE passthrough; mocks `fetch`, seeds KV directly | -| 2 — real client libs | `unstable_dev` worker + `node:http` mock upstream (Node via vitest; Python via `test/run-py.mjs`) | official `openai`/`@anthropic-ai/sdk`/`@google/genai` SDKs, Vercel AI SDK, LangChain, Genkit, LiteLLM, LlamaIndex, instructor, Pydantic AI — end-to-end | - -Tier 2 covers every auth slot — OpenAI (`Bearer`, `openai.ts` + `litellm.py`), Anthropic -(`x-api-key`, `anthropic-ai-sdk.ts`), Gemini native (`x-goog-api-key`, `google-genai.ts`), Gemini -OpenAI-compat (the OpenAI SDK at `/v1beta/openai`, `google-genai.ts`), and the Gemini `?key=` query -slot plus verbatim path/query/body forwarding (`fetch.ts`) — plus streaming for the main three; -OpenAI-compat streaming rides the same SSE passthrough, so it has no dedicated test. Each asserts the -real key reaches the mock and the token never does. Compatibility is fixed by the **auth slot, not the -SDK or language**, so each distinct library gets **one** end-to-end test in one language; a provider's -other-language packages (`openai-python`/`-go`/...), end-user apps (Aider, Cline, Continue, Open WebUI), -and JVM/.NET frameworks (Spring AI, Semantic Kernel) reuse a slot already proven and are documented -rather than re-tested — see -[`compat-is-the-auth-slot-not-the-sdk.md`](learnings/compat-is-the-auth-slot-not-the-sdk.md). **No -test hits a live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but -**Gemini has never run against the real Google API** (no key yet). - -## 14. Deployment +| 1 — proxy logic | `@cloudflare/vitest-pool-workers` (workerd) | routing, auth swap, expiry, CORS, rate limit, geo-403 fallback, SSE passthrough, **WS upgrade + subprotocol auth swap** (`test/ws.test.ts`); mocks `fetch`, seeds KV directly | +| 2 — real client libs | `unstable_dev` worker + `node:http` mock upstream (Node via vitest; Python via `test/run-py.mjs`) | official `openai`/`@anthropic-ai/sdk`/`@google/genai` SDKs, Vercel AI SDK, LangChain, Genkit, LiteLLM, LlamaIndex, instructor, Pydantic AI, and a **real wss round-trip** (`websocket.ts`: `ws` client → worker → `ws` mock) — end-to-end | + +Tier 2 covers every auth slot — OpenAI (`Bearer`, `openai.ts` + `litellm.py`), Anthropic (`x-api-key`, `anthropic-ai-sdk.ts`), Gemini native (`x-goog-api-key`, `google-genai.ts`), Gemini OpenAI-compat (the OpenAI SDK at `/v1beta/openai`, `google-genai.ts`), and the Gemini `?key=` query slot plus verbatim path/query/body forwarding (`fetch.ts`) — plus streaming for the main three; OpenAI-compat streaming rides the same SSE passthrough, so it has no dedicated test. Each asserts the real key reaches the mock and the token never does. Compatibility is fixed by the **auth slot, not the SDK or language**, so each distinct library gets **one** end-to-end test in one language; a provider's other-language packages (`openai-python`/`-go`/...), end-user apps (Aider, Cline, Continue, Open WebUI), and JVM/.NET frameworks (Spring AI, Semantic Kernel) reuse a slot already proven and are documented rather than re-tested — see [`compat-is-the-auth-slot-not-the-sdk.md`](learnings/compat-is-the-auth-slot-not-the-sdk.md). **No test hits a live provider** (mock upstream only): OpenAI/Anthropic are verified live in deployment, but **Gemini has never run against the real Google API** (no key yet). + +## 15. Deployment ```bash nub install @@ -231,21 +169,15 @@ nubx wrangler deploy Free Workers plan covers it (100k req/day); you only pay upstream providers for usage. -## 15. Security model +## 16. Security model -Invariants are detailed in §5 (auth swap), §6 (token hashing, revoke-safe `lastUsed`), §10 (admin -HMAC cookie), and §11 (real-key handling). +Invariants are detailed in §5 (auth swap), §6 (token hashing, revoke-safe `lastUsed`), §11 (admin HMAC cookie), and §12 (real-key handling). Caveats: -- KV is ~60s eventually consistent, so a revoke / expiry-flip is not instant — for an immediate - cutoff, rotate the provider secret (instant, and the key stays in Cloudflare). -- Do not host on `*.openai.azure.com` / `*.cognitiveservices.azure.com` (the OpenAI SDK switches to - Azure auth on those hostnames). +- KV is ~60s eventually consistent, so a revoke / expiry-flip is not instant — for an immediate cutoff, rotate the provider secret (instant, and the key stays in Cloudflare). +- Do not host on `*.openai.azure.com` / `*.cognitiveservices.azure.com` (the OpenAI SDK switches to Azure auth on those hostnames). -## 16. Deferred / future +## 17. Deferred / future -The token data model leaves room (`limits`, `spend`) without carrying the weight now: spend / -token-count caps + per-token usage analytics (needs a metering Durable Object and SSE usage parsing), -multiple real keys per provider (key pools), concurrency limits and longer rate-limit windows, and -instant (sub-minute) revocation via a DO allow/deny list. +The token data model leaves room (`limits`, `spend`) without carrying the weight now: spend / token-count caps + per-token usage analytics (needs a metering Durable Object and SSE usage parsing), multiple real keys per provider (key pools), concurrency limits and longer rate-limit windows, and instant (sub-minute) revocation via a DO allow/deny list. diff --git a/docs/learnings/README.md b/docs/learnings/README.md index af443e4..7769e55 100644 --- a/docs/learnings/README.md +++ b/docs/learnings/README.md @@ -2,9 +2,7 @@ A running log of project-specific knowledge worth not rediscovering. One topic per file, kept short. -Write a file for anything **non-general**: a gotcha, a constraint, a decision and its why, a platform or -API quirk we hit. The bar is "non-obvious and specific to this project," not "it changed the product -direction." Skip general/common knowledge anyone would already have. Don't rewrite history; append. +Write a file for anything **non-general**: a gotcha, a constraint, a decision and its why, a platform or API quirk we hit. The bar is "non-obvious and specific to this project," not "it changed the product direction." Skip general/common knowledge anyone would already have. Don't rewrite history; append. Each file: the problem, what we found, and the decision we keep. diff --git a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md index b370e0a..6d1ea76 100644 --- a/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md +++ b/docs/learnings/compat-is-the-auth-slot-not-the-sdk.md @@ -2,25 +2,13 @@ ## Problem -Which clients do we need a compat test for? The candidates are endless: the official SDKs in six -languages, the Vercel AI SDK, LangChain (JS + Python), LiteLLM, LlamaIndex, instructor, and every -agent tool (Aider, Cline, Continue, Open WebUI, ...). Writing a test per client would never end, and -most would be copies of each other. +Which clients do we need a compat test for? The candidates are endless: the official SDKs in six languages, the Vercel AI SDK, LangChain (JS + Python), LiteLLM, LlamaIndex, instructor, and every agent tool (Aider, Cline, Continue, Open WebUI, ...). Writing a test per client would never end, and most would be copies of each other. ## What we found -The proxy routes and authenticates **purely by which auth slot a request arrives in** plus one path -check; it rewrites only host/port and forwards path + query + body verbatim (`src/proxy.ts` -`routeProvider` / `extractToken`, `src/upstreams.ts` `rewriteToUpstream`). So a client's compatibility -is decided by exactly two things: (1) which auth slot it puts the key in — one of the four the proxy -reads (`x-api-key`, `x-goog-api-key`, `Authorization: Bearer`, or the `?key=` query param), with a -single path check (`/v1beta/openai/`) splitting Bearer into the openai vs gemini-openai upstream — and -(2) whether it lets you point its base URL at an arbitrary host. The SDK, the language, and the -wrapper are irrelevant once those two are fixed. +The proxy routes and authenticates **purely by which auth slot a request arrives in** plus one path check; it rewrites only host/port and forwards path + query + body verbatim (`src/proxy.ts` `routeProvider` / `extractToken`, `src/upstreams.ts` `rewriteToUpstream`). So a client's compatibility is decided by exactly two things: (1) which auth slot it puts the key in — one of the four the proxy reads (`x-api-key`, `x-goog-api-key`, `Authorization: Bearer`, or the `?key=` query param), with a single path check (`/v1beta/openai/`) splitting Bearer into the openai vs gemini-openai upstream — and (2) whether it lets you point its base URL at an arbitrary host. The SDK, the language, and the wrapper are irrelevant once those two are fixed. -A source-level survey (official SDK source, provider docs, and wrapper source) confirms every client -collapses onto one of these already-handled routes. **None hits a new slot or an unhandled path.** The -table below lists the four provider routes SDKs actually use (`?key=` is covered after it): +A source-level survey (official SDK source, provider docs, and wrapper source) confirms every client collapses onto one of these already-handled routes. **None hits a new slot or an unhandled path.** The table below lists the four provider routes SDKs actually use (`?key=` is covered after it): | Provider route | Slot the proxy keys on | Clients verified to use it | Wire proof | |---|---|---|---| @@ -29,57 +17,31 @@ table below lists the four provider routes SDKs actually use (`?key=` is covered | Gemini (native) | `x-goog-api-key` | `@google/genai` (JS+Py), legacy `@google/generative-ai` / `google-generativeai`; `@ai-sdk/google`; `@langchain/google-genai` (JS+Py); LlamaIndex GoogleGenAI | `@ai-sdk/google` source: `'x-goog-api-key': loadApiKey(...)` — **not** `?key=`, **not** Bearer | | Gemini (OpenAI-compat) | `Authorization: Bearer` + path `/v1beta/openai/` | any OpenAI SDK pointed at `…/v1beta/openai/` | Google's documented OpenAI-compat surface: `Authorization: Bearer `, `/v1beta/openai/chat/completions` | -Every client also exposes a first-class base-URL override (`base_url` / `baseURL` / `WithBaseURL` / -`OPENAI_BASE_URL` / `httpOptions.baseUrl` / `createX({ baseURL })` / `configuration.baseURL` / ...), so -all can be aimed at the worker. +Every client also exposes a first-class base-URL override (`base_url` / `baseURL` / `WithBaseURL` / `OPENAI_BASE_URL` / `httpOptions.baseUrl` / `createX({ baseURL })` / `configuration.baseURL` / ...), so all can be aimed at the worker. -All four slots the proxy reads are exercised end-to-end — the three header slots in the table plus -the `?key=` query param (which no SDK uses, only raw HTTP via `fetch.ts`). The full list of tested -libraries is in "What we test" below. +All four slots the proxy reads are exercised end-to-end — the three header slots in the table plus the `?key=` query param (which no SDK uses, only raw HTTP via `fetch.ts`). The full list of tested libraries is in "What we test" below. ## Caveats worth knowing (real divergences, not new slots) -- **Anthropic OAuth/token mode.** Every Anthropic SDK can alternatively authenticate with - `authToken` / `ANTHROPIC_AUTH_TOKEN`, which sends `Authorization: Bearer` instead of `x-api-key` — - that would route to the **openai** slot here. Use the normal API-key (`x-api-key`) mode. -- **Legacy `google-generativeai` (Python) defaults to gRPC**, not HTTP, so it won't transit an HTTP - proxy at all unless you set `transport="rest"`. The current `google-genai` SDK is HTTP by default. -- **OpenAI Responses API path.** Modern OpenAI clients (and the AI SDK 5 default) call `/v1/responses` - rather than `/v1/chat/completions`. Both are `Authorization: Bearer` and forwarded verbatim, so both - stay in the openai slot — different upstream endpoint, same proxy behavior. -- **Base-URL `/v1` convention differs per client.** The OpenAI SDK and `@ai-sdk/anthropic` want the - `/v1` in the base URL; the official `@anthropic-ai/sdk` does **not** (it appends `/v1/messages` - itself). Set each client's base URL the way that client documents it. +- **Anthropic OAuth/token mode.** Every Anthropic SDK can alternatively authenticate with `authToken` / `ANTHROPIC_AUTH_TOKEN`, which sends `Authorization: Bearer` instead of `x-api-key` — that would route to the **openai** slot here. Use the normal API-key (`x-api-key`) mode. +- **Legacy `google-generativeai` (Python) defaults to gRPC**, not HTTP, so it won't transit an HTTP proxy at all unless you set `transport="rest"`. The current `google-genai` SDK is HTTP by default. +- **OpenAI Responses API path.** Modern OpenAI clients (and the AI SDK 5 default) call `/v1/responses` rather than `/v1/chat/completions`. Both are `Authorization: Bearer` and forwarded verbatim, so both stay in the openai slot — different upstream endpoint, same proxy behavior. +- **Base-URL `/v1` convention differs per client.** The OpenAI SDK and `@ai-sdk/anthropic` want the `/v1` in the base URL; the official `@anthropic-ai/sdk` does **not** (it appends `/v1/messages` itself). Set each client's base URL the way that client documents it. ## What we test, and what we document -Compatibility is the slot, not the language — but a *library* is its own client with its own wiring -(base-URL option, default endpoint, extra headers), so each distinct library gets one end-to-end test -as a living usage example. We do **not** re-test the same library in every language: a provider's -packages share one auth slot (the matrix above), so one language proves them all. +Compatibility is the slot, not the language — but a *library* is its own client with its own wiring (base-URL option, default endpoint, extra headers), so each distinct library gets one end-to-end test as a living usage example. We do **not** re-test the same library in every language: a provider's packages share one auth slot (the matrix above), so one language proves them all. **Tested end-to-end** (`test/sdk-compat/`, each file named after its package): -- Node (`nub run test:compat`): the official `openai`, `@anthropic-ai/sdk`, `@google/genai`; the - Vercel AI SDK (`@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`); LangChain - (`@langchain/openai`, `@langchain/anthropic`, `@langchain/google-genai`); Genkit - (`@genkit-ai/google-genai`); raw `fetch`. -- Python (`nub run test:py`): LiteLLM, LlamaIndex (openai + anthropic + google-genai), instructor, - Pydantic AI. - -**Documented as compatible-by-construction** (not separately tested) — each collapses onto a slot -already proven above: - -- **Other-language packages of a tested SDK** — `openai-python` / `-go` / `-java` / `-ruby` / - `-dotnet`, `anthropic` (py/go/java/ruby), `google-genai` (py). Same package family, same slot as the - JS package already tested; re-testing each language is the redundancy we skip. -- **End-user apps, not importable libraries** — Aider, Cline, Continue, Open WebUI. Each speaks the - OpenAI-compatible surface (Bearer slot) with a user-set base URL. -- **JVM / .NET frameworks** — Spring AI, Semantic Kernel. Same slots; no JVM/.NET toolchain in this - repo to drive them. -- **Mastra** — `@mastra/core` 1.x is flagged by security advisory MAL-2026-6011 (embedded malicious - code), so it is deliberately **not** pulled into the toolchain. It builds on the Vercel AI SDK, so - by construction it uses the same Bearer slot already covered by `@ai-sdk/openai`. - -A new test is warranted only if a future client hits a genuinely new auth slot or routing path — -which nothing in the current ecosystem does. +- Node (`nub run test:compat`): the official `openai`, `@anthropic-ai/sdk`, `@google/genai`; the Vercel AI SDK (`@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`); LangChain (`@langchain/openai`, `@langchain/anthropic`, `@langchain/google-genai`); Genkit (`@genkit-ai/google-genai`); raw `fetch`. +- Python (`nub run test:py`): LiteLLM, LlamaIndex (openai + anthropic + google-genai), instructor, Pydantic AI. + +**Documented as compatible-by-construction** (not separately tested) — each collapses onto a slot already proven above: + +- **Other-language packages of a tested SDK** — `openai-python` / `-go` / `-java` / `-ruby` / `-dotnet`, `anthropic` (py/go/java/ruby), `google-genai` (py). Same package family, same slot as the JS package already tested; re-testing each language is the redundancy we skip. +- **End-user apps, not importable libraries** — Aider, Cline, Continue, Open WebUI. Each speaks the OpenAI-compatible surface (Bearer slot) with a user-set base URL. +- **JVM / .NET frameworks** — Spring AI, Semantic Kernel. Same slots; no JVM/.NET toolchain in this repo to drive them. +- **Mastra** — `@mastra/core` 1.x is flagged by security advisory MAL-2026-6011 (embedded malicious code), so it is deliberately **not** pulled into the toolchain. It builds on the Vercel AI SDK, so by construction it uses the same Bearer slot already covered by `@ai-sdk/openai`. + +A new test is warranted only if a future client hits a genuinely new auth slot or routing path — which nothing in the current ecosystem does. diff --git a/docs/learnings/cors-preflight-and-upload-passthrough.md b/docs/learnings/cors-preflight-and-upload-passthrough.md index 343560e..07f7eaf 100644 --- a/docs/learnings/cors-preflight-and-upload-passthrough.md +++ b/docs/learnings/cors-preflight-and-upload-passthrough.md @@ -6,15 +6,11 @@ Two browser-facing quirks from v2.1, both about what the proxy must *not* touch. ### Problem -Browser SDK callers trigger a CORS preflight: the browser sends an `OPTIONS` request *first*, and that -preflight carries **no auth header** (no `x-api-key`, no `Authorization`, no `?key=`). Run auth first -and `extractToken`/`routeProvider` see nothing, so every preflight 401s - the browser then never fires -the real request. All browser SDK callers break silently. +Browser SDK callers trigger a CORS preflight: the browser sends an `OPTIONS` request *first*, and that preflight carries **no auth header** (no `x-api-key`, no `Authorization`, no `?key=`). Run auth first and `extractToken`/`routeProvider` see nothing, so every preflight 401s - the browser then never fires the real request. All browser SDK callers break silently. ### What we found -`handleProxy` short-circuits `OPTIONS` to a `204` **before** any token work -(`src/proxy.ts`, the `if (req.method === "OPTIONS")` at the top, ahead of `proxyRequest`): +`handleProxy` short-circuits `OPTIONS` to a `204` **before** any token work (`src/proxy.ts`, the `if (req.method === "OPTIONS")` at the top, ahead of `proxyRequest`): ``` inbound request @@ -23,29 +19,21 @@ inbound request └─ else ──▶ extractToken / routeProvider / validate / forward ``` -The `204` reflects the caller's `Origin` and echoes the requested headers back -(`access-control-allow-headers` = the inbound `access-control-request-headers`, else `*`). -`withCors` also `append`s `Vary: Origin` to every response so caches don't mix per-origin replies. +The `204` reflects the caller's `Origin` and echoes the requested headers back (`access-control-allow-headers` = the inbound `access-control-request-headers`, else `*`). `withCors` also `append`s `Vary: Origin` to every response so caches don't mix per-origin replies. ### Decision we keep -Preflight is answered before auth, and the real key never rides any CORS path (`withCors`/`corsPreflight` -only set `access-control-*` and `Vary`). Auth-first would be the natural instinct and it is wrong here. +Preflight is answered before auth, and the real key never rides any CORS path (`withCors`/`corsPreflight` only set `access-control-*` and `Vary`). Auth-first would be the natural instinct and it is wrong here. ## B. Gemini resumable-upload URL is passed through, not rewritten ### Problem -Gemini's resumable upload returns an **absolute** `x-goog-upload-url` pointing straight at Google. If the -proxy tried to own that flow, large uploads would hit the Worker's 100MB request-body cap. +Gemini's resumable upload returns an **absolute** `x-goog-upload-url` pointing straight at Google. If the proxy tried to own that flow, large uploads would hit the Worker's 100MB request-body cap. ### What we found -`rewriteToUpstream` (`src/upstreams.ts`) only swaps `protocol`/`hostname`/`port` on the *request* URL - it -never rewrites the absolute `x-goog-upload-url` Google returns. So the client uploads bytes **directly to -Google**, bypassing the Worker and its body cap. The proxy's only job is to let the browser *read* that -header: `withCors` sets `access-control-expose-headers` to the `EXPOSE_HEADERS` constant -(`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). +`rewriteToUpstream` (`src/upstreams.ts`) only swaps `protocol`/`hostname`/`port` on the *request* URL - it never rewrites the absolute `x-goog-upload-url` Google returns. So the client uploads bytes **directly to Google**, bypassing the Worker and its body cap. The proxy's only job is to let the browser *read* that header: `withCors` sets `access-control-expose-headers` to the `EXPOSE_HEADERS` constant (`x-goog-upload-url, x-goog-upload-status, x-goog-upload-chunk-granularity`). ``` client ──▶ proxy ──▶ Google : returns absolute x-goog-upload-url (not rewritten) @@ -54,7 +42,4 @@ client ───────────▶ Google : uploads bytes straight to t ### Decision we keep -Pass the upload URL through untouched; only expose the headers. This is also **why a path-prefix routing -scheme would break Gemini** - the absolute upload URL can't carry a `/gemini/` prefix - which is the -upload half of the case in [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md). -Token security on the normal path is unchanged (see [proxy-token-security.md](proxy-token-security.md)). +Pass the upload URL through untouched; only expose the headers. This is also **why a path-prefix routing scheme would break Gemini** - the absolute upload URL can't carry a `/gemini/` prefix - which is the upload half of the case in [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md). Token security on the normal path is unchanged (see [proxy-token-security.md](proxy-token-security.md)). diff --git a/docs/learnings/openai-egress-geo-block.md b/docs/learnings/openai-egress-geo-block.md index e09ede5..c9a5fc7 100644 --- a/docs/learnings/openai-egress-geo-block.md +++ b/docs/learnings/openai-egress-geo-block.md @@ -2,8 +2,7 @@ ## Problem -Through the Worker, OpenAI returned `403 unsupported_country_region_territory` intermittently -(~40% of requests). Anthropic was always fine. A valid key, correct path, correct auth. +Through the Worker, OpenAI returned `403 unsupported_country_region_territory` intermittently (~40% of requests). Anthropic was always fine. A valid key, correct path, correct auth. ## The mechanism in one picture @@ -25,36 +24,22 @@ Roughly 40% of invocations happened to egress via HKG, hence the ~40% failure. ## What we found -- Single probes of `GET /v1/models`, `POST /v1/chat/completions`, and streaming all succeeded, so - egress was not blanket-blocked. -- Hammering the same endpoint exposed the ~40% failure, and the failures correlated **100%** with - the Cloudflare egress colo: every request that egressed via **Hong Kong (HKG) returned 403**, every - one via **Singapore (SIN) returned 200**. It is OpenAI's country geo-restriction (HKG/CN unsupported), - not an IP-reputation or bot block. Anthropic works because it does not geo-block those regions. +- Single probes of `GET /v1/models`, `POST /v1/chat/completions`, and streaming all succeeded, so egress was not blanket-blocked. +- Hammering the same endpoint exposed the ~40% failure, and the failures correlated **100%** with the Cloudflare egress colo: every request that egressed via **Hong Kong (HKG) returned 403**, every one via **Singapore (SIN) returned 200**. It is OpenAI's country geo-restriction (HKG/CN unsupported), not an IP-reputation or bot block. Anthropic works because it does not geo-block those regions. - A Worker's `fetch()` egresses from the colo the invocation runs in, and that colo varies per request. -- The egress colo is **pinned per invocation**: six sequential subrequests inside one invocation always - hit the same colo. So an in-invocation **retry cannot escape a bad colo** - it just re-hits HKG. The - colo only re-rolls across separate invocations. +- The egress colo is **pinned per invocation**: six sequential subrequests inside one invocation always hit the same colo. So an in-invocation **retry cannot escape a bad colo** - it just re-hits HKG. The colo only re-rolls across separate invocations. ## What does NOT fix it -- **Smart Placement** and **`placement.region`** optimize *execution* location for *latency*, not - *egress country*. They have no notion of "OpenAI-supported region," can leave a Worker in HKG, and - for a single-subrequest proxy may not relocate at all. Community reports confirm they don't fix this. +- **Smart Placement** and **`placement.region`** optimize *execution* location for *latency*, not *egress country*. They have no notion of "OpenAI-supported region," can leave a Worker in HKG, and for a single-subrequest proxy may not relocate at all. Community reports confirm they don't fix this. - **Dedicated Egress IPs / Regional Services** would pin egress region, but they are paid/Enterprise. -- A **third-party relay** in a supported region (Vercel `iad1`, free VPS) works, but routes the real - OpenAI key through another host - rejected on the "key never leaves Cloudflare" rule. +- A **third-party relay** in a supported region (Vercel `iad1`, free VPS) works, but routes the real OpenAI key through another host - rejected on the "key never leaves Cloudflare" rule. ## The fix -Route **only the OpenAI hop** through a Durable Object pinned to North America with -`locationHint:"wnam"` (`src/egress.ts`). The DO runs in a US colo, so its `fetch()` egresses from an -OpenAI-supported region. It is wired as a **fallback**, not the default path: try the fast edge fetch -first and re-issue through the DO **only on the geo-403** (`src/proxy.ts`). The request body is buffered -for OpenAI so it can be replayed to the DO. +Route **only the OpenAI hop** through a Durable Object pinned to North America with `locationHint:"wnam"` (`src/egress.ts`). The DO runs in a US colo, so its `fetch()` egresses from an OpenAI-supported region. It is wired as a **fallback**, not the default path: try the fast edge fetch first and re-issue through the DO **only on the geo-403** (`src/proxy.ts`). The request body is buffered for OpenAI so it can be replayed to the DO. -The egress DO is **pooled across 8 named instances** (`EGRESS_POOL=8`, `idFromName('oa-egress-N')` with a -random `N`), so all OpenAI traffic isn't funneled through one DO. +The egress DO is **pooled across 8 named instances** (`EGRESS_POOL=8`, `idFromName('oa-egress-N')` with a random `N`), so all OpenAI traffic isn't funneled through one DO. ``` OpenAI request @@ -74,11 +59,8 @@ OpenAI request Why this shape: - The real key never leaves Cloudflare. - Free on the Workers Free plan (SQLite-backed Durable Object). -- The ~60% of OpenAI calls that already egress from a good colo stay fast (no extra hop); Anthropic and - Gemini are untouched. +- The ~60% of OpenAI calls that already egress from a good colo stay fast (no extra hop); Anthropic and Gemini are untouched. ## Result -Post-fix stress test: 25/25 `200`, 0 `403`. The DO's egress colos verified as US (DFW/LAX/DEN/SJC/SEA). -Streaming survives the fallback. If another provider ever shows the same geo-403, apply the same DO -pattern by extending the `coarse(provider) === "openai"` branch. +Post-fix stress test: 25/25 `200`, 0 `403`. The DO's egress colos verified as US (DFW/LAX/DEN/SJC/SEA). Streaming survives the fallback. If another provider ever shows the same geo-403, apply the same DO pattern by extending the `coarse(provider) === "openai"` branch. diff --git a/docs/learnings/provider-routing-by-auth-header.md b/docs/learnings/provider-routing-by-auth-header.md index 557b576..d52f33a 100644 --- a/docs/learnings/provider-routing-by-auth-header.md +++ b/docs/learnings/provider-routing-by-auth-header.md @@ -2,9 +2,7 @@ ## Problem -One consolidated base URL has to transparently serve OpenAI, Anthropic, and Gemini. The goal: a client -changes only its base URL and API key, nothing else. So the proxy must decide which upstream a request -is for without the client adding a path prefix or custom header. +One consolidated base URL has to transparently serve OpenAI, Anthropic, and Gemini. The goal: a client changes only its base URL and API key, nothing else. So the proxy must decide which upstream a request is for without the client adding a path prefix or custom header. ## What we found @@ -33,15 +31,11 @@ inbound request ## Why not a path prefix (e.g. `/openai/...`) -- It would break Gemini, whose file-upload flow returns absolute `x-goog-upload-url` paths the client - then calls directly; a prefix scheme can't survive that round trip. -- It would force every client to rewrite the SDK's own base path, defeating the "change only base URL + - key" promise. +- It would break Gemini, whose file-upload flow returns absolute `x-goog-upload-url` paths the client then calls directly; a prefix scheme can't survive that round trip. +- It would force every client to rewrite the SDK's own base path, defeating the "change only base URL + key" promise. Auth-slot routing keeps each SDK's native path intact, so it stays a true drop-in. ## Decision we keep -Route by auth header. The token is extracted from the same slot, validated, and then **all** inbound -auth headers are stripped and exactly one real key is set for the chosen provider (see -[proxy-token-security.md](proxy-token-security.md)). +Route by auth header. The token is extracted from the same slot, validated, and then **all** inbound auth headers are stripped and exactly one real key is set for the chosen provider (see [proxy-token-security.md](proxy-token-security.md)). diff --git a/docs/learnings/proxy-token-security.md b/docs/learnings/proxy-token-security.md index 760e1c7..3f9057f 100644 --- a/docs/learnings/proxy-token-security.md +++ b/docs/learnings/proxy-token-security.md @@ -2,9 +2,7 @@ ## Idea -A proxy token is a shareable, revocable stand-in for a real provider key. The holder puts it -in the normal SDK auth slot; the proxy validates it, then swaps in the real key. You can hand someone -access without exposing your OpenAI/Anthropic/Gemini key, and revoke it any time. +A proxy token is a shareable, revocable stand-in for a real provider key. The holder puts it in the normal SDK auth slot; the proxy validates it, then swaps in the real key. You can hand someone access without exposing your OpenAI/Anthropic/Gemini key, and revoke it any time. ## Request flow @@ -23,25 +21,14 @@ auth slot = ──▶ extract token from auth slot ## The decisions that keep it safe -- **Token rides the SDK's auth slot.** No custom header, no path change. The client only swaps base URL - and key. The proxy reads the token from whichever slot routing matched - (see [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md)). +- **Token rides the SDK's auth slot.** No custom header, no path change. The client only swaps base URL and key. The proxy reads the token from whichever slot routing matched (see [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md)). -- **Strip-all-then-set-one.** Before forwarding, delete *every* inbound auth header - (`authorization`, `x-api-key`, `x-goog-api-key`) and set exactly one with the real key - (`src/proxy.ts` `swapAuth`). This guarantees the proxy token is never forwarded upstream, - even if a client sends it in an unexpected slot. A test asserts the token never appears in any - outbound auth header. +- **Strip-all-then-set-one.** Before forwarding, delete *every* inbound auth header (`authorization`, `x-api-key`, `x-goog-api-key`) and set exactly one with the real key (`src/proxy.ts` `swapAuth`). This guarantees the proxy token is never forwarded upstream, even if a client sends it in an unexpected slot. A test asserts the token never appears in any outbound auth header. -- **Hashed at rest.** Tokens are stored as `SHA-256(token)` and shown to the admin exactly once at - creation. The KV value never contains the plaintext. +- **Hashed at rest.** Tokens are stored as `SHA-256(token)` and shown to the admin exactly once at creation. The KV value never contains the plaintext. -- **Revoke-safe `lastUsed`.** Usage timestamps live in a separate `:lu` key, not in the token - record. Stamping "last used" on a hot path can therefore never recreate or re-enable a record that - was deleted or disabled - a revoked token stays revoked. +- **Revoke-safe `lastUsed`.** Usage timestamps live in a separate `:lu` key, not in the token record. Stamping "last used" on a hot path can therefore never recreate or re-enable a record that was deleted or disabled - a revoked token stays revoked. ## Scope (v1) -Per-token: label, provider scope, enable/disable, revoke, last-used. Rate limits, spend caps, expiry, -and per-token analytics are deliberately deferred - the data model leaves room without carrying the -weight now. +Per-token: label, provider scope, enable/disable, revoke, last-used. Rate limits, spend caps, expiry, and per-token analytics are deliberately deferred - the data model leaves room without carrying the weight now. diff --git a/docs/learnings/rate-limit-binding-free-and-loose.md b/docs/learnings/rate-limit-binding-free-and-loose.md index 9076041..b6f06fa 100644 --- a/docs/learnings/rate-limit-binding-free-and-loose.md +++ b/docs/learnings/rate-limit-binding-free-and-loose.md @@ -2,30 +2,19 @@ ## What we needed -A per-token request-rate cap that costs nothing, needs no new storage, and never lets the real key -leave Cloudflare. +A per-token request-rate cap that costs nothing, needs no new storage, and never lets the real key leave Cloudflare. ## What we found -- The Workers **Rate Limiting binding** (`[[ratelimits]]` in `wrangler.toml` + - `env.RATE_LIMITER.limit({ key })`) is an **in-process call, not a subrequest** - no subrequest - budget hit, no storage, microseconds of CPU. -- **Free-plan eligibility is undocumented** (a research pass even fabricated a "no additional charge" - quote). Verified empirically: `wrangler deploy` on the Free account accepts the binding (the summary - lists `env.RATE_LIMITER (N requests/60s) — Rate Limit`) and `limit()` enforces — treat undocumented - platform claims as "verify by deploying," not fact. -- It is **per-colo and eventually consistent**. With `limit = 2 / 60s`, ~13 rapid requests slipped - through before denials began, and a client spread across two colos can get up to ~2× the limit. - Cloudflare describes it as a "loose filter, not suited for strict abuse prevention." -- `period` must be exactly **10 or 60**. The limit is fixed per namespace at deploy time - no - per-token-variable limit without tiered namespaces or a Durable Object counter. +- The Workers **Rate Limiting binding** (`[[ratelimits]]` in `wrangler.toml` + `env.RATE_LIMITER.limit({ key })`) is an **in-process call, not a subrequest** - no subrequest budget hit, no storage, microseconds of CPU. +- **Free-plan eligibility is undocumented** (a research pass even fabricated a "no additional charge" quote). Verified empirically: `wrangler deploy` on the Free account accepts the binding (the summary lists `env.RATE_LIMITER (N requests/60s) — Rate Limit`) and `limit()` enforces — treat undocumented platform claims as "verify by deploying," not fact. +- It is **per-colo and eventually consistent**. With `limit = 2 / 60s`, ~13 rapid requests slipped through before denials began, and a client spread across two colos can get up to ~2× the limit. Cloudflare describes it as a "loose filter, not suited for strict abuse prevention." +- `period` must be exactly **10 or 60**. The limit is fixed per namespace at deploy time - no per-token-variable limit without tiered namespaces or a Durable Object counter. ## Decisions we keep - One shared per-token ceiling (KISS), keyed on the token's **SHA-256 hash** (never the plaintext). -- **Fail-open:** wrap `limit()` in try/catch and allow on any error - a missing or flaky limiter must - never brick the proxy. The real abuse defense is revoke + scope, not this loose ceiling. -- Return `429` + a static `Retry-After: 60` (the binding returns no reset time; the static value - matches `period`). +- **Fail-open:** wrap `limit()` in try/catch and allow on any error - a missing or flaky limiter must never brick the proxy. The real abuse defense is revoke + scope, not this loose ceiling. +- Return `429` + a static `Retry-After: 60` (the binding returns no reset time; the static value matches `period`). Related: [[proxy-token-security]]. diff --git a/docs/learnings/token-expiry-check-at-validate.md b/docs/learnings/token-expiry-check-at-validate.md index 4710959..7b80640 100644 --- a/docs/learnings/token-expiry-check-at-validate.md +++ b/docs/learnings/token-expiry-check-at-validate.md @@ -6,10 +6,8 @@ Optional per-token expiry, enforced cheaply, without a second storage backend. ## What we found -- **KV `expirationTtl` is the wrong tool:** 60s floor, it *deletes* the record on expiry (so the - dashboard can't show an "expired" row), and it orphans the separate `:lu` last-used key. -- `expiresAt` is set once and never mutates, so a check at read time has no consistency window - it - is exact and adds zero extra reads (the field rides in the JSON already fetched to validate). +- **KV `expirationTtl` is the wrong tool:** 60s floor, it *deletes* the record on expiry (so the dashboard can't show an "expired" row), and it orphans the separate `:lu` last-used key. +- `expiresAt` is set once and never mutates, so a check at read time has no consistency window - it is exact and adds zero extra reads (the field rides in the JSON already fetched to validate). ## Decision we keep @@ -22,9 +20,6 @@ if (meta.expiresAt) { } ``` -**Fail-closed on malformed input:** `NaN <= Date.now()` is `false`, which fails *open* (a garbage -`expiresAt` stays valid) - so `Number.isNaN(t)` is checked explicitly. The admin form converts its -local `datetime-local` value to UTC ISO and rejects unparseable input at creation. Instant cutoff -for a leak is still provider-key rotation, not expiry (KV revoke lags ~60s). +**Fail-closed on malformed input:** `NaN <= Date.now()` is `false`, which fails *open* (a garbage `expiresAt` stays valid) - so `Number.isNaN(t)` is checked explicitly. The admin form converts its local `datetime-local` value to UTC ISO and rejects unparseable input at creation. Instant cutoff for a leak is still provider-key rotation, not expiry (KV revoke lags ~60s). Related: [[proxy-token-security]].