refactor: improve type safety, add environment variable support for API endpoints, and harden local LLM network interactions

This commit is contained in:
reikernodd 2026-05-12 18:36:49 +01:00
parent 0195760e74
commit 421429dd7b
12 changed files with 92 additions and 50 deletions

View File

@ -152,8 +152,7 @@ bun run build
2. **OpenAI / Gemini / Grok**: 对应各自协议的云端服务。
- **Gemini (Google Auth)**: 支持交互式浏览器登录。
1. 在 Google Cloud Console 的 **APIs & Services > OAuth consent screen** 中配置 OAuth 客户端User Type 设为 External
2. 下载 credentials JSON 文件并保存到项目根目录的 `/.files/OAuth.json`
3. 在 `/login` 配置界面中留空 API Key 并按 Enter程序将自动拉起浏览器完成授权并自动拉取模型列表。
2. 下载 credentials JSON 文件并保存到项目根目录的 `.files/OAuth.json`。 3. 在 `/login` 配置界面中留空 API Key 并按 Enter程序将自动拉起浏览器完成授权并自动拉取模型列表。
3. **Local LLM**: **(推荐)** 使用本地运行的模型。
- 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**
- **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`一键拉取Pull。支持模型列表交互切换和硬件状态自动检测。
@ -172,6 +171,14 @@ bun run build
- **配置校验**: 检查环境变量(如 `LOCAL_BASE_URL`)和权限设置。
- **故障排查**: 识别多个重复安装的版本、过期的版本锁或权限不足的更新。
## 环境变量 (Environment Variables)
除了交互式 `/login` 配置外,你也可以通过环境变量直接配置:
- `LOCAL_BASE_URL`: 本地 LLM 运行地址 (例如 `http://localhost:11434`)。
- `LOCAL_MODEL`: 本地 LLM 模型名称 (例如 `llama3.1`)。用于 `local` provider 时覆盖默认模型。
- `GEMINI_BASE_URL`: Gemini API 的自定义基础地址。
## Feature Flags
所有功能开关通过 `FEATURE_<FLAG_NAME>=1` 环境变量启用,例如:

View File

@ -149,8 +149,7 @@ After running for the first time, type `/login` in the REPL to enter the login c
2. **OpenAI / Gemini / Grok**: Connect to cloud services using their respective protocols.
- **Gemini (Google Auth)**: Supports interactive browser login.
1. In the Google Cloud Console, navigate to **APIs & Services > OAuth consent screen** and configure the OAuth client (Set User Type to External).
2. Download the credentials JSON format and save it as `/.files/OAuth.json` in the project root.
3. Leave the API Key field blank and press Enter in the `/login` configuration interface; the CLI will automatically open your browser for Google OAuth 2.0 authorization and fetch available models.
2. Download the credentials JSON format and save it as `.files/OAuth.json` in the project root. 3. Leave the API Key field blank and press Enter in the `/login` configuration interface; the CLI will automatically open your browser for Google OAuth 2.0 authorization and fetch available models.
3. **Local LLM**: **(Recommended)** Use models running locally.
- Supports **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**.
- **Ollama Deep Integration**: View installed models directly in the CLI, or enter a model name (e.g., `llama3.1`) to pull it instantly. Supports interactive model selection navigation and hardware status auto-detection.
@ -158,6 +157,14 @@ After running for the first time, type `/login` in the REPL to enter the login c
> Supports all Anthropic API compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.), as long as the interface is compatible with the Messages API.
## Environment Variables
In addition to interactive `/login` configuration, you can also configure the CLI via environment variables:
- `LOCAL_BASE_URL`: The base URL for the local LLM runner (e.g., `http://localhost:11434`).
- `LOCAL_MODEL`: The model name for the local LLM (e.g., `llama3.1`). Overrides the default model when using the `local` provider.
- `GEMINI_BASE_URL`: Custom base URL for the Gemini API.
## Feature Flags
All feature toggles are enabled via `FEATURE_<FLAG_NAME>=1` environment variables, for example:

View File

@ -180,9 +180,10 @@ const provider = {
type: 'local',
name: 'provider',
description:
'Switch API provider (anthropic/openai/gemini/grok/bedrock/vertex/foundry)',
'Switch API provider (anthropic|openai|gemini|grok|bedrock|vertex|foundry|local)',
aliases: ['api'],
argumentHint: '[anthropic|openai|gemini|grok|bedrock|vertex|foundry|unset]',
argumentHint:
'[anthropic|openai|gemini|grok|bedrock|vertex|foundry|local|unset]',
supportsNonInteractive: true,
load: () => Promise.resolve({ call }),
} satisfies Command

