Merge pull request #42 from Askhz/fix/login-costrict-model

fix: 修复 sideQuery/模型选择在 CoStrict 和 OpenAI provider 下的问题
This commit is contained in:
geroge 2026-04-29 18:27:28 +08:00 committed by GitHub
commit f2dbc64f70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 331 additions and 38 deletions

View File

@ -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} />
}

View File

@ -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();

View File

@ -89,6 +89,7 @@ export function createCoStrictFetch(): CoStrictFetch {
// ========== 步骤 3: 构建 headers ==========
const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${creds.access_token}`)
headers.set('User-Agent', `csc/${VERSION}`)
headers.set('HTTP-Referer', 'https://github.com/zgsm-ai/costrict-cli')
headers.set('X-Title', 'CoStrict-CLI')
headers.set('X-Costrict-Version', `costrict-cli-${VERSION}`)

View File

@ -16,6 +16,7 @@ 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'
@ -103,6 +104,7 @@ export async function* queryModelCoStrict(
forAnthropicAPI: false,
}) as any,
fetch: costrictFetch as any,
defaultHeaders: { 'User-Agent': getUserAgent() },
})
logForDebugging(

View File

@ -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) {

View File

@ -1,5 +1,6 @@
import OpenAI from 'openai'
import { getProxyFetchOptions } from 'src/utils/proxy.js'
import { getUserAgent } from 'src/utils/http.js'
/**
* Environment variables:
@ -30,6 +31,7 @@ export function getGrokClient(options?: {
dangerouslyAllowBrowser: true,
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }),
...(options?.fetchOverride && { fetch: options.fetchOverride }),
defaultHeaders: { 'User-Agent': getUserAgent() },
})
if (!options?.fetchOverride) {

View File

@ -3,6 +3,7 @@ import { openaiAdapter } from 'src/services/providerUsage/adapters/openai.js'
import { updateProviderBuckets } from 'src/services/providerUsage/store.js'
import { getProxyFetchOptions } from 'src/utils/proxy.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { getUserAgent } from 'src/utils/http.js'
/**
* Environment variables:
@ -60,6 +61,7 @@ export function getOpenAIClient(options?: {
...(process.env.OPENAI_PROJECT_ID && { project: process.env.OPENAI_PROJECT_ID }),
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }),
fetch: wrappedFetch,
defaultHeaders: { 'User-Agent': getUserAgent() },
})
if (!options?.fetchOverride) {

View File

@ -31,7 +31,7 @@ export function getUserAgent(): string {
// so the read picks up the same setWorkload() value as getAttributionHeader.
const workload = getWorkload()
const workloadSuffix = workload ? `, workload/${workload}` : ''
return `claude-cli/${MACRO.VERSION} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})`
return `csc/${MACRO.VERSION}`
}
export function getMCPUserAgent(): string {
@ -46,7 +46,7 @@ export function getMCPUserAgent(): string {
parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`)
}
const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : ''
return `claude-code/${MACRO.VERSION}${suffix}`
return `csc/${MACRO.VERSION}${suffix}`
}
// User-Agent for WebFetch requests to arbitrary sites. `Claude-User` is

View File

@ -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

View File

@ -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)
}
/**

View File

@ -24,6 +24,17 @@ 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 { getUserAgent } from './http.js'
import { getMainLoopModel } from './model/model.js'
import OpenAI from 'openai'
type MessageParam = Anthropic.MessageParam
type TextBlockParam = Anthropic.TextBlockParam
@ -117,6 +128,29 @@ 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,
defaultHeaders: { 'User-Agent': getUserAgent() },
})
return sideQueryOpenAICompat(opts, client, resolveCoStrictModel(getMainLoopModel()), 'CoStrict')
}
const {
model,
system,
@ -189,7 +223,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}`

View 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
}