Skip to content

Commit 421429d

Browse files
committed
refactor: improve type safety, add environment variable support for API endpoints, and harden local LLM network interactions
1 parent 0195760 commit 421429d

12 files changed

Lines changed: 92 additions & 50 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,7 @@ bun run build
152152
2. **OpenAI / Gemini / Grok**: 对应各自协议的云端服务。
153153
- **Gemini (Google Auth)**: 支持交互式浏览器登录。
154154
1. 在 Google Cloud Console 的 **APIs & Services > OAuth consent screen** 中配置 OAuth 客户端(User Type 设为 External)。
155-
2. 下载 credentials JSON 文件并保存到项目根目录的 `/.files/OAuth.json`
156-
3.`/login` 配置界面中留空 API Key 并按 Enter,程序将自动拉起浏览器完成授权并自动拉取模型列表。
155+
2. 下载 credentials JSON 文件并保存到项目根目录的 `.files/OAuth.json`。 3. 在 `/login` 配置界面中留空 API Key 并按 Enter,程序将自动拉起浏览器完成授权并自动拉取模型列表。
157156
3. **Local LLM**: **(推荐)** 使用本地运行的模型。
158157
- 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**
159158
- **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。支持模型列表交互切换和硬件状态自动检测。
@@ -172,6 +171,14 @@ bun run build
172171
- **配置校验**: 检查环境变量(如 `LOCAL_BASE_URL`)和权限设置。
173172
- **故障排查**: 识别多个重复安装的版本、过期的版本锁或权限不足的更新。
174173

174+
## 环境变量 (Environment Variables)
175+
176+
除了交互式 `/login` 配置外,你也可以通过环境变量直接配置:
177+
178+
- `LOCAL_BASE_URL`: 本地 LLM 运行地址 (例如 `http://localhost:11434`)。
179+
- `LOCAL_MODEL`: 本地 LLM 模型名称 (例如 `llama3.1`)。用于 `local` provider 时覆盖默认模型。
180+
- `GEMINI_BASE_URL`: Gemini API 的自定义基础地址。
181+
175182
## Feature Flags
176183

177184
所有功能开关通过 `FEATURE_<FLAG_NAME>=1` 环境变量启用,例如:

README_EN.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,22 @@ After running for the first time, type `/login` in the REPL to enter the login c
149149
2. **OpenAI / Gemini / Grok**: Connect to cloud services using their respective protocols.
150150
- **Gemini (Google Auth)**: Supports interactive browser login.
151151
1. In the Google Cloud Console, navigate to **APIs & Services > OAuth consent screen** and configure the OAuth client (Set User Type to External).
152-
2. Download the credentials JSON format and save it as `/.files/OAuth.json` in the project root.
153-
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.
152+
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.
154153
3. **Local LLM**: **(Recommended)** Use models running locally.
155154
- Supports **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**.
156155
- **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.
157156
- Automatically detects local runner status and default ports.
158157

159158
> ℹ️ Supports all Anthropic API compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.), as long as the interface is compatible with the Messages API.
160159
160+
## Environment Variables
161+
162+
In addition to interactive `/login` configuration, you can also configure the CLI via environment variables:
163+
164+
- `LOCAL_BASE_URL`: The base URL for the local LLM runner (e.g., `http://localhost:11434`).
165+
- `LOCAL_MODEL`: The model name for the local LLM (e.g., `llama3.1`). Overrides the default model when using the `local` provider.
166+
- `GEMINI_BASE_URL`: Custom base URL for the Gemini API.
167+
161168
## Feature Flags
162169

163170
All feature toggles are enabled via `FEATURE_<FLAG_NAME>=1` environment variables, for example:

