Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

### Fixed

- Dashboard Errors tab now has a real clear action: `DELETE /admin/error-logs` removes current + backup JSONL files and the read cursor so repeated `StreamUpstreamPrematureClose` groups can be cleared from the page instead of only marked read. Anthropic setup defaults now map Opus 4.7 → `gpt-5.5` and Sonnet 4.6 → `gpt-5.4`, and the built-in Anthropic API-key catalog lists Claude Opus 4.7 (`src/logs/error-log.ts`, `src/routes/admin/error-logs.ts`, `shared/hooks/use-error-logs.ts`, `web/src/pages/ErrorsPage.tsx`, `web/src/components/AnthropicSetup.tsx`, `src/auth/api-key-catalog.ts`).
- Claude Code 的 `Read` 工具参数里如果 `pages` 传成空字符串或空白字符串,会在 Codex → Anthropic 转换时被自动剔除,避免 GPT-5.5 反复触发 `Read tool validation error: Invalid pages parameter: ""` 并重试隔离工作树;对应单测覆盖流式与非流式两条路径,以及非空 PDF 页码范围保留(`src/translation/codex-to-anthropic.ts`、`tests/unit/translation/codex-to-anthropic-read-pages.test.ts`)。
- Dashboard egress log details now include Codex request `reasoning` and optional `service_tier`, so `/admin/logs` can show the actual reasoning effort sent upstream instead of only `model` / `stream` / `useWebSocket` (`src/routes/shared/proxy-egress-log.ts`, `tests/unit/routes/shared/proxy-egress-log.test.ts`).
- implicit-resume 区分"真 missing tool call"与"client 自包含 full replay":`evaluateImplicitResume` 现在新增 `inlineFunctionCallIds` 入参,由 `buildProxySessionContext` 通过 `getInlineFunctionCallIds(codexRequest.input)` 在调用前收集 input 里所有 `function_call` 项的 call_id。当 input 里的 function_call_output 全部能在 input 内找到对应 function_call(典型 Codex CLI `/compact` 或客户端 fallback 自包含 replay 场景),返回 `reason: "self_contained_replay"` 而不是 `missing_tool_calls`,proxy 走正常透传不再触发 payload guard 413。混合场景(部分 inline 部分 storage 都找不到)仍判 `missing_tool_calls` 防真 runaway。新增 4 个 `evaluateImplicitResume`/`getInlineFunctionCallIds`/`isSelfContainedReplay` 单测覆盖纯 inline、混合、空 output、incremental turn 四类(`src/routes/shared/proxy-session-helpers.ts`、`src/routes/shared/proxy-session-context.ts`、`tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts`、`tests/unit/routes/shared/proxy-session-context.test.ts`)。
Expand Down
17 changes: 16 additions & 1 deletion shared/hooks/use-error-logs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { formatRelativeTime } from "./use-error-logs.js";
import { clearErrorLogsRequest, formatRelativeTime } from "./use-error-logs.js";

describe("formatRelativeTime", () => {
const now = new Date("2026-05-10T12:00:00Z").getTime();
Expand Down Expand Up @@ -28,3 +28,18 @@ describe("formatRelativeTime", () => {
expect(formatRelativeTime("not-a-date", now)).toBe("not-a-date");
});
});

describe("clearErrorLogsRequest", () => {
it("sends a collection DELETE to the error log endpoint", async () => {
const fetchImpl = async (
input: string,
init: RequestInit,
): Promise<Pick<Response, "ok">> => {
expect(input).toBe("/admin/error-logs");
expect(init).toEqual({ method: "DELETE" });
return { ok: true };
};

await expect(clearErrorLogsRequest(fetchImpl)).resolves.toBe(true);
});
});
24 changes: 23 additions & 1 deletion shared/hooks/use-error-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export interface ErrorLogCount {

const POLL_MS = 30_000;

type ErrorLogsFetch = (input: string, init: RequestInit) => Promise<Pick<Response, "ok">>;

export async function clearErrorLogsRequest(
fetchImpl: ErrorLogsFetch = (input, init) => fetch(input, init),
): Promise<boolean> {
const res = await fetchImpl("/admin/error-logs", { method: "DELETE" });
return res.ok;
}

export function useErrorLogs() {
const [groups, setGroups] = useState<ErrorGroup[]>([]);
const [count, setCount] = useState<ErrorLogCount>({ total: 0, unread: 0 });
Expand Down Expand Up @@ -68,6 +77,19 @@ export function useErrorLogs() {
}
}, [load]);

