Merge pull request #33 from Askhz/refactor/login-logic

refactor: 精简登录选项,保留 4 个主要登录方式
This commit is contained in:
geroge 2026-04-21 09:39:06 +08:00 committed by GitHub
commit 83beec0b5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 237 additions and 103 deletions

4
.gitignore vendored
View File

@ -38,3 +38,7 @@ data
.codex/skills/.system/**
!.codex/prompts/
!.codex/prompts/**
.costrict
.claude
.tmp/

View File

@ -4,6 +4,7 @@ import { getAPIProvider } from '../utils/model/providers.js'
import { updateSettingsForSource } from '../utils/settings/settings.js'
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
import { getDefaultSonnetModel, getDefaultOpusModel, getDefaultHaikuModel } from '../utils/model/model.js'
function getEnvVarForProvider(provider: string): string {
switch (provider) {
@ -43,6 +44,28 @@ const call: LocalCommandCall = async (args, context) => {
return { type: 'text', value: `Current API provider: ${current}` }
}
// Helper function to set default model based on provider
const setDefaultModel = (provider: string) => {
const settings = getSettings_DEPRECATED() || {}
const currentModel = settings.model
// Only set default if no model has been explicitly set
if (!currentModel) {
let defaultModel: string
switch (provider) {
case 'anthropic':
defaultModel = getDefaultSonnetModel()
break
case 'openai':
case 'gemini':
case 'grok':
default:
defaultModel = getDefaultSonnetModel()
}
updateSettingsForSource('userSettings', { model: defaultModel })
}
}
// unset - clear settings, fallback to env vars
if (arg === 'unset') {
updateSettingsForSource('userSettings', { modelType: undefined })
@ -53,6 +76,7 @@ const call: LocalCommandCall = async (args, context) => {
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
delete process.env.CLAUDE_CODE_USE_COSTRICT
return {
type: 'text',
value: 'API provider cleared (will use environment variables).',
@ -83,6 +107,7 @@ const call: LocalCommandCall = async (args, context) => {
const hasUrl = !!mergedEnv.OPENAI_BASE_URL
if (!hasKey || !hasUrl) {
updateSettingsForSource('userSettings', { modelType: 'openai' })
setDefaultModel('openai')
const missing = []
if (!hasKey) missing.push('OPENAI_API_KEY')
if (!hasUrl) missing.push('OPENAI_BASE_URL')
@ -99,6 +124,7 @@ const call: LocalCommandCall = async (args, context) => {
const hasKey = !!(mergedEnv.GROK_API_KEY || mergedEnv.XAI_API_KEY)
if (!hasKey) {
updateSettingsForSource('userSettings', { modelType: 'grok' })
setDefaultModel('grok')
return {
type: 'text',
value: `Switched to Grok provider.\nWarning: Missing env var: GROK_API_KEY (or XAI_API_KEY)\nConfigure it via settings.json env or set manually.`,
@ -113,6 +139,7 @@ const call: LocalCommandCall = async (args, context) => {
// GEMINI_BASE_URL is optional (has default)
if (!hasKey) {
updateSettingsForSource('userSettings', { modelType: 'gemini' })
setDefaultModel('gemini')
return {
type: 'text',
value: `Switched to Gemini provider.\nWarning: Missing env var: GEMINI_API_KEY\nConfigure it via /login or set manually.`,
@ -131,8 +158,11 @@ const call: LocalCommandCall = async (args, context) => {
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
delete process.env.CLAUDE_CODE_USE_COSTRICT
// Update settings.json
updateSettingsForSource('userSettings', { modelType: arg })
// Set default model for the provider
setDefaultModel(arg)
// Ensure settings.env gets applied to process.env
applyConfigEnvironmentVariables()
return { type: 'text', value: `API provider set to ${arg}.` }

View File

@ -13,9 +13,11 @@ import { OAuthService } from '../services/oauth/index.js'
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
import { logError } from '../utils/log.js'
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'
import { getDefaultSonnetModel } from '../utils/model/model.js'
import { Select } from './CustomSelect/select.js'
import { Spinner } from './Spinner.js'
import TextInput from './TextInput.js'
import { useSetAppState } from '../state/AppState.js'
import { fi } from 'zod/v4/locales'
type Props = {
@ -28,6 +30,14 @@ type Props = {
type OAuthStatus =
| { state: 'idle' } // Initial state, waiting to select login method
| { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry)
| {
state: 'costrict_waiting'
url: string
} // CoStrict OAuth: browser opened, waiting for user to login
| {
state: 'costrict_model_select'
models: Array<{ id: string; name?: string }>
} // CoStrict: login done, select a model
| {
state: 'custom_platform'
baseUrl: string
@ -94,6 +104,7 @@ export function ConsoleOAuthFlow({
}
return { state: 'idle' }
})
const setAppState = useSetAppState()
const [pastedCode, setPastedCode] = useState('')
const [cursorOffset, setCursorOffset] = useState(0)
@ -273,6 +284,10 @@ export function ConsoleOAuthFlow({
}
// Reset modelType to anthropic when using OAuth login
updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any)
// Set default model to sonnet for Anthropic provider
updateSettingsForSource('userSettings', { model: getDefaultSonnetModel() } as any)
// Clear costrict env var to prevent conflicts
delete process.env.CLAUDE_CODE_USE_COSTRICT
setOAuthStatus({ state: 'success' })
void sendNotification(
@ -438,6 +453,7 @@ function OAuthStatusMessage({
setLoginWithClaudeAi,
onDone,
}: OAuthStatusMessageProps): React.ReactNode {
const setAppState = useSetAppState()
switch (oauthStatus.state) {
case 'idle':
return (
@ -453,6 +469,16 @@ function OAuthStatusMessage({
<Box>
<Select
options={[
{
label: (
<Text>
CoStrict ·{' '}
<Text dimColor>Sign in with CoStrict account</Text>
{'\n'}
</Text>
),
value: 'costrict',
},
{
label: (
<Text>
@ -485,52 +511,72 @@ function OAuthStatusMessage({
),
value: 'gemini_api',
},
{
label: (
<Text>
Claude account with subscription ·{' '}
<Text dimColor>Pro, Max, Team, or Enterprise</Text>
{process.env.USER_TYPE === 'ant' && (
<Text>
{'\n'}
<Text color="warning">[ANT-ONLY]</Text>{' '}
<Text dimColor>
Please use this option unless you need to login to a
special org for accessing sensitive data (e.g.
customer data, HIPI data) with the Console option
</Text>
</Text>
)}
{'\n'}
</Text>
),
value: 'claudeai',
},
{
label: (
<Text>
Anthropic Console account ·{' '}
<Text dimColor>API usage billing</Text>
{'\n'}
</Text>
),
value: 'console',
},
{
label: (
<Text>
3rd-party platform ·{' '}
<Text dimColor>
Amazon Bedrock, Microsoft Foundry, or Vertex AI
</Text>
{'\n'}
</Text>
),
value: 'platform',
},
]}
onChange={value => {
if (value === 'custom_platform') {
if (value === 'costrict') {
void (async () => {
try {
const { generateState, getCoStrictBaseURL, buildCoStrictLoginURL, pollLoginToken } = await import('../costrict/provider/auth.js')
const { generateMachineId, saveCoStrictCredentials } = await import('../costrict/provider/credentials.js')
const { extractExpiryFromJWT } = await import('../costrict/provider/token.js')
const { updateSettingsForSource } = await import('../utils/settings/settings.js')
const baseUrl = getCoStrictBaseURL()
const state = generateState()
const machineId = generateMachineId()
const loginUrl = buildCoStrictLoginURL(baseUrl, state, machineId)
// 打开浏览器
const { openBrowser } = await import('../utils/browser.js')
await openBrowser(loginUrl)
setOAuthStatus({ state: 'costrict_waiting', url: 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')
const models = await fetchCoStrictModels(baseUrl, tokens.access_token)
if (models.length > 0) {
setOAuthStatus({ state: 'costrict_model_select', models })
return
}
} catch {
// 预取失败,直接进入 success
}
setOAuthStatus({ state: 'success' })
void onDone()
} catch (err: any) {
setOAuthStatus({
state: 'error',
message: err.message || String(err),
toRetry: { state: 'idle' },
})
}
})()
} else if (value === 'custom_platform') {
logEvent('tengu_custom_platform_selected', {})
setOAuthStatus({
state: 'custom_platform',
@ -562,19 +608,7 @@ function OAuthStatusMessage({
sonnetModel: process.env.GEMINI_DEFAULT_SONNET_MODEL ?? '',
opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
})
} else if (value === 'platform') {
logEvent('tengu_oauth_platform_selected', {})
setOAuthStatus({ state: 'platform_setup' })
} else {
setOAuthStatus({ state: 'ready_to_start' })
if (value === 'claudeai') {
logEvent('tengu_oauth_claudeai_selected', {})
setLoginWithClaudeAi(true)
} else {
logEvent('tengu_oauth_console_selected', {})
setLoginWithClaudeAi(false)
}
});
}
}}
/>
@ -696,6 +730,10 @@ function OAuthStatusMessage({
})
} else {
for (const [k, v] of Object.entries(env)) process.env[k] = v
// Clear costrict env var to prevent conflicts when using custom platform
delete process.env.CLAUDE_CODE_USE_COSTRICT
// Set default model to sonnet for this provider
updateSettingsForSource('userSettings', { model: getDefaultSonnetModel() } as any)
setOAuthStatus({ state: 'success' })
void onDone()
}
@ -916,6 +954,10 @@ function OAuthStatusMessage({
})
} else {
for (const [k, v] of Object.entries(env)) process.env[k] = v
// Clear costrict env var to prevent conflicts when using OpenAI
delete process.env.CLAUDE_CODE_USE_COSTRICT
// Set default model to sonnet for this provider
updateSettingsForSource('userSettings', { model: getDefaultSonnetModel() } as any)
setOAuthStatus({ state: 'success' })
void onDone()
}
@ -1149,6 +1191,10 @@ function OAuthStatusMessage({
})
} else {
for (const [k, v] of Object.entries(env)) process.env[k] = v
// Clear costrict env var to prevent conflicts when using Gemini
delete process.env.CLAUDE_CODE_USE_COSTRICT
// Set default model to sonnet for this provider
updateSettingsForSource('userSettings', { model: getDefaultSonnetModel() } as any)
setOAuthStatus({ state: 'success' })
void onDone()
}
@ -1275,6 +1321,62 @@ function OAuthStatusMessage({
)
}
case 'costrict_waiting':
return (
<Box flexDirection="column" gap={1}>
<Text>
Opening browser for CoStrict login. If it does not open
automatically, copy and paste this URL:
</Text>
<Box marginY={1}>
<Text color="suggestion">{oauthStatus.url}</Text>
</Box>
<Text dimColor>Waiting for authentication...</Text>
</Box>
)
case 'costrict_model_select': {
const sortedModels = [...oauthStatus.models].sort((a, b) => a.id.localeCompare(b.id));
// 直接使用已获取的模型列表渲染选项,避免通用 ModelPicker 因 getAPIProvider() 竞态
// 导致显示标准 Anthropic 模型列表而非 CoStrict 动态模型
const costrictOptions = sortedModels.map(m => ({
value: m.id,
label: m.name ?? m.id,
description:
m.id === 'Auto'
? `${Math.round(((m as any).creditDiscount ?? 0) * 100)}% discount`
: `${(m as any).creditConsumption ?? '?'}x credit`,
}));
return (
<Box flexDirection="column">
<Box marginBottom={1} flexDirection="column">
<Text color="remember" bold>
Select model
</Text>
<Text dimColor>Login successful. Select a CoStrict model to use:</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Select
options={costrictOptions}
onChange={(value: string) => {
process.env.COSTRICT_MODEL = value;
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
setOAuthStatus({ state: 'success' });
void onDone();
}}
onCancel={() => {
const selected = sortedModels[0]?.id ?? '';
process.env.COSTRICT_MODEL = selected;
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
setOAuthStatus({ state: 'success' });
void onDone();
}}
/>
</Box>
</Box>
);
}
case 'platform_setup':
return (
<Box flexDirection="column" gap={1} marginTop={1}>

View File

@ -34,6 +34,10 @@ function getVersion(): string {
const VERSION = getVersion()
type CoStrictFetch = typeof fetch & {
preconnect?: (url: string | URL) => void
}
/**
* fetch CoStrict API
*
@ -43,7 +47,7 @@ const VERSION = getVersion()
* 3. Authorization CoStrict headers
* 4. 401
*/
export function createCoStrictFetch() {
export function createCoStrictFetch(): CoStrictFetch {
const costrictFetch = async (
input: RequestInfo | URL,
init?: RequestInit,
@ -126,20 +130,21 @@ export function createCoStrictFetch() {
}
// Bun 原生支持 fetch.preconnect共享连接池预热
// Node.js 没有 preconnect API降级为 net.createConnection 做 TCP 预热
if (typeof fetch.preconnect === 'function') {
costrictFetch.preconnect = fetch.preconnect.bind(fetch)
} else {
costrictFetch.preconnect = (url: string | URL) => {
try {
const { hostname, port } = new URL(typeof url === 'string' ? url : url.toString())
const { createConnection } = require('node:net') as typeof import('node:net')
const sock = createConnection(Number(port) || 443, hostname, () => sock.destroy())
sock.setTimeout(3000, () => sock.destroy())
sock.on('error', () => sock.destroy())
} catch {
// 预连接失败不影响正常流程
}
}
}
return costrictFetch
const costrictFetchWithPreconnect = Object.assign(costrictFetch, {
preconnect: typeof fetch.preconnect === 'function'
? fetch.preconnect.bind(fetch)
: (url: string | URL) => {
try {
const { hostname, port } = new URL(typeof url === 'string' ? url : url.toString())
const { createConnection } = require('node:net') as typeof import('node:net')
const sock = createConnection(Number(port) || 443, hostname, () => sock.destroy())
sock.setTimeout(3000, () => sock.destroy())
sock.on('error', () => sock.destroy())
} catch {
// 预连接失败不影响正常流程
}
}
})
return costrictFetchWithPreconnect as CoStrictFetch
}

View File

@ -16,12 +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 { anthropicMessagesToOpenAI } from '../../services/api/openai/convertMessages.js'
import {
anthropicToolsToOpenAI,
anthropicToolChoiceToOpenAI,
} from '../../services/api/openai/convertTools.js'
import { adaptOpenAIStreamToAnthropic } from '../../services/api/openai/streamAdapter.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'

View File

@ -33,7 +33,6 @@ import {
} from './model.js'
import { has1mContext } from '../context.js'
import { getGlobalConfig } from '../config.js'
import { hasCoStrictCredentialsSync } from '../../costrict/provider/credentials.js'
// @[MODEL LAUNCH]: Update all the available and default model option strings below.
@ -380,10 +379,8 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
}
// CoStrict provider: 从缓存中动态展示 CoStrict 可用模型
// 也覆盖其他 provider 但用户已有 CoStrict 凭证的场景modelType 丢失/竞态)
const isCoStrictProvider = getAPIProvider() === 'costrict'
const hasCoStrictCreds = hasCoStrictCredentialsSync()
if (isCoStrictProvider || hasCoStrictCreds) {
if (isCoStrictProvider) {
// 延迟 require 避免循环依赖,且该模块只在 costrict provider 下加载
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getCachedCoStrictModels } =
@ -414,21 +411,27 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
]
}
// 用户没有 CoStrict 凭证,显示推荐登录选项
return [getCoStrictLoginOption()]
}
/**
* CoStrict
* CoStrict 使 CoStrict
*/
function getCoStrictLoginOption(): ModelOption {
return {
value: 'costrict-login',
label: 'CoStrict (Recommended)',
description:
'Sign in to CoStrict for more models and better pricing · Run /login and select CoStrict',
// PAYG 用户展示标准模型选项Sonnet, Opus, Haiku 等)
const paygOptions = [getDefaultOptionForUser(fastMode)]
const customSonnet = getCustomSonnetOption()
if (customSonnet) {
paygOptions.push(customSonnet)
} else {
paygOptions.push(getSonnet46Option())
}
const customOpus = getCustomOpusOption()
if (customOpus) {
paygOptions.push(customOpus)
} else {
paygOptions.push(getOpus46Option(fastMode))
}
const customHaiku = getCustomHaikuOption()
if (customHaiku) {
paygOptions.push(customHaiku)
} else {
paygOptions.push(getHaikuOption())
}
return paygOptions
}
// @[MODEL LAUNCH]: Add the new model ID to the appropriate family pattern below
@ -549,11 +552,6 @@ export function getModelOptions(fastMode = false): ModelOption[] {
} else if (initialMainLoopModel !== null) {
customModel = initialMainLoopModel
}
// 忽略 costrict-login 伪模型值(用户点击了推荐登录选项但未完成登录)
if (customModel === 'costrict-login') {
return filterModelOptionsByAllowlist(options)
}
if (customModel === null || options.some(opt => opt.value === customModel)) {
return filterModelOptionsByAllowlist(options)
} else if (customModel === 'opusplan') {