From 1aa79f517f5aca7166cbe132e23bfc64e149958b Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Sat, 3 Oct 2026 11:05:03 -0400 Subject: [PATCH] test(e2e): hermetic Tester Army suite for proxy use cases (MASTRA-768) Adds e2e/ as its own pnpm package (e2e 0.16.0, zod, oxfmt, oxlint, exact pins) with a no-op engine, a hermetic target that runs the candidate binary against a mock Codex/Claude upstream, and protocol tests for the Open SWE, Codex streaming, Claude Code, Responses-with-Claude, Chat Completions, models, failover and all-cooled cases. CI builds the binary, checks format and lint, runs the hermetic target and uploads report.json. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 32 + e2e/.gitignore | 3 + e2e/.oxfmtrc.json | 5 + e2e/e2e.config.ts | 34 ++ e2e/hermetic/mock-upstream.mjs | 112 ++++ e2e/hermetic/start.mjs | 45 ++ e2e/lib/proxy.ts | 32 + e2e/lib/sse.ts | 32 + e2e/package.json | 20 + e2e/pnpm-lock.yaml | 950 ++++++++++++++++++++++++++++++ e2e/pnpm-workspace.yaml | 2 + e2e/tests/chat-completions.e2e.ts | 34 ++ e2e/tests/failover.e2e.ts | 48 ++ e2e/tests/messages.e2e.ts | 36 ++ e2e/tests/models.e2e.ts | 27 + e2e/tests/responses.e2e.ts | 77 +++ 16 files changed, 1489 insertions(+) create mode 100644 e2e/.gitignore create mode 100644 e2e/.oxfmtrc.json create mode 100644 e2e/e2e.config.ts create mode 100644 e2e/hermetic/mock-upstream.mjs create mode 100644 e2e/hermetic/start.mjs create mode 100644 e2e/lib/proxy.ts create mode 100644 e2e/lib/sse.ts create mode 100644 e2e/package.json create mode 100644 e2e/pnpm-lock.yaml create mode 100644 e2e/pnpm-workspace.yaml create mode 100644 e2e/tests/chat-completions.e2e.ts create mode 100644 e2e/tests/failover.e2e.ts create mode 100644 e2e/tests/messages.e2e.ts create mode 100644 e2e/tests/models.e2e.ts create mode 100644 e2e/tests/responses.e2e.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a47315ab..5c666dbdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,35 @@ jobs: cache: true - run: go build ./... - run: go test ./... + + e2e-hermetic: + runs-on: ubuntu-latest + env: + E2E_TELEMETRY_DISABLED: "1" + MASTRA_TELEMETRY_DISABLED: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go build -o e2e/.bin/cli-proxy-api ./cmd/server + - uses: pnpm/action-setup@v4 + with: + package_json_file: e2e/package.json + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: e2e/pnpm-lock.yaml + - run: pnpm --dir e2e install --frozen-lockfile + - run: pnpm --dir e2e run fmt:check + - run: pnpm --dir e2e run lint + - run: pnpm --dir e2e exec e2e run --target hermetic --output .e2e/ci + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: e2e-hermetic-report + path: | + e2e/.e2e/ci/report.json + e2e/.e2e/logs/hermetic.log diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..2be148a67 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +.e2e/ +.bin/ +node_modules/ diff --git a/e2e/.oxfmtrc.json b/e2e/.oxfmtrc.json new file mode 100644 index 000000000..69d0285cb --- /dev/null +++ b/e2e/.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "singleQuote": true +} diff --git a/e2e/e2e.config.ts b/e2e/e2e.config.ts new file mode 100644 index 000000000..19a869357 --- /dev/null +++ b/e2e/e2e.config.ts @@ -0,0 +1,34 @@ +import type { E2EConfig } from 'e2e'; +import { defineEngine } from 'e2e/engine'; + +// No UI to drive: the runner starts app.command, probes readyUrl and exposes app.baseUrl. +const http = (platform: string) => + defineEngine({ name: 'http', version: '1', spiVersion: 1, platform }); + +export default { + tests: 'tests/**/*.e2e.ts', + retries: 0, + trace: 'off', + targets: [ + { + engine: http('hermetic'), + app: { + url: 'http://127.0.0.1:0', + readyUrl: 'http://127.0.0.1:{port}/healthz', + command: { + executable: 'node', + args: ['hermetic/start.mjs'], + // Loopback is never proxied, so this blocks only the binary's own internet fetches. + env: { + PORT: '{port}', + HTTPS_PROXY: 'http://127.0.0.1:9', + MASTRA_TELEMETRY_DISABLED: '1', + }, + log: '.e2e/logs/hermetic.log', + }, + }, + }, + // Live tests read CLIPROXY_LIVE_URL themselves: the runner refuses plain HTTP off loopback. + { engine: http('live') }, + ], +} satisfies E2EConfig; diff --git a/e2e/hermetic/mock-upstream.mjs b/e2e/hermetic/mock-upstream.mjs new file mode 100644 index 000000000..600db5e92 --- /dev/null +++ b/e2e/hermetic/mock-upstream.mjs @@ -0,0 +1,112 @@ +// Mock Codex and Claude upstream. A credential whose key ends in `-limited` answers Codex +// 429 usage_limit_reached; every other request gets a complete SSE stream (or a Claude JSON +// message when the request does not stream). GET /_mock/requests returns what it received. +import { createServer } from 'node:http'; + +const requests = []; +const resetAt = () => Math.floor(Date.now() / 1000) + 3600; + +const sse = (res, headers, events) => { + res.writeHead(200, { 'content-type': 'text/event-stream', ...headers }); + for (const data of events) res.write(`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`); + res.end(); +}; + +const codex = (res, key, body) => { + if (key.endsWith('-limited')) { + res.writeHead(429, { 'content-type': 'application/json' }); + const error = { type: 'usage_limit_reached', message: 'You have hit your usage limit.' }; + res.end(JSON.stringify({ error: { ...error, resets_at: resetAt(), resets_in_seconds: 3600 } })); + return; + } + const item = { + type: 'message', + id: 'msg_mock', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: '{"ok":true}', annotations: [] }], + }; + const response = { id: 'resp_mock', object: 'response', model: body.model, output: [] }; + const usage = { input_tokens: 5, output_tokens: 3, total_tokens: 8 }; + sse( + res, + { + 'x-codex-primary-used-percent': '10', + 'x-codex-primary-window-minutes': '300', + 'x-codex-primary-reset-at': String(resetAt()), + 'x-codex-secondary-used-percent': '20', + 'x-codex-secondary-window-minutes': '10080', + 'x-codex-secondary-reset-at': String(resetAt()), + }, + [ + { type: 'response.created', response: { ...response, status: 'in_progress' } }, + { type: 'response.output_item.added', output_index: 0, item: { ...item, content: [] } }, + { + type: 'response.output_text.delta', + output_index: 0, + content_index: 0, + delta: item.content[0].text, + }, + { type: 'response.output_item.done', output_index: 0, item }, + { type: 'response.completed', response: { ...response, status: 'completed', usage } }, + ], + ); +}; + +const claude = (res, body) => { + const headers = { + 'anthropic-ratelimit-unified-status': 'allowed', + 'anthropic-ratelimit-unified-5h-status': 'allowed', + 'anthropic-ratelimit-unified-5h-utilization': '0.1', + 'anthropic-ratelimit-unified-5h-reset': String(resetAt()), + 'anthropic-ratelimit-unified-7d-status': 'allowed', + 'anthropic-ratelimit-unified-7d-utilization': '0.2', + 'anthropic-ratelimit-unified-7d-reset': String(resetAt()), + }; + const message = { + id: 'msg_mock', + type: 'message', + role: 'assistant', + model: body.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }; + if (!body.stream) { + res.writeHead(200, { 'content-type': 'application/json', ...headers }); + const content = [{ type: 'text', text: 'ok' }]; + res.end(JSON.stringify({ ...message, content, stop_reason: 'end_turn' })); + return; + } + sse(res, headers, [ + { type: 'message_start', message }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ]); +}; + +export const startMock = () => + new Promise((resolve) => { + const server = createServer(async (req, res) => { + if (req.method === 'GET' && req.url === '/_mock/requests') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(requests)); + return; + } + let raw = ''; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + const key = (req.headers['x-api-key'] ?? req.headers.authorization ?? '').replace( + 'Bearer ', + '', + ); + requests.push({ path: req.url, headers: req.headers, body, key }); + if (req.url === '/v1/responses') codex(res, key, body); + else claude(res, body); + }); + server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)); + }); diff --git a/e2e/hermetic/start.mjs b/e2e/hermetic/start.mjs new file mode 100644 index 000000000..dd5fd4d5b --- /dev/null +++ b/e2e/hermetic/start.mjs @@ -0,0 +1,45 @@ +// Starts the mock upstream, writes a proxy config whose API-key credentials point at it, and +// runs the candidate binary on $PORT. Tests read the mock URL from .e2e/mock.json. +import { spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startMock } from './mock-upstream.mjs'; + +const url = await startMock(); +mkdirSync('.e2e', { recursive: true }); +writeFileSync('.e2e/mock.json', JSON.stringify({ url })); + +const dir = mkdtempSync(join(tmpdir(), 'cliproxy-e2e-')); +mkdirSync(join(dir, 'auths')); +const codex = (key, model, priority = 0) => ({ + 'api-key': key, + 'base-url': `${url}/v1`, + priority, + models: [{ name: model }], +}); +// YAML accepts JSON. The failover and cooldown tests own their models and credentials; +// failover-a has the higher priority, so the proxy tries it first. +const config = { + host: '127.0.0.1', + port: Number(process.env.PORT), + 'auth-dir': join(dir, 'auths'), + 'api-keys': ['e2e-client-key'], + 'codex-api-key': [ + codex('codex-main', 'gpt-5.6-sol'), + codex('failover-a-limited', 'e2e-failover', 1), + codex('failover-b', 'e2e-failover'), + codex('cooled-a-limited', 'e2e-cooled'), + codex('cooled-b-limited', 'e2e-cooled'), + ], + 'claude-api-key': [ + { 'api-key': 'claude-main', 'base-url': url, models: [{ name: 'claude-sonnet-4-6' }] }, + ], +}; +writeFileSync(join(dir, 'config.yaml'), JSON.stringify(config, null, 2)); + +const proxy = spawn('.bin/cli-proxy-api', ['-config', join(dir, 'config.yaml'), '-local-model'], { + stdio: 'inherit', +}); +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => proxy.kill(signal)); +proxy.on('exit', (code) => process.exit(code ?? 1)); diff --git a/e2e/lib/proxy.ts b/e2e/lib/proxy.ts new file mode 100644 index 000000000..0018e3d93 --- /dev/null +++ b/e2e/lib/proxy.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; + +export const clientKey = 'e2e-client-key'; + +/** POSTs JSON to the proxy with the client key. */ +export const post = (baseUrl: string | undefined, path: string, body: unknown, headers = {}) => + fetch(new URL(path, baseUrl), { + method: 'POST', + headers: { + authorization: `Bearer ${clientKey}`, + 'content-type': 'application/json', + ...headers, + }, + body: JSON.stringify(body), + }); + +/** A Responses `input` holding one user message. */ +export const input = (text: string) => [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }, +]; + +/** The mock upstream's URL, written by hermetic/start.mjs. */ +export const mockUrl = (): string => + JSON.parse(readFileSync(new URL('../.e2e/mock.json', import.meta.url), 'utf8')).url; + +export type UpstreamRequest = { path: string; body: Record; key: string }; + +/** Requests the mock received whose body contains `marker`, oldest first. */ +export async function upstreamRequests(marker: string): Promise { + const all: UpstreamRequest[] = await (await fetch(`${mockUrl()}/_mock/requests`)).json(); + return all.filter((request) => JSON.stringify(request.body).includes(marker)); +} diff --git a/e2e/lib/sse.ts b/e2e/lib/sse.ts new file mode 100644 index 000000000..fea596b98 --- /dev/null +++ b/e2e/lib/sse.ts @@ -0,0 +1,32 @@ +export type SseEvent = { event: string | undefined; data: string }; + +/** Reads a server-sent event stream to its end, or until `signal` aborts. */ +export async function readSse(response: Response, signal: AbortSignal): Promise { + const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); + const cancel = () => void reader.cancel(signal.reason); + signal.addEventListener('abort', cancel, { once: true }); + const events: SseEvent[] = []; + let buffer = ''; + try { + for (;;) { + const { value, done } = await reader.read(); + signal.throwIfAborted(); + if (done) break; + buffer += value.replaceAll('\r\n', '\n'); + let end; + while ((end = buffer.indexOf('\n\n')) >= 0) { + const lines = buffer.slice(0, end).split('\n'); + buffer = buffer.slice(end + 2); + const field = (name: string) => + lines + .filter((line) => line.startsWith(`${name}:`)) + .map((line) => line.slice(name.length + 1).trimStart()); + const data = field('data'); + if (data.length > 0) events.push({ event: field('event')[0], data: data.join('\n') }); + } + } + } finally { + signal.removeEventListener('abort', cancel); + } + return events; +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..562b8e2e1 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "cliproxyapi-e2e", + "private": true, + "type": "module", + "scripts": { + "fmt": "oxfmt .", + "fmt:check": "oxfmt --check .", + "lint": "oxlint --deny-warnings ." + }, + "devDependencies": { + "e2e": "0.16.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", + "zod": "4.6.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "packageManager": "pnpm@11.21.0" +} diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml new file mode 100644 index 000000000..89c6b5a19 --- /dev/null +++ b/e2e/pnpm-lock.yaml @@ -0,0 +1,950 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + e2e: + specifier: 0.16.0 + version: 0.16.0 + oxfmt: + specifier: 0.71.0 + version: 0.71.0 + oxlint: + specifier: 1.86.0 + version: 1.86.0 + zod: + specifier: 4.6.1 + version: 4.6.1 + +packages: + + '@ai-sdk/provider@4.0.18': + resolution: {integrity: sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==} + engines: {node: '>=22'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@modelcontextprotocol/core@2.2.0': + resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.2.0': + resolution: {integrity: sha512-qFnltjus6Gk8Lx6J1w2RXmr3YPN22MNT6qD6zjAkaiWIlFIFxWClz0rXHNuJF/KSUyn5YGhkSvXkWDbQHVLFEQ==} + engines: {node: '>=20'} + + '@oxfmt/binding-android-arm-eabi@0.71.0': + resolution: {integrity: sha512-l31EyBfB4egJeFua11aQFEUE9nnDtfpWCadqQovm0WL30t/yUHtdFl/5HyFXvd5xT8Ewh3U4TI9f30GowrQ1fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.71.0': + resolution: {integrity: sha512-E8q39SUZzSXQ08oZoFoTkxRADkdBNgL6De4qgZYr2O4JCunwqrZwAyCebxIF3yVHBafHM30WUkhTkDi+jNYZOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.71.0': + resolution: {integrity: sha512-pTteTrN88DicrmJ3DocBmpNDa6Umfh0iveEG8rnAlDFQpclmtDS7hnIvZbIEV9mDO0Ot1U0sjtoJQ1itd/ZMhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.71.0': + resolution: {integrity: sha512-G5melRC3IiNEgXlNKJhSExJYQGAQIppHFp2cl/wFrqQcEwmc4k4TkrHaFTR29SDudjoW5AZq5cXPGJDqtlH/Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.71.0': + resolution: {integrity: sha512-Hb1P5yKb9aiH2VSbm3Ml37mKjzqpunYet9krw/YK7V5JsAUJDQlju3Yk/dEqQvJD/usBvpi01Vbk9ZDNo0a8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': + resolution: {integrity: sha512-r7n4JEO+rkp0kaP3ia+0hv6FzK3PQIHqaF0jxGuhjJ4JHVX9ZVRbgpDVDW+OloyuJsRWpWFDKIHe8gfl1p/4mQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': + resolution: {integrity: sha512-EEynTF4nakgqDhVCmd3cHQCmTcw0BM8ea6eHigTdtZ3yxdsBNv+dYkt+OpDDZx1n/jzjI7J+Ht7IgkqFWuJUxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.71.0': + resolution: {integrity: sha512-7VgJIrywCwR/G6YMv+HqsccUt8Z4q4mbM2xR+Ry/6AeMk4pCmZ9aO4Srd3mmrvVu8PsCZGP7oHEHSXWTmyloaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.71.0': + resolution: {integrity: sha512-AOCaminv/+fhinUXKvtPZT4POhNYmN9GfvaJdgJa1AbvuzClWGwDIXfJg8eu3sYaVL2B68jNvmS7LZGAYa0z3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': + resolution: {integrity: sha512-0SHxuaa4QRLM7jfsd8wc6L7KdLg+FR6/ah9k1eAvcrK/Sni7oxchMvsjwUsyakVAta+wBVDkjJTr7/RFU0uUGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': + resolution: {integrity: sha512-UNKj1+rPorTK6uSlQzpN96vDNbHbL/63zcpznweo0ImQdkDdtQCS7G3puxX++2cESPKT2B56Y6DICUmFTJde5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.71.0': + resolution: {integrity: sha512-5cVXKj7a0mWu7GU6/bZgNyv+KtLgM5S9cnT26Os2/abj8pK/bklHsbdYEHGLLniGjNYkyzwem8mZciPCaDtilw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.71.0': + resolution: {integrity: sha512-lNpcMuZvyU1V/CY5h19hac03m4E4PLSYRckcxZOynSPojI4Mwt6nEu9wT/gVf0bclH3xGEWjwqApnSTgjkd2oA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.71.0': + resolution: {integrity: sha512-5/Z6pUewQpknXqC4/ykK6Zc6RiteAnPem1Ci7K1RZLVF6w6MMjwHjR4vsjijW4Czidgv7HKeVglGjElADliT9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.71.0': + resolution: {integrity: sha512-uVdG2N/4GEbOeljpQ+xv+NeEwJWJGj0WaxSiSYnoiqIYy3RWrWd3rGUmxWXP1A8+ferNvvwFoDAtvgsDUvBuSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.71.0': + resolution: {integrity: sha512-1LwEZmSDRFKeBaGd/Hgj2xI3SmgGFpF4YtgkSx0sTYdTiZfHWbcFvRIBjL1YiDNdQd9Khda3Ij8CoqBULjskDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.71.0': + resolution: {integrity: sha512-5tplmkeMXmza3PUptJQy25wCPJbIAg5g5r7zs+DI+7eEYOqbY+7roMdSH2zbDvv1Pb4oTDJ6NWYx9IRxBxXwhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.71.0': + resolution: {integrity: sha512-T2LWgh3Vx4PYDjtcqRRGDyCsVxeF/VE5xTpUQiCm65bNLwMpRnJYj6Hlkf6qRM3+jxCIOqHD1kVHNQCCiAVMNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.71.0': + resolution: {integrity: sha512-L06SVp7Hu1EQTkh3+PHnKK26dxVbEpOG2InMwCooTE4MA437b5FgfTleCCiGn4ykISHnHU6UHaIg7SiMO7rBHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.86.0': + resolution: {integrity: sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.86.0': + resolution: {integrity: sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.86.0': + resolution: {integrity: sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.86.0': + resolution: {integrity: sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.86.0': + resolution: {integrity: sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + resolution: {integrity: sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.86.0': + resolution: {integrity: sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.86.0': + resolution: {integrity: sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.86.0': + resolution: {integrity: sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.86.0': + resolution: {integrity: sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.86.0': + resolution: {integrity: sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.86.0': + resolution: {integrity: sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.86.0': + resolution: {integrity: sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.86.0': + resolution: {integrity: sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.86.0': + resolution: {integrity: sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.86.0': + resolution: {integrity: sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.86.0': + resolution: {integrity: sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.86.0': + resolution: {integrity: sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.86.0': + resolution: {integrity: sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@vitest/expect@5.0.2': + resolution: {integrity: sha512-pd6YDkhOHptwC65NYNpGpNxWlJH9M9Gxwtej73osTddb1NpiE0Jz5CIjTTEGXRgK0YtiSN7qBokmBXZ5uPJL1w==} + + '@vitest/pretty-format@5.0.2': + resolution: {integrity: sha512-YHM+mQQ7N1ROHMugJ7P/M+fC/q1G4zs/iAMZZCvhHcY8WkwPQcXBHD+z18oz+808Cfa12K7jOr3XPQMcLIovjw==} + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + + '@vitest/utils@5.0.2': + resolution: {integrity: sha512-mgpUtxFhKeOKVaArBVHNJGdYHAeRh135o59l13uVmPUCS1OUtis5pBqXEgE25iqRgFqEBwXvJlkhknRsYnMeqg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + e2e@0.16.0: + resolution: {integrity: sha512-KXFks4yu4bCjrVgbebR/eULRzDWTpb3ECqhE+563O92vQwgwJHhIwoAKy7AsJQ5L2/r4qoCF2j5Jn3HW+DHx3A==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@ai-sdk/openai': ^4.0.0 + '@ai-sdk/openai-compatible': ^3.0.0 + '@ai-sdk/xai': ^5.0.0 + ai: ^7.0.0 + peerDependenciesMeta: + '@ai-sdk/openai': + optional: true + '@ai-sdk/openai-compatible': + optional: true + '@ai-sdk/xai': + optional: true + ai: + optional: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + oxfmt@0.71.0: + resolution: {integrity: sha512-lUPUl0d/+Io5pDrsPXWs6rB4N/bpB78oj9CTDpnbulfDz+0r3XXcHPlQ7kRPJ2GjIT4nX+/mcqunOeP9BvsEtg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.86.0: + resolution: {integrity: sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + tsx@4.23.14: + resolution: {integrity: sha512-yFwMnbAsUFz/T3kR8P2ENc/DDUaYd9tKg4oVYo0lhkX0LlK4UuqYAO6mpQ2y6lmcyip1uPJ34C2kPACopDwG4w==} + engines: {node: '>=18.0.0'} + hasBin: true + + zod@4.6.1: + resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==} + +snapshots: + + '@ai-sdk/provider@4.0.18': + dependencies: + json-schema: 0.4.0 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@modelcontextprotocol/core@2.2.0': + dependencies: + zod: 4.6.1 + + '@modelcontextprotocol/server@2.2.0': + dependencies: + '@modelcontextprotocol/core': 2.2.0 + zod: 4.6.1 + + '@oxfmt/binding-android-arm-eabi@0.71.0': + optional: true + + '@oxfmt/binding-android-arm64@0.71.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.71.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.71.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.71.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.71.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.71.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.71.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.71.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.71.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.71.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.86.0': + optional: true + + '@oxlint/binding-android-arm64@1.86.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.86.0': + optional: true + + '@oxlint/binding-darwin-x64@1.86.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.86.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.86.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.86.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.86.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.86.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.86.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.86.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.86.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.86.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@vitest/expect@5.0.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 5.0.2 + '@vitest/utils': 5.0.2 + chai: 6.3.0 + tinyrainbow: 3.2.0 + + '@vitest/pretty-format@5.0.2': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/spy@5.0.2': {} + + '@vitest/utils@5.0.2': + dependencies: + '@vitest/pretty-format': 5.0.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + + assertion-error@2.0.1: {} + + chai@6.3.0: {} + + commander@15.0.0: {} + + convert-source-map@2.0.0: {} + + e2e@0.16.0: + dependencies: + '@ai-sdk/provider': 4.0.18 + '@clack/prompts': 1.8.1 + '@modelcontextprotocol/server': 2.2.0 + '@vitest/expect': 5.0.2 + commander: 15.0.0 + picocolors: 1.1.1 + pngjs: 7.0.0 + tsx: 4.23.14 + zod: 4.6.1 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fsevents@2.3.3: + optional: true + + json-schema@0.4.0: {} + + oxfmt@0.71.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.71.0 + '@oxfmt/binding-android-arm64': 0.71.0 + '@oxfmt/binding-darwin-arm64': 0.71.0 + '@oxfmt/binding-darwin-x64': 0.71.0 + '@oxfmt/binding-freebsd-x64': 0.71.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.71.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.71.0 + '@oxfmt/binding-linux-arm64-gnu': 0.71.0 + '@oxfmt/binding-linux-arm64-musl': 0.71.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-musl': 0.71.0 + '@oxfmt/binding-linux-s390x-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-musl': 0.71.0 + '@oxfmt/binding-openharmony-arm64': 0.71.0 + '@oxfmt/binding-win32-arm64-msvc': 0.71.0 + '@oxfmt/binding-win32-ia32-msvc': 0.71.0 + '@oxfmt/binding-win32-x64-msvc': 0.71.0 + + oxlint@1.86.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.86.0 + '@oxlint/binding-android-arm64': 1.86.0 + '@oxlint/binding-darwin-arm64': 1.86.0 + '@oxlint/binding-darwin-x64': 1.86.0 + '@oxlint/binding-freebsd-x64': 1.86.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.86.0 + '@oxlint/binding-linux-arm-musleabihf': 1.86.0 + '@oxlint/binding-linux-arm64-gnu': 1.86.0 + '@oxlint/binding-linux-arm64-musl': 1.86.0 + '@oxlint/binding-linux-ppc64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-musl': 1.86.0 + '@oxlint/binding-linux-s390x-gnu': 1.86.0 + '@oxlint/binding-linux-x64-gnu': 1.86.0 + '@oxlint/binding-linux-x64-musl': 1.86.0 + '@oxlint/binding-openharmony-arm64': 1.86.0 + '@oxlint/binding-win32-arm64-msvc': 1.86.0 + '@oxlint/binding-win32-ia32-msvc': 1.86.0 + '@oxlint/binding-win32-x64-msvc': 1.86.0 + + picocolors@1.1.1: {} + + pngjs@7.0.0: {} + + sisteransi@1.0.5: {} + + tinypool@2.2.0: {} + + tinyrainbow@3.2.0: {} + + tsx@4.23.14: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + zod@4.6.1: {} diff --git a/e2e/pnpm-workspace.yaml b/e2e/pnpm-workspace.yaml new file mode 100644 index 000000000..49c0ad742 --- /dev/null +++ b/e2e/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: false diff --git a/e2e/tests/chat-completions.e2e.ts b/e2e/tests/chat-completions.e2e.ts new file mode 100644 index 000000000..49a637ffd --- /dev/null +++ b/e2e/tests/chat-completions.e2e.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'e2e'; +import { post } from '../lib/proxy.ts'; +import { readSse } from '../lib/sse.ts'; + +test( + 'streaming Chat Completions with json_schema ends with a usage chunk', + { platforms: ['hermetic'] }, + async ({ app }) => { + const schema = { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + additionalProperties: false, + }; + const response = await post(app.baseUrl, '/v1/chat/completions', { + model: 'gpt-5.6-sol', + stream: true, + stream_options: { include_usage: true }, + response_format: { type: 'json_schema', json_schema: { name: 'ok', schema, strict: true } }, + messages: [{ role: 'user', content: 'hi' }], + }); + expect(response.status).toBe(200); + const events = await readSse(response, AbortSignal.timeout(10_000)); + expect(events.at(-1)?.data).toBe('[DONE]'); + const chunks = events.slice(0, -1).map((event) => JSON.parse(event.data)); + const text = chunks.map((chunk) => chunk.choices[0]?.delta.content ?? '').join(''); + expect(JSON.parse(text)).toEqual({ ok: true }); + expect(chunks.at(-1).usage).toEqual({ + prompt_tokens: 5, + completion_tokens: 3, + total_tokens: 8, + }); + }, +); diff --git a/e2e/tests/failover.e2e.ts b/e2e/tests/failover.e2e.ts new file mode 100644 index 000000000..6e85b3c93 --- /dev/null +++ b/e2e/tests/failover.e2e.ts @@ -0,0 +1,48 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { expect, test } from 'e2e'; +import { input, mockUrl, post, upstreamRequests } from '../lib/proxy.ts'; + +const hermetic = { platforms: ['hermetic'] }; + +// The auth index of a config Codex key (sdk/cliproxy/auth/types.go, stableAuthIndex/indexSeed). +const authIndex = (key: string) => + createHash('sha256').update(`codex-api-key:${mockUrl()}/v1+${key}`).digest('hex').slice(0, 16); + +test('a usage-limited Codex credential fails over to the next one', hermetic, async ({ app }) => { + const marker = randomUUID(); + const response = await post(app.baseUrl, '/v1/responses', { + model: 'e2e-failover', + input: input(marker), + }); + expect(response.status).toBe(200); + expect((await response.json()).status).toBe('completed'); + const [, servedBy] = /^\d{14}-([0-9a-f]{16})-/.exec(response.headers.get('x-cpa-trace-id')!)!; + expect(servedBy).toBe(authIndex('failover-b')); + const keys = (await upstreamRequests(marker)).map((request) => request.key); + expect(keys).toEqual(['failover-a-limited', 'failover-b']); +}); + +test( + 'all Codex credentials cooled returns the local 429 model_cooldown', + hermetic, + async ({ app }) => { + const first = await post(app.baseUrl, '/v1/responses', { + model: 'e2e-cooled', + input: input(randomUUID()), + }); + expect(first.status).toBe(429); + + const marker = randomUUID(); + const response = await post(app.baseUrl, '/v1/responses', { + model: 'e2e-cooled', + input: input(marker), + }); + expect(response.status).toBe(429); + expect((await response.json()).error).toMatchObject({ + code: 'model_cooldown', + model: 'e2e-cooled', + provider: 'codex', + }); + expect(await upstreamRequests(marker)).toEqual([]); + }, +); diff --git a/e2e/tests/messages.e2e.ts b/e2e/tests/messages.e2e.ts new file mode 100644 index 000000000..386a7cbac --- /dev/null +++ b/e2e/tests/messages.e2e.ts @@ -0,0 +1,36 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from 'e2e'; +import { clientKey, post } from '../lib/proxy.ts'; +import { readSse } from '../lib/sse.ts'; + +test( + 'Claude Code shape: streaming Messages with a session id', + { platforms: ['hermetic'] }, + async ({ app }) => { + const response = await post( + app.baseUrl, + '/v1/messages?beta=true', + { + model: 'claude-sonnet-4-6', + max_tokens: 64, + stream: true, + messages: [{ role: 'user', content: 'hi' }], + }, + { + 'x-api-key': clientKey, + 'anthropic-version': '2023-06-01', + 'X-Claude-Code-Session-Id': randomUUID(), + }, + ); + expect(response.status).toBe(200); + const events = await readSse(response, AbortSignal.timeout(10_000)); + expect(events.map((event) => event.event)).toEqual([ + 'message_start', + 'content_block_start', + 'content_block_delta', + 'content_block_stop', + 'message_delta', + 'message_stop', + ]); + }, +); diff --git a/e2e/tests/models.e2e.ts b/e2e/tests/models.e2e.ts new file mode 100644 index 000000000..c2b733aca --- /dev/null +++ b/e2e/tests/models.e2e.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'e2e'; +import { clientKey } from '../lib/proxy.ts'; + +const hermetic = { platforms: ['hermetic'] }; + +test('GET /v1/models without a key returns 401', hermetic, async ({ app }) => { + const response = await fetch(new URL('/v1/models', app.baseUrl)); + expect(response.status).toBe(401); +}); + +test( + 'GET /v1/models with the client key lists the configured models', + hermetic, + async ({ app }) => { + const response = await fetch(new URL('/v1/models', app.baseUrl), { + headers: { authorization: `Bearer ${clientKey}` }, + }); + expect(response.status).toBe(200); + const { data } = await response.json(); + expect(data.map((model: { id: string }) => model.id).sort()).toEqual([ + 'claude-sonnet-4-6', + 'e2e-cooled', + 'e2e-failover', + 'gpt-5.6-sol', + ]); + }, +); diff --git a/e2e/tests/responses.e2e.ts b/e2e/tests/responses.e2e.ts new file mode 100644 index 000000000..6b62edaae --- /dev/null +++ b/e2e/tests/responses.e2e.ts @@ -0,0 +1,77 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from 'e2e'; +import { z } from 'zod'; +import { input, post, upstreamRequests } from '../lib/proxy.ts'; +import { readSse } from '../lib/sse.ts'; + +const hermetic = { platforms: ['hermetic'] }; + +const CompletedResponse = z.object({ + status: z.literal('completed'), + output: z.array( + z.object({ type: z.literal('message'), content: z.array(z.object({ text: z.string() })) }), + ), +}); + +test( + 'Open SWE shape: non-streaming Responses keeps tools, store and include', + hermetic, + async ({ app }) => { + const marker = randomUUID(); + const tool = { + type: 'function', + name: 'read_file', + description: 'Read a file', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, + strict: false, + }; + const response = await post(app.baseUrl, '/v1/responses', { + model: 'gpt-5.6-sol', + stream: false, + store: false, + include: ['reasoning.encrypted_content'], + reasoning: { effort: 'xhigh', summary: 'auto' }, + tools: [tool], + input: input(marker), + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = expect(await response.json()).toMatchSchema(CompletedResponse); + expect(body.output[0].content[0].text).toBe('{"ok":true}'); + + const [upstream] = await upstreamRequests(marker); + expect(upstream.body).toMatchObject({ + store: false, + include: ['reasoning.encrypted_content'], + tools: expect.arrayContaining([tool]), + }); + }, +); + +test('streaming Responses relays the upstream events in order', hermetic, async ({ app }) => { + const response = await post(app.baseUrl, '/v1/responses', { + model: 'gpt-5.6-sol', + stream: true, + input: input('hi'), + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + const events = await readSse(response, AbortSignal.timeout(10_000)); + expect(events.map((event) => JSON.parse(event.data).type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.output_text.delta', + 'response.output_item.done', + 'response.completed', + ]); +}); + +test('Responses with a Claude model returns a complete response', hermetic, async ({ app }) => { + const response = await post(app.baseUrl, '/v1/responses', { + model: 'claude-sonnet-4-6', + input: input('hi'), + }); + expect(response.status).toBe(200); + const body = expect(await response.json()).toMatchSchema(CompletedResponse); + expect(body.output[0].content[0].text).toBe('ok'); +});