const clearAll = useCallback(async () => {
try {
const ok = await clearErrorLogsRequest();
if (!ok) {
setError("Failed to clear error logs");
return;
}
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to clear error logs");
}
}, [load]);

useEffect(() => {
void load();
timerRef.current = setInterval(() => void load(), POLL_MS);
Expand All @@ -76,7 +98,7 @@ export function useErrorLogs() {
};
}, [load]);

return { groups, count, loading, error, refresh: load, markAllSeen };
return { groups, count, loading, error, refresh: load, markAllSeen, clearAll };
}

/**
Expand Down
2 changes: 2 additions & 0 deletions shared/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const translations = {
errorsTabDesc: "Uncaught crashes from the proxy backend, Electron main, and the dashboard renderer. Stored locally — nothing leaves this machine.",
errorsRefresh: "Refresh",
errorsMarkSeen: "Mark all read",
errorsClear: "Clear all",
errorsBadge: "errors",
errorsBadgeTooltip: "Unread errors — click to view",
errorsNone: "No errors recorded.",
Expand Down Expand Up @@ -634,6 +635,7 @@ export const translations = {
errorsTabDesc: "Proxy 后端、Electron 主进程、Dashboard 渲染进程的 uncaught 崩溃。仅本地存储,不上传任何数据。",
errorsRefresh: "刷新",
errorsMarkSeen: "全部标记已读",
errorsClear: "清空错误",
errorsBadge: "条错误",
errorsBadgeTooltip: "未读错误 — 点击查看",
errorsNone: "暂无错误记录。",
Expand Down
2 changes: 1 addition & 1 deletion src/auth/api-key-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface ProviderMeta {
}

const ANTHROPIC_MODELS: CatalogModel[] = [
{ id: "claude-opus-4-6", displayName: "Claude Opus 4.6" },
{ id: "claude-opus-4-7", displayName: "Claude Opus 4.7" },
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5" },
];
Expand Down
13 changes: 13 additions & 0 deletions src/logs/error-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "fs";
import { resolve } from "path";
Expand Down Expand Up @@ -214,6 +215,18 @@ export function readErrorLog(limit?: number): ErrorLogEntry[] {
return combined;
}

/** Remove all persisted error log entries and the read cursor. */
export function clearErrorLog(): void {
for (const file of [LOG_FILE, BACKUP_FILE, CURSOR_FILE]) {
try {
const path = resolve(getDataDir(), file);
if (existsSync(path)) unlinkSync(path);
} catch {
// Clearing is best-effort; a failed delete must not break the admin UI.
}
}
}

