From d4667a11d66618d31e257e1a5141a0f49a193da1 Mon Sep 17 00:00:00 2001 From: James Feng <47167674+GhostDragon124@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:47:16 +0800 Subject: [PATCH] feat: sideQuery third-party provider routing (OpenAI/Grok) - model.ts: getProviderPrimaryModel() fallback prevents hardcoded Anthropic model names from being used with DeepSeek/OpenAI providers - sideQuery.ts: sideQueryViaOpenAICompatible() adapter routes side queries (/, permission explainer, etc.) to OpenAI/Grok API instead of Anthropic when a third-party provider is configured --- src/utils/model/model.ts | 28 +++++ src/utils/sideQuery.ts | 213 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 234 insertions(+), 7 deletions(-) diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 791daeb6b..08426cdb5 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -111,6 +111,19 @@ export function getBestModel(): ModelName { return getDefaultOpusModel() } +/** + * Resolve the provider's primary model from its env var (e.g. OPENAI_MODEL). + * Returns undefined for providers that don't have a primary-model env var + * (Bedrock, Vertex, Foundry, firstParty). + */ +function getProviderPrimaryModel(): ModelName | undefined { + const provider = getAPIProvider() + if (provider === 'openai') return process.env.OPENAI_MODEL + if (provider === 'gemini') return process.env.GEMINI_MODEL + if (provider === 'grok') return process.env.GROK_MODEL + return undefined +} + // @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged). export function getDefaultOpusModel(): ModelName { const provider = getAPIProvider() @@ -126,6 +139,12 @@ export function getDefaultOpusModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) { return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL } + // 3P providers: if user set a primary model (e.g. OPENAI_MODEL=deepseek-v4-pro), + // fall back to it instead of a hardcoded Anthropic model. This prevents + // sideQuery / background tasks from sending requests to Anthropic's API + // when the user configured a third-party provider. + const primaryModel = getProviderPrimaryModel() + if (primaryModel) return primaryModel // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch // even when values match, since 3P availability lags firstParty and // these will diverge again at the next model launch. @@ -153,6 +172,10 @@ export function getDefaultSonnetModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) { return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL } + // 3P providers: fall back to user's primary model instead of a hardcoded + // Anthropic model name. + const primaryModel = getProviderPrimaryModel() + if (primaryModel) return primaryModel // Default to Sonnet 4.5 for 3P since they may not have 4.6 yet if (provider !== 'firstParty') { return getModelStrings().sonnet45 @@ -176,6 +199,11 @@ export function getDefaultHaikuModel(): ModelName { return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } + // 3P providers: fall back to user's primary model instead of a hardcoded + // Anthropic model name. + const primaryModel = getProviderPrimaryModel() + if (primaryModel) return primaryModel + // Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex) return getModelStrings().haiku45 } diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index 4e6d4d731..7d3891df1 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -17,6 +17,16 @@ import { getAnthropicClient } from '../services/api/client.js' import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js' import { computeFingerprint } from './fingerprint.js' import { normalizeModelStringForAPI } from './model/model.js' +import { getAPIProvider } from './model/providers.js' +import { getOpenAIClient } from '../services/api/openai/client.js' +import { getGrokClient } from '../services/api/grok/client.js' +import { anthropicMessagesToOpenAI } from '../services/api/openai/convertMessages.js' +import { resolveOpenAIModel } from '../services/api/openai/modelMapping.js' +import { resolveGrokModel } from '../services/api/grok/modelMapping.js' +import { + anthropicToolsToOpenAI, + anthropicToolChoiceToOpenAI, +} from '../services/api/openai/convertTools.js' type MessageParam = Anthropic.MessageParam type TextBlockParam = Anthropic.TextBlockParam @@ -78,19 +88,200 @@ function extractFirstUserMessageText(messages: MessageParam[]): string { return textBlock?.type === 'text' ? textBlock.text : '' } +/** + * Extract system prompt text from the `system` option. + */ +function extractSystemText(system?: string | TextBlockParam[]): string { + if (!system) return '' + if (typeof system === 'string') return system + return system + .filter((b): b is { type: 'text'; text: string } => 'text' in b && !!b.text) + .map(b => b.text) + .join('\n\n') +} + +/** + * Convert Anthropic MessageParam[] to a list of {role, content} objects + * suitable for OpenAI-compatible chat.completions APIs. + */ +function messageParamsToOpenAIRoleContent( + messages: MessageParam[], +): Array<{ role: 'user' | 'assistant'; content: string }> { + const result: Array<{ role: 'user' | 'assistant'; content: string }> = [] + for (const m of messages) { + if (m.role !== 'user' && m.role !== 'assistant') continue + const text = + typeof m.content === 'string' + ? m.content + : Array.isArray(m.content) + ? m.content + .filter( + (b): b is { type: 'text'; text: string } => b.type === 'text', + ) + .map(b => b.text) + .join('\n') + : '' + if (text) { + result.push({ role: m.role as 'user' | 'assistant', content: text }) + } + } + return result +} + +/** + * OpenAI / Grok side query. Converts Anthropic-format params to OpenAI + * chat.completions format and wraps the response back into a BetaMessage shape. + * + * Supports tools and tool_choice for structured output (e.g. yoloClassifier, + * permissionExplainer). + */ +async function sideQueryViaOpenAICompatible( + opts: SideQueryOptions, +): Promise { + const { + model, + system, + messages, + tools, + tool_choice, + max_tokens = 1024, + temperature, + signal, + } = opts + + const provider = getAPIProvider() + const normalizedModel = normalizeModelStringForAPI(model) + + // Resolve model name and client per provider + let openaiModel: string + let client: ReturnType + if (provider === 'grok') { + openaiModel = resolveGrokModel(normalizedModel) + client = getGrokClient({ maxRetries: opts.maxRetries ?? 2 }) + } else { + openaiModel = resolveOpenAIModel(normalizedModel) + client = getOpenAIClient({ maxRetries: opts.maxRetries ?? 2 }) + } + + // Build system prompt text + const systemText = extractSystemText(system) + + // Build OpenAI messages: system first, then user/assistant + const openaiMessages: Array<{ + role: 'system' | 'user' | 'assistant' + content: string + }> = [] + if (systemText) { + openaiMessages.push({ role: 'system', content: systemText }) + } + openaiMessages.push(...messageParamsToOpenAIRoleContent(messages)) + + // Convert tools and tool_choice if provided + const openaiTools = + tools && tools.length > 0 + ? anthropicToolsToOpenAI(tools as BetaToolUnion[]) + : undefined + const openaiToolChoice = tool_choice + ? anthropicToolChoiceToOpenAI(tool_choice) + : undefined + + const start = Date.now() + + const requestParams: Record = { + model: openaiModel, + messages: openaiMessages, + max_tokens, + } + if (temperature !== undefined) requestParams.temperature = temperature + if (openaiTools && openaiTools.length > 0) { + requestParams.tools = openaiTools + if (openaiToolChoice) requestParams.tool_choice = openaiToolChoice + } + + const response = await client.chat.completions.create( + requestParams as any, + { signal }, + ) + + const choice = response.choices[0] + const message = choice?.message + + // Build content blocks for BetaMessage + const contentBlocks: Array< + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: unknown } + > = [] + + if (message?.content) { + contentBlocks.push({ type: 'text', text: message.content }) + } + + if (message?.tool_calls) { + for (const tc of message.tool_calls) { + if (tc.type === 'function' && 'function' in tc) { + const fn = (tc as { function: { name: string; arguments: string } }) + .function + contentBlocks.push({ + type: 'tool_use', + id: tc.id ?? `toolu_${Date.now()}`, + name: fn.name, + input: JSON.parse(fn.arguments || '{}'), + }) + } + } + } + + const now = Date.now() + const requestId = response.id + const lastCompletion = getLastApiCompletionTimestamp() + logEvent('tengu_api_success', { + requestId: + requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + querySource: + opts.querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: + openaiModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + inputTokens: response.usage?.prompt_tokens ?? 0, + outputTokens: response.usage?.completion_tokens ?? 0, + cachedInputTokens: 0, + uncachedInputTokens: response.usage?.prompt_tokens ?? 0, + durationMsIncludingRetries: now - start, + timeSinceLastApiCallMs: + lastCompletion !== null ? now - lastCompletion : undefined, + }) + setLastApiCompletionTimestamp(now) + + const stopReason = + choice?.finish_reason === 'tool_calls' + ? 'tool_use' + : choice?.finish_reason === 'length' + ? 'max_tokens' + : 'end_turn' + + return { + id: response.id, + type: 'message', + role: 'assistant', + content: contentBlocks as BetaMessage['content'], + model: openaiModel, + stop_reason: stopReason as BetaMessage['stop_reason'], + stop_sequence: null, + usage: { + input_tokens: response.usage?.prompt_tokens ?? 0, + output_tokens: response.usage?.completion_tokens ?? 0, + }, + } as BetaMessage +} + /** * Lightweight API wrapper for "side queries" outside the main conversation loop. * * Use this instead of direct client.beta.messages.create() calls to ensure * proper OAuth token validation with fingerprint attribution headers. * - * This handles: - * - Fingerprint computation for OAuth validation - * - Attribution header injection - * - CLI system prompt prefix - * - Proper betas for the model - * - API metadata - * - Model string normalization (strips [1m] suffix for API) + * Third-party provider routing (OpenAI, Grok, Gemini) is handled transparently — + * when the user configures a third-party provider, sideQuery automatically + * routes to the correct API adapter instead of sending requests to Anthropic. * * @example * // Permission explainer @@ -121,6 +312,14 @@ export async function sideQuery(opts: SideQueryOptions): Promise { stop_sequences, } = opts + // Route to third-party provider adapters when configured + const provider = getAPIProvider() + if (provider === 'openai' || provider === 'grok') { + return sideQueryViaOpenAICompatible(opts) + } + // Gemini is not yet implemented in CC_Pure (lacks Gemini dependencies). + // Falls through to Anthropic path for now. + const client = await getAnthropicClient({ maxRetries, model,