Skip to content

Commit 5bfe6fa

Browse files
YuanyuanMa03claude
andauthored
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>
1 parent 91cffe1 commit 5bfe6fa

2 files changed

Lines changed: 565 additions & 0 deletions

File tree

src/components/ConsoleOAuthFlow.tsx

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
1919
import { openBrowser } from '../utils/browser.js';
2020
import { logError } from '../utils/log.js';
2121
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
22+
import { CHINA_LLM_PROVIDERS, type ProviderPreset, resolveChinaProviderBaseURL } from 'src/utils/chinaLlmProviders.js';
2223
import { Select } from './CustomSelect/select.js';
2324
import { Spinner } from './Spinner.js';
2425
import TextInput from './TextInput.js';
@@ -65,6 +66,10 @@ type OAuthStatus =
6566
opusModel: string;
6667
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
6768
} // Gemini Generate Content API platform
69+
| { state: 'china_provider_select'; activeIndex: number } // China LLM: pick provider
70+
| { state: 'china_mode_select'; provider: ProviderPreset; activeIndex: number } // China LLM: pick access mode
71+
| { state: 'china_model_select'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; activeIndex: number } // China LLM: pick model
72+
| { state: 'china_apikey'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; modelId: string; apiKey: string } // China LLM: enter API key
6873
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
6974
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
7075
| { state: 'creating_api_key' } // Got access token, creating API key
@@ -457,6 +462,15 @@ function OAuthStatusMessage({
457462
),
458463
value: 'openai_chat_api',
459464
},
465+
{
466+
label: (
467+
<Text>
468+
China LLM Providers · <Text dimColor>DeepSeek, Zhipu GLM, Qwen, MiMo</Text>
469+
{'\n'}
470+
</Text>
471+
),
472+
value: 'china_providers',
473+
},
460474
{
461475
label: (
462476
<Text>
@@ -536,6 +550,9 @@ function OAuthStatusMessage({
536550
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
537551
activeField: 'base_url',
538552
});
553+
} else if (value === 'china_providers') {
554+
logEvent('tengu_china_providers_selected', {});
555+
setOAuthStatus({ state: 'china_provider_select', activeIndex: 0 });
539556
} else if (value === 'chatgpt_subscription') {
540557
logEvent('tengu_chatgpt_subscription_selected', {});
541558
setOAuthStatus({
@@ -1274,6 +1291,274 @@ function OAuthStatusMessage({
12741291
);
12751292
}
12761293

1294+
case 'china_provider_select': {
1295+
return (
1296+
<Box flexDirection="column" gap={1} marginTop={1}>
1297+
<Text bold>Select China LLM Provider</Text>
1298+
<Text dimColor>Direct connection, no proxy needed. All providers are OpenAI-compatible.</Text>
1299+
<Box>
1300+
<Select
1301+
options={CHINA_LLM_PROVIDERS.map(p => ({
1302+
label: (
1303+
<Text>
1304+
{p.icon} {p.label} · <Text dimColor>{p.description}</Text>
1305+
{'\n'}
1306+
</Text>
1307+
),
1308+
value: p.id,
1309+
}))}
1310+
onChange={value => {
1311+
const provider = CHINA_LLM_PROVIDERS.find(p => p.id === value);
1312+
if (!provider) return;
1313+
logEvent('tengu_china_provider_selected', {});
1314+
if (provider.codingPlan) {
1315+
setOAuthStatus({ state: 'china_mode_select', provider, activeIndex: 0 });
1316+
} else {
1317+
setOAuthStatus({ state: 'china_model_select', provider, mode: 'api', activeIndex: 0 });
1318+
}
1319+
}}
1320+
/>
1321+
</Box>
1322+
</Box>
1323+
);
1324+
}
1325+
1326+
case 'china_mode_select': {
1327+
const { provider } = oauthStatus;
1328+
const modeOptions = [
1329+
{ id: 'api' as const, label: 'Pay-as-you-go (API)', desc: 'Top up freely, pay per use' },
1330+
{ id: 'coding-plan' as const, label: 'Coding Plan', desc: 'Fixed monthly fee, high usage' },
1331+
];
1332+
return (
1333+
<Box flexDirection="column" gap={1} marginTop={1}>
1334+
<Text bold>
1335+
{provider.icon} {provider.label} — Select Access Mode
1336+
</Text>
1337+
<Box>
1338+
<Select
1339+
options={modeOptions.map(m => ({
1340+
label: (
1341+
<Text>
1342+
{m.label} · <Text dimColor>{m.desc}</Text>
1343+
{'\n'}
1344+
</Text>
1345+
),
1346+
value: m.id,
1347+
}))}
1348+
onChange={value => {
1349+
logEvent('tengu_china_mode_selected', {});
1350+
setOAuthStatus({
1351+
state: 'china_model_select',
1352+
provider,
1353+
mode: value as 'api' | 'coding-plan',
1354+
activeIndex: 0,
1355+
});
1356+
}}
1357+
/>
1358+
</Box>
1359+
<Text dimColor>
1360+
No plan? Select "Pay-as-you-go"
1361+
{provider.id === 'zhipu' ? ' · GLM-4.7-Flash is free forever' : ''}
1362+
</Text>
1363+
</Box>
1364+
);
1365+
}
1366+
1367+
case 'china_model_select': {
1368+
const { provider, mode: accessMode } = oauthStatus;
1369+
const models = provider.models;
1370+
return (
1371+
<Box flexDirection="column" gap={1} marginTop={1}>
1372+
<Text bold>
1373+
{provider.icon} {provider.label} — Select Model
1374+
</Text>
1375+
<Box>
1376+
<Select
1377+
options={[
1378+
...models.map(m => {
1379+
const priceLabel =
1380+
m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0
1381+
? 'Free'
1382+
: ${m.inputPricePerMTok}${m.outputPricePerMTok}`;
1383+
const tagLabel = m.tags?.length ? ` [${m.tags.join(', ')}]` : '';
1384+
return {
1385+
label: (
1386+
<Text>
1387+
{m.label} ·{' '}
1388+
<Text dimColor>
1389+
{priceLabel} · {m.contextWindow}
1390+
{tagLabel}
1391+
</Text>
1392+
{'\n'}
1393+
</Text>
1394+
),
1395+
value: m.id,
1396+
};
1397+
}),
1398+
{
1399+
label: (
1400+
<Text>
1401+
✏️ Custom model
1402+
<Text dimColor> · enter model name manually</Text>
1403+
{'\n'}
1404+
</Text>
1405+
),
1406+
value: '__custom__',
1407+
},
1408+
]}
1409+
onChange={value => {
1410+
logEvent('tengu_china_model_selected', {});
1411+
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' });
1412+
}}
1413+
/>
1414+
</Box>
1415+
</Box>
1416+
);
1417+
}
1418+
1419+
case 'china_apikey': {
1420+
const { provider, mode: accessMode, modelId } = oauthStatus;
1421+
1422+
const [chinaKeyValue, setChinaKeyValue] = useState('');
1423+
const [chinaKeyCursor, setChinaKeyCursor] = useState(0);
1424+
const [chinaKeyError, setChinaKeyError] = useState<string | null>(null);
1425+
1426+
const doChinaSave = useCallback(() => {
1427+
const effectiveModelId = modelId === '__custom__' ? chinaKeyValue.trim() : modelId;
1428+
if (!effectiveModelId) {
1429+
setChinaKeyError(modelId === '__custom__' ? 'Please enter a model name' : 'Please enter an API key');
1430+
return;
1431+
}
1432+
if (modelId === '__custom__') {
1433+
logEvent('tengu_china_custom_model_entered', {});
1434+
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: effectiveModelId, apiKey: '' });
1435+
setChinaKeyValue('');
1436+
setChinaKeyError(null);
1437+
return;
1438+
}
1439+
if (!chinaKeyValue.trim()) {
1440+
setChinaKeyError('Please enter an API key');
1441+
return;
1442+
}
1443+
const baseUrl = resolveChinaProviderBaseURL(provider.id, accessMode);
1444+
const env: Record<string, string | undefined> = {
1445+
OPENAI_AUTH_MODE: undefined,
1446+
OPENAI_BASE_URL: baseUrl,
1447+
OPENAI_API_KEY: chinaKeyValue.trim(),
1448+
OPENAI_DEFAULT_SONNET_MODEL: modelId,
1449+
OPENAI_DEFAULT_HAIKU_MODEL: modelId,
1450+
OPENAI_DEFAULT_OPUS_MODEL: modelId,
1451+
};
1452+
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
1453+
modelType: 'openai',
1454+
env: env as unknown as Record<string, string>,
1455+
};
1456+
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
1457+
if (error) {
1458+
setOAuthStatus({
1459+
state: 'error',
1460+
message: 'Failed to save settings. Please try again.',
1461+
toRetry: { state: 'china_apikey', provider, mode: accessMode, modelId, apiKey: chinaKeyValue },
1462+
});
1463+
} else {
1464+
for (const [k, v] of Object.entries(env)) {
1465+
if (v === undefined) {
1466+
delete process.env[k];
1467+
} else {
1468+
process.env[k] = v;
1469+
}
1470+
}
1471+
logEvent('tengu_china_login_success', {});
1472+
setOAuthStatus({ state: 'success' });
1473+
void onDone();
1474+
}
1475+
}, [chinaKeyValue, provider, accessMode, modelId, onDone, setOAuthStatus]);
1476+
1477+
useKeybinding(
1478+
'confirm:no',
1479+
() => {
1480+
setOAuthStatus({ state: 'china_model_select', provider, mode: accessMode, activeIndex: 0 });
1481+
},
1482+
{ context: 'Confirmation' },
1483+
);
1484+
1485+
const isCustomModelEntry = modelId === '__custom__';
1486+
const allModels = CHINA_LLM_PROVIDERS.flatMap(p =>
1487+
p.models.map(m => ({ id: m.id, label: m.label, provider: p.label })),
1488+
);
1489+
const modelSuggestions = isCustomModelEntry
1490+
? chinaKeyValue.trim()
1491+
? allModels.filter(m => m.id.toLowerCase().includes(chinaKeyValue.trim().toLowerCase()))
1492+
: allModels
1493+
: [];
1494+
const keyPage = isCustomModelEntry
1495+
? provider.apiKeyPage
1496+
: accessMode === 'coding-plan' && provider.codingPlan
1497+
? provider.codingPlan.purchasePage
1498+
: provider.apiKeyPage;
1499+
const keyFormat = isCustomModelEntry
1500+
? provider.keyFormat
1501+
: accessMode === 'coding-plan' && provider.codingPlan
1502+
? provider.codingPlan.keyFormat
1503+
: provider.keyFormat;
1504+
1505+
return (
1506+
<Box flexDirection="column" gap={1} marginTop={1}>
1507+
<Text bold>
1508+
{provider.icon} {provider.label} {isCustomModelEntry ? '— Custom Model' : 'API Key'}
1509+
</Text>
1510+
<Box flexDirection="column" gap={0}>
1511+
{isCustomModelEntry ? (
1512+
<Text dimColor> Enter any model ID supported by this provider. Browse models: {provider.modelsPage}</Text>
1513+
) : (
1514+
<>
1515+
<Text dimColor> Get your key: {keyPage}</Text>
1516+
<Text dimColor>
1517+
{' '}
1518+
{accessMode === 'coding-plan' ? 'Use your Coding Plan credential here' : provider.freeTier}
1519+
</Text>
1520+
<Text dimColor> Key format: {keyFormat}</Text>
1521+
</>
1522+
)}
1523+
</Box>
1524+
<Box>
1525+
<Text>{isCustomModelEntry ? 'Model name: ' : 'API Key: '}</Text>
1526+
<TextInput
1527+
value={chinaKeyValue}
1528+
onChange={v => {
1529+
setChinaKeyValue(v);
1530+
setChinaKeyError(null);
1531+
}}
1532+
onSubmit={doChinaSave}
1533+
cursorOffset={chinaKeyCursor}
1534+
onChangeCursorOffset={setChinaKeyCursor}
1535+
columns={useTerminalSize().columns - 12}
1536+
mask={isCustomModelEntry ? undefined : '*'}
1537+
focus={true}
1538+
/>
1539+
</Box>
1540+
{chinaKeyError ? <Text color="error">{chinaKeyError}</Text> : null}
1541+
{isCustomModelEntry && modelSuggestions.length > 0 && (
1542+
<Box flexDirection="column" gap={0}>
1543+
<Text dimColor>{chinaKeyValue.trim() ? 'Matching models:' : 'Known models:'}</Text>
1544+
{modelSuggestions.map(m => (
1545+
<Text key={m.id} dimColor>
1546+
{' '}
1547+
{m.id}{' '}
1548+
<Text>
1549+
({m.label}{m.provider})
1550+
</Text>
1551+
</Text>
1552+
))}
1553+
</Box>
1554+
)}
1555+
<Text dimColor>
1556+
{isCustomModelEntry ? 'Enter to continue · Esc to go back' : 'Enter to confirm · Esc to go back'}
1557+
</Text>
1558+
</Box>
1559+
);
1560+
}
1561+
12771562
case 'platform_setup':
12781563
return (
12791564
<Box flexDirection="column" gap={1} marginTop={1}>

0 commit comments

Comments
 (0)