From d0d03bed636f8364ec65970d2d80d949e337d3d1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:53:29 +0200 Subject: [PATCH 1/7] fix(gui): re-split ClaudeCode and ApiKeys after foundations rebase Rebase onto upstream/dev and re-apply the react-doctor module split against current stacked pages, including API key load/create/delete failure UI state. --- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/ApiKeys.tsx | 382 +++++++--------------- gui/src/pages/ClaudeCode.tsx | 422 +++---------------------- gui/src/pages/api-keys-panels.tsx | 332 +++++++++++++++++++ gui/src/pages/api-keys-utils.ts | 41 +++ gui/src/pages/claude-code-sections.tsx | 265 ++++++++++++++++ gui/src/pages/claude-code-settings.tsx | 106 +++++++ gui/src/pages/claude-code-types.ts | 31 ++ gui/tests/apikeys-layout.test.ts | 20 +- gui/tests/claudecode-layout.test.ts | 10 +- 15 files changed, 963 insertions(+), 664 deletions(-) create mode 100644 gui/src/pages/api-keys-panels.tsx create mode 100644 gui/src/pages/api-keys-utils.ts create mode 100644 gui/src/pages/claude-code-sections.tsx create mode 100644 gui/src/pages/claude-code-settings.tsx create mode 100644 gui/src/pages/claude-code-types.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 76639a7f8..e74c3599e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -765,6 +765,9 @@ export const de: Record = { "api.confirm": "Bestätigen", "api.deleteAria": "API-Schlüssel löschen", "api.usageSampleInput": "Hallo, Welt!", + "api.keysLoadFailed": "API-Schlüssel konnten nicht geladen werden.", + "api.createFailed": "API-Schlüssel konnte nicht erstellt werden.", + "api.deleteFailed": "API-Schlüssel konnte nicht gelöscht werden.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "GPT, Gemini und andere Modelle in Claude Code verwenden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b6712e9fb..c6b3db64d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1068,6 +1068,9 @@ export const en = { "api.usageResponsesTitle": "Responses example", "api.usageMessagesTitle": "Messages example", "api.usageSampleInput": "Hello, world!", + "api.keysLoadFailed": "Could not load API keys.", + "api.createFailed": "Could not create API key.", + "api.deleteFailed": "Could not delete API key.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Use GPT, Gemini, and other models inside Claude Code.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index afb687806..a719cc214 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1022,6 +1022,9 @@ export const ja: Record = { "api.confirm": "確認", "api.deleteAria": "API キーを削除", "api.usageSampleInput": "こんにちは、世界!", + "api.keysLoadFailed": "APIキーを読み込めませんでした。", + "api.createFailed": "APIキーを作成できませんでした。", + "api.deleteFailed": "APIキーを削除できませんでした。", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Claude Code 内で GPT、Gemini などのモデルを使用します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6fea283bd..a1d81c080 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -785,6 +785,9 @@ export const ko: Record = { "api.confirm": "확인", "api.deleteAria": "API 키 삭제", "api.usageSampleInput": "안녕하세요, 세계!", + "api.keysLoadFailed": "API 키를 불러오지 못했습니다.", + "api.createFailed": "API 키를 만들지 못했습니다.", + "api.deleteFailed": "API 키를 삭제하지 못했습니다.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Claude Code에서 GPT, Gemini 등 다른 모델도 쓸 수 있게 해줍니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6b7da5154..a9114f35c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1067,6 +1067,9 @@ export const ru: Record = { "api.confirm": "Подтвердить", "api.deleteAria": "Удалить API-ключ", "api.usageSampleInput": "Привет, мир!", + "api.keysLoadFailed": "Не удалось загрузить API-ключи.", + "api.createFailed": "Не удалось создать API-ключ.", + "api.deleteFailed": "Не удалось удалить API-ключ.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Используйте GPT, Gemini и другие модели внутри Claude Code.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c6ae2ae71..1ceaefbd3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -785,6 +785,9 @@ export const zh: Record = { "api.confirm": "确认", "api.deleteAria": "删除 API 密钥", "api.usageSampleInput": "你好,世界!", + "api.keysLoadFailed": "无法加载 API 密钥。", + "api.createFailed": "无法创建 API 密钥。", + "api.deleteFailed": "无法删除 API 密钥。", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "在 Claude Code 中使用 GPT、Gemini 等其他模型。", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index a986dba2a..57e29237d 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,53 +1,40 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { IconCheck, IconPlus, IconX } from "../icons"; +import { Notice } from "../ui"; import { useI18n, LOCALES } from "../i18n/shared"; +import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { classifyExternalModel, externalModelId, - gatewayInboundProtocols, type ExternalModelRow, } from "../api-access-models"; +import { + DEFAULT_ENDPOINTS, + deriveApiEndpoints, + type ApiEndpointInfo, + type ApiKeyEntry, + type ModelTestState, +} from "./api-keys-utils"; +import { + ApiKeysAuthPanel, + ApiKeysEndpointsPanel, + ApiKeysManagePanel, + ApiKeysModelsPanel, + ApiKeysUsagePanel, +} from "./api-keys-panels"; -interface ApiKeyEntry { - id: string; - name: string; - prefix: string; - createdAt: string; -} - -interface ApiEndpointInfo { - baseUrl: string; - responses: string; - chatCompletions: string; - messages: string; - models: string; -} - -type ModelTestState = "idle" | "testing" | "ok" | "error"; - -const DEFAULT_ENDPOINTS: ApiEndpointInfo = { - baseUrl: "http://127.0.0.1:10100/v1", - responses: "http://127.0.0.1:10100/v1/responses", - chatCompletions: "http://127.0.0.1:10100/v1/chat/completions", - messages: "http://127.0.0.1:10100/v1/messages", - models: "http://127.0.0.1:10100/v1/models", -}; - -function deriveApiEndpoints(endpoint: string): ApiEndpointInfo { - const responses = endpoint || DEFAULT_ENDPOINTS.responses; - const match = responses.match(/^(.*)\/v1\/responses\/?$/); - const baseUrl = match ? `${match[1]}/v1` : responses.replace(/\/responses\/?$/, ""); - return { - baseUrl, - responses, - chatCompletions: `${baseUrl}/chat/completions`, - messages: `${baseUrl}/messages`, - models: `${baseUrl}/models`, - }; +interface KeysResponse { + keys?: ApiKeyEntry[]; + endpoint?: string; + baseUrl?: string; + responsesEndpoint?: string; + chatCompletionsEndpoint?: string; + messagesEndpoint?: string; + modelsEndpoint?: string; + claudeCodeEnabled?: boolean; } -function formatCreatedDate(iso: string, localeTag?: string): string { - return new Date(iso).toLocaleDateString(localeTag); +interface CreateKeyResponse { + key?: unknown; } export default function ApiKeys({ apiBase }: { apiBase: string }) { @@ -56,6 +43,8 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const [keys, setKeys] = useState([]); const [endpoints, setEndpoints] = useState(DEFAULT_ENDPOINTS); const [claudeCodeEnabled, setClaudeCodeEnabled] = useState(true); + const [keysLoadFailed, setKeysLoadFailed] = useState(false); + const [actionError, setActionError] = useState(null); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoadFailed, setModelsLoadFailed] = useState(false); @@ -72,19 +61,27 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const fetchKeys = useCallback(async () => { try { const res = await fetch(`${apiBase}/api/keys`); - if (res.ok) { - const data = await res.json(); - setKeys(data.keys ?? []); - setEndpoints({ - baseUrl: data.baseUrl ?? deriveApiEndpoints(data.endpoint ?? "").baseUrl, - responses: data.responsesEndpoint ?? data.endpoint ?? DEFAULT_ENDPOINTS.responses, - chatCompletions: data.chatCompletionsEndpoint ?? deriveApiEndpoints(data.endpoint ?? "").chatCompletions, - messages: data.messagesEndpoint ?? deriveApiEndpoints(data.endpoint ?? "").messages, - models: data.modelsEndpoint ?? deriveApiEndpoints(data.endpoint ?? "").models, - }); - setClaudeCodeEnabled(data.claudeCodeEnabled !== false); + const data = await readJsonIfOk(res); + if (!data) { + setKeys([]); + setKeysLoadFailed(true); + return; } - } catch { /* proxy down */ } + const derived = deriveApiEndpoints(data.endpoint ?? ""); + setKeys(data.keys ?? []); + setEndpoints({ + baseUrl: data.baseUrl ?? derived.baseUrl, + responses: data.responsesEndpoint ?? data.endpoint ?? DEFAULT_ENDPOINTS.responses, + chatCompletions: data.chatCompletionsEndpoint ?? derived.chatCompletions, + messages: data.messagesEndpoint ?? derived.messages, + models: data.modelsEndpoint ?? derived.models, + }); + setClaudeCodeEnabled(data.claudeCodeEnabled !== false); + setKeysLoadFailed(false); + } catch { + setKeys([]); + setKeysLoadFailed(true); + } }, [apiBase]); const fetchModels = useCallback(async () => { @@ -148,6 +145,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { if (creatingRef.current) return false; creatingRef.current = true; setCreating(true); + setActionError(null); try { const effectiveName = name ?? newName; const res = await fetch(`${apiBase}/api/keys`, { @@ -155,14 +153,17 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: effectiveName || "default" }), }); - if (!res.ok) return false; - const data = await res.json() as { key?: unknown }; - if (typeof data.key !== "string" || data.key.length === 0) return false; + const data = await readJsonOrThrow(res, t("api.createFailed")); + if (typeof data?.key !== "string" || data.key.length === 0) { + setActionError(t("api.createFailed")); + return false; + } setNewKey(data.key); setNewName(""); void fetchKeys(); return true; } catch { + setActionError(t("api.createFailed")); return false; } finally { creatingRef.current = false; @@ -171,13 +172,22 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { }; const handleDelete = async (id: string) => { - await fetch(`${apiBase}/api/keys`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - setConfirmDelete(null); - fetchKeys(); + setActionError(null); + try { + const res = await fetch(`${apiBase}/api/keys`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (!res.ok) { + setActionError(t("api.deleteFailed")); + return; + } + setConfirmDelete(null); + void fetchKeys(); + } catch { + setActionError(t("api.deleteFailed")); + } }; const copyKey = () => { @@ -258,220 +268,44 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { {subtitleParts[2]}

-
-

{t("api.endpointsTitle")}

-
-
- {t("api.baseUrl")} - {endpoints.baseUrl} -
-
- {t("api.responsesEndpoint")} - {endpoints.responses} -
-
- {t("api.chatCompletionsEndpoint")} - {endpoints.chatCompletions} -
- {claudeCodeEnabled && ( -
- {t("api.messagesEndpoint")} - {endpoints.messages} -
- )} -
- {t("api.modelsEndpoint")} - {endpoints.models} -
-
-

{t("api.endpointNote")}

-
- -
-

{t("api.authTitle")}

-
    -
  • {t("api.authChatCompletions")}
  • -
  • {t("api.authResponses")}
  • - {claudeCodeEnabled &&
  • {t("api.authMessages")}
  • } -
  • {t("api.authLoopback")}
  • -
-

{t("api.authBaseUrlNote")}

-
- - {newKey && ( -
-

{t("api.newKeyTitle")}

-

{t("api.newKeyNote")}

-
- {newKey} - -
- -
+ {(keysLoadFailed || actionError) && ( + {actionError ?? t("api.keysLoadFailed")} )} -
-

{t("api.generateTitle")}

-
- setNewName(e.target.value)} - className="input" - /> - -
-
- -
-

{t("api.activeKeys", { count: keys.length })}

- {keys.length === 0 ? ( -

{t("api.noKeys")}

- ) : ( -
- - - - - - {keys.map(k => ( - - - - - - - ))} - -
{t("api.colName")}{t("api.colKey")}{t("api.colCreated")}
{k.name}{k.prefix}{formatCreatedDate(k.createdAt, localeTag)} - {confirmDelete === k.id ? ( - - - - - ) : ( - - )} -
-
- )} -
- -
-
-

{t("api.modelsTitle")}

- {t("api.modelsCount", { count: filteredModels.length })} -
-

{t("api.modelsSubtitle")}

- setModelQuery(event.target.value)} - placeholder={t("api.modelsSearch")} - aria-label={t("api.modelsSearch")} - /> - {modelsLoading ? ( -

{t("api.modelsLoading")}

- ) : modelsLoadFailed ? ( -

{t("api.modelsLoadFailed")}

- ) : filteredModels.length === 0 ? ( -

{t("api.modelsEmpty")}

- ) : ( -
- - - - - - - - - - - {filteredModels.map(model => { - const modelId = externalModelId(model); - const testState = modelTests[modelId]?.state ?? "idle"; - return ( - - - - - - - ); - })} - -
{t("api.colModel")}{t("api.colSource")}{t("api.colProtocols")}
-
- {modelId} - {model.displayName !== model.id && {model.displayName}} -
-
{sourceLabel(model)}{gatewayInboundProtocols(claudeCodeEnabled).map(protocolLabel).join(", ")} -
- - -
- {testState === "ok" &&

{t("api.testSucceeded")}

} - {testState === "error" &&

{modelTests[modelId]?.detail ?? t("api.testFailed")}

} -
-
- )} -
- -
-

{t("api.usageChatTitle")}

-
{`curl ${endpoints.chatCompletions} \\
-  -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\
-  -H "Content-Type: application/json" \\
-  -d '{
-    "model": "gpt-5.4",
-    "messages": [{"role": "user", "content": ${JSON.stringify(t("api.usageSampleInput"))}}]
-  }'`}
-
- -
-

{t("api.usageResponsesTitle")}

-
{`curl ${endpoints.responses} \\
-  -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\
-  -H "Content-Type: application/json" \\
-  -d '{
-    "model": "gpt-5.4",
-    "input": ${JSON.stringify(t("api.usageSampleInput"))}
-  }'`}
-
- - {claudeCodeEnabled && ( -
-

{t("api.usageMessagesTitle")}

-
{`curl ${endpoints.messages} \\
-  -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\
-  -H "Content-Type: application/json" \\
-  -d '{
-    "model": "claude-sonnet-4-6",
-    "max_tokens": 64,
-    "messages": [{"role": "user", "content": ${JSON.stringify(t("api.usageSampleInput"))}}]
-  }'`}
-
- )} + + + { void handleCreate(); }} + onDismissNewKey={() => setNewKey(null)} + onCopyKey={copyKey} + onConfirmDelete={setConfirmDelete} + onCancelDelete={() => setConfirmDelete(null)} + onDelete={(id) => { void handleDelete(id); }} + /> + { void copyModelId(modelId); }} + onTestModel={(model) => { void testModel(model); }} + sourceLabel={sourceLabel} + protocolLabel={protocolLabel} + /> + ); } diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index 9bb93febf..e9ea81537 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -1,141 +1,20 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Notice, Select, type SelectOption } from "../ui"; -import { IconPlus, IconX } from "../icons"; -import { Trans } from "../i18n/provider"; +import { Notice } from "../ui"; import { useT } from "../i18n/shared"; import { modelLabel } from "../model-display"; +import { readJsonOrThrow } from "../fetch-json"; import { reconcileAutoConnectState } from "./claude-autoconnect"; -import { buildManualEnv, type SidecarBackend, type SidecarOverride } from "./claude-manual-env"; - -interface ClaudeCodeState { - enabled: boolean; - authMode: "subscription" | "proxy"; - autoConnectSupported: boolean; - systemEnv: boolean; - fastMode: boolean | null; - /** Legacy config override (no GUI control anymore) — still disables auto-context when hand-set. */ - maxContextTokens: number | null; - autoContext: boolean; - autoCompactWindow: number | null; - injectAgents: boolean; - smallFastModel: string; - tierModels?: { haiku?: string }; - effectiveModelEnv: Record; - available: string[]; - aliases: { id: string; display_name: string }[]; - webSearchSidecar?: SidecarOverride; - visionSidecar?: SidecarOverride; - port: number; -} - -interface MapRow { from: string; to: string } - -function formatCompactWindow(value: number): string { - return value >= 1_000_000 ? "1M" : `${Math.round(value / 1_000)}k`; -} - -function SettingToggle({ - label, - checked, - onChange, - disabled = false, - describedBy, -}: { - label: string; - checked: boolean; - onChange: (value: boolean) => void; - disabled?: boolean; - describedBy?: string; -}) { - return ( - - ); -} - -export function AutoConnectSetting({ - supported, - checked, - onChange, -}: { - supported: boolean; - checked: boolean; - onChange: (value: boolean) => void; -}) { - const t = useT(); - const unsupportedDescriptionId = supported ? undefined : "claude-system-env-unsupported"; - - return ( -
-
- {t("claude.systemEnv")} - {supported ? ( - {t("claude.systemEnvDesc")} - ) : ( - - - - )} - {supported && checked && ( - - {t("claude.systemEnvWarn")} - - )} -
- -
- ); -} - -export function SmallFastModelSetting({ - value, - tierHaikuModel, - options, - onChange, -}: { - value: string; - tierHaikuModel?: string; - options: SelectOption[]; - onChange: (value: string) => void; -}) { - const t = useT(); - const effectiveHelperModel = tierHaikuModel ?? value; - return ( - <> -
{t("claude.smallFastModel")}
-

- {t("claude.smallFastModelAccurateHint")} -

- setState({ ...state, authMode: v as ClaudeCodeState["authMode"] })} - label={t("claude.authMode")} - style={{ minWidth: 220 }} - portal - /> - - - setState({ ...state, systemEnv })} - /> - -
-
- {t("claude.fastMode")} - {t("claude.fastModeDesc")} -
- -
- -
-
- {t("claude.autoContext")} - {t("claude.autoContextDesc")} - {state.maxContextTokens !== null && {t("claude.autoContextInert")}} -
- setState({ ...state, autoContext })} /> -
- - {state.autoContext && ( -
-
- {t("claude.autoCompactWindow")} - {t("claude.autoCompactWindowDesc")} - {state.autoCompactWindow !== null && {t("claude.autoCompactWindowWarn")}} -
- setState({ - ...state, - [key]: value === "inherit" - ? undefined - : { ...override, backend: value === "auto" ? undefined : value as SidecarBackend }, - })} - label={t("dash.sidecarBackend")} - portal - /> - setState({ ...state, [key]: { ...override, model: e.target.value } })} - placeholder={t("claude.sidecarModelPlaceholder")} - disabled={!override} - aria-label={t("dash.sidecarModel")} - style={{ minWidth: 210 }} - /> -
- - ); - })} - - - ); - - const quickstartSection = ( - <> -
{t("claude.quickstart")}
-

-
ocx claude
-
- {t("claude.manualEnv")} -
{manualEnv}
-
- - ); - - const smallFastSection = ( - setState({ ...state, smallFastModel })} - /> - ); - - const modelMapSection = ( - <> -
{t("claude.modelMap")} {rows.length}
-

{t("claude.modelMapHint")}

-
- {rows.map((row, i) => ( -
- setRows(prev => prev.map((r, j) => j === i ? { ...r, from: e.target.value } : r))} - style={{ flex: 1 }} - /> - - setRows(prev => prev.map((r, j) => j === i ? { ...r, to: e.target.value } : r))} - style={{ flex: 1 }} - /> - -
- ))} -
-
- -
- -
- -
- - ); - - const aliasesSection = ( - <> -
{t("claude.aliases")} {state.aliases.length}
-

{t("claude.aliasesHint")}

- {state.aliases.length === 0 ? ( -
{t("claude.none")}
- ) : ( -
- {Array.from( - state.aliases.reduce((groups, a) => { - const m = /\(([^)]+)\)\s*$/.exec(a.display_name); - const provider = m ? m[1]! : "etc"; - (groups.get(provider) ?? groups.set(provider, []).get(provider)!).push(a); - return groups; - }, new Map()), - ).map(([provider, aliasRows]) => ( -
-
{provider} · {aliasRows.length}
-
- {aliasRows.map(a => ( -
- {a.id} - {a.display_name} -
- ))} -
-
- ))} -
- )} - - ); - return ( <>

{t("claude.pageTitle")}

{t("claude.subtitle")}

{status && {status}} - {settingsSection} - {quickstartSection} - {smallFastSection} - {modelMapSection} - {aliasesSection} + + + setState({ ...state, smallFastModel })} + /> + { void save(); }} /> + ); } diff --git a/gui/src/pages/api-keys-panels.tsx b/gui/src/pages/api-keys-panels.tsx new file mode 100644 index 000000000..54e38b058 --- /dev/null +++ b/gui/src/pages/api-keys-panels.tsx @@ -0,0 +1,332 @@ +import { IconCheck, IconPlus, IconX } from "../icons"; +import { useI18n } from "../i18n/shared"; +import { + externalModelId, + gatewayInboundProtocols, + type ExternalModelRow, +} from "../api-access-models"; +import { + formatCreatedDate, + type ApiEndpointInfo, + type ApiKeyEntry, + type ModelTestState, +} from "./api-keys-utils"; + +export function ApiKeysEndpointsPanel({ + endpoints, + claudeCodeEnabled, +}: { + endpoints: ApiEndpointInfo; + claudeCodeEnabled: boolean; +}) { + const { t } = useI18n(); + return ( +
+

{t("api.endpointsTitle")}

+
+
+ {t("api.baseUrl")} + {endpoints.baseUrl} +
+
+ {t("api.responsesEndpoint")} + {endpoints.responses} +
+
+ {t("api.chatCompletionsEndpoint")} + {endpoints.chatCompletions} +
+ {claudeCodeEnabled && ( +
+ {t("api.messagesEndpoint")} + {endpoints.messages} +
+ )} +
+ {t("api.modelsEndpoint")} + {endpoints.models} +
+
+

{t("api.endpointNote")}

+
+ ); +} + +export function ApiKeysAuthPanel({ claudeCodeEnabled }: { claudeCodeEnabled: boolean }) { + const { t } = useI18n(); + return ( +
+

{t("api.authTitle")}

+
    +
  • {t("api.authChatCompletions")}
  • +
  • {t("api.authResponses")}
  • + {claudeCodeEnabled &&
  • {t("api.authMessages")}
  • } +
  • {t("api.authLoopback")}
  • +
+

{t("api.authBaseUrlNote")}

+
+ ); +} + +export function ApiKeysManagePanel({ + keys, + keysLoadFailed, + newName, + creating, + newKey, + copied, + confirmDelete, + localeTag, + onNewNameChange, + onCreate, + onDismissNewKey, + onCopyKey, + onConfirmDelete, + onCancelDelete, + onDelete, +}: { + keys: ApiKeyEntry[]; + keysLoadFailed: boolean; + newName: string; + creating: boolean; + newKey: string | null; + copied: boolean; + confirmDelete: string | null; + localeTag?: string; + onNewNameChange: (value: string) => void; + onCreate: () => void; + onDismissNewKey: () => void; + onCopyKey: () => void; + onConfirmDelete: (id: string) => void; + onCancelDelete: () => void; + onDelete: (id: string) => void; +}) { + const { t } = useI18n(); + + return ( + <> + {newKey && ( +
+

{t("api.newKeyTitle")}

+

{t("api.newKeyNote")}

+
+ {newKey} + +
+ +
+ )} + +
+

{t("api.generateTitle")}

+
+ onNewNameChange(e.target.value)} + className="input" + /> + +
+
+ +
+

{t("api.activeKeys", { count: keys.length })}

+ {keysLoadFailed ? ( +

{t("api.keysLoadFailed")}

+ ) : keys.length === 0 ? ( +

{t("api.noKeys")}

+ ) : ( +
+ + + + + + {keys.map(k => ( + + + + + + + ))} + +
{t("api.colName")}{t("api.colKey")}{t("api.colCreated")}
{k.name}{k.prefix}{formatCreatedDate(k.createdAt, localeTag)} + {confirmDelete === k.id ? ( + + + + + ) : ( + + )} +
+
+ )} +
+ + ); +} + +export function ApiKeysModelsPanel({ + filteredModels, + modelsLoading, + modelsLoadFailed, + modelQuery, + copiedModelId, + modelTests, + claudeCodeEnabled, + onModelQueryChange, + onCopyModelId, + onTestModel, + sourceLabel, + protocolLabel, +}: { + filteredModels: ExternalModelRow[]; + modelsLoading: boolean; + modelsLoadFailed: boolean; + modelQuery: string; + copiedModelId: string | null; + modelTests: Record; + claudeCodeEnabled: boolean; + onModelQueryChange: (value: string) => void; + onCopyModelId: (modelId: string) => void; + onTestModel: (model: ExternalModelRow) => void; + sourceLabel: (model: ExternalModelRow) => string; + protocolLabel: (protocol: string) => string; +}) { + const { t } = useI18n(); + return ( +
+
+

{t("api.modelsTitle")}

+ {t("api.modelsCount", { count: filteredModels.length })} +
+

{t("api.modelsSubtitle")}

+ onModelQueryChange(event.target.value)} + placeholder={t("api.modelsSearch")} + aria-label={t("api.modelsSearch")} + /> + {modelsLoading ? ( +

{t("api.modelsLoading")}

+ ) : modelsLoadFailed ? ( +

{t("api.modelsLoadFailed")}

+ ) : filteredModels.length === 0 ? ( +

{t("api.modelsEmpty")}

+ ) : ( +
+ + + + + + + + + + + {filteredModels.map(model => { + const modelId = externalModelId(model); + const testState = modelTests[modelId]?.state ?? "idle"; + return ( + + + + + + + ); + })} + +
{t("api.colModel")}{t("api.colSource")}{t("api.colProtocols")}
+
+ {modelId} + {model.displayName !== model.id && {model.displayName}} +
+
{sourceLabel(model)}{gatewayInboundProtocols(claudeCodeEnabled).map(protocolLabel).join(", ")} +
+ + +
+ {testState === "ok" &&

{t("api.testSucceeded")}

} + {testState === "error" &&

{modelTests[modelId]?.detail ?? t("api.testFailed")}

} +
+
+ )} +
+ ); +} + +export function ApiKeysUsagePanel({ + endpoints, + claudeCodeEnabled, +}: { + endpoints: ApiEndpointInfo; + claudeCodeEnabled: boolean; +}) { + const { t } = useI18n(); + const sampleInput = JSON.stringify(t("api.usageSampleInput")); + + return ( + <> +
+

{t("api.usageChatTitle")}

+
{`curl ${endpoints.chatCompletions} \\
+  -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "model": "gpt-5.4",
+    "messages": [{"role": "user", "content": ${sampleInput}}]
+  }'`}
+
+ +
+

{t("api.usageResponsesTitle")}

+
{`curl ${endpoints.responses} \\
+  -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "model": "gpt-5.4",
+    "input": ${sampleInput}
+  }'`}
+
+ + {claudeCodeEnabled && ( +
+

{t("api.usageMessagesTitle")}

+
{`curl ${endpoints.messages} \\
+  -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "model": "claude-sonnet-4-6",
+    "max_tokens": 64,
+    "messages": [{"role": "user", "content": ${sampleInput}}]
+  }'`}
+
+ )} + + ); +} diff --git a/gui/src/pages/api-keys-utils.ts b/gui/src/pages/api-keys-utils.ts new file mode 100644 index 000000000..1c5c109c5 --- /dev/null +++ b/gui/src/pages/api-keys-utils.ts @@ -0,0 +1,41 @@ +export interface ApiKeyEntry { + id: string; + name: string; + prefix: string; + createdAt: string; +} + +export interface ApiEndpointInfo { + baseUrl: string; + responses: string; + chatCompletions: string; + messages: string; + models: string; +} + +export type ModelTestState = "idle" | "testing" | "ok" | "error"; + +export const DEFAULT_ENDPOINTS: ApiEndpointInfo = { + baseUrl: "http://127.0.0.1:10100/v1", + responses: "http://127.0.0.1:10100/v1/responses", + chatCompletions: "http://127.0.0.1:10100/v1/chat/completions", + messages: "http://127.0.0.1:10100/v1/messages", + models: "http://127.0.0.1:10100/v1/models", +}; + +export function deriveApiEndpoints(endpoint: string): ApiEndpointInfo { + const responses = endpoint || DEFAULT_ENDPOINTS.responses; + const match = responses.match(/^(.*)\/v1\/responses\/?$/); + const baseUrl = match ? `${match[1]}/v1` : responses.replace(/\/responses\/?$/, ""); + return { + baseUrl, + responses, + chatCompletions: `${baseUrl}/chat/completions`, + messages: `${baseUrl}/messages`, + models: `${baseUrl}/models`, + }; +} + +export function formatCreatedDate(iso: string, localeTag?: string): string { + return new Date(iso).toLocaleDateString(localeTag); +} diff --git a/gui/src/pages/claude-code-sections.tsx b/gui/src/pages/claude-code-sections.tsx new file mode 100644 index 000000000..f0c007672 --- /dev/null +++ b/gui/src/pages/claude-code-sections.tsx @@ -0,0 +1,265 @@ +import { IconPlus, IconX } from "../icons"; +import { useT } from "../i18n/shared"; +import { Trans } from "../i18n/provider"; +import { Select } from "../ui"; +import type { SidecarBackend } from "./claude-manual-env"; +import { AutoConnectSetting, SettingToggle } from "./claude-code-settings"; +import type { ClaudeCodeState, MapRow } from "./claude-code-types"; + +export function ClaudeCodeSettingsCard({ + state, + autoCompactOptions, + onStateChange, +}: { + state: ClaudeCodeState; + autoCompactOptions: { value: string; label: string }[]; + onStateChange: (next: ClaudeCodeState) => void; +}) { + const t = useT(); + + return ( +
+
+
+ {t("claude.enabledLabel")} + {t("claude.enabledHint")} +
+ onStateChange({ ...state, enabled })} /> +
+ +
+
+ {t("claude.authMode")} + {t("claude.authModeHint")} +
+ { + const v = e.target.value; + onStateChange({ ...state, fastMode: v === "auto" ? null : v === "on" }); + }} + className="text-label font-medium" + aria-label={t("claude.fastMode")} + style={{ padding: "5px 10px", borderRadius: "var(--radius-xs)", border: "1px solid var(--border)", background: "var(--surface)", color: "var(--text)" }} + > + + + + +
+ +
+
+ {t("claude.autoContext")} + {t("claude.autoContextDesc")} + {state.maxContextTokens !== null && {t("claude.autoContextInert")}} +
+ onStateChange({ ...state, autoContext })} /> +
+ + {state.autoContext && ( +
+
+ {t("claude.autoCompactWindow")} + {t("claude.autoCompactWindowDesc")} + {state.autoCompactWindow !== null && {t("claude.autoCompactWindowWarn")}} +
+ onStateChange({ + ...state, + [key]: value === "inherit" + ? undefined + : { ...override, backend: value === "auto" ? undefined : value as SidecarBackend }, + })} + label={t("dash.sidecarBackend")} + portal + /> + onStateChange({ ...state, [key]: { ...override, model: e.target.value } })} + placeholder={t("claude.sidecarModelPlaceholder")} + disabled={!override} + aria-label={t("dash.sidecarModel")} + style={{ minWidth: 210 }} + /> +
+
+ ); + })} + + ); +} + +export function ClaudeCodeQuickstartSection({ manualEnv }: { manualEnv: string }) { + const t = useT(); + return ( + <> +
{t("claude.quickstart")}
+

+
ocx claude
+
+ {t("claude.manualEnv")} +
{manualEnv}
+
+ + ); +} + +export function ClaudeCodeModelMapSection({ + rows, + onRowsChange, + onSave, +}: { + rows: MapRow[]; + onRowsChange: (rows: MapRow[]) => void; + onSave: () => void; +}) { + const t = useT(); + return ( + <> +
{t("claude.modelMap")} {rows.length}
+

{t("claude.modelMapHint")}

+
+ {rows.map((row, i) => ( +
+ onRowsChange(rows.map((r, j) => j === i ? { ...r, from: e.target.value } : r))} + style={{ flex: 1 }} + /> + + onRowsChange(rows.map((r, j) => j === i ? { ...r, to: e.target.value } : r))} + style={{ flex: 1 }} + /> + +
+ ))} +
+
+ +
+ +
+ +
+ + ); +} + +type AliasRow = { id: string; display_name: string }; + +function groupAliasesByProvider(aliases: AliasRow[]): Array<[string, AliasRow[]]> { + const groups = new Map(); + for (const alias of aliases) { + const match = /\(([^)]+)\)\s*$/.exec(alias.display_name); + const provider = match ? match[1]! : "etc"; + const bucket = groups.get(provider); + if (bucket) bucket.push(alias); + else groups.set(provider, [alias]); + } + return Array.from(groups); +} + +export function ClaudeCodeAliasesSection({ aliases }: { aliases: AliasRow[] }) { + const t = useT(); + return ( + <> +
{t("claude.aliases")} {aliases.length}
+

{t("claude.aliasesHint")}

+ {aliases.length === 0 ? ( +
{t("claude.none")}
+ ) : ( +
+ {groupAliasesByProvider(aliases).map(([provider, aliasRows]) => ( +
+
{provider} · {aliasRows.length}
+
+ {aliasRows.map(a => ( +
+ {a.id} + {a.display_name} +
+ ))} +
+
+ ))} +
+ )} + + ); +} diff --git a/gui/src/pages/claude-code-settings.tsx b/gui/src/pages/claude-code-settings.tsx new file mode 100644 index 000000000..384684f70 --- /dev/null +++ b/gui/src/pages/claude-code-settings.tsx @@ -0,0 +1,106 @@ +import { Trans } from "../i18n/provider"; +import { useT } from "../i18n/shared"; +import { Select, type SelectOption } from "../ui"; + +export function SettingToggle({ + label, + checked, + onChange, + disabled = false, + describedBy, +}: { + label: string; + checked: boolean; + onChange: (value: boolean) => void; + disabled?: boolean; + describedBy?: string; +}) { + return ( + + ); +} + +export function AutoConnectSetting({ + supported, + checked, + onChange, +}: { + supported: boolean; + checked: boolean; + onChange: (value: boolean) => void; +}) { + const t = useT(); + const unsupportedDescriptionId = supported ? undefined : "claude-system-env-unsupported"; + + return ( +
+
+ {t("claude.systemEnv")} + {supported ? ( + {t("claude.systemEnvDesc")} + ) : ( + + + + )} + {supported && checked && ( + + {t("claude.systemEnvWarn")} + + )} +
+ +
+ ); +} + +export function SmallFastModelSetting({ + value, + tierHaikuModel, + options, + onChange, +}: { + value: string; + tierHaikuModel?: string; + options: SelectOption[]; + onChange: (value: string) => void; +}) { + const t = useT(); + const effectiveHelperModel = tierHaikuModel ?? value; + return ( + <> +
{t("claude.smallFastModel")}
+

+ {t("claude.smallFastModelAccurateHint")} +

+ onStateChange({ - ...state, - [key]: value === "inherit" - ? undefined - : { ...override, backend: value === "auto" ? undefined : value as SidecarBackend }, - })} + onChange={value => { + // Empty Auto ({ backend: undefined, model: "" }) is deleted by the + // management API and reloads as inherit — don't offer a distinct + // non-persistable Auto state. + if (value === "inherit") { + onStateChange({ ...state, [key]: undefined }); + return; + } + if (value === "auto") { + const model = override?.model?.trim(); + onStateChange({ + ...state, + [key]: model ? { model: override!.model, backend: undefined } : undefined, + }); + return; + } + onStateChange({ + ...state, + [key]: { ...override, backend: value as SidecarBackend }, + }); + }} label={t("dash.sidecarBackend")} portal /> onStateChange({ ...state, [key]: { ...override, model: e.target.value } })} + onChange={e => { + const model = e.target.value; + // Clearing the only field of an Auto override collapses to inherit + // (same persist semantics as an empty sidecar object). + if (!model.trim() && !override?.backend) { + onStateChange({ ...state, [key]: undefined }); + return; + } + onStateChange({ ...state, [key]: { ...override, model } }); + }} placeholder={t("claude.sidecarModelPlaceholder")} disabled={!override} aria-label={t("dash.sidecarModel")} diff --git a/gui/src/pages/claude-code-types.ts b/gui/src/pages/claude-code-types.ts index 27d333803..fa9ce59b0 100644 --- a/gui/src/pages/claude-code-types.ts +++ b/gui/src/pages/claude-code-types.ts @@ -29,8 +29,12 @@ export interface ClaudeCodeState { export function formatCompactWindow(value: number): string { if (value >= 1_000_000) { const millions = value / 1_000_000; - const label = Number.isInteger(millions) ? String(millions) : millions.toFixed(1).replace(/\.0$/, ""); - return `${label}M`; + if (Number.isInteger(millions)) return `${millions}M`; + // One decimal is enough for ladder-ish values (1.5M); if it would collide with + // another million label (e.g. 1_040_000 → "1M"), fall back to an exact k label. + const oneDecimal = millions.toFixed(1).replace(/\.0$/, ""); + if (Number(oneDecimal) * 1_000_000 === value) return `${oneDecimal}M`; + return `${Math.round(value / 1_000)}k`; } return `${Math.round(value / 1_000)}k`; } diff --git a/gui/tests/claude-code-types.test.ts b/gui/tests/claude-code-types.test.ts index 1f3a31460..7c8ebc43a 100644 --- a/gui/tests/claude-code-types.test.ts +++ b/gui/tests/claude-code-types.test.ts @@ -6,4 +6,6 @@ test("formatCompactWindow uses k below 1M and exact millions at/above 1M", () => expect(formatCompactWindow(1_000_000)).toBe("1M"); expect(formatCompactWindow(2_000_000)).toBe("2M"); expect(formatCompactWindow(1_500_000)).toBe("1.5M"); + // Off-ladder values that would round to a colliding "1M" keep a distinct k label. + expect(formatCompactWindow(1_040_000)).toBe("1040k"); }); From e404398bc9dba7023775afc01bd3cb738862381e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:47:24 +0200 Subject: [PATCH 6/7] fix(gui): keep Claude sidecar Auto as a selectable draft Empty Auto was collapsing to Inherit before a model could be entered. Keep Auto in memory with an enabled model input, and only persist { backend: null, model } when the trimmed model is non-empty. --- gui/src/pages/ClaudeCode.tsx | 9 +- gui/src/pages/claude-code-sections.tsx | 44 ++---- gui/src/pages/claude-code-sidecar.ts | 54 +++++++ gui/tests/claude-code-sidecar-draft.test.tsx | 148 +++++++++++++++++++ gui/tests/claude-code-sidecar.test.ts | 63 ++++++++ 5 files changed, 282 insertions(+), 36 deletions(-) create mode 100644 gui/src/pages/claude-code-sidecar.ts create mode 100644 gui/tests/claude-code-sidecar-draft.test.tsx create mode 100644 gui/tests/claude-code-sidecar.test.ts diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index 65e37b9af..e25a3ff8a 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -11,6 +11,7 @@ import { ClaudeCodeQuickstartSection, ClaudeCodeSettingsCard, } from "./claude-code-sections"; +import { serializeSidecarOverride } from "./claude-code-sidecar"; import { formatCompactWindow, type ClaudeCodeState, type MapRow } from "./claude-code-types"; import { SmallFastModelSetting } from "./claude-code-settings"; @@ -102,12 +103,8 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) { injectAgents: state.injectAgents, smallFastModel: state.smallFastModel, modelMap, - webSearchSidecar: state.webSearchSidecar - ? { backend: state.webSearchSidecar.backend ?? null, model: state.webSearchSidecar.model ?? "" } - : null, - visionSidecar: state.visionSidecar - ? { backend: state.visionSidecar.backend ?? null, model: state.visionSidecar.model ?? "" } - : null, + webSearchSidecar: serializeSidecarOverride(state.webSearchSidecar), + visionSidecar: serializeSidecarOverride(state.visionSidecar), }), }); await readJsonOrThrow(r, t("claude.saveFailed")); diff --git a/gui/src/pages/claude-code-sections.tsx b/gui/src/pages/claude-code-sections.tsx index a67e13102..70f84f3c2 100644 --- a/gui/src/pages/claude-code-sections.tsx +++ b/gui/src/pages/claude-code-sections.tsx @@ -2,7 +2,12 @@ import { IconPlus, IconX } from "../icons"; import { useT } from "../i18n/shared"; import { Trans } from "../i18n/provider"; import { Select } from "../ui"; -import type { SidecarBackend } from "./claude-manual-env"; +import { + applySidecarBackendChange, + applySidecarModelChange, + sidecarSelectValue, + type SidecarSelectValue, +} from "./claude-code-sidecar"; import { AutoConnectSetting, SettingToggle } from "./claude-code-settings"; import type { ClaudeCodeState, MapRow } from "./claude-code-types"; @@ -119,11 +124,7 @@ export function ClaudeCodeSettingsCard({