fix: 修复 sideQuery/模型选择在 CoStrict 和 OpenAI provider 下的问题
- sideQuery:新增 openai/costrict provider 分支,分别走各自的 base_url 而非 Anthropic API;提取 sideQueryOpenAICompat 共用转换逻辑; CoStrict 分支统一使用主循环模型(getMainLoopModel) - ConsoleOAuthFlow:CoStrict 登录选模型后同步写入 settings.json - /model 命令:CoStrict provider 下模型切换持久化到 settings.json; 支持 costrict-login 触发登录并重载模型列表;未登录时提示先 /login - modelOptions:CoStrict provider 展示服务器动态模型列表; 仅对 Anthropic 兼容 provider 注入自定义模型到选项列表 - configs:补充 CoStrict 的 claude-opus-4-7 模型配置 - cli:默认禁用非必要 Anthropic 流量(CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5864d1a9a7
commit
2f7fc7959d
|
|
@ -29,6 +29,21 @@ import {
|
||||||
} from '../../utils/model/model.js'
|
} from '../../utils/model/model.js'
|
||||||
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
|
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
|
||||||
import { validateModel } from '../../utils/model/validateModel.js'
|
import { validateModel } from '../../utils/model/validateModel.js'
|
||||||
|
import { isNotLoggedIn } from '../../utils/logoV2Utils.js'
|
||||||
|
import {
|
||||||
|
buildCoStrictLoginURL,
|
||||||
|
generateState,
|
||||||
|
getCoStrictBaseURL,
|
||||||
|
pollLoginToken,
|
||||||
|
} from '../../costrict/provider/auth.js'
|
||||||
|
import {
|
||||||
|
generateMachineId,
|
||||||
|
saveCoStrictCredentials,
|
||||||
|
} from '../../costrict/provider/credentials.js'
|
||||||
|
import { extractExpiryFromJWT } from '../../costrict/provider/token.js'
|
||||||
|
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||||
|
import { openBrowser } from '../../utils/browser.js'
|
||||||
|
import { getAPIProvider } from '../../utils/model/providers.js'
|
||||||
|
|
||||||
function ModelPickerWrapper({
|
function ModelPickerWrapper({
|
||||||
onDone,
|
onDone,
|
||||||
|
|
@ -42,6 +57,7 @@ function ModelPickerWrapper({
|
||||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
||||||
const isFastMode = useAppState(s => s.fastMode)
|
const isFastMode = useAppState(s => s.fastMode)
|
||||||
const setAppState = useSetAppState()
|
const setAppState = useSetAppState()
|
||||||
|
const [pickerKey, setPickerKey] = React.useState(0)
|
||||||
|
|
||||||
function handleCancel(): void {
|
function handleCancel(): void {
|
||||||
logEvent('tengu_model_command_menu', {
|
logEvent('tengu_model_command_menu', {
|
||||||
|
|
@ -58,6 +74,57 @@ function ModelPickerWrapper({
|
||||||
model: string | null,
|
model: string | null,
|
||||||
effort: EffortLevel | undefined,
|
effort: EffortLevel | undefined,
|
||||||
): void {
|
): void {
|
||||||
|
// CoStrict 登录流程
|
||||||
|
if (model === 'costrict-login') {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getCoStrictBaseURL()
|
||||||
|
const state = generateState()
|
||||||
|
const machineId = generateMachineId()
|
||||||
|
const loginUrl = buildCoStrictLoginURL(baseUrl, state, machineId)
|
||||||
|
|
||||||
|
// 打开浏览器(不调用 onDone,保持 picker 显示)
|
||||||
|
await openBrowser(loginUrl)
|
||||||
|
|
||||||
|
// 轮询等待登录完成
|
||||||
|
const tokens = await pollLoginToken(baseUrl, state, machineId)
|
||||||
|
|
||||||
|
// 保存凭证
|
||||||
|
const expiryDate = extractExpiryFromJWT(tokens.access_token)
|
||||||
|
await saveCoStrictCredentials({
|
||||||
|
id: 'csc',
|
||||||
|
name: 'CSC Auth',
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
state,
|
||||||
|
machine_id: machineId,
|
||||||
|
base_url: baseUrl,
|
||||||
|
expiry_date: expiryDate,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
expired_at: expiryDate ? new Date(expiryDate).toISOString() : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置 modelType 为 costrict
|
||||||
|
updateSettingsForSource('userSettings', { modelType: 'costrict' as any } as any)
|
||||||
|
process.env.CLAUDE_CODE_USE_COSTRICT = '1'
|
||||||
|
|
||||||
|
// 预取并缓存模型列表
|
||||||
|
try {
|
||||||
|
const { fetchCoStrictModels } = await import('../../costrict/provider/models.js')
|
||||||
|
await fetchCoStrictModels(baseUrl, tokens.access_token)
|
||||||
|
} catch {
|
||||||
|
// 预取失败,picker 重载后会显示默认模型列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功后重载 ModelPicker,显示 CoStrict 模型选择界面
|
||||||
|
setPickerKey(k => k + 1)
|
||||||
|
} catch (err: any) {
|
||||||
|
onDone(`Login failed: ${err.message || String(err)}`, { display: 'system' })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logEvent('tengu_model_command_menu', {
|
logEvent('tengu_model_command_menu', {
|
||||||
action:
|
action:
|
||||||
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
|
|
@ -72,6 +139,11 @@ function ModelPickerWrapper({
|
||||||
mainLoopModelForSession: null,
|
mainLoopModelForSession: null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// CoStrict provider 下持久化模型选择到 settings.json
|
||||||
|
if (getAPIProvider() === 'costrict') {
|
||||||
|
updateSettingsForSource('userSettings', { model: model ?? undefined } as any)
|
||||||
|
}
|
||||||
|
|
||||||
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`
|
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`
|
||||||
if (effort !== undefined) {
|
if (effort !== undefined) {
|
||||||
message += ` with ${chalk.bold(effort)} effort`
|
message += ` with ${chalk.bold(effort)} effort`
|
||||||
|
|
@ -118,6 +190,7 @@ function ModelPickerWrapper({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
|
key={pickerKey}
|
||||||
initial={mainLoopModel}
|
initial={mainLoopModel}
|
||||||
sessionModel={mainLoopModelForSession}
|
sessionModel={mainLoopModelForSession}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
|
@ -326,6 +399,14 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||||
return <SetModelAndClose args={args} onDone={onDone} />
|
return <SetModelAndClose args={args} onDone={onDone} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is logged in before showing model picker
|
||||||
|
if (isNotLoggedIn()) {
|
||||||
|
onDone('You need to login first. Use /login to authenticate.', {
|
||||||
|
display: 'system',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return <ModelPickerWrapper onDone={onDone} />
|
return <ModelPickerWrapper onDone={onDone} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1368,6 +1368,7 @@ function OAuthStatusMessage({
|
||||||
options={costrictOptions}
|
options={costrictOptions}
|
||||||
onChange={(value: string) => {
|
onChange={(value: string) => {
|
||||||
process.env.COSTRICT_MODEL = value;
|
process.env.COSTRICT_MODEL = value;
|
||||||
|
updateSettingsForSource('userSettings', { model: value } as any)
|
||||||
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
|
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
|
||||||
setOAuthStatus({ state: 'success' });
|
setOAuthStatus({ state: 'success' });
|
||||||
void onDone();
|
void onDone();
|
||||||
|
|
@ -1375,6 +1376,7 @@ function OAuthStatusMessage({
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
const selected = sortedModels[0]?.id ?? '';
|
const selected = sortedModels[0]?.id ?? '';
|
||||||
process.env.COSTRICT_MODEL = selected;
|
process.env.COSTRICT_MODEL = selected;
|
||||||
|
updateSettingsForSource('userSettings', { model: selected } as any)
|
||||||
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
|
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
|
||||||
setOAuthStatus({ state: 'success' });
|
setOAuthStatus({ state: 'success' });
|
||||||
void onDone();
|
void onDone();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ if (typeof globalThis.MACRO === 'undefined') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default to disabling nonessential traffic to api.anthropic.com
|
||||||
|
// (telemetry, GrowthBook, metrics, MCP registry, etc.)
|
||||||
|
// Users can opt back in by setting CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=0
|
||||||
|
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||||
|
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC ??= '1'
|
||||||
|
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE)) {
|
if (isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE)) {
|
||||||
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
||||||
if (!stream.isTTY) {
|
if (!stream.isTTY) {
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ export const CLAUDE_OPUS_4_7_CONFIG = {
|
||||||
foundry: 'claude-opus-4-7',
|
foundry: 'claude-opus-4-7',
|
||||||
openai: 'claude-opus-4-7',
|
openai: 'claude-opus-4-7',
|
||||||
gemini: 'claude-opus-4-7',
|
gemini: 'claude-opus-4-7',
|
||||||
|
costrict: 'claude-opus-4-7',
|
||||||
grok: 'claude-opus-4-7',
|
grok: 'claude-opus-4-7',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||||
import { getInitialMainLoopModel } from '../../bootstrap/state.js'
|
import { getInitialMainLoopModel } from '../../bootstrap/state.js'
|
||||||
|
import { getCachedCoStrictModels } from '../../costrict/provider/models.js'
|
||||||
import {
|
import {
|
||||||
isClaudeAISubscriber,
|
isClaudeAISubscriber,
|
||||||
isMaxSubscriber,
|
isMaxSubscriber,
|
||||||
|
|
@ -402,6 +403,23 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||||
return standardOptions
|
return standardOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoStrict provider: 用从服务器拉回的模型列表并按 id 排序,缓存为空时返回空列表
|
||||||
|
if (getAPIProvider() === 'costrict') {
|
||||||
|
const costrictModels = getCachedCoStrictModels()
|
||||||
|
const sorted = [...costrictModels].sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
return sorted.map(m => {
|
||||||
|
const description =
|
||||||
|
m.id === 'Auto'
|
||||||
|
? `${Math.round((m.creditDiscount ?? 0) * 100)}% discount`
|
||||||
|
: `${m.creditConsumption ?? '?'}x credit`
|
||||||
|
return {
|
||||||
|
value: m.id,
|
||||||
|
label: m.id,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// PAYG 1P API: Default (Sonnet) + Opus 4.7 1M + Opus 4.6 1M + Sonnet 1M + Haiku
|
// PAYG 1P API: Default (Sonnet) + Opus 4.7 1M + Opus 4.6 1M + Sonnet 1M + Haiku
|
||||||
if (getAPIProvider() === 'firstParty') {
|
if (getAPIProvider() === 'firstParty') {
|
||||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||||
|
|
@ -562,43 +580,56 @@ export function getModelOptions(fastMode = false): ModelOption[] {
|
||||||
|
|
||||||
// Add custom model from either the current model value or the initial one
|
// Add custom model from either the current model value or the initial one
|
||||||
// if it is not already in the options.
|
// if it is not already in the options.
|
||||||
let customModel: ModelSetting = null
|
// Only applies to Anthropic-compatible providers (firstParty/bedrock/vertex/foundry).
|
||||||
const currentMainLoopModel = getUserSpecifiedModelSetting()
|
// For CoStrict/OpenAI/Gemini/Grok, the model list comes from additionalModelOptionsCache
|
||||||
const initialMainLoopModel = getInitialMainLoopModel()
|
// or getModelOptionsBase(), so we must not inject ANTHROPIC_MODEL / settings.model here.
|
||||||
if (currentMainLoopModel !== undefined && currentMainLoopModel !== null) {
|
const currentProvider = getAPIProvider()
|
||||||
customModel = currentMainLoopModel
|
const isAnthropicCompatible =
|
||||||
} else if (initialMainLoopModel !== null) {
|
currentProvider === 'firstParty' ||
|
||||||
customModel = initialMainLoopModel
|
currentProvider === 'bedrock' ||
|
||||||
}
|
currentProvider === 'vertex' ||
|
||||||
if (customModel === null || options.some(opt => opt.value === customModel)) {
|
currentProvider === 'foundry'
|
||||||
return filterModelOptionsByAllowlist(options)
|
|
||||||
} else if (customModel === 'opusplan') {
|
if (isAnthropicCompatible) {
|
||||||
return filterModelOptionsByAllowlist([...options, getOpusPlanOption()])
|
let customModel: ModelSetting = null
|
||||||
} else if (customModel === 'opus' && getAPIProvider() === 'firstParty') {
|
const currentMainLoopModel = getUserSpecifiedModelSetting()
|
||||||
return filterModelOptionsByAllowlist([
|
const initialMainLoopModel = getInitialMainLoopModel()
|
||||||
...options,
|
if (currentMainLoopModel !== undefined && currentMainLoopModel !== null) {
|
||||||
getMaxOpusOption(fastMode),
|
customModel = currentMainLoopModel
|
||||||
])
|
} else if (initialMainLoopModel !== null) {
|
||||||
} else if (customModel === 'opus[1m]' && getAPIProvider() === 'firstParty') {
|
customModel = initialMainLoopModel
|
||||||
return filterModelOptionsByAllowlist([
|
}
|
||||||
...options,
|
if (customModel !== null && !options.some(opt => opt.value === customModel)) {
|
||||||
getMergedOpus1MOption(fastMode),
|
if (customModel === 'opusplan') {
|
||||||
])
|
return filterModelOptionsByAllowlist([...options, getOpusPlanOption()])
|
||||||
} else {
|
} else if (customModel === 'opus') {
|
||||||
// Try to show a human-readable label for known Anthropic models, with an
|
return filterModelOptionsByAllowlist([
|
||||||
// upgrade hint if the alias now resolves to a newer version.
|
...options,
|
||||||
const knownOption = getKnownModelOption(customModel)
|
getMaxOpusOption(fastMode),
|
||||||
if (knownOption) {
|
])
|
||||||
options.push(knownOption)
|
} else if (customModel === 'opus[1m]') {
|
||||||
} else {
|
return filterModelOptionsByAllowlist([
|
||||||
options.push({
|
...options,
|
||||||
value: customModel,
|
getMergedOpus1MOption(fastMode),
|
||||||
label: customModel,
|
])
|
||||||
description: 'Custom model',
|
} else {
|
||||||
})
|
// Try to show a human-readable label for known Anthropic models, with an
|
||||||
|
// upgrade hint if the alias now resolves to a newer version.
|
||||||
|
const knownOption = getKnownModelOption(customModel)
|
||||||
|
if (knownOption) {
|
||||||
|
options.push(knownOption)
|
||||||
|
} else {
|
||||||
|
options.push({
|
||||||
|
value: customModel,
|
||||||
|
label: customModel,
|
||||||
|
description: 'Custom model',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return filterModelOptionsByAllowlist(options)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return filterModelOptionsByAllowlist(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,16 @@ import { errorMessage } from './errors.js'
|
||||||
import { computeFingerprint } from './fingerprint.js'
|
import { computeFingerprint } from './fingerprint.js'
|
||||||
import { getAPIProvider } from './model/providers.js'
|
import { getAPIProvider } from './model/providers.js'
|
||||||
import { normalizeModelStringForAPI } from './model/model.js'
|
import { normalizeModelStringForAPI } from './model/model.js'
|
||||||
|
import { sideQueryOpenAICompat } from './sideQueryOpenAICompat.js'
|
||||||
|
import { getOpenAIClient } from '../services/api/openai/client.js'
|
||||||
|
import { resolveOpenAIModel } from '@ant/model-provider'
|
||||||
|
import { createCoStrictFetch } from '../costrict/provider/fetch.js'
|
||||||
|
import { resolveCoStrictModel } from '../costrict/provider/modelMapping.js'
|
||||||
|
import { getCoStrictBaseURL } from '../costrict/provider/auth.js'
|
||||||
|
import { loadCoStrictCredentials } from '../costrict/provider/credentials.js'
|
||||||
|
import { getProxyFetchOptions } from './proxy.js'
|
||||||
|
import { getMainLoopModel } from './model/model.js'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
|
||||||
type MessageParam = Anthropic.MessageParam
|
type MessageParam = Anthropic.MessageParam
|
||||||
type TextBlockParam = Anthropic.TextBlockParam
|
type TextBlockParam = Anthropic.TextBlockParam
|
||||||
|
|
@ -117,6 +127,28 @@ function extractFirstUserMessageText(messages: MessageParam[]): string {
|
||||||
* await sideQuery({ querySource: 'model_validation', model, max_tokens: 1, messages: [{ role: 'user', content: 'Hi' }] })
|
* await sideQuery({ querySource: 'model_validation', model, max_tokens: 1, messages: [{ role: 'user', content: 'Hi' }] })
|
||||||
*/
|
*/
|
||||||
export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
||||||
|
const provider = getAPIProvider()
|
||||||
|
|
||||||
|
if (provider === 'openai') {
|
||||||
|
const client = getOpenAIClient({ maxRetries: opts.maxRetries ?? 2 })
|
||||||
|
return sideQueryOpenAICompat(opts, client, resolveOpenAIModel(opts.model), 'OpenAI')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'costrict') {
|
||||||
|
const creds = await loadCoStrictCredentials()
|
||||||
|
const baseUrl = getCoStrictBaseURL(creds?.base_url)
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: 'costrict-managed',
|
||||||
|
baseURL: `${baseUrl}/chat-rag/api/v1`,
|
||||||
|
maxRetries: 0,
|
||||||
|
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
|
||||||
|
dangerouslyAllowBrowser: true,
|
||||||
|
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }) as Record<string, unknown>,
|
||||||
|
fetch: createCoStrictFetch() as unknown as typeof fetch,
|
||||||
|
})
|
||||||
|
return sideQueryOpenAICompat(opts, client, resolveCoStrictModel(getMainLoopModel()), 'CoStrict')
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
model,
|
model,
|
||||||
system,
|
system,
|
||||||
|
|
@ -189,7 +221,6 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedModel = normalizeModelStringForAPI(model)
|
const normalizedModel = normalizeModelStringForAPI(model)
|
||||||
const provider = getAPIProvider()
|
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const traceName = `side-query:${opts.querySource}`
|
const traceName = `side-query:${opts.querySource}`
|
||||||
|
|
||||||
|
|
|
||||||
132
src/utils/sideQueryOpenAICompat.ts
Normal file
132
src/utils/sideQueryOpenAICompat.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
/**
|
||||||
|
* OpenAI-compatible sideQuery for providers that use the Chat Completions API
|
||||||
|
* (openai, grok, costrict). Callers pass a pre-configured OpenAI client and a
|
||||||
|
* resolved model name; the rest of the conversion is shared.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type OpenAI from 'openai'
|
||||||
|
import type { BetaMessage } from '@anthropic-ai/sdk/resources/beta/messages.js'
|
||||||
|
import type { SideQueryOptions } from './sideQuery.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 {
|
||||||
|
getLastApiCompletionTimestamp,
|
||||||
|
setLastApiCompletionTimestamp,
|
||||||
|
} from '../bootstrap/state.js'
|
||||||
|
import { logForDebugging } from './debug.js'
|
||||||
|
|
||||||
|
function buildMessages(
|
||||||
|
system: SideQueryOptions['system'],
|
||||||
|
messages: SideQueryOptions['messages'],
|
||||||
|
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
||||||
|
const result: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = []
|
||||||
|
|
||||||
|
if (system) {
|
||||||
|
const systemText = Array.isArray(system)
|
||||||
|
? system.map(b => b.text).join('\n\n')
|
||||||
|
: system
|
||||||
|
if (systemText.trim()) {
|
||||||
|
result.push({ role: 'system', content: systemText })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (typeof msg.content === 'string') {
|
||||||
|
result.push({ role: msg.role as 'user' | 'assistant', content: msg.content })
|
||||||
|
} else {
|
||||||
|
const text = msg.content
|
||||||
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
||||||
|
.map(b => b.text)
|
||||||
|
.join('\n')
|
||||||
|
result.push({ role: msg.role as 'user' | 'assistant', content: text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOP_REASON_MAP: Record<string, BetaMessage['stop_reason']> = {
|
||||||
|
stop: 'end_turn',
|
||||||
|
length: 'max_tokens',
|
||||||
|
tool_calls: 'tool_use',
|
||||||
|
content_filter: 'end_turn',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sideQueryOpenAICompat(
|
||||||
|
opts: SideQueryOptions,
|
||||||
|
client: OpenAI,
|
||||||
|
resolvedModel: string,
|
||||||
|
providerTag: string,
|
||||||
|
): Promise<BetaMessage> {
|
||||||
|
const { system, messages, max_tokens = 1024, signal, temperature, stop_sequences, querySource } = opts
|
||||||
|
|
||||||
|
logForDebugging(`[${providerTag} sideQuery] querySource=${querySource}, model=${resolvedModel}`)
|
||||||
|
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create(
|
||||||
|
{
|
||||||
|
model: resolvedModel,
|
||||||
|
messages: buildMessages(system, messages),
|
||||||
|
max_tokens,
|
||||||
|
stream: false,
|
||||||
|
...(temperature !== undefined && { temperature }),
|
||||||
|
...(stop_sequences && { stop: stop_sequences }),
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const lastCompletion = getLastApiCompletionTimestamp()
|
||||||
|
const inputTokens = response.usage?.prompt_tokens ?? 0
|
||||||
|
const outputTokens = response.usage?.completion_tokens ?? 0
|
||||||
|
|
||||||
|
logEvent('tengu_api_success', {
|
||||||
|
requestId: (response.id ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
|
querySource: querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
|
model: resolvedModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cachedInputTokens: 0,
|
||||||
|
uncachedInputTokens: 0,
|
||||||
|
durationMsIncludingRetries: now - start,
|
||||||
|
timeSinceLastApiCallMs: lastCompletion !== null ? now - lastCompletion : undefined,
|
||||||
|
})
|
||||||
|
setLastApiCompletionTimestamp(now)
|
||||||
|
|
||||||
|
const choice = response.choices[0]
|
||||||
|
const content: BetaMessage['content'] = []
|
||||||
|
|
||||||
|
if (choice?.message?.content) {
|
||||||
|
content.push({ type: 'text', text: choice.message.content })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice?.message?.tool_calls) {
|
||||||
|
for (const tc of choice.message.tool_calls) {
|
||||||
|
let input: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
input = JSON.parse(tc.function.arguments) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
// leave input empty on parse failure
|
||||||
|
}
|
||||||
|
content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: response.id,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
model: resolvedModel,
|
||||||
|
stop_reason: STOP_REASON_MAP[choice?.finish_reason ?? 'stop'] ?? 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
cache_creation_input_tokens: null,
|
||||||
|
cache_read_input_tokens: null,
|
||||||
|
server_tool_use: null,
|
||||||
|
},
|
||||||
|
} as unknown as BetaMessage
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user