mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Merge pull request #702 from binaricat/codex/issue-677-codex-provider-followup
[codex] finish Codex provider follow-up for #677
This commit is contained in:
14 files changed
+539
-62
No files matched your search
@@ -1744,12 +1744,16 @@ const en: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
|
||||
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
@@ -1760,7 +1764,7 @@ const en: Messages = {
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
|
||||
'ai.codex.apiKeyHint': 'Detected an enabled OpenAI-compatible provider API key. Codex ACP can use it without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
|
||||
@@ -1752,12 +1752,16 @@ const zhCN: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
|
||||
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
|
||||
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
|
||||
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
@@ -1768,7 +1772,7 @@ const zhCN: Messages = {
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的兼容 OpenAI 的 API Key。Codex ACP 也可以不走 ChatGPT 登录直接使用它。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
@@ -480,6 +481,42 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
const isCodexManagedAgent = useMemo(
|
||||
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
|
||||
// so the picker can show just that model instead of the hardcoded ChatGPT
|
||||
// preset list. Probing codex-acp for its full catalog returns the stock
|
||||
// OpenAI models regardless of the active provider, which is misleading.
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
return;
|
||||
}
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void bridge.aiCodexGetIntegration().then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
// Only flip "resolved" to true when the probe confirms this is a
|
||||
// custom-config session; otherwise keep it false so we fall back to
|
||||
// the static CODEX_MODEL_PRESETS.
|
||||
setCodexCustomConfigResolved(hasCustom);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCodexConfigModel(null);
|
||||
setCodexCustomConfigResolved(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isCodexManagedAgent, currentAgentId]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
@@ -520,10 +557,26 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
|
||||
|
||||
const agentModelPresets = useMemo(
|
||||
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
|
||||
);
|
||||
// When Codex is backed by a ~/.codex/config.toml custom provider, the
|
||||
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
|
||||
// codexCustomConfigResolved (declared above alongside codexConfigModel)
|
||||
// stays false until the integration probe confirms this session is
|
||||
// custom-config, so we don't flash an empty picker while loading.
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
if (hasCodexCustomConfig) {
|
||||
// Config.toml with a pinned model → show just that model.
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
// Config.toml custom provider without a pinned model → codex-acp
|
||||
// uses its provider default. Don't surface the OpenAI presets; they
|
||||
// wouldn't work. Empty list disables the picker.
|
||||
return [];
|
||||
}
|
||||
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
|
||||
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
|
||||
+35
-11
@@ -23,6 +23,9 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -166,10 +169,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
// Permission mode chip removed — agents run in autonomous mode
|
||||
|
||||
// selectedModelId may be "model/thinking" for codex
|
||||
const selectedBaseModelId = selectedModelId?.split('/')[0];
|
||||
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
|
||||
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
|
||||
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
|
||||
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
|
||||
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
|
||||
// split on the first '/'. Match against the full id first; only treat the
|
||||
// trailing segment as a thinking level when we find a preset whose
|
||||
// declared thinkingLevels make the combined form equal to selectedModelId.
|
||||
const { selectedPreset, selectedThinking } = (() => {
|
||||
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
const direct = modelPresets.find(m => m.id === selectedModelId);
|
||||
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
|
||||
const viaThinking = modelPresets.find(
|
||||
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
|
||||
);
|
||||
if (viaThinking) {
|
||||
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
|
||||
return { selectedPreset: viaThinking, selectedThinking: thinking };
|
||||
}
|
||||
return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
})();
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
@@ -375,7 +394,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
if (!hasModelPicker) return;
|
||||
if (!showModelPicker) {
|
||||
const rect = modelBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
if (rect) {
|
||||
// Clamp so the popover stays inside the viewport when
|
||||
// the chip is near the right edge of a narrow AI side
|
||||
// panel.
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
@@ -395,8 +420,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
@@ -420,12 +445,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 text-foreground/85">{preset.name}</span>
|
||||
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
|
||||
</button>
|
||||
{/* Thinking level sub-menu */}
|
||||
{hasThinking && hoveredModelId === preset.id && (
|
||||
|
||||
@@ -30,7 +30,7 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
|
||||
import { findManagedAgentProvider, matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -556,16 +556,13 @@ export function useAIChatStreaming({
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
// Resolve the correct provider based on agent type:
|
||||
// - Claude agent → anthropic provider (prefer over generic custom)
|
||||
// - Codex agent → openai provider
|
||||
// - Codex agent → openai provider (fallback to openai-compatible custom)
|
||||
const agentProviderId = (() => {
|
||||
if (matchesManagedAgentConfig(agentConfig, 'claude')) {
|
||||
return (
|
||||
context.providers.find(p => p.providerId === 'anthropic' && p.enabled && p.apiKey)?.id
|
||||
?? context.providers.find(p => p.providerId === 'custom' && p.enabled && p.apiKey && p.baseURL)?.id
|
||||
);
|
||||
return findManagedAgentProvider(context.providers, 'claude')?.id;
|
||||
}
|
||||
if (matchesManagedAgentConfig(agentConfig, 'codex')) {
|
||||
return context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey)?.id;
|
||||
return findManagedAgentProvider(context.providers, 'codex')?.id;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import {
|
||||
findManagedAgentProvider,
|
||||
getManagedAgentStoredPath,
|
||||
matchesManagedAgentConfig,
|
||||
type ManagedAgentKey,
|
||||
@@ -304,18 +305,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
||||
], [externalAgents, t]);
|
||||
|
||||
const hasOpenAiProviderKey = providers.some(
|
||||
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
|
||||
);
|
||||
const hasCodexCompatibleProvider = Boolean(findManagedAgentProvider(providers, "codex"));
|
||||
|
||||
const refreshCodexIntegration = useCallback(async () => {
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration();
|
||||
const integration = await bridge.aiCodexGetIntegration(opts);
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
@@ -524,9 +523,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
integration={codexIntegration}
|
||||
loginSession={codexLoginSession}
|
||||
isLoading={isCodexLoading}
|
||||
hasOpenAiProviderKey={hasOpenAiProviderKey}
|
||||
hasCompatibleProvider={hasCodexCompatibleProvider}
|
||||
error={codexError}
|
||||
onRefresh={() => void refreshCodexIntegration()}
|
||||
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
|
||||
onConnect={() => void handleStartCodexLogin()}
|
||||
onCancel={() => void handleCancelCodexLogin()}
|
||||
onOpenUrl={handleOpenCodexLoginUrl}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration: CodexIntegrationStatus | null;
|
||||
loginSession: CodexLoginSession | null;
|
||||
isLoading: boolean;
|
||||
hasOpenAiProviderKey: boolean;
|
||||
hasCompatibleProvider: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
@@ -31,7 +31,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration,
|
||||
loginSession,
|
||||
isLoading,
|
||||
hasOpenAiProviderKey,
|
||||
hasCompatibleProvider,
|
||||
error,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
@@ -42,6 +42,14 @@ export const CodexConnectionCard: React.FC<{
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const customConfigIncomplete = Boolean(
|
||||
integration?.state === "connected_custom_config"
|
||||
&& integration.customConfig
|
||||
&& integration.customConfig.envKey
|
||||
&& !integration.customConfig.envKeyPresent
|
||||
&& !integration.customConfig.hasHardcodedApiKey,
|
||||
);
|
||||
|
||||
const status = isResolvingPath
|
||||
? t('ai.codex.detecting')
|
||||
: !found
|
||||
@@ -52,9 +60,13 @@ export const CodexConnectionCard: React.FC<{
|
||||
? t('ai.codex.connectedChatGPT')
|
||||
: integration?.state === "connected_api_key"
|
||||
? t('ai.codex.connectedApiKey')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
: integration?.state === "connected_custom_config"
|
||||
? customConfigIncomplete
|
||||
? t('ai.codex.customConfigIncomplete')
|
||||
: t('ai.codex.connectedCustomConfig')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
@@ -62,9 +74,11 @@ export const CodexConnectionCard: React.FC<{
|
||||
? "text-amber-500"
|
||||
: loginSession?.state === "running"
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
: customConfigIncomplete
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const outputText = loginSession?.error
|
||||
? loginSession.error
|
||||
@@ -139,6 +153,9 @@ export const CodexConnectionCard: React.FC<{
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : integration?.state === "connected_custom_config" ? (
|
||||
// Nothing to log out of; config.toml is user-owned state.
|
||||
null
|
||||
) : integration?.isConnected ? (
|
||||
<Button variant="outline" size="sm" onClick={onLogout}>
|
||||
<LogOut size={14} className="mr-1.5" />
|
||||
@@ -157,7 +174,26 @@ export const CodexConnectionCard: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasOpenAiProviderKey && (
|
||||
{integration?.state === "connected_custom_config" && integration.customConfig && (
|
||||
<>
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.customConfigHint').replace(
|
||||
'{provider}',
|
||||
integration.customConfig.displayName || integration.customConfig.providerName,
|
||||
)}
|
||||
</p>
|
||||
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.customConfigMissingEnvKey').replace(
|
||||
'{envKey}',
|
||||
integration.customConfig.envKey,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasCompatibleProvider && integration?.state !== "connected_custom_config" && (
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.apiKeyHint')}
|
||||
</p>
|
||||
|
||||
@@ -10,14 +10,27 @@ import type {
|
||||
export type CodexIntegrationState =
|
||||
| "connected_chatgpt"
|
||||
| "connected_api_key"
|
||||
| "connected_custom_config"
|
||||
| "not_logged_in"
|
||||
| "unknown";
|
||||
|
||||
export interface CodexCustomProviderConfig {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
}
|
||||
|
||||
export interface CodexIntegrationStatus {
|
||||
state: CodexIntegrationState;
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: CodexCustomProviderConfig | null;
|
||||
}
|
||||
|
||||
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
|
||||
@@ -57,7 +70,7 @@ export interface FetchBridge {
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { existsSync } = require("node:fs");
|
||||
const { existsSync, readFileSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
|
||||
@@ -124,6 +125,212 @@ function getActiveCodexLoginSession() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Codex config.toml probing ──
|
||||
//
|
||||
// Users who hand-configure `~/.codex/config.toml` with a custom
|
||||
// `model_provider` + matching `[model_providers.<name>]` entry are fully
|
||||
// functional from the Codex CLI, but `codex login status` doesn't see them
|
||||
// because it only reports on `~/.codex/auth.json` (populated by `codex login`).
|
||||
// We read and minimally parse the config file so we can surface this as a
|
||||
// valid "ready" state and skip the ChatGPT login prompt in the UI.
|
||||
|
||||
/** Find `#` outside quoted regions. Tracks escape state via a flag rather
|
||||
* than peeking at the previous character, so even runs of backslashes like
|
||||
* `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML
|
||||
* strings don't recognize `\` as an escape, so only honor escapes inside
|
||||
* basic (double-quoted) strings. */
|
||||
function findUnquotedHash(value) {
|
||||
let inStr = false;
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (inStr) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"' && ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
inStr = false;
|
||||
quote = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inStr = true;
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "#") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the narrow subset of TOML we need from Codex's config.toml:
|
||||
* - top-level string keys (e.g. `model_provider = "my_provider"`)
|
||||
* - `[model_providers.<name>]` tables with string-valued keys
|
||||
* Unsupported TOML features (arrays, inline tables, multi-line strings, etc.)
|
||||
* are ignored — Codex's config.toml doesn't use them for provider definitions.
|
||||
*/
|
||||
function parseCodexConfigToml(text) {
|
||||
const result = { model_providers: {} };
|
||||
let currentProvider = null;
|
||||
let atTopLevel = true;
|
||||
|
||||
// Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files.
|
||||
const normalized = String(text || "").replace(/^\uFEFF/, "");
|
||||
const lines = normalized.split(/\r?\n/);
|
||||
for (const rawLine of lines) {
|
||||
let line = rawLine;
|
||||
const hashIdx = findUnquotedHash(line);
|
||||
if (hashIdx >= 0) line = line.slice(0, hashIdx);
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
const section = sectionMatch[1].trim();
|
||||
if (section.startsWith("model_providers.")) {
|
||||
currentProvider = section.slice("model_providers.".length);
|
||||
if (!result.model_providers[currentProvider]) {
|
||||
result.model_providers[currentProvider] = {};
|
||||
}
|
||||
atTopLevel = false;
|
||||
} else {
|
||||
currentProvider = null;
|
||||
atTopLevel = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/);
|
||||
if (!kvMatch) continue;
|
||||
const key = kvMatch[1];
|
||||
let raw = kvMatch[2].trim();
|
||||
let value;
|
||||
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
||||
value = raw.slice(1, -1);
|
||||
} else {
|
||||
value = raw;
|
||||
}
|
||||
|
||||
if (atTopLevel) {
|
||||
result[key] = value;
|
||||
} else if (currentProvider) {
|
||||
result.model_providers[currentProvider][key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect `~/.codex/config.toml` to determine whether the user has
|
||||
* configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT
|
||||
* path.
|
||||
*
|
||||
* Returns null when:
|
||||
* - the config file doesn't exist or can't be read
|
||||
* - no `model_provider` is set, or it points to the default `openai` preset
|
||||
* - the referenced provider entry is missing (config is malformed)
|
||||
*
|
||||
* Returns a summary object otherwise — even if the env_key isn't currently
|
||||
* exported in the shell environment. That case is surfaced via
|
||||
* `envKeyPresent: false` so the UI can warn the user; we don't want the
|
||||
* absence of an env var to silently fall back to the ChatGPT login flow,
|
||||
* because the config.toml is a strong signal the user doesn't want that.
|
||||
*/
|
||||
function readCodexCustomProviderConfig(shellEnv) {
|
||||
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
|
||||
if (!home) return null;
|
||||
const configPath = path.join(home, ".codex", "config.toml");
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(configPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseCodexConfigToml(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeName = typeof parsed.model_provider === "string"
|
||||
? parsed.model_provider.trim()
|
||||
: "";
|
||||
if (!activeName) return null;
|
||||
// The built-in "openai" provider still goes through ChatGPT/API-key auth
|
||||
// managed by `codex login`, so treating it as "custom" would be wrong.
|
||||
if (activeName === "openai") return null;
|
||||
|
||||
const providerEntry = parsed.model_providers?.[activeName];
|
||||
if (!providerEntry) return null;
|
||||
|
||||
const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : "";
|
||||
const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : "";
|
||||
const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : "";
|
||||
const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
|
||||
|
||||
// Hash the actual auth material (either the hardcoded api_key or the
|
||||
// resolved env_key value) so the ACP provider fingerprint changes when
|
||||
// the user rotates their key — without ever returning the raw value
|
||||
// across the IPC boundary.
|
||||
const authMaterial = hardcodedApiKey || envKeyValue;
|
||||
const authHash = authMaterial
|
||||
? createHash("sha256").update(authMaterial).digest("hex")
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerName: activeName,
|
||||
displayName: providerEntry.name || activeName,
|
||||
baseUrl: providerEntry.base_url || null,
|
||||
envKey: envKeyName || null,
|
||||
envKeyPresent: Boolean(envKeyValue),
|
||||
hasHardcodedApiKey: Boolean(hardcodedApiKey),
|
||||
model: activeModel || null,
|
||||
authHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-facing error message when a Codex config.toml custom
|
||||
* provider references an env_key that isn't exported in the shell env and
|
||||
* doesn't have a hardcoded api_key either — otherwise returns null. Shared
|
||||
* by every spawn path (stream handler, list-models handler) so users get
|
||||
* the same actionable message regardless of which one hits first.
|
||||
*/
|
||||
function getCodexCustomConfigPreflightError(customConfig) {
|
||||
if (!customConfig) return null;
|
||||
if (!customConfig.envKey) return null;
|
||||
if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null;
|
||||
return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the ACP auth override object for Codex spawn sites.
|
||||
* - netcatty-managed API key present → "codex-api-key"
|
||||
* - user's own ~/.codex/config.toml custom provider detected → no override
|
||||
* (so codex-acp resolves auth from the shell env / config itself)
|
||||
* - otherwise → "chatgpt" (triggers the browser OAuth login flow)
|
||||
*
|
||||
* Returned as an object designed to be spread into createACPProvider options.
|
||||
*/
|
||||
function getCodexAuthOverride(apiKey, shellEnv) {
|
||||
if (apiKey) return { authMethodId: "codex-api-key" };
|
||||
if (readCodexCustomProviderConfig(shellEnv)) return {};
|
||||
return { authMethodId: "chatgpt" };
|
||||
}
|
||||
|
||||
// ── Integration state ──
|
||||
|
||||
function normalizeCodexIntegrationState(rawOutput) {
|
||||
@@ -199,6 +406,9 @@ module.exports = {
|
||||
toCodexLoginSessionResponse,
|
||||
getActiveCodexLoginSession,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
getCodexAuthOverride,
|
||||
getCodexCustomConfigPreflightError,
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
getCodexAuthFingerprint,
|
||||
|
||||
@@ -211,6 +211,15 @@ async function getShellEnv() {
|
||||
return _cachedShellEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the shell-env cache so the next getShellEnv() call re-spawns the
|
||||
* login shell. Useful when the user has just exported a new variable in
|
||||
* their rc file and clicks "Refresh Status" without restarting the app.
|
||||
*/
|
||||
function invalidateShellEnvCache() {
|
||||
_cachedShellEnv = null;
|
||||
}
|
||||
|
||||
// ── Claude Code ACP binary resolution ──
|
||||
|
||||
/**
|
||||
@@ -316,5 +325,6 @@ module.exports = {
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
};
|
||||
+109
-13
@@ -24,6 +24,7 @@ const {
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
toUnpackedAsarPath,
|
||||
} = require("./ai/shellUtils.cjs");
|
||||
@@ -35,6 +36,9 @@ const {
|
||||
toCodexLoginSessionResponse,
|
||||
getActiveCodexLoginSession,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
getCodexAuthOverride,
|
||||
getCodexCustomConfigPreflightError,
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
getCodexAuthFingerprint,
|
||||
@@ -234,6 +238,34 @@ function resolveProviderApiKey(providerId) {
|
||||
};
|
||||
}
|
||||
|
||||
function getAcpProviderAuthFingerprint(apiKey, provider, customConfig) {
|
||||
const parts = [
|
||||
typeof apiKey === "string" ? apiKey.trim() : "",
|
||||
typeof provider?.id === "string" ? provider.id.trim() : "",
|
||||
typeof provider?.providerId === "string" ? provider.providerId.trim() : "",
|
||||
typeof provider?.baseURL === "string" ? provider.baseURL.trim() : "",
|
||||
customConfig
|
||||
? [
|
||||
"custom",
|
||||
customConfig.providerName || "",
|
||||
customConfig.baseUrl || "",
|
||||
customConfig.envKey || "",
|
||||
customConfig.envKeyPresent ? "1" : "0",
|
||||
// authHash changes when the user rotates their hardcoded api_key
|
||||
// or the env_key's resolved value; without it a cached ACP
|
||||
// provider would keep serving the stale key.
|
||||
customConfig.authHash || "",
|
||||
].join(":")
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCodexAuthFingerprint(parts.join("\n"));
|
||||
}
|
||||
|
||||
/** Check if TLS verification should be skipped for a given provider. */
|
||||
function shouldSkipTLSVerify(providerId) {
|
||||
if (!providerId) return false;
|
||||
@@ -1689,8 +1721,14 @@ function registerHandlers(ipcMain) {
|
||||
return { path: resolvedPath, version, available: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// When the user clicks "Refresh Status" in Settings we also want to
|
||||
// rescan the shell env — otherwise a newly-exported variable in
|
||||
// .zshrc stays invisible until they restart netcatty entirely.
|
||||
if (options && options.refreshShellEnv) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
try {
|
||||
const result = await runCodexCli(["login", "status"]);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
@@ -1724,11 +1762,33 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
// `codex login status` only reflects ~/.codex/auth.json. A user who
|
||||
// configured a custom provider directly in ~/.codex/config.toml is
|
||||
// functional from the CLI but would look "not_logged_in" here. Probe
|
||||
// config.toml so we can surface that as a valid ready state instead of
|
||||
// pushing the user into the ChatGPT login flow.
|
||||
let customConfig = null;
|
||||
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
customConfig = readCodexCustomProviderConfig(shellEnv);
|
||||
if (customConfig) {
|
||||
state = "connected_custom_config";
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput: effectiveRawOutput,
|
||||
exitCode: result.exitCode,
|
||||
customConfig,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -1736,6 +1796,7 @@ function registerHandlers(ipcMain) {
|
||||
isConnected: false,
|
||||
rawOutput: err?.message || String(err),
|
||||
exitCode: null,
|
||||
customConfig: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1847,7 +1908,10 @@ function registerHandlers(ipcMain) {
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput,
|
||||
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
@@ -2102,6 +2166,19 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
// Mirror the stream handler's pre-flight: if Codex is pointed at a
|
||||
// config.toml custom provider whose env_key is not exported, surface
|
||||
// a targeted error instead of spawning codex-acp and letting it fail
|
||||
// mid-init with an opaque message.
|
||||
if (isCodexAgent && !apiKey) {
|
||||
const preflight = getCodexCustomConfigPreflightError(
|
||||
readCodexCustomProviderConfig(shellEnv),
|
||||
);
|
||||
if (preflight) {
|
||||
return { ok: false, models: [], error: preflight };
|
||||
}
|
||||
}
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
@@ -2143,7 +2220,7 @@ function registerHandlers(ipcMain) {
|
||||
mcpServers: [],
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
@@ -2254,7 +2331,28 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
if (isCodexAgent && !apiKey) {
|
||||
// Probe ~/.codex/config.toml first so we can tell a ChatGPT user
|
||||
// (needs login validation) from a custom-provider user (must NOT be
|
||||
// forced through ChatGPT validation, since their auth lives in
|
||||
// config.toml / shell env, not auth.json).
|
||||
const codexCustomConfig = isCodexAgent && !apiKey
|
||||
? readCodexCustomProviderConfig(shellEnv)
|
||||
: null;
|
||||
|
||||
// Fail loud: custom-provider config is set but has no usable auth
|
||||
// material yet (env_key is named but not exported in the shell env,
|
||||
// and no api_key is hardcoded). Don't spawn — codex-acp would fail
|
||||
// mid-request with an opaque "Missing environment variable" error.
|
||||
const preflightError = getCodexCustomConfigPreflightError(codexCustomConfig);
|
||||
if (preflightError) {
|
||||
safeSend(event.sender, "netcatty:ai:acp:error", {
|
||||
requestId,
|
||||
error: preflightError,
|
||||
});
|
||||
return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` };
|
||||
}
|
||||
|
||||
if (isCodexAgent && !apiKey && !codexCustomConfig) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
if (!validation.ok) {
|
||||
@@ -2275,11 +2373,9 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
const authFingerprint = isCodexAgent
|
||||
? getCodexAuthFingerprint(apiKey)
|
||||
: isClaudeAgent
|
||||
? getCodexAuthFingerprint(apiKey + (resolvedProvider?.provider?.baseURL || ""))
|
||||
: null;
|
||||
const authFingerprint = isCodexAgent || isClaudeAgent
|
||||
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
|
||||
: null;
|
||||
const mcpSnapshot = isCodexAgent
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
@@ -2388,7 +2484,7 @@ function registerHandlers(ipcMain) {
|
||||
},
|
||||
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
@@ -2400,7 +2496,7 @@ function registerHandlers(ipcMain) {
|
||||
resolvedCommand,
|
||||
resolvedArgs,
|
||||
mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name),
|
||||
authMethodId: isCodexAgent ? (apiKey ? "codex-api-key" : "chatgpt") : null,
|
||||
authMethodId: isCodexAgent ? (getCodexAuthOverride(apiKey, shellEnv).authMethodId || null) : null,
|
||||
});
|
||||
|
||||
if (isCopilotAgent) {
|
||||
@@ -2496,7 +2592,7 @@ function registerHandlers(ipcMain) {
|
||||
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
|
||||
@@ -1184,8 +1184,8 @@ const api = {
|
||||
aiResolveCli: async (params) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
|
||||
},
|
||||
aiCodexGetIntegration: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
|
||||
aiCodexGetIntegration: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-integration", options);
|
||||
},
|
||||
aiCodexStartLogin: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
|
||||
|
||||
Vendored
+12
-2
@@ -732,11 +732,21 @@ declare global {
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}>>;
|
||||
aiCodexGetIntegration?(): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
|
||||
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
aiCodexStartLogin?(): Promise<{
|
||||
ok: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
|
||||
import type { DiscoveredAgent, ExternalAgentConfig, ProviderConfig } from './types';
|
||||
|
||||
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
|
||||
|
||||
@@ -67,3 +67,24 @@ export function getManagedAgentStoredPath(
|
||||
);
|
||||
return fallbackAgent?.command ?? null;
|
||||
}
|
||||
|
||||
export function findManagedAgentProvider(
|
||||
providers: ProviderConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
): ProviderConfig | undefined {
|
||||
if (agentKey === 'codex') {
|
||||
return (
|
||||
providers.find((provider) => provider.providerId === 'openai' && provider.enabled && !!provider.apiKey)
|
||||
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
|
||||
);
|
||||
}
|
||||
|
||||
if (agentKey === 'claude') {
|
||||
return (
|
||||
providers.find((provider) => provider.providerId === 'anthropic' && provider.enabled && !!provider.apiKey)
|
||||
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Reference in new issue
Block a user