diff --git a/gui/src/fetch-json.ts b/gui/src/fetch-json.ts
index a179ee718a..e788ba5fc7 100644
--- a/gui/src/fetch-json.ts
+++ b/gui/src/fetch-json.ts
@@ -31,5 +31,10 @@ export async function readJsonOrThrow(
export async function readJsonIfOk(res: Response): Promise {
if (!res.ok) return null;
- return readJsonBody(res);
+ try {
+ return await readJsonBody(res);
+ } catch {
+ // Malformed / non-JSON OK bodies must not throw — callers treat null as "no usable payload".
+ return null;
+ }
}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 76639a7f82..214e0653d9 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.",
@@ -815,6 +818,7 @@ export const de: Record = {
"claude.removeMapping": "Regel entfernen",
"claude.aliases": "Verfügbare Modelle",
"claude.aliasesHint": "Modelle, die im /model-Menü von Claude Code erscheinen.",
+ "claude.aliasProviderOther": "Sonstiges",
"claude.loading": "Lädt…",
"claude.loadFail": "Claude-Einstellungen konnten nicht geladen werden",
"claude.saved": "Gespeichert.",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index b6712e9fb5..246a3f7567 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.",
@@ -1119,6 +1122,7 @@ export const en = {
"claude.removeMapping": "Remove rule",
"claude.aliases": "Available models",
"claude.aliasesHint": "Models that appear in Claude Code's /model menu.",
+ "claude.aliasProviderOther": "Other",
"claude.loading": "Loading…",
"claude.loadFail": "Failed to load Claude settings",
"claude.saved": "Saved.",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index afb6878065..8dd75b331b 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 などのモデルを使用します。",
@@ -1073,6 +1076,7 @@ export const ja: Record = {
"claude.removeMapping": "ルールを削除",
"claude.aliases": "利用可能なモデル",
"claude.aliasesHint": "Claude Code の /model メニューに表示されるモデル。",
+ "claude.aliasProviderOther": "その他",
"claude.loading": "読み込み中…",
"claude.loadFail": "Claude 設定の読み込みに失敗しました",
"claude.saved": "保存しました。",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 6fea283bd1..e3645402ec 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 등 다른 모델도 쓸 수 있게 해줍니다.",
@@ -835,6 +838,7 @@ export const ko: Record = {
"claude.removeMapping": "규칙 삭제",
"claude.aliases": "사용 가능한 모델",
"claude.aliasesHint": "Claude Code의 /model 메뉴에 표시되는 모델 목록입니다.",
+ "claude.aliasProviderOther": "기타",
"claude.loading": "불러오는 중…",
"claude.loadFail": "Claude 설정을 불러오지 못했습니다",
"claude.saved": "저장되었습니다.",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 6b7da51540..19770fac34 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.",
@@ -1118,6 +1121,7 @@ export const ru: Record = {
"claude.removeMapping": "Удалить правило",
"claude.aliases": "Доступные модели",
"claude.aliasesHint": "Модели, которые появляются в меню /model в Claude Code.",
+ "claude.aliasProviderOther": "Другое",
"claude.loading": "Загрузка…",
"claude.loadFail": "Не удалось загрузить настройки Claude",
"claude.saved": "Сохранено.",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index c6ae2ae714..7f29a64ee9 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 等其他模型。",
@@ -835,6 +838,7 @@ export const zh: Record = {
"claude.removeMapping": "删除规则",
"claude.aliases": "可用模型",
"claude.aliasesHint": "Claude Code 的 /model 菜单中显示的模型列表。",
+ "claude.aliasProviderOther": "其他",
"claude.loading": "加载中…",
"claude.loadFail": "加载 Claude 设置失败",
"claude.saved": "已保存。",
diff --git a/gui/src/intl-formatters.ts b/gui/src/intl-formatters.ts
index 0b765cdaf0..4693170c7b 100644
--- a/gui/src/intl-formatters.ts
+++ b/gui/src/intl-formatters.ts
@@ -26,14 +26,19 @@ const CREDIT_DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
});
export function formatCreditDate(iso: string): string {
- return CREDIT_DATE_FORMAT.format(new Date(iso));
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return "\u2014";
+ return CREDIT_DATE_FORMAT.format(date);
}
/** Format a USD cost estimate for display. Returns "—" when unavailable. */
export function formatEstimatedUsdValue(value: number, locale?: string): string {
if (!Number.isFinite(value) || value < 0) return "\u2014";
- return `~$${cachedNumberFormat(locale, {
+ const formatted = cachedNumberFormat(locale, {
+ style: "currency",
+ currency: "USD",
minimumFractionDigits: 4,
maximumFractionDigits: 4,
- }).format(value)}`;
+ }).format(value);
+ return `~${formatted}`;
}
diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx
index a986dba2a8..87f0704d74 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,26 @@ 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) {
+ // Keep last-good keys/endpoints/Claude setting; only mark the refresh failed.
+ 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 {
+ setKeysLoadFailed(true);
+ }
}, [apiBase]);
const fetchModels = useCallback(async () => {
@@ -148,6 +144,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 +152,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 +171,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 +267,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")}
- ) : (
-
-
-
- | {t("api.colName")} | {t("api.colKey")} | {t("api.colCreated")} | |
-
-
- {keys.map(k => (
-
- | {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")}
- ) : (
-
-
-
-
- | {t("api.colModel")} |
- {t("api.colSource")} |
- {t("api.colProtocols")} |
- |
-
-
-
- {filteredModels.map(model => {
- const modelId = externalModelId(model);
- const testState = modelTests[modelId]?.state ?? "idle";
- return (
-
- |
-
- {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 9bb93febf5..384013135e 100644
--- a/gui/src/pages/ClaudeCode.tsx
+++ b/gui/src/pages/ClaudeCode.tsx
@@ -1,144 +1,26 @@
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 { useT } from "../i18n/shared";
+import { Notice } from "../ui";
+import { useI18n, useT, LOCALES } 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")}
-
-
- {effectiveHelperModel === "" && (
-
- {t("claude.smallFastModelNativeWarning")}
-
- )}
- >
- );
-}
+import { buildManualEnv } from "./claude-manual-env";
+import {
+ ClaudeCodeAliasesSection,
+ ClaudeCodeModelMapSection,
+ 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";
+
+export { AutoConnectSetting, SmallFastModelSetting } from "./claude-code-settings";
export default function ClaudeCode({ apiBase }: { apiBase: string }) {
const t = useT();
+ const { locale } = useI18n();
+ const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang ?? "en";
const [state, setState] = useState(null);
const [rows, setRows] = useState([]);
const [status, setStatus] = useState("");
@@ -147,7 +29,16 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) {
const load = useCallback(async () => {
try {
- const r = await fetch(`${apiBase}/api/claude-code`).then(res => res.json());
+ const res = await fetch(`${apiBase}/api/claude-code`);
+ const r = await readJsonOrThrow }>(
+ res,
+ t("claude.loadFail"),
+ );
+ if (!r) {
+ setOk(false);
+ setStatus(t("claude.loadFail"));
+ return;
+ }
setState({
...r,
authMode: r.authMode === "proxy" ? "proxy" : "subscription",
@@ -160,13 +51,14 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) {
effectiveModelEnv: r.effectiveModelEnv ?? {},
});
setRows(Object.entries(r.modelMap ?? {}).map(([from, to]) => ({ from, to: String(to) })));
- } catch {
+ } catch (error) {
setOk(false);
- setStatus(t("claude.loadFail"));
+ setStatus(error instanceof Error && error.message ? error.message : t("claude.loadFail"));
} finally {
setLoading(false);
}
}, [apiBase, t]);
+
useEffect(() => {
// Deferred initial load (matches Models/Usage): avoids synchronous setState
// inside the effect, per the react-hooks/set-state-in-effect lint gate.
@@ -188,9 +80,9 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) {
const values = current !== null && !ladder.includes(current) ? [...ladder, current].sort((a, b) => a - b) : ladder;
return [
{ value: "", label: t("claude.autoCompactDefault") },
- ...values.map(value => ({ value: String(value), label: formatCompactWindow(value) })),
+ ...values.map(value => ({ value: String(value), label: formatCompactWindow(value, localeTag) })),
];
- }, [state?.autoCompactWindow, t]);
+ }, [state?.autoCompactWindow, t, localeTag]);
const save = async () => {
if (!state) return;
@@ -213,272 +105,38 @@ 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),
}),
});
- const d = await r.json();
- setOk(r.ok);
- setStatus(r.ok ? t("claude.saved") : (d.error || t("claude.saveFailed")));
- if (r.ok) await load();
- } catch {
+ await readJsonOrThrow(r, t("claude.saveFailed"));
+ setOk(true);
+ setStatus(t("claude.saved"));
+ await load();
+ } catch (error) {
setOk(false);
- setStatus(t("claude.networkError"));
+ setStatus(error instanceof Error && error.message ? error.message : t("claude.networkError"));
}
};
if (loading) return {t("claude.loading")}
;
if (!state) return {status || t("claude.loadFail")};
- const manualEnv = buildManualEnv(state);
-
- const settingsSection = (
- <>
-
-
-
- {t("claude.enabledLabel")}
- {t("claude.enabledHint")}
-
-
setState({ ...state, enabled })} />
-
-
-
-
- {t("claude.authMode")}
- {t("claude.authModeHint")}
-
-
-
-
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")}}
-
-
- )}
-
-
-
- {t("claude.injectAgents")}
- {t("claude.injectAgentsDesc")}
-
-
setState({ ...state, injectAgents })} />
-
-
- {(["webSearchSidecar", "visionSidecar"] as const).map(key => {
- const override = state[key];
- const titleKey = key === "webSearchSidecar" ? "claude.webSearchSidecar" : "claude.visionSidecar";
- const hintKey = key === "webSearchSidecar" ? "claude.webSearchSidecarHint" : "claude.visionSidecarHint";
- return (
-
-
- {t(titleKey)}
- {t(hintKey)}
-
-
-
-
- );
- })}
-
- >
- );
-
- 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")}
-
-
-
-
-
-
-
-
- >
- );
-
- 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 0000000000..b6ec5f9fd7
--- /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 })}
+ {keys.length > 0 ? (
+
+
+
+ | {t("api.colName")} | {t("api.colKey")} | {t("api.colCreated")} | |
+
+
+ {keys.map(k => (
+
+ | {k.name} |
+ {k.prefix} |
+ {formatCreatedDate(k.createdAt, localeTag)} |
+
+ {confirmDelete === k.id ? (
+
+
+
+
+ ) : (
+
+ )}
+ |
+
+ ))}
+
+
+
+ ) : keysLoadFailed ? (
+
{t("api.keysLoadFailed")}
+ ) : (
+
{t("api.noKeys")}
+ )}
+
+ >
+ );
+}
+
+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")}
+ ) : (
+
+
+
+
+ | {t("api.colModel")} |
+ {t("api.colSource")} |
+ {t("api.colProtocols")} |
+ |
+
+
+
+ {filteredModels.map(model => {
+ const modelId = externalModelId(model);
+ const testState = modelTests[modelId]?.state ?? "idle";
+ return (
+
+ |
+
+ {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 0000000000..1c5c109c53
--- /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 0000000000..70f84f3c26
--- /dev/null
+++ b/gui/src/pages/claude-code-sections.tsx
@@ -0,0 +1,280 @@
+import { IconPlus, IconX } from "../icons";
+import { useT } from "../i18n/shared";
+import { Trans } from "../i18n/provider";
+import { Select } from "../ui";
+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";
+
+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")}
+
+
+
+
onStateChange({ ...state, systemEnv })}
+ />
+
+
+
+ {t("claude.fastMode")}
+ {t("claude.fastModeDesc")}
+
+
+
+
+
+
+ {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")}}
+
+
+ )}
+
+
+
+ {t("claude.injectAgents")}
+ {t("claude.injectAgentsDesc")}
+
+
onStateChange({ ...state, injectAgents })} />
+
+
+ {(["webSearchSidecar", "visionSidecar"] as const).map(key => {
+ const override = state[key];
+ const titleKey = key === "webSearchSidecar" ? "claude.webSearchSidecar" : "claude.visionSidecar";
+ const hintKey = key === "webSearchSidecar" ? "claude.webSearchSidecarHint" : "claude.visionSidecarHint";
+ return (
+
+
+ {t(titleKey)}
+ {t(hintKey)}
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+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")}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+type AliasRow = { id: string; display_name: string };
+
+/** Sentinel bucket key for aliases whose display_name has no trailing `(provider)`. */
+const ALIAS_PROVIDER_OTHER = "etc";
+
+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]! : ALIAS_PROVIDER_OTHER;
+ 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 === ALIAS_PROVIDER_OTHER ? t("claude.aliasProviderOther") : 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 0000000000..384684f706
--- /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")}
+
+
+ {effectiveHelperModel === "" && (
+
+ {t("claude.smallFastModelNativeWarning")}
+
+ )}
+ >
+ );
+}
diff --git a/gui/src/pages/claude-code-sidecar.ts b/gui/src/pages/claude-code-sidecar.ts
new file mode 100644
index 0000000000..c7861adc4e
--- /dev/null
+++ b/gui/src/pages/claude-code-sidecar.ts
@@ -0,0 +1,54 @@
+/**
+ * Claude Code sidecar override helpers — keep Auto as an in-memory draft so
+ * users can enter a model before persist, and only serialize Auto when a
+ * trimmed model is present.
+ */
+
+import type { SidecarBackend, SidecarOverride } from "./claude-manual-env";
+
+export type SidecarSelectValue = "inherit" | "auto" | SidecarBackend;
+
+export type PersistedSidecarOverride = {
+ backend: SidecarBackend | null;
+ model: string;
+};
+
+/** Select value: any in-memory override without a backend is Auto (including empty drafts). */
+export function sidecarSelectValue(override?: SidecarOverride): SidecarSelectValue {
+ if (!override) return "inherit";
+ return override.backend ?? "auto";
+}
+
+/** Backend select changes. Inherit clears; Auto/OpenAI/Anthropic preserve model. */
+export function applySidecarBackendChange(
+ override: SidecarOverride | undefined,
+ value: SidecarSelectValue,
+): SidecarOverride | undefined {
+ if (value === "inherit") return undefined;
+ if (value === "auto") return { ...override, backend: undefined };
+ return { ...override, backend: value };
+}
+
+/** Model typing updates the draft in place; empty Auto stays selectable until save. */
+export function applySidecarModelChange(
+ override: SidecarOverride | undefined,
+ model: string,
+): SidecarOverride {
+ return { ...override, model };
+}
+
+/**
+ * Persist shape for PUT /api/claude-code.
+ * Empty Auto drafts ({ backend: undefined } / blank model) become null → reload as Inherit.
+ */
+export function serializeSidecarOverride(
+ override?: SidecarOverride,
+): PersistedSidecarOverride | null {
+ if (!override) return null;
+ const trimmed = (override.model ?? "").trim();
+ if (!override.backend) {
+ if (!trimmed) return null;
+ return { backend: null, model: trimmed };
+ }
+ return { backend: override.backend, model: trimmed };
+}
diff --git a/gui/src/pages/claude-code-types.ts b/gui/src/pages/claude-code-types.ts
new file mode 100644
index 0000000000..2ddc0cacc8
--- /dev/null
+++ b/gui/src/pages/claude-code-types.ts
@@ -0,0 +1,46 @@
+import type { SidecarOverride } from "./claude-manual-env";
+
+export interface MapRow {
+ from: string;
+ to: string;
+}
+
+export 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;
+}
+
+/** Compact auto-summarize window labels (350k / 1M). Uses Intl for the million suffix. */
+export function formatCompactWindow(value: number, locale = "en"): string {
+ if (value >= 1_000_000) {
+ const millions = value / 1_000_000;
+ const oneDecimal = millions.toFixed(1).replace(/\.0$/, "");
+ // Exact million ladder values use locale-aware compact notation; off-ladder
+ // values that would collide with "1M" keep a distinct k label.
+ if (Number.isInteger(millions) || Number(oneDecimal) * 1_000_000 === value) {
+ return new Intl.NumberFormat(locale, {
+ notation: "compact",
+ compactDisplay: "short",
+ maximumFractionDigits: Number.isInteger(millions) ? 0 : 1,
+ }).format(value);
+ }
+ return `${Math.round(value / 1_000)}k`;
+ }
+ return `${Math.round(value / 1_000)}k`;
+}
diff --git a/gui/tests/apikeys-layout.test.ts b/gui/tests/apikeys-layout.test.ts
index 953241f182..626bf7f206 100644
--- a/gui/tests/apikeys-layout.test.ts
+++ b/gui/tests/apikeys-layout.test.ts
@@ -1,7 +1,14 @@
import { expect, test } from "bun:test";
+async function apiKeysSources(): Promise {
+ const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text();
+ const panels = await Bun.file(new URL("../src/pages/api-keys-panels.tsx", import.meta.url)).text();
+ return `${page}\n${panels}`;
+}
+
test("ApiKeys renders the single stacked layout (no layout toggle, no workspace rail)", async () => {
const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text();
+ const src = await apiKeysSources();
const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text();
const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text();
@@ -19,12 +26,13 @@ test("ApiKeys renders the single stacked layout (no layout toggle, no workspace
expect(css).toContain(".api-auth-list");
expect(css).toContain(".api-test-note--ok");
expect(css).toContain(".api-test-note--error");
- expect(page).toContain('className="api-auth-list');
- expect(page).toContain("api-test-note--ok");
+ expect(src).toContain('className="api-auth-list');
+ expect(src).toContain("api-test-note--ok");
});
test("ApiKeys stacked layout keeps endpoint, generate, keys table, and usage panels", async () => {
- const src = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text();
+ const src = await apiKeysSources();
+ const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text();
const order = [
't("api.endpointsTitle")',
@@ -51,15 +59,15 @@ test("ApiKeys stacked layout keeps endpoint, generate, keys table, and usage pan
expect(between).not.toContain('t("api.usageChatTitle")');
expect(between).not.toContain('t("api.usageResponsesTitle")');
expect(src).toContain("gatewayInboundProtocols(claudeCodeEnabled)");
- expect(src).toContain("classifyExternalModel(row)");
- expect(src).toContain('from "../api-access-models"');
+ expect(page).toContain("classifyExternalModel(row)");
+ expect(page).toContain('from "../api-access-models"');
// Inline per-row delete confirmation, not a workspace detail pane.
expect(src).toContain("confirmDelete === k.id");
expect(src).toContain('t("api.noKeys")');
expect(src).toContain('t("api.colKey")');
// Double-create guard kept from the workspace era.
- expect(src).toContain("if (creatingRef.current) return false");
+ expect(page).toContain("if (creatingRef.current) return false");
});
test("retired apikeys workspace i18n keys stay removed from every locale", async () => {
diff --git a/gui/tests/apikeys-refresh-preserve.test.tsx b/gui/tests/apikeys-refresh-preserve.test.tsx
new file mode 100644
index 0000000000..a350da2feb
--- /dev/null
+++ b/gui/tests/apikeys-refresh-preserve.test.tsx
@@ -0,0 +1,212 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act } from "react";
+import type { Root } from "react-dom/client";
+import { LanguageProvider } from "../src/i18n/provider";
+import ApiKeys from "../src/pages/ApiKeys";
+
+const originalFetch = globalThis.fetch;
+let restoreGlobals: (() => void) | undefined;
+let previousLanguageDescriptor: PropertyDescriptor | undefined;
+
+beforeEach(() => {
+ previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language");
+ Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" });
+ const previous = {
+ document: Object.getOwnPropertyDescriptor(globalThis, "document"),
+ window: Object.getOwnPropertyDescriptor(globalThis, "window"),
+ localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
+ actEnv: Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT"),
+ };
+ restoreGlobals = () => {
+ for (const [key, descriptor] of [
+ ["document", previous.document],
+ ["window", previous.window],
+ ["localStorage", previous.localStorage],
+ ["IS_REACT_ACT_ENVIRONMENT", previous.actEnv],
+ ] as const) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else delete (globalThis as Record)[key];
+ }
+ if (previousLanguageDescriptor) {
+ Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor);
+ } else {
+ delete (globalThis.navigator as { language?: string }).language;
+ }
+ };
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ restoreGlobals?.();
+});
+
+const EXISTING_KEY = {
+ id: "key-1",
+ name: "existing-key",
+ prefix: "ocx_exist",
+ createdAt: "2026-01-15T12:00:00.000Z",
+};
+
+const KEYS_OK = {
+ keys: [EXISTING_KEY],
+ baseUrl: "http://127.0.0.1:10100/v1",
+ endpoint: "http://127.0.0.1:10100/v1/responses",
+ responsesEndpoint: "http://127.0.0.1:10100/v1/responses",
+ chatCompletionsEndpoint: "http://127.0.0.1:10100/v1/chat/completions",
+ messagesEndpoint: "http://127.0.0.1:10100/v1/messages",
+ modelsEndpoint: "http://127.0.0.1:10100/v1/models",
+ claudeCodeEnabled: true,
+};
+
+test("successful key create keeps last-good keys visible when follow-up refresh fails", async () => {
+ const testWindow = new Window({ url: "http://localhost/" });
+ const container = testWindow.document.createElement("div");
+ testWindow.document.body.appendChild(container);
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: testWindow.document },
+ window: { configurable: true, value: testWindow },
+ localStorage: { configurable: true, value: testWindow.localStorage },
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+
+ let keysGets = 0;
+ globalThis.fetch = (async (input, init) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (url.endsWith("/v1/models")) {
+ return Response.json({ data: [] });
+ }
+ if (url.endsWith("/api/keys") && method === "GET") {
+ keysGets += 1;
+ if (keysGets === 1) return Response.json(KEYS_OK);
+ return new Response("upstream unavailable", { status: 503 });
+ }
+ if (url.endsWith("/api/keys") && method === "POST") {
+ return Response.json({ key: "ocx_new_secret_value_only_shown_once" });
+ }
+ return new Response(null, { status: 404 });
+ }) as typeof fetch;
+
+ const { createRoot } = await import("react-dom/client");
+ let root!: Root;
+ try {
+ await act(async () => {
+ root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ });
+
+ expect(container.textContent).toContain("existing-key");
+ expect(container.textContent).toContain("ocx_exist");
+ expect(container.textContent).toContain("http://127.0.0.1:10100/v1");
+ expect(container.textContent).not.toContain("Could not load API keys.");
+
+ const generate = [...container.querySelectorAll("button")]
+ .find((button) => button.textContent?.includes("Generate"));
+ expect(generate).toBeTruthy();
+
+ await act(async () => {
+ generate!.click();
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ await Promise.resolve();
+ });
+
+ expect(keysGets).toBeGreaterThanOrEqual(2);
+ expect(container.textContent).toContain("existing-key");
+ expect(container.textContent).toContain("ocx_exist");
+ expect(container.textContent).toContain("http://127.0.0.1:10100/v1");
+ expect(container.textContent).toContain("Could not load API keys.");
+ expect(container.textContent).not.toContain("No API keys yet.");
+ } finally {
+ await act(async () => root.unmount());
+ testWindow.close();
+ }
+});
+
+test("successful key delete keeps last-good keys visible when follow-up refresh fails", async () => {
+ const testWindow = new Window({ url: "http://localhost/" });
+ const container = testWindow.document.createElement("div");
+ testWindow.document.body.appendChild(container);
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: testWindow.document },
+ window: { configurable: true, value: testWindow },
+ localStorage: { configurable: true, value: testWindow.localStorage },
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+
+ let keysGets = 0;
+ globalThis.fetch = (async (input, init) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (url.endsWith("/v1/models")) {
+ return Response.json({ data: [] });
+ }
+ if (url.endsWith("/api/keys") && method === "GET") {
+ keysGets += 1;
+ if (keysGets === 1) return Response.json(KEYS_OK);
+ return Response.json({ not: "keys" }, { status: 500 });
+ }
+ if (url.endsWith("/api/keys") && method === "DELETE") {
+ return Response.json({ ok: true });
+ }
+ return new Response(null, { status: 404 });
+ }) as typeof fetch;
+
+ const { createRoot } = await import("react-dom/client");
+ let root!: Root;
+ try {
+ await act(async () => {
+ root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ });
+
+ expect(container.textContent).toContain("existing-key");
+
+ const deleteBtn = container.querySelector('button[aria-label="Delete API key"]');
+ expect(deleteBtn).toBeTruthy();
+ await act(async () => {
+ deleteBtn!.click();
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ });
+
+ const confirmBtn = [...container.querySelectorAll("button")]
+ .find((button) => button.textContent === "Confirm");
+ expect(confirmBtn).toBeTruthy();
+
+ await act(async () => {
+ confirmBtn!.click();
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ await Promise.resolve();
+ });
+
+ expect(keysGets).toBeGreaterThanOrEqual(2);
+ expect(container.textContent).toContain("existing-key");
+ expect(container.textContent).toContain("ocx_exist");
+ expect(container.textContent).toContain("Could not load API keys.");
+ } finally {
+ await act(async () => root.unmount());
+ testWindow.close();
+ }
+});
diff --git a/gui/tests/claude-code-sidecar-draft.test.tsx b/gui/tests/claude-code-sidecar-draft.test.tsx
new file mode 100644
index 0000000000..9505060b2f
--- /dev/null
+++ b/gui/tests/claude-code-sidecar-draft.test.tsx
@@ -0,0 +1,152 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, useState } from "react";
+import type { Root } from "react-dom/client";
+import { LanguageProvider } from "../src/i18n/provider";
+import { ClaudeCodeSettingsCard } from "../src/pages/claude-code-sections";
+import type { ClaudeCodeState } from "../src/pages/claude-code-types";
+
+const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>;
+let testWindow: Window;
+
+beforeEach(() => {
+ previousGlobals = Object.fromEntries(
+ globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
+ ) as typeof previousGlobals;
+ testWindow = new Window({ url: "http://localhost/" });
+ Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" });
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: testWindow.document },
+ window: { configurable: true, value: testWindow },
+ navigator: { configurable: true, value: testWindow.navigator },
+ localStorage: { configurable: true, value: testWindow.localStorage },
+ });
+ (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+});
+
+afterEach(() => {
+ testWindow.close();
+ for (const key of globals) {
+ const descriptor = previousGlobals[key];
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+});
+
+const BASE_STATE: ClaudeCodeState = {
+ enabled: true,
+ authMode: "proxy",
+ autoConnectSupported: false,
+ systemEnv: false,
+ fastMode: null,
+ maxContextTokens: null,
+ autoContext: true,
+ autoCompactWindow: null,
+ injectAgents: true,
+ smallFastModel: "",
+ effectiveModelEnv: {},
+ available: [],
+ aliases: [],
+ port: 10100,
+};
+
+async function mountSettings(
+ initial: ClaudeCodeState = BASE_STATE,
+): Promise<{ root: Root; host: HTMLDivElement; getState: () => ClaudeCodeState }> {
+ const host = document.createElement("div");
+ document.body.append(host);
+ let latest = initial;
+ function Harness() {
+ const [state, setState] = useState(initial);
+ latest = state;
+ return (
+
+
+
+ );
+ }
+ const { createRoot } = await import("react-dom/client");
+ let root!: Root;
+ await act(async () => {
+ root = createRoot(host);
+ root.render();
+ });
+ return { root, host, getState: () => latest };
+}
+
+function sidecarModelInputs(host: HTMLElement): HTMLInputElement[] {
+ return [...host.querySelectorAll('input[aria-label="Model"]')];
+}
+
+function sidecarBackendTriggers(host: HTMLElement): HTMLButtonElement[] {
+ return [...host.querySelectorAll('button.select-trigger[aria-label="Backend"]')];
+}
+
+async function pickOption(trigger: HTMLButtonElement, label: string): Promise {
+ await act(async () => {
+ trigger.click();
+ });
+ const option = [...document.body.querySelectorAll('[role="option"]')]
+ .find(el => el.textContent?.includes(label));
+ expect(option).toBeTruthy();
+ await act(async () => {
+ option!.click();
+ });
+}
+
+test("Inherit → Auto stays Auto and enables the sidecar model input", async () => {
+ const { root, host, getState } = await mountSettings();
+ const [webSearchTrigger] = sidecarBackendTriggers(host);
+ const [webSearchModel] = sidecarModelInputs(host);
+ expect(webSearchTrigger).toBeTruthy();
+ expect(webSearchModel.disabled).toBe(true);
+ expect(getState().webSearchSidecar).toBeUndefined();
+
+ await pickOption(webSearchTrigger!, "Auto");
+
+ expect(getState().webSearchSidecar).toEqual({ backend: undefined });
+ expect(sidecarSelectLabel(host, 0)).toContain("Auto");
+ expect(sidecarModelInputs(host)[0]!.disabled).toBe(false);
+
+ await act(async () => { root.unmount(); });
+});
+
+test("Empty Auto draft keeps model input enabled until Inherit is chosen", async () => {
+ const { root, host, getState } = await mountSettings();
+ const [webSearchTrigger] = sidecarBackendTriggers(host);
+ await pickOption(webSearchTrigger!, "Auto");
+
+ const model = sidecarModelInputs(host)[0]!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!
+ .set!.call(model, "gpt-4o");
+ model.dispatchEvent(new testWindow.Event("input", { bubbles: true }));
+ });
+ expect(getState().webSearchSidecar).toEqual({ backend: undefined, model: "gpt-4o" });
+
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!
+ .set!.call(model, "");
+ model.dispatchEvent(new testWindow.Event("input", { bubbles: true }));
+ });
+
+ // Clearing the model must not collapse Auto back to Inherit.
+ expect(getState().webSearchSidecar).toEqual({ backend: undefined, model: "" });
+ expect(model.disabled).toBe(false);
+ expect(sidecarSelectLabel(host, 0)).toContain("Auto");
+
+ await pickOption(sidecarBackendTriggers(host)[0]!, "Use main setting");
+ expect(getState().webSearchSidecar).toBeUndefined();
+ expect(sidecarModelInputs(host)[0]!.disabled).toBe(true);
+
+ await act(async () => { root.unmount(); });
+});
+
+function sidecarSelectLabel(host: HTMLElement, index: number): string {
+ return sidecarBackendTriggers(host)[index]?.textContent ?? "";
+}
diff --git a/gui/tests/claude-code-sidecar.test.ts b/gui/tests/claude-code-sidecar.test.ts
new file mode 100644
index 0000000000..e8b2a3e0a7
--- /dev/null
+++ b/gui/tests/claude-code-sidecar.test.ts
@@ -0,0 +1,74 @@
+import { expect, test } from "bun:test";
+import {
+ applySidecarBackendChange,
+ applySidecarModelChange,
+ serializeSidecarOverride,
+ sidecarSelectValue,
+} from "../src/pages/claude-code-sidecar";
+
+test("Inherit → Auto remains Auto (empty draft) and keeps a selectable override", () => {
+ const draft = applySidecarBackendChange(undefined, "auto");
+ expect(draft).toEqual({ backend: undefined });
+ expect(sidecarSelectValue(draft)).toBe("auto");
+ // Model input is enabled whenever an override object exists.
+ expect(draft).toBeTruthy();
+});
+
+test("Entering a model under Auto survives serialize (save payload)", () => {
+ let override = applySidecarBackendChange(undefined, "auto");
+ override = applySidecarModelChange(override, " gpt-4o-mini ");
+ expect(sidecarSelectValue(override)).toBe("auto");
+ expect(serializeSidecarOverride(override)).toEqual({
+ backend: null,
+ model: "gpt-4o-mini",
+ });
+});
+
+test("Empty Auto serializes as null (reloads as Inherit)", () => {
+ expect(serializeSidecarOverride({ backend: undefined })).toBeNull();
+ expect(serializeSidecarOverride({ backend: undefined, model: "" })).toBeNull();
+ expect(serializeSidecarOverride({ backend: undefined, model: " " })).toBeNull();
+ expect(serializeSidecarOverride({})).toBeNull();
+});
+
+test("Switching back to Inherit clears the override", () => {
+ const withModel = applySidecarModelChange(
+ applySidecarBackendChange(undefined, "auto"),
+ "gpt-4o",
+ );
+ expect(applySidecarBackendChange(withModel, "inherit")).toBeUndefined();
+ expect(sidecarSelectValue(undefined)).toBe("inherit");
+ expect(serializeSidecarOverride(undefined)).toBeNull();
+});
+
+test("Switching Auto ↔ OpenAI ↔ Anthropic preserves the model", () => {
+ let override = applySidecarModelChange(
+ applySidecarBackendChange(undefined, "auto"),
+ "claude-sonnet-4",
+ );
+ override = applySidecarBackendChange(override, "openai")!;
+ expect(override).toEqual({ backend: "openai", model: "claude-sonnet-4" });
+ override = applySidecarBackendChange(override, "anthropic")!;
+ expect(override).toEqual({ backend: "anthropic", model: "claude-sonnet-4" });
+ override = applySidecarBackendChange(override, "auto")!;
+ expect(override).toEqual({ backend: undefined, model: "claude-sonnet-4" });
+ expect(sidecarSelectValue(override)).toBe("auto");
+});
+
+test("Explicit backends serialize even with an empty model", () => {
+ expect(serializeSidecarOverride({ backend: "openai", model: "" })).toEqual({
+ backend: "openai",
+ model: "",
+ });
+});
+
+test("Explicit backends trim whitespace-padded models before persist", () => {
+ expect(serializeSidecarOverride({ backend: "openai", model: " gpt-4o " })).toEqual({
+ backend: "openai",
+ model: "gpt-4o",
+ });
+ expect(serializeSidecarOverride({ backend: "anthropic", model: " claude-sonnet-4 " })).toEqual({
+ backend: "anthropic",
+ model: "claude-sonnet-4",
+ });
+});
diff --git a/gui/tests/claude-code-types.test.ts b/gui/tests/claude-code-types.test.ts
new file mode 100644
index 0000000000..459defd919
--- /dev/null
+++ b/gui/tests/claude-code-types.test.ts
@@ -0,0 +1,11 @@
+import { expect, test } from "bun:test";
+import { formatCompactWindow } from "../src/pages/claude-code-types";
+
+test("formatCompactWindow uses k below 1M and exact millions at/above 1M", () => {
+ expect(formatCompactWindow(350_000, "en")).toBe("350k");
+ expect(formatCompactWindow(1_000_000, "en")).toBe("1M");
+ expect(formatCompactWindow(2_000_000, "en")).toBe("2M");
+ expect(formatCompactWindow(1_500_000, "en")).toBe("1.5M");
+ // Off-ladder values that would round to a colliding "1M" keep a distinct k label.
+ expect(formatCompactWindow(1_040_000, "en")).toBe("1040k");
+});
diff --git a/gui/tests/claudecode-fetch-errors.test.tsx b/gui/tests/claudecode-fetch-errors.test.tsx
new file mode 100644
index 0000000000..f3bd724298
--- /dev/null
+++ b/gui/tests/claudecode-fetch-errors.test.tsx
@@ -0,0 +1,174 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act } from "react";
+import type { Root } from "react-dom/client";
+import { LanguageProvider } from "../src/i18n/provider";
+import ClaudeCode from "../src/pages/ClaudeCode";
+
+const originalFetch = globalThis.fetch;
+let previousLanguageDescriptor: PropertyDescriptor | undefined;
+let restoreGlobals: (() => void) | undefined;
+
+beforeEach(() => {
+ previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language");
+ Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" });
+ const previous = {
+ document: Object.getOwnPropertyDescriptor(globalThis, "document"),
+ window: Object.getOwnPropertyDescriptor(globalThis, "window"),
+ localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
+ actEnv: Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT"),
+ };
+ restoreGlobals = () => {
+ for (const [key, descriptor] of [
+ ["document", previous.document],
+ ["window", previous.window],
+ ["localStorage", previous.localStorage],
+ ["IS_REACT_ACT_ENVIRONMENT", previous.actEnv],
+ ] as const) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else delete (globalThis as Record)[key];
+ }
+ if (previousLanguageDescriptor) {
+ Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor);
+ } else {
+ delete (globalThis.navigator as { language?: string }).language;
+ }
+ };
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ restoreGlobals?.();
+});
+
+const CLAUDE_OK = {
+ enabled: true,
+ authMode: "proxy",
+ autoConnectSupported: false,
+ systemEnv: false,
+ fastMode: null,
+ maxContextTokens: null,
+ autoContext: true,
+ autoCompactWindow: null,
+ injectAgents: true,
+ smallFastModel: "",
+ effectiveModelEnv: {},
+ available: ["mock/model"],
+ aliases: [],
+ port: 10100,
+ modelMap: {},
+};
+
+async function mountClaudeCode(): Promise<{ container: HTMLElement; root: Root; testWindow: Window }> {
+ const testWindow = new Window({ url: "http://localhost/" });
+ const container = testWindow.document.createElement("div");
+ testWindow.document.body.appendChild(container);
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: testWindow.document },
+ window: { configurable: true, value: testWindow },
+ localStorage: { configurable: true, value: testWindow.localStorage },
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+
+ const { createRoot } = await import("react-dom/client");
+ let root!: Root;
+ await act(async () => {
+ root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ });
+ return { container, root, testWindow };
+}
+
+test("ClaudeCode load surfaces the server error message from a failed GET", async () => {
+ globalThis.fetch = (async (input) => {
+ const url = String(input);
+ if (url.endsWith("/api/claude-code")) {
+ return Response.json({ error: "claude config locked" }, { status: 503 });
+ }
+ return new Response(null, { status: 404 });
+ }) as typeof fetch;
+
+ const { container, root, testWindow } = await mountClaudeCode();
+ try {
+ expect(container.textContent).toContain("claude config locked");
+ expect(container.textContent).not.toContain("Claude Code");
+ } finally {
+ await act(async () => root.unmount());
+ testWindow.close();
+ }
+});
+
+test("ClaudeCode save surfaces the server error message from a failed PUT", async () => {
+ globalThis.fetch = (async (input, init) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (url.endsWith("/api/claude-code") && method === "GET") {
+ return Response.json(CLAUDE_OK);
+ }
+ if (url.endsWith("/api/claude-code") && method === "PUT") {
+ return Response.json({ error: "model map rejected" }, { status: 400 });
+ }
+ return new Response(null, { status: 404 });
+ }) as typeof fetch;
+
+ const { container, root, testWindow } = await mountClaudeCode();
+ try {
+ expect(container.textContent).toContain("Claude Code");
+ const save = [...container.querySelectorAll("button")]
+ .find((button) => /save/i.test(button.textContent ?? ""));
+ expect(save).toBeTruthy();
+
+ await act(async () => {
+ save!.click();
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain("model map rejected");
+ } finally {
+ await act(async () => root.unmount());
+ testWindow.close();
+ }
+});
+
+test("ClaudeCode save treats an empty 200 body as success", async () => {
+ let putCount = 0;
+ globalThis.fetch = (async (input, init) => {
+ const url = String(input);
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (url.endsWith("/api/claude-code") && method === "GET") {
+ return Response.json(CLAUDE_OK);
+ }
+ if (url.endsWith("/api/claude-code") && method === "PUT") {
+ putCount += 1;
+ return new Response("", { status: 200, headers: { "Content-Type": "application/json" } });
+ }
+ return new Response(null, { status: 404 });
+ }) as typeof fetch;
+
+ const { container, root, testWindow } = await mountClaudeCode();
+ try {
+ const save = [...container.querySelectorAll("button")]
+ .find((button) => /save/i.test(button.textContent ?? ""));
+ expect(save).toBeTruthy();
+
+ await act(async () => {
+ save!.click();
+ await new Promise((resolve) => testWindow.setTimeout(resolve, 0));
+ await Promise.resolve();
+ });
+
+ expect(putCount).toBeGreaterThanOrEqual(1);
+ expect(container.textContent).toContain("Saved.");
+ } finally {
+ await act(async () => root.unmount());
+ testWindow.close();
+ }
+});
diff --git a/gui/tests/claudecode-layout.test.ts b/gui/tests/claudecode-layout.test.ts
index b9dbffa3d3..4ef79262d4 100644
--- a/gui/tests/claudecode-layout.test.ts
+++ b/gui/tests/claudecode-layout.test.ts
@@ -16,11 +16,11 @@ test("ClaudeCode stacked layout mounts every section in order", async () => {
const src = await Bun.file(new URL("../src/pages/ClaudeCode.tsx", import.meta.url)).text();
const order = [
- "{settingsSection}",
- "{quickstartSection}",
- "{smallFastSection}",
- "{modelMapSection}",
- "{aliasesSection}",
+ " {
+ const res = new Response("nope", { status: 500 });
+ expect(await readJsonIfOk(res)).toBeNull();
+});
+
+test("readJsonIfOk returns null for malformed OK JSON (does not throw)", async () => {
+ const res = new Response("{not-json", {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ expect(await readJsonIfOk(res)).toBeNull();
+});
+
+test("readJsonIfOk returns undefined for empty OK bodies", async () => {
+ const res = new Response("", { status: 200 });
+ expect(await readJsonIfOk(res)).toBeUndefined();
+});
+
+test("readJsonOrThrow surfaces server error messages", async () => {
+ const res = Response.json({ error: "locked" }, { status: 503 });
+ await expect(readJsonOrThrow(res, "fallback")).rejects.toThrow("locked");
+});
diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts
index dbe3bd6591..035582f19e 100644
--- a/tests/codex-v2-gate.test.ts
+++ b/tests/codex-v2-gate.test.ts
@@ -203,7 +203,9 @@ describe("max_concurrent_threads_per_session reader/writer", () => {
expect(transitionMultiAgentV2(true, flipPrefixFlag, { configPath: prefixOnly }).ok).toBe(true);
expect(readFileSync(prefixOnly, "utf8")).toContain("backup_max_concurrent_threads_per_session = 7");
expect(getMaxConcurrentThreads(prefixOnly)).toBe(100);
- });
+ // Two transitions × several atomic writes; on Windows each write runs icacls and
+ // can exceed bun's 5s default under CI load.
+ }, { timeout: 20_000 });
});
describe("thread-limit-preserving v1/v2 transition", () => {
diff --git a/tests/web-search-progress-stream.test.ts b/tests/web-search-progress-stream.test.ts
index d24fe6d617..e994459bee 100644
--- a/tests/web-search-progress-stream.test.ts
+++ b/tests/web-search-progress-stream.test.ts
@@ -87,14 +87,36 @@ describe("web-search streamed-body progress collector", () => {
});
test("raw response bytes keep a generation alive beyond its initial total elapsed time", async () => {
- // Timing contract: total elapsed (20 chunks x 10ms nominal = 200ms) far exceeds the
- // 120ms inactivity timeout, proving raw bytes reset the timer — while any SINGLE
- // inter-chunk gap stays far below 120ms even under Windows CI timer granularity
- // (a nominal 10ms sleep can stretch to 30ms+ there; the old 15ms-vs-30ms margin flaked).
- const response = new Response(chunkStream(
- Array.from({ length: 20 }, (_, i) => ({ after: 10, value: String.fromCharCode(97 + (i % 26)) })),
- ));
- const events = await collect(parseStreamWithProgress(response, drainThenDone, { inactivityTimeoutMs: 120 }));
+ // Drive each byte through an explicit gate so suite-load jitter on Windows CI cannot
+ // invent a false stall between sleeps (10ms-vs-120ms flaked on run 30185030821).
+ // Gaps stay a fixed fraction of the inactivity window; total wall time still exceeds it.
+ const inactivityTimeoutMs = 1_000;
+ const gapMs = 80; // 12.5× under the window — tolerates multi-100ms scheduler stalls
+ const target = 16; // ~1.2s total >> 1s
+ let resolveGate!: () => void;
+ let gate = new Promise(resolve => { resolveGate = resolve; });
+ let sent = 0;
+ const body = new ReadableStream({
+ async pull(controller) {
+ if (sent >= target) {
+ controller.close();
+ return;
+ }
+ await gate;
+ gate = new Promise(resolve => { resolveGate = resolve; });
+ controller.enqueue(bytes(String.fromCharCode(97 + (sent++ % 26))));
+ },
+ }, { highWaterMark: 0 });
+
+ const pending = collect(parseStreamWithProgress(new Response(body), drainThenDone, { inactivityTimeoutMs }));
+ const started = Date.now();
+ resolveGate(); // first byte immediately — inactivity is already armed at collector start
+ for (let i = 1; i < target; i++) {
+ await sleep(gapMs);
+ resolveGate();
+ }
+ const events = await pending;
+ expect(Date.now() - started).toBeGreaterThan(inactivityTimeoutMs);
expect(events.at(-1)).toEqual({ type: "done" });
expect(events.some(event => event.type === "heartbeat")).toBe(true);
});