diff --git a/.env.docker.example b/.env.docker.example index b536ae3..8eeca02 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -17,6 +17,13 @@ ZAI_API_KEY=your-zai-api-key-here # Anthropic API configuration - Get your API key from https://console.anthropic.com ANTHROPIC_API_KEY=your-anthropic-api-key-here +# Claude subscription (Pro/Max) long-lived OAuth token. +# Generate once on the host with: claude setup-token +# It prints a ~1-year token — paste it here. This takes precedence over the +# mounted credentials file and removes the need for the macOS keychain/launchd +# sync. Regenerate it once a year when it expires. +CLAUDE_CODE_OAUTH_TOKEN= + # ============================================================================= # DATABASE CONFIGURATION # ============================================================================= diff --git a/Makefile b/Makefile index 59b0442..8f9a0ad 100644 --- a/Makefile +++ b/Makefile @@ -18,23 +18,23 @@ dev: ## Run in development mode test: ## Run tests npm test -docker-build: ## Build Docker image - docker-compose build +podman-build: ## Build image with podman compose + podman compose build -docker-run: ## Run Docker container - docker-compose up -d +podman-run: ## Run containers with podman compose + podman compose up -d -docker-logs: ## Show Docker logs - docker-compose logs -f +podman-logs: ## Show container logs + podman compose logs -f -docker-stop: ## Stop Docker containers - docker-compose down +podman-stop: ## Stop containers + podman compose down -docker-clean: ## Remove Docker containers and images - docker-compose down -v - docker rmi claude-code-proxy-cc-proxy:latest 2>/dev/null || true +podman-clean: ## Remove containers and images + podman compose down -v + podman rmi claude-code-proxy-cc-proxy:latest 2>/dev/null || true -docker-restart: docker-stop docker-run ## Restart Docker containers +podman-restart: podman-stop podman-run ## Restart containers lint: ## Run linter npm run lint diff --git a/docker-compose.yml b/docker-compose.yml index dd0dec4..9d14ba8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ services: # Claude Code Proxy cc-proxy: - container_name: cc-proxy build: context: . dockerfile: Dockerfile + container_name: ${PROXY_CONTAINER_NAME:-cc-proxy} ports: - "${HOST_PROXY_PORT:-4181}:4181" env_file: @@ -14,9 +14,9 @@ services: - API_STREAMING_TIMEOUT_MS=${API_STREAMING_TIMEOUT_MS:-18000000} - API_MAX_RETRIES=${API_MAX_RETRIES:-5} - API_RETRY_DELAY_MS=${API_RETRY_DELAY_MS:-2000} + - LOG_LEVEL=${LOG_LEVEL:-info} - NODE_ENV=${NODE_ENV:-production} - CLAUDE_CREDENTIALS_PATH=/app/credentials.json - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@${DB_HOST:-cc-db}:5432/${POSTGRES_DB:-claude_proxy} volumes: - ~/.claude/claude-credentials.json:/app/credentials.json:rw healthcheck: @@ -41,8 +41,8 @@ services: # PostgreSQL Database cc-db: - container_name: cc-db image: postgres:${POSTGRES_VERSION:-15}-alpine + container_name: ${DB_CONTAINER_NAME:-cc-db} environment: - POSTGRES_DB=${POSTGRES_DB:-claude_proxy} - POSTGRES_USER=${POSTGRES_USER:-postgres} @@ -61,22 +61,6 @@ services: networks: - claude-network - # Adminer (Database Management UI) - cc-adminer: - container_name: cc-adminer - image: adminer:${ADMINER_VERSION:-latest} - ports: - - "${ADMINER_PORT:-8080}:8080" - environment: - - ADMINER_DEFAULT_SERVER=${ADMINER_DEFAULT_SERVER:-cc-db} - - ADMINER_DESIGN=${ADMINER_DESIGN:-nette} - depends_on: - cc-db: - condition: service_healthy - restart: ${RESTART_POLICY:-unless-stopped} - networks: - - claude-network - volumes: postgres_data: driver: local diff --git a/docs/CREDENTIAL_SYNC.md b/docs/CREDENTIAL_SYNC.md index 468f5e2..56fb23f 100644 --- a/docs/CREDENTIAL_SYNC.md +++ b/docs/CREDENTIAL_SYNC.md @@ -1,8 +1,34 @@ -# Auto-Sync Claude Credentials +# Claude Subscription Credentials -The proxy needs fresh Claude OAuth credentials to use your subscription (savings plan) instead of API keys. This setup automatically syncs credentials from the macOS Keychain to a file that the proxy container reads. +The proxy needs Claude OAuth credentials to use your subscription (Pro/Max) instead of API keys. There are two ways to provide them. -## How It Works +## Recommended: Static long-lived token (`claude setup-token`) + +For containers and headless setups, mint a **~1-year** OAuth token once and pass it as an environment variable. This avoids the macOS Keychain entirely, so it keeps working when your Mac is asleep or logged out, and there's nothing to sync. + +```bash +# On the host (interactive, opens a browser for OAuth): +claude setup-token +# Copy the printed token into your .env.docker: +# CLAUDE_CODE_OAUTH_TOKEN= +``` + +Then start the stack normally. The proxy reads `CLAUDE_CODE_OAUTH_TOKEN` and **prefers it over the mounted credentials file**. Confirm it was picked up: + +```bash +curl -s http://127.0.0.1:4181/config | python3 -c "import sys,json; print(json.load(sys.stdin)['providers']['claudeSubscription']['authMode'])" +# → static-token +``` + +Regenerate the token once a year (or whenever it's revoked) by re-running `claude setup-token` and updating the env var. + +--- + +## Legacy: Keychain → file sync (interactive host only) + +> This is the older approach. It only works on a macOS host where a GUI login session can read the Keychain, and it can go stale under `launchd`. Prefer the static token above for containers. When `CLAUDE_CODE_OAUTH_TOKEN` is unset, the proxy falls back to the mounted credentials file described below. + +### How It Works 1. **macOS Keychain** stores your Claude OAuth token (service: `Claude Code-credentials`) 2. **sync-credentials.sh** extracts the token and writes to `~/.claude/claude-credentials.json` diff --git a/src/index.ts b/src/index.ts index 7b11d7c..15eaac3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,18 +78,23 @@ const DEFAULT_CONFIG: ProxyConfig = { path.join(os.homedir(), ".claude", ".credentials.json") ), enabled: process.env.CLAUDE_SUBSCRIPTION_ENABLED !== "false", + oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || "", }, modelFallbackMap: { // Anthropic Claude models (use directly) - "claude-sonnet-4-6": "claude-sonnet-4-6", + "claude-opus-4-8": "claude-opus-4-8", // Latest Opus (April 2026) + "claude-opus-4-7": "claude-opus-4-7", // Previous Opus "claude-opus-4-6": "claude-opus-4-6", + "claude-sonnet-4-6": "claude-sonnet-4-6", + "claude-haiku-4-6": "claude-haiku-4-6", // Newer Haiku "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001", "claude-sonnet-4-5": "claude-sonnet-4-5", // Legacy Claude models -> latest versions - "claude-opus": "claude-opus-4-6", + "claude-opus": "claude-opus-4-8", "claude-sonnet": "claude-sonnet-4-6", - "claude-haiku": "claude-haiku-4-5-20251001", + "claude-haiku": "claude-haiku-4-6", // Z.AI GLM models (use directly) + "glm-5.2": "glm-5.2", // Latest flagship (June 2026) "glm-5.1": "glm-5.1", "glm-5": "glm-5", "glm-5-turbo": "glm-5-turbo", @@ -103,7 +108,7 @@ const DEFAULT_CONFIG: ProxyConfig = { "glm-4.5-air": "glm-4.5-air", "glm-4-32b": "glm-4-32b", // Legacy GLM models -> latest - "glm-4": "glm-4.7", + "glm-4": "glm-5.2", // Now points to GLM-5.2 // Fallback to OpenRouter free models (when others fail) "openrouter-free": "google/gemma-3-27b-it:free", }, @@ -329,6 +334,12 @@ export class ClaudeCodeProxy { } private async readClaudeOAuthToken(retries = 3): Promise { + // A static long-lived token (`claude setup-token`) takes precedence over the + // credentials file. It has no readable expiry, so treat it as always-valid; + // a revoked/expired token falls through the normal 401 re-read + fallback path. + const staticToken = this.config.claudeSubscription.oauthToken; + if (staticToken) return staticToken; + for (let attempt = 0; attempt <= retries; attempt++) { try { const raw = fs.readFileSync(this.config.claudeSubscription.credentialsPath, "utf-8"); @@ -866,13 +877,15 @@ export class ClaudeCodeProxy { const modelLower = requestedModel.toLowerCase(); if (provider === "zai" && modelLower.startsWith("claude-")) { + // Map Claude models to GLM for Z.AI if (modelLower.includes("haiku")) return "glm-4.5-air"; - return "glm-4"; + return "glm-5.2"; // Map Opus/Sonnet to latest GLM } if (provider === "anthropic" && modelLower.startsWith("glm-")) { + // Map GLM models to Claude for Anthropic API if (modelLower.includes("-air") || modelLower.includes("-turbo")) { - return "claude-haiku-4-5-20251001"; + return "claude-haiku-4-6"; } return "claude-sonnet-4-6"; } @@ -946,6 +959,9 @@ export class ClaudeCodeProxy { private recordClaudeSubscriptionFailure(status: number, body: Buffer): void { const errorType = this.classifyProviderError(status, body); if (errorType === "rate_limit" || errorType === "context_window" || errorType === "auth_error") { + if (errorType === "context_window") { + this.logger.warn(`Claude subscription context window limit exceeded`); + } this.enterClaudeSubscriptionCooldown(errorType); return; } @@ -1325,7 +1341,7 @@ export class ClaudeCodeProxy { if (response.status >= 400) { errorType = this.classifyProviderError(response.status, response.body); if (errorType === "context_window") { - this.logger.debug(` Context window error detected for ${provider}`); + this.logger.warn(` ⚠️ Context window error from ${provider.toUpperCase()} - message too long (consider reducing message size or enabling CONTEXT_WINDOW_ENABLED=true)`); } } @@ -1346,6 +1362,33 @@ export class ClaudeCodeProxy { const requestId = this.generateRequestId(); const cbEnabled = this.config.circuitBreaker?.enabled !== false; + // For Claude models, ALWAYS try subscription first if available + const modelLower = (model || '').toLowerCase(); + const isClaudeModel = modelLower.startsWith('claude-') || modelLower === 'claude'; + + if (isClaudeModel && this.canTryClaudeSubscription()) { + const oauthToken = await this.readClaudeOAuthToken(); + if (oauthToken) { + this.logger.info(`[${requestId}] → 🎟️ Claude Subscription ▸ ${model || 'unknown'} (primary for Claude models)`); + const subResult = await this.trySubscriptionRequest(reqBody, reqHeaders, reqPath, reqMethod); + if (subResult && subResult.status < 400) { + this.logger.ok(`[${requestId}] ← ✅ SUBSCRIPTION ${subResult.status}`); + await this.trackRequestMetrics(reqMethod, reqPath, startTime, subResult.status, true, 'anthropic', model, false, subResult.body); + return subResult; + } + // Subscription failed - will try fallback below based on error type + const subErrorType = subResult ? this.classifyProviderError(subResult.status, subResult.body) : 'other'; + if (subErrorType === 'auth_error') { + this.logger.error(`[${requestId}] ← ❌ Subscription auth error - check credentials. Not falling back.`); + if (subResult) { + await this.trackRequestMetrics(reqMethod, reqPath, startTime, subResult.status, false, 'anthropic', model, false, subResult.body); + } + return subResult || { status: 401, body: Buffer.from('{"error":{"message":"Authentication failed"}}'), headers: {} }; + } + this.logger.warn(`[${requestId}] ← ⚠️ Subscription failed (${subErrorType}) - trying fallback providers`); + } + } + // Determine best provider+model (circuit breaker enabled) const providerAndModel = cbEnabled ? this.providerHealth.getBestProviderAndModel(model) : null; let selectedProvider: 'anthropic' | 'zai' | 'openrouter' | null = providerAndModel?.provider ?? 'anthropic'; @@ -1356,23 +1399,6 @@ export class ClaudeCodeProxy { selectedProvider = 'anthropic'; } - // Check if we should prefer Claude subscription over Z.AI - // (when Anthropic has no API key but subscription is available) - if (selectedProvider === 'zai' && this.canTryClaudeSubscription()) { - const oauthToken = await this.readClaudeOAuthToken(); - if (oauthToken && !this.config.anthropic.apiKey) { - this.logger.info(`Claude subscription available - preferring subscription over Z.AI`); - // Skip provider selection and use subscription directly - const subResult = await this.trySubscriptionRequest(reqBody, reqHeaders, reqPath, reqMethod); - if (subResult) { - this.logger.ok(`[${requestId}] ✓ SUBSCRIPTION ${subResult.status}`); - await this.trackRequestMetrics(reqMethod, reqPath, startTime, subResult.status, true, 'anthropic', model, false, subResult.body); - return subResult; - } - // If subscription fails, continue with Z.AI as planned - } - } - // Apply model conversion if provider selected a different model let effectiveReqBody = reqBody; const effectiveModel = providerAndModel?.model ?? model; @@ -1441,6 +1467,13 @@ export class ClaudeCodeProxy { return subResult; } + // Skip fallback for auth errors - these are configuration issues, not transient failures + if (errorType === 'auth_error') { + this.logger.error(`[${requestId}] ← ❌ Authentication error - check API keys/credentials. Not falling back.`); + await this.trackRequestMetrics(reqMethod, reqPath, startTime, response.status, false, primaryProvider, model, false, response.body); + return response; + } + const fallbackProviders = this.getFallbackProviders(primaryProvider); let lastFallbackResponse: HttpResponse | undefined; @@ -1629,20 +1662,22 @@ export class ClaudeCodeProxy { const cbEnabled = this.config.circuitBreaker?.enabled !== false; let headersSent = false; - // Determine best provider based on model + // For Claude models, ALWAYS try subscription first if available + const modelLower = (model || '').toLowerCase(); + const isClaudeModel = modelLower.startsWith('claude-') || modelLower === 'claude'; + + // Determine best provider based on model (used as fallback if subscription fails) let selectedProvider = cbEnabled ? this.providerHealth.getBestProviderForModel(model) : 'anthropic'; if (!selectedProvider) { this.logger.warn(`No healthy provider available for model ${model || 'unknown'}, defaulting to Anthropic`); selectedProvider = 'anthropic'; } - // Check if we should prefer Claude subscription over Z.AI - // (when Anthropic has no API key but subscription is available) - if (selectedProvider === 'zai' && this.canTryClaudeSubscription()) { + // For Claude models, try subscription first + if (isClaudeModel && this.canTryClaudeSubscription()) { const oauthToken = await this.readClaudeOAuthToken(); - if (oauthToken && !this.config.anthropic.apiKey) { - this.logger.info(`[${requestId}] → Claude subscription preferred over Z.AI`); - // Try subscription first + if (oauthToken) { + this.logger.info(`[${requestId}] → 🎟️ Claude Subscription ▸ ${model || 'unknown'} (primary for Claude models)`); const cleanedPath = this.cleanPath(reqPath); const cleanedBody = this.cleanBody(reqBody, "subscription"); @@ -1756,22 +1791,42 @@ export class ClaudeCodeProxy { const subStatus = subRes.statusCode || 0; if (subStatus >= 400) { const subErrorBody = await this.readIncomingBody(subRes); + const errorType = this.classifyProviderError(subStatus, subErrorBody); this.recordClaudeSubscriptionFailure(subStatus, subErrorBody); + + // Skip fallback for auth errors + if (errorType === 'auth_error') { + this.logger.error(`[${requestId}] ← ❌ Subscription auth error - check credentials. Not falling back.`); + if (!clientRes.headersSent) { + clientRes.writeHead(subStatus, { "Content-Type": "application/json" }); + clientRes.end(subErrorBody); + } + return; + } + this.logger.warn( `← Claude subscription ❌ ${subStatus}: ${subErrorBody .toString() - .slice(0, 300)} — falling back to Z.AI` + .slice(0, 300)} — trying fallback providers` ); } } catch (err) { const semanticError = err as Error & { streamingErrorType?: ProviderErrorType; errorBody?: Buffer }; if (semanticError.streamingErrorType) { this.recordClaudeSubscriptionFailure(429, semanticError.errorBody || Buffer.from("")); + // Skip fallback for auth errors + if (semanticError.streamingErrorType === 'auth_error') { + this.logger.error(`[${requestId}] ← ❌ Subscription auth error - check credentials. Not falling back.`); + if (!clientRes.headersSent) { + clientRes.writeHead(401, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ error: { message: "Authentication failed - check credentials" } })); + } + return; + } } else { this.enterClaudeSubscriptionCooldown("stream error"); } - this.logger.warn(`← Claude subscription stream failed: ${err instanceof Error ? err.message : 'Unknown'} — falling back to Z.AI`); - // Continue with Z.AI + this.logger.warn(`← Claude subscription stream failed: ${err instanceof Error ? err.message : 'Unknown'} — trying fallback providers`); } } } @@ -1844,8 +1899,11 @@ export class ClaudeCodeProxy { this.providerHealth.recordFailure(provider, errorType); } + const contextMsg = errorType === "context_window" + ? ` (message too long - enable CONTEXT_WINDOW_ENABLED=true)` + : ""; this.logger.warn( - `[${requestId}] ← ⚠️ ${provider.toUpperCase()} ${statusCode}: ${errorBody + `[${requestId}] ← ⚠️ ${provider.toUpperCase()} ${statusCode}${contextMsg}: ${errorBody .toString() .slice(0, 300)} — trying fallback` ); @@ -1962,6 +2020,16 @@ export class ClaudeCodeProxy { return; } + // Skip fallback for auth errors - these are configuration issues, not transient failures + if (primaryResult.errorType === 'auth_error') { + this.logger.error(`[${requestId}] ← ❌ Authentication error - check API keys/credentials. Not falling back.`); + if (!clientRes.headersSent) { + clientRes.writeHead(401, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ error: { message: "Authentication failed - check API keys or credentials" } })); + } + return; + } + // If headers were already sent, we can't try fallback providers if (headersSent) { this.logger.debug("Headers already sent to client, cannot retry with fallback providers"); @@ -2325,6 +2393,7 @@ export class ClaudeCodeProxy { enabled: subEnabled, baseUrl: this.config.claudeSubscription.baseUrl, credentialsPath: this.config.claudeSubscription.credentialsPath, + authMode: this.config.claudeSubscription.oauthToken ? "static-token" : "credentials-file", priority: 2 }, zai: { @@ -2655,7 +2724,8 @@ es.onerror = () => { } console.log(` │ ├─ 1. ${hasAnthropicKey ? `\x1b[32m✓\x1b[0m Anthropic API ${anthropicState}` : '\x1b[90m✗ Anthropic API (no key)\x1b[0m'}`); - console.log(` │ ├─ 2. ${subEnabled ? '\x1b[32m✓\x1b[0m Claude Subscription' : '\x1b[90m✗ Claude Subscription (disabled)\x1b[0m'}`); + const subAuthMode = this.config.claudeSubscription.oauthToken ? ' \x1b[90m(static token)\x1b[0m' : ''; + console.log(` │ ├─ 2. ${subEnabled ? `\x1b[32m✓\x1b[0m Claude Subscription${subAuthMode}` : '\x1b[90m✗ Claude Subscription (disabled)\x1b[0m'}`); console.log(` │ ├─ 3. ${hasZaiKey ? `\x1b[32m✓\x1b[0m Z.AI ${zaiState}` : '\x1b[90m✗ Z.AI (no key)\x1b[0m'}`); console.log(` │ └─ 4. ${hasOpenRouterKey ? `\x1b[32m✓\x1b[0m OpenRouter ${openRouterState}` : '\x1b[90m✗ OpenRouter (no key)\x1b[0m'}`); @@ -2700,11 +2770,12 @@ es.onerror = () => { console.log('\x1b[1m\x1b[34m' + `● Model Configuration (${modelCount} mappings)` + '\x1b[0m'); const notableModels = [ + 'claude-opus-4-8', + 'claude-opus', 'claude-sonnet-4-6', - 'claude-opus-4-6', - 'claude-haiku-4-5-20251001', - 'glm-5', - 'glm-4.7' + 'claude-haiku-4-6', + 'glm-5.2', + 'glm-4' ]; console.log(` └─ Key mappings:`); diff --git a/src/provider-health.ts b/src/provider-health.ts index 23a9554..4052322 100644 --- a/src/provider-health.ts +++ b/src/provider-health.ts @@ -497,10 +497,13 @@ export class ProviderHealth { */ private mapClaudeToGLM(claudeModel: string): string { const claudeLower = claudeModel.toLowerCase(); - if (claudeLower.includes("opus")) return "glm-4"; - if (claudeLower.includes("sonnet")) return "glm-4"; - if (claudeLower.includes("haiku")) return "glm-3-air"; - return "glm-4"; // Default to GLM 4 + // Map Opus models to latest GLM flagship + if (claudeLower.includes("opus")) return "glm-5.2"; + // Map Sonnet models to latest GLM + if (claudeLower.includes("sonnet")) return "glm-5.2"; + // Map Haiku models to faster/cheaper GLM + if (claudeLower.includes("haiku")) return "glm-4.5-air"; + return "glm-5.2"; // Default to GLM 5.2 } /** @@ -508,9 +511,11 @@ export class ProviderHealth { */ private mapGLMToClaude(glmModel: string): string { const glmLower = glmModel.toLowerCase(); - if (glmLower === "glm-4" || glmLower === "glm-4-plus") return "claude-sonnet-4-6"; - if (glmLower.includes("-air") || glmLower.includes("-turbo")) return "claude-haiku-4-5"; - return "claude-sonnet-4-6"; // Default to Sonnet + // Latest GLM flagship maps to Opus + if (glmLower === "glm-5.2" || glmLower === "glm-5.1" || glmLower === "glm-5") return "claude-opus-4-8"; + if (glmLower === "glm-4" || glmLower === "glm-4-plus") return "claude-opus-4-8"; + if (glmLower.includes("-air") || glmLower.includes("-turbo")) return "claude-haiku-4-6"; + return "claude-opus-4-8"; // Default to Opus } /** @@ -519,6 +524,7 @@ export class ProviderHealth { private mapClaudeToOpenRouter(claudeModel: string): string { const claudeLower = claudeModel.toLowerCase(); if (claudeLower.includes("haiku")) return "~anthropic/claude-haiku-latest"; + if (claudeLower.includes("opus")) return "~anthropic/claude-opus-latest"; return "~anthropic/claude-sonnet-latest"; } diff --git a/src/types.ts b/src/types.ts index a2d04cf..c8b2d14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,7 @@ export interface ProxyConfig { baseUrl: string; credentialsPath: string; enabled: boolean; + oauthToken?: string; // static long-lived token from `claude setup-token` }; modelFallbackMap: Record; fallbackOnCodes: number[]; diff --git a/tests/model-conversion.test.js b/tests/model-conversion.test.js index eede4cd..bd5dc3a 100644 --- a/tests/model-conversion.test.js +++ b/tests/model-conversion.test.js @@ -44,14 +44,14 @@ describe("Model Conversion - Claude Models", () => { const result = providerHealth.getBestProviderAndModel("claude-sonnet-4-6"); assert.strictEqual(result.provider, "zai"); - assert.strictEqual(result.model, "glm-4"); + assert.strictEqual(result.model, "glm-5.2"); assert.strictEqual(result.wasConverted, true); assert.ok(result.conversionReason?.includes("converted")); assert.ok(result.conversionReason?.includes("claude-sonnet-4-6")); - assert.ok(result.conversionReason?.includes("glm-4")); + assert.ok(result.conversionReason?.includes("glm-5.2")); }); - it("should convert Claude Opus to GLM 4", () => { + it("should convert Claude Opus to GLM 5.2", () => { providerHealth.resetAll(); for (let i = 0; i < 5; i++) { providerHealth.recordFailure("anthropic", "other"); @@ -60,11 +60,11 @@ describe("Model Conversion - Claude Models", () => { const result = providerHealth.getBestProviderAndModel("claude-opus-4-5"); assert.strictEqual(result.provider, "zai"); - assert.strictEqual(result.model, "glm-4"); + assert.strictEqual(result.model, "glm-5.2"); assert.strictEqual(result.wasConverted, true); }); - it("should convert Claude Haiku to GLM 3-Air", () => { + it("should convert Claude Haiku to GLM 4.5-Air", () => { providerHealth.resetAll(); for (let i = 0; i < 5; i++) { providerHealth.recordFailure("anthropic", "other"); @@ -73,7 +73,7 @@ describe("Model Conversion - Claude Models", () => { const result = providerHealth.getBestProviderAndModel("claude-haiku-4-5"); assert.strictEqual(result.provider, "zai"); - assert.strictEqual(result.model, "glm-3-air"); + assert.strictEqual(result.model, "glm-4.5-air"); assert.strictEqual(result.wasConverted, true); }); @@ -105,7 +105,7 @@ describe("Model Conversion - Claude Models", () => { const result = providerHealth.getBestProviderAndModel("claude-opus-4-5"); assert.strictEqual(result.provider, "openrouter"); - assert.strictEqual(result.model, "~anthropic/claude-sonnet-latest"); + assert.strictEqual(result.model, "~anthropic/claude-opus-latest"); }); it("should map Claude Haiku to OpenRouter format", () => { @@ -159,10 +159,10 @@ describe("Model Conversion - GLM Models", () => { const result = providerHealth.getBestProviderAndModel("glm-4"); assert.strictEqual(result.provider, "anthropic"); - assert.strictEqual(result.model, "claude-sonnet-4-6"); + assert.strictEqual(result.model, "claude-opus-4-8"); assert.strictEqual(result.wasConverted, true); assert.ok(result.conversionReason?.includes("glm-4")); - assert.ok(result.conversionReason?.includes("claude-sonnet-4-6")); + assert.ok(result.conversionReason?.includes("claude-opus-4-8")); }); it("should convert GLM 3-Air to Claude Haiku", () => { @@ -173,7 +173,7 @@ describe("Model Conversion - GLM Models", () => { const result = providerHealth.getBestProviderAndModel("glm-3-air"); assert.strictEqual(result.provider, "anthropic"); - assert.strictEqual(result.model, "claude-haiku-4-5"); + assert.strictEqual(result.model, "claude-haiku-4-6"); assert.strictEqual(result.wasConverted, true); }); @@ -231,13 +231,13 @@ describe("Model Conversion - Complete Fallback Chain", () => { assert.strictEqual(result.model, "claude-sonnet-4-6"); assert.strictEqual(result.wasConverted, false); - // Anthropic fails → Z.AI + GLM 4 + // Anthropic fails → Z.AI + GLM 5.2 for (let i = 0; i < 5; i++) { providerHealth.recordFailure("anthropic", "other"); } result = providerHealth.getBestProviderAndModel("claude-sonnet-4-6"); assert.strictEqual(result.provider, "zai"); - assert.strictEqual(result.model, "glm-4"); + assert.strictEqual(result.model, "glm-5.2"); assert.strictEqual(result.wasConverted, true); // Z.AI fails → OpenRouter + OpenRouter Claude @@ -257,13 +257,13 @@ describe("Model Conversion - Complete Fallback Chain", () => { assert.strictEqual(result.model, "glm-4"); assert.strictEqual(result.wasConverted, false); - // Z.AI fails → Anthropic + Claude Sonnet + // Z.AI fails → Anthropic + Claude Opus for (let i = 0; i < 5; i++) { providerHealth.recordFailure("zai", "other"); } result = providerHealth.getBestProviderAndModel("glm-4"); assert.strictEqual(result.provider, "anthropic"); - assert.strictEqual(result.model, "claude-sonnet-4-6"); + assert.strictEqual(result.model, "claude-opus-4-8"); assert.strictEqual(result.wasConverted, true); // Anthropic fails → OpenRouter + GLM 4 @@ -305,7 +305,7 @@ describe("Model Conversion - Provider Recovery", () => { let result = providerHealth.getBestProviderAndModel("claude-sonnet-4-6"); assert.strictEqual(result.provider, "zai"); - assert.strictEqual(result.model, "glm-4"); + assert.strictEqual(result.model, "glm-5.2"); assert.strictEqual(result.wasConverted, true); // Anthropic recovers (reset metrics to simulate recovery after cooldown) @@ -325,7 +325,7 @@ describe("Model Conversion - Provider Recovery", () => { let result = providerHealth.getBestProviderAndModel("glm-4"); assert.strictEqual(result.provider, "anthropic"); - assert.strictEqual(result.model, "claude-sonnet-4-6"); + assert.strictEqual(result.model, "claude-opus-4-8"); assert.strictEqual(result.wasConverted, true); // Z.AI recovers (reset metrics to simulate recovery after cooldown) @@ -349,7 +349,7 @@ describe("Model Conversion - API Key Validation", () => { const result = providerHealth.getBestProviderAndModel("claude-sonnet-4-6"); assert.strictEqual(result.provider, "zai"); - assert.strictEqual(result.model, "glm-4"); + assert.strictEqual(result.model, "glm-5.2"); assert.strictEqual(result.wasConverted, true); assert.ok(result.conversionReason?.includes("Anthropic unavailable")); @@ -366,7 +366,7 @@ describe("Model Conversion - API Key Validation", () => { const result = providerHealth.getBestProviderAndModel("glm-4"); assert.strictEqual(result.provider, "anthropic"); - assert.strictEqual(result.model, "claude-sonnet-4-6"); + assert.strictEqual(result.model, "claude-opus-4-8"); assert.strictEqual(result.wasConverted, true); assert.ok(result.conversionReason?.includes("Z.AI unavailable")); @@ -408,7 +408,7 @@ describe("Model Conversion - Quota Management", () => { const result = providerHealth.getBestProviderAndModel("claude-sonnet-4-6"); assert.strictEqual(result.provider, "zai"); - assert.strictEqual(result.model, "glm-4"); + assert.strictEqual(result.model, "glm-5.2"); assert.strictEqual(result.wasConverted, true); providerHealth.destroy(); @@ -432,7 +432,7 @@ describe("Model Conversion - Quota Management", () => { const result = providerHealth.getBestProviderAndModel("glm-4"); assert.strictEqual(result.provider, "anthropic"); - assert.strictEqual(result.model, "claude-sonnet-4-6"); + assert.strictEqual(result.model, "claude-opus-4-8"); assert.strictEqual(result.wasConverted, true); providerHealth.destroy(); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 200326b..69aaa09 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -451,7 +451,7 @@ describe("Claude Code Proxy fallback chain", () => { await clientRes.ended; assert.equal(clientRes.statusCode, 200); - assert.match(Buffer.concat(clientRes.chunks).toString(), /"model":"glm-4\.7"/); + assert.match(Buffer.concat(clientRes.chunks).toString(), /"model":"glm-5\.2"/); assert.equal(proxy.getClaudeSubscriptionState().state, "cooling_down"); } finally { proxy?.providerHealth.destroy();