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()
|
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).
|
// @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged).
|
||||||
export function getDefaultOpusModel(): ModelName {
|
export function getDefaultOpusModel(): ModelName {
|
||||||
const provider = getAPIProvider()
|
const provider = getAPIProvider()
|
||||||
|
|
@ -126,6 +139,12 @@ export function getDefaultOpusModel(): ModelName {
|
||||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||||
return 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
|
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
|
||||||
// even when values match, since 3P availability lags firstParty and
|
// even when values match, since 3P availability lags firstParty and
|
||||||
// these will diverge again at the next model launch.
|
// these will diverge again at the next model launch.
|
||||||
|
|
@ -153,6 +172,10 @@ export function getDefaultSonnetModel(): ModelName {
|
||||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||||
return 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
|
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
||||||
if (provider !== 'firstParty') {
|
if (provider !== 'firstParty') {
|
||||||
return getModelStrings().sonnet45
|
return getModelStrings().sonnet45
|
||||||
|
|
@ -176,6 +199,11 @@ export function getDefaultHaikuModel(): ModelName {
|
||||||
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
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)
|
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
||||||
return getModelStrings().haiku45
|
return getModelStrings().haiku45
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ import { getAnthropicClient } from '../services/api/client.js'
|
||||||
import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
|
import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
|
||||||
import { computeFingerprint } from './fingerprint.js'
|
import { computeFingerprint } from './fingerprint.js'
|
||||||
import { normalizeModelStringForAPI } from './model/model.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 MessageParam = Anthropic.MessageParam
|
||||||
type TextBlockParam = Anthropic.TextBlockParam
|
type TextBlockParam = Anthropic.TextBlockParam
|
||||||
|
|
@ -78,19 +88,200 @@ function extractFirstUserMessageText(messages: MessageParam[]): string {
|
||||||
return textBlock?.type === 'text' ? textBlock.text : ''
|
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.
|
* Lightweight API wrapper for "side queries" outside the main conversation loop.
|
||||||
*
|
*
|
||||||
* Use this instead of direct client.beta.messages.create() calls to ensure
|
* Use this instead of direct client.beta.messages.create() calls to ensure
|
||||||
* proper OAuth token validation with fingerprint attribution headers.
|
* proper OAuth token validation with fingerprint attribution headers.
|
||||||
*
|
*
|
||||||
* This handles:
|
* Third-party provider routing (OpenAI, Grok, Gemini) is handled transparently —
|
||||||
* - Fingerprint computation for OAuth validation
|
* when the user configures a third-party provider, sideQuery automatically
|
||||||
* - Attribution header injection
|
* routes to the correct API adapter instead of sending requests to Anthropic.
|
||||||
* - CLI system prompt prefix
|
|
||||||
* - Proper betas for the model
|
|
||||||
* - API metadata
|
|
||||||
* - Model string normalization (strips [1m] suffix for API)
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Permission explainer
|
* // Permission explainer
|
||||||
|
|
@ -121,6 +312,14 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
||||||
stop_sequences,
|
stop_sequences,
|
||||||
} = opts
|
} = 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({
|
const client = await getAnthropicClient({
|
||||||
maxRetries,
|
maxRetries,
|
||||||
model,
|
model,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user