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[] = []