claude-code-best/src/utils/sideQuery.ts
y574444354 ddb776cd5f refactor: 优化 Windows TTY 处理和代码风格统一
- scripts/dev.ts: 添加 CLAUDE_CODE_FORCE_INTERACTIVE 环境变量处理,优化 Windows 嵌套 Bun 启动时的交互模式检测
- cli.tsx: 统一代码风格(分号、引号),添加 ref/unref/setRawMode polyfill 以支持 Windows 环境
- model.ts: 优化模型选择逻辑,仅对 Anthropic 相关 provider 使用 ANTHROPIC_MODEL 环境变量
- sideQuery.ts: 更新导入路径,使用 @ant/model-provider 包
- README.md: 添加分隔线
2026-04-20 17:55:09 +08:00

454 lines
15 KiB
TypeScript

import type Anthropic from '@anthropic-ai/sdk'
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js'
import OpenAI from 'openai'
import {
getLastApiCompletionTimestamp,
setLastApiCompletionTimestamp,
} from '../bootstrap/state.js'
import { STRUCTURED_OUTPUTS_BETA_HEADER } from '../constants/betas.js'
import type { QuerySource } from '../constants/querySource.js'
import {
getAttributionHeader,
getCLISyspromptPrefix,
} from '../constants/system.js'
import { logEvent } from '../services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js'
import { getAPIMetadata } from '../services/api/claude.js'
import { getAnthropicClient } from '../services/api/client.js'
import { getOpenAIClient } from '../services/api/openai/client.js'
import { resolveOpenAIModel } from '@ant/model-provider'
import { anthropicMessagesToOpenAI } from '@ant/model-provider'
import { createCoStrictFetch } from '../costrict/provider/fetch.js'
import { loadCoStrictCredentials } from '../costrict/provider/credentials.js'
import { getCoStrictBaseURL } from '../costrict/provider/auth.js'
import { resolveCoStrictModel } from '../costrict/provider/modelMapping.js'
import { getProxyFetchOptions } from './proxy.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 { asSystemPrompt } from './systemPromptType.js'
import type { UserMessage, AssistantMessage } from '../types/message.js'
type MessageParam = Anthropic.MessageParam
type TextBlockParam = Anthropic.TextBlockParam
type Tool = Anthropic.Tool
type ToolChoice = Anthropic.ToolChoice
type BetaMessage = Anthropic.Beta.Messages.BetaMessage
type BetaJSONOutputFormat = Anthropic.Beta.Messages.BetaJSONOutputFormat
type BetaThinkingConfigParam = Anthropic.Beta.Messages.BetaThinkingConfigParam
export type SideQueryOptions = {
/** Model to use for the query */
model: string
/**
* System prompt - string or array of text blocks (will be prefixed with CLI attribution).
*
* The attribution header is always placed in its own TextBlockParam block to ensure
* server-side parsing correctly extracts the cc_entrypoint value without including
* system prompt content.
*/
system?: string | TextBlockParam[]
/** Messages to send (supports cache_control on content blocks) */
messages: MessageParam[]
/** Optional tools (supports both standard Tool[] and BetaToolUnion[] for custom tool types) */
tools?: Tool[] | BetaToolUnion[]
/** Optional tool choice (use { type: 'tool', name: 'x' } for forced output) */
tool_choice?: ToolChoice
/** Optional JSON output format for structured responses */
output_format?: BetaJSONOutputFormat
/** Max tokens (default: 1024) */
max_tokens?: number
/** Max retries (default: 2) */
maxRetries?: number
/** Abort signal */
signal?: AbortSignal
/** Skip CLI system prompt prefix (keeps attribution header for OAuth). For internal classifiers that provide their own prompt. */
skipSystemPromptPrefix?: boolean
/** Temperature override */
temperature?: number
/** Thinking budget (enables thinking), or `false` to send `{ type: 'disabled' }`. */
thinking?: number | false
/** Stop sequences — generation stops when any of these strings is emitted */
stop_sequences?: string[]
/** Attributes this call in tengu_api_success for COGS joining against reporting.sampling_calls. */
querySource: QuerySource
}
/**
* Extract text from first user message for fingerprint computation.
*/
function extractFirstUserMessageText(messages: MessageParam[]): string {
const firstUserMessage = messages.find(m => m.role === 'user')
if (!firstUserMessage) return ''
const content = firstUserMessage.content
if (typeof content === 'string') return content
// Array of content blocks - find first text block
const textBlock = content.find(block => block.type === 'text')
return textBlock?.type === 'text' ? textBlock.text : ''
}
/**
* 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)
*
* @example
* // Permission explainer
* await sideQuery({ querySource: 'permission_explainer', model, system: SYSTEM_PROMPT, messages, tools, tool_choice })
*
* @example
* // Session search
* await sideQuery({ querySource: 'session_search', model, system: SEARCH_PROMPT, messages })
*
* @example
* // Model validation
* await sideQuery({ querySource: 'model_validation', model, max_tokens: 1, messages: [{ role: 'user', content: 'Hi' }] })
*/
export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
const {
model,
system,
messages,
tools,
tool_choice,
output_format,
max_tokens = 1024,
maxRetries = 2,
signal,
skipSystemPromptPrefix,
temperature,
thinking,
stop_sequences,
} = opts
// Route to appropriate provider
const provider = getAPIProvider()
if (provider === 'costrict') {
return sideQueryCoStrict(opts)
}
if (provider === 'openai') {
return sideQueryOpenAI(opts)
}
const client = await getAnthropicClient({
maxRetries,
model,
source: 'side_query',
})
const betas = [...getModelBetas(model)]
// Add structured-outputs beta if using output_format and provider supports it
if (
output_format &&
modelSupportsStructuredOutputs(model) &&
!betas.includes(STRUCTURED_OUTPUTS_BETA_HEADER)
) {
betas.push(STRUCTURED_OUTPUTS_BETA_HEADER)
}
// Extract first user message text for fingerprint
const messageText = extractFirstUserMessageText(messages)
// Compute fingerprint for OAuth attribution
const fingerprint = computeFingerprint(messageText, MACRO.VERSION)
const attributionHeader = getAttributionHeader(fingerprint)
// Build system as array to keep attribution header in its own block
// (prevents server-side parsing from including system content in cc_entrypoint)
const systemBlocks: TextBlockParam[] = [
attributionHeader ? { type: 'text', text: attributionHeader } : null,
// Skip CLI system prompt prefix for internal classifiers that provide their own prompt
...(skipSystemPromptPrefix
? []
: [
{
type: 'text' as const,
text: getCLISyspromptPrefix({
isNonInteractive: false,
hasAppendSystemPrompt: false,
}),
},
]),
...(Array.isArray(system)
? system
: system
? [{ type: 'text' as const, text: system }]
: []),
].filter((block): block is TextBlockParam => block !== null)
let thinkingConfig: BetaThinkingConfigParam | undefined
if (thinking === false) {
thinkingConfig = { type: 'disabled' }
} else if (thinking !== undefined) {
thinkingConfig = {
type: 'enabled',
budget_tokens: Math.min(thinking, max_tokens - 1),
}
}
const normalizedModel = normalizeModelStringForAPI(model)
const start = Date.now()
// biome-ignore lint/plugin: this IS the wrapper that handles OAuth attribution
const response = await client.beta.messages.create(
{
model: normalizedModel,
max_tokens,
system: systemBlocks,
messages,
...(tools && { tools }),
...(tool_choice && { tool_choice }),
...(output_format && { output_config: { format: output_format } }),
...(temperature !== undefined && { temperature }),
...(stop_sequences && { stop_sequences }),
...(thinkingConfig && { thinking: thinkingConfig }),
...(betas.length > 0 && { betas }),
metadata: getAPIMetadata(),
},
{ signal },
)
const requestId =
(response as { _request_id?: string | null })._request_id ?? undefined
const now = Date.now()
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:
normalizedModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
cachedInputTokens: response.usage.cache_read_input_tokens ?? 0,
uncachedInputTokens: response.usage.cache_creation_input_tokens ?? 0,
durationMsIncludingRetries: now - start,
timeSinceLastApiCallMs:
lastCompletion !== null ? now - lastCompletion : undefined,
})
setLastApiCompletionTimestamp(now)
return response
}
/**
* CoStrict provider implementation for sideQuery
*/
async function sideQueryCoStrict(opts: SideQueryOptions): Promise<BetaMessage> {
const {
model,
system,
messages,
max_tokens = 1024,
signal,
skipSystemPromptPrefix,
temperature,
output_format,
} = opts
// Build system prompt
const systemContent = skipSystemPromptPrefix
? typeof system === 'string'
? system
: ''
: `${getCLISyspromptPrefix({ isNonInteractive: false, hasAppendSystemPrompt: false })}
${typeof system === 'string' ? system : ''}`
// Resolve model and get base URL
const costrictModel = resolveCoStrictModel(model)
const creds = await loadCoStrictCredentials()
const baseUrl = getCoStrictBaseURL(creds?.base_url)
const chatBaseURL = `${baseUrl}/chat-rag/api/v1`
// Create OpenAI client with CoStrict custom fetch
const costrictFetch = createCoStrictFetch()
const client = new OpenAI({
apiKey: 'costrict-managed',
baseURL: chatBaseURL,
maxRetries: 0,
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
dangerouslyAllowBrowser: true,
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }) as any,
fetch: costrictFetch as any,
})
// Convert messages to OpenAI format
const openaiMessages = anthropicMessagesToOpenAI(
messages as unknown as (UserMessage | AssistantMessage)[],
asSystemPrompt([systemContent]),
{ enableThinking: false },
)
// Build request
const requestBody: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
model: costrictModel,
messages: openaiMessages,
max_tokens: max_tokens,
...(temperature !== undefined && { temperature }),
}
// Add response_format for JSON schema if specified
if (output_format?.type === 'json_schema') {
;(requestBody as any).response_format = {
type: 'json_schema',
json_schema: {
name: 'response',
schema: output_format.schema,
strict: true,
},
}
}
const start = Date.now()
const response = await client.chat.completions.create(requestBody, { signal })
// Convert OpenAI response to Anthropic BetaMessage format
const choice = response.choices[0]
const content = choice?.message?.content || ''
const betaMessage: BetaMessage = {
id: response.id,
type: 'message',
role: 'assistant',
model: costrictModel,
content: [{ type: 'text', text: content }],
stop_reason: choice?.finish_reason === 'stop' ? 'end_turn' : 'max_tokens',
usage: {
input_tokens: response.usage?.prompt_tokens || 0,
output_tokens: response.usage?.completion_tokens || 0,
},
} as BetaMessage
// Log analytics
const now = Date.now()
const lastCompletion = getLastApiCompletionTimestamp()
logEvent('tengu_api_success', {
requestId:
response.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
querySource:
opts.querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
model:
costrictModel 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: 0,
durationMsIncludingRetries: now - start,
timeSinceLastApiCallMs:
lastCompletion !== null ? now - lastCompletion : undefined,
})
setLastApiCompletionTimestamp(now)
return betaMessage
}
/**
* OpenAI provider implementation for sideQuery
*/
async function sideQueryOpenAI(opts: SideQueryOptions): Promise<BetaMessage> {
const {
model,
system,
messages,
max_tokens = 1024,
signal,
skipSystemPromptPrefix,
temperature,
output_format,
} = opts
// Build system prompt
const systemContent = skipSystemPromptPrefix
? typeof system === 'string'
? system
: ''
: `${getCLISyspromptPrefix({ isNonInteractive: false, hasAppendSystemPrompt: false })}
${typeof system === 'string' ? system : ''}`
// Resolve model
const openaiModel = resolveOpenAIModel(model)
// Get OpenAI client
const client = getOpenAIClient({ maxRetries: 0, source: 'side_query' })
// Convert messages to OpenAI format
const openaiMessages = anthropicMessagesToOpenAI(
messages as unknown as (UserMessage | AssistantMessage)[],
asSystemPrompt([systemContent]),
{ enableThinking: false },
)
// Build request
const requestBody: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
model: openaiModel,
messages: openaiMessages,
max_tokens: max_tokens,
...(temperature !== undefined && { temperature }),
}
// Add response_format for JSON schema if specified
if (output_format?.type === 'json_schema') {
;(requestBody as any).response_format = {
type: 'json_schema',
json_schema: {
name: 'response',
schema: output_format.schema,
strict: true,
},
}
}
const start = Date.now()
const response = await client.chat.completions.create(requestBody, { signal })
// Convert OpenAI response to Anthropic BetaMessage format
const choice = response.choices[0]
const content = choice?.message?.content || ''
const betaMessage: BetaMessage = {
id: response.id,
type: 'message',
role: 'assistant',
model: openaiModel,
content: [{ type: 'text', text: content }],
stop_reason: choice?.finish_reason === 'stop' ? 'end_turn' : 'max_tokens',
usage: {
input_tokens: response.usage?.prompt_tokens || 0,
output_tokens: response.usage?.completion_tokens || 0,
},
} as BetaMessage
// Log analytics
const now = Date.now()
const lastCompletion = getLastApiCompletionTimestamp()
logEvent('tengu_api_success', {
requestId:
response.id 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: 0,
durationMsIncludingRetries: now - start,
timeSinceLastApiCallMs:
lastCompletion !== null ? now - lastCompletion : undefined,
})
setLastApiCompletionTimestamp(now)
return betaMessage
}