@@ -19,6 +19,7 @@ import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
1919import { openBrowser } from '../utils/browser.js' ;
2020import { logError } from '../utils/log.js' ;
2121import { getSettings_DEPRECATED , updateSettingsForSource } from '../utils/settings/settings.js' ;
22+ import { CHINA_LLM_PROVIDERS , type ProviderPreset , resolveChinaProviderBaseURL } from 'src/utils/chinaLlmProviders.js' ;
2223import { Select } from './CustomSelect/select.js' ;
2324import { Spinner } from './Spinner.js' ;
2425import 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