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

This commit is contained in:
YuanyuanMa03 2026-06-12 11:50:50 +08:00
parent dc90f0d214
commit a02e4f27af
2 changed files with 112 additions and 29 deletions

View File

@ -19,7 +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 '../utils/chinaLlmProviders.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';
@ -1372,26 +1372,38 @@ function OAuthStatusMessage({
</Text> </Text>
<Box> <Box>
<Select <Select
options={models.map(m => { options={[
const priceLabel = ...models.map(m => {
m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0 const priceLabel =
? 'Free' m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0
: `¥${m.inputPricePerMTok}${m.outputPricePerMTok}`; ? 'Free'
const tagLabel = m.tags?.length ? ` [${m.tags.join(', ')}]` : ''; : `¥${m.inputPricePerMTok}${m.outputPricePerMTok}`;
return { 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: ( label: (
<Text> <Text>
{m.label} ·{' '} Custom model
<Text dimColor> <Text dimColor> · enter model name manually</Text>
{priceLabel} · {m.contextWindow}
{tagLabel}
</Text>
{'\n'} {'\n'}
</Text> </Text>
), ),
value: m.id, value: '__custom__',
}; },
})} ]}
onChange={value => { onChange={value => {
logEvent('tengu_china_model_selected', {}); logEvent('tengu_china_model_selected', {});
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' }); setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' });
@ -1410,22 +1422,36 @@ function OAuthStatusMessage({
const [chinaKeyError, setChinaKeyError] = useState<string | null>(null); const [chinaKeyError, setChinaKeyError] = useState<string | null>(null);
const doChinaSave = useCallback(() => { 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()) { if (!chinaKeyValue.trim()) {
setChinaKeyError('Please enter an API key'); setChinaKeyError('Please enter an API key');
return; return;
} }
const baseUrl = resolveChinaProviderBaseURL(provider.id, accessMode); const baseUrl = resolveChinaProviderBaseURL(provider.id, accessMode);
const env: Record<string, string> = { const env: Record<string, string | undefined> = {
OPENAI_AUTH_MODE: undefined,
OPENAI_BASE_URL: baseUrl, OPENAI_BASE_URL: baseUrl,
OPENAI_API_KEY: chinaKeyValue.trim(), OPENAI_API_KEY: chinaKeyValue.trim(),
OPENAI_DEFAULT_SONNET_MODEL: modelId, OPENAI_DEFAULT_SONNET_MODEL: modelId,
OPENAI_DEFAULT_HAIKU_MODEL: modelId, OPENAI_DEFAULT_HAIKU_MODEL: modelId,
OPENAI_DEFAULT_OPUS_MODEL: modelId, OPENAI_DEFAULT_OPUS_MODEL: modelId,
}; };
const { error } = updateSettingsForSource('userSettings', { const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
modelType: 'openai' as any, modelType: 'openai',
env, env: env as unknown as Record<string, string>,
} as any); };
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
if (error) { if (error) {
setOAuthStatus({ setOAuthStatus({
state: 'error', state: 'error',
@ -1433,7 +1459,13 @@ function OAuthStatusMessage({
toRetry: { state: 'china_apikey', provider, mode: accessMode, modelId, apiKey: chinaKeyValue }, toRetry: { state: 'china_apikey', provider, mode: accessMode, modelId, apiKey: chinaKeyValue },
}); });
} else { } else {
for (const [k, v] of Object.entries(env)) process.env[k] = v; 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', {}); logEvent('tengu_china_login_success', {});
setOAuthStatus({ state: 'success' }); setOAuthStatus({ state: 'success' });
void onDone(); void onDone();
@ -1448,18 +1480,47 @@ function OAuthStatusMessage({
{ context: 'Confirmation' }, { 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 ( return (
<Box flexDirection="column" gap={1} marginTop={1}> <Box flexDirection="column" gap={1} marginTop={1}>
<Text bold> <Text bold>
{provider.icon} {provider.label} API Key {provider.icon} {provider.label} {isCustomModelEntry ? '— Custom Model' : 'API Key'}
</Text> </Text>
<Box flexDirection="column" gap={0}> <Box flexDirection="column" gap={0}>
<Text dimColor> Get your key: {provider.apiKeyPage}</Text> {isCustomModelEntry ? (
<Text dimColor> {provider.freeTier}</Text> <Text dimColor> Enter any model ID supported by this provider. Browse models: {provider.modelsPage}</Text>
<Text dimColor> Key format: {provider.keyFormat}</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>
<Box> <Box>
<Text>API Key: </Text> <Text>{isCustomModelEntry ? 'Model name: ' : 'API Key: '}</Text>
<TextInput <TextInput
value={chinaKeyValue} value={chinaKeyValue}
onChange={v => { onChange={v => {
@ -1470,12 +1531,28 @@ function OAuthStatusMessage({
cursorOffset={chinaKeyCursor} cursorOffset={chinaKeyCursor}
onChangeCursorOffset={setChinaKeyCursor} onChangeCursorOffset={setChinaKeyCursor}
columns={useTerminalSize().columns - 12} columns={useTerminalSize().columns - 12}
mask="*" mask={isCustomModelEntry ? undefined : '*'}
focus={true} focus={true}
/> />
</Box> </Box>
{chinaKeyError ? <Text color="error">{chinaKeyError}</Text> : null} {chinaKeyError ? <Text color="error">{chinaKeyError}</Text> : null}
<Text dimColor>Enter to confirm · Esc to go back</Text> {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> </Box>
); );
} }

View File

@ -29,6 +29,7 @@ export type ProviderPreset = {
icon: string icon: string
baseURL: string baseURL: string
apiKeyPage: string apiKeyPage: string
modelsPage: string
freeTier: string freeTier: string
keyFormat: string keyFormat: string
codingPlan?: { codingPlan?: {
@ -48,6 +49,7 @@ export const CHINA_LLM_PROVIDERS: ProviderPreset[] = [
icon: '\u{1F525}', icon: '\u{1F525}',
baseURL: 'https://api.deepseek.com', baseURL: 'https://api.deepseek.com',
apiKeyPage: 'https://platform.deepseek.com/api_keys', 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', freeTier: '5M tokens on signup (30 days), min top-up ¥10',
keyFormat: 'sk-...', keyFormat: 'sk-...',
models: [ models: [
@ -76,6 +78,7 @@ export const CHINA_LLM_PROVIDERS: ProviderPreset[] = [
icon: '\u{1F9E0}', icon: '\u{1F9E0}',
baseURL: 'https://open.bigmodel.cn/api/paas/v4', baseURL: 'https://open.bigmodel.cn/api/paas/v4',
apiKeyPage: 'https://open.bigmodel.cn/user/apiKeys', 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', freeTier: 'GLM-4.7-Flash / GLM-Z1-Flash free forever',
keyFormat: '{id}.{secret}', keyFormat: '{id}.{secret}',
codingPlan: { codingPlan: {
@ -141,6 +144,8 @@ export const CHINA_LLM_PROVIDERS: ProviderPreset[] = [
icon: '☁️', icon: '☁️',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKeyPage: 'https://bailian.console.aliyun.com', 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', freeTier: '90-day free tier for all models after activation',
keyFormat: 'sk-...', keyFormat: 'sk-...',
codingPlan: { codingPlan: {
@ -191,6 +196,7 @@ export const CHINA_LLM_PROVIDERS: ProviderPreset[] = [
icon: '\u{1F4F1}', icon: '\u{1F4F1}',
baseURL: 'https://api.xiaomimimo.com/v1', baseURL: 'https://api.xiaomimimo.com/v1',
apiKeyPage: 'https://platform.xiaomimimo.com/api-keys', apiKeyPage: 'https://platform.xiaomimimo.com/api-keys',
modelsPage: 'https://platform.xiaomimimo.com/models',
freeTier: 'Credits for new users, mimo-v2-flash low cost', freeTier: 'Credits for new users, mimo-v2-flash low cost',
keyFormat: 'sk-...', keyFormat: 'sk-...',
codingPlan: { codingPlan: {