feat: add China LLM providers guided login flow (#1254)

* docs: update contributors

* docs: update contributors

* feat: add China LLM providers guided login flow

Add a guided login experience for 4 domestic (China) LLM providers
in the /login command: DeepSeek, Zhipu GLM, Tongyi Qianwen, and
MiMo Xiaomi. Each provider includes model presets with pricing,
context windows, and optional Coding Plan integration.

- New file: src/utils/chinaLlmProviders.ts — provider preset configs
- Modified: src/components/ConsoleOAuthFlow.tsx — 4-step guided flow
  (select provider → select mode → select model → enter API key)

All providers are OpenAI-compatible; credentials saved as
OPENAI_BASE_URL + OPENAI_API_KEY under modelType: 'openai'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: add custom model input with suggestions and model listing links

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
YYMa
2026-06-13 22:25:18 +08:00
committed by GitHub
parent 91cffe16e2
commit 5bfe6fa590
2 changed files with 565 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
import { openBrowser } from '../utils/browser.js'; import { openBrowser } from '../utils/browser.js';
import { logError } from '../utils/log.js'; import { logError } from '../utils/log.js';
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
import { CHINA_LLM_PROVIDERS, type ProviderPreset, resolveChinaProviderBaseURL } from 'src/utils/chinaLlmProviders.js';
import { Select } from './CustomSelect/select.js'; import { Select } from './CustomSelect/select.js';
import { Spinner } from './Spinner.js'; import { Spinner } from './Spinner.js';
import TextInput from './TextInput.js'; import TextInput from './TextInput.js';
@@ -65,6 +66,10 @@ type OAuthStatus =
opusModel: string; opusModel: string;
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
} // Gemini Generate Content API platform } // Gemini Generate Content API platform
| { state: 'china_provider_select'; activeIndex: number } // China LLM: pick provider
| { state: 'china_mode_select'; provider: ProviderPreset; activeIndex: number } // China LLM: pick access mode
| { state: 'china_model_select'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; activeIndex: number } // China LLM: pick model
| { state: 'china_apikey'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; modelId: string; apiKey: string } // China LLM: enter API key
| { state: 'ready_to_start' } // Flow started, waiting for browser to open | { state: 'ready_to_start' } // Flow started, waiting for browser to open
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
| { state: 'creating_api_key' } // Got access token, creating API key | { state: 'creating_api_key' } // Got access token, creating API key
@@ -457,6 +462,15 @@ function OAuthStatusMessage({
), ),
value: 'openai_chat_api', value: 'openai_chat_api',
}, },
{
label: (
<Text>
China LLM Providers · <Text dimColor>DeepSeek, Zhipu GLM, Qwen, MiMo</Text>
{'\n'}
</Text>
),
value: 'china_providers',
},
{ {
label: ( label: (
<Text> <Text>
@@ -536,6 +550,9 @@ function OAuthStatusMessage({
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url', activeField: 'base_url',
}); });
} else if (value === 'china_providers') {
logEvent('tengu_china_providers_selected', {});
setOAuthStatus({ state: 'china_provider_select', activeIndex: 0 });
} else if (value === 'chatgpt_subscription') { } else if (value === 'chatgpt_subscription') {
logEvent('tengu_chatgpt_subscription_selected', {}); logEvent('tengu_chatgpt_subscription_selected', {});
setOAuthStatus({ setOAuthStatus({
@@ -1274,6 +1291,274 @@ function OAuthStatusMessage({
); );
} }
case 'china_provider_select': {
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>Select China LLM Provider</Text>
<Text dimColor>Direct connection, no proxy needed. All providers are OpenAI-compatible.</Text>
<Box>
<Select
options={CHINA_LLM_PROVIDERS.map(p => ({
label: (
<Text>
{p.icon} {p.label} · <Text dimColor>{p.description}</Text>
{'\n'}
</Text>
),
value: p.id,
}))}
onChange={value => {
const provider = CHINA_LLM_PROVIDERS.find(p => p.id === value);
if (!provider) return;
logEvent('tengu_china_provider_selected', {});
if (provider.codingPlan) {
setOAuthStatus({ state: 'china_mode_select', provider, activeIndex: 0 });
} else {
setOAuthStatus({ state: 'china_model_select', provider, mode: 'api', activeIndex: 0 });
}
}}
/>
</Box>
</Box>
);
}
case 'china_mode_select': {
const { provider } = oauthStatus;
const modeOptions = [
{ id: 'api' as const, label: 'Pay-as-you-go (API)', desc: 'Top up freely, pay per use' },
{ id: 'coding-plan' as const, label: 'Coding Plan', desc: 'Fixed monthly fee, high usage' },
];
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} Select Access Mode
</Text>
<Box>
<Select
options={modeOptions.map(m => ({
label: (
<Text>
{m.label} · <Text dimColor>{m.desc}</Text>
{'\n'}
</Text>
),
value: m.id,
}))}
onChange={value => {
logEvent('tengu_china_mode_selected', {});
setOAuthStatus({
state: 'china_model_select',
provider,
mode: value as 'api' | 'coding-plan',
activeIndex: 0,
});
}}
/>
</Box>
<Text dimColor>
No plan? Select "Pay-as-you-go"
{provider.id === 'zhipu' ? ' · GLM-4.7-Flash is free forever' : ''}
</Text>
</Box>
);
}
case 'china_model_select': {
const { provider, mode: accessMode } = oauthStatus;
const models = provider.models;
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} Select Model
</Text>
<Box>
<Select
options={[
...models.map(m => {
const priceLabel =
m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0
? 'Free'
: `¥${m.inputPricePerMTok}${m.outputPricePerMTok}`;
const tagLabel = m.tags?.length ? ` [${m.tags.join(', ')}]` : '';
return {
label: (
<Text>
{m.label} ·{' '}
<Text dimColor>
{priceLabel} · {m.contextWindow}
{tagLabel}
</Text>
{'\n'}
</Text>
),
value: m.id,
};
}),
{
label: (
<Text>
Custom model
<Text dimColor> · enter model name manually</Text>
{'\n'}
</Text>
),
value: '__custom__',
},
]}
onChange={value => {
logEvent('tengu_china_model_selected', {});
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' });
}}
/>
</Box>
</Box>
);
}
case 'china_apikey': {
const { provider, mode: accessMode, modelId } = oauthStatus;
const [chinaKeyValue, setChinaKeyValue] = useState('');
const [chinaKeyCursor, setChinaKeyCursor] = useState(0);
const [chinaKeyError, setChinaKeyError] = useState<string | null>(null);
const doChinaSave = useCallback(() => {
const effectiveModelId = modelId === '__custom__' ? chinaKeyValue.trim() : modelId;
if (!effectiveModelId) {
setChinaKeyError(modelId === '__custom__' ? 'Please enter a model name' : 'Please enter an API key');
return;
}
if (modelId === '__custom__') {
logEvent('tengu_china_custom_model_entered', {});
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: effectiveModelId, apiKey: '' });
setChinaKeyValue('');
setChinaKeyError(null);
return;
}
if (!chinaKeyValue.trim()) {
setChinaKeyError('Please enter an API key');
return;
}
const baseUrl = resolveChinaProviderBaseURL(provider.id, accessMode);
const env: Record<string, string | undefined> = {
OPENAI_AUTH_MODE: undefined,
OPENAI_BASE_URL: baseUrl,
OPENAI_API_KEY: chinaKeyValue.trim(),
OPENAI_DEFAULT_SONNET_MODEL: modelId,
OPENAI_DEFAULT_HAIKU_MODEL: modelId,
OPENAI_DEFAULT_OPUS_MODEL: modelId,
};
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
modelType: 'openai',
env: env as unknown as Record<string, string>,
};
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
if (error) {
setOAuthStatus({
state: 'error',
message: 'Failed to save settings. Please try again.',
toRetry: { state: 'china_apikey', provider, mode: accessMode, modelId, apiKey: chinaKeyValue },
});
} else {
for (const [k, v] of Object.entries(env)) {
if (v === undefined) {
delete process.env[k];
} else {
process.env[k] = v;
}
}
logEvent('tengu_china_login_success', {});
setOAuthStatus({ state: 'success' });
void onDone();
}
}, [chinaKeyValue, provider, accessMode, modelId, onDone, setOAuthStatus]);
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'china_model_select', provider, mode: accessMode, activeIndex: 0 });
},
{ context: 'Confirmation' },
);
const isCustomModelEntry = modelId === '__custom__';
const allModels = CHINA_LLM_PROVIDERS.flatMap(p =>
p.models.map(m => ({ id: m.id, label: m.label, provider: p.label })),
);
const modelSuggestions = isCustomModelEntry
? chinaKeyValue.trim()
? allModels.filter(m => m.id.toLowerCase().includes(chinaKeyValue.trim().toLowerCase()))
: allModels
: [];
const keyPage = isCustomModelEntry
? provider.apiKeyPage
: accessMode === 'coding-plan' && provider.codingPlan
? provider.codingPlan.purchasePage
: provider.apiKeyPage;
const keyFormat = isCustomModelEntry
? provider.keyFormat
: accessMode === 'coding-plan' && provider.codingPlan
? provider.codingPlan.keyFormat
: provider.keyFormat;
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} {isCustomModelEntry ? '— Custom Model' : 'API Key'}
</Text>
<Box flexDirection="column" gap={0}>
{isCustomModelEntry ? (
<Text dimColor> Enter any model ID supported by this provider. Browse models: {provider.modelsPage}</Text>
) : (
<>
<Text dimColor> Get your key: {keyPage}</Text>
<Text dimColor>
{' '}
{accessMode === 'coding-plan' ? 'Use your Coding Plan credential here' : provider.freeTier}
</Text>
<Text dimColor> Key format: {keyFormat}</Text>
</>
)}
</Box>
<Box>
<Text>{isCustomModelEntry ? 'Model name: ' : 'API Key: '}</Text>
<TextInput
value={chinaKeyValue}
onChange={v => {
setChinaKeyValue(v);
setChinaKeyError(null);
}}
onSubmit={doChinaSave}
cursorOffset={chinaKeyCursor}
onChangeCursorOffset={setChinaKeyCursor}
columns={useTerminalSize().columns - 12}
mask={isCustomModelEntry ? undefined : '*'}
focus={true}
/>
</Box>
{chinaKeyError ? <Text color="error">{chinaKeyError}</Text> : null}
{isCustomModelEntry && modelSuggestions.length > 0 && (
<Box flexDirection="column" gap={0}>
<Text dimColor>{chinaKeyValue.trim() ? 'Matching models:' : 'Known models:'}</Text>
{modelSuggestions.map(m => (
<Text key={m.id} dimColor>
{' '}
{m.id}{' '}
<Text>
({m.label} {m.provider})
</Text>
</Text>
))}
</Box>
)}
<Text dimColor>
{isCustomModelEntry ? 'Enter to continue · Esc to go back' : 'Enter to confirm · Esc to go back'}
</Text>
</Box>
);
}
case 'platform_setup': case 'platform_setup':
return ( return (
<Box flexDirection="column" gap={1} marginTop={1}> <Box flexDirection="column" gap={1} marginTop={1}>

View File

@@ -0,0 +1,280 @@
/**
* Domestic (China) LLM provider presets with URLs, pricing, and model data.
* All providers are OpenAI-compatible — just swap baseURL + apiKey.
*/
export type ProviderModel = {
id: string
label: string
inputPricePerMTok: number
outputPricePerMTok: number
contextWindow: string
free?: boolean
tags?: string[]
deprecated?: string
}
export type CodingPlanTier = {
id: string
label: string
price: string
credits: string
description: string
}
export type ProviderPreset = {
id: string
label: string
description: string
icon: string
baseURL: string
apiKeyPage: string
modelsPage: string
freeTier: string
keyFormat: string
codingPlan?: {
baseURL: string
keyFormat: string
purchasePage: string
tiers: CodingPlanTier[]
}
models: ProviderModel[]
}
export const CHINA_LLM_PROVIDERS: ProviderPreset[] = [
{
id: 'deepseek',
label: 'DeepSeek',
description: 'Cheapest pricing, best code, 5M free tokens',
icon: '\u{1F525}',
baseURL: 'https://api.deepseek.com',
apiKeyPage: 'https://platform.deepseek.com/api_keys',
modelsPage: 'https://api-docs.deepseek.com/zh-cn/',
freeTier: '5M tokens on signup (30 days), min top-up ¥10',
keyFormat: 'sk-...',
models: [
{
id: 'deepseek-v4-pro',
label: 'DeepSeek V4 Pro',
inputPricePerMTok: 3,
outputPricePerMTok: 6,
contextWindow: '1M',
tags: ['Recommended', 'Best code'],
},
{
id: 'deepseek-v4-flash',
label: 'DeepSeek V4 Flash',
inputPricePerMTok: 1,
outputPricePerMTok: 2,
contextWindow: '1M',
tags: ['Fast'],
},
],
},
{
id: 'zhipu',
label: 'Zhipu GLM',
description: 'Free models, Coding Plan, strong reasoning',
icon: '\u{1F9E0}',
baseURL: 'https://open.bigmodel.cn/api/paas/v4',
apiKeyPage: 'https://open.bigmodel.cn/user/apiKeys',
modelsPage: 'https://docs.bigmodel.cn/cn/guide/start/model-overview',
freeTier: 'GLM-4.7-Flash / GLM-Z1-Flash free forever',
keyFormat: '{id}.{secret}',
codingPlan: {
baseURL: 'https://open.bigmodel.cn/api/coding/paas/v4',
keyFormat: '{id}.{secret}',
purchasePage: 'https://bigmodel.cn/claude-code',
tiers: [
{
id: 'lite',
label: 'Lite',
price: '¥72/mo ($30/quarter)',
credits: '~400 prompts/week',
description: 'GLM-5.1/5-Turbo/4.7/4.5-Air, MCP tools',
},
{
id: 'pro',
label: 'Pro',
price: '¥216/mo ($90/quarter)',
credits: '~2000 prompts/week',
description: 'Lite + GLM-5, 5x quota',
},
{
id: 'max',
label: 'Max',
price: '¥576/mo ($240/quarter)',
credits: '~8000 prompts/week',
description: '4x Pro quota for heavy use',
},
],
},
models: [
{
id: 'glm-5.1',
label: 'GLM-5.1',
inputPricePerMTok: 10.1,
outputPricePerMTok: 31.7,
contextWindow: '203K',
tags: ['Flagship'],
},
{
id: 'glm-4.7',
label: 'GLM-4.7',
inputPricePerMTok: 4.3,
outputPricePerMTok: 15.8,
contextWindow: '205K',
tags: ['Recommended'],
},
{
id: 'glm-4.7-flash',
label: 'GLM-4.7 Flash',
inputPricePerMTok: 0,
outputPricePerMTok: 0,
contextWindow: '203K',
free: true,
tags: ['Free forever'],
},
],
},
{
id: 'qwen',
label: 'Tongyi Qianwen',
description: 'Alibaba Cloud, Coding Plan, 90-day free tier',
icon: '☁️',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKeyPage: 'https://bailian.console.aliyun.com',
modelsPage:
'https://help.aliyun.com/zh/model-studio/getting-started/models',
freeTier: '90-day free tier for all models after activation',
keyFormat: 'sk-...',
codingPlan: {
baseURL: 'https://coding.dashscope.aliyuncs.com/v1',
keyFormat: 'sk-sp-...',
purchasePage: 'https://bailian.console.aliyun.com',
tiers: [
{
id: 'pro',
label: 'Pro',
price: '¥200/mo',
credits: 'Includes Qwen/GLM/Kimi/MiniMax models',
description: 'Entry tier (Lite discontinued 2026/03)',
},
],
},
models: [
{
id: 'qwen3-max',
label: 'Qwen3 Max',
inputPricePerMTok: 2.5,
outputPricePerMTok: 10,
contextWindow: '262K',
tags: ['Flagship'],
},
{
id: 'qwen3.5-plus',
label: 'Qwen3.5 Plus',
inputPricePerMTok: 0.8,
outputPricePerMTok: 4.8,
contextWindow: '1M',
tags: ['Recommended', 'Value'],
},
{
id: 'qwen3.5-flash',
label: 'Qwen3.5 Flash',
inputPricePerMTok: 0.2,
outputPricePerMTok: 2,
contextWindow: '1M',
tags: ['Fast'],
},
],
},
{
id: 'mimo',
label: 'MiMo Xiaomi',
description: '1M context, 128K output, Token Plan, open source',
icon: '\u{1F4F1}',
baseURL: 'https://api.xiaomimimo.com/v1',
apiKeyPage: 'https://platform.xiaomimimo.com/api-keys',
modelsPage: 'https://platform.xiaomimimo.com/models',
freeTier: 'Credits for new users, mimo-v2-flash low cost',
keyFormat: 'sk-...',
codingPlan: {
baseURL: 'https://token-plan-cn.xiaomimimo.com/v1',
keyFormat: 'tp-...',
purchasePage: 'https://platform.xiaomimimo.com/token-plan',
tiers: [
{
id: 'lite',
label: 'Lite',
price: '¥39/mo ($6/mo)',
credits: '4.1B Credits/mo',
description: 'Light use, all MiMo models',
},
{
id: 'standard',
label: 'Standard',
price: '¥99/mo ($16/mo)',
credits: '11B Credits/mo',
description: '2.7x Lite, daily coding',
},
{
id: 'pro',
label: 'Pro',
price: '¥329/mo ($50/mo)',
credits: '38B Credits/mo',
description: '9x Lite, heavy complex projects',
},
{
id: 'max',
label: 'Max',
price: '¥659/mo ($100/mo)',
credits: '82B Credits/mo',
description: '20x Lite, team-level usage',
},
],
},
models: [
{
id: 'mimo-v2.5-pro',
label: 'MiMo V2.5 Pro',
inputPricePerMTok: 3,
outputPricePerMTok: 6,
contextWindow: '1M',
tags: ['Recommended', 'Flagship'],
},
{
id: 'mimo-v2.5',
label: 'MiMo V2.5',
inputPricePerMTok: 1,
outputPricePerMTok: 2,
contextWindow: '1M',
tags: ['Multimodal'],
},
{
id: 'mimo-v2-flash',
label: 'MiMo V2 Flash',
inputPricePerMTok: 0.7,
outputPricePerMTok: 2.1,
contextWindow: '256K',
tags: ['Fast'],
},
],
},
]
export function findChinaProviderById(id: string): ProviderPreset | undefined {
return CHINA_LLM_PROVIDERS.find(p => p.id === id)
}
export function resolveChinaProviderBaseURL(
providerId: string,
mode: 'api' | 'coding-plan',
): string {
const provider = findChinaProviderById(providerId)
if (!provider) return ''
if (mode === 'coding-plan' && provider.codingPlan) {
return provider.codingPlan.baseURL
}
return provider.baseURL
}