从 /ai-gateway/api/v1/models 返回的模型信息中读取 maxTokensKey 和 maxTokens 字段,发送请求时动态注入对应参数名和参数值。 maxTokensKey 缺失时默认使用 max_tokens,maxTokens 无值时不传该参数。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
283 lines
9.9 KiB
TypeScript
283 lines
9.9 KiB
TypeScript
/**
|
||
* CoStrict 查询入口
|
||
* 复用 OpenAI 兼容路径,注入 CoStrict 自定义 fetch 和 baseURL
|
||
*/
|
||
|
||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||
import type { SDKAssistantMessageError } from '../../entrypoints/agentSdkTypes.js'
|
||
import type { SystemPrompt } from '../../utils/systemPromptType.js'
|
||
import type {
|
||
Message,
|
||
StreamEvent,
|
||
SystemAPIErrorMessage,
|
||
AssistantMessage,
|
||
} from '../../types/message.js'
|
||
import type { Tools } from '../../Tool.js'
|
||
import type { Options } from '../../services/api/claude.js'
|
||
import OpenAI from 'openai'
|
||
import { getProxyFetchOptions } from '../../utils/proxy.js'
|
||
import { getUserAgent } from '../../utils/http.js'
|
||
import { anthropicMessagesToOpenAI, anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI, adaptOpenAIStreamToAnthropic } from '@ant/model-provider'
|
||
import { normalizeMessagesForAPI } from '../../utils/messages.js'
|
||
import { toolToAPISchema } from '../../utils/api.js'
|
||
import { logForDebugging } from '../../utils/debug.js'
|
||
import { addToTotalSessionCost } from '../../cost-tracker.js'
|
||
import { calculateUSDCost } from '../../utils/modelCost.js'
|
||
import {
|
||
createAssistantAPIErrorMessage,
|
||
normalizeContentFromAPI,
|
||
} from '../../utils/messages.js'
|
||
import { randomUUID } from 'crypto'
|
||
import { createCoStrictFetch } from './fetch.js'
|
||
import { resolveCoStrictModel } from './modelMapping.js'
|
||
import { getCoStrictBaseURL } from './auth.js'
|
||
import { loadCoStrictCredentials } from './credentials.js'
|
||
import { isOpenAIThinkingEnabled } from '../../services/api/openai/requestBody.js'
|
||
import { fetchCoStrictModels } from './models.js'
|
||
|
||
/**
|
||
* CoStrict 查询路径
|
||
* 与 queryModelOpenAI 结构相同,使用 CoStrict 自定义 fetch 和 baseURL
|
||
*/
|
||
export async function* queryModelCoStrict(
|
||
messages: Message[],
|
||
systemPrompt: SystemPrompt,
|
||
tools: Tools,
|
||
signal: AbortSignal,
|
||
options: Options,
|
||
): AsyncGenerator<
|
||
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
|
||
void
|
||
> {
|
||
try {
|
||
// 1. 解析模型名
|
||
const costrictModel = resolveCoStrictModel(options.model)
|
||
|
||
// 2. 获取 CoStrict base URL
|
||
const creds = await loadCoStrictCredentials()
|
||
const baseUrl = getCoStrictBaseURL(creds?.base_url)
|
||
const chatBaseURL = `${baseUrl}/chat-rag/api/v1`
|
||
|
||
// 3. 从模型列表获取 maxTokens 相关参数
|
||
let maxTokensParamKey: string = 'max_tokens'
|
||
let maxTokensValue: number | undefined
|
||
if (creds?.access_token) {
|
||
try {
|
||
const modelList = await fetchCoStrictModels(baseUrl, creds.access_token)
|
||
const modelInfo = modelList.find(m => m.id === costrictModel)
|
||
if (modelInfo) {
|
||
maxTokensParamKey = modelInfo.maxTokensKey || 'max_tokens'
|
||
if (modelInfo.maxTokens != null) {
|
||
maxTokensValue = modelInfo.maxTokens
|
||
}
|
||
}
|
||
} catch {
|
||
// 获取模型列表失败,使用默认值
|
||
}
|
||
}
|
||
|
||
// 4. 规范化消息
|
||
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
|
||
|
||
// 5. 构建工具 schema
|
||
const toolSchemas = await Promise.all(
|
||
tools.map(tool =>
|
||
toolToAPISchema(tool, {
|
||
getToolPermissionContext: options.getToolPermissionContext,
|
||
tools,
|
||
agents: options.agents,
|
||
allowedAgentTypes: options.allowedAgentTypes,
|
||
model: options.model,
|
||
}),
|
||
),
|
||
)
|
||
const standardTools = toolSchemas.filter(
|
||
(t): t is BetaToolUnion & { type: string } => {
|
||
const anyT = t as unknown as Record<string, unknown>
|
||
return (
|
||
anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124'
|
||
)
|
||
},
|
||
)
|
||
|
||
// 6. 转换为 OpenAI 格式
|
||
// 根据模型名称自动检测是否启用thinking模式
|
||
const enableThinking = isOpenAIThinkingEnabled(costrictModel)
|
||
const openaiMessages = anthropicMessagesToOpenAI(
|
||
messagesForAPI,
|
||
systemPrompt,
|
||
{ enableThinking }
|
||
)
|
||
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
||
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
||
|
||
// 7. 创建专用的 CoStrict OpenAI 客户端(不缓存,每次使用新的 fetch)
|
||
const costrictFetch = createCoStrictFetch()
|
||
const client = new OpenAI({
|
||
apiKey: 'costrict-managed', // 实际 token 由 createCoStrictFetch 注入
|
||
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,
|
||
defaultHeaders: { 'User-Agent': getUserAgent() },
|
||
})
|
||
|
||
logForDebugging(
|
||
`[CoStrict] model=${costrictModel}, baseURL=${chatBaseURL}, messages=${openaiMessages.length}, tools=${openaiTools.length}`,
|
||
)
|
||
|
||
// 8. 调用 API(流式)
|
||
const requestBody: Record<string, unknown> = {
|
||
model: costrictModel,
|
||
messages: openaiMessages,
|
||
...(openaiTools.length > 0 && {
|
||
tools: openaiTools,
|
||
...(openaiToolChoice && {
|
||
tool_choice:
|
||
openaiToolChoice as OpenAI.Chat.Completions.ChatCompletionToolChoiceOption,
|
||
}),
|
||
}),
|
||
stream: true,
|
||
stream_options: { include_usage: true },
|
||
...(options.temperatureOverride !== undefined && {
|
||
temperature: options.temperatureOverride,
|
||
}),
|
||
...(maxTokensValue != null && {
|
||
[maxTokensParamKey]: maxTokensValue,
|
||
}),
|
||
}
|
||
const stream = await client.chat.completions.create(
|
||
requestBody as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming,
|
||
{ signal },
|
||
)
|
||
|
||
// 9. 转换流并 yield 事件
|
||
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, costrictModel)
|
||
|
||
const contentBlocks: Record<number, any> = {}
|
||
// 跟踪已 yield 的 assistant messages,用于 message_delta 时回写 usage
|
||
const yieldedMessages: AssistantMessage[] = []
|
||
let partialMessage: any
|
||
let stopReason: string | null = null
|
||
let usage = {
|
||
input_tokens: 0,
|
||
output_tokens: 0,
|
||
cache_creation_input_tokens: 0,
|
||
cache_read_input_tokens: 0,
|
||
}
|
||
let ttftMs = 0
|
||
const start = Date.now()
|
||
|
||
for await (const event of adaptedStream) {
|
||
switch (event.type) {
|
||
case 'message_start': {
|
||
partialMessage = (event as any).message
|
||
ttftMs = Date.now() - start
|
||
if ((event as any).message?.usage) {
|
||
usage = { ...usage, ...(event as any).message.usage }
|
||
}
|
||
break
|
||
}
|
||
case 'content_block_start': {
|
||
const idx = (event as any).index
|
||
const cb = (event as any).content_block
|
||
if (cb.type === 'tool_use') {
|
||
contentBlocks[idx] = { ...cb, input: '' }
|
||
} else if (cb.type === 'text') {
|
||
contentBlocks[idx] = { ...cb, text: '' }
|
||
} else if (cb.type === 'thinking') {
|
||
contentBlocks[idx] = { ...cb, thinking: '', signature: '' }
|
||
} else {
|
||
contentBlocks[idx] = { ...cb }
|
||
}
|
||
break
|
||
}
|
||
case 'content_block_delta': {
|
||
const idx = (event as any).index
|
||
const delta = (event as any).delta
|
||
const block = contentBlocks[idx]
|
||
if (!block) break
|
||
if (delta.type === 'text_delta') {
|
||
block.text = (block.text || '') + delta.text
|
||
} else if (delta.type === 'input_json_delta') {
|
||
block.input = (block.input || '') + delta.partial_json
|
||
} else if (delta.type === 'thinking_delta') {
|
||
block.thinking = (block.thinking || '') + delta.thinking
|
||
} else if (delta.type === 'signature_delta') {
|
||
block.signature = delta.signature
|
||
}
|
||
break
|
||
}
|
||
case 'content_block_stop': {
|
||
const idx = (event as any).index
|
||
const block = contentBlocks[idx]
|
||
if (!block || !partialMessage) break
|
||
const m: AssistantMessage = {
|
||
message: {
|
||
...partialMessage,
|
||
content: normalizeContentFromAPI([block], tools, options.agentId),
|
||
usage,
|
||
},
|
||
requestId: undefined,
|
||
type: 'assistant',
|
||
uuid: randomUUID(),
|
||
timestamp: new Date().toISOString(),
|
||
}
|
||
yieldedMessages.push(m)
|
||
yield m
|
||
break
|
||
}
|
||
case 'message_delta': {
|
||
const deltaUsage = (event as any).usage
|
||
if (deltaUsage) usage = { ...usage, ...deltaUsage }
|
||
// 回写 usage 到已 yield 的 assistant messages
|
||
// 与 Anthropic 原生路径 claude.ts:2298 保持一致
|
||
for (const msg of yieldedMessages) {
|
||
msg.message.usage = usage
|
||
}
|
||
// 记录 stop_reason,回写到最后的 message
|
||
if ((event as any).delta?.stop_reason != null) {
|
||
stopReason = (event as any).delta.stop_reason
|
||
const lastMsg = yieldedMessages[yieldedMessages.length - 1]
|
||
if (lastMsg) {
|
||
lastMsg.message.stop_reason = stopReason
|
||
}
|
||
}
|
||
break
|
||
}
|
||
case 'message_stop':
|
||
break
|
||
}
|
||
|
||
if (
|
||
event.type === 'message_stop' &&
|
||
usage.input_tokens + usage.output_tokens > 0
|
||
) {
|
||
const costUSD = calculateUSDCost(costrictModel, usage as any)
|
||
addToTotalSessionCost(costUSD, usage as any, options.model)
|
||
}
|
||
|
||
yield {
|
||
type: 'stream_event',
|
||
event,
|
||
...(event.type === 'message_start' ? { ttftMs } : undefined),
|
||
} as StreamEvent
|
||
}
|
||
} catch (error) {
|
||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||
logForDebugging(`[CoStrict] Error: ${errorMsg}`, { level: 'error' })
|
||
yield createAssistantAPIErrorMessage({
|
||
content: `CoStrict API Error: ${errorMsg}`,
|
||
apiError: 'api_error',
|
||
error:
|
||
error instanceof Error
|
||
? (error as unknown as SDKAssistantMessageError)
|
||
: undefined,
|
||
})
|
||
}
|
||
}
|