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:
@@ -683,10 +683,12 @@ class OpenAIShimStream {
|
||||
class OpenAIShimMessages {
|
||||
private defaultHeaders: Record<string, string>
|
||||
private reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
|
||||
private providerOverride?: { model: string; baseURL: string; apiKey: string }
|
||||
|
||||
constructor(defaultHeaders: Record<string, string>, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh') {
|
||||
constructor(defaultHeaders: Record<string, string>, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh', providerOverride?: { model: string; baseURL: string; apiKey: string }) {
|
||||
this.defaultHeaders = defaultHeaders
|
||||
this.reasoningEffort = reasoningEffort
|
||||
this.providerOverride = providerOverride
|
||||
}
|
||||
|
||||
create(
|
||||
@@ -698,7 +700,7 @@ class OpenAIShimMessages {
|
||||
let httpResponse: Response | undefined
|
||||
|
||||
const promise = (async () => {
|
||||
const request = resolveProviderRequest({ model: params.model, reasoningEffortOverride: self.reasoningEffort })
|
||||
const request = resolveProviderRequest({ model: self.providerOverride?.model ?? params.model, baseUrl: self.providerOverride?.baseURL, reasoningEffortOverride: self.reasoningEffort })
|
||||
const response = await self._doRequest(request, params, options)
|
||||
httpResponse = response
|
||||
|
||||
@@ -857,7 +859,7 @@ class OpenAIShimMessages {
|
||||
...(options?.headers ?? {}),
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY ?? ''
|
||||
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
|
||||
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
|
||||
// path segments like https://evil.com/cognitiveservices.azure.com/
|
||||
let isAzure = false
|
||||
@@ -1056,8 +1058,8 @@ class OpenAIShimBeta {
|
||||
messages: OpenAIShimMessages
|
||||
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
|
||||
|
||||
constructor(defaultHeaders: Record<string, string>, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh') {
|
||||
this.messages = new OpenAIShimMessages(defaultHeaders, reasoningEffort)
|
||||
constructor(defaultHeaders: Record<string, string>, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh', providerOverride?: { model: string; baseURL: string; apiKey: string }) {
|
||||
this.messages = new OpenAIShimMessages(defaultHeaders, reasoningEffort, providerOverride)
|
||||
this.reasoningEffort = reasoningEffort
|
||||
}
|
||||
}
|
||||
@@ -1067,6 +1069,7 @@ export function createOpenAIShimClient(options: {
|
||||
maxRetries?: number
|
||||
timeout?: number
|
||||
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
|
||||
providerOverride?: { model: string; baseURL: string; apiKey: string }
|
||||
}): unknown {
|
||||
hydrateGithubModelsTokenFromSecureStorage()
|
||||
|
||||
@@ -1089,7 +1092,7 @@ export function createOpenAIShimClient(options: {
|
||||
|
||||
const beta = new OpenAIShimBeta({
|
||||
...(options.defaultHeaders ?? {}),
|
||||
}, options.reasoningEffort)
|
||||
}, options.reasoningEffort, options.providerOverride)
|
||||
|
||||
return {
|
||||
beta,
|
||||
|
||||
Reference in New Issue
Block a user