From 87da2d000f8805ea282f6a0466609fc2d648f5dc Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 29 Mar 2026 19:33:05 +0200 Subject: [PATCH 1/2] modify default anthropic.transformer to use oauth --- packages/core/src/api/routes.ts | 9 +- packages/core/src/services/provider.ts | 33 +++- .../src/transformer/anthropic.transformer.ts | 148 +++++++++++++++++- 3 files changed, 180 insertions(+), 10 deletions(-) diff --git a/packages/core/src/api/routes.ts b/packages/core/src/api/routes.ts index bfe24aa26..caf2323ef 100644 --- a/packages/core/src/api/routes.ts +++ b/packages/core/src/api/routes.ts @@ -309,8 +309,13 @@ async function sendRequestToProvider( const url = config.url || new URL(provider.baseUrl); // Handle authentication in passthrough mode - if (bypass && typeof transformer.auth === "function") { - const auth = await transformer.auth(requestBody, provider); + // Prefer provider's own transformer instance (may have options like OAuth:true) over the global one + const authTransformer = + bypass && provider.transformer?.use?.length === 1 + ? provider.transformer.use[0] + : transformer; + if (bypass && typeof authTransformer.auth === "function") { + const auth = await authTransformer.auth(requestBody, provider, { headers: config.headers }); if (auth.body) { requestBody = auth.body; let headers = config.headers || {}; diff --git a/packages/core/src/services/provider.ts b/packages/core/src/services/provider.ts index aa08a2023..fc10e5df7 100644 --- a/packages/core/src/services/provider.ts +++ b/packages/core/src/services/provider.ts @@ -45,9 +45,20 @@ export class ProviderService { if (Array.isArray(providerConfig.transformer.use)) { transformer.use = providerConfig.transformer.use.map((transformer) => { if (Array.isArray(transformer) && typeof transformer[0] === 'string') { - const Constructor = this.transformerService.getTransformer(transformer[0]); - if (Constructor) { - return new (Constructor as TransformerConstructor)(transformer[1]); + const registered = this.transformerService.getTransformer(transformer[0]); + if (registered) { + if (typeof registered === 'function') { + return new (registered as TransformerConstructor)(transformer[1]); + } + // Instance-registered transformer: create new from its constructor with options + const Ctor = (registered as any).constructor; + if (Ctor && typeof Ctor === 'function') { + const instance = new Ctor(transformer[1]); + if (instance && typeof instance === 'object') { + (instance as any).logger = this.logger; + } + return instance; + } } } if (typeof transformer === 'string') { @@ -64,9 +75,19 @@ export class ProviderService { transformer[key] = { use: providerConfig.transformer[key].use.map((transformer) => { if (Array.isArray(transformer) && typeof transformer[0] === 'string') { - const Constructor = this.transformerService.getTransformer(transformer[0]); - if (Constructor) { - return new (Constructor as TransformerConstructor)(transformer[1]); + const registered = this.transformerService.getTransformer(transformer[0]); + if (registered) { + if (typeof registered === 'function') { + return new (registered as TransformerConstructor)(transformer[1]); + } + const Ctor = (registered as any).constructor; + if (Ctor && typeof Ctor === 'function') { + const instance = new Ctor(transformer[1]); + if (instance && typeof instance === 'object') { + (instance as any).logger = this.logger; + } + return instance; + } } } if (typeof transformer === 'string') { diff --git a/packages/core/src/transformer/anthropic.transformer.ts b/packages/core/src/transformer/anthropic.transformer.ts index 3231c8029..020997946 100644 --- a/packages/core/src/transformer/anthropic.transformer.ts +++ b/packages/core/src/transformer/anthropic.transformer.ts @@ -14,21 +14,165 @@ import { v4 as uuidv4 } from "uuid"; import { getThinkLevel } from "@/utils/thinking"; import { createApiError } from "@/api/middleware"; import { formatBase64 } from "@/utils/image"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Whitelist of top-level fields accepted by the Anthropic /v1/messages API (OAuth mode) +const ALLOWED_FIELDS = new Set([ + "model", "messages", "system", "max_tokens", + "metadata", "stop_sequences", "stream", + "temperature", "top_p", "top_k", + "tools", "tool_choice", + "thinking", +]); + +// Whitelist of keys allowed inside cache_control objects (OAuth mode) +const ALLOWED_CACHE_CONTROL_KEYS = new Set(["type"]); export class AnthropicTransformer implements Transformer { name = "Anthropic"; endPoint = "/v1/messages"; private useBearer: boolean; + private oauth: boolean; + private _tokenCache: string | null = null; + private _tokenCacheExpiry = 0; logger?: any; constructor(private readonly options?: TransformerOptions) { this.useBearer = this.options?.UseBearer ?? false; + this.oauth = this.options?.OAuth ?? false; } - async auth(request: any, provider: LLMProvider): Promise { + // --------------------------------------------------------------------------- + // OAuth token resolution + // --------------------------------------------------------------------------- + + private _readTokenFromObject(stringObject: string): string | null { + try { + const object = JSON.parse(stringObject); + return object?.claudeAiOauth?.accessToken || null; + } catch { + return null; + } + } + + private _readTokenFromFile(): string | null { + const credPath = join( + process.env.HOME || "/root", + ".claude", + ".credentials.json" + ); + try { + const raw = readFileSync(credPath, "utf-8"); + const data = JSON.parse(raw); + return data?.claudeAiOauth?.accessToken || null; + } catch { + return null; + } + } + + private _readTokenFromKeychain(): string | null { + try { + const hex = execFileSync( + "security", + ["find-generic-password", "-s", "Claude Code-credentials", "-w"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] } + ).trim(); + return this._readTokenFromObject(hex); + } catch { + return null; + } + } + + private _resolveOAuthToken(provider: LLMProvider): string | null { + const now = Date.now(); + if (this._tokenCache && now < this._tokenCacheExpiry) { + return this._tokenCache; + } + + let token: string | null = null; + + // 1. Explicit env var + if (process.env.CCR_OAUTH_TOKEN) { + token = process.env.CCR_OAUTH_TOKEN; + } + // 2. Linux credentials file + if (!token) { + token = this._readTokenFromFile(); + } + // 3. macOS Keychain + if (!token) { + token = this._readTokenFromKeychain(); + } + // 4. api_key from config + if (!token) { + token = provider.apiKey; + } + + if (token) { + this._tokenCache = token; + this._tokenCacheExpiry = now + 60_000; + } + return token; + } + + // --------------------------------------------------------------------------- + // Request sanitization (OAuth mode) + // --------------------------------------------------------------------------- + + private _sanitizeRequest(request: Record): void { + for (const key of Object.keys(request)) { + if (!ALLOWED_FIELDS.has(key)) { + delete request[key]; + } + } + this._sanitizeCacheControl(request); + } + + private _sanitizeCacheControl(obj: any): void { + if (Array.isArray(obj)) { + obj.forEach((item) => this._sanitizeCacheControl(item)); + } else if (obj && typeof obj === "object") { + if (obj.cache_control && typeof obj.cache_control === "object") { + for (const key of Object.keys(obj.cache_control)) { + if (!ALLOWED_CACHE_CONTROL_KEYS.has(key)) { + delete obj.cache_control[key]; + } + } + } + for (const val of Object.values(obj)) { + this._sanitizeCacheControl(val); + } + } + } + + async auth( + request: any, + provider: LLMProvider, + context?: TransformerContext + ): Promise { const headers: Record = {}; - if (this.useBearer) { + if (this.oauth) { + this._sanitizeRequest(request); + const token = this._resolveOAuthToken(provider); + + // Preserve existing anthropic-beta values and append oauth flag + const existingBeta = + (context?.headers?.["anthropic-beta"] as string) || ""; + const betaSet = new Set( + existingBeta + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + ); + betaSet.add("oauth-2025-04-20"); + + headers["authorization"] = `Bearer ${token}`; + headers["x-api-key"] = undefined; + headers["anthropic-beta"] = [...betaSet].join(","); + } else if (this.useBearer) { headers["authorization"] = `Bearer ${provider.apiKey}`; headers["x-api-key"] = undefined; } else { From 474bedb370e6a2ea471ab1137dd1e987577ee794 Mon Sep 17 00:00:00 2001 From: Claudio Date: Tue, 19 May 2026 18:09:17 +0200 Subject: [PATCH 2/2] test: add vitest and tests for anthropic.transformer --- packages/core/package.json | 8 +- .../__tests__/anthropic.transformer.test.ts | 617 ++++++++++++++++++ packages/core/vitest.config.ts | 11 + pnpm-lock.yaml | 360 ++++++++++ 4 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/transformer/__tests__/anthropic.transformer.test.ts create mode 100644 packages/core/vitest.config.ts diff --git a/packages/core/package.json b/packages/core/package.json index d798a0ecb..9da5715fd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,9 @@ "dev": "nodemon", "start": "node dist/cjs/server.cjs", "start:esm": "node dist/esm/server.mjs", - "lint": "eslint src --ext .ts,.tsx" + "lint": "eslint src --ext .ts,.tsx", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [ "llm", @@ -54,6 +56,8 @@ "@types/node": "^24.0.15", "esbuild": "^0.25.1", "tsx": "^4.20.3", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.0.0" } } diff --git a/packages/core/src/transformer/__tests__/anthropic.transformer.test.ts b/packages/core/src/transformer/__tests__/anthropic.transformer.test.ts new file mode 100644 index 000000000..4972e82ae --- /dev/null +++ b/packages/core/src/transformer/__tests__/anthropic.transformer.test.ts @@ -0,0 +1,617 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readFileSync: vi.fn() }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFileSync: vi.fn() }; +}); + +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { AnthropicTransformer } from "../anthropic.transformer"; +import type { LLMProvider } from "@/types/llm"; + +const mockReadFileSync = vi.mocked(readFileSync); +const mockExecFileSync = vi.mocked(execFileSync); + +const provider = (overrides: Partial = {}): LLMProvider => + ({ + name: "Anthropic", + baseUrl: "https://api.anthropic.com/v1/messages", + apiKey: "sk-from-config", + models: ["claude-sonnet-4-6"], + ...overrides, + }) as LLMProvider; + +const fsNotFound = () => { + const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + throw err; +}; + +const keychainMissing = () => { + throw new Error("keychain unavailable"); +}; + +beforeEach(() => { + delete process.env.CCR_OAUTH_TOKEN; + mockReadFileSync.mockImplementation(fsNotFound); + mockExecFileSync.mockImplementation(keychainMissing); +}); + +describe("AnthropicTransformer.auth", () => { + describe("default (x-api-key)", () => { + it("sends x-api-key with the provider apiKey and no Authorization header", async () => { + const t = new AnthropicTransformer(); + const result = await t.auth({}, provider({ apiKey: "sk-abc" })); + expect(result.config.headers).toEqual({ + "x-api-key": "sk-abc", + authorization: undefined, + }); + }); + }); + + describe("UseBearer", () => { + it("sends Authorization: Bearer and clears x-api-key", async () => { + const t = new AnthropicTransformer({ UseBearer: true }); + const result = await t.auth({}, provider({ apiKey: "sk-abc" })); + expect(result.config.headers).toEqual({ + authorization: "Bearer sk-abc", + "x-api-key": undefined, + }); + }); + }); + + describe("OAuth", () => { + it("uses CCR_OAUTH_TOKEN when set (highest priority)", async () => { + process.env.CCR_OAUTH_TOKEN = "tok-env"; + mockReadFileSync.mockReturnValue( + JSON.stringify({ claudeAiOauth: { accessToken: "tok-file" } }) + ); + + const t = new AnthropicTransformer({ OAuth: true }); + const result = await t.auth({}, provider()); + + expect(result.config.headers.authorization).toBe("Bearer tok-env"); + expect(result.config.headers["x-api-key"]).toBeUndefined(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it("falls back to ~/.claude/.credentials.json when no env token", async () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ claudeAiOauth: { accessToken: "tok-file" } }) + ); + + const t = new AnthropicTransformer({ OAuth: true }); + const result = await t.auth({}, provider()); + + expect(result.config.headers.authorization).toBe("Bearer tok-file"); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it("falls back to macOS Keychain when env + file are unavailable", async () => { + mockExecFileSync.mockReturnValue( + JSON.stringify({ claudeAiOauth: { accessToken: "tok-keychain" } }) + ); + + const t = new AnthropicTransformer({ OAuth: true }); + const result = await t.auth({}, provider()); + + expect(result.config.headers.authorization).toBe("Bearer tok-keychain"); + }); + + it("falls back to provider.apiKey as last resort", async () => { + const t = new AnthropicTransformer({ OAuth: true }); + const result = await t.auth({}, provider({ apiKey: "sk-config" })); + expect(result.config.headers.authorization).toBe("Bearer sk-config"); + }); + + it("appends oauth-2025-04-20 to existing anthropic-beta header", async () => { + process.env.CCR_OAUTH_TOKEN = "tok"; + const t = new AnthropicTransformer({ OAuth: true }); + const result = await t.auth({}, provider(), { + headers: { "anthropic-beta": "feature-x,feature-y" }, + }); + const beta = (result.config.headers["anthropic-beta"] as string).split(","); + expect(beta).toEqual( + expect.arrayContaining(["feature-x", "feature-y", "oauth-2025-04-20"]) + ); + expect(beta).toHaveLength(3); + }); + + it("sets anthropic-beta to oauth-2025-04-20 when no prior header", async () => { + process.env.CCR_OAUTH_TOKEN = "tok"; + const t = new AnthropicTransformer({ OAuth: true }); + const result = await t.auth({}, provider()); + expect(result.config.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + }); + + it("strips disallowed top-level fields from the request", async () => { + process.env.CCR_OAUTH_TOKEN = "tok"; + const req: Record = { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hi" }], + max_tokens: 100, + // disallowed: + user: "shouldGo", + custom_field: "shouldGo", + }; + + const t = new AnthropicTransformer({ OAuth: true }); + await t.auth(req, provider()); + + expect(req).toEqual({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hi" }], + max_tokens: 100, + }); + }); + + it("filters non-'type' keys from cache_control objects (deeply)", async () => { + process.env.CCR_OAUTH_TOKEN = "tok"; + const req: Record = { + model: "claude-sonnet-4-6", + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "hi", + cache_control: { type: "ephemeral", ttl: "1h" }, + }, + ], + }, + ], + }; + + const t = new AnthropicTransformer({ OAuth: true }); + await t.auth(req, provider()); + + expect(req.messages[0].content[0].cache_control).toEqual({ + type: "ephemeral", + }); + }); + + it.each([ + ["context omitted entirely", undefined], + ["context provided but headers missing", {}], + ["context.headers present but anthropic-beta missing", { headers: {} }], + ])( + "tolerates missing context shape (%s) and still sets oauth beta header", + async (_label, contextArg) => { + process.env.CCR_OAUTH_TOKEN = "tok"; + const t = new AnthropicTransformer({ OAuth: true }); + const result = await t.auth({}, provider(), contextArg as any); + expect(result.config.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + expect(result.config.headers.authorization).toBe("Bearer tok"); + } + ); + + it("caches the resolved token for ~60s (no re-read on second call)", async () => { + process.env.CCR_OAUTH_TOKEN = "tok"; + const t = new AnthropicTransformer({ OAuth: true }); + + await t.auth({}, provider()); + await t.auth({}, provider()); + + // env-var resolution doesn't touch fs/exec at all; just verify the + // cache shortcircuits the resolver — second call shouldn't re-check + // any fallback source either. + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + }); +}); + +describe("AnthropicTransformer.transformRequestOut", () => { + const t = new AnthropicTransformer(); + + it("converts string system prompt into a system message", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + system: "you are helpful", + messages: [], + }); + expect(out.messages[0]).toEqual({ role: "system", content: "you are helpful" }); + }); + + it("converts array system prompt and preserves cache_control on text parts", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + system: [ + { type: "text", text: "rule one", cache_control: { type: "ephemeral" } }, + { type: "text", text: "rule two" }, + { type: "image", source: {} }, // non-text, should be filtered out + ], + messages: [], + }); + expect(out.messages[0]).toEqual({ + role: "system", + content: [ + { type: "text", text: "rule one", cache_control: { type: "ephemeral" } }, + { type: "text", text: "rule two", cache_control: undefined }, + ], + }); + }); + + it("passes a user string message through unchanged", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hello" }], + }); + expect(out.messages).toEqual([{ role: "user", content: "hello" }]); + }); + + it("converts user image content to image_url with base64 data URI", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe this" }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "iVBORw0KG" }, + }, + ], + }, + ], + }); + const userMsg = out.messages[0] as any; + expect(userMsg.role).toBe("user"); + expect(userMsg.content[0]).toEqual({ type: "text", text: "describe this" }); + expect(userMsg.content[1]).toMatchObject({ + type: "image_url", + image_url: { url: "data:image/png;base64,iVBORw0KG" }, + media_type: "image/png", + }); + }); + + it("emits a separate tool role message for each tool_result block", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "result-text", + }, + { + type: "tool_result", + tool_use_id: "tool-2", + content: [{ type: "text", text: "structured" }], + }, + ], + }, + ], + }); + expect(out.messages).toHaveLength(2); + expect(out.messages[0]).toMatchObject({ + role: "tool", + content: "result-text", + tool_call_id: "tool-1", + }); + expect(out.messages[1]).toMatchObject({ + role: "tool", + content: JSON.stringify([{ type: "text", text: "structured" }]), + tool_call_id: "tool-2", + }); + }); + + it("joins assistant text parts and converts tool_use to tool_calls", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + { + type: "tool_use", + id: "tu-1", + name: "search", + input: { q: "ts" }, + }, + ], + }, + ], + }); + const msg = out.messages[0] as any; + expect(msg.role).toBe("assistant"); + expect(msg.content).toBe("first\nsecond"); + expect(msg.tool_calls).toEqual([ + { + id: "tu-1", + type: "function", + function: { name: "search", arguments: JSON.stringify({ q: "ts" }) }, + }, + ]); + }); + + it("preserves assistant thinking block (content + signature)", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning here", signature: "sig-xyz" }, + { type: "text", text: "answer" }, + ], + }, + ], + }); + const msg = out.messages[0] as any; + expect(msg.thinking).toEqual({ content: "reasoning here", signature: "sig-xyz" }); + expect(msg.content).toBe("answer"); + }); + + it("maps Anthropic tools to unified function tools", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [], + tools: [ + { + name: "search", + description: "search the web", + input_schema: { type: "object", properties: { q: { type: "string" } } }, + }, + ], + }); + expect(out.tools).toEqual([ + { + type: "function", + function: { + name: "search", + description: "search the web", + parameters: { type: "object", properties: { q: { type: "string" } } }, + }, + }, + ]); + }); + + it("maps thinking config to reasoning with effort + enabled flag", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [], + thinking: { type: "enabled", budget_tokens: 10000 }, + }); + expect(out.reasoning).toMatchObject({ enabled: true }); + expect(out.reasoning?.effort).toBeDefined(); + }); + + it.each([ + [{ type: "auto" }, "auto"], + [{ type: "any" }, "any"], + ])("maps tool_choice %j to %s", async (input, expected) => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [], + tool_choice: input, + }); + expect(out.tool_choice).toBe(expected); + }); + + it("maps tool_choice {type:'tool', name} to function form", async () => { + const out = await t.transformRequestOut({ + model: "claude-sonnet-4-6", + messages: [], + tool_choice: { type: "tool", name: "search" }, + }); + expect(out.tool_choice).toEqual({ + type: "function", + function: { name: "search" }, + }); + }); +}); + +describe("AnthropicTransformer.transformResponseIn (non-stream)", () => { + const makeContext = () => ({ req: { id: "test-req" } }) as any; + const makeJsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + }); + + const newTransformer = () => { + const t = new AnthropicTransformer(); + (t as any).logger = { debug: vi.fn() }; + return t; + }; + + it("converts a plain text completion into a single text content block", async () => { + const t = newTransformer(); + const openaiResponse = makeJsonResponse({ + id: "cmpl-1", + model: "claude-sonnet-4-6", + choices: [ + { + finish_reason: "stop", + message: { role: "assistant", content: "hello there" }, + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }); + + const result = await t.transformResponseIn(openaiResponse, makeContext()); + const body = await result.json(); + + expect(body).toMatchObject({ + id: "cmpl-1", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "hello there" }], + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0 }, + }); + }); + + it("converts tool_calls into tool_use content blocks with parsed arguments", async () => { + const t = newTransformer(); + const openaiResponse = makeJsonResponse({ + id: "cmpl-2", + model: "claude-sonnet-4-6", + choices: [ + { + finish_reason: "tool_calls", + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call-1", + function: { name: "search", arguments: JSON.stringify({ q: "ts" }) }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 8, completion_tokens: 4 }, + }); + + const body = await (await t.transformResponseIn(openaiResponse, makeContext())).json(); + + expect(body.stop_reason).toBe("tool_use"); + expect(body.content).toEqual([ + { type: "tool_use", id: "call-1", name: "search", input: { q: "ts" } }, + ]); + }); + + it("falls back to {text: } when tool_call arguments are unparseable", async () => { + const t = newTransformer(); + const openaiResponse = makeJsonResponse({ + id: "cmpl-3", + model: "claude-sonnet-4-6", + choices: [ + { + finish_reason: "tool_calls", + message: { + role: "assistant", + content: null, + tool_calls: [ + { id: "call-x", function: { name: "noop", arguments: "{not-json" } }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + + const body = await (await t.transformResponseIn(openaiResponse, makeContext())).json(); + + expect(body.content[0].input).toEqual({ text: "{not-json" }); + }); + + it("emits a thinking block when the message carries thinking metadata", async () => { + const t = newTransformer(); + const openaiResponse = makeJsonResponse({ + id: "cmpl-4", + model: "claude-sonnet-4-6", + choices: [ + { + finish_reason: "stop", + message: { + role: "assistant", + content: "answer", + thinking: { content: "reasoning", signature: "sig" }, + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + + const body = await (await t.transformResponseIn(openaiResponse, makeContext())).json(); + + expect(body.content).toContainEqual({ + type: "thinking", + thinking: "reasoning", + signature: "sig", + }); + }); + + it("translates annotations into server_tool_use + web_search_tool_result blocks", async () => { + const t = newTransformer(); + const openaiResponse = makeJsonResponse({ + id: "cmpl-5", + model: "claude-sonnet-4-6", + choices: [ + { + finish_reason: "stop", + message: { + role: "assistant", + content: "see refs", + annotations: [ + { url_citation: { url: "https://a", title: "A" } }, + { url_citation: { url: "https://b", title: "B" } }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + + const body = await (await t.transformResponseIn(openaiResponse, makeContext())).json(); + + const serverToolUse = body.content.find((c: any) => c.type === "server_tool_use"); + const webResult = body.content.find((c: any) => c.type === "web_search_tool_result"); + + expect(serverToolUse).toMatchObject({ name: "web_search" }); + expect(webResult.tool_use_id).toBe(serverToolUse.id); + expect(webResult.content).toEqual([ + { type: "web_search_result", url: "https://a", title: "A" }, + { type: "web_search_result", url: "https://b", title: "B" }, + ]); + }); + + it.each([ + ["stop", "end_turn"], + ["length", "max_tokens"], + ["tool_calls", "tool_use"], + ["content_filter", "stop_sequence"], + ["unknown_value", "end_turn"], + ])("maps finish_reason '%s' → stop_reason '%s'", async (finish, stop) => { + const t = newTransformer(); + const openaiResponse = makeJsonResponse({ + id: "cmpl-fr", + model: "claude-sonnet-4-6", + choices: [ + { finish_reason: finish, message: { role: "assistant", content: "x" } }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0 }, + }); + const body = await (await t.transformResponseIn(openaiResponse, makeContext())).json(); + expect(body.stop_reason).toBe(stop); + }); + + it("subtracts cached tokens from input_tokens and surfaces cache_read_input_tokens", async () => { + const t = newTransformer(); + const openaiResponse = makeJsonResponse({ + id: "cmpl-cache", + model: "claude-sonnet-4-6", + choices: [ + { finish_reason: "stop", message: { role: "assistant", content: "ok" } }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + prompt_tokens_details: { cached_tokens: 30 }, + }, + }); + const body = await (await t.transformResponseIn(openaiResponse, makeContext())).json(); + expect(body.usage).toEqual({ + input_tokens: 70, + output_tokens: 20, + cache_read_input_tokens: 30, + }); + }); +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 000000000..2d5065916 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + include: ["src/**/*.test.ts"], + clearMocks: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32164c7d7..7a0b99775 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,12 @@ importers: typescript: specifier: ^5.8.2 version: 5.8.3 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.8.3)(vite@7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)) + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) packages/server: dependencies: @@ -2664,56 +2670,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -2892,24 +2909,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2992,6 +3013,9 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect-history-api-fallback@1.5.4': resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} @@ -3001,6 +3025,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3230,6 +3257,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -3441,6 +3497,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -3587,6 +3647,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -3635,6 +3699,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3662,6 +3730,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -4027,6 +4099,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4229,6 +4305,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -4403,6 +4482,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} @@ -4675,6 +4758,9 @@ packages: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-auth-library@10.4.0: resolution: {integrity: sha512-CmIrSy1bqMQUsPmA9+hcSbAXL80cFhu40cGMUjCaLpNKVzzvi+0uAHq8GNZxkoGYIsTX4ZQ7e4aInAqWxgn4fg==} engines: {node: '>=18'} @@ -5130,6 +5216,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -5253,24 +5342,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -5337,6 +5430,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -5977,6 +6073,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7023,6 +7126,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -7106,6 +7212,9 @@ packages: resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} engines: {node: '>=12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -7176,6 +7285,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -7297,6 +7409,12 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -7305,6 +7423,14 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -7363,6 +7489,16 @@ packages: '@swc/wasm': optional: true + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7570,6 +7706,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-singlefile@2.3.0: resolution: {integrity: sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==} engines: {node: '>18.0.0'} @@ -7577,6 +7718,14 @@ packages: rollup: ^4.44.1 vite: ^5.4.11 || ^6.0.0 || ^7.0.0 + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7617,6 +7766,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -7713,6 +7890,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@4.0.1: resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} engines: {node: '>=12'} @@ -11090,6 +11272,11 @@ snapshots: dependencies: '@types/node': 24.7.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 4.19.7 @@ -11103,6 +11290,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -11388,6 +11577,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -11633,6 +11864,8 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + astring@1.9.0: {} async@3.2.6: {} @@ -11794,6 +12027,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + cacheable-lookup@7.0.0: {} cacheable-request@10.2.14: @@ -11847,6 +12082,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -11866,6 +12109,8 @@ snapshots: chardet@0.7.0: {} + check-error@2.1.3: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -12247,6 +12492,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -12429,6 +12676,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: @@ -12686,6 +12935,8 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + expect-type@1.3.0: {} + express@4.22.1: dependencies: accepts: 1.3.8 @@ -13039,6 +13290,8 @@ snapshots: merge2: 1.4.1 slash: 4.0.0 + globrex@0.1.2: {} + google-auth-library@10.4.0: dependencies: base64-js: 1.5.1 @@ -13563,6 +13816,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -13736,6 +13991,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -14620,6 +14877,10 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -15811,6 +16072,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -15897,6 +16160,8 @@ snapshots: srcset@4.0.0: {} + stackback@0.0.2: {} + state-local@1.0.7: {} statuses@1.5.0: {} @@ -15965,6 +16230,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -16114,6 +16383,10 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -16121,6 +16394,10 @@ snapshots: tinypool@1.1.1: {} + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -16169,6 +16446,10 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tsconfck@3.1.6(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + tslib@2.8.1: {} tsx@4.21.0: @@ -16368,12 +16649,44 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-singlefile@2.3.0(rollup@4.54.0)(vite@7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)): dependencies: micromatch: 4.0.8 rollup: 4.54.0 vite: 7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.8.3) + optionalDependencies: + vite: 7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0): dependencies: esbuild: 0.27.2 @@ -16390,6 +16703,48 @@ snapshots: terser: 5.44.1 tsx: 4.21.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.7.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@3.1.0: {} watchpack@2.5.0: @@ -16553,6 +16908,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@4.0.1: dependencies: string-width: 5.1.2