function firstStackFrame(stack: string | undefined): string {
if (!stack) return "";
for (const line of stack.split("\n")) {
Expand Down
6 changes: 6 additions & 0 deletions src/routes/admin/error-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hono } from "hono";
import { z } from "zod";
import {
appendErrorLog,
clearErrorLog,
groupErrorLog,
getUnreadCount,
readErrorLog,
Expand Down Expand Up @@ -59,6 +60,11 @@ export function createErrorLogRoutes(): Hono {
return c.json({ ok: true, cursor });
});

app.delete("/admin/error-logs", (c) => {
clearErrorLog();
return c.json({ ok: true });
});

app.post("/admin/error-logs/report", async (c) => {
const raw = await c.req.json().catch(() => null);
if (raw === null) {
Expand Down
48 changes: 47 additions & 1 deletion tests/unit/routes/admin/error-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, existsSync, rmSync, readFileSync } from "fs";
import { mkdtempSync, existsSync, rmSync, readFileSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { resolve } from "path";
import { Hono } from "hono";
Expand Down Expand Up @@ -183,6 +183,52 @@ describe("POST /admin/error-logs/seen", () => {
});
});

describe("DELETE /admin/error-logs", () => {
it("clears current, rotated backup, and cursor files so grouped logs and counts become empty", async () => {
await appendFew();
writeFileSync(
resolve(tmpDataDir, "error-log.1.jsonl"),
JSON.stringify({
ts: "2026-05-01T00:00:00.000Z",
version: "0.0.0-test",
platform: "darwin",
source: "server",
error: { name: "StreamUpstreamPrematureClose", message: "closed early" },
}) + "\n",
"utf-8",
);
const { setReadCursor } = await import("@src/logs/error-log.js");
setReadCursor("2025-01-01T00:00:00.000Z");
const app = await buildApp();

expect(existsSync(resolve(tmpDataDir, "error-log.jsonl"))).toBe(true);
expect(existsSync(resolve(tmpDataDir, "error-log.1.jsonl"))).toBe(true);
expect(existsSync(resolve(tmpDataDir, "error-log.cursor"))).toBe(true);

const before = await app.request("/admin/error-logs/count");
expect(((await before.json()) as { total: number; unread: number })).toEqual({
total: 4,
unread: 4,
});

const clearRes = await app.request("/admin/error-logs", { method: "DELETE" });
expect(clearRes.status).toBe(200);
expect(await clearRes.json()).toEqual({ ok: true });
expect(existsSync(resolve(tmpDataDir, "error-log.jsonl"))).toBe(false);
expect(existsSync(resolve(tmpDataDir, "error-log.1.jsonl"))).toBe(false);
expect(existsSync(resolve(tmpDataDir, "error-log.cursor"))).toBe(false);

const grouped = await app.request("/admin/error-logs");
expect((await grouped.json()) as { groups: unknown[] }).toEqual({ groups: [] });

const after = await app.request("/admin/error-logs/count");
expect(((await after.json()) as { total: number; unread: number })).toEqual({
total: 0,
unread: 0,
});
});
});

describe("POST /admin/error-logs/report", () => {
it("appends a renderer-reported error to the log with sanitized context", async () => {
const app = await buildApp();
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/routes/api-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ describe("api key routes", () => {
app = createApiKeyRoutes(pool);
});

it("returns current built-in Anthropic catalog defaults", async () => {
const res = await app.request("/auth/api-keys/catalog");
expect(res.status).toBe(200);

const body = await res.json() as {
catalog: {
anthropic: {
models: Array<{ id: string; displayName: string }>;
};
};
};
expect(body.catalog.anthropic.models.slice(0, 2)).toEqual([
{ id: "claude-opus-4-7", displayName: "Claude Opus 4.7" },
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
]);
});

it("adds one stored entry per selected model and masks returned keys", async () => {
const res = await app.request("/auth/api-keys", {
method: "POST",
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/web/anthropic-setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import {
ANTHROPIC_MODEL_PRESETS,
DEFAULT_ANTHROPIC_MODELS,
} from "../../../web/src/components/AnthropicSetup";

describe("AnthropicSetup defaults", () => {
it("maps current Claude families to the desired Codex defaults", () => {
expect(DEFAULT_ANTHROPIC_MODELS).toEqual({
opus: "gpt-5.5",
sonnet: "gpt-5.4",
haiku: "gpt-5.4-mini",
});

expect(ANTHROPIC_MODEL_PRESETS.slice(0, 2)).toEqual([
{ label: "gpt-5.5 (Opus 4.7)", value: "gpt-5.5" },
{ label: "gpt-5.4 (Sonnet 4.6)", value: "gpt-5.4" },
]);
});
});
12 changes: 12 additions & 0 deletions tests/unit/web/errors-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,16 @@ describe("ErrorsPage", () => {
expect(source).toContain("group.sample_context");
expect(source).toContain("JSON.stringify(group.sample_context, null, 2)");
});

it("wires a clear-all control for persisted error log entries", () => {
const source = readFileSync(
resolve(__dirname, "../../../web/src/pages/ErrorsPage.tsx"),
"utf-8",
);

expect(source).toContain("clearAll");
expect(source).toContain("errorsClear");
expect(source).toContain("aria-label={t(\"errorsClear\")}");
expect(source).toContain("onClick={() => void clearAll()}");
});
});
22 changes: 14 additions & 8 deletions web/src/components/AnthropicSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,25 @@ interface AnthropicSetupProps {
serviceTier: string | null;
}

const PRESETS: Array<{ label: string; value: string }> = [
{ label: "gpt-5.4 (Opus)", value: "gpt-5.4" },
{ label: "gpt-5.3-codex (Sonnet)", value: "gpt-5.3-codex" },
export const DEFAULT_ANTHROPIC_MODELS = {
opus: "gpt-5.5",
sonnet: "gpt-5.4",
haiku: "gpt-5.4-mini",
};

export const ANTHROPIC_MODEL_PRESETS: Array<{ label: string; value: string }> = [
{ label: "gpt-5.5 (Opus 4.7)", value: DEFAULT_ANTHROPIC_MODELS.opus },
{ label: "gpt-5.4 (Sonnet 4.6)", value: DEFAULT_ANTHROPIC_MODELS.sonnet },
{ label: "gpt-5.4-mini (Haiku)", value: "gpt-5.4-mini" },
];

export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, serviceTier }: AnthropicSetupProps) {
const t = useT();
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:8080";

const [opusModel, setOpusModel] = useState("gpt-5.4");
const [sonnetModel, setSonnetModel] = useState("gpt-5.3-codex");
const [haikuModel, setHaikuModel] = useState("gpt-5.4-mini");
const [opusModel, setOpusModel] = useState(DEFAULT_ANTHROPIC_MODELS.opus);
const [sonnetModel, setSonnetModel] = useState(DEFAULT_ANTHROPIC_MODELS.sonnet);
const [haikuModel, setHaikuModel] = useState(DEFAULT_ANTHROPIC_MODELS.haiku);

// Custom model from ApiConfig
const customModel = useMemo(() => {
Expand All @@ -40,7 +46,7 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service
.catch(() => {});
}, []);

const presetValues = new Set(PRESETS.map((p) => p.value));
const presetValues = new Set(ANTHROPIC_MODEL_PRESETS.map((p) => p.value));
const extraModels = allModels.filter((id) => !presetValues.has(id));

const envText = useMemo(() => [
Expand All @@ -59,7 +65,7 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service

const modelDropdown = (value: string, onChange: (v: string) => void) => (
<select class={selectCls} value={value} onChange={(e) => onChange((e.target as HTMLSelectElement).value)}>
{PRESETS.map((p) => <option key={p.value} value={p.value}>{p.label}</option>)}
{ANTHROPIC_MODEL_PRESETS.map((p) => <option key={p.value} value={p.value}>{p.label}</option>)}
{extraModels.length > 0 && <option disabled>───</option>}
{extraModels.map((id) => <option key={id} value={id}>{id}</option>)}
{!presetValues.has(value) && !extraModels.includes(value) && <option value={value}>{value}</option>}
Expand Down
18 changes: 17 additions & 1 deletion web/src/pages/ErrorsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function ErrorRow({ group }: { group: ErrorGroup }) {

export function ErrorsPage() {
const t = useT();
const { groups, count, loading, error, refresh, markAllSeen } = useErrorLogs();
const { groups, count, loading, error, refresh, markAllSeen, clearAll } = useErrorLogs();

return (
<section class="flex flex-col gap-4">
Expand Down Expand Up @@ -114,6 +114,22 @@ export function ErrorsPage() {
{t("errorsMarkSeen")} ({count.unread})
</button>
)}
{groups.length > 0 && (
<button
type="button"
onClick={() => void clearAll()}
aria-label={t("errorsClear")}
title={t("errorsClear")}
class="inline-flex size-8 items-center justify-center rounded-lg border border-red-200 text-red-600 hover:bg-red-50 dark:border-red-700/30 dark:text-red-400 dark:hover:bg-red-900/20 transition-colors"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 6h18" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8 6V4h8v2" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 6l-1 14H6L5 6" />
<path stroke-linecap="round" stroke-linejoin="round" d="M10 11v5m4-5v5" />
</svg>
</button>
)}
</div>
</div>

Expand Down
Loading