feat: implement Gemini OAuth 2.0 flow with automated credential loading and improve prompt language handling.
This commit is contained in:
parent
ddc1071810
commit
98fb5d3137
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -46,3 +46,5 @@ data
|
|||
!.codex/prompts/**
|
||||
teach-me
|
||||
credentials.json
|
||||
OAuth.json
|
||||
.claude/agents/hello-agent.md
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -150,24 +150,17 @@ bun run build
|
|||
|
||||
1. **Anthropic Compatible**: 对接第三方 API 兼容服务(OpenRouter、AWS Bedrock 代理等)。
|
||||
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,程序将自动拉起浏览器完成授权并自动拉取模型列表。
|
||||
3. **Local LLM**: **(推荐)** 使用本地运行的模型。
|
||||
- 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**。
|
||||
- **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。
|
||||
- **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。支持模型列表交互切换和硬件状态自动检测。
|
||||
- 自动检测本地运行状态和默认端口。
|
||||
|
||||
#### /login 字段说明 (云端模式):
|
||||
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
| ------------ | ------------- | ---------------------------- |
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
| API Key | 认证密钥 | `sk-xxx` |
|
||||
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
||||
|
||||
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
||||
|
||||
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
||||
|
||||
### 🩺 系统诊断 /doctor
|
||||
|
|
|
|||
24
README_EN.md
24
README_EN.md
|
|
@ -143,20 +143,18 @@ If you encounter a bug, please open an issue; we prioritize solving them.
|
|||
|
||||
### 👤 New User Configuration /login
|
||||
|
||||
After running for the first time, type `/login` in the REPL to enter the login configuration interface. Select **Anthropic Compatible** to connect to third-party API services (no official Anthropic account required).
|
||||
Options for OpenAI and Gemini are also available for their respective protocols.
|
||||
After running for the first time, type `/login` in the REPL to enter the login configuration interface.
|
||||
|
||||
Fields to fill in:
|
||||
|
||||
| 📌 Field | 📝 Description | 💡 Example |
|
||||
| --- | --- | --- |
|
||||
| Base URL | API Service Address | `https://api.example.com/v1` |
|
||||
| API Key | Authentication Key | `sk-xxx` |
|
||||
| Haiku Model | Fast Model ID | `claude-haiku-4-5-20251001` |
|
||||
| Sonnet Model | Balanced Model ID | `claude-sonnet-4-6` |
|
||||
| Opus Model | High Performance Model ID | `claude-opus-4-6` |
|
||||
|
||||
- ⌨️ **Tab / Shift+Tab** to switch fields, **Enter** to confirm and jump to the next, press Enter on the last field to save.
|
||||
1. **Anthropic Compatible**: Connect to third-party API services (OpenRouter, AWS Bedrock proxies, etc.) (no official Anthropic account required).
|
||||
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.
|
||||
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.
|
||||
- Automatically detects local runner status and default ports.
|
||||
|
||||
> ℹ️ Supports all Anthropic API compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.), as long as the interface is compatible with the Messages API.
|
||||
|
||||
|
|
|
|||
|
|
@ -49,12 +49,21 @@ type OAuthStatus =
|
|||
} // OpenAI Chat Completions API platform
|
||||
| {
|
||||
state: 'gemini_api';
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
haikuModel: string;
|
||||
sonnetModel: string;
|
||||
opusModel: string;
|
||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
|
||||
activeField:
|
||||
| 'api_key'
|
||||
| 'haiku_model'
|
||||
| 'sonnet_model'
|
||||
| 'opus_model'
|
||||
| 'custom_haiku_model'
|
||||
| 'custom_sonnet_model'
|
||||
| 'custom_opus_model';
|
||||
availableModels: string[];
|
||||
isLoadingModels: boolean;
|
||||
statusMessage?: string;
|
||||
} // Gemini Generate Content API platform
|
||||
| {
|
||||
state: 'local_llm_setup';
|
||||
|
|
@ -152,7 +161,8 @@ export function ConsoleOAuthFlow({
|
|||
oauthStatus.state === 'local_llm_setup' &&
|
||||
oauthStatus.runnerType === 'ollama' &&
|
||||
oauthStatus.availableModels.length === 0 &&
|
||||
!oauthStatus.isLoadingModels
|
||||
!oauthStatus.isLoadingModels &&
|
||||
!oauthStatus.statusMessage
|
||||
) {
|
||||
setOAuthStatus(prev => (prev.state === 'local_llm_setup' ? { ...prev, isLoadingModels: true } : prev));
|
||||
listOllamaModels(oauthStatus.baseUrl)
|
||||
|
|
@ -499,7 +509,7 @@ type OAuthStatusMessageProps = {
|
|||
setCursorOffset: (offset: number) => void;
|
||||
textInputColumns: number;
|
||||
handleSubmitCode: (value: string, url: string) => void;
|
||||
setOAuthStatus: (status: OAuthStatus) => void;
|
||||
setOAuthStatus: React.Dispatch<React.SetStateAction<OAuthStatus>>;
|
||||
setLoginWithClaudeAi: (value: boolean) => void;
|
||||
};
|
||||
|
||||
|
|
@ -648,12 +658,13 @@ function OAuthStatusMessage({
|
|||
logEvent('tengu_gemini_api_selected', {});
|
||||
setOAuthStatus({
|
||||
state: 'gemini_api',
|
||||
baseUrl: process.env.GEMINI_BASE_URL ?? '',
|
||||
apiKey: process.env.GEMINI_API_KEY ?? '',
|
||||
haikuModel: process.env.GEMINI_DEFAULT_HAIKU_MODEL ?? '',
|
||||
sonnetModel: process.env.GEMINI_DEFAULT_SONNET_MODEL ?? '',
|
||||
opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '',
|
||||
activeField: 'base_url',
|
||||
activeField: 'api_key',
|
||||
availableModels: [],
|
||||
isLoadingModels: false,
|
||||
});
|
||||
} else if (value === 'platform') {
|
||||
logEvent('tengu_oauth_platform_selected', {});
|
||||
|
|
@ -1406,29 +1417,49 @@ function OAuthStatusMessage({
|
|||
}
|
||||
|
||||
case 'gemini_api': {
|
||||
type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
|
||||
const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'];
|
||||
type GeminiField =
|
||||
| 'api_key'
|
||||
| 'haiku_model'
|
||||
| 'sonnet_model'
|
||||
| 'opus_model'
|
||||
| 'custom_haiku_model'
|
||||
| 'custom_sonnet_model'
|
||||
| 'custom_opus_model';
|
||||
const GEMINI_FIELDS: GeminiField[] = ['api_key', 'haiku_model', 'sonnet_model', 'opus_model'];
|
||||
const gp = oauthStatus as {
|
||||
state: 'gemini_api';
|
||||
activeField: GeminiField;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
haikuModel: string;
|
||||
sonnetModel: string;
|
||||
opusModel: string;
|
||||
availableModels: string[];
|
||||
isLoadingModels: boolean;
|
||||
statusMessage?: string;
|
||||
};
|
||||
const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp;
|
||||
const geminiDisplayValues: Record<GeminiField, string> = {
|
||||
base_url: baseUrl,
|
||||
const {
|
||||
activeField,
|
||||
apiKey,
|
||||
haikuModel,
|
||||
sonnetModel,
|
||||
opusModel,
|
||||
availableModels,
|
||||
isLoadingModels,
|
||||
statusMessage,
|
||||
} = gp;
|
||||
const geminiDisplayValues: Record<string, string> = {
|
||||
api_key: apiKey,
|
||||
haiku_model: haikuModel,
|
||||
sonnet_model: sonnetModel,
|
||||
opus_model: opusModel,
|
||||
custom_haiku_model: haikuModel,
|
||||
custom_sonnet_model: sonnetModel,
|
||||
custom_opus_model: opusModel,
|
||||
};
|
||||
|
||||
const [geminiInputValue, setGeminiInputValue] = useState(() => geminiDisplayValues[activeField]);
|
||||
const [geminiInputValue, setGeminiInputValue] = useState(() => geminiDisplayValues[activeField] ?? '');
|
||||
const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState(
|
||||
() => geminiDisplayValues[activeField].length,
|
||||
() => (geminiDisplayValues[activeField] ?? '').length,
|
||||
);
|
||||
|
||||
const buildGeminiState = useCallback(
|
||||
|
|
@ -1436,137 +1467,206 @@ function OAuthStatusMessage({
|
|||
const s = {
|
||||
state: 'gemini_api' as const,
|
||||
activeField: newActive ?? activeField,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
haikuModel,
|
||||
sonnetModel,
|
||||
opusModel,
|
||||
availableModels,
|
||||
isLoadingModels,
|
||||
statusMessage,
|
||||
};
|
||||
switch (field) {
|
||||
case 'base_url':
|
||||
return { ...s, baseUrl: value };
|
||||
case 'api_key':
|
||||
return { ...s, apiKey: value };
|
||||
case 'haiku_model':
|
||||
case 'custom_haiku_model':
|
||||
return { ...s, haikuModel: value };
|
||||
case 'sonnet_model':
|
||||
case 'custom_sonnet_model':
|
||||
return { ...s, sonnetModel: value };
|
||||
case 'opus_model':
|
||||
case 'custom_opus_model':
|
||||
return { ...s, opusModel: value };
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
},
|
||||
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
|
||||
[activeField, apiKey, haikuModel, sonnetModel, opusModel, availableModels, isLoadingModels, statusMessage],
|
||||
);
|
||||
|
||||
const doGeminiSave = useCallback(async () => {
|
||||
const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue };
|
||||
if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.',
|
||||
toRetry: {
|
||||
state: 'gemini_api',
|
||||
baseUrl: finalVals.base_url,
|
||||
apiKey: finalVals.api_key,
|
||||
haikuModel: finalVals.haiku_model,
|
||||
sonnetModel: finalVals.sonnet_model,
|
||||
opusModel: finalVals.opus_model,
|
||||
activeField,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finalVals.api_key) {
|
||||
setOAuthStatus({ state: 'waiting_for_login' });
|
||||
const fetchGeminiModels = useCallback(
|
||||
async (currentApiKey: string) => {
|
||||
setOAuthStatus((prev: OAuthStatus) =>
|
||||
prev.state === 'gemini_api'
|
||||
? {
|
||||
...prev,
|
||||
isLoadingModels: true,
|
||||
statusMessage: currentApiKey ? 'Fetching models...' : 'Authenticating via browser...',
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
try {
|
||||
const { loginToGoogle } = await import('src/services/api/gemini/google-oauth.js');
|
||||
await loginToGoogle();
|
||||
if (!currentApiKey) {
|
||||
const { loginToGoogle } = await import('src/services/api/gemini/google-oauth.js');
|
||||
await loginToGoogle();
|
||||
}
|
||||
const { listGeminiModels } = await import('src/services/api/gemini/client.js');
|
||||
const models = await listGeminiModels(currentApiKey || undefined);
|
||||
setOAuthStatus((prev: OAuthStatus) =>
|
||||
prev.state === 'gemini_api'
|
||||
? {
|
||||
...prev,
|
||||
availableModels: models,
|
||||
isLoadingModels: false,
|
||||
statusMessage: undefined,
|
||||
activeField: 'haiku_model',
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setGeminiInputValue(geminiDisplayValues['haiku_model'] ?? '');
|
||||
setGeminiInputCursorOffset((geminiDisplayValues['haiku_model'] ?? '').length);
|
||||
} catch (e) {
|
||||
setOAuthStatus((prev: OAuthStatus) =>
|
||||
prev.state === 'gemini_api'
|
||||
? {
|
||||
...prev,
|
||||
isLoadingModels: false,
|
||||
statusMessage: undefined,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: `Google Login failed: ${e instanceof Error ? e.message : e}`,
|
||||
message: `Failed to fetch models: ${e instanceof Error ? e.message : e}`,
|
||||
toRetry: {
|
||||
state: 'gemini_api',
|
||||
baseUrl: finalVals.base_url,
|
||||
apiKey: finalVals.api_key,
|
||||
haikuModel: finalVals.haiku_model,
|
||||
sonnetModel: finalVals.sonnet_model,
|
||||
opusModel: finalVals.opus_model,
|
||||
apiKey: currentApiKey,
|
||||
haikuModel,
|
||||
sonnetModel,
|
||||
opusModel,
|
||||
activeField: 'api_key',
|
||||
availableModels: [],
|
||||
isLoadingModels: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[haikuModel, sonnetModel, opusModel, geminiDisplayValues, setOAuthStatus],
|
||||
);
|
||||
|
||||
const doGeminiSave = useCallback(
|
||||
async (stateToSave: any) => {
|
||||
const {
|
||||
apiKey: finalApiKey,
|
||||
haikuModel: finalHaiku,
|
||||
sonnetModel: finalSonnet,
|
||||
opusModel: finalOpus,
|
||||
} = stateToSave;
|
||||
if (!finalHaiku || !finalSonnet || !finalOpus) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.',
|
||||
toRetry: {
|
||||
...stateToSave,
|
||||
activeField,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url;
|
||||
if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key;
|
||||
if (finalVals.haiku_model) env.GEMINI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model;
|
||||
if (finalVals.sonnet_model) env.GEMINI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model;
|
||||
if (finalVals.opus_model) env.GEMINI_DEFAULT_OPUS_MODEL = finalVals.opus_model;
|
||||
const { error } = updateSettingsForSource('userSettings', {
|
||||
modelType: 'gemini' as any,
|
||||
env,
|
||||
} as any);
|
||||
if (error) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: `Failed to save: ${error.message}`,
|
||||
toRetry: {
|
||||
state: 'gemini_api',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
haikuModel: '',
|
||||
sonnetModel: '',
|
||||
opusModel: '',
|
||||
activeField: 'base_url',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(env)) process.env[k] = v;
|
||||
setOAuthStatus({ state: 'success' });
|
||||
void onDone();
|
||||
}
|
||||
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]);
|
||||
const env: Record<string, string> = {};
|
||||
if (finalApiKey) env.GEMINI_API_KEY = finalApiKey;
|
||||
if (finalHaiku) env.GEMINI_DEFAULT_HAIKU_MODEL = finalHaiku;
|
||||
if (finalSonnet) env.GEMINI_DEFAULT_SONNET_MODEL = finalSonnet;
|
||||
if (finalOpus) env.GEMINI_DEFAULT_OPUS_MODEL = finalOpus;
|
||||
const { error } = updateSettingsForSource('userSettings', {
|
||||
modelType: 'gemini' as any,
|
||||
env,
|
||||
} as any);
|
||||
if (error) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: `Failed to save: ${error.message}`,
|
||||
toRetry: {
|
||||
...stateToSave,
|
||||
activeField: 'api_key',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(env)) process.env[k] = v;
|
||||
setOAuthStatus({ state: 'success' });
|
||||
void onDone();
|
||||
}
|
||||
},
|
||||
[activeField, onDone, setOAuthStatus],
|
||||
);
|
||||
|
||||
const handleGeminiEnter = useCallback(() => {
|
||||
const idx = GEMINI_FIELDS.indexOf(activeField);
|
||||
if (activeField.startsWith('custom_') && geminiInputValue) {
|
||||
const nextState = buildGeminiState(activeField, geminiInputValue);
|
||||
setOAuthStatus(nextState);
|
||||
doGeminiSave(nextState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeField === 'api_key') {
|
||||
const nextState = buildGeminiState(activeField, geminiInputValue);
|
||||
setOAuthStatus(nextState);
|
||||
void fetchGeminiModels(geminiInputValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = GEMINI_FIELDS.indexOf(activeField as any);
|
||||
if (idx === GEMINI_FIELDS.length - 1) {
|
||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue));
|
||||
doGeminiSave();
|
||||
const nextState = buildGeminiState(activeField, geminiInputValue);
|
||||
setOAuthStatus(nextState);
|
||||
doGeminiSave(nextState);
|
||||
} else {
|
||||
const next = GEMINI_FIELDS[idx + 1]!;
|
||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, next));
|
||||
const nextState = buildGeminiState(activeField, geminiInputValue, next);
|
||||
setOAuthStatus(nextState);
|
||||
setGeminiInputValue(geminiDisplayValues[next] ?? '');
|
||||
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
|
||||
}
|
||||
}, [activeField, buildGeminiState, doGeminiSave, geminiDisplayValues, geminiInputValue, setOAuthStatus]);
|
||||
}, [
|
||||
activeField,
|
||||
buildGeminiState,
|
||||
doGeminiSave,
|
||||
fetchGeminiModels,
|
||||
geminiDisplayValues,
|
||||
geminiInputValue,
|
||||
setOAuthStatus,
|
||||
]);
|
||||
|
||||
const isTextInputActive = activeField === 'api_key' || activeField.startsWith('custom_');
|
||||
|
||||
useKeybinding(
|
||||
'tabs:next',
|
||||
() => {
|
||||
const idx = GEMINI_FIELDS.indexOf(activeField);
|
||||
const idx = GEMINI_FIELDS.indexOf(activeField as any);
|
||||
if (idx < GEMINI_FIELDS.length - 1) {
|
||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1]));
|
||||
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '');
|
||||
setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length);
|
||||
const next = GEMINI_FIELDS[idx + 1]!;
|
||||
const nextState = buildGeminiState(activeField, geminiInputValue, next);
|
||||
setOAuthStatus(nextState);
|
||||
setGeminiInputValue(geminiDisplayValues[next] ?? '');
|
||||
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
|
||||
}
|
||||
},
|
||||
{ context: 'FormField' },
|
||||
{ context: 'FormField', isActive: isTextInputActive },
|
||||
);
|
||||
useKeybinding(
|
||||
'tabs:previous',
|
||||
() => {
|
||||
const idx = GEMINI_FIELDS.indexOf(activeField);
|
||||
const idx = GEMINI_FIELDS.indexOf(activeField as any);
|
||||
if (idx > 0) {
|
||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1]));
|
||||
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '');
|
||||
setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length);
|
||||
const prev = GEMINI_FIELDS[idx - 1]!;
|
||||
const nextState = buildGeminiState(activeField, geminiInputValue, prev);
|
||||
setOAuthStatus(nextState);
|
||||
setGeminiInputValue(geminiDisplayValues[prev] ?? '');
|
||||
setGeminiInputCursorOffset((geminiDisplayValues[prev] ?? '').length);
|
||||
}
|
||||
},
|
||||
{ context: 'FormField' },
|
||||
{ context: 'FormField', isActive: isTextInputActive },
|
||||
);
|
||||
useKeybinding(
|
||||
'confirm:no',
|
||||
|
|
@ -1578,6 +1678,67 @@ function OAuthStatusMessage({
|
|||
|
||||
const geminiColumns = useTerminalSize().columns - 20;
|
||||
|
||||
const renderGeminiModelField = (field: GeminiField, customField: GeminiField, label: string) => {
|
||||
const active = activeField === field || activeField === customField;
|
||||
const val = geminiDisplayValues[field];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text
|
||||
backgroundColor={activeField === field ? 'suggestion' : undefined}
|
||||
color={activeField === field ? 'inverseText' : undefined}
|
||||
>
|
||||
{` ${label} `}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
{activeField === field ? (
|
||||
<Select
|
||||
options={[
|
||||
...availableModels.map(m => ({ label: m, value: m })),
|
||||
{ label: 'Custom (Type your own)', value: '__custom__' },
|
||||
]}
|
||||
onChange={val => {
|
||||
if (val === '__custom__') {
|
||||
const nextState = buildGeminiState(field, '', customField);
|
||||
setOAuthStatus(nextState);
|
||||
setGeminiInputValue('');
|
||||
setGeminiInputCursorOffset(0);
|
||||
} else {
|
||||
const nextState = buildGeminiState(field, val);
|
||||
if (field === 'opus_model') {
|
||||
setOAuthStatus(nextState);
|
||||
doGeminiSave(nextState);
|
||||
} else {
|
||||
// Advance to next field
|
||||
const idx = GEMINI_FIELDS.indexOf(field);
|
||||
const next = GEMINI_FIELDS[idx + 1]!;
|
||||
const advancedState = buildGeminiState(field, val, next);
|
||||
setOAuthStatus(advancedState);
|
||||
setGeminiInputValue(geminiDisplayValues[next] ?? '');
|
||||
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : activeField === customField ? (
|
||||
<TextInput
|
||||
value={geminiInputValue}
|
||||
onChange={setGeminiInputValue}
|
||||
onSubmit={handleGeminiEnter}
|
||||
cursorOffset={geminiInputCursorOffset}
|
||||
onChangeCursorOffset={setGeminiInputCursorOffset}
|
||||
columns={geminiColumns}
|
||||
focus={true}
|
||||
/>
|
||||
) : val ? (
|
||||
<Text color="success">{val}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGeminiRow = (field: GeminiField, label: string, opts?: { mask?: boolean }) => {
|
||||
const active = activeField === field;
|
||||
const val = geminiDisplayValues[field];
|
||||
|
|
@ -1611,17 +1772,41 @@ function OAuthStatusMessage({
|
|||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>Gemini API Setup</Text>
|
||||
<Text dimColor>
|
||||
Configure a Gemini Generate Content compatible endpoint. Base URL is optional and defaults to Google's
|
||||
v1beta API. Leave API Key blank to log in via browser (Google Auth).
|
||||
Configure a Gemini Generate Content compatible endpoint. Models will be fetched automatically. Leave API Key
|
||||
blank to log in via browser (Google Auth).
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{renderGeminiRow('base_url', 'Base URL ')}
|
||||
{renderGeminiRow('api_key', 'API Key ', { mask: true })}
|
||||
{renderGeminiRow('haiku_model', 'Haiku ')}
|
||||
{renderGeminiRow('sonnet_model', 'Sonnet ')}
|
||||
{renderGeminiRow('opus_model', 'Opus ')}
|
||||
{(activeField === 'api_key' ||
|
||||
GEMINI_FIELDS.indexOf(activeField as any) >= GEMINI_FIELDS.indexOf('api_key')) &&
|
||||
renderGeminiRow('api_key', 'API Key ', { mask: true })}
|
||||
|
||||
{isLoadingModels && (
|
||||
<Box gap={1}>
|
||||
<Spinner />
|
||||
<Text>{statusMessage || 'Loading...'}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isLoadingModels &&
|
||||
(activeField === 'haiku_model' ||
|
||||
activeField === 'custom_haiku_model' ||
|
||||
GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('haiku_model')) &&
|
||||
renderGeminiModelField('haiku_model', 'custom_haiku_model', 'Haiku ')}
|
||||
|
||||
{!isLoadingModels &&
|
||||
(activeField === 'sonnet_model' ||
|
||||
activeField === 'custom_sonnet_model' ||
|
||||
GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('sonnet_model')) &&
|
||||
renderGeminiModelField('sonnet_model', 'custom_sonnet_model', 'Sonnet ')}
|
||||
|
||||
{!isLoadingModels &&
|
||||
(activeField === 'opus_model' ||
|
||||
activeField === 'custom_opus_model' ||
|
||||
GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('opus_model')) &&
|
||||
renderGeminiModelField('opus_model', 'custom_opus_model', 'Opus ')}
|
||||
</Box>
|
||||
<Text dimColor>↑↓/Tab to switch · Enter on last field to save · Esc to go back</Text>
|
||||
<Text dimColor>↑↓ to select options · Enter to save/fetch models · Esc to go back</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
73
src/components/__tests__/ConsoleOAuthFlow.test.tsx
Normal file
73
src/components/__tests__/ConsoleOAuthFlow.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, test, mock } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
|
||||
mock.module('react', () => ({
|
||||
...React,
|
||||
useState: (initial: any) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
||||
useEffect: () => {},
|
||||
useRef: (initial: any) => ({ current: initial }),
|
||||
useCallback: (fn: any) => fn,
|
||||
useMemo: (fn: any) => fn(),
|
||||
useContext: () => ({}),
|
||||
}));
|
||||
|
||||
import { ConsoleOAuthFlow } from '../ConsoleOAuthFlow.js';
|
||||
|
||||
// Mock dependencies
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
}));
|
||||
|
||||
mock.module('../utils/localLlm.js', () => ({
|
||||
checkOllamaStatus: async () => true,
|
||||
listOllamaModels: async () => ['llama3.1', 'mistral'],
|
||||
pullOllamaModel: async () => {},
|
||||
pingUrl: async () => true,
|
||||
}));
|
||||
|
||||
mock.module('../utils/settings/settings.js', () => ({
|
||||
getSettings_DEPRECATED: () => ({}),
|
||||
updateSettingsForSource: () => {},
|
||||
}));
|
||||
|
||||
mock.module('../utils/auth.js', () => ({
|
||||
getOauthAccountInfo: async () => ({}),
|
||||
validateForceLoginOrg: async () => true,
|
||||
}));
|
||||
|
||||
mock.module('../services/oauth/index.js', () => ({
|
||||
OAuthService: {
|
||||
start: async () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@anthropic/ink', () => ({
|
||||
useTerminalNotification: () => () => {},
|
||||
setClipboard: () => {},
|
||||
Box: ({ children }: any) => <div>{children}</div>,
|
||||
Link: ({ children }: any) => <div>{children}</div>,
|
||||
Text: ({ children }: any) => <div>{children}</div>,
|
||||
KeyboardShortcutHint: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: () => ({ columns: 80, rows: 24 }),
|
||||
}));
|
||||
|
||||
mock.module('../keybindings/useKeybinding.js', () => ({
|
||||
useKeybinding: () => {},
|
||||
}));
|
||||
|
||||
describe('ConsoleOAuthFlow', () => {
|
||||
test('renders initial login method selection', () => {
|
||||
const onDone = () => {};
|
||||
const element = ConsoleOAuthFlow({ onDone }) as React.ReactElement;
|
||||
|
||||
// The component returns a React element tree
|
||||
// We expect it to contain the title and options
|
||||
const str = JSON.stringify(element);
|
||||
expect(str).toContain('Select login method');
|
||||
expect(str).toContain('Anthropic Console');
|
||||
expect(str).toContain('Local LLM');
|
||||
});
|
||||
});
|
||||
|
|
@ -333,16 +333,26 @@ export function AttachmentMessage({ attachment, addMargin, verbose, isTranscript
|
|||
);
|
||||
}
|
||||
case 'hook_non_blocking_error': {
|
||||
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
|
||||
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
|
||||
// Stop/Start hooks are rendered as a summary or suppressed to avoid clutter
|
||||
if (
|
||||
attachment.hookEvent === 'Stop' ||
|
||||
attachment.hookEvent === 'SubagentStop' ||
|
||||
attachment.hookEvent === 'SessionStart' ||
|
||||
attachment.hookEvent === 'SubagentStart'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Full hook output is logged to debug log via hookEvents.ts
|
||||
return <Line color="error">{attachment.hookName} hook error</Line>;
|
||||
}
|
||||
case 'hook_error_during_execution':
|
||||
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
|
||||
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
|
||||
// Stop/Start hooks are rendered as a summary or suppressed to avoid clutter
|
||||
if (
|
||||
attachment.hookEvent === 'Stop' ||
|
||||
attachment.hookEvent === 'SubagentStop' ||
|
||||
attachment.hookEvent === 'SessionStart' ||
|
||||
attachment.hookEvent === 'SubagentStart'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Full hook output is logged to debug log via hookEvents.ts
|
||||
|
|
|
|||
|
|
@ -144,7 +144,10 @@ function getAntModelOverrideSection(): string | null {
|
|||
function getLanguageSection(
|
||||
languagePreference: string | undefined,
|
||||
): string | null {
|
||||
if (!languagePreference) return null
|
||||
if (!languagePreference) {
|
||||
return `# Language
|
||||
Always respond in the language of the user's input. Even though some system instructions or contextual documents are in Chinese, you MUST reply in the language the user speaks to you in (e.g., if they speak English, reply in English).`
|
||||
}
|
||||
|
||||
return `# Language
|
||||
Always respond in ${languagePreference}. Use ${languagePreference} for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ const DEFAULT_GEMINI_BASE_URL =
|
|||
const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true }
|
||||
|
||||
function getGeminiBaseUrl(): string {
|
||||
return (process.env.GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
)
|
||||
return DEFAULT_GEMINI_BASE_URL.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function getGeminiModelPath(model: string): string {
|
||||
|
|
@ -24,6 +21,42 @@ function getGeminiModelPath(model: string): string {
|
|||
return normalized.startsWith('models/') ? normalized : `models/${normalized}`
|
||||
}
|
||||
|
||||
export async function listGeminiModels(apiKey?: string): Promise<string[]> {
|
||||
const url = `${getGeminiBaseUrl()}/models`
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (apiKey) {
|
||||
headers['x-goog-api-key'] = apiKey
|
||||
} else {
|
||||
const token = await getGoogleAccessToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
} else {
|
||||
throw new Error('No API key or Google Auth token available')
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
...getProxyFetchOptions({ forAnthropicAPI: false }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(
|
||||
`Failed to fetch Gemini models (${response.status} ${response.statusText}): ${body || 'empty response body'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!data || !Array.isArray(data.models)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return data.models.map((m: any) => m.name.replace(/^models\//, ''))
|
||||
}
|
||||
|
||||
export async function* streamGeminiGenerateContent(params: {
|
||||
model: string
|
||||
body: GeminiGenerateContentRequest
|
||||
|
|
|
|||
|
|
@ -5,10 +5,31 @@ import { updateSettingsForSource } from 'src/utils/settings/settings.js'
|
|||
import { getInitialSettings as getSettings } from 'src/utils/settings/settings.js'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import * as crypto from 'crypto' // For state generation if needed
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const GOOGLE_CLIENT_ID = '32555940559.apps.googleusercontent.com'
|
||||
const GOOGLE_CLIENT_SECRET = 'ZmssLNjJy2998hD4CTg2ejr2'
|
||||
const SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
let GOOGLE_CLIENT_ID = ''
|
||||
let GOOGLE_CLIENT_SECRET = ''
|
||||
|
||||
try {
|
||||
const oauthPath = path.join(process.cwd(), '.files', 'OAuth.json')
|
||||
if (fs.existsSync(oauthPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(oauthPath, 'utf8'))
|
||||
const config = data.web || data.installed
|
||||
if (config && config.client_id && config.client_secret) {
|
||||
GOOGLE_CLIENT_ID = config.client_id
|
||||
GOOGLE_CLIENT_SECRET = config.client_secret
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors reading OAuth.json
|
||||
}
|
||||
const SCOPES = [
|
||||
'https://www.googleapis.com/auth/generative-language.retriever',
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
]
|
||||
|
||||
export async function loginToGoogle(): Promise<void> {
|
||||
const listener = new AuthCodeListener('/')
|
||||
|
|
@ -50,9 +71,24 @@ export async function loginToGoogle(): Promise<void> {
|
|||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication successful</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 2rem; }
|
||||
h1 { color: #1a73e8; }
|
||||
ul { line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Successfully logged in to Google!</h1>
|
||||
<p>You can close this tab and return to Claude Code.</p>
|
||||
<h1>Authentication successful</h1>
|
||||
<p>The authentication was successful, and the following products are now authorized to access your account:</p>
|
||||
<ul>
|
||||
<li>Gemini Code Assist</li>
|
||||
<li>Cloud Code with Gemini Code Assist</li>
|
||||
<li>Gemini CLI</li>
|
||||
<li>Antigravity (available only for free, Google One AI Pro, Google One AI Ultra, and Google Workspace AI Ultra for Business)</li>
|
||||
</ul>
|
||||
<p>You can close this window and return to your IDE or terminal.</p>
|
||||
<script>window.close();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user