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
This commit is contained in:
parent
a45eee67fc
commit
d4667a11d6
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<BetaMessage> {
|
||||
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<typeof getOpenAIClient>
|
||||
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<string, unknown> = {
|
||||
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<BetaMessage> {
|
|||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user