mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 05:00:46 +00:00
feat: add auth entry: coding plan
This commit is contained in:
parent
169ad2d030
commit
b9dd080bd1
21 changed files with 721 additions and 447 deletions
|
|
@ -169,9 +169,9 @@ describe('AuthDialog', () => {
|
|||
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// Since the auth dialog shows API-KEY option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
expect(lastFrame()).toContain('OpenAI');
|
||||
expect(lastFrame()).toContain('API-KEY');
|
||||
});
|
||||
|
||||
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
|
||||
|
|
@ -257,15 +257,17 @@ describe('AuthDialog', () => {
|
|||
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// Since the auth dialog shows API-KEY option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
expect(lastFrame()).toContain('OpenAI');
|
||||
expect(lastFrame()).toContain('API-KEY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => {
|
||||
it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => {
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
|
||||
// QWEN_OAUTH is the only valid AuthType that can be selected via env var
|
||||
// API-KEY is not an AuthType enum value, so it cannot be selected this way
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.QWEN_OAUTH;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
|
|
@ -302,8 +304,8 @@ describe('AuthDialog', () => {
|
|||
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 2. OpenAI');
|
||||
// QWEN_OAUTH is the first option, so it should be selected
|
||||
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
||||
});
|
||||
|
||||
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Box, Text } from 'ink';
|
|||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { ApiKeyInput } from '../components/ApiKeyInput.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
|
|
@ -28,30 +29,54 @@ function parseDefaultAuthType(
|
|||
return null;
|
||||
}
|
||||
|
||||
// Sub-mode types for API-KEY authentication
|
||||
type ApiKeySubMode = 'coding-plan' | 'custom';
|
||||
|
||||
// View level for navigation
|
||||
type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info';
|
||||
|
||||
export function AuthDialog(): React.JSX.Element {
|
||||
const { pendingAuthType, authError } = useUIState();
|
||||
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||
const { handleAuthSelect: onAuthSelect, handleCodingPlanSubmit } =
|
||||
useUIActions();
|
||||
const config = useConfig();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [viewLevel, setViewLevel] = useState<ViewLevel>('main');
|
||||
const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState<number>(0);
|
||||
|
||||
const items = [
|
||||
// Main authentication entries
|
||||
const mainItems = [
|
||||
{
|
||||
key: AuthType.QWEN_OAUTH,
|
||||
label: t('Qwen OAuth'),
|
||||
value: AuthType.QWEN_OAUTH,
|
||||
},
|
||||
{
|
||||
key: AuthType.USE_OPENAI,
|
||||
label: t('OpenAI'),
|
||||
value: AuthType.USE_OPENAI,
|
||||
key: 'API-KEY',
|
||||
label: t('API-KEY'),
|
||||
value: 'API-KEY' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// API-KEY sub-mode entries
|
||||
const apiKeySubItems = [
|
||||
{
|
||||
key: 'coding-plan',
|
||||
label: t('Coding Plan'),
|
||||
value: 'coding-plan' as ApiKeySubMode,
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: t('Custom'),
|
||||
value: 'custom' as ApiKeySubMode,
|
||||
},
|
||||
];
|
||||
|
||||
const initialAuthIndex = Math.max(
|
||||
0,
|
||||
items.findIndex((item) => {
|
||||
mainItems.findIndex((item) => {
|
||||
// Priority 1: pendingAuthType
|
||||
if (pendingAuthType) {
|
||||
return item.value === pendingAuthType;
|
||||
|
|
@ -79,29 +104,75 @@ export function AuthDialog(): React.JSX.Element {
|
|||
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
|
||||
const currentSelectedAuthType =
|
||||
selectedIndex !== null
|
||||
? items[selectedIndex]?.value
|
||||
: items[initialAuthIndex]?.value;
|
||||
? mainItems[selectedIndex]?.value
|
||||
: mainItems[initialAuthIndex]?.value;
|
||||
|
||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||
const handleMainSelect = async (
|
||||
value: (typeof mainItems)[number]['value'],
|
||||
) => {
|
||||
setErrorMessage(null);
|
||||
await onAuthSelect(authMethod);
|
||||
|
||||
if (value === 'API-KEY') {
|
||||
// Navigate to API-KEY sub-mode selection
|
||||
setViewLevel('api-key-sub');
|
||||
return;
|
||||
}
|
||||
|
||||
// For Qwen OAuth, proceed directly
|
||||
await onAuthSelect(value);
|
||||
};
|
||||
|
||||
const handleHighlight = (authMethod: AuthType) => {
|
||||
const index = items.findIndex((item) => item.value === authMethod);
|
||||
setSelectedIndex(index);
|
||||
const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => {
|
||||
setErrorMessage(null);
|
||||
|
||||
if (subMode === 'coding-plan') {
|
||||
setViewLevel('api-key-input');
|
||||
} else {
|
||||
setViewLevel('custom-info');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyInputSubmit = async (apiKey: string) => {
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!apiKey.trim()) {
|
||||
setErrorMessage(t('API key cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit to parent for processing
|
||||
await handleCodingPlanSubmit(apiKey);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
setErrorMessage(null);
|
||||
|
||||
if (viewLevel === 'api-key-sub') {
|
||||
setViewLevel('main');
|
||||
} else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
|
||||
setViewLevel('api-key-sub');
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
// Prevent exit if there is an error message.
|
||||
// This means they user is not authenticated yet.
|
||||
// Handle Escape based on current view level
|
||||
if (viewLevel === 'api-key-sub') {
|
||||
handleGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
|
||||
handleGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// For main view, use existing logic
|
||||
if (errorMessage) {
|
||||
return;
|
||||
}
|
||||
if (config.getAuthType() === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
setErrorMessage(
|
||||
t(
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
||||
|
|
@ -115,6 +186,129 @@ export function AuthDialog(): React.JSX.Element {
|
|||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Render main auth selection
|
||||
const renderMainView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('How would you like to authenticate for this project?')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={mainItems}
|
||||
initialIndex={initialAuthIndex}
|
||||
onSelect={handleMainSelect}
|
||||
onHighlight={(value) => {
|
||||
const index = mainItems.findIndex((item) => item.value === value);
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render API-KEY sub-mode selection
|
||||
const renderApiKeySubView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Select API-KEY configuration mode:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={apiKeySubItems}
|
||||
initialIndex={apiKeySubModeIndex}
|
||||
onSelect={handleApiKeySubSelect}
|
||||
onHighlight={(value) => {
|
||||
const index = apiKeySubItems.findIndex(
|
||||
(item) => item.value === value,
|
||||
);
|
||||
setApiKeySubModeIndex(index);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>{t('(Press Escape to go back)')}</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render API key input for coding-plan mode
|
||||
const renderApiKeyInputView = () => (
|
||||
<Box marginTop={1}>
|
||||
<ApiKeyInput onSubmit={handleApiKeyInputSubmit} onCancel={handleGoBack} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Render custom mode info
|
||||
const renderCustomInfoView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text bold>{t('Custom API-KEY Configuration')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{t('For advanced users who want to configure models manually.')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please configure your models in settings.json:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
2.{' '}
|
||||
{t(
|
||||
"Add model configuration to modelProviders['openai'] (or other auth types)",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
3.{' '}
|
||||
{t(
|
||||
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
4.{' '}
|
||||
{t(
|
||||
'Use /model command to select your preferred model from the configured list',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
{t(
|
||||
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>{t('(Press Escape to go back)')}</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
const getViewTitle = () => {
|
||||
switch (viewLevel) {
|
||||
case 'main':
|
||||
return t('Get started');
|
||||
case 'api-key-sub':
|
||||
return t('API-KEY Configuration');
|
||||
case 'api-key-input':
|
||||
return t('Coding Plan Setup');
|
||||
case 'custom-info':
|
||||
return t('Custom Configuration');
|
||||
default:
|
||||
return t('Get started');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
|
|
@ -123,43 +317,47 @@ export function AuthDialog(): React.JSX.Element {
|
|||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Get started')}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('How would you like to authenticate for this project?')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialAuthIndex}
|
||||
onSelect={handleAuthSelect}
|
||||
onHighlight={handleHighlight}
|
||||
/>
|
||||
</Box>
|
||||
<Text bold>{getViewTitle()}</Text>
|
||||
|
||||
{viewLevel === 'main' && renderMainView()}
|
||||
{viewLevel === 'api-key-sub' && renderApiKeySubView()}
|
||||
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
|
||||
{viewLevel === 'custom-info' && renderCustomInfoView()}
|
||||
|
||||
{(authError || errorMessage) && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
|
||||
</Box>
|
||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
{t(
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{viewLevel === 'main' && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Use Enter to Set Auth)')}
|
||||
</Text>
|
||||
</Box>
|
||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
{t(
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{t('Terms of Services and Privacy Notice for Qwen Code')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
Config,
|
||||
ContentGeneratorConfig,
|
||||
ModelProvidersConfig,
|
||||
ProviderModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
|
|
@ -18,11 +19,20 @@ import {
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||
export interface OpenAICredentials {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}
|
||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
CODING_PLAN_TEMPLATE,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
} from '../../constants/codingPlanTemplates.js';
|
||||
|
||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
|
||||
|
|
@ -272,6 +282,123 @@ export const useAuthCommand = (
|
|||
setAuthError(null);
|
||||
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
|
||||
|
||||
/**
|
||||
* Handle coding plan submission - generates configs from template and stores api-key
|
||||
*/
|
||||
const handleCodingPlanSubmit = useCallback(
|
||||
async (apiKey: string) => {
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
setAuthError(null);
|
||||
|
||||
const envKeyName = CODING_PLAN_ENV_KEY;
|
||||
|
||||
// Get persist scope
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
|
||||
// Store api-key in settings.env
|
||||
settings.setValue(persistScope, `env.${envKeyName}`, apiKey);
|
||||
|
||||
// Sync to process.env immediately so refreshAuth can read the apiKey
|
||||
process.env[envKeyName] = apiKey;
|
||||
|
||||
// Generate model configs from template
|
||||
const newConfigs: ProviderModelConfig[] = CODING_PLAN_TEMPLATE.map(
|
||||
(templateConfig) => ({
|
||||
...templateConfig,
|
||||
envKey: envKeyName,
|
||||
}),
|
||||
);
|
||||
|
||||
// Get existing configs
|
||||
const existingConfigs =
|
||||
(
|
||||
settings.merged.modelProviders as ModelProvidersConfig | undefined
|
||||
)?.[AuthType.USE_OPENAI] || [];
|
||||
|
||||
// Deduplicate: check if config with same id, baseUrl, and envKey exists
|
||||
const isDuplicate = (config: ProviderModelConfig) =>
|
||||
existingConfigs.some(
|
||||
(existing) =>
|
||||
existing.id === config.id &&
|
||||
existing.baseUrl === config.baseUrl &&
|
||||
existing.envKey === config.envKey,
|
||||
);
|
||||
|
||||
// Filter out duplicates and replace existing ones
|
||||
const uniqueNewConfigs = newConfigs.filter(
|
||||
(config) => !isDuplicate(config),
|
||||
);
|
||||
|
||||
// Unshift new configs to the beginning
|
||||
const updatedConfigs = [...uniqueNewConfigs, ...existingConfigs];
|
||||
|
||||
// Persist to modelProviders
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`modelProviders.${AuthType.USE_OPENAI}`,
|
||||
updatedConfigs,
|
||||
);
|
||||
|
||||
// Also persist authType
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_OPENAI,
|
||||
);
|
||||
|
||||
// If there are configs, use the first one as the model
|
||||
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
|
||||
settings.setValue(persistScope, 'model.name', updatedConfigs[0].id);
|
||||
}
|
||||
|
||||
// Hot-reload model providers configuration before refreshAuth
|
||||
// This ensures ModelsConfig has the latest configuration from settings.json
|
||||
const updatedModelProviders: ModelProvidersConfig = {
|
||||
...(settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined),
|
||||
[AuthType.USE_OPENAI]: updatedConfigs,
|
||||
};
|
||||
config.reloadModelProvidersConfig(updatedModelProviders);
|
||||
|
||||
// Refresh auth with the new configuration
|
||||
await config.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
// Success handling
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
setIsAuthDialogOpen(false);
|
||||
setIsAuthenticating(false);
|
||||
|
||||
// Trigger UI refresh
|
||||
onAuthChange?.();
|
||||
|
||||
// Add success message
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Authenticated successfully with Coding Plan. API key is stored in settings.env.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Log success
|
||||
const authEvent = new AuthEvent(
|
||||
AuthType.USE_OPENAI,
|
||||
'coding-plan',
|
||||
'success',
|
||||
);
|
||||
logAuth(config, authEvent);
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
}
|
||||
},
|
||||
[settings, config, handleAuthFailure, addItem, onAuthChange],
|
||||
);
|
||||
|
||||
/**
|
||||
/**
|
||||
* We previously used a useEffect to trigger authentication automatically when
|
||||
|
|
@ -322,6 +449,7 @@ export const useAuthCommand = (
|
|||
pendingAuthType,
|
||||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
handleCodingPlanSubmit,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue