From 3b26d0e472f6cd1b505419bc5b6906af483e670a Mon Sep 17 00:00:00 2001 From: im10furry Date: Mon, 10 Aug 2026 16:14:57 +0800 Subject: [PATCH] feat(model-config): layer setup and env credentials --- .../ModelSelector/ModelSelectorView.tsx | 8 - .../flow/actions/saveConfiguration.ts | 7 +- .../flow/screens/ApiKeyScreen.tsx | 279 +++--------------- .../flow/screens/ConfirmationScreen.tsx | 61 +--- .../flow/screens/ContextLengthScreen.tsx | 12 +- .../flow/screens/ModelParamsScreen.tsx | 12 +- .../ModelSelector/useModelSelectorActions.tsx | 9 +- .../useModelSelectorController.tsx | 35 +-- .../ModelSelector/useModelSelectorInput.ts | 47 ++- .../useModelSelectorModelFlow.tsx | 132 +++++---- .../ModelSelector/useModelSelectorState.tsx | 23 +- .../useModelSelectorTextHandlers.ts | 48 +-- .../ui/components/ModelSelector/viewTypes.ts | 2 - packages/ai/src/internal/runtimeConfig.ts | 8 +- packages/ai/src/openai/completion.ts | 1 - packages/config/src/index.ts | 1 + packages/config/src/modelCredentials.ts | 46 +++ packages/config/src/modelYaml.ts | 62 ++-- packages/config/src/schema.ts | 8 + packages/core/src/ai/llm.ts | 11 +- packages/core/src/ai/llm/anthropic/client.ts | 11 +- packages/core/src/ai/llm/anthropic/native.ts | 14 +- .../core/src/ai/llm/openai/queryOpenAI.ts | 6 +- packages/core/src/ai/openai/completion.ts | 1 - packages/core/src/model/manager.ts | 1 + .../test/e2e/tui-interactions.misc.test.tsx | 24 +- .../src/test/unit/model-config-yaml.test.ts | 55 +++- .../test/unit/model-selector-actions.test.ts | 34 ++- 28 files changed, 415 insertions(+), 543 deletions(-) create mode 100644 packages/config/src/modelCredentials.ts diff --git a/apps/cli/src/ui/components/ModelSelector/ModelSelectorView.tsx b/apps/cli/src/ui/components/ModelSelector/ModelSelectorView.tsx index 91e63024..f60b0aa2 100644 --- a/apps/cli/src/ui/components/ModelSelector/ModelSelectorView.tsx +++ b/apps/cli/src/ui/components/ModelSelector/ModelSelectorView.tsx @@ -191,11 +191,8 @@ export function ModelSelectorView( handleApiKeyChange={props.handleApiKeyChange} handleApiKeySubmit={props.handleApiKeySubmit} handleCursorOffsetChange={props.handleCursorOffsetChange} - apiKeyCleanedNotification={props.apiKeyCleanedNotification} isLoadingModels={props.isLoadingModels} - providerBaseUrl={props.providerBaseUrl} modelLoadError={props.modelLoadError} - formatApiKeyDisplay={props.formatApiKeyDisplay} getProviderLabel={props.getProviderLabel} /> ) @@ -369,12 +366,7 @@ export function ModelSelectorView( ollamaBaseUrl={props.ollamaBaseUrl} customBaseUrl={props.customBaseUrl} apiKey={props.apiKey} - maxTokens={props.maxTokens} - contextLength={props.contextLength} - supportsReasoningEffort={props.supportsReasoningEffort} - reasoningEffort={props.reasoningEffort} validationError={props.validationError} - formatApiKeyDisplay={props.formatApiKeyDisplay} getProviderLabel={props.getProviderLabel} /> ) diff --git a/apps/cli/src/ui/components/ModelSelector/flow/actions/saveConfiguration.ts b/apps/cli/src/ui/components/ModelSelector/flow/actions/saveConfiguration.ts index 61eb82e8..60aea10d 100644 --- a/apps/cli/src/ui/components/ModelSelector/flow/actions/saveConfiguration.ts +++ b/apps/cli/src/ui/components/ModelSelector/flow/actions/saveConfiguration.ts @@ -12,7 +12,7 @@ type Params = { providerBaseUrl: string resourceName: string customBaseUrl: string - apiKey: string + apiKeyEnv?: string maxTokens: string contextLength: number reasoningEffort: any @@ -26,7 +26,7 @@ export async function saveModelConfiguration({ providerBaseUrl, resourceName, customBaseUrl, - apiKey, + apiKeyEnv, maxTokens, contextLength, reasoningEffort, @@ -68,7 +68,8 @@ export async function saveModelConfiguration({ provider: actualProvider, modelName: model || actualProvider, // Use provider name if no specific model baseURL: baseURL, - apiKey: apiKey || '', + apiKey: '', + ...(apiKeyEnv ? { apiKeyEnv } : {}), maxTokens: parseInt(maxTokens) || DEFAULT_MAX_TOKENS, contextLength: contextLength || DEFAULT_CONTEXT_LENGTH, reasoningEffort, diff --git a/apps/cli/src/ui/components/ModelSelector/flow/screens/ApiKeyScreen.tsx b/apps/cli/src/ui/components/ModelSelector/flow/screens/ApiKeyScreen.tsx index 7a13b64d..2ad03fc9 100644 --- a/apps/cli/src/ui/components/ModelSelector/flow/screens/ApiKeyScreen.tsx +++ b/apps/cli/src/ui/components/ModelSelector/flow/screens/ApiKeyScreen.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Box, Newline, Text } from 'ink' +import { Box, Text } from 'ink' import TextInput from '#ui-ink/components/TextInput' import { @@ -21,11 +21,8 @@ type Props = { handleApiKeyChange: (value: string) => void handleApiKeySubmit: (value: string) => void handleCursorOffsetChange: (offset: number) => void - apiKeyCleanedNotification: boolean isLoadingModels: boolean - providerBaseUrl: string modelLoadError: string | null - formatApiKeyDisplay: (key: string) => string getProviderLabel: (provider: string, modelCount: number) => string } @@ -43,271 +40,67 @@ export function ApiKeyScreen({ handleApiKeyChange, handleApiKeySubmit, handleCursorOffsetChange, - apiKeyCleanedNotification, isLoadingModels, - providerBaseUrl, modelLoadError, - formatApiKeyDisplay, getProviderLabel, }: Props) { - const modelTypeText = 'this model profile' - const apiKeyInputColumns = Math.max(1, Math.min(80, terminalColumns - 10)) - const descriptionWidth = Math.max(1, Math.min(70, terminalColumns - 10)) + const inputColumns = Math.max(1, Math.min(80, terminalColumns - 10)) const providerDisplayName = getProviderLabel(selectedProvider, 0).split( ' (', )[0] - const skipsValidation = - selectedProvider === 'minimax' || selectedProvider === 'minimax-coding' - - const providerHint = (() => { - if (tightLayout) return null - - if (selectedProvider === 'kimi') { - return ( - - Tip: Get your API key from:{' '} - - https://platform.moonshot.cn/console/api-keys - - - ) - } - if (selectedProvider === 'deepseek') { - return ( - - Tip: Get your API key from:{' '} - - https://platform.deepseek.com/api_keys - - - ) - } - if (selectedProvider === 'siliconflow') { - return ( - - Tip: Get your API key from:{' '} - - https://cloud.siliconflow.cn/i/oJWsm6io - - - ) - } - if (selectedProvider === 'qwen') { - return ( - - Tip: Get your API key from:{' '} - - https://bailian.console.aliyun.com/?tab=model#/api-key - - - ) - } - if (selectedProvider === 'glm') { - return ( - - Tip: Get your API key from:{' '} - - https://open.bigmodel.cn (API Keys section) - - - ) - } - if (selectedProvider === 'glm-coding') { - return ( - - Tip: This is for GLM Coding Plan API.{' '} - - Use the same API key as regular GLM - - - - Note: This uses a special endpoint for coding tasks. - - - ) - } - if (selectedProvider === 'minimax') { - return ( - - Tip: Get your API key from:{' '} - - https://www.minimax.io/platform/user-center/basic-information - - - ) - } - if (selectedProvider === 'minimax-coding') { - return ( - - Tip: Get your Coding Plan API key from:{' '} - - https://platform.minimaxi.com/user-center/payment/coding-plan - - - - Note: This requires a MiniMax Coding Plan subscription. - - - ) - } - if (selectedProvider === 'baidu-qianfan') { - return ( - - Tip: Get your API key from:{' '} - - https://console.bce.baidu.com/iam/#/iam/accesslist - - - ) - } - if (selectedProvider === 'openai') { - return ( - - Tip: Get your API key from:{' '} - - https://platform.openai.com/api-keys - - - ) - } - if (selectedProvider === 'anthropic') { - return ( - - Tip: Get your API key from your provider dashboard. - - ) - } - - return null - })() return ( - - Enter your {providerDisplayName} API key for {modelTypeText}: + + Environment variable for {providerDisplayName}: + + + Enter the variable name, not its secret value. Kode saves only this + reference and never shows the key here. - - {tightLayout ? ( - - Stored locally. Never sent to our servers. - - ) : ( - <> - - {compactLayout - ? `Stored locally and used to access ${selectedProvider}. Never sent to our servers.` - : `This key will be stored locally and used to access the ${selectedProvider} API. Your key is never sent to our servers.`} - - {providerHint ? {providerHint} : null} - - )} - - - - + - {apiKey && !tightLayout && ( - - - Key: {formatApiKeyDisplay(apiKey)} ({apiKey.length} chars) - - - )} - - - {apiKeyCleanedNotification && !tightLayout && ( - - - ✓ API key cleaned: trimmed whitespace - + {isLoadingModels ? ( + + Discovering available models… - )} + ) : null} - {!tightLayout && ( - - - - [Submit API Key] - - - {' '} - - Press Enter to{' '} - {skipsValidation ? 'continue' : 'validate and continue'} - + {modelLoadError ? ( + + + {modelLoadError} - - )} - - {isLoadingModels && ( - - Validating API key… - {!tightLayout && modelLoadError ? ( - {modelLoadError} - ) : null} - {!tightLayout && providerBaseUrl ? ( - Endpoint: {providerBaseUrl} - ) : null} - - )} - - {modelLoadError && !isLoadingModels && ( - - - Validation failed{tightLayout ? `: ${modelLoadError}` : ''} + + Correct the reference, or press Enter to configure the model ID + without discovery. - {!tightLayout ? ( - <> - {modelLoadError} - - Please check your API key and try again. - - - ) : null} - )} + ) : null} - - Press Enter to continue,{' '} - Tab to{' '} - {selectedProvider === 'anthropic' || - selectedProvider === 'kimi' || - selectedProvider === 'deepseek' || - selectedProvider === 'qwen' || - selectedProvider === 'glm' || - selectedProvider === 'glm-coding' || - selectedProvider === 'minimax' || - selectedProvider === 'minimax-coding' || - selectedProvider === 'baidu-qianfan' || - selectedProvider === 'siliconflow' || - selectedProvider === 'custom-openai' - ? 'skip to manual model input' - : 'skip using a key'} - , or Esc to go back + + Enter use reference & enter model ID · Tab discover models · Esc + back diff --git a/apps/cli/src/ui/components/ModelSelector/flow/screens/ConfirmationScreen.tsx b/apps/cli/src/ui/components/ModelSelector/flow/screens/ConfirmationScreen.tsx index 44587fbf..e79ebf68 100644 --- a/apps/cli/src/ui/components/ModelSelector/flow/screens/ConfirmationScreen.tsx +++ b/apps/cli/src/ui/components/ModelSelector/flow/screens/ConfirmationScreen.tsx @@ -1,7 +1,6 @@ import React from 'react' import { Box, Text } from 'ink' -import { CONTEXT_LENGTH_OPTIONS } from '../options' import { ScreenFrame, type ScreenExitState, @@ -20,12 +19,7 @@ type Props = { ollamaBaseUrl: string customBaseUrl: string apiKey: string - maxTokens: string - contextLength: number - supportsReasoningEffort: boolean - reasoningEffort: any validationError: string | null - formatApiKeyDisplay: (key: string) => string getProviderLabel: (provider: string, modelCount: number) => string } @@ -42,12 +36,7 @@ export function ConfirmationScreen({ ollamaBaseUrl, customBaseUrl, apiKey, - maxTokens, - contextLength, - supportsReasoningEffort, - reasoningEffort, validationError, - formatApiKeyDisplay, getProviderLabel, }: Props) { // Show model profile being created @@ -57,8 +46,7 @@ export function ConfirmationScreen({ ' (', )[0] - // Determine if provider requires API key - const showsApiKey = selectedProvider !== 'ollama' + const showsCredential = selectedProvider !== 'ollama' return ( - Confirm your model configuration: + Quick configuration / 快速配置 {!tightLayout && ( - Please review your selections before saving. + Review the provider, model, and credential reference before saving. )} @@ -117,50 +105,29 @@ export function ConfirmationScreen({ {selectedModel} - {showsApiKey && ( + {showsCredential && ( - API Key: + Credential: {apiKey - ? tightLayout - ? '(set)' - : formatApiKeyDisplay(apiKey) - : '(none)'} + ? `environment variable ${apiKey}` + : '(existing local credential)'} )} - {!tightLayout && maxTokens && ( - - Max Tokens: - {maxTokens} - - )} - - - Context Length: - - {CONTEXT_LENGTH_OPTIONS.find(opt => opt.value === contextLength) - ?.label || `${contextLength.toLocaleString()} tokens`} + {!tightLayout && ( + + Advanced controls appear only for values reported by model + discovery. Tool permissions are configured separately with + /permissions. - - - {!tightLayout && supportsReasoningEffort && ( - - Reasoning Effort: - {reasoningEffort} - - )} - - {compactLayout && tightLayout && maxTokens && ( - Max Tokens: {maxTokens} )} - - Press Esc to go back or{' '} - Enter to save configuration + + Enter save · A advanced settings · Esc back diff --git a/apps/cli/src/ui/components/ModelSelector/flow/screens/ContextLengthScreen.tsx b/apps/cli/src/ui/components/ModelSelector/flow/screens/ContextLengthScreen.tsx index 5a64a703..17e7871c 100644 --- a/apps/cli/src/ui/components/ModelSelector/flow/screens/ContextLengthScreen.tsx +++ b/apps/cli/src/ui/components/ModelSelector/flow/screens/ContextLengthScreen.tsx @@ -35,19 +35,19 @@ export function ContextLengthScreen({ return ( - Choose the context window length for your model: + Set the configured context window: {!tightLayout && ( {compactLayout - ? 'Controls how much history the model can process.' - : 'This determines how much conversation history and context the model can process at once. Higher values allow for longer conversations but may increase costs.'} + ? 'Used for local context management; it does not change provider limits.' + : 'Use only a verified provider limit. This value guides Kode context management; it cannot increase model capacity or estimate cost without provider pricing data.'} )} @@ -76,8 +76,8 @@ export function ContextLengthScreen({ )} - - ↑/↓ to select · Enter to continue · Esc to go back + + ↑/↓ select · Enter continue · Esc back diff --git a/apps/cli/src/ui/components/ModelSelector/flow/screens/ModelParamsScreen.tsx b/apps/cli/src/ui/components/ModelSelector/flow/screens/ModelParamsScreen.tsx index 5423908f..34a161c3 100644 --- a/apps/cli/src/ui/components/ModelSelector/flow/screens/ModelParamsScreen.tsx +++ b/apps/cli/src/ui/components/ModelSelector/flow/screens/ModelParamsScreen.tsx @@ -90,19 +90,19 @@ export function ModelParamsScreen({ return ( - Configure parameters for {selectedModel}: + Advanced settings for {selectedModel}: {!tightLayout && ( {compactLayout - ? 'Tab: next field · Enter: continue' - : 'Use Tab to navigate between fields. Press Enter to continue.'} + ? 'Output, reasoning, and compatibility settings.' + : 'These optional limits are applied only after the provider accepts them. Kode does not expose unsupported temperature or timeout controls here.'} )} @@ -209,7 +209,9 @@ export function ModelParamsScreen({ ))} - Tab: next · Enter: continue · Esc: back + + Tab next · Enter review · Esc back + diff --git a/apps/cli/src/ui/components/ModelSelector/useModelSelectorActions.tsx b/apps/cli/src/ui/components/ModelSelector/useModelSelectorActions.tsx index ab196b79..9c171b41 100644 --- a/apps/cli/src/ui/components/ModelSelector/useModelSelectorActions.tsx +++ b/apps/cli/src/ui/components/ModelSelector/useModelSelectorActions.tsx @@ -1,4 +1,5 @@ import type { ProviderType } from '#core/utils/config' +import { resolveModelApiKey } from '#core/utils/config' import { logError } from '#core/utils/log' import { runConnectionTestFlow } from './flow/actions/connectionTest' import { handleProviderSelection as handleProviderSelectionAction } from './flow/actions/providerSelection' @@ -33,7 +34,7 @@ export function useModelSelectorActions({ props, state, onDone }: Args) { providerBaseUrl: state.providerBaseUrl, resourceName: state.resourceName, customBaseUrl: state.customBaseUrl, - apiKey: state.apiKey, + apiKeyEnv: state.apiKey || undefined, maxTokens: state.maxTokens, contextLength: state.contextLength, reasoningEffort: state.reasoningEffort ?? undefined, @@ -104,7 +105,6 @@ export function useModelSelectorActions({ props, state, onDone }: Args) { state.setApiKeyEdited(false) state.setApiKey('') state.setCursorOffset(0) - state.setApiKeyCleanedNotification(false) state.setModelLoadError(null) state.setAvailableModels([]) state.setSelectedModel('') @@ -139,7 +139,10 @@ export function useModelSelectorActions({ props, state, onDone }: Args) { params: { selectedProvider: state.selectedProvider, selectedModel: state.selectedModel, - apiKey: state.apiKey, + apiKey: resolveModelApiKey({ + apiKey: '', + apiKeyEnv: state.apiKey || undefined, + }), maxTokens: state.maxTokens, providerBaseUrl: state.providerBaseUrl, customBaseUrl: state.customBaseUrl, diff --git a/apps/cli/src/ui/components/ModelSelector/useModelSelectorController.tsx b/apps/cli/src/ui/components/ModelSelector/useModelSelectorController.tsx index e4115dcb..42c5383a 100644 --- a/apps/cli/src/ui/components/ModelSelector/useModelSelectorController.tsx +++ b/apps/cli/src/ui/components/ModelSelector/useModelSelectorController.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo } from 'react' import { getTheme } from '#core/utils/theme' +import { suggestedApiKeyEnvironmentReference } from '#core/utils/config' import { useExitOnCtrlCD } from '#ui-ink/hooks/useExitOnCtrlCD' import { useCliExit } from '#ui-ink/hooks/useCliExit' import { useScreenLayout } from '#ui-ink/primitives/layout/useScreenLayout' @@ -13,29 +14,6 @@ import { useModelSelectorState } from './useModelSelectorState' import { useModelSelectorActions } from './useModelSelectorActions' import { useEscapeNavigation } from './flow/useEscapeNavigation' -function normalizeProviderForApiKeyEnvVar(provider: string): string { - // Some "coding plan" providers share auth with their base provider. - if (provider === 'glm-coding') return 'glm' - if (provider === 'minimax-coding') return 'minimax' - return provider -} - -function getApiKeyEnvVarNames(provider: string): string[] { - const normalizedProvider = normalizeProviderForApiKeyEnvVar(provider) - const sanitizedProvider = normalizedProvider.replace(/[^a-z0-9]/gi, '_') - const canonical = `${sanitizedProvider.toUpperCase()}_API_KEY` - const legacy = `${normalizedProvider.toUpperCase()}_API_KEY` - return canonical === legacy ? [canonical] : [canonical, legacy] -} - -function readApiKeyFromEnv(provider: string): string | undefined { - for (const envVarName of getApiKeyEnvVarNames(provider)) { - const value = process.env[envVarName] - if (value) return value - } - return undefined -} - function clampOptionIndex(next: number, length: number): number { if (length <= 0) return 0 return Math.max(0, Math.min(next, length - 1)) @@ -93,9 +71,11 @@ export function useModelSelectorController( if (props.initialModelProfile) return if (!state.apiKeyEdited && state.selectedProvider) { - const envValue = readApiKeyFromEnv(state.selectedProvider) ?? '' - state.setApiKey(envValue) - state.setCursorOffset(envValue.length) + const reference = suggestedApiKeyEnvironmentReference( + state.selectedProvider, + ) + state.setApiKey(reference) + state.setCursorOffset(reference.length) } }, [ state.apiKeyEdited, @@ -184,7 +164,6 @@ export function useModelSelectorController( codingPlanFocusIndex: state.codingPlanFocusIndex, setCodingPlanFocusIndex: state.setCodingPlanFocusIndex, selectedProvider: state.selectedProvider, - apiKey: state.apiKey, resourceName: state.resourceName, providerBaseUrl: state.providerBaseUrl, customBaseUrl: state.customBaseUrl, @@ -230,7 +209,6 @@ export function useModelSelectorController( handleApiKeyChange: actions.handleApiKeyChange, handleApiKeySubmit: actions.handleApiKeySubmit, handleCursorOffsetChange: actions.handleCursorOffsetChange, - apiKeyCleanedNotification: state.apiKeyCleanedNotification, isLoadingModels: state.isLoadingModels, modelLoadError: state.modelLoadError, providerBaseUrl: state.providerBaseUrl, @@ -297,7 +275,6 @@ export function useModelSelectorController( codingReservedLines: menus.codingReservedLines, onCodingPlanOptionPress, onCodingPlanOptionWheel, - formatApiKeyDisplay: actions.formatApiKeyDisplay, getProviderLabel: menus.getProviderLabel, } } diff --git a/apps/cli/src/ui/components/ModelSelector/useModelSelectorInput.ts b/apps/cli/src/ui/components/ModelSelector/useModelSelectorInput.ts index 9f66c3a3..3626e078 100644 --- a/apps/cli/src/ui/components/ModelSelector/useModelSelectorInput.ts +++ b/apps/cli/src/ui/components/ModelSelector/useModelSelectorInput.ts @@ -25,7 +25,8 @@ export function useModelSelectorInput(args: { setCodingPlanFocusIndex: (value: number | ((prev: number) => number)) => void selectedProvider: ProviderType - apiKey: string + /** Legacy plumbing for callers; this hook never reads credential values. */ + apiKey?: string resourceName: string providerBaseUrl: string customBaseUrl: string @@ -216,37 +217,29 @@ export function useModelSelectorInput(args: { } if (args.currentScreen === 'apiKey' && key.tab) { - if ( - args.selectedProvider === 'anthropic' || - args.selectedProvider === 'kimi' || - args.selectedProvider === 'deepseek' || - args.selectedProvider === 'qwen' || - args.selectedProvider === 'glm' || - args.selectedProvider === 'glm-coding' || - args.selectedProvider === 'minimax' || - args.selectedProvider === 'minimax-coding' || - args.selectedProvider === 'baidu-qianfan' || - args.selectedProvider === 'siliconflow' || - args.selectedProvider === 'custom-openai' - ) { - args.navigateTo('modelInput') - return true - } - void args.fetchModelsWithRetry().catch(error => { logError(error) }) return true } - if (args.currentScreen === 'confirmation' && key.return) { - void args.handleConfirmation().catch(error => { - logError(error) - args.setValidationError( - error instanceof Error ? error.message : 'Unexpected error occurred', - ) - }) - return true + if (args.currentScreen === 'confirmation') { + if (inputChar.toLowerCase() === 'a') { + args.setActiveFieldIndex(0) + args.navigateTo('modelParams') + return true + } + if (key.return) { + void args.handleConfirmation().catch(error => { + logError(error) + args.setValidationError( + error instanceof Error + ? error.message + : 'Unexpected error occurred', + ) + }) + return true + } } if (args.currentScreen === 'connectionTest') { @@ -310,7 +303,7 @@ export function useModelSelectorInput(args: { ((key.ctrl && input === 'v') || (key.meta && input === 'v')) ) { args.setModelLoadError( - "Please use your terminal's paste functionality or type the API key manually", + "Paste the environment variable name with your terminal's normal paste action.", ) return true } diff --git a/apps/cli/src/ui/components/ModelSelector/useModelSelectorModelFlow.tsx b/apps/cli/src/ui/components/ModelSelector/useModelSelectorModelFlow.tsx index d8ff2f54..299414de 100644 --- a/apps/cli/src/ui/components/ModelSelector/useModelSelectorModelFlow.tsx +++ b/apps/cli/src/ui/components/ModelSelector/useModelSelectorModelFlow.tsx @@ -10,6 +10,10 @@ import { import type { ModelInfo } from './flow/types' import * as modelFetchers from './flow/modelFetchers' import { logError } from '#core/utils/log' +import { + resolveModelApiKey, + validateApiKeyEnvironmentReference, +} from '#core/utils/config' import { fetchOllamaModels } from './fetchOllamaModels' import type { ModelSelectorState } from './useModelSelectorState' import type { ModelParamsField } from './viewTypes' @@ -25,9 +29,19 @@ export function useModelSelectorModelFlow(state: ModelSelectorState) { } async function fetchModels(): Promise { + const apiKey = resolveModelApiKey({ + apiKey: '', + apiKeyEnv: state.apiKey || undefined, + }) + if (!apiKey && state.selectedProvider !== 'ollama') { + throw new Error( + `Environment variable '${state.apiKey || 'API_KEY'}' is not set in this shell. Set it, or press Enter to enter a model ID manually.`, + ) + } + return await fetchModelsForProvider({ selectedProvider: state.selectedProvider, - apiKey: state.apiKey, + apiKey, providerBaseUrl: state.providerBaseUrl, customBaseUrl: state.customBaseUrl, modelFetchers, @@ -76,40 +90,24 @@ export function useModelSelectorModelFlow(state: ModelSelectorState) { const errorMessage = summarizeErrorMessage(lastError?.message || '') state.setModelLoadError( - `Failed to validate API key after ${MAX_RETRIES} attempts: ${errorMessage}`, + `Model discovery could not use the credential reference after ${MAX_RETRIES} attempts: ${errorMessage}`, ) - throw new Error(`API key validation failed: ${errorMessage}`) + throw new Error(`Model discovery failed: ${errorMessage}`) } async function handleApiKeySubmit(key: string) { - const cleanedKey = key.replace(/\s+/g, '').trim() - state.setApiKey(cleanedKey) - state.setModelLoadError(null) - - if (state.selectedProvider === 'azure') { - state.navigateTo('resourceName') - return - } - - if ( - state.selectedProvider === 'minimax' || - state.selectedProvider === 'minimax-coding' - ) { - state.navigateTo('modelInput') + const reference = key.trim() + const validationError = validateApiKeyEnvironmentReference(reference) + if (validationError) { + state.setModelLoadError(validationError) return } - try { - state.setIsLoadingModels(true) - const models = await fetchModelsWithRetry() - if (models.length === 0) { - state.navigateTo('modelInput') - } - } catch (error) { - logError(error) - } finally { - state.setIsLoadingModels(false) - } + state.setApiKey(reference) + state.setModelLoadError(null) + state.navigateTo( + state.selectedProvider === 'azure' ? 'resourceName' : 'modelInput', + ) } function handleResourceNameSubmit(name: string) { @@ -149,6 +147,8 @@ export function useModelSelectorModelFlow(state: ModelSelectorState) { state.setCustomModelName(model) state.setSelectedModel(model) state.setSupportsReasoningEffort(false) + state.setSupportsMaxTokens(false) + state.setSupportsContextLength(false) state.setReasoningEffort(null) state.setMaxTokensMode('preset') @@ -156,8 +156,7 @@ export function useModelSelectorModelFlow(state: ModelSelectorState) { state.setMaxTokens(DEFAULT_MAX_TOKENS.toString()) state.setMaxTokensCursorOffset(DEFAULT_MAX_TOKENS.toString().length) - state.navigateTo('modelParams') - state.setActiveFieldIndex(0) + state.navigateTo('confirmation') } function handleModelSelection(model: string) { @@ -172,23 +171,39 @@ export function useModelSelectorModelFlow(state: ModelSelectorState) { state.setReasoningEffort(null) } - state.setContextLength(modelInfo?.context_length ?? DEFAULT_CONTEXT_LENGTH) + const modelContextLength = modelInfo?.context_length + const hasReportedContextLength = + typeof modelContextLength === 'number' && + Number.isFinite(modelContextLength) && + modelContextLength > 0 + state.setSupportsContextLength(hasReportedContextLength) + state.setContextLength( + hasReportedContextLength + ? (modelContextLength as number) + : DEFAULT_CONTEXT_LENGTH, + ) const modelMaxTokens = modelInfo?.max_tokens - if (typeof modelMaxTokens === 'number' && Number.isFinite(modelMaxTokens)) { + const hasReportedMaxTokens = + typeof modelMaxTokens === 'number' && + Number.isFinite(modelMaxTokens) && + modelMaxTokens > 0 + state.setSupportsMaxTokens(hasReportedMaxTokens) + if (hasReportedMaxTokens) { + const reportedMaxTokens = modelMaxTokens as number const matchingPreset = MAX_TOKENS_OPTIONS.find( - option => option.value === modelMaxTokens, + option => option.value === reportedMaxTokens, ) if (matchingPreset) { state.setMaxTokensMode('preset') - state.setSelectedMaxTokensPreset(modelMaxTokens) - state.setMaxTokens(modelMaxTokens.toString()) + state.setSelectedMaxTokensPreset(reportedMaxTokens) + state.setMaxTokens(reportedMaxTokens.toString()) } else { state.setMaxTokensMode('custom') - state.setMaxTokens(modelMaxTokens.toString()) + state.setMaxTokens(reportedMaxTokens.toString()) } - state.setMaxTokensCursorOffset(modelMaxTokens.toString().length) + state.setMaxTokensCursorOffset(reportedMaxTokens.toString().length) } else { state.setMaxTokensMode('preset') state.setSelectedMaxTokensPreset(DEFAULT_MAX_TOKENS) @@ -196,28 +211,32 @@ export function useModelSelectorModelFlow(state: ModelSelectorState) { state.setMaxTokensCursorOffset(DEFAULT_MAX_TOKENS.toString().length) } - state.navigateTo('modelParams') - state.setActiveFieldIndex(0) + state.navigateTo('confirmation') } const handleModelParamsSubmit = () => { - state.navigateTo('contextLength') + state.navigateTo( + state.supportsContextLength ? 'contextLength' : 'confirmation', + ) } const getFormFieldsForModelParams = (): ModelParamsField[] => { - const fields: ModelParamsField[] = [ - { + const fields: ModelParamsField[] = [] + + if (state.supportsMaxTokens) { + fields.push({ name: 'maxTokens', - label: 'Maximum Tokens', - description: 'Select the maximum number of tokens to generate.', + label: 'Maximum output (tokens)', + description: + 'Upper limit for one response. The provider can enforce a lower limit.', component: 'select', options: MAX_TOKENS_OPTIONS.map(option => ({ label: option.label, value: option.value.toString(), })), defaultValue: state.maxTokens, - }, - ] + }) + } if (state.supportsReasoningEffort) { fields.push({ @@ -228,22 +247,11 @@ export function useModelSelectorModelFlow(state: ModelSelectorState) { }) } - if (state.selectedModel.toLowerCase().includes('claude')) { - fields.push({ - name: 'requestStrategy', - label: 'Request Strategy', - description: - 'Choose how Kode should try compatibility request profiles if a provider blocks third-party clients.', - component: 'select', - options: REQUEST_STRATEGY_OPTIONS.map(option => ({ - label: option.label, - value: option.value, - })), - defaultValue: state.requestStrategy, - }) - } - - fields.push({ name: 'submit', label: 'Continue →', component: 'button' }) + fields.push({ + name: 'submit', + label: 'Review setup →', + component: 'button', + }) return fields } diff --git a/apps/cli/src/ui/components/ModelSelector/useModelSelectorState.tsx b/apps/cli/src/ui/components/ModelSelector/useModelSelectorState.tsx index 12d939fe..7e578ec9 100644 --- a/apps/cli/src/ui/components/ModelSelector/useModelSelectorState.tsx +++ b/apps/cli/src/ui/components/ModelSelector/useModelSelectorState.tsx @@ -45,7 +45,7 @@ export function useModelSelectorState(opts: { const [screenStack, setScreenStack] = useState(() => initialModelProfile - ? ['modelParams'] + ? ['confirmation'] : opts.initialProvider ? ['apiKey'] : createInitialScreenStack({ skipModelType: opts.skipModelType }), @@ -66,7 +66,7 @@ export function useModelSelectorState(opts: { initialModelProfile?.modelName ?? '', ) const [apiKey, setApiKey] = useState( - initialModelProfile?.apiKey ?? '', + initialModelProfile?.apiKeyEnv ?? '', ) const [maxTokens, setMaxTokens] = useState( @@ -79,11 +79,13 @@ export function useModelSelectorState(opts: { useState(initialMaxTokens) const [reasoningEffort, setReasoningEffort] = useState( - (initialModelProfile?.reasoningEffort as ReasoningEffortOption) ?? - 'medium', + (initialModelProfile?.reasoningEffort as ReasoningEffortOption) ?? null, ) const [supportsReasoningEffort, setSupportsReasoningEffort] = - useState(Boolean(initialModelProfile?.reasoningEffort)) + useState(false) + const [supportsMaxTokens, setSupportsMaxTokens] = useState(false) + const [supportsContextLength, setSupportsContextLength] = + useState(false) const [contextLength, setContextLength] = useState(initialContextLength) @@ -110,9 +112,6 @@ export function useModelSelectorState(opts: { initialMaxTokens.toString().length, ) - const [apiKeyCleanedNotification, setApiKeyCleanedNotification] = - useState(false) - const [availableModels, setAvailableModels] = useState([]) const [isLoadingModels, setIsLoadingModels] = useState(false) const [modelLoadError, setModelLoadError] = useState(null) @@ -120,7 +119,7 @@ export function useModelSelectorState(opts: { const [modelSearchCursorOffset, setModelSearchCursorOffset] = useState(0) const [cursorOffset, setCursorOffset] = useState( - initialModelProfile?.apiKey?.length ?? 0, + initialModelProfile?.apiKeyEnv?.length ?? 0, ) const [apiKeyEdited, setApiKeyEdited] = useState( Boolean(initialModelProfile), @@ -203,6 +202,10 @@ export function useModelSelectorState(opts: { setReasoningEffort, supportsReasoningEffort, setSupportsReasoningEffort, + supportsMaxTokens, + setSupportsMaxTokens, + supportsContextLength, + setSupportsContextLength, contextLength, contextLengthOptions, setContextLength, @@ -212,8 +215,6 @@ export function useModelSelectorState(opts: { setActiveFieldIndex, maxTokensCursorOffset, setMaxTokensCursorOffset, - apiKeyCleanedNotification, - setApiKeyCleanedNotification, availableModels, setAvailableModels, isLoadingModels, diff --git a/apps/cli/src/ui/components/ModelSelector/useModelSelectorTextHandlers.ts b/apps/cli/src/ui/components/ModelSelector/useModelSelectorTextHandlers.ts index 39f31b9e..62a4fe88 100644 --- a/apps/cli/src/ui/components/ModelSelector/useModelSelectorTextHandlers.ts +++ b/apps/cli/src/ui/components/ModelSelector/useModelSelectorTextHandlers.ts @@ -1,55 +1,16 @@ import type { ModelSelectorState } from './useModelSelectorState' -import { useCallback, useEffect, useRef } from 'react' export function useModelSelectorTextHandlers(state: ModelSelectorState) { - const cleanedNotificationTimeoutRef = useRef | null>(null) - - useEffect(() => { - return () => { - if (cleanedNotificationTimeoutRef.current) { - clearTimeout(cleanedNotificationTimeoutRef.current) - } - } - }, []) - - const scheduleCleanedNotificationClear = useCallback(() => { - if (cleanedNotificationTimeoutRef.current) { - clearTimeout(cleanedNotificationTimeoutRef.current) - } - cleanedNotificationTimeoutRef.current = setTimeout(() => { - cleanedNotificationTimeoutRef.current = null - state.setApiKeyCleanedNotification(false) - }, 3000) - }, [state.setApiKeyCleanedNotification]) - function handleCursorOffsetChange(offset: number) { state.setCursorOffset(offset) } - function formatApiKeyDisplay(key: string): string { - if (!key) return '' - if (key.length <= 10) return '*'.repeat(key.length) - - const prefix = key.slice(0, 4) - const suffix = key.slice(-4) - return `${prefix}***${suffix}` - } - function handleApiKeyChange(value: string) { state.setApiKeyEdited(true) - // API keys should not contain whitespace. Remove spaces/newlines introduced by - // terminal wrapping, copy/paste formatting, or legacy paste behavior. - const cleanedValue = value.replace(/\s+/g, '').trim() - - if (value !== cleanedValue && value.length > 0) { - state.setApiKeyCleanedNotification(true) - scheduleCleanedNotificationClear() - } - - state.setApiKey(cleanedValue) - state.setCursorOffset(cleanedValue.length) + // This input stores only an environment-variable name. Keep the user's text + // intact so validation can explain invalid names instead of silently changing it. + state.setApiKey(value) + state.setCursorOffset(value.length) } function handleModelSearchChange(value: string) { @@ -63,7 +24,6 @@ export function useModelSelectorTextHandlers(state: ModelSelectorState) { return { handleCursorOffsetChange, - formatApiKeyDisplay, handleApiKeyChange, handleModelSearchChange, handleModelSearchCursorOffsetChange, diff --git a/apps/cli/src/ui/components/ModelSelector/viewTypes.ts b/apps/cli/src/ui/components/ModelSelector/viewTypes.ts index 1cd81f55..74b7f826 100644 --- a/apps/cli/src/ui/components/ModelSelector/viewTypes.ts +++ b/apps/cli/src/ui/components/ModelSelector/viewTypes.ts @@ -46,7 +46,6 @@ export type ModelSelectorViewProps = { handleApiKeyChange: (value: string) => void handleApiKeySubmit: (key: string) => void | Promise handleCursorOffsetChange: (offset: number) => void - apiKeyCleanedNotification: boolean isLoadingModels: boolean modelLoadError: string | null @@ -130,6 +129,5 @@ export type ModelSelectorViewProps = { onCodingPlanOptionPress: (optionIndex: number) => void onCodingPlanOptionWheel: (direction: 'up' | 'down') => void - formatApiKeyDisplay: (key: string) => string getProviderLabel: (provider: string, modelCount: number) => string } diff --git a/packages/ai/src/internal/runtimeConfig.ts b/packages/ai/src/internal/runtimeConfig.ts index 238494df..97dda7ef 100644 --- a/packages/ai/src/internal/runtimeConfig.ts +++ b/packages/ai/src/internal/runtimeConfig.ts @@ -9,6 +9,7 @@ export type AiModelProfileLike = { provider?: string baseURL?: string apiKey?: string + apiKeyEnv?: string reasoningEffort?: string [key: string]: unknown } @@ -83,7 +84,12 @@ export function getAiStream(): boolean { export function getAiMainModelProfile(): AiModelProfileLike | null { try { - return getMainModelProfileImpl() ?? null + const profile = getMainModelProfileImpl() ?? null + if (!profile?.apiKeyEnv) return profile + return { + ...profile, + apiKey: process.env[profile.apiKeyEnv]?.trim() || '', + } } catch { return null } diff --git a/packages/ai/src/openai/completion.ts b/packages/ai/src/openai/completion.ts index a5180e43..99c23ef6 100644 --- a/packages/ai/src/openai/completion.ts +++ b/packages/ai/src/openai/completion.ts @@ -231,7 +231,6 @@ export async function getCompletionWithProfile( model: opts.model, provider, apiKeyConfigured: !!apiKey, - apiKeyPrefix: apiKey ? apiKey.substring(0, 8) : null, maxTokens: opts.max_tokens, temperature: opts.temperature, messageCount: opts.messages?.length || 0, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 62f927ee..6ff53a41 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -9,6 +9,7 @@ export * from './sources' export * from './files' export * from './local' export * from './modelYaml' +export * from './modelCredentials' export * from './paths' export * from './dataRoots' export * from './errors' diff --git a/packages/config/src/modelCredentials.ts b/packages/config/src/modelCredentials.ts new file mode 100644 index 00000000..d97142c8 --- /dev/null +++ b/packages/config/src/modelCredentials.ts @@ -0,0 +1,46 @@ +import type { ModelProfile } from './schema' + +const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Validate a reference without resolving its value. This is safe to use in UI + * flows because it never reads or displays the secret itself. + */ +export function validateApiKeyEnvironmentReference( + value: string, +): string | null { + const trimmed = value.trim() + if (!trimmed) return 'Enter the name of an environment variable.' + if (!ENVIRONMENT_VARIABLE_NAME.test(trimmed)) { + return 'Use letters, digits, and underscores; the name cannot start with a digit.' + } + return null +} + +export function suggestedApiKeyEnvironmentReference(provider: string): string { + const normalizedProvider = + provider === 'glm-coding' + ? 'glm' + : provider === 'minimax-coding' + ? 'minimax' + : provider + const sanitizedProvider = normalizedProvider.replace(/[^a-z0-9]/gi, '_') + return `${sanitizedProvider.toUpperCase()}_API_KEY` +} + +/** + * Resolve credentials only at a request boundary. Configuration UIs retain and + * display the reference, never the value returned here. + */ +export function resolveModelApiKey( + profile: Pick, +): string { + const envName = profile.apiKeyEnv?.trim() + if (envName) return process.env[envName]?.trim() || '' + return profile.apiKey +} + +export function withResolvedModelApiKey(profile: ModelProfile): ModelProfile { + const apiKey = resolveModelApiKey(profile) + return apiKey === profile.apiKey ? profile : { ...profile, apiKey } +} diff --git a/packages/config/src/modelYaml.ts b/packages/config/src/modelYaml.ts index cc5eeff8..f92dc9c3 100644 --- a/packages/config/src/modelYaml.ts +++ b/packages/config/src/modelYaml.ts @@ -2,6 +2,7 @@ import { dump, load } from 'js-yaml' import { z } from 'zod' import type { GlobalConfig, ModelPointers, ModelProfile } from './schema' +import { suggestedApiKeyEnvironmentReference } from './modelCredentials' const ApiKeySpecSchema = z.union([ z @@ -69,54 +70,43 @@ const ModelConfigYamlSchema = z export type ModelConfigYaml = z.infer -function suggestedApiKeyEnvForProvider(provider: string): string | undefined { - switch (provider) { - case 'anthropic': - return 'ANTHROPIC_API_KEY' - case 'openai': - case 'custom-openai': - return 'OPENAI_API_KEY' - case 'openrouter': - return 'OPENROUTER_API_KEY' - case 'azure': - return 'AZURE_OPENAI_API_KEY' - case 'gemini': - return 'GEMINI_API_KEY' - default: - return undefined - } -} - -function resolveApiKeyFromYaml( +function resolveCredentialReferenceFromYaml( input: { apiKey?: ApiKeySpec apiKeyEnv?: string }, existingApiKey: string | undefined, -): { apiKey: string; warnings: string[] } { + existingApiKeyEnv: string | undefined, +): { apiKey: string; apiKeyEnv?: string; warnings: string[] } { const warnings: string[] = [] if (input.apiKeyEnv) { - const envValue = process.env[input.apiKeyEnv] - if (envValue) return { apiKey: envValue, warnings } - if (existingApiKey) return { apiKey: existingApiKey, warnings } - warnings.push(`Missing env var '${input.apiKeyEnv}' for apiKey`) - return { apiKey: '', warnings } + return { + apiKey: existingApiKey ?? '', + apiKeyEnv: input.apiKeyEnv, + warnings, + } } if (input.apiKey && 'fromEnv' in input.apiKey) { - const envValue = process.env[input.apiKey.fromEnv] - if (envValue) return { apiKey: envValue, warnings } - if (existingApiKey) return { apiKey: existingApiKey, warnings } - warnings.push(`Missing env var '${input.apiKey.fromEnv}' for apiKey`) - return { apiKey: '', warnings } + return { + apiKey: existingApiKey ?? '', + apiKeyEnv: input.apiKey.fromEnv, + warnings, + } } if (input.apiKey && 'value' in input.apiKey) { return { apiKey: input.apiKey.value, warnings } } - if (existingApiKey) return { apiKey: existingApiKey, warnings } + if (existingApiKey || existingApiKeyEnv) { + return { + apiKey: existingApiKey ?? '', + ...(existingApiKeyEnv ? { apiKeyEnv: existingApiKeyEnv } : {}), + warnings, + } + } warnings.push( 'Missing apiKey (set apiKey.fromEnv, apiKeyEnv, or apiKey.value)', @@ -145,7 +135,6 @@ export function formatModelConfigYamlForSharing(config: GlobalConfig): string { const exported: ModelConfigYaml = { version: 1, profiles: modelProfiles.map(p => { - const suggestedEnv = suggestedApiKeyEnvForProvider(p.provider) return { name: p.name, provider: p.provider, @@ -158,7 +147,10 @@ export function formatModelConfigYamlForSharing(config: GlobalConfig): string { isActive: p.isActive, createdAt: p.createdAt, ...(typeof p.lastUsed === 'number' ? { lastUsed: p.lastUsed } : {}), - apiKey: { fromEnv: suggestedEnv ?? 'API_KEY' }, + apiKey: { + fromEnv: + p.apiKeyEnv ?? suggestedApiKeyEnvironmentReference(p.provider), + }, } }), ...(pointers ? { pointers } : {}), @@ -186,9 +178,10 @@ export function applyModelConfigYamlImport( const now = Date.now() const importedProfiles: ModelProfile[] = parsed.profiles.map(profile => { const existing = existingByModelName.get(profile.modelName) - const resolved = resolveApiKeyFromYaml( + const resolved = resolveCredentialReferenceFromYaml( { apiKey: profile.apiKey, apiKeyEnv: profile.apiKeyEnv }, existing?.apiKey, + existing?.apiKeyEnv, ) warnings.push(...resolved.warnings.map(w => `[${profile.modelName}] ${w}`)) @@ -198,6 +191,7 @@ export function applyModelConfigYamlImport( modelName: profile.modelName, ...(profile.baseURL ? { baseURL: profile.baseURL } : {}), apiKey: resolved.apiKey, + ...(resolved.apiKeyEnv ? { apiKeyEnv: resolved.apiKeyEnv } : {}), maxTokens: profile.maxTokens, contextLength: profile.contextLength, ...(profile.reasoningEffort diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index da028ccb..82706a22 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -4,11 +4,13 @@ export type ThemeNames = // Light themes | 'light' | 'light-daltonized' + | 'high-contrast-light' | 'solarized-light' | 'github-light' // Dark themes | 'dark' | 'dark-daltonized' + | 'high-contrast-dark' | 'dracula' | 'nord' | 'monokai' @@ -177,6 +179,12 @@ export type ModelProfile = { provider: ProviderType modelName: string baseURL?: string + /** Name of the environment variable that supplies the credential at request time. */ + apiKeyEnv?: string + /** + * Legacy local credential. New interactive setup stores an apiKeyEnv instead; + * keep this field for existing configurations and non-interactive integrations. + */ apiKey: string maxTokens: number contextLength: number diff --git a/packages/core/src/ai/llm.ts b/packages/core/src/ai/llm.ts index 9b0bb998..b39f81ae 100644 --- a/packages/core/src/ai/llm.ts +++ b/packages/core/src/ai/llm.ts @@ -5,7 +5,11 @@ import type { AssistantMessage, UserMessage } from '#core/query' import { resolveToolDescription, type Tool } from '#core/tooling/Tool' import { queryOpenAI } from '#core/ai/llm/openai' import { queryAnthropicNative } from '#core/ai/llm/anthropic' -import { getGlobalConfig, type ModelProfile } from '#core/utils/config' +import { + getGlobalConfig, + withResolvedModelApiKey, + type ModelProfile, +} from '#core/utils/config' import { withVCR } from '#core/services/vcr' import { debug as debugLogger, @@ -313,11 +317,12 @@ export async function queryLLM( delete cleanOptions.__testQueryLLMWithPromptCaching const executeQueryWithProfile = (profile: ModelProfile) => { + const requestProfile = withResolvedModelApiKey(profile) const runQuery = () => queryFn(messages, systemPrompt, maxThinkingTokens, tools, signal, { ...cleanOptions, - model: profile.modelName, - modelProfile: profile, + model: requestProfile.modelName, + modelProfile: requestProfile, toolUseContext, }) diff --git a/packages/core/src/ai/llm/anthropic/client.ts b/packages/core/src/ai/llm/anthropic/client.ts index cf07e684..e1d70ab2 100644 --- a/packages/core/src/ai/llm/anthropic/client.ts +++ b/packages/core/src/ai/llm/anthropic/client.ts @@ -3,7 +3,11 @@ import { AnthropicBedrock } from '@anthropic-ai/bedrock-sdk' import { AnthropicVertex } from '@anthropic-ai/vertex-sdk' import chalk from 'chalk' -import { getAnthropicApiKey, getGlobalConfig } from '#core/utils/config' +import { + getAnthropicApiKey, + getGlobalConfig, + withResolvedModelApiKey, +} from '#core/utils/config' import { USER_AGENT } from '#core/utils/http' import { buildCompatHeaders, @@ -44,7 +48,10 @@ export function getAnthropicClient( const region = getVertexRegionForModel(model) const modelManager = getModelManager() - const modelProfile = modelManager.getModel('main') + const configuredProfile = modelManager.getModel('main') + const modelProfile = configuredProfile + ? withResolvedModelApiKey(configuredProfile) + : null const defaultHeaders: { [key: string]: string } = requestHeadersProfile === 'compat' diff --git a/packages/core/src/ai/llm/anthropic/native.ts b/packages/core/src/ai/llm/anthropic/native.ts index 3fc514b3..93ad5d42 100644 --- a/packages/core/src/ai/llm/anthropic/native.ts +++ b/packages/core/src/ai/llm/anthropic/native.ts @@ -13,7 +13,11 @@ import { zodToJsonSchema } from 'zod-to-json-schema' import { getCLISyspromptPrefix } from '#core/constants/prompts' import type { AssistantMessage, UserMessage } from '#core/query' import type { Tool, ToolUseContext } from '#core/tooling/Tool' -import { getGlobalConfig, type ModelProfile } from '#core/utils/config' +import { + getGlobalConfig, + withResolvedModelApiKey, + type ModelProfile, +} from '#core/utils/config' import { USER_AGENT } from '#core/utils/http' import { buildCompatHeaders, @@ -69,9 +73,6 @@ function traceAnthropicQuery(args: { model: args.model, provider: args.provider, apiKeyConfigured: !!args.modelProfile?.apiKey, - apiKeyPrefix: args.modelProfile?.apiKey - ? args.modelProfile.apiKey.substring(0, 8) - : null, maxTokens: args.params.max_tokens, temperature: args.temperature ?? MAIN_QUERY_TEMPERATURE, messageCount: args.params.messages?.length || 0, @@ -141,8 +142,11 @@ export async function queryAnthropicNative( const assistantStreamRequestId = toolUseContext?.requestId ?? getCurrentRequest()?.id ?? nanoid() - const modelProfile = + const configuredProfile = options?.modelProfile ?? getModelManager().getModel('main') + const modelProfile = configuredProfile + ? withResolvedModelApiKey(configuredProfile) + : null let anthropic: Anthropic | AnthropicBedrock | AnthropicVertex let model: string let provider: string diff --git a/packages/core/src/ai/llm/openai/queryOpenAI.ts b/packages/core/src/ai/llm/openai/queryOpenAI.ts index 8253d95b..abd185f5 100644 --- a/packages/core/src/ai/llm/openai/queryOpenAI.ts +++ b/packages/core/src/ai/llm/openai/queryOpenAI.ts @@ -10,6 +10,7 @@ import { getGlobalConfig, MODEL_COSTS, resolveModelCostTier, + withResolvedModelApiKey, } from '#core/utils/config' import { getModelManager } from '#core/utils/model' import { @@ -112,8 +113,11 @@ export async function queryOpenAI( const config = getGlobalConfig() const toolUseContext = options?.toolUseContext - const modelProfile = + const configuredProfile = options?.modelProfile ?? getModelManager().getModel('main') + const modelProfile = configuredProfile + ? withResolvedModelApiKey(configuredProfile) + : null let model: string // 🔍 Debug: 记录模型配置详情 diff --git a/packages/core/src/ai/openai/completion.ts b/packages/core/src/ai/openai/completion.ts index b5700a3f..05c44652 100644 --- a/packages/core/src/ai/openai/completion.ts +++ b/packages/core/src/ai/openai/completion.ts @@ -231,7 +231,6 @@ export async function getCompletionWithProfile( model: opts.model, provider, apiKeyConfigured: !!apiKey, - apiKeyPrefix: apiKey ? apiKey.substring(0, 8) : null, maxTokens: opts.max_tokens, temperature: opts.temperature, messageCount: opts.messages?.length || 0, diff --git a/packages/core/src/model/manager.ts b/packages/core/src/model/manager.ts index 70249a5b..bc8b5934 100644 --- a/packages/core/src/model/manager.ts +++ b/packages/core/src/model/manager.ts @@ -265,6 +265,7 @@ export class ModelManager { ...existing, ...config, apiKey: config.apiKey || existing.apiKey, + apiKeyEnv: config.apiKeyEnv ?? existing.apiKeyEnv, reasoningEffort: config.reasoningEffort ?? existing.reasoningEffort, createdAt: existing.createdAt, lastUsed: existing.lastUsed, diff --git a/packages/core/src/test/e2e/tui-interactions.misc.test.tsx b/packages/core/src/test/e2e/tui-interactions.misc.test.tsx index 4c4dc788..3738ba12 100644 --- a/packages/core/src/test/e2e/tui-interactions.misc.test.tsx +++ b/packages/core/src/test/e2e/tui-interactions.misc.test.tsx @@ -2165,7 +2165,6 @@ describe('TUI E2E regression (Ink render): Misc', () => { codingPlanFocusIndex: 0, setCodingPlanFocusIndex: () => {}, selectedProvider: 'custom-openai', - apiKey: '', resourceName: '', providerBaseUrl: '', customBaseUrl: '', @@ -2261,6 +2260,28 @@ describe('TUI E2E regression (Ink render): Misc', () => { expect(output).toContain('Enter your custom API URL') }) + test('ModelSelector: quick setup records a credential reference and continues to manual model ID', async () => { + const h = createInkTestHarness( + + {}} + abortController={new AbortController()} + /> + , + ) + harnessManager.track(h) + + await h.wait(75) + expect(h.getOutput()).toContain('Credential Source') + expect(h.getOutput()).toContain('OPENAI_API_KEY') + expect(h.getOutput()).not.toContain('sk-test-secret') + + h.stdin.write('\r') + await h.wait(75) + expect(h.getOutput()).toContain('Manual Model Setup') + }) + test('ModelSelector: mouse wheel moves provider focus', async () => { const h = createInkTestHarness( @@ -2329,7 +2350,6 @@ describe('TUI E2E regression (Ink render): Misc', () => { codingPlanFocusIndex: 0, setCodingPlanFocusIndex: () => {}, selectedProvider: 'custom-openai', - apiKey: '', resourceName: '', providerBaseUrl: '', customBaseUrl: '', diff --git a/packages/core/src/test/unit/model-config-yaml.test.ts b/packages/core/src/test/unit/model-config-yaml.test.ts index 0f92e885..d7e9cfad 100644 --- a/packages/core/src/test/unit/model-config-yaml.test.ts +++ b/packages/core/src/test/unit/model-config-yaml.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test' import { applyModelConfigYamlImport, formatModelConfigYamlForSharing, + resolveModelApiKey, + validateApiKeyEnvironmentReference, } from '#config' describe('modelConfigYaml', () => { @@ -79,7 +81,7 @@ describe('modelConfigYaml', () => { expect(yamlText).not.toContain('SECRET_KEY_SHOULD_NOT_APPEAR') }) - test('import resolves apiKey from env and applies pointers', () => { + test('import preserves an environment reference without resolving it', () => { process.env.TEST_OPENAI_KEY = 'resolved-from-env' const existingConfig: any = { @@ -109,7 +111,8 @@ pointers: ) expect(warnings).toEqual([]) - expect(nextConfig.modelProfiles?.[0]?.apiKey).toBe('resolved-from-env') + expect(nextConfig.modelProfiles?.[0]?.apiKey).toBe('') + expect(nextConfig.modelProfiles?.[0]?.apiKeyEnv).toBe('TEST_OPENAI_KEY') expect(nextConfig.modelPointers?.main).toBe('gpt-4o') expect(nextConfig.modelPointers?.quick).toBe('gpt-4o') }) @@ -152,5 +155,53 @@ profiles: ) expect(nextConfig.modelProfiles?.[0]?.apiKey).toBe('existing-key') + expect(nextConfig.modelProfiles?.[0]?.apiKeyEnv).toBe('MISSING_ENV') + }) + + test('import keeps explicit legacy credentials for backward compatibility', () => { + const existingConfig: any = { + modelProfiles: [], + modelPointers: { main: '', task: '', compact: '', quick: '' }, + } + + const { nextConfig } = applyModelConfigYamlImport( + existingConfig, + ` +version: 1 +profiles: + - name: Legacy OpenAI + provider: openai + modelName: gpt-4o + maxTokens: 1024 + contextLength: 128000 + apiKey: + value: legacy-local-key +`, + { replace: true }, + ) + + expect(nextConfig.modelProfiles?.[0]?.apiKey).toBe('legacy-local-key') + expect(nextConfig.modelProfiles?.[0]?.apiKeyEnv).toBeUndefined() + }) + + test('resolves a credential reference only for a request', () => { + process.env.TEST_OPENAI_KEY = 'resolved-from-env' + + expect( + resolveModelApiKey({ apiKey: '', apiKeyEnv: 'TEST_OPENAI_KEY' } as any), + ).toBe('resolved-from-env') + expect(resolveModelApiKey({ apiKey: 'legacy-local-key' } as any)).toBe( + 'legacy-local-key', + ) + }) + + test('accepts only safe environment variable names', () => { + expect(validateApiKeyEnvironmentReference('OPENAI_API_KEY')).toBeNull() + expect(validateApiKeyEnvironmentReference('1OPENAI_API_KEY')).toContain( + 'cannot start with a digit', + ) + expect(validateApiKeyEnvironmentReference('OPENAI API KEY')).toContain( + 'letters, digits, and underscores', + ) }) }) diff --git a/packages/core/src/test/unit/model-selector-actions.test.ts b/packages/core/src/test/unit/model-selector-actions.test.ts index 1dc2003e..78140d1f 100644 --- a/packages/core/src/test/unit/model-selector-actions.test.ts +++ b/packages/core/src/test/unit/model-selector-actions.test.ts @@ -3,7 +3,10 @@ import { describe, expect, test } from 'bun:test' import { runConnectionTestFlow } from '#ui-ink/components/ModelSelector/flow/actions/connectionTest' import { fetchModelsForProvider } from '#ui-ink/components/ModelSelector/flow/actions/fetchModels' import { handleProviderSelection } from '#ui-ink/components/ModelSelector/flow/actions/providerSelection' -import { applyPointersForNewModel } from '#ui-ink/components/ModelSelector/flow/actions/saveConfiguration' +import { + applyPointersForNewModel, + saveModelConfiguration, +} from '#ui-ink/components/ModelSelector/flow/actions/saveConfiguration' describe('model selector actions', () => { test('provider -> apiKey -> model (anthropic happy path)', async () => { @@ -92,6 +95,35 @@ describe('model selector actions', () => { expect(setAllPointersCalls).toEqual(['m1']) }) + test('quick setup saves an environment reference, never a secret value', async () => { + let savedProfile: Record | null = null + + await saveModelConfiguration({ + provider: 'openai', + model: 'gpt-4o', + providerBaseUrl: '', + resourceName: '', + customBaseUrl: '', + apiKeyEnv: 'OPENAI_API_KEY', + maxTokens: '8192', + contextLength: 128_000, + reasoningEffort: undefined, + getModelManagerFn: () => + ({ + upsertModel: async (profile: Record) => { + savedProfile = profile + return 'openai-gpt-4o' + }, + }) as any, + }) + + expect(savedProfile).toMatchObject({ + apiKey: '', + apiKeyEnv: 'OPENAI_API_KEY', + modelName: 'gpt-4o', + }) + }) + test('connection test failure does not auto-advance', async () => { const navigations: string[] = [] const timeouts: number[] = []