refactor: improve type safety, add environment variable support for API endpoints, and harden local LLM network interactions
This commit is contained in:
parent
0195760e74
commit
421429dd7b
11
README.md
11
README.md
|
|
@ -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` 环境变量启用,例如:
|
||||
|
|
|
|||
11
README_EN.md
11
README_EN.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user