From 2f7fc7959d7e33f3905546367267c38e29cf2c84 Mon Sep 17 00:00:00 2001 From: Askhz <1361267452@qq.com> Date: Wed, 29 Apr 2026 18:02:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20sideQuery/=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=80=89=E6=8B=A9=E5=9C=A8=20CoStrict=20=E5=92=8C=20O?= =?UTF-8?q?penAI=20provider=20=E4=B8=8B=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/commands/model/model.tsx | 81 +++++++++++++++++ src/components/ConsoleOAuthFlow.tsx | 2 + src/entrypoints/cli.tsx | 6 ++ src/utils/model/configs.ts | 1 + src/utils/model/modelOptions.ts | 101 +++++++++++++-------- src/utils/sideQuery.ts | 33 ++++++- src/utils/sideQueryOpenAICompat.ts | 132 ++++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 src/utils/sideQueryOpenAICompat.ts diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index bc49e77ce..942f62533 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -29,6 +29,21 @@ import { } from '../../utils/model/model.js' import { isModelAllowed } from '../../utils/model/modelAllowlist.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({ onDone, @@ -42,6 +57,7 @@ function ModelPickerWrapper({ const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession) const isFastMode = useAppState(s => s.fastMode) const setAppState = useSetAppState() + const [pickerKey, setPickerKey] = React.useState(0) function handleCancel(): void { logEvent('tengu_model_command_menu', { @@ -58,6 +74,57 @@ function ModelPickerWrapper({ model: string | null, effort: EffortLevel | undefined, ): 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', { action: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -72,6 +139,11 @@ function ModelPickerWrapper({ 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))}` if (effort !== undefined) { message += ` with ${chalk.bold(effort)} effort` @@ -118,6 +190,7 @@ function ModelPickerWrapper({ return ( { return } + // 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 } diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 751fe12c1..0d914873e 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -1368,6 +1368,7 @@ function OAuthStatusMessage({ options={costrictOptions} onChange={(value: string) => { process.env.COSTRICT_MODEL = value; + updateSettingsForSource('userSettings', { model: value } as any) setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null })); setOAuthStatus({ state: 'success' }); void onDone(); @@ -1375,6 +1376,7 @@ function OAuthStatusMessage({ onCancel={() => { const selected = sortedModels[0]?.id ?? ''; process.env.COSTRICT_MODEL = selected; + updateSettingsForSource('userSettings', { model: selected } as any) setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null })); setOAuthStatus({ state: 'success' }); void onDone(); diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 6cb7feec8..e22289bd6 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -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)) { for (const stream of [process.stdin, process.stdout, process.stderr]) { if (!stream.isTTY) { diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index f1d9d409e..1b9c24d01 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -123,6 +123,7 @@ export const CLAUDE_OPUS_4_7_CONFIG = { foundry: 'claude-opus-4-7', openai: 'claude-opus-4-7', gemini: 'claude-opus-4-7', + costrict: 'claude-opus-4-7', grok: 'claude-opus-4-7', } as const satisfies ModelConfig diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 754963955..0984118e1 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -1,5 +1,6 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import { getInitialMainLoopModel } from '../../bootstrap/state.js' +import { getCachedCoStrictModels } from '../../costrict/provider/models.js' import { isClaudeAISubscriber, isMaxSubscriber, @@ -402,6 +403,23 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { 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 if (getAPIProvider() === 'firstParty') { 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 // if it is not already in the options. - let customModel: ModelSetting = null - const currentMainLoopModel = getUserSpecifiedModelSetting() - const initialMainLoopModel = getInitialMainLoopModel() - if (currentMainLoopModel !== undefined && currentMainLoopModel !== null) { - customModel = currentMainLoopModel - } else if (initialMainLoopModel !== null) { - customModel = initialMainLoopModel - } - if (customModel === null || options.some(opt => opt.value === customModel)) { - return filterModelOptionsByAllowlist(options) - } else if (customModel === 'opusplan') { - return filterModelOptionsByAllowlist([...options, getOpusPlanOption()]) - } else if (customModel === 'opus' && getAPIProvider() === 'firstParty') { - return filterModelOptionsByAllowlist([ - ...options, - getMaxOpusOption(fastMode), - ]) - } else if (customModel === 'opus[1m]' && getAPIProvider() === 'firstParty') { - return filterModelOptionsByAllowlist([ - ...options, - getMergedOpus1MOption(fastMode), - ]) - } 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', - }) + // Only applies to Anthropic-compatible providers (firstParty/bedrock/vertex/foundry). + // For CoStrict/OpenAI/Gemini/Grok, the model list comes from additionalModelOptionsCache + // or getModelOptionsBase(), so we must not inject ANTHROPIC_MODEL / settings.model here. + const currentProvider = getAPIProvider() + const isAnthropicCompatible = + currentProvider === 'firstParty' || + currentProvider === 'bedrock' || + currentProvider === 'vertex' || + currentProvider === 'foundry' + + if (isAnthropicCompatible) { + let customModel: ModelSetting = null + const currentMainLoopModel = getUserSpecifiedModelSetting() + const initialMainLoopModel = getInitialMainLoopModel() + if (currentMainLoopModel !== undefined && currentMainLoopModel !== null) { + customModel = currentMainLoopModel + } else if (initialMainLoopModel !== null) { + customModel = initialMainLoopModel + } + if (customModel !== null && !options.some(opt => opt.value === customModel)) { + if (customModel === 'opusplan') { + return filterModelOptionsByAllowlist([...options, getOpusPlanOption()]) + } else if (customModel === 'opus') { + return filterModelOptionsByAllowlist([ + ...options, + getMaxOpusOption(fastMode), + ]) + } else if (customModel === 'opus[1m]') { + return filterModelOptionsByAllowlist([ + ...options, + getMergedOpus1MOption(fastMode), + ]) + } 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) } /** diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index c1f5bc3c6..ab1ba9715 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -24,6 +24,16 @@ import { errorMessage } from './errors.js' import { computeFingerprint } from './fingerprint.js' import { getAPIProvider } from './model/providers.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 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' }] }) */ export async function sideQuery(opts: SideQueryOptions): Promise { + 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, + fetch: createCoStrictFetch() as unknown as typeof fetch, + }) + return sideQueryOpenAICompat(opts, client, resolveCoStrictModel(getMainLoopModel()), 'CoStrict') + } + const { model, system, @@ -189,7 +221,6 @@ export async function sideQuery(opts: SideQueryOptions): Promise { } const normalizedModel = normalizeModelStringForAPI(model) - const provider = getAPIProvider() const start = Date.now() const traceName = `side-query:${opts.querySource}` diff --git a/src/utils/sideQueryOpenAICompat.ts b/src/utils/sideQueryOpenAICompat.ts new file mode 100644 index 000000000..bab76b547 --- /dev/null +++ b/src/utils/sideQueryOpenAICompat.ts @@ -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 = { + 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 { + 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 = {} + try { + input = JSON.parse(tc.function.arguments) as Record + } 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 +}