diff --git a/.env b/.env new file mode 100644 index 000000000..e637682be --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +# Copilot API Environment Configuration +# Copy this file to .env and modify as needed + +# Trace folder for logging LLM requests and responses +# Used when --trace flag is enabled +# Default: ./traces (current working directory) +TRACE_OUTPUT_FOLDER=./traces + +# Idle timeout in seconds for the Bun server +# Default: 255 seconds +IDLE_TIMEOUT=255 + +# Model mappings to redirect requests from one model to another +# Format: "source1:target1,source2:target2" +# Example: "claude-sonnet-4-20250514:claude-opus-4,gpt-4:gpt-4-turbo" +# MODEL_MAPPINGS="claude-sonnet-4-5-20250929:claude-sonnet-4.5,claude-opus-4-5-20251101:claude-opus-4.5,claude-haiku-4-5-20251001:claude-haiku-4.5" +MODEL_MAPPINGS="claude-opus-4-6:claude-opus-4.6,claude-sonnet-4-5:claude-sonnet-4.5,claude-opus-4-5:claude-opus-4.5,claude-haiku-4-5:claude-haiku-4.5" diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..95088bad5 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copilot API Environment Configuration +# Copy this file to .env and modify as needed + +# Trace folder for logging LLM requests and responses +# Used when --trace flag is enabled +# Default: ./traces (current working directory) +TRACE_OUTPUT_FOLDER=./traces + +# Idle timeout in seconds for the Bun server +# Default: 255 seconds +IDLE_TIMEOUT=255 + +# Model mappings to redirect requests from one model to another +# Format: "source1:target1,source2:target2" +# Example: "claude-sonnet-4-20250514:claude-opus-4,gpt-4:gpt-4-turbo" +# MODEL_MAPPINGS= diff --git a/README.md b/README.md index 0d36c13c9..83f7f8417 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ https://github.com/user-attachments/assets/7654b383-669d-4eb9-b23c-06d7aefee8c5 ## Prerequisites -- Bun (>= 1.2.x) +- Bun (>= 1.2.x) [Bun Installation](https://bun.com/docs/installation#windows) - GitHub account with Copilot subscription (individual, business, or enterprise) ## Installation @@ -349,3 +349,23 @@ bun run start - `--rate-limit `: Enforces a minimum time interval between requests. For example, `copilot-api start --rate-limit 30` will ensure there's at least a 30-second gap between requests. - `--wait`: Use this with `--rate-limit`. It makes the server wait for the cooldown period to end instead of rejecting the request with an error. This is useful for clients that don't automatically retry on rate limit errors. - If you have a GitHub business or enterprise plan account with Copilot, use the `--account-type` flag (e.g., `--account-type business`). See the [official documentation](https://docs.github.com/en/enterprise-cloud@latest/copilot/managing-copilot/managing-github-copilot-in-your-organization/managing-access-to-github-copilot-in-your-organization/managing-github-copilot-access-to-your-organizations-network#configuring-copilot-subscription-based-network-routing-for-your-enterprise-or-organization) for more details. + +## Trace + +## Enable VS Code Claude Extension + +- Copy [settings.json](/settings.json) to `C:/Users//.claude/settings.json` + (Create the file if it does not already exist.) +- Restart the VS Code Claude extension + (Close the current Claude chat and reopen it.) + +### Optional: Lightweight Windows Terminal startup + +- Copy the full path of `start-claude.cmd`. +- Add it to Windows Terminal: + 1. Open Windows Terminal. + 2. Go to **Settings → Add a new profile → New empty profile**. + 3. Enter a clear name and paste the script path into the **Command line** field. + 4. Save the profile and open it from the dropdown list. + 5. Configure `start-copilot-api.cmd` using the same steps. +- You can now start the Copilot API and Claude scripts directly from Windows Terminal. \ No newline at end of file diff --git a/package.json b/package.json index a5adbb8e7..345a9546f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "scripts": { "build": "tsdown", - "dev": "bun run --watch ./src/main.ts", + "dev": "bun run --watch ./src/main.ts start", "knip": "knip-bun", "lint": "eslint --cache", "lint:all": "eslint --cache .", diff --git a/settings.json b/settings.json new file mode 100644 index 000000000..90974fe69 --- /dev/null +++ b/settings.json @@ -0,0 +1,20 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:4141", + "ANTHROPIC_AUTH_TOKEN": "dummy", + "ANTHROPIC_MODEL": "claude-opus-4-6", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-6", + "CLAUDE_CODE_SUBAGENT_MODEL": "claude-opus-4-6", + "CLAUDE_CODE_ATTRIBUTION_HEADER": "0", + "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD": "1" + }, + "permissions": { + "deny": [ + "WebSearch" + ] + }, + "model": "claude-opus-4-6", + "autoUpdatesChannel": "latest" +} \ No newline at end of file diff --git a/src/lib/model-mapping.ts b/src/lib/model-mapping.ts new file mode 100644 index 000000000..723b37c56 --- /dev/null +++ b/src/lib/model-mapping.ts @@ -0,0 +1,92 @@ +import consola from "consola" + +/** + * Parse model mappings from environment variable. + * Format: "source1:target1,source2:target2" + * Example: "claude-sonnet-4-20250514:claude-opus-4-20250514,gpt-4:gpt-4-turbo" + */ +export function parseModelMappings(envValue?: string): Map { + const mappings = new Map() + + if (!envValue || envValue.trim() === "") { + return mappings + } + + const pairs = envValue.split(",") + for (const pair of pairs) { + const trimmedPair = pair.trim() + if (!trimmedPair) continue + + const colonIndex = trimmedPair.indexOf(":") + if (colonIndex === -1) { + consola.warn(`Invalid model mapping format: "${trimmedPair}". Expected "source:target"`) + continue + } + + const source = trimmedPair.slice(0, colonIndex).trim() + const target = trimmedPair.slice(colonIndex + 1).trim() + + if (!source || !target) { + consola.warn(`Invalid model mapping: "${trimmedPair}". Both source and target must be non-empty`) + continue + } + + mappings.set(source, target) + } + + return mappings +} + +/** + * Apply model mapping if the requested model matches a mapping. + * Returns the mapped model name and whether a mapping was applied. + */ +export function applyModelMapping( + modelName: string, + mappings: Map, + verbose: boolean = false, +): { model: string; mapped: boolean } { + const mappedModel = mappings.get(modelName) + + if (mappedModel) { + if (verbose) { + consola.warn(`Model mapping applied: "${modelName}" -> "${mappedModel}"`) + } + return { model: mappedModel, mapped: true } + } + + return { model: modelName, mapped: false } +} + +/** + * Get model mappings from environment variable. + * Caches the parsed result for performance. + */ +let cachedMappings: Map | null = null +let cachedEnvValue: string | undefined + +export function getModelMappings(): Map { + const envValue = process.env.MODEL_MAPPINGS + + // Return cached mappings if env value hasn't changed + if (cachedMappings !== null && cachedEnvValue === envValue) { + return cachedMappings + } + + cachedEnvValue = envValue + cachedMappings = parseModelMappings(envValue) + + if (cachedMappings.size > 0) { + consola.info(`Loaded ${cachedMappings.size} model mapping(s)`) + } + + return cachedMappings +} + +/** + * Clear the cached model mappings (useful for testing) + */ +export function clearModelMappingsCache(): void { + cachedMappings = null + cachedEnvValue = undefined +} diff --git a/src/lib/state.ts b/src/lib/state.ts index 5ba4dc1d1..fd08a6d8d 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -11,10 +11,15 @@ export interface State { manualApprove: boolean rateLimitWait: boolean showToken: boolean + verbose: boolean // Rate limiting configuration rateLimitSeconds?: number lastRequestTimestamp?: number + + // Trace configuration + traceEnabled: boolean + traceFolder?: string } export const state: State = { @@ -22,4 +27,6 @@ export const state: State = { manualApprove: false, rateLimitWait: false, showToken: false, + verbose: false, + traceEnabled: false, } diff --git a/src/lib/trace.ts b/src/lib/trace.ts new file mode 100644 index 000000000..1268ad7a8 --- /dev/null +++ b/src/lib/trace.ts @@ -0,0 +1,121 @@ +import consola from "consola" +import fs from "node:fs/promises" +import path from "node:path" + +import { state } from "./state" + +/** + * Generates a timestamp string for trace file naming. + * Format: YYYYMMDD_HHmmss_SSS (e.g., 20251204_143052_123) + */ +function getTimestamp(): string { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, "0") + const day = String(now.getDate()).padStart(2, "0") + const hours = String(now.getHours()).padStart(2, "0") + const minutes = String(now.getMinutes()).padStart(2, "0") + const seconds = String(now.getSeconds()).padStart(2, "0") + const millis = String(now.getMilliseconds()).padStart(3, "0") + return `${year}${month}${day}_${hours}${minutes}${seconds}_${millis}` +} + +/** + * Ensures the trace folder exists. + */ +export async function ensureTraceFolder(): Promise { + if (!state.traceEnabled || !state.traceFolder) return + + try { + await fs.mkdir(state.traceFolder, { recursive: true }) + consola.debug(`Trace folder ensured: ${state.traceFolder}`) + } catch (error) { + consola.error("Failed to create trace folder:", error) + } +} + +/** + * Logs a request to the trace folder. + * @param request The request payload to log + * @returns The timestamp used for the request file (to use for matching response) + */ +export async function traceRequest(request: unknown): Promise { + if (!state.traceEnabled || !state.traceFolder) return null + + const timestamp = getTimestamp() + const filename = `${timestamp}.req` + const filepath = path.join(state.traceFolder, filename) + + try { + const content = JSON.stringify(request, null, 2) + await fs.writeFile(filepath, content, "utf8") + consola.debug(`Trace request written: ${filepath}`) + return timestamp + } catch (error) { + consola.error("Failed to write trace request:", error) + return null + } +} + +/** + * Logs a response to the trace folder. + * @param response The response payload to log + * @param timestamp The timestamp from the corresponding request + */ +export async function traceResponse( + response: unknown, + timestamp: string | null, +): Promise { + if (!state.traceEnabled || !state.traceFolder || !timestamp) return + + const filename = `${timestamp}.resp` + const filepath = path.join(state.traceFolder, filename) + + try { + const content = JSON.stringify(response, null, 2) + await fs.writeFile(filepath, content, "utf8") + consola.debug(`Trace response written: ${filepath}`) + } catch (error) { + consola.error("Failed to write trace response:", error) + } +} + +/** + * Logs a streaming response to the trace folder. + * For streaming responses, we collect all chunks and write them at the end. + */ +export class StreamTracer { + private chunks: Array = [] + private timestamp: string | null + + constructor(timestamp: string | null) { + this.timestamp = timestamp + } + + addChunk(chunk: unknown): void { + if (!state.traceEnabled) return + this.chunks.push(chunk) + } + + async finish(): Promise { + if (!state.traceEnabled || !state.traceFolder || !this.timestamp) return + + const filename = `${this.timestamp}.resp` + const filepath = path.join(state.traceFolder, filename) + + try { + const content = JSON.stringify( + { + streaming: true, + chunks: this.chunks, + }, + null, + 2, + ) + await fs.writeFile(filepath, content, "utf8") + consola.debug(`Trace streaming response written: ${filepath}`) + } catch (error) { + consola.error("Failed to write trace streaming response:", error) + } + } +} diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 04a5ae9ed..95ad6fb21 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -4,9 +4,11 @@ import consola from "consola" import { streamSSE, type SSEMessage } from "hono/streaming" import { awaitApproval } from "~/lib/approval" +import { applyModelMapping, getModelMappings } from "~/lib/model-mapping" import { checkRateLimit } from "~/lib/rate-limit" import { state } from "~/lib/state" import { getTokenCount } from "~/lib/tokenizer" +import { StreamTracer, traceRequest, traceResponse } from "~/lib/trace" import { isNullish } from "~/lib/utils" import { createChatCompletions, @@ -20,6 +22,18 @@ export async function handleCompletion(c: Context) { let payload = await c.req.json() consola.debug("Request payload:", JSON.stringify(payload).slice(-400)) + // Apply model mapping if configured + const mappings = getModelMappings() + if (mappings.size > 0) { + const { model, mapped } = applyModelMapping(payload.model, mappings, state.verbose) + if (mapped) { + payload = { ...payload, model } + } + } + + // Trace the request + const traceTimestamp = await traceRequest(payload) + // Find the selected model const selectedModel = state.models?.data.find( (model) => model.id === payload.model, @@ -51,15 +65,20 @@ export async function handleCompletion(c: Context) { if (isNonStreaming(response)) { consola.debug("Non-streaming response:", JSON.stringify(response)) + // Trace the non-streaming response + await traceResponse(response, traceTimestamp) return c.json(response) } consola.debug("Streaming response") return streamSSE(c, async (stream) => { + const streamTracer = new StreamTracer(traceTimestamp) for await (const chunk of response) { consola.debug("Streaming chunk:", JSON.stringify(chunk)) + streamTracer.addChunk(chunk) await stream.writeSSE(chunk as SSEMessage) } + await streamTracer.finish() }) } diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index 85dbf6243..fd5b2f801 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -4,8 +4,10 @@ import consola from "consola" import { streamSSE } from "hono/streaming" import { awaitApproval } from "~/lib/approval" +import { applyModelMapping, getModelMappings } from "~/lib/model-mapping" import { checkRateLimit } from "~/lib/rate-limit" import { state } from "~/lib/state" +import { StreamTracer, traceRequest, traceResponse } from "~/lib/trace" import { createChatCompletions, type ChatCompletionChunk, @@ -25,9 +27,24 @@ import { translateChunkToAnthropicEvents } from "./stream-translation" export async function handleCompletion(c: Context) { await checkRateLimit(state) - const anthropicPayload = await c.req.json() + let anthropicPayload = await c.req.json() consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload)) + // Apply model mapping if configured + const mappings = getModelMappings() + if (mappings.size > 0) { + const { model, mapped } = applyModelMapping(anthropicPayload.model, mappings, state.verbose) + if (mapped) { + anthropicPayload = { ...anthropicPayload, model } + } + } + + // Trace the original Anthropic request + const traceTimestamp = await traceRequest({ + type: "anthropic", + original: anthropicPayload, + }) + const openAIPayload = translateToOpenAI(anthropicPayload) consola.debug( "Translated OpenAI request payload:", @@ -50,6 +67,15 @@ export async function handleCompletion(c: Context) { "Translated Anthropic response:", JSON.stringify(anthropicResponse), ) + // Trace the response (both OpenAI and translated Anthropic) + await traceResponse( + { + type: "anthropic", + openai: response, + translated: anthropicResponse, + }, + traceTimestamp, + ) return c.json(anthropicResponse) } @@ -62,6 +88,8 @@ export async function handleCompletion(c: Context) { toolCalls: {}, } + const streamTracer = new StreamTracer(traceTimestamp) + for await (const rawEvent of response) { consola.debug("Copilot raw stream event:", JSON.stringify(rawEvent)) if (rawEvent.data === "[DONE]") { @@ -77,12 +105,15 @@ export async function handleCompletion(c: Context) { for (const event of events) { consola.debug("Translated Anthropic event:", JSON.stringify(event)) + streamTracer.addChunk({ openai: chunk, anthropic: event }) await stream.writeSSE({ event: event.type, data: JSON.stringify(event), }) } } + + await streamTracer.finish() }) } diff --git a/src/start.ts b/src/start.ts index 14abbbdff..f27cb5847 100644 --- a/src/start.ts +++ b/src/start.ts @@ -3,6 +3,7 @@ import { defineCommand } from "citty" import clipboard from "clipboardy" import consola from "consola" +import path from "node:path" import { serve, type ServerHandler } from "srvx" import invariant from "tiny-invariant" @@ -11,6 +12,7 @@ import { initProxyFromEnv } from "./lib/proxy" import { generateEnvScript } from "./lib/shell" import { state } from "./lib/state" import { setupCopilotToken, setupGitHubToken } from "./lib/token" +import { ensureTraceFolder } from "./lib/trace" import { cacheModels, cacheVSCodeVersion } from "./lib/utils" import { server } from "./server" @@ -25,6 +27,8 @@ interface RunServerOptions { claudeCode: boolean showToken: boolean proxyEnv: boolean + trace: boolean + traceFolder?: string } export async function runServer(options: RunServerOptions): Promise { @@ -34,6 +38,7 @@ export async function runServer(options: RunServerOptions): Promise { if (options.verbose) { consola.level = 5 + state.verbose = true consola.info("Verbose logging enabled") } @@ -47,6 +52,18 @@ export async function runServer(options: RunServerOptions): Promise { state.rateLimitWait = options.rateLimitWait state.showToken = options.showToken + // Configure tracing + if (options.trace) { + state.traceEnabled = true + // Use provided folder, env variable, or default to ./traces + state.traceFolder = + options.traceFolder + || process.env.TRACE_OUTPUT_FOLDER + || path.join(process.cwd(), "traces") + consola.info(`Tracing enabled. Logs will be saved to: ${state.traceFolder}`) + await ensureTraceFolder() + } + await ensurePaths() await cacheVSCodeVersion() @@ -114,9 +131,18 @@ export async function runServer(options: RunServerOptions): Promise { `🌐 Usage Viewer: https://ericc-ch.github.io/copilot-api?endpoint=${serverUrl}/usage`, ) + const idleTimeout = + process.env.IDLE_TIMEOUT ? + Number.parseInt(process.env.IDLE_TIMEOUT, 10) + : 255 + serve({ fetch: server.fetch as ServerHandler, port: options.port, + // Pass idleTimeout through the bun-specific options + bun: { + idleTimeout, + }, }) } @@ -184,6 +210,18 @@ export const start = defineCommand({ default: false, description: "Initialize proxy from environment variables", }, + trace: { + alias: "t", + type: "boolean", + default: false, + description: + "Enable tracing to log all LLM requests and responses to files", + }, + "trace-folder": { + type: "string", + description: + "Folder to save trace files (defaults to TRACE_OUTPUT_FOLDER env var or ./traces)", + }, }, run({ args }) { const rateLimitRaw = args["rate-limit"] @@ -202,6 +240,8 @@ export const start = defineCommand({ claudeCode: args["claude-code"], showToken: args["show-token"], proxyEnv: args["proxy-env"], + trace: args.trace, + traceFolder: args["trace-folder"], }) }, }) diff --git a/start-claude.cmd b/start-claude.cmd new file mode 100644 index 000000000..61dd2469d --- /dev/null +++ b/start-claude.cmd @@ -0,0 +1,17 @@ +@ECHO OFF +set ANTHROPIC_BASE_URL=http://localhost:4141 +set ANTHROPIC_AUTH_TOKEN=dummy +set ANTHROPIC_MODEL=claude-opus-4-6 +set ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5 +set ANTHROPIC_SMALL_FAST_MODEL=claude-sonnet-4-5 +set ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4-5 +set CLAUDE_CODE_SUBAGENT_MODEL=claude-opus-4-6 +set CLAUDE_CODE_ATTRIBUTION_HEADER=0 +set CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 +set CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=95 +set CLAUDE_CODE_EFFORT_LEVEL=high +if not exist "c:/src/controlplane" mkdir "c:/src/controlplane" +pushd c:/src/controlplane +ECHO === Claude starting === +ECHO === (Ctrl+C to stop Claude only) === +cmd /k claude \ No newline at end of file diff --git a/start-copilot-api.cmd b/start-copilot-api.cmd new file mode 100644 index 000000000..492e05f58 --- /dev/null +++ b/start-copilot-api.cmd @@ -0,0 +1,12 @@ +@echo off +echo ================================================ +echo GitHub Copilot API Server with Usage Viewer +echo Start Copilot API Server at %~dp0 +echo ================================================ +echo. + +ECHO Starting Copilot-Api service... + +CALL CD /D %~dp0 && npm run dev + +pause diff --git a/start.bat b/start.bat index 1a0f8cb83..c621304bd 100644 --- a/start.bat +++ b/start.bat @@ -17,4 +17,4 @@ echo. start "" "https://ericc-ch.github.io/copilot-api?endpoint=http://localhost:4141/usage" bun run dev -pause +pause \ No newline at end of file diff --git a/tests/model-mapping.test.ts b/tests/model-mapping.test.ts new file mode 100644 index 000000000..b8b6fe551 --- /dev/null +++ b/tests/model-mapping.test.ts @@ -0,0 +1,215 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" + +import { + parseModelMappings, + applyModelMapping, + getModelMappings, + clearModelMappingsCache, +} from "../src/lib/model-mapping" + +describe("parseModelMappings", () => { + test("should return empty map for undefined input", () => { + const result = parseModelMappings(undefined) + expect(result.size).toBe(0) + }) + + test("should return empty map for empty string", () => { + const result = parseModelMappings("") + expect(result.size).toBe(0) + }) + + test("should return empty map for whitespace-only string", () => { + const result = parseModelMappings(" ") + expect(result.size).toBe(0) + }) + + test("should parse single mapping", () => { + const result = parseModelMappings("claude-sonnet-4:claude-opus-4") + expect(result.size).toBe(1) + expect(result.get("claude-sonnet-4")).toBe("claude-opus-4") + }) + + test("should parse multiple mappings", () => { + const result = parseModelMappings( + "claude-sonnet-4:claude-opus-4,gpt-4:gpt-4-turbo", + ) + expect(result.size).toBe(2) + expect(result.get("claude-sonnet-4")).toBe("claude-opus-4") + expect(result.get("gpt-4")).toBe("gpt-4-turbo") + }) + + test("should handle whitespace around mappings", () => { + const result = parseModelMappings( + " claude-sonnet-4 : claude-opus-4 , gpt-4 : gpt-4-turbo ", + ) + expect(result.size).toBe(2) + expect(result.get("claude-sonnet-4")).toBe("claude-opus-4") + expect(result.get("gpt-4")).toBe("gpt-4-turbo") + }) + + test("should skip invalid mappings without colon", () => { + const result = parseModelMappings("valid-source:valid-target,invalid-mapping") + expect(result.size).toBe(1) + expect(result.get("valid-source")).toBe("valid-target") + }) + + test("should skip mappings with empty source", () => { + const result = parseModelMappings(":target,valid-source:valid-target") + expect(result.size).toBe(1) + expect(result.get("valid-source")).toBe("valid-target") + }) + + test("should skip mappings with empty target", () => { + const result = parseModelMappings("source:,valid-source:valid-target") + expect(result.size).toBe(1) + expect(result.get("valid-source")).toBe("valid-target") + }) + + test("should handle model names with multiple colons (target contains colon)", () => { + // Edge case: if target somehow contains colons + const result = parseModelMappings("source:target:with:colons") + expect(result.size).toBe(1) + expect(result.get("source")).toBe("target:with:colons") + }) + + test("should skip empty pairs from trailing comma", () => { + const result = parseModelMappings("source:target,") + expect(result.size).toBe(1) + expect(result.get("source")).toBe("target") + }) + + test("should handle model names with version numbers", () => { + const result = parseModelMappings( + "claude-sonnet-4-20250514:claude-opus-4-20250514", + ) + expect(result.size).toBe(1) + expect(result.get("claude-sonnet-4-20250514")).toBe("claude-opus-4-20250514") + }) +}) + +describe("applyModelMapping", () => { + test("should return original model when no mappings", () => { + const mappings = new Map() + const result = applyModelMapping("gpt-4", mappings) + expect(result.model).toBe("gpt-4") + expect(result.mapped).toBe(false) + }) + + test("should return original model when no match found", () => { + const mappings = new Map([["claude-sonnet-4", "claude-opus-4"]]) + const result = applyModelMapping("gpt-4", mappings) + expect(result.model).toBe("gpt-4") + expect(result.mapped).toBe(false) + }) + + test("should return mapped model when match found", () => { + const mappings = new Map([["claude-sonnet-4", "claude-opus-4"]]) + const result = applyModelMapping("claude-sonnet-4", mappings) + expect(result.model).toBe("claude-opus-4") + expect(result.mapped).toBe(true) + }) + + test("should handle multiple mappings and return correct match", () => { + const mappings = new Map([ + ["claude-sonnet-4", "claude-opus-4"], + ["gpt-4", "gpt-4-turbo"], + ]) + + const result1 = applyModelMapping("claude-sonnet-4", mappings) + expect(result1.model).toBe("claude-opus-4") + expect(result1.mapped).toBe(true) + + const result2 = applyModelMapping("gpt-4", mappings) + expect(result2.model).toBe("gpt-4-turbo") + expect(result2.mapped).toBe(true) + + const result3 = applyModelMapping("gpt-3.5-turbo", mappings) + expect(result3.model).toBe("gpt-3.5-turbo") + expect(result3.mapped).toBe(false) + }) + + test("should work with verbose=false (default)", () => { + const mappings = new Map([["source", "target"]]) + const result = applyModelMapping("source", mappings, false) + expect(result.model).toBe("target") + expect(result.mapped).toBe(true) + }) + + test("should work with verbose=true", () => { + const mappings = new Map([["source", "target"]]) + // Just ensure it doesn't throw - verbose only affects logging + const result = applyModelMapping("source", mappings, true) + expect(result.model).toBe("target") + expect(result.mapped).toBe(true) + }) +}) + +describe("getModelMappings", () => { + const originalEnv = process.env.MODEL_MAPPINGS + + beforeEach(() => { + clearModelMappingsCache() + }) + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.MODEL_MAPPINGS + } else { + process.env.MODEL_MAPPINGS = originalEnv + } + clearModelMappingsCache() + }) + + test("should return empty map when env variable is not set", () => { + delete process.env.MODEL_MAPPINGS + const result = getModelMappings() + expect(result.size).toBe(0) + }) + + test("should parse env variable correctly", () => { + process.env.MODEL_MAPPINGS = "claude-sonnet-4:claude-opus-4" + const result = getModelMappings() + expect(result.size).toBe(1) + expect(result.get("claude-sonnet-4")).toBe("claude-opus-4") + }) + + test("should cache results for same env value", () => { + process.env.MODEL_MAPPINGS = "source:target" + const result1 = getModelMappings() + const result2 = getModelMappings() + // Both should return the same cached map instance + expect(result1).toBe(result2) + }) + + test("should refresh cache when env value changes", () => { + process.env.MODEL_MAPPINGS = "source1:target1" + const result1 = getModelMappings() + expect(result1.get("source1")).toBe("target1") + + process.env.MODEL_MAPPINGS = "source2:target2" + const result2 = getModelMappings() + expect(result2.get("source2")).toBe("target2") + expect(result2.get("source1")).toBeUndefined() + }) +}) + +describe("clearModelMappingsCache", () => { + afterEach(() => { + delete process.env.MODEL_MAPPINGS + clearModelMappingsCache() + }) + + test("should clear the cache", () => { + process.env.MODEL_MAPPINGS = "source:target" + const result1 = getModelMappings() + expect(result1.size).toBe(1) + + clearModelMappingsCache() + + // After clearing, it should re-parse from env + process.env.MODEL_MAPPINGS = "different:mapping" + const result2 = getModelMappings() + expect(result2.get("different")).toBe("mapping") + expect(result2.get("source")).toBeUndefined() + }) +})