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:
JasonVon
2026-04-03 21:47:26 +08:00
committed by GitHub
parent 59ab2701f7
commit fb32e3f829
11 changed files with 319 additions and 9 deletions

View File

@@ -704,6 +704,7 @@ export type Options = {
// so the model can pace itself. `remaining` is computed by the caller
// (query.ts decrements across the agentic loop).
taskBudget?: { total: number; remaining?: number }
providerOverride?: { model: string; baseURL: string; apiKey: string }
}
export async function queryModelWithoutStreaming({
@@ -820,6 +821,7 @@ export async function* executeNonStreamingRequest(
model: string
fetchOverride?: Options['fetchOverride']
source: string
providerOverride?: Options['providerOverride']
},
retryOptions: {
model: string
@@ -847,6 +849,7 @@ export async function* executeNonStreamingRequest(
model: clientOptions.model,
fetchOverride: clientOptions.fetchOverride,
source: clientOptions.source,
providerOverride: clientOptions.providerOverride,
}),
async (anthropic, attempt, context) => {
const start = Date.now()
@@ -1782,6 +1785,7 @@ async function* queryModel(
model: options.model,
fetchOverride: options.fetchOverride,
source: options.querySource,
providerOverride: options.providerOverride,
}),
async (anthropic, attempt, context) => {
attemptNumber = attempt
@@ -2549,7 +2553,7 @@ async function* queryModel(
: 'other') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
const result = yield* executeNonStreamingRequest(
{ model: options.model, source: options.querySource },
{ model: options.model, source: options.querySource, providerOverride: options.providerOverride },
{
model: options.model,
fallbackModel: options.fallbackModel,