View File

@ -88,6 +88,7 @@ type OAuthStatus =
}
| {
state: 'local_llm_pulling';
baseUrl: string;
modelName: string;
status: string;
percentage?: number;
@ -103,6 +104,8 @@ type OAuthStatus =
toRetry?: OAuthStatus;
};
type LocalLlmSetupState = Extract<OAuthStatus, { state: 'local_llm_setup' }>;
const PASTE_HERE_MSG = 'Paste code here if prompted > ';
const POPULAR_MODELS = ['llama3.1', 'mistral', 'phi3', 'qwen2', 'gemma2', 'codellama'];
export function ConsoleOAuthFlow({
@ -206,13 +209,10 @@ export function ConsoleOAuthFlow({
useEffect(() => {
if (oauthStatus.state === 'local_llm_pulling') {
const abortController = new AbortController();
const { baseUrl, modelName } = oauthStatus;
(async () => {
try {
for await (const progress of pullOllamaModel(
oauthStatus.modelName,
'http://localhost:11434',
abortController.signal,
)) {
for await (const progress of pullOllamaModel(modelName, baseUrl, abortController.signal)) {
setOAuthStatus(prev =>
prev.state === 'local_llm_pulling'
? {
@ -227,8 +227,8 @@ export function ConsoleOAuthFlow({
setOAuthStatus({
state: 'local_llm_setup',
runnerType: 'ollama',
baseUrl: 'http://localhost:11434',
modelName: oauthStatus.modelName,
baseUrl,
modelName,
activeField: 'model_name',
availableModels: [],
isLoadingModels: false,
@ -718,7 +718,7 @@ function OAuthStatusMessage({
const displayValues: Record<LocalField, string> = {
runner_type: oauthStatus.runnerType,
base_url: oauthStatus.baseUrl,
api_key: (oauthStatus as any).apiKey ?? '',
api_key: oauthStatus.apiKey ?? '',
model_name: oauthStatus.modelName,
custom_model_name: oauthStatus.modelName,
};
@ -727,10 +727,10 @@ function OAuthStatusMessage({
const [localInputCursorOffset, setLocalInputCursorOffset] = useState((displayValues[activeField] ?? '').length);
const buildLocalState = useCallback(
(field: LocalField, val: string, nextField?: LocalField): OAuthStatus => {
const newState = { ...oauthStatus } as any;
(field: LocalField, val: string, nextField?: LocalField): LocalLlmSetupState => {
const newState = { ...oauthStatus };
if (field === 'runner_type') {
newState.runnerType = val;
newState.runnerType = val as LocalLlmSetupState['runnerType'];
if (val === 'ollama') newState.baseUrl = 'http://localhost:11434';
else if (val === 'lmstudio') newState.baseUrl = 'http://localhost:1234/v1';
else if (val === 'jan') newState.baseUrl = 'http://localhost:1337/v1';
@ -749,7 +749,7 @@ function OAuthStatusMessage({
);
const doLocalSave = useCallback(
async (stateToSave: any) => {
async (stateToSave: LocalLlmSetupState) => {
const { runnerType, baseUrl, modelName, apiKey } = stateToSave;
const env: Record<string, string> = {
LOCAL_BASE_URL: baseUrl,
@ -761,11 +761,11 @@ function OAuthStatusMessage({
updateSettingsForSource('userSettings', {
modelType: 'local',
env,
} as any);
});
updateSettingsForSource('userSettings', {
model: modelName || 'llama3.1',
} as any);
});
setOAuthStatus({ state: 'success' });
void onDone();
@ -778,6 +778,7 @@ function OAuthStatusMessage({
if (oauthStatus.runnerType === 'ollama' && !oauthStatus.availableModels.includes(localInputValue)) {
setOAuthStatus({
state: 'local_llm_pulling',
baseUrl: oauthStatus.baseUrl,
modelName: localInputValue,
status: 'Starting download...',
});
@ -797,7 +798,7 @@ function OAuthStatusMessage({
} else {
// find next interactive field (skip model_name if custom_model_name is next, but that's handled by onChange)
const next = LOCAL_FIELDS[idx + 1]!;
const nextState = buildLocalState(activeField, localInputValue, next) as any;
const nextState = buildLocalState(activeField, localInputValue, next);
setOAuthStatus(nextState);
const nextVal =
nextState[
@ -821,7 +822,7 @@ function OAuthStatusMessage({
const idx = LOCAL_FIELDS.indexOf(activeField);
if (idx < LOCAL_FIELDS.length - 1) {
const next = LOCAL_FIELDS[idx + 1]!;
const nextState = buildLocalState(activeField, localInputValue, next) as any;
const nextState = buildLocalState(activeField, localInputValue, next);
setOAuthStatus(nextState);
const nextVal =
nextState[
@ -847,7 +848,7 @@ function OAuthStatusMessage({
const idx = LOCAL_FIELDS.indexOf(activeField);
if (idx > 0) {
const next = LOCAL_FIELDS[idx - 1]!;
const nextState = buildLocalState(activeField, localInputValue, next) as any;
const nextState = buildLocalState(activeField, localInputValue, next);
setOAuthStatus(nextState);
const nextVal =
nextState[
@ -929,7 +930,7 @@ function OAuthStatusMessage({
<Select
options={runnerTypeOptions}
onChange={val => {
const nextState = buildLocalState('runner_type', val, 'base_url') as any;
const nextState = buildLocalState('runner_type', val, 'base_url');
setOAuthStatus(nextState);
setLocalInputValue(nextState.baseUrl ?? '');
setLocalInputCursorOffset((nextState.baseUrl ?? '').length);
@ -975,7 +976,7 @@ function OAuthStatusMessage({
]}
onChange={val => {
if (val === '__custom__') {
const nextState = buildLocalState('model_name', '', 'custom_model_name') as any;
const nextState = buildLocalState('model_name', '', 'custom_model_name');
setOAuthStatus(nextState);
setLocalInputValue('');
setLocalInputCursorOffset(0);
@ -984,6 +985,7 @@ function OAuthStatusMessage({
if (oauthStatus.runnerType === 'ollama' && !oauthStatus.availableModels.includes(val)) {
setOAuthStatus({
state: 'local_llm_pulling',
baseUrl: oauthStatus.baseUrl,
modelName: val,
status: 'Starting download...',
});
@ -1701,9 +1703,9 @@ function OAuthStatusMessage({
if (finalSonnet) env.GEMINI_DEFAULT_SONNET_MODEL = finalSonnet;
if (finalOpus) env.GEMINI_DEFAULT_OPUS_MODEL = finalOpus;
const { error } = updateSettingsForSource('userSettings', {
modelType: 'gemini' as any,
modelType: 'gemini',
env,
} as any);
});
if (error) {
setOAuthStatus({
state: 'error',

View File

@ -1331,7 +1331,8 @@ async function* queryModel(
// OpenAI-compatible provider: delegate to the OpenAI adapter layer
// after shared preprocessing (message normalization, tool filtering,
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
if (getAPIProvider() === 'openai' || getAPIProvider() === 'local') {
const provider = getAPIProvider()
if (provider === 'openai' || provider === 'local') {
const { queryModelOpenAI } = await import('./openai/index.js')
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
// the full tool pool so SearchExtraToolsTool can search deferred MCP tools that

View File

@ -13,7 +13,10 @@ const DEFAULT_GEMINI_BASE_URL =
const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true }
function getGeminiBaseUrl(): string {
return DEFAULT_GEMINI_BASE_URL.replace(/\/+$/, '')
return (process.env.GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace(
/\/+$/,
'',
)
}
function getGeminiModelPath(model: string): string {
@ -54,7 +57,13 @@ export async function listGeminiModels(apiKey?: string): Promise<string[]> {
return []
}
return data.models.map((m: any) => m.name.replace(/^models\//, ''))
const models: string[] = []
for (const m of data.models) {
if (m && typeof m === 'object' && typeof m.name === 'string') {
models.push(m.name.replace(/^models\//, ''))
}
}
return models
}
export async function* streamGeminiGenerateContent(params: {

View File

@ -61,11 +61,11 @@ export async function loginToGoogle(): Promise<void> {
// Save tokens
updateSettingsForSource('userSettings', {
googleOAuth: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expiry_date: tokens.expiry_date,
access_token: tokens.access_token ?? undefined,
refresh_token: tokens.refresh_token ?? undefined,
expiry_date: tokens.expiry_date ?? undefined,
},
} as any)
})
listener.handleSuccessRedirect(SCOPES, res => {
res.writeHead(200, { 'Content-Type': 'text/html' })
@ -107,7 +107,7 @@ export async function loginToGoogle(): Promise<void> {
export async function getGoogleAccessToken(): Promise<string | null> {
const settings = getSettings()
const googleOAuth = (settings as any).googleOAuth
const googleOAuth = settings.googleOAuth
if (!googleOAuth || !googleOAuth.refresh_token) {
return null
@ -129,18 +129,18 @@ export async function getGoogleAccessToken(): Promise<string | null> {
if (credentials.access_token !== googleOAuth.access_token) {
updateSettingsForSource('userSettings', {
googleOAuth: {
access_token: credentials.access_token,
access_token: credentials.access_token ?? undefined,
refresh_token: credentials.refresh_token || googleOAuth.refresh_token,
expiry_date: credentials.expiry_date,
expiry_date: credentials.expiry_date ?? undefined,
},
} as any)
})
}
return credentials.access_token || null
} catch (error) {
// If refresh fails, clear it
updateSettingsForSource('userSettings', {
googleOAuth: undefined,
} as any)
})
return null
}
}

View File

@ -620,6 +620,7 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
const ollamaRunning = await checkOllamaStatus()
const ollamaModels = ollamaRunning ? await listOllamaModels() : []
const cpuInfo = cpus()
const diagnostic: DiagnosticInfo = {
installationType,
version,
@ -644,10 +645,10 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
},
},
hardwareInfo: {
cpus: cpus().length,
cpuModel: cpus()[0]?.model || 'Unknown',
totalMem: Math.round(totalmem() / 1024 / 1024 / 1024) + ' GB',
freeMem: Math.round(freemem() / 1024 / 1024 / 1024) + ' GB',
cpus: cpuInfo.length,
cpuModel: cpuInfo[0]?.model || 'Unknown',
totalMem: (totalmem() / 1024 ** 3).toFixed(1) + ' GB',
freeMem: (freemem() / 1024 ** 3).toFixed(1) + ' GB',
arch: arch(),
},
}

View File

@ -8,7 +8,10 @@ export async function checkOllamaStatus(
baseUrl: string = 'http://localhost:11434',
): Promise<boolean> {
try {
const response = await fetch(`${baseUrl}/api/tags`, { method: 'GET' })
const response = await fetch(`${baseUrl}/api/tags`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
})
return response.ok
} catch (error) {
logForDebugging(`Ollama status check failed: ${error}`)
@ -20,7 +23,9 @@ export async function listOllamaModels(
baseUrl: string = 'http://localhost:11434',
): Promise<string[]> {
try {
const response = await fetch(`${baseUrl}/api/tags`)
const response = await fetch(`${baseUrl}/api/tags`, {
signal: AbortSignal.timeout(5000),
})
if (!response.ok) return []
const data = (await response.json()) as { models: OllamaModel[] }
return data.models.map(m => m.name)
@ -37,6 +42,7 @@ export async function* pullOllamaModel(
): AsyncGenerator<{ status: string; percentage?: number }> {
const response = await fetch(`${baseUrl}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: model }),
signal,
})

View File

@ -10,6 +10,7 @@ import {
ALL_MODEL_CONFIGS,
CANONICAL_ID_TO_KEY,
type CanonicalModelId,
type ModelConfig,
type ModelKey,
} from './configs.js'
import { type APIProvider, getAPIProvider } from './providers.js'
@ -25,9 +26,8 @@ const MODEL_KEYS = Object.keys(ALL_MODEL_CONFIGS) as ModelKey[]
function getBuiltinModelStrings(provider: APIProvider): ModelStrings {
const out = {} as ModelStrings
for (const key of MODEL_KEYS) {
out[key] =
(ALL_MODEL_CONFIGS[key] as any)[provider] ||
ALL_MODEL_CONFIGS[key].firstParty
const config = ALL_MODEL_CONFIGS[key] as unknown as ModelConfig
out[key] = config[provider as keyof ModelConfig] || config.firstParty
}
return out
}

View File

@ -362,6 +362,14 @@ export const SettingsSchema = lazySchema(() =>
.describe(
"Include built-in commit and PR workflow instructions in Claude's system prompt (default: true)",
),
googleOAuth: z
.object({
access_token: z.string().optional(),
refresh_token: z.string().optional(),
expiry_date: z.number().optional(),
})
.optional()
.describe('Google OAuth credentials for Gemini API'),
permissions: PermissionsSchema()
.optional()
.describe('Tool usage permissions configuration'),

View File

@ -4,9 +4,9 @@ import { getAPIProvider } from '../model/providers.js'
// @[MODEL LAUNCH]: Update the fallback model below.
// When the user has never set teammateDefaultModel in /config, new teammates
// use Opus 4.6. Must be provider-aware so Bedrock/Vertex/Foundry customers get
// the correct model ID.
// the correct model ID. For local provider, it uses process.env.LOCAL_MODEL.
export function getHardcodedTeammateModelFallback(): string {
const provider = getAPIProvider()
if (provider === 'local') return 'claude-opus-4-6' // Fallback for local
if (provider === 'local') return process.env.LOCAL_MODEL || 'claude-opus-4-6'
return CLAUDE_OPUS_4_6_CONFIG[provider]
}