src/commands/provider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,10 @@ const provider = {
180180
type: 'local',
181181
name: 'provider',
182182
description:
183-
'Switch API provider (anthropic/openai/gemini/grok/bedrock/vertex/foundry)',
183+
'Switch API provider (anthropic|openai|gemini|grok|bedrock|vertex|foundry|local)',
184184
aliases: ['api'],
185-
argumentHint: '[anthropic|openai|gemini|grok|bedrock|vertex|foundry|unset]',
185+
argumentHint:
186+
'[anthropic|openai|gemini|grok|bedrock|vertex|foundry|local|unset]',
186187
supportsNonInteractive: true,
187188
load: () => Promise.resolve({ call }),
188189
} satisfies Command

src/components/ConsoleOAuthFlow.tsx

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ type OAuthStatus =
8888
}
8989
| {
9090
state: 'local_llm_pulling';
91+
baseUrl: string;
9192
modelName: string;
9293
status: string;
9394
percentage?: number;
@@ -103,6 +104,8 @@ type OAuthStatus =
103104
toRetry?: OAuthStatus;
104105
};
105106

107+
type LocalLlmSetupState = Extract<OAuthStatus, { state: 'local_llm_setup' }>;
108+
106109
const PASTE_HERE_MSG = 'Paste code here if prompted > ';
107110
const POPULAR_MODELS = ['llama3.1', 'mistral', 'phi3', 'qwen2', 'gemma2', 'codellama'];
108111
export function ConsoleOAuthFlow({
@@ -206,13 +209,10 @@ export function ConsoleOAuthFlow({
206209
useEffect(() => {
207210
if (oauthStatus.state === 'local_llm_pulling') {
208211
const abortController = new AbortController();
212+
const { baseUrl, modelName } = oauthStatus;
209213
(async () => {
210214
try {
211-
for await (const progress of pullOllamaModel(
212-
oauthStatus.modelName,
213-
'http://localhost:11434',
214-
abortController.signal,
215-
)) {
215+
for await (const progress of pullOllamaModel(modelName, baseUrl, abortController.signal)) {
216216
setOAuthStatus(prev =>
217217
prev.state === 'local_llm_pulling'
218218
? {
@@ -227,8 +227,8 @@ export function ConsoleOAuthFlow({
227227
setOAuthStatus({
228228
state: 'local_llm_setup',
229229
runnerType: 'ollama',
230-
baseUrl: 'http://localhost:11434',
231-
modelName: oauthStatus.modelName,
230+
baseUrl,
231+
modelName,
232232
activeField: 'model_name',
233233
availableModels: [],
234234
isLoadingModels: false,
@@ -718,7 +718,7 @@ function OAuthStatusMessage({
718718
const displayValues: Record<LocalField, string> = {
719719
runner_type: oauthStatus.runnerType,
720720
base_url: oauthStatus.baseUrl,
721-
api_key: (oauthStatus as any).apiKey ?? '',
721+
api_key: oauthStatus.apiKey ?? '',
722722
model_name: oauthStatus.modelName,
723723
custom_model_name: oauthStatus.modelName,
724724
};
@@ -727,10 +727,10 @@ function OAuthStatusMessage({
727727
const [localInputCursorOffset, setLocalInputCursorOffset] = useState((displayValues[activeField] ?? '').length);
728728

729729
const buildLocalState = useCallback(
730-
(field: LocalField, val: string, nextField?: LocalField): OAuthStatus => {
731-
const newState = { ...oauthStatus } as any;
730+
(field: LocalField, val: string, nextField?: LocalField): LocalLlmSetupState => {
731+
const newState = { ...oauthStatus };
732732
if (field === 'runner_type') {
733-
newState.runnerType = val;
733+
newState.runnerType = val as LocalLlmSetupState['runnerType'];
734734
if (val === 'ollama') newState.baseUrl = 'http://localhost:11434';
735735
else if (val === 'lmstudio') newState.baseUrl = 'http://localhost:1234/v1';
736736
else if (val === 'jan') newState.baseUrl = 'http://localhost:1337/v1';
@@ -749,7 +749,7 @@ function OAuthStatusMessage({
749749
);
750750

751751
const doLocalSave = useCallback(
752-
async (stateToSave: any) => {
752+
async (stateToSave: LocalLlmSetupState) => {
753753
const { runnerType, baseUrl, modelName, apiKey } = stateToSave;
754754
const env: Record<string, string> = {
755755
LOCAL_BASE_URL: baseUrl,
@@ -761,11 +761,11 @@ function OAuthStatusMessage({
761761
updateSettingsForSource('userSettings', {
762762
modelType: 'local',
763763
env,
764-
} as any);
764+
});
765765

766766
updateSettingsForSource('userSettings', {
767767
model: modelName || 'llama3.1',
768-
} as any);
768+
});
769769

770770
setOAuthStatus({ state: 'success' });
771771
void onDone();
@@ -778,6 +778,7 @@ function OAuthStatusMessage({
778778
if (oauthStatus.runnerType === 'ollama' && !oauthStatus.availableModels.includes(localInputValue)) {
779779
setOAuthStatus({
780780
state: 'local_llm_pulling',
781+
baseUrl: oauthStatus.baseUrl,
781782
modelName: localInputValue,
782783
status: 'Starting download...',
783784
});
@@ -797,7 +798,7 @@ function OAuthStatusMessage({
797798
} else {
798799
// find next interactive field (skip model_name if custom_model_name is next, but that's handled by onChange)
799800
const next = LOCAL_FIELDS[idx + 1]!;
800-
const nextState = buildLocalState(activeField, localInputValue, next) as any;
801+
const nextState = buildLocalState(activeField, localInputValue, next);
801802
setOAuthStatus(nextState);
802803
const nextVal =
803804
nextState[
@@ -821,7 +822,7 @@ function OAuthStatusMessage({
821822
const idx = LOCAL_FIELDS.indexOf(activeField);
822823
if (idx < LOCAL_FIELDS.length - 1) {
823824
const next = LOCAL_FIELDS[idx + 1]!;
824-
const nextState = buildLocalState(activeField, localInputValue, next) as any;
825+
const nextState = buildLocalState(activeField, localInputValue, next);
825826
setOAuthStatus(nextState);
826827
const nextVal =
827828
nextState[
@@ -847,7 +848,7 @@ function OAuthStatusMessage({
847848
const idx = LOCAL_FIELDS.indexOf(activeField);
848849
if (idx > 0) {
849850
const next = LOCAL_FIELDS[idx - 1]!;
850-
const nextState = buildLocalState(activeField, localInputValue, next) as any;
851+
const nextState = buildLocalState(activeField, localInputValue, next);
851852
setOAuthStatus(nextState);
852853
const nextVal =
853854
nextState[
@@ -929,7 +930,7 @@ function OAuthStatusMessage({
929930
<Select
930931
options={runnerTypeOptions}
931932
onChange={val => {
932-
const nextState = buildLocalState('runner_type', val, 'base_url') as any;
933+
const nextState = buildLocalState('runner_type', val, 'base_url');
933934
setOAuthStatus(nextState);
934935
setLocalInputValue(nextState.baseUrl ?? '');
935936
setLocalInputCursorOffset((nextState.baseUrl ?? '').length);
@@ -975,7 +976,7 @@ function OAuthStatusMessage({
975976
]}
976977
onChange={val => {
977978
if (val === '__custom__') {
978-
const nextState = buildLocalState('model_name', '', 'custom_model_name') as any;
979+
const nextState = buildLocalState('model_name', '', 'custom_model_name');
979980
setOAuthStatus(nextState);
980981
setLocalInputValue('');
981982
setLocalInputCursorOffset(0);
@@ -984,6 +985,7 @@ function OAuthStatusMessage({
984985
if (oauthStatus.runnerType === 'ollama' && !oauthStatus.availableModels.includes(val)) {
985986
setOAuthStatus({
986987
state: 'local_llm_pulling',
988+
baseUrl: oauthStatus.baseUrl,
987989
modelName: val,
988990
status: 'Starting download...',
989991
});
@@ -1701,9 +1703,9 @@ function OAuthStatusMessage({
17011703
if (finalSonnet) env.GEMINI_DEFAULT_SONNET_MODEL = finalSonnet;
17021704
if (finalOpus) env.GEMINI_DEFAULT_OPUS_MODEL = finalOpus;
17031705
const { error } = updateSettingsForSource('userSettings', {
1704-
modelType: 'gemini' as any,
1706+
modelType: 'gemini',
17051707
env,
1706-
} as any);
1708+
});
17071709
if (error) {
17081710
setOAuthStatus({
17091711
state: 'error',

src/services/api/claude.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1331,7 +1331,8 @@ async function* queryModel(
13311331
// OpenAI-compatible provider: delegate to the OpenAI adapter layer
13321332
// after shared preprocessing (message normalization, tool filtering,
13331333
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
1334-
if (getAPIProvider() === 'openai' || getAPIProvider() === 'local') {
1334+
const provider = getAPIProvider()
1335+
if (provider === 'openai' || provider === 'local') {
13351336
const { queryModelOpenAI } = await import('./openai/index.js')
13361337
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
13371338
// the full tool pool so SearchExtraToolsTool can search deferred MCP tools that

src/services/api/gemini/client.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ const DEFAULT_GEMINI_BASE_URL =
1313
const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true }
1414

1515
function getGeminiBaseUrl(): string {
16-
return DEFAULT_GEMINI_BASE_URL.replace(/\/+$/, '')
16+
return (process.env.GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace(
17+
/\/+$/,
18+
'',
19+
)
1720
}
1821

1922
function getGeminiModelPath(model: string): string {
@@ -54,7 +57,13 @@ export async function listGeminiModels(apiKey?: string): Promise<string[]> {
5457
return []
5558
}
5659

57-
return data.models.map((m: any) => m.name.replace(/^models\//, ''))
60+
const models: string[] = []
61+
for (const m of data.models) {
62+
if (m && typeof m === 'object' && typeof m.name === 'string') {
63+
models.push(m.name.replace(/^models\//, ''))
64+
}
65+
}
66+
return models
5867
}
5968

6069
export async function* streamGeminiGenerateContent(params: {

src/services/api/gemini/google-oauth.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ export async function loginToGoogle(): Promise<void> {
6161
// Save tokens
6262
updateSettingsForSource('userSettings', {
6363
googleOAuth: {
64-
access_token: tokens.access_token,
65-
refresh_token: tokens.refresh_token,
66-
expiry_date: tokens.expiry_date,
64+
access_token: tokens.access_token ?? undefined,
65+
refresh_token: tokens.refresh_token ?? undefined,
66+
expiry_date: tokens.expiry_date ?? undefined,
6767
},
68-
} as any)
68+
})
6969

7070
listener.handleSuccessRedirect(SCOPES, res => {
7171
res.writeHead(200, { 'Content-Type': 'text/html' })
@@ -107,7 +107,7 @@ export async function loginToGoogle(): Promise<void> {
107107

108108
export async function getGoogleAccessToken(): Promise<string | null> {
109109
const settings = getSettings()
110-
const googleOAuth = (settings as any).googleOAuth
110+
const googleOAuth = settings.googleOAuth
111111

112112
if (!googleOAuth || !googleOAuth.refresh_token) {
113113
return null
@@ -129,18 +129,18 @@ export async function getGoogleAccessToken(): Promise<string | null> {
129129
if (credentials.access_token !== googleOAuth.access_token) {
130130
updateSettingsForSource('userSettings', {
131131
googleOAuth: {
132-
access_token: credentials.access_token,
132+
access_token: credentials.access_token ?? undefined,
133133
refresh_token: credentials.refresh_token || googleOAuth.refresh_token,
134-
expiry_date: credentials.expiry_date,
134+
expiry_date: credentials.expiry_date ?? undefined,
135135
},
136-
} as any)
136+
})
137137
}
138138
return credentials.access_token || null
139139
} catch (error) {
140140
// If refresh fails, clear it
141141
updateSettingsForSource('userSettings', {
142142
googleOAuth: undefined,
143-
} as any)
143+
})
144144
return null
145145
}
146146
}

src/utils/doctorDiagnostic.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
620620
const ollamaRunning = await checkOllamaStatus()
621621
const ollamaModels = ollamaRunning ? await listOllamaModels() : []
622622

623+
const cpuInfo = cpus()
623624
const diagnostic: DiagnosticInfo = {
624625
installationType,
625626
version,
@@ -644,10 +645,10 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
644645
},
645646
},
646647
hardwareInfo: {
647-
cpus: cpus().length,
648-
cpuModel: cpus()[0]?.model || 'Unknown',
649-
totalMem: Math.round(totalmem() / 1024 / 1024 / 1024) + ' GB',
650-
freeMem: Math.round(freemem() / 1024 / 1024 / 1024) + ' GB',
648+
cpus: cpuInfo.length,
649+
cpuModel: cpuInfo[0]?.model || 'Unknown',
650+
totalMem: (totalmem() / 1024 ** 3).toFixed(1) + ' GB',
651+
freeMem: (freemem() / 1024 ** 3).toFixed(1) + ' GB',
651652
arch: arch(),
652653
},
653654
}

src/utils/localLlm.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ export async function checkOllamaStatus(
88
baseUrl: string = 'http://localhost:11434',
99
): Promise<boolean> {
1010
try {
11-
const response = await fetch(`${baseUrl}/api/tags`, { method: 'GET' })
11+
const response = await fetch(`${baseUrl}/api/tags`, {
12+
method: 'GET',
13+
signal: AbortSignal.timeout(5000),
14+
})
1215
return response.ok
1316
} catch (error) {
1417
logForDebugging(`Ollama status check failed: ${error}`)
@@ -20,7 +23,9 @@ export async function listOllamaModels(
2023
baseUrl: string = 'http://localhost:11434',
2124
): Promise<string[]> {
2225
try {
23-
const response = await fetch(`${baseUrl}/api/tags`)
26+
const response = await fetch(`${baseUrl}/api/tags`, {
27+
signal: AbortSignal.timeout(5000),
28+
})
2429
if (!response.ok) return []
2530
const data = (await response.json()) as { models: OllamaModel[] }
2631
return data.models.map(m => m.name)
@@ -37,6 +42,7 @@ export async function* pullOllamaModel(
3742
): AsyncGenerator<{ status: string; percentage?: number }> {
3843
const response = await fetch(`${baseUrl}/api/pull`, {
3944
method: 'POST',
45+
headers: { 'Content-Type': 'application/json' },
4046
body: JSON.stringify({ name: model }),
4147
signal,
4248
})

src/utils/model/modelStrings.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ALL_MODEL_CONFIGS,
1111
CANONICAL_ID_TO_KEY,
1212
type CanonicalModelId,
13+
type ModelConfig,
1314
type ModelKey,
1415
} from './configs.js'
1516
import { type APIProvider, getAPIProvider } from './providers.js'
@@ -25,9 +26,8 @@ const MODEL_KEYS = Object.keys(ALL_MODEL_CONFIGS) as ModelKey[]
2526
function getBuiltinModelStrings(provider: APIProvider): ModelStrings {
2627
const out = {} as ModelStrings
2728
for (const key of MODEL_KEYS) {
28-
out[key] =
29-
(ALL_MODEL_CONFIGS[key] as any)[provider] ||
30-
ALL_MODEL_CONFIGS[key].firstParty
29+
const config = ALL_MODEL_CONFIGS[key] as unknown as ModelConfig
30+
out[key] = config[provider as keyof ModelConfig] || config.firstParty
3131
}
3232
return out
3333
}

0 commit comments

Comments
 (0)