Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
65 KiB
JSON
1 line
65 KiB
JSON
{"repo": "chenyongda2018/smartperfetto", "files": {"backend/.env.example": {"c78e8934c86c612f6b7ce2f42f21c35c99de00ce": {"t": 1785746591.1997037, "n": 10361, "h": "08997993ba6d6700", "c": "# ===================================================\n# SmartPerfetto Configuration\n# ===================================================\n# Copy this file to .env and fill in your API key:\n# cp .env.example .env\n#\n# Minimum required: ANTHROPIC_API_KEY (for Claude Agent SDK)\n\n# ---------------------------------------------------\n# AI Service Configuration\n# ---------------------------------------------------\n# SmartPerfetto uses the Claude Agent SDK as its AI runtime.\n# You can connect it to Anthropic directly, or to any LLM\n# provider via an API proxy that translates requests to\n# Anthropic Messages API format.\n#\n# Two ways to connect:\n#\n# 1. Direct Anthropic (default) — just set ANTHROPIC_API_KEY\n# 2. Third-party LLM via proxy — set ANTHROPIC_BASE_URL to\n# a proxy (one-api / new-api / LiteLLM) that converts\n# Anthropic Messages API -> provider's OpenAI-compatible API\n#\n# ┌──────────────────────────────────────────────────┐\n# │ SmartPerfetto │\n# │ │ (Claude Agent SDK) │\n# │ ▼ │\n# │ ANTHROPIC_BASE_URL ──► API Proxy (one-api等) │\n# │ │ │\n# │ ┌─────────┼─────────┐ │\n# │ ▼ ▼ ▼ │\n# │ GLM-5.1 DeepSeek Qwen │\n# └──────────────────────────────────────────────────┘\n#\n# Popular open-source API proxies:\n# - one-api: https://github.com/songquanpeng/one-api\n# - new-api: https://github.com/Calcium-Ion/new-api\n# - LiteLLM: https://github.com/BerriAI/litellm\n\n# ===================================================\n# Option 1: Anthropic Direct (default, recommended)\n# ===================================================\n# Get your API key at https://console.anthropic.com/\nANTHROPIC_API_KEY=your_anthropic_api_key_here\n\n# ===================================================\n# Option 2: AWS Bedrock (Bearer Token)\n# ===================================================\n# Use Claude via AWS Bedrock with bearer token authentication.\n# CLAUDE_CODE_USE_BEDROCK=1\n# ANTHROPIC_BEDROCK_BASE_URL=https://bedrock-runtime.us-east-1.amazonaws.com\n# AWS_BEARER_TOKEN_BEDROCK=your_bearer_token_here\n\n# ===================================================\n# Option 3: Third-party LLM via API Proxy\n# ===================================================\n# Uncomment ONE provider block below. All require a proxy\n# (one-api / new-api / LiteLLM) that accepts Anthropic API\n# format and forwards to the provider's OpenAI-compatible API.\n#\n# In your proxy, create a channel for the provider and map\n# the model names below. Then point ANTHROPIC_BASE_URL at\n# the proxy's Anthropic-compatible endpoint.\n\n# --- Proxy Connection (required for all providers below) ---\n# ANTHROPIC_BASE_URL=http://localhost:3000 # Your proxy address\n# ANTHROPIC_API_KEY=sk-proxy-xxx # Proxy auth token\n\n# ---------------------------------------------------\n# GLM / Z.ai (智谱AI / ZhipuAI)\n# ---------------------------------------------------\n# Console: https://open.bigmodel.cn/\n# Proxy backend URL: https://open.bigmodel.cn/api/paas/v4\n# CLAUDE_MODEL=glm-5.1\n# CLAUDE_LIGHT_MODEL=glm-4.7-flash\n\n# ---------------------------------------------------\n# DeepSeek\n# ---------------------------------------------------\n# Console: https://platform.deepseek.com/\n# Proxy backend URL: https://api.deepseek.com\n# Note: deepseek-chat / deepseek-reasoner are legacy compatibility aliases.\n# CLAUDE_MODEL=deepseek-v4-pro\n# CLAUDE_LIGHT_MODEL=deepseek-v4-flash\n\n# ---------------------------------------------------\n# Qwen (通义千问 / Alibaba Cloud)\n# ---------------------------------------------------\n# Console: https://dashscope.console.aliyun.com/\n# Proxy backend URL: https://dashscope.aliyuncs.com/compatible-mode/v1\n# CLAUDE_MODEL=qwen3-max\n# CLAUDE_LIGHT_MODEL=qwen3.5-flash\n\n# ---------------------------------------------------\n# Kimi (月之暗面 / Moonshot AI)\n# ---------------------------------------------------\n# Console: https://platform.moonshot.cn/\n# Proxy backend URL: https://api.moonshot.cn/v1\n# CLAUDE_MODEL=kimi-k2.6\n# CLAUDE_LIGHT_MODEL=kimi-k2.5\n\n# ---------------------------------------------------\n# Doubao (豆包 / ByteDance Volcano Engine)\n# ---------------------------------------------------\n# Console: https://console.volcengine.com/ark\n# Proxy backend URL: https://ark.cn-beijing.volces.com/api/v3\n# Note: Production Ark deployments often use endpoint IDs (ep-xxx).\n# Coding Plan setups may also expose model names such as\n# doubao-seed-2.0-code or doubao-seed-code.\n# CLAUDE_MODEL=ep-your-doubao-pro-endpoint-id\n# CLAUDE_LIGHT_MODEL=ep-your-doubao-lite-endpoint-id\n\n# ---------------------------------------------------\n# MiniMax\n# ---------------------------------------------------\n# Console: https://platform.minimaxi.com/\n# Proxy backend URL: https://api.minimaxi.com/v1\n# CLAUDE_MODEL=MiniMax-M2.7\n# CLAUDE_LIGHT_MODEL=MiniMax-M2.5\n\n# ---------------------------------------------------\n# Legacy / account-specific proxy examples\n# ---------------------------------------------------\n# The providers below are kept for users whose proxy accounts still expose\n# these model IDs. Verify the current model catalog and OpenAI-compatible\n# backend URL in the provider console before use.\n\n# ---------------------------------------------------\n# Baichuan (百川智能)\n# ---------------------------------------------------\n# Console: https://platform.baichuan-ai.com/\n# Proxy backend URL: https://api.baichuan-ai.com/v1\n# CLAUDE_MODEL=Baichuan4\n# CLAUDE_LIGHT_MODEL=Baichuan3-Turbo\n\n# ---------------------------------------------------\n# Yi (零一万物 / 01.AI)\n# ---------------------------------------------------\n# Console: https://platform.lingyiwanwu.com/\n# Proxy backend URL: https://api.lingyiwanwu.com/v1\n# CLAUDE_MODEL=yi-large\n# CLAUDE_LIGHT_MODEL=yi-lightning\n\n# ---------------------------------------------------\n# Spark (讯飞星火 / iFlytek)\n# ---------------------------------------------------\n# Console: https://console.xfyun.cn/\n# Proxy backend URL: https://spark-api-open.xf-yun.com/v1\n# CLAUDE_MODEL=4.0Ultra\n# CLAUDE_LIGHT_MODEL=generalv3.5\n\n# ---------------------------------------------------\n# Hunyuan (腾讯混元 / Tencent)\n# ---------------------------------------------------\n# Console: https://cloud.tencent.com/product/hunyuan\n# Proxy backend URL: https://api.hunyuan.cloud.tencent.com/v1\n# CLAUDE_MODEL=hunyuan-turbo-latest\n# CLAUDE_LIGHT_MODEL=hunyuan-lite\n\n# ---------------------------------------------------\n# OpenAI\n# ---------------------------------------------------\n# Console: https://platform.openai.com/\n# Proxy backend URL: https://api.openai.com/v1\n# CLAUDE_MODEL=gpt-5.5\n# CLAUDE_LIGHT_MODEL=gpt-5.4-mini\n\n# ---------------------------------------------------\n# Google Gemini\n# ---------------------------------------------------\n# Console: https://aistudio.google.com/\n# Proxy backend URL: https://generativelanguage.googleapis.com/v1beta/openai\n# Preview path:\n# CLAUDE_MODEL=gemini-3-pro-preview\n# CLAUDE_LIGHT_MODEL=gemini-3-flash-preview\n# Stable path:\n# CLAUDE_MODEL=gemini-2.5-pro\n# CLAUDE_LIGHT_MODEL=gemini-2.5-flash\n\n# ---------------------------------------------------\n# Ollama (local, free)\n# ---------------------------------------------------\n# Install: https://ollama.com/\n# No proxy needed if proxy supports Ollama backend,\n# or point proxy backend URL at: http://localhost:11434/v1\n# Verify the chosen local tag supports tool calling before full analysis.\n# CLAUDE_MODEL=qwen3:30b\n# CLAUDE_LIGHT_MODEL=qwen3:30b\n\n# ---------------------------------------------------\n# Model & Agent Tuning (optional, for all providers)\n# ---------------------------------------------------\n# CLAUDE_MAX_TURNS=30 # Max reasoning turns per analysis\n# CLAUDE_QUICK_MAX_TURNS=5 # Quick/fast mode max reasoning turns (default 5)\n# CLAUDE_MAX_BUDGET_USD=5 # Per-analysis budget cap (Anthropic only)\n# CLAUDE_EFFORT=high # SDK extended thinking: low|medium|high|max (Anthropic only)\n# CLAUDE_ENABLE_SUB_AGENTS=false # Sub-agent delegation (research preview, off by default for all users)\n# CLAUDE_SUB_AGENT_MODEL=sonnet # Sub-agent model: haiku|sonnet|opus|inherit\n\n# Per-turn timeouts — raise for slower LLMs (DeepSeek / Ollama / GLM / Qwen etc).\n# Defaults are tuned for Claude Sonnet / Haiku; most non-Anthropic providers need larger values.\n# CLAUDE_FULL_PER_TURN_MS=60000 # Full analysis per-turn budget (default 60s)\n# CLAUDE_QUICK_PER_TURN_MS=40000 # Quick (fast mode) per-turn budget (default 40s)\n# CLAUDE_VERIFIER_TIMEOUT_MS=60000 # Verifier single-turn LLM timeout (default 60s)\n# CLAUDE_CLASSIFIER_TIMEOUT_MS=30000 # Query complexity classifier timeout (default 30s)\n\n# ---------------------------------------------------\n# Server\n# ---------------------------------------------------\nPORT=3000\nNODE_ENV=development\nFRONTEND_URL=http://localhost:10000\n\n# ---------------------------------------------------\n# API Authentication (optional, recommended for shared/public deployments)\n# ---------------------------------------------------\n# If set, all protected APIs require:\n# Authorization: Bearer <SMARTPERFETTO_API_KEY>\n# SMARTPERFETTO_API_KEY=replace_with_a_strong_random_secret\n\n# ---------------------------------------------------\n# Request Throttling (optional, in-memory, per API key/IP)\n# ---------------------------------------------------\n# SMARTPERFETTO_USAGE_MAX_REQUESTS=200\n# SMARTPERFETTO_USAGE_MAX_TRACE_REQUESTS=100\n# SMARTPERFETTO_USAGE_WINDOW_MS=86400000\n\n# ---------------------------------------------------\n# Agent Safety Limits (optional)\n# ---------------------------------------------------\n# AGENT_SQL_MAX_ROWS=1000\n# AGENT_SQL_TABLE_CACHE_TTL_MS=300000\n# AGENT_TASK_TIMEOUT_MS=180000\n\n# ---------------------------------------------------\n# Trace Processor (optional -- auto-detected if not set)\n# ---------------------------------------------------\n# TRACE_PROCESSOR_PATH=/path/to/trace_processor_shell\n# PERFETTO_PATH=/path/to/perfetto\n\n# ---------------------------------------------------\n# File Upload\n# ---------------------------------------------------\nMAX_FILE_SIZE=2147483648\nUPLOAD_DIR=./uploads\n"}}, ".env.example": {"c78e8934c86c612f6b7ce2f42f21c35c99de00ce": {"t": 1785747105.3157814, "n": 2791, "h": "c30cbf20c49646d8", "c": "# Server Configuration\nPORT=3000\nNODE_ENV=development\n\n# API Authentication (recommended in any shared environment)\n# SMARTPERFETTO_API_KEY=replace_with_a_strong_random_secret\n\n# AI Service Configuration\n# Default runtime is agentv3 (Claude Agent SDK) when AI_SERVICE is unset.\n# Set AI_SERVICE=deepseek only for the deprecated agentv2 fallback.\n# AI_SERVICE=claude-code\n\n# OpenAI Configuration\n# OPENAI_API_KEY=your_openai_api_key_here\n# OPENAI_MODEL=gpt-5.4\n\n# Claude Configuration\n# ANTHROPIC_API_KEY=your_claude_api_key_here\n# CLAUDE_MODEL=claude-sonnet-4-6\n\n# DeepSeek Configuration (legacy agentv2 fallback)\nDEEPSEEK_API_KEY=your_deepseek_api_key_here\nDEEPSEEK_BASE_URL=https://api.deepseek.com\n# OpenAI-compatible DeepSeek endpoint also supports: https://api.deepseek.com/v1\n# Note: /v1 here is API path compatibility only, not model version.\nDEEPSEEK_MODEL=deepseek-v4-flash\n# deepseek-v4-flash = efficient DeepSeek V4 path\n# deepseek-v4-pro = stronger DeepSeek V4 path\n# deepseek-chat / deepseek-reasoner are legacy compatibility aliases.\n# Optional dedicated reasoning model for fallback after provider/model failures\n# DEEPSEEK_REASONING_MODEL=deepseek-v4-pro\n# Router order (default): glm-5.1 -> deepseek-v4-pro -> deepseek-v4-flash\n# Optional override:\n# MODEL_DEFAULT=glm-5.1\n# MODEL_FALLBACK_CHAIN=deepseek-v4-pro,deepseek-v4-flash\n# Native OpenAI-compatible invocation defaults (enabled by default)\n# MODEL_NATIVE_OPTIONS_ENABLED=true\n# MODEL_NATIVE_TOOLS=[{\"type\":\"function\",\"function\":{\"name\":\"inspect_trace\",\"description\":\"Inspect trace signals\",\"parameters\":{\"type\":\"object\",\"properties\":{\"focus\":{\"type\":\"string\"}}}}}]\n# MODEL_NATIVE_TOOL_CHOICE=auto\n# MODEL_NATIVE_REASONING={\"effort\":\"medium\"}\n# Optional per-task reasoning override (JSON object)\n# MODEL_NATIVE_REASONING_BY_TASK={\"planning\":{\"effort\":\"high\"},\"evaluation\":{\"effort\":\"high\"},\"formatting\":{\"effort\":\"low\"}}\n\n# GLM (Zhipu AI) Configuration\n# GLM_API_KEY=your_glm_api_key_here\n# GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4\n# GLM_CODING_BASE_URL=https://open.bigmodel.cn/api/coding/paas/v4\n# GLM_MODEL=glm-5.1\n\n# Strict startup guard for trace-analysis AI credentials.\n# When true, backend startup fails fast if required env (e.g. DEEPSEEK_API_KEY) is missing.\nTRACE_ANALYSIS_STRICT_STARTUP=false\n\n# File Upload\nMAX_FILE_SIZE=2147483648 # 2GB\nUPLOAD_DIR=./uploads\n\n# Perfetto Configuration (optional - uses default paths if not set)\n# TRACE_PROCESSOR_PATH=/path/to/trace_processor_shell\n# PERFETTO_PATH=/path/to/perfetto\n\n# CORS\nFRONTEND_URL=http://localhost:10000\n\n# Agent feature flags\nFEATURE_AGENT_SCENE_RECONSTRUCT=true\nFEATURE_AGENT_LOGS_API=true\n\n# ADB full-mode guard (default false). Requires this flag AND per-request allowAgentFullMode=true.\nSMARTPERFETTO_ALLOW_AGENT_ADB_FULL_MODE=false\n"}}, "backend/src/agent/core/__tests__/modelRouter.test.ts": {"c78e8934c86c612f6b7ce2f42f21c35c99de00ce": {"t": 1786068275.0369406, "n": 50813, "h": "5dfc9460fc2f5382", "c": "// SPDX-License-Identifier: AGPL-3.0-or-later\n// Copyright (C) 2024-2026 Gracker (Chris)\n// This file is part of SmartPerfetto. See LICENSE for details.\n\n/**\n * ModelRouter Unit Tests\n *\n * Tests for the multi-model routing system:\n * 1. Model Selection (routeByTask, findByStrengths)\n * 2. Fallback Chain mechanism\n * 3. Ensemble mode (multi-model voting)\n * 4. Statistics tracking\n * 5. Model management (enable/disable/add/remove)\n * 6. LLM Client creation\n */\n\nimport { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';\nimport { ModelRouter, AllModelsFailedError } from '../modelRouter';\nimport type { ModelProfile } from '../../types';\n\n// =============================================================================\n// Helper functions for creating typed mocks\n// =============================================================================\n\nfunction createMockComplete(returnValue: string) {\n return jest.fn<() => Promise<string>>().mockResolvedValue(returnValue);\n}\n\nfunction createFailingMockComplete(errorMessage: string) {\n return jest.fn<() => Promise<string>>().mockRejectedValue(new Error(errorMessage));\n}\n\ninterface MockLLMClient {\n complete: jest.Mock<() => Promise<string>>;\n}\n\nfunction createMockClient(returnValue: string): MockLLMClient {\n return {\n complete: createMockComplete(returnValue),\n };\n}\n\nfunction createFailingMockClient(errorMessage: string): MockLLMClient {\n return {\n complete: createFailingMockComplete(errorMessage),\n };\n}\n\n// =============================================================================\n// Test Fixtures\n// =============================================================================\n\nconst createMockModel = (overrides: Partial<ModelProfile> = {}): ModelProfile => ({\n id: 'test-model',\n provider: 'mock',\n model: 'test-model-v1',\n strengths: ['reasoning'],\n costPerInputToken: 0.001,\n costPerOutputToken: 0.002,\n avgLatencyMs: 1000,\n maxTokens: 4096,\n supportsJSON: true,\n supportsStreaming: true,\n enabled: true,\n ...overrides,\n});\n\nconst createTestModels = (): ModelProfile[] => [\n createMockModel({\n id: 'reasoning-model',\n provider: 'mock',\n model: 'reasoning-v1',\n strengths: ['reasoning'],\n costPerInputToken: 0.003,\n costPerOutputToken: 0.006,\n }),\n createMockModel({\n id: 'coding-model',\n provider: 'mock',\n model: 'coding-v1',\n strengths: ['coding', 'cost'],\n costPerInputToken: 0.001,\n costPerOutputToken: 0.002,\n }),\n createMockModel({\n id: 'speed-model',\n provider: 'mock',\n model: 'speed-v1',\n strengths: ['speed', 'cost'],\n costPerInputToken: 0.0005,\n costPerOutputToken: 0.001,\n }),\n createMockModel({\n id: 'disabled-model',\n provider: 'mock',\n model: 'disabled-v1',\n strengths: ['reasoning', 'coding'],\n enabled: false,\n }),\n];\n\n// =============================================================================\n// Test Suite\n// =============================================================================\n\ndescribe('ModelRouter', () => {\n let router: ModelRouter;\n let testModels: ModelProfile[];\n\n beforeEach(() => {\n testModels = createTestModels();\n router = new ModelRouter({\n models: testModels,\n defaultModel: 'reasoning-model',\n fallbackChain: ['reasoning-model', 'coding-model', 'speed-model'],\n enableEnsemble: false,\n ensembleThreshold: 0.8,\n taskModelMapping: {},\n });\n });\n\n afterEach(() => {\n router.resetStats();\n });\n\n // ===========================================================================\n // Model Selection Tests\n // ===========================================================================\n\n describe('Model Selection', () => {\n describe('routeByTask', () => {\n it('should select model based on task type strengths', () => {\n // sql_generation maps to 'coding' strength\n // The router also factors in cost, so it may select the cheapest model\n // that matches the strength, or a cheaper model with partial match\n const model = router.routeByTask('sql_generation');\n // The model should be enabled\n expect(model.enabled).toBe(true);\n });\n\n it('should use explicit task-to-model mapping when configured', () => {\n const routerWithMapping = new ModelRouter({\n models: testModels,\n defaultModel: 'reasoning-model',\n fallbackChain: ['reasoning-model'],\n taskModelMapping: {\n sql_generation: 'speed-model',\n },\n });\n\n const model = routerWithMapping.routeByTask('sql_generation');\n expect(model.id).toBe('speed-model');\n });\n\n it('should fall back to default model when no match', () => {\n // Create router with models that don't match the required strengths\n const limitedModels = [\n createMockModel({\n id: 'only-model',\n strengths: [],\n }),\n ];\n\n const limitedRouter = new ModelRouter({\n models: limitedModels,\n defaultModel: 'only-model',\n fallbackChain: ['only-model'],\n });\n\n const model = limitedRouter.routeByTask('intent_understanding');\n expect(model.id).toBe('only-model');\n });\n\n it('should skip disabled models in task mapping', () => {\n const routerWithDisabledMapping = new ModelRouter({\n models: testModels,\n defaultModel: 'reasoning-model',\n fallbackChain: ['reasoning-model'],\n taskModelMapping: {\n sql_generation: 'disabled-model', // This model is disabled\n },\n });\n\n const model = routerWithDisabledMapping.routeByTask('sql_generation');\n // Should not use disabled model, should fall back to strength-based selection\n expect(model.id).not.toBe('disabled-model');\n expect(model.enabled).toBe(true);\n });\n\n it('should throw error when no models are available', () => {\n const emptyRouter = new ModelRouter({\n models: [createMockModel({ id: 'disabled', enabled: false })],\n defaultModel: 'disabled',\n fallbackChain: [],\n });\n\n expect(() => emptyRouter.routeByTask('general')).toThrow('No available models');\n });\n\n it('should select a valid model for intent_understanding task', () => {\n const model = router.routeByTask('intent_understanding');\n // The model should be enabled and valid\n expect(model.enabled).toBe(true);\n expect(model.id).toBeDefined();\n });\n\n it('should select speed/cost model for simple_extraction task', () => {\n const model = router.routeByTask('simple_extraction');\n // Should prefer models with speed or cost strengths\n expect(model.strengths.some(s => s === 'speed' || s === 'cost')).toBe(true);\n });\n });\n\n describe('findByStrengths', () => {\n it('should find model matching required strengths', () => {\n // Create a router with only one model that has the required strength\n const codingOnlyModels = [\n createMockModel({\n id: 'coding-only',\n strengths: ['coding'],\n costPerInputToken: 0.001,\n costPerOutputToken: 0.002,\n }),\n ];\n\n const codingRouter = new ModelRouter({\n models: codingOnlyModels,\n defaultModel: 'coding-only',\n fallbackChain: [],\n });\n\n const model = codingRouter.findByStrengths(['coding']);\n expect(model).toBeDefined();\n expect(model!.strengths).toContain('coding');\n });\n\n it('should return undefined when no enabled models match', () => {\n const noMatchRouter = new ModelRouter({\n models: [\n createMockModel({ id: 'model1', strengths: ['reasoning'], enabled: false }),\n ],\n defaultModel: 'model1',\n fallbackChain: [],\n });\n\n const model = noMatchRouter.findByStrengths(['vision']);\n expect(model).toBeUndefined();\n });\n\n it('should prefer model with multiple matching strengths', () => {\n const modelsWithVaryingStrengths = [\n createMockModel({\n id: 'single-strength',\n strengths: ['reasoning'],\n costPerInputToken: 0.001,\n costPerOutputToken: 0.002,\n }),\n createMockModel({\n id: 'multi-strength',\n strengths: ['reasoning', 'coding'],\n costPerInputToken: 0.001,\n costPerOutputToken: 0.002,\n }),\n ];\n\n const multiRouter = new ModelRouter({\n models: modelsWithVaryingStrengths,\n defaultModel: 'single-strength',\n fallbackChain: [],\n });\n\n const model = multiRouter.findByStrengths(['reasoning', 'coding']);\n expect(model!.id).toBe('multi-strength');\n });\n\n it('should factor in cost when selecting model', () => {\n const similarModels = [\n createMockModel({\n id: 'expensive',\n strengths: ['reasoning'],\n costPerInputToken: 0.01,\n costPerOutputToken: 0.02,\n }),\n createMockModel({\n id: 'cheap',\n strengths: ['reasoning'],\n costPerInputToken: 0.0001,\n costPerOutputToken: 0.0002,\n }),\n ];\n\n const costRouter = new ModelRouter({\n models: similarModels,\n defaultModel: 'expensive',\n fallbackChain: [],\n });\n\n const model = costRouter.findByStrengths(['reasoning']);\n // Should prefer cheaper model when strengths are equal\n expect(model!.id).toBe('cheap');\n });\n });\n\n describe('getModel', () => {\n it('should return model by ID', () => {\n const model = router.getModel('coding-model');\n expect(model).toBeDefined();\n expect(model!.id).toBe('coding-model');\n });\n\n it('should return undefined for non-existent model', () => {\n const model = router.getModel('non-existent');\n expect(model).toBeUndefined();\n });\n });\n\n describe('getEnabledModels', () => {\n it('should return only enabled models', () => {\n const enabledModels = router.getEnabledModels();\n expect(enabledModels.length).toBe(3); // 3 enabled, 1 disabled\n expect(enabledModels.every(m => m.enabled)).toBe(true);\n });\n\n it('should not include disabled models', () => {\n const enabledModels = router.getEnabledModels();\n expect(enabledModels.find(m => m.id === 'disabled-model')).toBeUndefined();\n });\n });\n });\n\n // ===========================================================================\n // Fallback Chain Tests\n // ===========================================================================\n\n describe('Fallback Chain', () => {\n describe('callWithFallback', () => {\n it('should try primary model first', async () => {\n const mockClient = createMockClient('Success response');\n\n // Register mock client for the primary model\n const primaryModel = router.routeByTask('general');\n router.registerClient(primaryModel.id, mockClient as any);\n\n const result = await router.callWithFallback('Test prompt', 'general');\n\n expect(result.success).toBe(true);\n expect(result.modelId).toBe(primaryModel.id);\n expect(mockClient.complete).toHaveBeenCalledTimes(1);\n });\n\n it('should fall back to next model on failure', async () => {\n const failingClient = createFailingMockClient('Primary model failed');\n const successClient = createMockClient('Fallback success');\n\n // Register failing client for ALL models except the last one\n router.registerClient('speed-model', failingClient as any);\n router.registerClient('reasoning-model', failingClient as any);\n router.registerClient('coding-model', successClient as any);\n\n const result = await router.callWithFallback('Test prompt', 'intent_understanding');\n\n expect(result.success).toBe(true);\n // At least one failing client should have been called\n expect(failingClient.complete).toHaveBeenCalled();\n });\n\n it('should throw AllModelsFailedError when all models fail', async () => {\n const failingClient = createFailingMockClient('Model failed');\n\n // Register failing client for all models in fallback chain\n router.registerClient('reasoning-model', failingClient as any);\n router.registerClient('coding-model', failingClient as any);\n router.registerClient('speed-model', failingClient as any);\n\n await expect(router.callWithFallback('Test prompt', 'general'))\n .rejects.toThrow(AllModelsFailedError);\n\n try {\n await router.callWithFallback('Test prompt', 'general');\n } catch (error) {\n expect(error).toBeInstanceOf(AllModelsFailedError);\n expect((error as AllModelsFailedError).triedModels.length).toBeGreaterThan(0);\n }\n });\n\n it('should record failures in stats', async () => {\n const failingClient = createFailingMockClient('Model failed');\n\n router.registerClient('reasoning-model', failingClient as any);\n router.registerClient('coding-model', failingClient as any);\n router.registerClient('speed-model', failingClient as any);\n\n try {\n await router.callWithFallback('Test prompt', 'general');\n } catch {\n // Expected to fail\n }\n\n const stats = router.getStats();\n // At least one model should have recorded failure\n const hasFailures = Object.values(stats).some(s => s.failures > 0);\n expect(hasFailures).toBe(true);\n });\n\n it('retries with compacted prompt when context length is exceeded', async () => {\n const primaryModel = router.routeByTask('general');\n const contextOverflowClient: MockLLMClient = {\n complete: jest\n .fn<() => Promise<string>>()\n .mockRejectedValueOnce(new Error('This model\\'s maximum context length is 8192 tokens.'))\n .mockResolvedValue('Recovered with compacted prompt'),\n };\n\n router.registerClient(primaryModel.id, contextOverflowClient as any);\n\n const longPrompt = 'A'.repeat(8000) + '\\n' + 'B'.repeat(8000);\n const result = await router.callWithFallback(longPrompt, 'general');\n\n expect(result.success).toBe(true);\n expect(result.modelId).toBe(primaryModel.id);\n expect(contextOverflowClient.complete).toHaveBeenCalledTimes(2);\n\n const firstPrompt = String((contextOverflowClient.complete as jest.Mock).mock.calls[0][0] || '');\n const secondPrompt = String((contextOverflowClient.complete as jest.Mock).mock.calls[1][0] || '');\n expect(secondPrompt.length).toBeLessThan(firstPrompt.length);\n expect(secondPrompt).toContain('[...context compacted for model limit...]');\n });\n });\n\n describe('getFallbackChain', () => {\n it('should return fallback chain excluding specified model', () => {\n const chain = router.getFallbackChain('reasoning-model');\n expect(chain).not.toContain('reasoning-model');\n expect(chain).toContain('coding-model');\n expect(chain).toContain('speed-model');\n });\n\n it('should return all models when no exclusion specified', () => {\n const chain = router.getFallbackChain();\n expect(chain.length).toBe(3);\n });\n\n it('should only include enabled models in fallback chain', () => {\n // Disable a model that's in the fallback chain\n router.disableModel('coding-model');\n\n const chain = router.getFallbackChain();\n expect(chain).not.toContain('coding-model');\n });\n });\n\n describe('setFallbackChain', () => {\n it('should update fallback chain', () => {\n const newChain = ['speed-model', 'coding-model'];\n router.setFallbackChain(newChain);\n\n const chain = router.getFallbackChain();\n expect(chain).toEqual(newChain);\n });\n\n it('should emit configUpdated event', () => {\n const handler = jest.fn();\n router.on('configUpdated', handler);\n\n router.setFallbackChain(['speed-model']);\n\n expect(handler).toHaveBeenCalledWith({ fallbackChain: ['speed-model'] });\n });\n });\n });\n\n // ===========================================================================\n // Ensemble Mode Tests\n // ===========================================================================\n\n describe('Ensemble Mode', () => {\n let mockClients: Map<string, MockLLMClient>;\n\n beforeEach(() => {\n mockClients = new Map();\n\n // Create mock clients for each model\n for (const model of testModels.filter(m => m.enabled)) {\n const client = createMockClient(`Response from ${model.id}`);\n mockClients.set(model.id, client);\n router.registerClient(model.id, client as any);\n }\n });\n\n it('should call multiple models in parallel', async () => {\n const result = await router.ensemble('Test prompt', ['reasoning-model', 'coding-model']);\n\n expect(result.responses.length).toBe(2);\n expect(mockClients.get('reasoning-model')!.complete).toHaveBeenCalled();\n expect(mockClients.get('coding-model')!.complete).toHaveBeenCalled();\n });\n\n it('should aggregate responses (longest wins)', async () => {\n // Set up different length responses\n mockClients.get('reasoning-model')!.complete.mockResolvedValue('Short');\n mockClients.get('coding-model')!.complete.mockResolvedValue('This is a much longer response');\n\n const result = await router.ensemble('Test prompt', ['reasoning-model', 'coding-model']);\n\n expect(result.aggregatedResponse).toBe('This is a much longer response');\n });\n\n it('should calculate agreement score', async () => {\n // Set up similar length responses for high agreement\n mockClients.get('reasoning-model')!.complete.mockResolvedValue('Response A');\n mockClients.get('coding-model')!.complete.mockResolvedValue('Response B');\n\n const result = await router.ensemble('Test prompt', ['reasoning-model', 'coding-model']);\n\n expect(result.agreementScore).toBeGreaterThan(0);\n expect(result.agreementScore).toBeLessThanOrEqual(1);\n });\n\n it('should return total cost', async () => {\n const result = await router.ensemble('Test prompt', ['reasoning-model', 'coding-model']);\n\n expect(result.totalCost).toBeGreaterThan(0);\n });\n\n it('should require at least 2 models', async () => {\n await expect(router.ensemble('Test prompt', ['reasoning-model']))\n .rejects.toThrow('Ensemble requires at least 2 models');\n });\n\n it('should throw error when all models fail in ensemble', async () => {\n mockClients.get('reasoning-model')!.complete.mockRejectedValue(new Error('Failed'));\n mockClients.get('coding-model')!.complete.mockRejectedValue(new Error('Failed'));\n\n await expect(router.ensemble('Test prompt', ['reasoning-model', 'coding-model']))\n .rejects.toThrow('All models failed in ensemble');\n });\n\n it('should emit ensemble event', async () => {\n const handler = jest.fn();\n router.on('ensemble', handler);\n\n await router.ensemble('Test prompt', ['reasoning-model', 'coding-model']);\n\n expect(handler).toHaveBeenCalledWith(expect.objectContaining({\n responses: expect.any(Array),\n aggregatedResponse: expect.any(String),\n agreementScore: expect.any(Number),\n }));\n });\n\n it('should use first 3 enabled models when modelIds not specified', async () => {\n const result = await router.ensemble('Test prompt');\n\n expect(result.responses.length).toBe(3);\n });\n });\n\n // ===========================================================================\n // Statistics Tests\n // ===========================================================================\n\n describe('Statistics', () => {\n let mockClient: MockLLMClient;\n\n beforeEach(() => {\n mockClient = createMockClient('Success response');\n router.registerClient('reasoning-model', mockClient as any);\n });\n\n describe('updateStats', () => {\n it('should track calls, tokens, and cost', async () => {\n // Register mock client for all enabled models\n const mockClient = createMockClient('Success response');\n router.registerClient('speed-model', mockClient as any);\n router.registerClient('reasoning-model', mockClient as any);\n router.registerClient('coding-model', mockClient as any);\n\n await router.callWithFallback('Test prompt', 'general');\n\n const stats = router.getStats();\n // At least one model should have stats\n const modelIds = Object.keys(stats);\n expect(modelIds.length).toBeGreaterThan(0);\n\n const firstModelStats = stats[modelIds[0]];\n expect(firstModelStats.calls).toBe(1);\n expect(firstModelStats.tokens).toBeGreaterThan(0);\n expect(firstModelStats.cost).toBeGreaterThan(0);\n });\n\n it('should accumulate stats across multiple calls', async () => {\n // Register mock client for all enabled models\n const mockClient = createMockClient('Success response');\n router.registerClient('speed-model', mockClient as any);\n router.registerClient('reasoning-model', mockClient as any);\n router.registerClient('coding-model', mockClient as any);\n\n await router.callWithFallback('Test prompt 1', 'general');\n await router.callWithFallback('Test prompt 2', 'general');\n\n const stats = router.getStats();\n const modelIds = Object.keys(stats);\n expect(modelIds.length).toBeGreaterThan(0);\n\n // The total calls across all models should be 2\n const totalCalls = Object.values(stats).reduce((sum, s) => sum + s.calls, 0);\n expect(totalCalls).toBe(2);\n });\n });\n\n describe('recordFailure', () => {\n it('should increment failure count', async () => {\n const failingClient = createFailingMockClient('Failed');\n\n // Also register for other models so they fail too\n router.registerClient('reasoning-model', failingClient as any);\n router.registerClient('coding-model', failingClient as any);\n router.registerClient('speed-model', failingClient as any);\n\n try {\n await router.callWithFallback('Test prompt', 'general');\n } catch {\n // Expected\n }\n\n const stats = router.getStats();\n // At least one model should have failures\n const totalFailures = Object.values(stats).reduce((sum, s) => sum + s.failures, 0);\n expect(totalFailures).toBeGreaterThan(0);\n });\n });\n\n describe('resetStats', () => {\n it('should clear all stats', async () => {\n await router.callWithFallback('Test prompt', 'general');\n expect(router.getTotalCost()).toBeGreaterThan(0);\n\n router.resetStats();\n\n expect(router.getTotalCost()).toBe(0);\n const stats = router.getStats();\n expect(Object.keys(stats).length).toBe(0);\n });\n\n it('should emit statsReset event', () => {\n const handler = jest.fn();\n router.on('statsReset', handler);\n\n router.resetStats();\n\n expect(handler).toHaveBeenCalledTimes(1);\n });\n });\n\n describe('getTotalCost', () => {\n it('should return sum of all model costs', async () => {\n // Register mock clients for multiple models\n const client2 = createMockClient('Response');\n router.registerClient('coding-model', client2 as any);\n\n // Make calls to different models\n await router.callWithFallback('Test 1', 'intent_understanding'); // reasoning model\n await router.callWithFallback('Test 2', 'sql_generation'); // coding model\n\n const totalCost = router.getTotalCost();\n expect(totalCost).toBeGreaterThan(0);\n });\n\n it('should return 0 when no calls made', () => {\n expect(router.getTotalCost()).toBe(0);\n });\n });\n });\n\n // ===========================================================================\n // Model Management Tests\n // ===========================================================================\n\n describe('Model Management', () => {\n describe('enableModel', () => {\n it('should enable a disabled model', () => {\n const model = router.getModel('disabled-model');\n expect(model!.enabled).toBe(false);\n\n router.enableModel('disabled-model');\n\n expect(router.getModel('disabled-model')!.enabled).toBe(true);\n });\n\n it('should emit modelEnabled event', () => {\n const handler = jest.fn();\n router.on('modelEnabled', handler);\n\n router.enableModel('disabled-model');\n\n expect(handler).toHaveBeenCalledWith({ modelId: 'disabled-model' });\n });\n\n it('should handle non-existent model gracefully', () => {\n // Should not throw\n router.enableModel('non-existent');\n });\n });\n\n describe('disableModel', () => {\n it('should disable an enabled model', () => {\n expect(router.getModel('reasoning-model')!.enabled).toBe(true);\n\n router.disableModel('reasoning-model');\n\n expect(router.getModel('reasoning-model')!.enabled).toBe(false);\n });\n\n it('should emit modelDisabled event', () => {\n const handler = jest.fn();\n router.on('modelDisabled', handler);\n\n router.disableModel('reasoning-model');\n\n expect(handler).toHaveBeenCalledWith({ modelId: 'reasoning-model' });\n });\n });\n\n describe('addModel', () => {\n it('should add a new model', () => {\n const newModel = createMockModel({\n id: 'new-model',\n strengths: ['vision'],\n });\n\n router.addModel(newModel);\n\n expect(router.getModel('new-model')).toBeDefined();\n expect(router.listModels().find(m => m.id === 'new-model')).toBeDefined();\n });\n\n it('should emit modelAdded event', () => {\n const handler = jest.fn();\n router.on('modelAdded', handler);\n\n router.addModel(createMockModel({ id: 'new-model' }));\n\n expect(handler).toHaveBeenCalledWith({ modelId: 'new-model' });\n });\n });\n\n describe('removeModel', () => {\n it('should remove a model', () => {\n expect(router.getModel('speed-model')).toBeDefined();\n\n router.removeModel('speed-model');\n\n expect(router.getModel('speed-model')).toBeUndefined();\n });\n\n it('should emit modelRemoved event', () => {\n const handler = jest.fn();\n router.on('modelRemoved', handler);\n\n router.removeModel('speed-model');\n\n expect(handler).toHaveBeenCalledWith({ modelId: 'speed-model' });\n });\n });\n\n describe('updateModel', () => {\n it('should update model properties', () => {\n router.updateModel('reasoning-model', {\n costPerInputToken: 0.01,\n maxTokens: 8192,\n });\n\n const model = router.getModel('reasoning-model');\n expect(model!.costPerInputToken).toBe(0.01);\n expect(model!.maxTokens).toBe(8192);\n });\n\n it('should emit modelUpdated event', () => {\n const handler = jest.fn();\n router.on('modelUpdated', handler);\n\n router.updateModel('reasoning-model', { maxTokens: 8192 });\n\n expect(handler).toHaveBeenCalledWith({\n modelId: 'reasoning-model',\n updates: { maxTokens: 8192 },\n });\n });\n });\n\n describe('listModels', () => {\n it('should return all models including disabled', () => {\n const models = router.listModels();\n expect(models.length).toBe(4); // 3 enabled + 1 disabled\n });\n });\n });\n\n // ===========================================================================\n // Client Creation Tests\n // ===========================================================================\n\n describe('Client Creation', () => {\n it('should create DeepSeek client for deepseek provider', async () => {\n const deepseekModel = createMockModel({\n id: 'deepseek-test',\n provider: 'deepseek',\n model: 'deepseek-chat',\n });\n\n const deepseekRouter = new ModelRouter({\n models: [deepseekModel],\n defaultModel: 'deepseek-test',\n fallbackChain: ['deepseek-test'],\n });\n\n // The client creation happens internally when callModel is invoked\n // We can verify by checking it doesn't throw for valid provider\n // Note: Actual API call will fail without key, but client creation succeeds\n const model = deepseekRouter.getModel('deepseek-test');\n expect(model!.provider).toBe('deepseek');\n });\n\n it('should create Anthropic client for anthropic provider', () => {\n const anthropicModel = createMockModel({\n id: 'anthropic-test',\n provider: 'anthropic',\n model: 'claude-3-opus',\n });\n\n const anthropicRouter = new ModelRouter({\n models: [anthropicModel],\n defaultModel: 'anthropic-test',\n fallbackChain: ['anthropic-test'],\n });\n\n const model = anthropicRouter.getModel('anthropic-test');\n expect(model!.provider).toBe('anthropic');\n });\n\n it('should create OpenAI client for openai provider', () => {\n const openaiModel = createMockModel({\n id: 'openai-test',\n provider: 'openai',\n model: 'gpt-4',\n });\n\n const openaiRouter = new ModelRouter({\n models: [openaiModel],\n defaultModel: 'openai-test',\n fallbackChain: ['openai-test'],\n });\n\n const model = openaiRouter.getModel('openai-test');\n expect(model!.provider).toBe('openai');\n });\n\n it('should create Mock client for mock provider', async () => {\n const mockModel = createMockModel({\n id: 'mock-test',\n provider: 'mock',\n model: 'mock-v1',\n });\n\n const mockRouter = new ModelRouter({\n models: [mockModel],\n defaultModel: 'mock-test',\n fallbackChain: ['mock-test'],\n });\n\n // Mock client should work without API key\n const result = await mockRouter.callWithFallback('Test prompt', 'general');\n expect(result.success).toBe(true);\n expect(result.response).toContain('mock');\n });\n\n it('should throw error for unknown provider', async () => {\n const unknownModel = createMockModel({\n id: 'unknown-test',\n provider: 'unknown' as any,\n model: 'unknown-v1',\n });\n\n const unknownRouter = new ModelRouter({\n models: [unknownModel],\n defaultModel: 'unknown-test',\n fallbackChain: ['unknown-test'],\n });\n\n // The error will be wrapped in AllModelsFailedError since all models fail\n await expect(unknownRouter.callWithFallback('Test', 'general'))\n .rejects.toThrow(AllModelsFailedError);\n });\n\n it('should reuse existing client for same model', async () => {\n const mockModel = createMockModel({\n id: 'reuse-test',\n provider: 'mock',\n model: 'mock-v1',\n });\n\n const reuseRouter = new ModelRouter({\n models: [mockModel],\n defaultModel: 'reuse-test',\n fallbackChain: ['reuse-test'],\n });\n\n // Make multiple calls\n await reuseRouter.callWithFallback('Test 1', 'general');\n await reuseRouter.callWithFallback('Test 2', 'general');\n\n // Client should be created once and reused\n // We verify this by checking stats show 2 calls\n const stats = reuseRouter.getStats();\n expect(stats['reuse-test'].calls).toBe(2);\n });\n });\n\n // ===========================================================================\n // Event Emission Tests\n // ===========================================================================\n\n describe('Event Emission', () => {\n let mockClient: MockLLMClient;\n\n beforeEach(() => {\n mockClient = createMockClient('Response');\n router.registerClient('reasoning-model', mockClient as any);\n });\n\n it('should emit modelCall event on successful call', async () => {\n const handler = jest.fn();\n router.on('modelCall', handler);\n\n await router.callWithFallback('Test', 'general');\n\n expect(handler).toHaveBeenCalledWith(expect.objectContaining({\n modelId: expect.any(String),\n response: expect.any(String),\n success: true,\n }));\n });\n\n it('should emit llmTelemetry event with error on failure', async () => {\n // Create a router with an unknown provider that will fail during client creation\n const unknownModel = createMockModel({\n id: 'unknown-provider',\n provider: 'unknown-provider' as any,\n model: 'test-v1',\n });\n\n const unknownRouter = new ModelRouter({\n models: [unknownModel],\n defaultModel: 'unknown-provider',\n fallbackChain: ['unknown-provider'],\n });\n\n const telemetryHandler = jest.fn();\n unknownRouter.on('llmTelemetry', telemetryHandler);\n\n try {\n await unknownRouter.callWithFallback('Test', 'general');\n } catch {\n // Expected - AllModelsFailedError\n }\n\n // The error is recorded in llmTelemetry event when callModel catches the error\n expect(telemetryHandler).toHaveBeenCalledWith(expect.objectContaining({\n modelId: 'unknown-provider',\n success: false,\n error: expect.stringContaining('Unknown provider'),\n }));\n });\n\n it('should emit llmTelemetry event on call', async () => {\n const handler = jest.fn();\n router.on('llmTelemetry', handler);\n\n await router.callWithFallback('Test', 'general', {\n sessionId: 'session-1',\n traceId: 'trace-1',\n });\n\n expect(handler).toHaveBeenCalledWith(expect.objectContaining({\n schemaVersion: '1.0.0',\n sessionId: 'session-1',\n traceId: 'trace-1',\n modelId: expect.any(String),\n provider: expect.any(String),\n success: true,\n }));\n });\n });\n\n // ===========================================================================\n // Edge Cases and Error Handling\n // ===========================================================================\n\n describe('Edge Cases', () => {\n it('should handle empty prompt', async () => {\n const mockClient = createMockClient('Response');\n router.registerClient('reasoning-model', mockClient as any);\n\n const result = await router.callWithFallback('', 'general');\n expect(result.success).toBe(true);\n });\n\n it('should handle very long prompt', async () => {\n const mockClient = createMockClient('Response');\n router.registerClient('reasoning-model', mockClient as any);\n\n const longPrompt = 'a'.repeat(100000);\n const result = await router.callWithFallback(longPrompt, 'general');\n expect(result.success).toBe(true);\n });\n\n it('should handle concurrent calls', async () => {\n const mockClient: MockLLMClient = {\n complete: jest.fn<() => Promise<string>>().mockImplementation(async () => {\n await new Promise(resolve => setTimeout(resolve, 10));\n return 'Response';\n }),\n };\n router.registerClient('reasoning-model', mockClient as any);\n\n const promises = [\n router.callWithFallback('Test 1', 'general'),\n router.callWithFallback('Test 2', 'general'),\n router.callWithFallback('Test 3', 'general'),\n ];\n\n const results = await Promise.all(promises);\n expect(results.every(r => r.success)).toBe(true);\n });\n\n it('should handle JSON mode detection from prompt', async () => {\n const mockClient = createMockClient('{\"key\": \"value\"}');\n router.registerClient('reasoning-model', mockClient as any);\n\n const result = await router.callWithFallback(\n 'Please respond in JSON format: { \"result\": \"...\" }',\n 'general'\n );\n\n expect(result.success).toBe(true);\n });\n\n it('should handle custom call options', async () => {\n const mockClient = createMockClient('Response');\n // Register mock client for all enabled models\n for (const model of router.getEnabledModels()) {\n router.registerClient(model.id, mockClient as any);\n }\n\n const result = await router.callWithFallback('Test', 'general', {\n maxTokens: 1000,\n temperature: 0.5,\n jsonMode: true,\n });\n\n expect(result.success).toBe(true);\n // Verify the call was made - the options are passed internally\n expect(mockClient.complete).toHaveBeenCalled();\n });\n\n it('should enable native tools/tool_choice/reasoning by default from env', async () => {\n const complete = jest\n .fn<(prompt: string, options?: Record<string, unknown>) => Promise<string>>()\n .mockResolvedValue('Response');\n const mockClient = { complete };\n for (const model of router.getEnabledModels()) {\n router.registerClient(model.id, mockClient as any);\n }\n\n const previousEnabled = process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n const previousTools = process.env.MODEL_NATIVE_TOOLS;\n const previousToolChoice = process.env.MODEL_NATIVE_TOOL_CHOICE;\n const previousReasoning = process.env.MODEL_NATIVE_REASONING;\n\n process.env.MODEL_NATIVE_OPTIONS_ENABLED = 'true';\n process.env.MODEL_NATIVE_TOOLS = JSON.stringify([\n { type: 'function', function: { name: 'default_tool', parameters: { type: 'object' } } },\n ]);\n process.env.MODEL_NATIVE_TOOL_CHOICE = 'required';\n process.env.MODEL_NATIVE_REASONING = JSON.stringify({ effort: 'high' });\n\n try {\n const result = await router.callWithFallback('Test', 'general');\n expect(result.success).toBe(true);\n expect(complete).toHaveBeenCalled();\n const callOptions = (complete.mock.calls[0]?.[1] ?? {}) as Record<string, unknown>;\n expect(callOptions.tools).toEqual([\n { type: 'function', function: { name: 'default_tool', parameters: { type: 'object' } } },\n ]);\n expect(callOptions.toolChoice).toBe('required');\n expect(callOptions.reasoning).toEqual({ effort: 'high' });\n } finally {\n if (previousEnabled === undefined) delete process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n else process.env.MODEL_NATIVE_OPTIONS_ENABLED = previousEnabled;\n if (previousTools === undefined) delete process.env.MODEL_NATIVE_TOOLS;\n else process.env.MODEL_NATIVE_TOOLS = previousTools;\n if (previousToolChoice === undefined) delete process.env.MODEL_NATIVE_TOOL_CHOICE;\n else process.env.MODEL_NATIVE_TOOL_CHOICE = previousToolChoice;\n if (previousReasoning === undefined) delete process.env.MODEL_NATIVE_REASONING;\n else process.env.MODEL_NATIVE_REASONING = previousReasoning;\n }\n });\n\n it('should apply task-aware reasoning profile when reasoning is not explicitly provided', async () => {\n const complete = jest\n .fn<(prompt: string, options?: Record<string, unknown>) => Promise<string>>()\n .mockResolvedValue('Response');\n const mockClient = { complete };\n for (const model of router.getEnabledModels()) {\n router.registerClient(model.id, mockClient as any);\n }\n\n const previousTaskReasoning = process.env.MODEL_NATIVE_REASONING_BY_TASK;\n const previousReasoning = process.env.MODEL_NATIVE_REASONING;\n\n delete process.env.MODEL_NATIVE_REASONING_BY_TASK;\n delete process.env.MODEL_NATIVE_REASONING;\n\n try {\n const result = await router.callWithFallback('Plan this task', 'planning');\n expect(result.success).toBe(true);\n expect(complete).toHaveBeenCalled();\n\n const callOptions = (complete.mock.calls[0]?.[1] ?? {}) as Record<string, unknown>;\n expect(callOptions.reasoning).toEqual({ effort: 'high' });\n } finally {\n if (previousTaskReasoning === undefined) delete process.env.MODEL_NATIVE_REASONING_BY_TASK;\n else process.env.MODEL_NATIVE_REASONING_BY_TASK = previousTaskReasoning;\n if (previousReasoning === undefined) delete process.env.MODEL_NATIVE_REASONING;\n else process.env.MODEL_NATIVE_REASONING = previousReasoning;\n }\n });\n\n it('should disable env default native tools for formatting/simple extraction tasks', async () => {\n const complete = jest\n .fn<(prompt: string, options?: Record<string, unknown>) => Promise<string>>()\n .mockResolvedValue('Response');\n const mockClient = { complete };\n for (const model of router.getEnabledModels()) {\n router.registerClient(model.id, mockClient as any);\n }\n\n const previousEnabled = process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n const previousTools = process.env.MODEL_NATIVE_TOOLS;\n const previousToolChoice = process.env.MODEL_NATIVE_TOOL_CHOICE;\n\n process.env.MODEL_NATIVE_OPTIONS_ENABLED = 'true';\n process.env.MODEL_NATIVE_TOOLS = JSON.stringify([\n { type: 'function', function: { name: 'default_tool', parameters: { type: 'object' } } },\n ]);\n process.env.MODEL_NATIVE_TOOL_CHOICE = 'auto';\n\n try {\n const result = await router.callWithFallback('Format this output', 'formatting');\n expect(result.success).toBe(true);\n expect(complete).toHaveBeenCalled();\n\n const callOptions = (complete.mock.calls[0]?.[1] ?? {}) as Record<string, unknown>;\n expect(callOptions.tools).toEqual([]);\n expect(callOptions.toolChoice).toBeUndefined();\n } finally {\n if (previousEnabled === undefined) delete process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n else process.env.MODEL_NATIVE_OPTIONS_ENABLED = previousEnabled;\n if (previousTools === undefined) delete process.env.MODEL_NATIVE_TOOLS;\n else process.env.MODEL_NATIVE_TOOLS = previousTools;\n if (previousToolChoice === undefined) delete process.env.MODEL_NATIVE_TOOL_CHOICE;\n else process.env.MODEL_NATIVE_TOOL_CHOICE = previousToolChoice;\n }\n });\n\n it('should skip task-aware native defaults when native options are disabled', async () => {\n const complete = jest\n .fn<(prompt: string, options?: Record<string, unknown>) => Promise<string>>()\n .mockResolvedValue('Response');\n const mockClient = { complete };\n for (const model of router.getEnabledModels()) {\n router.registerClient(model.id, mockClient as any);\n }\n\n const previousEnabled = process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n const previousTools = process.env.MODEL_NATIVE_TOOLS;\n const previousReasoning = process.env.MODEL_NATIVE_REASONING;\n\n process.env.MODEL_NATIVE_OPTIONS_ENABLED = 'false';\n process.env.MODEL_NATIVE_TOOLS = JSON.stringify([\n { type: 'function', function: { name: 'default_tool', parameters: { type: 'object' } } },\n ]);\n process.env.MODEL_NATIVE_REASONING = JSON.stringify({ effort: 'high' });\n\n try {\n const result = await router.callWithFallback('Plan this task', 'planning');\n expect(result.success).toBe(true);\n expect(complete).toHaveBeenCalled();\n\n const callOptions = (complete.mock.calls[0]?.[1] ?? {}) as Record<string, unknown>;\n expect(callOptions.tools).toBeUndefined();\n expect(callOptions.toolChoice).toBeUndefined();\n expect(callOptions.reasoning).toBeUndefined();\n } finally {\n if (previousEnabled === undefined) delete process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n else process.env.MODEL_NATIVE_OPTIONS_ENABLED = previousEnabled;\n if (previousTools === undefined) delete process.env.MODEL_NATIVE_TOOLS;\n else process.env.MODEL_NATIVE_TOOLS = previousTools;\n if (previousReasoning === undefined) delete process.env.MODEL_NATIVE_REASONING;\n else process.env.MODEL_NATIVE_REASONING = previousReasoning;\n }\n });\n\n it('should allow per-call native options to override env defaults', async () => {\n const complete = jest\n .fn<(prompt: string, options?: Record<string, unknown>) => Promise<string>>()\n .mockResolvedValue('Response');\n const mockClient = { complete };\n for (const model of router.getEnabledModels()) {\n router.registerClient(model.id, mockClient as any);\n }\n\n const previousEnabled = process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n const previousTools = process.env.MODEL_NATIVE_TOOLS;\n const previousToolChoice = process.env.MODEL_NATIVE_TOOL_CHOICE;\n const previousReasoning = process.env.MODEL_NATIVE_REASONING;\n\n process.env.MODEL_NATIVE_OPTIONS_ENABLED = 'true';\n process.env.MODEL_NATIVE_TOOLS = JSON.stringify([\n { type: 'function', function: { name: 'default_tool', parameters: { type: 'object' } } },\n ]);\n process.env.MODEL_NATIVE_TOOL_CHOICE = 'required';\n process.env.MODEL_NATIVE_REASONING = JSON.stringify({ effort: 'high' });\n\n try {\n const overrideTools = [\n { type: 'function', function: { name: 'override_tool', parameters: { type: 'object' } } },\n ];\n const overrideToolChoice = 'auto';\n const overrideReasoning = { effort: 'low' };\n\n const result = await router.callWithFallback('Test', 'general', {\n tools: overrideTools,\n toolChoice: overrideToolChoice,\n reasoning: overrideReasoning,\n });\n expect(result.success).toBe(true);\n expect(complete).toHaveBeenCalled();\n const callOptions = (complete.mock.calls[0]?.[1] ?? {}) as Record<string, unknown>;\n expect(callOptions.tools).toEqual(overrideTools);\n expect(callOptions.toolChoice).toBe(overrideToolChoice);\n expect(callOptions.reasoning).toEqual(overrideReasoning);\n } finally {\n if (previousEnabled === undefined) delete process.env.MODEL_NATIVE_OPTIONS_ENABLED;\n else process.env.MODEL_NATIVE_OPTIONS_ENABLED = previousEnabled;\n if (previousTools === undefined) delete process.env.MODEL_NATIVE_TOOLS;\n else process.env.MODEL_NATIVE_TOOLS = previousTools;\n if (previousToolChoice === undefined) delete process.env.MODEL_NATIVE_TOOL_CHOICE;\n else process.env.MODEL_NATIVE_TOOL_CHOICE = previousToolChoice;\n if (previousReasoning === undefined) delete process.env.MODEL_NATIVE_REASONING;\n else process.env.MODEL_NATIVE_REASONING = previousReasoning;\n }\n });\n\n it('should pass native tools/tool_choice/reasoning options to client', async () => {\n const complete = jest\n .fn<(prompt: string, options?: Record<string, unknown>) => Promise<string>>()\n .mockResolvedValue('Response');\n const mockClient = { complete };\n for (const model of router.getEnabledModels()) {\n router.registerClient(model.id, mockClient as any);\n }\n\n const tools = [\n {\n type: 'function',\n function: {\n name: 'analyze_perfetto_slice',\n parameters: {\n type: 'object',\n properties: {\n table: { type: 'string' },\n },\n },\n },\n },\n ];\n const toolChoice = {\n type: 'function',\n function: { name: 'analyze_perfetto_slice' },\n };\n const reasoning = { effort: 'high' };\n\n const result = await router.callWithFallback('Test', 'general', {\n tools,\n tool_choice: toolChoice,\n reasoning,\n });\n\n expect(result.success).toBe(true);\n expect(complete).toHaveBeenCalled();\n const callOptions = (complete.mock.calls[0]?.[1] ?? {}) as Record<string, unknown>;\n expect(callOptions.tools).toEqual(tools);\n expect(callOptions.toolChoice).toEqual(toolChoice);\n expect(callOptions.reasoning).toEqual(reasoning);\n });\n\n it('should forward native tools/tool_choice/reasoning to DeepSeek request body', async () => {\n const deepseekModel = createMockModel({\n id: 'deepseek-native',\n provider: 'deepseek',\n model: 'deepseek-reasoner',\n });\n const deepseekRouter = new ModelRouter({\n models: [deepseekModel],\n defaultModel: 'deepseek-native',\n fallbackChain: ['deepseek-native'],\n });\n\n const previousApiKey = process.env.DEEPSEEK_API_KEY;\n const previousBaseUrl = process.env.DEEPSEEK_BASE_URL;\n process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';\n process.env.DEEPSEEK_BASE_URL = 'https://api.deepseek.com';\n\n const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({\n ok: true,\n status: 200,\n json: async () => ({ choices: [{ message: { content: 'ok' } }] }),\n text: async () => '',\n } as any);\n\n const tools = [\n {\n type: 'function',\n function: { name: 'inspect_trace', parameters: { type: 'object' } },\n },\n ];\n const reasoning = { effort: 'high' };\n\n try {\n const result = await deepseekRouter.callWithFallback('DeepSeek passthrough', 'general', {\n tools,\n toolChoice: 'required',\n reasoning,\n });\n\n expect(result.success).toBe(true);\n expect(fetchSpy).toHaveBeenCalledTimes(1);\n const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];\n const body = JSON.parse(String(init.body)) as Record<string, unknown>;\n expect(body.tools).toEqual(tools);\n expect(body.tool_choice).toBe('required');\n expect(body.reasoning).toEqual(reasoning);\n } finally {\n fetchSpy.mockRestore();\n if (previousApiKey === undefined) {\n delete process.env.DEEPSEEK_API_KEY;\n } else {\n process.env.DEEPSEEK_API_KEY = previousApiKey;\n }\n if (previousBaseUrl === undefined) {\n delete process.env.DEEPSEEK_BASE_URL;\n } else {\n process.env.DEEPSEEK_BASE_URL = previousBaseUrl;\n }\n }\n });\n\n });\n\n // ===========================================================================\n // AllModelsFailedError Tests\n // ===========================================================================\n\n describe('AllModelsFailedError', () => {\n it('should have correct name', () => {\n const error = new AllModelsFailedError('Test', ['model1', 'model2']);\n expect(error.name).toBe('AllModelsFailedError');\n });\n\n it('should store tried models', () => {\n const error = new AllModelsFailedError('Test', ['model1', 'model2', 'model3']);\n expect(error.triedModels).toEqual(['model1', 'model2', 'model3']);\n });\n\n it('should have correct message', () => {\n const error = new AllModelsFailedError('All failed for task: general', []);\n expect(error.message).toBe('All failed for task: general');\n });\n });\n});"}}}} |