From 9710a5c3a22280eb9a58005d860d67ddb89aabb8 Mon Sep 17 00:00:00 2001 From: kylehuang Date: Thu, 11 Dec 2025 22:06:55 -0800 Subject: [PATCH 1/9] update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0d36c13c9..884af9cd2 100644 --- a/README.md +++ b/README.md @@ -349,3 +349,5 @@ 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 \ No newline at end of file From a04ef10a734fed757e3622b231ccb8f6b4884adb Mon Sep 17 00:00:00 2001 From: kylehuang Date: Thu, 11 Dec 2025 22:29:38 -0800 Subject: [PATCH 2/9] support model mapping --- .env | 16 ++ .env.example | 16 ++ src/lib/model-mapping.ts | 92 +++++++++++ src/lib/state.ts | 7 + src/lib/trace.ts | 121 ++++++++++++++ src/routes/chat-completions/handler.ts | 19 +++ src/routes/messages/handler.ts | 33 +++- src/start.ts | 40 +++++ tests/model-mapping.test.ts | 215 +++++++++++++++++++++++++ 9 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 src/lib/model-mapping.ts create mode 100644 src/lib/trace.ts create mode 100644 tests/model-mapping.test.ts diff --git a/.env b/.env new file mode 100644 index 000000000..4c819e703 --- /dev/null +++ b/.env @@ -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="claude-opus-4-5-20251101:claude-opus-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/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/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() + }) +}) From 5fe26f73a4ccdc4672c9713aca85b87901725165 Mon Sep 17 00:00:00 2001 From: Kyle Huang Date: Thu, 15 Jan 2026 21:07:35 -0800 Subject: [PATCH 3/9] Update .env --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index 4c819e703..469c8d8f7 100644 --- a/.env +++ b/.env @@ -13,4 +13,4 @@ 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-opus-4-5-20251101:claude-opus-4.5" +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" From 3074b38199cb1f9afd4a5b058d44566d80f86207 Mon Sep 17 00:00:00 2001 From: Wenbo Zhao <179686042+wenbo97@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:55:26 +0800 Subject: [PATCH 4/9] Create quick start copilot-api cmd shell; Create quick start claude cli shell; Update Bun download link in readme.md --- README.md | 2 +- package.json | 2 +- start-claude.cmd | 10 ++++++++++ start-copilot-api.cmd | 12 ++++++++++++ start.bat | 20 -------------------- 5 files changed, 24 insertions(+), 22 deletions(-) create mode 100644 start-claude.cmd create mode 100644 start-copilot-api.cmd delete mode 100644 start.bat diff --git a/README.md b/README.md index 884af9cd2..b52955622 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 diff --git a/package.json b/package.json index a5adbb8e7..4a9ff6468 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 --trace", "knip": "knip-bun", "lint": "eslint --cache", "lint:all": "eslint --cache .", diff --git a/start-claude.cmd b/start-claude.cmd new file mode 100644 index 000000000..5446f334e --- /dev/null +++ b/start-claude.cmd @@ -0,0 +1,10 @@ +@ECHO OFF + +set ANTHROPIC_BASE_URL=http://localhost:4141 +set ANTHROPIC_AUTH_TOKEN=dummy-key +set ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4.5 +set ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4.5 +set ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.5 +call claude + +ECHO Exist 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 deleted file mode 100644 index 1a0f8cb83..000000000 --- a/start.bat +++ /dev/null @@ -1,20 +0,0 @@ -@echo off -echo ================================================ -echo GitHub Copilot API Server with Usage Viewer -echo ================================================ -echo. - -if not exist node_modules ( - echo Installing dependencies... - bun install - echo. -) - -echo Starting server... -echo The usage viewer page will open automatically after the server starts -echo. - -start "" "https://ericc-ch.github.io/copilot-api?endpoint=http://localhost:4141/usage" -bun run dev - -pause From f46efd6d1efe36dc5ffd6e2cb5f2023cede646e3 Mon Sep 17 00:00:00 2001 From: Wenbo Zhao <179686042+wenbo97@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:58:47 +0800 Subject: [PATCH 5/9] Remove trace option as default --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a9ff6468..345a9546f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "scripts": { "build": "tsdown", - "dev": "bun run --watch ./src/main.ts start --trace", + "dev": "bun run --watch ./src/main.ts start", "knip": "knip-bun", "lint": "eslint --cache", "lint:all": "eslint --cache .", From 6170c5b793e47022aefd057dc0250b9a41c70337 Mon Sep 17 00:00:00 2001 From: Wenbo Zhao <179686042+wenbo97@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:02:11 +0800 Subject: [PATCH 6/9] Recover start.bat --- start.bat | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 start.bat diff --git a/start.bat b/start.bat new file mode 100644 index 000000000..c621304bd --- /dev/null +++ b/start.bat @@ -0,0 +1,20 @@ +@echo off +echo ================================================ +echo GitHub Copilot API Server with Usage Viewer +echo ================================================ +echo. + +if not exist node_modules ( + echo Installing dependencies... + bun install + echo. +) + +echo Starting server... +echo The usage viewer page will open automatically after the server starts +echo. + +start "" "https://ericc-ch.github.io/copilot-api?endpoint=http://localhost:4141/usage" +bun run dev + +pause \ No newline at end of file From 8a10bb8e18057f4252751799d094dbc3366fb4e8 Mon Sep 17 00:00:00 2001 From: "Wenbo Zhao (Shanghai Wicresoft Co Ltd) (from Dev Box)" Date: Thu, 29 Jan 2026 13:15:43 +0800 Subject: [PATCH 7/9] Update start-claude.cmd to bypass CLAUDE_CODE_ATTRIBUTION_HEADER to fix x-anthropic-billing-header error; Add the settings file to use VS Code Claude Code --- README.md | 20 +++++++++++++++++++- settings.json | 18 ++++++++++++++++++ start-claude.cmd | 14 +++++++++----- 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 settings.json diff --git a/README.md b/README.md index b52955622..83f7f8417 100644 --- a/README.md +++ b/README.md @@ -350,4 +350,22 @@ bun run start - `--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 \ No newline at end of file +## 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/settings.json b/settings.json new file mode 100644 index 000000000..b61fbb132 --- /dev/null +++ b/settings.json @@ -0,0 +1,18 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:4141", + "ANTHROPIC_AUTH_TOKEN": "dummy", + "ANTHROPIC_MODEL": "claude-opus-4.5", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.5", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4.5", + "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" + }, + "permissions": { + "deny": [ + "WebSearch" + ] + }, + "model": "claude-opus-4.5", + "autoUpdatesChannel": "latest" +} \ No newline at end of file diff --git a/start-claude.cmd b/start-claude.cmd index 5446f334e..079835f65 100644 --- a/start-claude.cmd +++ b/start-claude.cmd @@ -1,10 +1,14 @@ @ECHO OFF - set ANTHROPIC_BASE_URL=http://localhost:4141 set ANTHROPIC_AUTH_TOKEN=dummy-key +set ANTHROPIC_API_KEY=dummy-key +set ANTHROPIC_MODEL=claude-opus-4.5 +set ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.5 set ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4.5 set ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4.5 -set ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.5 -call claude - -ECHO Exist claude... \ No newline at end of file +set CLAUDE_CODE_ATTRIBUTION_HEADER=0 +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 From 3a68a8807fba5f2c661928492a7460fda6d5e209 Mon Sep 17 00:00:00 2001 From: "Wenbo Zhao (Shanghai Wicresoft Co Ltd) (from Dev Box)" Date: Tue, 10 Feb 2026 10:28:32 +0800 Subject: [PATCH 8/9] Update model to claude-opus-4-6; Update settings.json and start claude script --- .env | 2 +- settings.json | 14 ++++++++------ start-claude.cmd | 15 +++++++++------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.env b/.env index 469c8d8f7..26f0be38c 100644 --- a/.env +++ b/.env @@ -13,4 +13,4 @@ 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/settings.json b/settings.json index b61fbb132..90974fe69 100644 --- a/settings.json +++ b/settings.json @@ -2,17 +2,19 @@ "env": { "ANTHROPIC_BASE_URL": "http://localhost:4141", "ANTHROPIC_AUTH_TOKEN": "dummy", - "ANTHROPIC_MODEL": "claude-opus-4.5", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.5", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4.5", - "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" + "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.5", + "model": "claude-opus-4-6", "autoUpdatesChannel": "latest" } \ No newline at end of file diff --git a/start-claude.cmd b/start-claude.cmd index 079835f65..61dd2469d 100644 --- a/start-claude.cmd +++ b/start-claude.cmd @@ -1,12 +1,15 @@ @ECHO OFF set ANTHROPIC_BASE_URL=http://localhost:4141 -set ANTHROPIC_AUTH_TOKEN=dummy-key -set ANTHROPIC_API_KEY=dummy-key -set ANTHROPIC_MODEL=claude-opus-4.5 -set ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.5 -set ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4.5 -set ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4.5 +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 === From ca28c9a0eb898eb1ace297a2221bbab4301f5434 Mon Sep 17 00:00:00 2001 From: "Wenbo Zhao (Shanghai Wicresoft Co Ltd) (from Dev Box)" Date: Tue, 10 Feb 2026 10:32:53 +0800 Subject: [PATCH 9/9] Backup previous model mapping --- .env | 1 + 1 file changed, 1 insertion(+) diff --git a/.env b/.env index 26f0be38c..e637682be 100644 --- a/.env +++ b/.env @@ -13,4 +13,5 @@ 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"