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 .github/scripts/enforce-pr-target.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe("enforce-pr-target workflow", () => {
assert.match(workflow, /stackedBase/);
assert.match(workflow, /github\.rest\.pulls\.list/);
assert.match(workflow, /treating as stacked/);
assert.match(workflow, /other\.base\?\.repo\?\.owner/);
Comment thread
Wibias marked this conversation as resolved.
const qualityCall = workflow.match(
/collectPrQualityFailures\(\{([\s\S]*?)\}\);/,
);
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/enforce-pr-target.yml
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,8 @@ jobs:
other =>
other.number !== pull_number &&
other.head?.ref === pr.base.ref &&
(other.head?.repo?.owner?.login ?? owner) === baseOwner &&
(other.head?.repo?.name ?? repo) === baseName
(other.base?.repo?.owner?.login ?? owner) === baseOwner &&
(other.base?.repo?.name ?? repo) === baseName
);
if (stackedBase) {
core.info(
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints
| `GET /api/codex-auth/accounts?refresh=1` | List main and pool accounts, force quota refresh, and report main-account `hasCredential` / terminal `needsReauth` state. |
| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. |
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. |
| `GET /api/logs?tail=50&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. |
| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, total, logs }` where `total` is the filtered row count before pagination. |
| `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. |
| `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. |

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ja/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです
| `GET /api/codex-auth/accounts?refresh=1` | メインおよびプールアカウントを参照しクォータを強制更新し、メインの `hasCredential` / terminal `needsReauth` 状態を返します。 |
| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 次のリクエストで使うアカウントとプールルーティングポリシーを設定します。 |
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | ブラウザログインでプールアカウントを追加します。 |
| `GET /api/logs?tail=50&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。 |
| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。`limit`/`offset` は最新行から過去方向にページングします(`offset=0` が最新ページ)。応答は `{ timeZone, total, logs }` で、`total` はページング前の一致件数です。 |
| `GET` / `PUT /api/subagent-models` | `spawn_agent` に優先公開するモデル 5 つを読むか設定します。 |
| `POST /api/stop` | プロキシ/サービスを停止しネイティブ Codex を復元した後終了します。 |

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ko/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니
| `GET /api/codex-auth/accounts?refresh=1` | main 및 pool 계정을 조회하고 할당량을 강제로 갱신하며 main 계정의 `hasCredential` / terminal `needsReauth` 상태를 표시합니다. |
| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 다음 요청에 사용할 계정과 풀 라우팅 정책을 설정합니다. |
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 브라우저 로그인으로 pool 계정을 추가합니다. |
| `GET /api/logs?tail=50&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. |
| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. `limit`/`offset`은 최신 행에서 과거 방향으로 페이지네이션합니다(`offset=0`이 최신 페이지). 응답은 `{ timeZone, total, logs }`이며 `total`은 페이지네이션 전 필터 일치 건수입니다. |
| `GET` / `PUT /api/subagent-models` | `spawn_agent`에 우선 노출할 모델 5개를 읽거나 설정합니다. |
| `POST /api/stop` | 프록시/서비스를 멈추고 네이티브 Codex를 복원한 뒤 종료합니다. |

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ru/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ GUI — это тонкий клиент поверх JSON-API управлен
| `GET /api/codex-auth/accounts?refresh=1` | Список основного и пуловых аккаунтов с принудительным обновлением квот и состояниями `hasCredential` / terminal `needsReauth` основного аккаунта. |
| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Выбор аккаунта для следующего запроса и настройка маршрутизации пула. |
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Добавление аккаунта пула через вход в браузере. |
| `GET /api/logs?tail=50&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. |
| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. `limit`/`offset` листают назад от самой новой строки (`offset=0` — последняя страница). Ответ: `{ timeZone, total, logs }`, где `total` — число совпадений до пагинации. |
| `GET` / `PUT /api/subagent-models` | Чтение или настройка пяти выделенных моделей переопределения `spawn_agent`. |
| `POST /api/stop` | Остановка прокси/сервиса, восстановление нативного Codex и выход. |

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括
| `GET /api/codex-auth/accounts?refresh=1` | 列出主账号与池账号、强制刷新配额,并返回主账号的 `hasCredential` / terminal `needsReauth` 状态。 |
| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 选择下一次请求使用的账号并配置账号池路由。 |
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 通过浏览器登录添加池账号。 |
| `GET /api/logs?tail=50&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。 |
| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。`limit`/`offset` 从最新一行向前分页(`offset=0` 为最新一页)。响应为 `{ timeZone, total, logs }`,其中 `total` 为分页前的匹配行数。 |
| `GET` / `PUT /api/subagent-models` | 读取或设置五个置顶的 `spawn_agent` override 模型。 |
| `POST /api/stop` | 停止代理/服务,恢复原生 Codex 并退出。 |

Expand Down
5 changes: 3 additions & 2 deletions gui/src/pages/Logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,10 @@ export default function Logs({ apiBase }: { apiBase: string }) {
const selectTab = selectLogsTab;

const loadLogs = useCallback(async (signal: AbortSignal): Promise<LogEntry[]> => {
const res = await fetch(`${apiBase}/api/logs`, { signal });
const res = await fetch(`${apiBase}/api/logs?limit=2000`, { signal });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim());
const next = await res.json() as LogEntry[];
const body = await res.json() as LogEntry[] | { logs?: LogEntry[] };
const next = Array.isArray(body) ? body : (body.logs ?? []);
Comment thread
Wibias marked this conversation as resolved.
writeSessionListCache(logsCacheKey(apiBase), next);
return next;
}, [apiBase]);
Expand Down
12 changes: 9 additions & 3 deletions src/server/management/logs-usage-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import {
} from "../../lib/debug-settings";
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
import { drainAndShutdown } from "../lifecycle";
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log";
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
import type { PersistedUsageAttempt } from "../../usage/log";
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
Expand Down Expand Up @@ -124,8 +124,14 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;

if (url.pathname === "/api/logs" && req.method === "GET") {
const logs = filterRequestLogs(getRequestLogEntries(), url.searchParams);
return jsonResponse(logs.map(requestLogDto));
const all = getRequestLogEntries();
const total = filteredRequestLogCount(all, url.searchParams);
const logs = filterRequestLogs(all, url.searchParams);
return jsonResponse({
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
total,
logs: logs.map(requestLogDto),
});
Comment thread
Wibias marked this conversation as resolved.
}

if (url.pathname === "/api/debug" && req.method === "GET") {
Expand Down
25 changes: 24 additions & 1 deletion src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export interface RequestLogEntry {
}

const requestLog: RequestLogEntry[] = [];
const MAX_LOG_SIZE = 200;
const MAX_LOG_SIZE = 2000;
let requestLogSeq = 0;
/** True after hydrateRequestLogsFromDisk ran once in this process. */
let requestLogsHydratedFromDisk = false;
Expand Down Expand Up @@ -760,9 +760,32 @@ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchPara
const tail = Number.parseInt(tailRaw, 10);
if (Number.isFinite(tail) && tail > 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE));
}
const offsetRaw = params.get("offset")?.trim();
const limitRaw = params.get("limit")?.trim();
if (limitRaw) {
Comment thread
Wibias marked this conversation as resolved.
const limit = Number.parseInt(limitRaw, 10);
const offset = offsetRaw ? Number.parseInt(offsetRaw, 10) : 0;
if (Number.isFinite(limit) && limit > 0) {
const capped = Math.min(limit, MAX_LOG_SIZE);
const startOffset = Number.isFinite(offset) && offset > 0 ? offset : 0;
const end = filtered.length - startOffset;
if (end <= 0) filtered = [];
else {
const begin = Math.max(0, end - capped);
filtered = filtered.slice(begin, end);
}
}
}
return filtered;
}

export function filteredRequestLogCount(logs: RequestLogEntry[], params: URLSearchParams): number {
const withoutPagination = new URLSearchParams(params);
withoutPagination.delete("limit");
withoutPagination.delete("offset");
return filterRequestLogs(logs, withoutPagination).length;
}

interface FinalizedUsageResult {
usage?: OcxUsage;
status: UsageStatus;
Expand Down
5 changes: 3 additions & 2 deletions tests/claude-messages-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { managementFetch as fetch } from "./helpers/management-auth";
import { logsFromApiBody } from "./helpers/logs-api";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -112,9 +113,9 @@ test("POST /v1/messages?beta=true streams an Anthropic-shaped turn end to end",
// Request log regression (live smoke round 2): the tap must see the PRE-translation
// Responses stream — the translated Anthropic stream has no response.completed, which
// used to record a bogus 502 with no usage.
const logs = await (await fetch(new URL("/api/logs", server.url))).json() as {
const logs = logsFromApiBody<{
status: number; model: string; usage?: { inputTokens: number; outputTokens: number }; usageStatus: string;
}[];
}>(await (await fetch(new URL("/api/logs", server.url))).json());
const row = logs.find(l => l.model === "test-model" || l.model === "mock/test-model");
expect(row).toBeDefined();
expect(row!.status).toBe(200);
Expand Down
9 changes: 5 additions & 4 deletions tests/claude-native-passthrough.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { managementFetch as fetch } from "./helpers/management-auth";
import { logsFromApiBody } from "./helpers/logs-api";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -116,8 +117,8 @@ test("unmapped claude model + sk-ant credential passes through verbatim", async
expect(hit.body).toEqual(claudeBody());

// Request log: native provider tag + usage incl. cache detail from the SSE tap.
const logs = await (await fetch(new URL("/api/logs", server.url))).json() as any[];
const row = logs.find(l => l.provider === "anthropic-native");
const logs = logsFromApiBody(await (await fetch(new URL("/api/logs?tail=1", server.url))).json());
const row = logs.at(-1);
expect(row).toBeDefined();
expect(row.status).toBe(200);
expect(row.model).toBe("claude-fable-5");
Expand Down Expand Up @@ -168,10 +169,10 @@ test("native passthrough persists conversationId from metadata.user_id", async (
expect(res.status).toBe(200);
await res.text();

const logs = await (await fetch(new URL("/api/logs?tail=1", server.url))).json() as Array<{
const logs = logsFromApiBody<{
provider?: string;
conversationId?: string;
}>;
}>(await (await fetch(new URL("/api/logs?tail=1", server.url))).json());
expect(logs).toHaveLength(1);
expect(logs[0]?.provider).toBe("anthropic-native");
expect(logs[0]?.conversationId).toBe(createHash("sha256").update(userId).digest("hex").slice(0, 32));
Expand Down
2 changes: 1 addition & 1 deletion tests/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("CLI subcommand help", () => {
expect(binResult.status).toBe(0);
expect(binResult.stdout.trim()).toMatch(/^opencodex \d+\.\d+\.\d+/);
expect(binResult.stdout.trim().split("\n")).toHaveLength(1);
});
}, { timeout: 20_000 });

test("help command routes to subcommand help", () => {
const result = runCli(["help", "start"]);
Expand Down
2 changes: 1 addition & 1 deletion tests/codex-plugins-doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,5 +320,5 @@ describe("ocx status --json codexPlugins (spawned, read-only)", () => {
rmSync(opencodexHome, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
});
}, { timeout: 20_000 });
});
7 changes: 7 additions & 0 deletions tests/helpers/logs-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function logsFromApiBody<T extends Record<string, unknown> = Record<string, unknown>>(body: unknown): T[] {
if (Array.isArray(body)) return body as T[];
if (body && typeof body === "object" && Array.isArray((body as { logs?: unknown }).logs)) {
return (body as { logs: T[] }).logs;
}
return [];
}
20 changes: 12 additions & 8 deletions tests/logs-timezone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ const config = { providers: [] } as unknown as OcxConfig;
* #725: the dashboard rendered request-log timestamps in the BROWSER's zone, so a proxy
* running in KST viewed from a UTC browser reported every request nine hours off.
*
* The zone rides on /api/settings rather than /api/logs. PR #790 put it in a
* `{timeZone, logs}` envelope on /api/logs, which would have broken four tests that read
* that response as an array (server-auth:1623, claude-native-passthrough:119,
* openai-provider-option-e2e:489, server-403-permission-e2e:86) without touching any of them.
* The zone is on /api/settings and also on the /api/logs envelope (`{ timeZone, total, logs }`,
* #726). Consumers that still need the row list go through `logsFromApiBody`.
*/
describe("log timestamp timezone (#725)", () => {
test("/api/settings reports the server's IANA zone", async () => {
Expand All @@ -26,13 +24,19 @@ describe("log timestamp timezone (#725)", () => {
expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow();
});

test("/api/logs still returns a bare array", async () => {
// The contract four other suites depend on. If this ever becomes an object, those
// suites fail somewhere far from here, so assert it at the source.
test("/api/logs envelope includes a usable timeZone", async () => {
const url = new URL("http://localhost/api/logs");
const response = await handleManagementAPI(new Request(url), url, config);
expect(response?.status).toBe(200);
expect(Array.isArray(await response!.json())).toBe(true);
const body = await response!.json() as {
timeZone?: unknown;
total?: unknown;
logs?: unknown;
};
expect(typeof body.timeZone).toBe("string");
expect(typeof body.total).toBe("number");
expect(Array.isArray(body.logs)).toBe(true);
expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow();
});
});

Expand Down
17 changes: 16 additions & 1 deletion tests/management-api-logs-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ async function readLogs(): Promise<Array<Record<string, any>>> {
const url = new URL("http://localhost/api/logs");
const response = await handleManagementAPI(new Request(url), url, config);
expect(response?.status).toBe(200);
return await response!.json() as Array<Record<string, any>>;
const body = await response!.json() as { logs?: Array<Record<string, any>>; timeZone?: string };
expect(typeof body.timeZone).toBe("string");
expect(body.timeZone!.length).toBeGreaterThan(0);
return body.logs ?? [];
}

function baseEntry(overrides: Partial<RequestLogEntry>): RequestLogEntry {
Expand All @@ -33,6 +36,18 @@ function baseEntry(overrides: Partial<RequestLogEntry>): RequestLogEntry {
}

describe("GET /api/logs display metrics", () => {
test("reports filtered total before limit pagination", async () => {
addRequestLog(baseEntry({ requestId: "ok-a", provider: "anthropic", status: 200 }));
addRequestLog(baseEntry({ requestId: "ok-b", provider: "anthropic", status: 200 }));
addRequestLog(baseEntry({ requestId: "fail", provider: "openai", status: 500 }));
const url = new URL("http://localhost/api/logs?provider=anthropic&limit=1");
const response = await handleManagementAPI(new Request(url), url, config);
expect(response?.status).toBe(200);
const body = await response!.json() as { total?: number; logs?: Array<{ requestId?: string }> };
expect(body.total).toBe(2);
expect(body.logs?.map(row => row.requestId)).toEqual(["ok-b"]);
});

test("adds tok/s and cost without mutating the stored log", async () => {
addRequestLog(baseEntry({
usage: { inputTokens: 1000, outputTokens: 240 },
Expand Down
Loading
Loading