diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index f0636f273..c09f63326 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -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/); const qualityCall = workflow.match( /collectPrQualityFailures\(\{([\s\S]*?)\}\);/, ); diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index c6b9a3123..a4675fa48 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -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( diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 4e20b136b..adc970bc8 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -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. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 16a62950a..38bd2086b 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -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 を復元した後終了します。 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index b5ea350c9..63ef1b0da 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -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를 복원한 뒤 종료합니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index c9fde72ee..d7a4c97d2 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -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 и выход. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index cac81c25c..85dc44d9b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -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 并退出。 | diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 7d0053bf8..7b8b0370d 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -395,9 +395,10 @@ export default function Logs({ apiBase }: { apiBase: string }) { const selectTab = selectLogsTab; const loadLogs = useCallback(async (signal: AbortSignal): Promise => { - 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 ?? []); writeSessionListCache(logsCacheKey(apiBase), next); return next; }, [apiBase]); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 718005fb8..939127caf 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -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"; @@ -124,8 +124,14 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE)); } + const offsetRaw = params.get("offset")?.trim(); + const limitRaw = params.get("limit")?.trim(); + if (limitRaw) { + 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; diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 15e7b7f16..205c5cdfb 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -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"; @@ -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); diff --git a/tests/claude-native-passthrough.test.ts b/tests/claude-native-passthrough.test.ts index 6743ded19..0fd82b427 100644 --- a/tests/claude-native-passthrough.test.ts +++ b/tests/claude-native-passthrough.test.ts @@ -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"; @@ -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"); @@ -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)); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 4591ebe9e..c64d2b6f6 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -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"]); diff --git a/tests/codex-plugins-doctor.test.ts b/tests/codex-plugins-doctor.test.ts index 40a6d9ff7..af7ff029c 100644 --- a/tests/codex-plugins-doctor.test.ts +++ b/tests/codex-plugins-doctor.test.ts @@ -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 }); }); diff --git a/tests/helpers/logs-api.ts b/tests/helpers/logs-api.ts new file mode 100644 index 000000000..82a324723 --- /dev/null +++ b/tests/helpers/logs-api.ts @@ -0,0 +1,7 @@ +export function logsFromApiBody = Record>(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 []; +} diff --git a/tests/logs-timezone.test.ts b/tests/logs-timezone.test.ts index 5b6392e06..d31edfc08 100644 --- a/tests/logs-timezone.test.ts +++ b/tests/logs-timezone.test.ts @@ -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 () => { @@ -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(); }); }); diff --git a/tests/management-api-logs-metrics.test.ts b/tests/management-api-logs-metrics.test.ts index 3b4c6c2d5..b24b9e6d3 100644 --- a/tests/management-api-logs-metrics.test.ts +++ b/tests/management-api-logs-metrics.test.ts @@ -16,7 +16,10 @@ async function readLogs(): Promise>> { 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>; + const body = await response!.json() as { logs?: Array>; timeZone?: string }; + expect(typeof body.timeZone).toBe("string"); + expect(body.timeZone!.length).toBeGreaterThan(0); + return body.logs ?? []; } function baseEntry(overrides: Partial): RequestLogEntry { @@ -33,6 +36,18 @@ function baseEntry(overrides: Partial): 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 }, diff --git a/tests/openai-api-virtual-models.test.ts b/tests/openai-api-virtual-models.test.ts index 3ff38c5e1..56a403708 100644 --- a/tests/openai-api-virtual-models.test.ts +++ b/tests/openai-api-virtual-models.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementHeaders } from "./helpers/management-auth"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -231,7 +232,8 @@ describe("OpenAI API compact transport", () => { const server = startServer(0); const readLogs = () => originalFetch(new URL("/api/logs", server.url), { headers: managementHeaders() }) - .then(response => response.json()) as Promise>>; + .then(response => response.json()) + .then(body => logsFromApiBody(body)); const readUsage = (): Array> => existsSync(usageLogPath()) ? readFileSync(usageLogPath(), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line) as Record) : []; @@ -399,7 +401,8 @@ describe("OpenAI API Pro transport identities", () => { ? readFileSync(usageLogPath(), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line) as Record) : []; const readLogs = () => originalFetch(new URL("/api/logs", server.url), { headers: managementHeaders() }) - .then(response => response.json()) as Promise>>; + .then(response => response.json()) + .then(body => logsFromApiBody(body)); const expectOnePersisted = async ( beforeLogs: number, beforeUsage: number, diff --git a/tests/openai-provider-option-e2e.test.ts b/tests/openai-provider-option-e2e.test.ts index c865f2eb0..243e43dba 100644 --- a/tests/openai-provider-option-e2e.test.ts +++ b/tests/openai-provider-option-e2e.test.ts @@ -1,3 +1,4 @@ +import { logsFromApiBody } from "./helpers/logs-api"; import { describe, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { createHash } from "node:crypto"; @@ -486,7 +487,7 @@ describe("OpenAI provider-option integration spine", () => { expect((await put("/api/injection-model", { model: selected, effort: "high" })).status).toBe(200); expect(await local("/api/injection-model").then(response => response.json())).toMatchObject({ model: selected, effort: "high" }); - const logs = await local("/api/logs").then(response => response.json()) as Array>; + const logs = logsFromApiBody(await local("/api/logs").then(response => response.json())); expect(logs.some(row => row.provider === "openai-p123abc" && row.requestedModel === "gpt-5.6-sol" && row.resolvedModel === "gpt-5.6-sol")).toBe(true); diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index 2e39509df..562cb004d 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -544,6 +544,17 @@ describe("request log metadata", () => { expect(combined.map(entry => entry.requestId)).toEqual(["c"]); }); + test("filters logs by offset and limit", () => { + const logs = Array.from({ length: 5 }, (_, i) => log({ requestId: `r${i}`, provider: "openai", status: 200 })); + expect(filterRequestLogs(logs, new URLSearchParams("limit=2")).map(entry => entry.requestId)).toEqual(["r3", "r4"]); + expect(filterRequestLogs(logs, new URLSearchParams("offset=2&limit=2")).map(entry => entry.requestId)).toEqual(["r1", "r2"]); + }); + + test("limit returns newest rows when buffer exceeds limit", () => { + const logs = Array.from({ length: 10 }, (_, i) => log({ requestId: `r${i}`, provider: "openai", status: 200 })); + expect(filterRequestLogs(logs, new URLSearchParams("limit=3")).map(entry => entry.requestId)).toEqual(["r7", "r8", "r9"]); + }); + test("deferred JSON logging preserves response service tier before final log", async () => { const entries: RequestLogEntry[] = []; const logCtx = { @@ -1250,7 +1261,7 @@ describe("request log restart hydrate", () => { test("hydrate keeps only the newest MAX_LOG_SIZE rows from a long usage.jsonl", () => { clearRequestLogsForTests(); - const persisted: PersistedUsageEntry[] = Array.from({ length: 205 }, (_, i) => ({ + const persisted: PersistedUsageEntry[] = Array.from({ length: 2005 }, (_, i) => ({ requestId: `ocx-${i}`, timestamp: i, provider: "openai", @@ -1259,10 +1270,10 @@ describe("request log restart hydrate", () => { durationMs: 1, usageStatus: "unreported" as const, })); - expect(hydrateRequestLogsFromDisk(() => persisted)).toBe(200); + expect(hydrateRequestLogsFromDisk(() => persisted)).toBe(2000); const ids = getRequestLogEntries().map(e => e.requestId); expect(ids[0]).toBe("ocx-5"); - expect(ids.at(-1)).toBe("ocx-204"); + expect(ids.at(-1)).toBe("ocx-2004"); }); test("hydrate swallows usage.jsonl read failures instead of crashing startup", () => { diff --git a/tests/server-403-permission-e2e.test.ts b/tests/server-403-permission-e2e.test.ts index a4f0ce348..71ca9e3bb 100644 --- a/tests/server-403-permission-e2e.test.ts +++ b/tests/server-403-permission-e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -83,11 +84,7 @@ async function runUpstreamFailure(status: 401 | 403, body: unknown): Promise<{ const payload = await response.json() as { error: { message?: string; type?: string; code?: string | null }; }; - const logs = await fetch(new URL("/api/logs?tail=1", proxy.url)).then(res => res.json()) as Array<{ - status?: number; - errorCode?: string; - upstreamError?: string; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", proxy.url)).then(res => res.json())); return { path, responseStatus: response.status, diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index dead5f571..3a949453d 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; @@ -1620,7 +1621,7 @@ describe("server local API auth", () => { ws.close(); expect(seenAuth).toEqual(["Bearer old-access-token", "Bearer new-access-token"]); - const logs = await fetch(new URL("/api/logs?tail=2", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ status: number }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=2", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.map(entry => entry.status)).toEqual([200, 200]); } finally { Date.now = originalNow; @@ -1692,14 +1693,7 @@ describe("server local API auth", () => { await waitForTerminal(); ws.close(); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ - status: number; - terminalStatus?: string; - closeReason?: string; - usageStatus?: string; - totalTokens?: number; - usage?: { inputTokens: number; outputTokens: number; cachedInputTokens?: number }; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 200, terminalStatus: "completed", @@ -2214,7 +2208,7 @@ describe("server local API auth", () => { consecutiveFailures: 3, lastFailureStatus: 502, }); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ status: number; errorCode?: string; terminalStatus?: string; closeReason?: string }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 502, errorCode: "upstream_server_error", @@ -2270,14 +2264,7 @@ describe("server local API auth", () => { expect(response.status).toBe(200); await response.text(); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ - status: number; - terminalStatus?: string; - closeReason?: string; - usageStatus?: string; - totalTokens?: number; - usage?: { inputTokens: number; outputTokens: number; cachedInputTokens?: number; reasoningOutputTokens?: number }; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 200, terminalStatus: "completed", diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 4de483572..718b1773c 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementFetch as fetch, ManagementRequest as Request } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -286,7 +287,7 @@ async function postModelLogged( async function latestAttemptReceipts(config: OcxConfig) { const response = await management(config, "GET", "/api/logs?tail=1"); - const logs = await response!.json() as Array>; + const logs = logsFromApiBody(await response!.json()); const usage = readUsageEntries(); return { log: logs[0]!, usage: usage.at(-1)! }; } @@ -493,7 +494,7 @@ describe("server combo failover 030 activation matrix", () => { clearRequestLogsForTests(); expect(hydrateRequestLogsFromDisk()).toBe(1); const hydratedResponse = await management(config, "GET", "/api/logs?tail=1"); - const hydrated = await hydratedResponse!.json() as Array>; + const hydrated = logsFromApiBody(await hydratedResponse!.json()); expect(hydrated).toHaveLength(1); expectMappedReceipt(hydrated[0]!); }); diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index 8c89765d2..448a690db 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -1441,7 +1441,7 @@ describe("listTrashEntries + restoreTrashEntry", () => { has_user_event: 1, archived: 1, }); - }); + }, { timeout: 20_000 }); test.each([ ["failAfterStateCommit", { failAfterStateCommit: true }, "db_reconcile_failed"],