feat: per-agent model routing — route different agents to different providers (#238)
* feat: add agentModels and agentRouting to SettingsSchema Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add agentRouting module for per-agent provider resolution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: thread providerOverride through OpenAI shim for per-agent routing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: getAnthropicClient accepts providerOverride for agent routing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: thread providerOverride through Options and queryModel calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: thread providerOverride through query loop and ToolUseContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: resolve agent routing in runAgent and inject providerOverride Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Agent Routing configuration guide to README Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add unit tests for resolveAgentProvider + plaintext api_key note - 15 tests covering priority chain (name > subagentType > default > null) - normalize() case-insensitive and hyphen/underscore equivalence - Edge cases: null settings, missing config sections, non-existent model - README note about api_key stored in plaintext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * security: address code review — SSRF, credential leak, key collision - base_url schema now uses z.string().url() for SSRF mitigation - Strip auth headers (Authorization, x-api-key, api-key) from defaultHeaders when providerOverride is active, preventing Anthropic credentials from leaking to third-party endpoints - Warn on duplicate normalized routing keys to prevent silent shadowing - providerOverride.apiKey is never logged (verified via grep) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: 冯俊辉 <fengjunhui@shiyanjia.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
125
src/services/api/agentRouting.test.ts
Normal file
125
src/services/api/agentRouting.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { resolveAgentProvider } from './agentRouting.js'
|
||||
import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
|
||||
const baseSettings = {
|
||||
agentModels: {
|
||||
'deepseek-chat': { base_url: 'https://api.deepseek.com/v1', api_key: 'sk-ds' },
|
||||
'gpt-4o': { base_url: 'https://api.openai.com/v1', api_key: 'sk-oai' },
|
||||
},
|
||||
agentRouting: {
|
||||
Explore: 'deepseek-chat',
|
||||
'general-purpose': 'gpt-4o',
|
||||
'frontend-dev': 'deepseek-chat',
|
||||
default: 'gpt-4o',
|
||||
},
|
||||
} as unknown as SettingsJson
|
||||
|
||||
describe('resolveAgentProvider', () => {
|
||||
// ── Priority chain ──────────────────────────────────────────
|
||||
|
||||
test('name takes priority over subagentType', () => {
|
||||
const result = resolveAgentProvider('frontend-dev', 'Explore', baseSettings)
|
||||
expect(result).toEqual({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
apiKey: 'sk-ds',
|
||||
})
|
||||
})
|
||||
|
||||
test('subagentType used when name has no match', () => {
|
||||
const result = resolveAgentProvider('unknown-name', 'Explore', baseSettings)
|
||||
expect(result).toEqual({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
apiKey: 'sk-ds',
|
||||
})
|
||||
})
|
||||
|
||||
test('falls back to "default" when neither name nor subagentType match', () => {
|
||||
const result = resolveAgentProvider('nobody', 'unknown-type', baseSettings)
|
||||
expect(result).toEqual({
|
||||
model: 'gpt-4o',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-oai',
|
||||
})
|
||||
})
|
||||
|
||||
test('returns null when no routing match and no default', () => {
|
||||
const settings = {
|
||||
agentModels: baseSettings.agentModels,
|
||||
agentRouting: { Explore: 'deepseek-chat' },
|
||||
} as unknown as SettingsJson
|
||||
const result = resolveAgentProvider('nobody', 'unknown-type', settings)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when name and subagentType are both undefined', () => {
|
||||
const settings = {
|
||||
agentModels: baseSettings.agentModels,
|
||||
agentRouting: { Explore: 'deepseek-chat' },
|
||||
} as unknown as SettingsJson
|
||||
const result = resolveAgentProvider(undefined, undefined, settings)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ── normalize() matching ────────────────────────────────────
|
||||
|
||||
test('matching is case-insensitive', () => {
|
||||
const result = resolveAgentProvider(undefined, 'explore', baseSettings)
|
||||
expect(result?.model).toBe('deepseek-chat')
|
||||
})
|
||||
|
||||
test('matching is case-insensitive (UPPER)', () => {
|
||||
const result = resolveAgentProvider(undefined, 'EXPLORE', baseSettings)
|
||||
expect(result?.model).toBe('deepseek-chat')
|
||||
})
|
||||
|
||||
test('hyphen and underscore are equivalent', () => {
|
||||
const result = resolveAgentProvider(undefined, 'general_purpose', baseSettings)
|
||||
expect(result?.model).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
test('underscore in config matches hyphen in input', () => {
|
||||
const settings = {
|
||||
agentModels: baseSettings.agentModels,
|
||||
agentRouting: { general_purpose: 'deepseek-chat' },
|
||||
} as unknown as SettingsJson
|
||||
const result = resolveAgentProvider(undefined, 'general-purpose', settings)
|
||||
expect(result?.model).toBe('deepseek-chat')
|
||||
})
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────
|
||||
|
||||
test('returns null when settings is null', () => {
|
||||
expect(resolveAgentProvider('Explore', 'Explore', null)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when agentRouting is missing', () => {
|
||||
const settings = { agentModels: baseSettings.agentModels } as unknown as SettingsJson
|
||||
expect(resolveAgentProvider(undefined, 'Explore', settings)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when agentModels is missing', () => {
|
||||
const settings = { agentRouting: baseSettings.agentRouting } as unknown as SettingsJson
|
||||
expect(resolveAgentProvider(undefined, 'Explore', settings)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when routing references non-existent model', () => {
|
||||
const settings = {
|
||||
agentModels: {},
|
||||
agentRouting: { Explore: 'non-existent-model' },
|
||||
} as unknown as SettingsJson
|
||||
expect(resolveAgentProvider(undefined, 'Explore', settings)).toBeNull()
|
||||
})
|
||||
|
||||
test('subagentType only (no name)', () => {
|
||||
const result = resolveAgentProvider(undefined, 'Explore', baseSettings)
|
||||
expect(result?.model).toBe('deepseek-chat')
|
||||
})
|
||||
|
||||
test('name only (no subagentType)', () => {
|
||||
const result = resolveAgentProvider('frontend-dev', undefined, baseSettings)
|
||||
expect(result?.model).toBe('deepseek-chat')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user