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'
|
||||
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 (
|
||||
<ModelPicker
|
||||
key={pickerKey}
|
||||
initial={mainLoopModel}
|
||||
sessionModel={mainLoopModelForSession}
|
||||
onSelect={handleSelect}
|
||||
|
|
@ -326,6 +399,14 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
|||
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} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
model,
|
||||
system,
|
||||
|
|
@ -189,7 +221,6 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
|||
}
|
||||
|
||||
const normalizedModel = normalizeModelStringForAPI(model)
|
||||
const provider = getAPIProvider()
|
||||
const start = Date.now()
|
||||
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