diff --git a/CHANGELOG.md b/CHANGELOG.md index eb575cd1..7dddecdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Added +- Dashboard: new Logs tab to inspect ingress/egress requests, with enable/pause controls, filters, search, and details panel. +- 控制台新增日志页面:支持启用/暂停、方向筛选、搜索与详情查看,便于排查请求流向。 - `auth.tier_priority` 配置项:按 plan 类型排序账号选择优先级(如 `["plus", "pro", "team", "free"]`),高优先级 tier 的账号在有可用时始终优先选择;默认 `null`(不启用),与所有轮转策略兼容 (#348) - `server.trust_proxy` config option (default `false`): when enabled, the real client IP is read from `X-Forwarded-For` / `X-Real-IP` headers instead of the raw socket address. Required for users who expose codex-proxy via tunnel software (frp, ngrok, etc.) so that dashboard auth works correctly — previously all tunnel traffic appeared as `127.0.0.1` and bypassed authentication even when `proxy_api_key` was set (#350) diff --git a/config/default.yaml b/config/default.yaml index fc420f39..13d265f2 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -29,6 +29,11 @@ server: port: 8080 proxy_api_key: null trust_proxy: false +logs: + enabled: false + capacity: 2000 + capture_body: false + llm_only: true session: ttl_minutes: 60 cleanup_interval_minutes: 5 diff --git a/shared/hooks/use-general-settings.ts b/shared/hooks/use-general-settings.ts index 815c44ea..48615885 100644 --- a/shared/hooks/use-general-settings.ts +++ b/shared/hooks/use-general-settings.ts @@ -14,6 +14,10 @@ export interface GeneralSettingsData { refresh_concurrency: number; auto_update: boolean; auto_download: boolean; + logs_enabled: boolean; + logs_capacity: number; + logs_capture_body: boolean; + logs_llm_only: boolean; } interface GeneralSettingsSaveResponse extends GeneralSettingsData { @@ -72,6 +76,10 @@ export function useGeneralSettings(apiKey: string | null) { refresh_concurrency: result.refresh_concurrency, auto_update: result.auto_update, auto_download: result.auto_download, + logs_enabled: result.logs_enabled, + logs_capacity: result.logs_capacity, + logs_capture_body: result.logs_capture_body, + logs_llm_only: result.logs_llm_only, }); setRestartRequired(result.restart_required); setSaved(true); diff --git a/shared/hooks/use-logs.test.ts b/shared/hooks/use-logs.test.ts new file mode 100644 index 00000000..93f0f7c5 --- /dev/null +++ b/shared/hooks/use-logs.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("preact/hooks", () => ({ + useState: vi.fn(), + useEffect: vi.fn(), + useCallback: (fn: unknown) => fn, + useRef: vi.fn(), +})); + +import { normalizeLogsQueryState } from "./use-logs.js"; + +describe("normalizeLogsQueryState", () => { + it("resets page and clears selection when filters change", () => { + const next = normalizeLogsQueryState( + { direction: "all", search: "", page: 3, selected: { id: "1" } }, + { direction: "egress" }, + ); + + expect(next.direction).toBe("egress"); + expect(next.page).toBe(0); + expect(next.selected).toBeNull(); + }); + + it("keeps page for pagination changes but clears selection", () => { + const next = normalizeLogsQueryState( + { direction: "all", search: "abc", page: 1, selected: { id: "1" } }, + { page: 2 }, + ); + + expect(next.search).toBe("abc"); + expect(next.page).toBe(2); + expect(next.selected).toBeNull(); + }); + + it("preserves selection when query state is unchanged", () => { + const selected = { id: "1" }; + const next = normalizeLogsQueryState( + { direction: "all", search: "abc", page: 1, selected }, + {}, + ); + + expect(next.page).toBe(1); + expect(next.selected).toBe(selected); + }); +}); diff --git a/shared/hooks/use-logs.ts b/shared/hooks/use-logs.ts new file mode 100644 index 00000000..2ee1a056 --- /dev/null +++ b/shared/hooks/use-logs.ts @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback, useRef } from "preact/hooks"; + +export type LogFilterDirection = "ingress" | "egress" | "all"; + +export function normalizeLogsQueryState( + prev: { direction: LogFilterDirection; search: string; page: number; selected: T | null }, + next: { direction?: LogFilterDirection; search?: string; page?: number }, +): { direction: LogFilterDirection; search: string; page: number; selected: T | null } { + const direction = next.direction ?? prev.direction; + const search = next.search ?? prev.search; + const page = next.page ?? prev.page; + const queryChanged = direction !== prev.direction || search !== prev.search; + const pageChanged = page !== prev.page; + return { + direction, + search, + page: queryChanged ? 0 : page, + selected: queryChanged || pageChanged ? null : prev.selected, + }; +} + + +export interface LogRecord { + id: string; + requestId: string; + direction: "ingress" | "egress"; + ts: string; + method: string; + path: string; + model?: string | null; + provider?: string | null; + status?: number | null; + latencyMs?: number | null; + stream?: boolean | null; + error?: string | null; + request?: unknown; + response?: unknown; +} + +export interface LogState { + enabled: boolean; + paused: boolean; + dropped: number; + size: number; + capacity: number; +} + +export function useLogs(refreshIntervalMs = 1500) { + const [direction, setDirectionState] = useState("all"); + const [search, setSearchState] = useState(""); + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [state, setState] = useState(null); + const [selected, setSelected] = useState(null); + const [page, setPageState] = useState(0); + const pageSize = 50; + const timerRef = useRef | null>(null); + + const load = useCallback(async (nextPage: number) => { + try { + const params = new URLSearchParams({ + direction, + search: search.trim(), + limit: String(pageSize), + offset: String(nextPage * pageSize), + }); + const resp = await fetch(`/admin/logs?${params.toString()}`); + if (resp.ok) { + const body = await resp.json(); + setRecords(body.records); + setTotal(body.total); + } + } catch { /* ignore */ } + setLoading(false); + }, [direction, search, pageSize]); + + const setDirection = useCallback((nextDirection: LogFilterDirection) => { + setDirectionState((prevDirection) => { + const next = normalizeLogsQueryState({ direction: prevDirection, search, page, selected }, { direction: nextDirection }); + setPageState(next.page); + setSelected(next.selected); + return next.direction; + }); + }, [search, page, selected]); + + const setSearch = useCallback((nextSearch: string) => { + const next = normalizeLogsQueryState({ direction, search, page, selected }, { search: nextSearch }); + setSearchState(next.search); + setPageState(next.page); + setSelected(next.selected); + }, [direction, search, page, selected]); + + const setPage = useCallback((updater: number | ((prev: number) => number)) => { + setPageState((prevPage) => { + const nextPage = typeof updater === "function" ? updater(prevPage) : updater; + const next = normalizeLogsQueryState({ direction, search, page: prevPage, selected }, { page: nextPage }); + setSelected(next.selected); + return next.page; + }); + }, [direction, search, selected]); + + const loadState = useCallback(async () => { + try { + const resp = await fetch("/admin/logs/state"); + if (resp.ok) setState(await resp.json()); + } catch { /* ignore */ } + }, []); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + useEffect(() => { + setLoading(true); + load(page); + loadState(); + clearTimer(); + + const tick = () => { + if (!document.hidden) { + load(page); + loadState(); + } + }; + + timerRef.current = setInterval(tick, refreshIntervalMs); + const onVisibility = () => { + if (!document.hidden) tick(); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { + clearTimer(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [load, loadState, page, refreshIntervalMs, clearTimer]); + + const setLogState = useCallback(async (patch: Partial>) => { + const resp = await fetch("/admin/logs/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (resp.ok) setState(await resp.json()); + }, []); + + const selectLog = useCallback(async (id: string) => { + try { + const resp = await fetch(`/admin/logs/${id}`); + if (resp.ok) setSelected(await resp.json()); + } catch { /* ignore */ } + }, []); + + const nextPage = useCallback(() => setPage((p) => p + 1), []); + const prevPage = useCallback(() => setPage((p) => Math.max(0, p - 1)), []); + + return { + direction, + setDirection, + search, + setSearch, + records, + total, + loading, + state, + setLogState, + selected, + selectLog, + page, + pageSize, + nextPage, + prevPage, + hasNext: (page + 1) * pageSize < total, + hasPrev: page > 0, + }; +} diff --git a/shared/i18n/context.test.ts b/shared/i18n/context.test.ts new file mode 100644 index 00000000..f117b759 --- /dev/null +++ b/shared/i18n/context.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from "vitest"; +import { interpolateTranslation } from "./context"; + +describe("interpolateTranslation", () => { + it("replaces template variables", () => { + expect(interpolateTranslation("共 {count} 条", { count: 2 })).toBe("共 2 条"); + }); + + it("keeps unknown variables unchanged", () => { + expect(interpolateTranslation("{count} / {total}", { count: 2 })).toBe("2 / {total}"); + }); +}); diff --git a/shared/i18n/context.tsx b/shared/i18n/context.tsx index 8dcdf727..43620e1d 100644 --- a/shared/i18n/context.tsx +++ b/shared/i18n/context.tsx @@ -6,11 +6,16 @@ import type { ComponentChildren } from "preact"; interface I18nContextValue { lang: LangCode; toggleLang: () => void; - t: (key: TranslationKey) => string; + t: (key: TranslationKey, vars?: Record) => string; } const I18nContext = createContext(null!); +export function interpolateTranslation(template: string, vars?: Record): string { + if (!vars) return template; + return template.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`)); +} + function getInitialLang(): LangCode { try { const saved = localStorage.getItem("codex-proxy-lang"); @@ -31,8 +36,9 @@ export function I18nProvider({ children }: { children: ComponentChildren }) { }, []); const t = useCallback( - (key: TranslationKey): string => { - return translations[lang][key] ?? translations.en[key] ?? key; + (key: TranslationKey, vars?: Record): string => { + const template = translations[lang][key] ?? translations.en[key] ?? key; + return interpolateTranslation(template, vars); }, [lang] ); diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 028c0d13..93c51d0e 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -214,6 +214,37 @@ export const translations = { cancel: "Cancel", apiKeys: "API Keys", usageStats: "Usage Stats", + logs: "Logs", + logsEnabled: "Enabled", + logsEnabledHint: "Enable request logging and history.", + logsDisabled: "Disabled", + logsPaused: "Paused", + logsRunning: "Running", + logsCapacity: "Log Capacity", + logsCapacityHint: "Maximum number of logs to keep before older entries are dropped.", + logsCaptureBody: "Capture Request/Response Bodies", + logsCaptureBodyHint: "Store request and response bodies in logs.", + logsLlmOnly: "Only record LLM logs", + logsLlmOnlyHint: "When enabled, only keep LLM-related routes and forwarded requests in logs.", + logsModeLlmOnlyToggle: "Only record LLM logs (click to toggle)", + logsModeAllToggle: "Record all request logs (click to toggle)", + "logsFilter.all": "All", + "logsFilter.ingress": "Ingress", + "logsFilter.egress": "Egress", + logsSearch: "Search logs...", + logsCount: "{count} logs", + logsTime: "Time", + logsDirection: "Direction", + logsPath: "Path", + logsStatus: "Status", + logsLatency: "Latency", + logsLoading: "Loading logs...", + logsEmpty: "No logs", + logsDetails: "Details", + logsSelectHint: "Select a log to view details", + logsPrev: "Prev", + logsNext: "Next", + logsPageSummary: "{total} total · {range}", totalInputTokens: "Input Tokens", totalOutputTokens: "Output Tokens", totalRequestCount: "Total Requests", @@ -260,6 +291,8 @@ export const translations = { generalSettingsAutoUpdateHint: "Periodically check for new versions.", generalSettingsAutoDownload: "Auto Download Updates", generalSettingsAutoDownloadHint: "Download updates silently without asking. If off, a dialog will prompt before downloading.", + logsSettings: "Log Settings", + logsEnable: "Enable Logs", proxyExport: "Export", proxyImport: "Import", proxyImportTitle: "Import Proxies (YAML)", @@ -524,6 +557,39 @@ export const translations = { cancel: "取消", apiKeys: "API Keys", usageStats: "用量统计", + logs: "日志", + logsSettings: "日志设置", + logsEnable: "启用日志", + logsEnabled: "已启用", + logsEnabledHint: "开启请求日志与历史记录。", + logsDisabled: "已禁用", + logsPaused: "已暂停", + logsRunning: "运行中", + logsCapacity: "日志容量", + logsCapacityHint: "最多保留多少条日志,超出后会删除较旧的记录。", + logsCaptureBody: "记录请求/响应正文", + logsCaptureBodyHint: "在日志中保存请求和响应正文。", + logsLlmOnly: "仅记录 LLM 日志", + logsLlmOnlyHint: "开启后,只保留 LLM 相关路由和实际转发请求的日志。", + logsModeLlmOnlyToggle: "仅记录LLM日志(点击切换)", + logsModeAllToggle: "记录全部请求日志(点击切换)", + "logsFilter.all": "全部", + "logsFilter.ingress": "入口", + "logsFilter.egress": "出口", + logsSearch: "搜索日志...", + logsCount: "共 {count} 条", + logsTime: "时间", + logsDirection: "方向", + logsPath: "路径", + logsStatus: "状态", + logsLatency: "耗时", + logsLoading: "加载日志中...", + logsEmpty: "暂无日志", + logsDetails: "详情", + logsSelectHint: "选择一条日志查看详情", + logsPrev: "上一页", + logsNext: "下一页", + logsPageSummary: "共 {total} 条 · {range}", totalInputTokens: "输入 Token", totalOutputTokens: "输出 Token", totalRequestCount: "总请求数", diff --git a/src/config-schema.ts b/src/config-schema.ts index 829a99fc..28eddf2b 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -44,6 +44,12 @@ export const ConfigSchema = z.object({ proxy_api_key: z.string().nullable().default(null), trust_proxy: z.boolean().default(false), }), + logs: z.object({ + enabled: z.boolean().default(false), + capacity: z.number().int().min(1).default(2000), + capture_body: z.boolean().default(false), + llm_only: z.boolean().default(true), + }).default({}), session: z.object({ ttl_minutes: z.number().min(1).default(1440), cleanup_interval_minutes: z.number().min(1).default(5), diff --git a/src/hono-context.ts b/src/hono-context.ts new file mode 100644 index 00000000..a8e8ec8e --- /dev/null +++ b/src/hono-context.ts @@ -0,0 +1,7 @@ +declare module "hono" { + interface ContextVariableMap { + requestId: string; + } +} + +export {}; diff --git a/src/index.ts b/src/index.ts index 9f3441d6..127da3bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ import { requestId } from "./middleware/request-id.js"; import { logger } from "./middleware/logger.js"; import { errorHandler } from "./middleware/error-handler.js"; import { dashboardAuth } from "./middleware/dashboard-auth.js"; +import { logCapture } from "./middleware/log-capture.js"; + +import type { UpstreamAdapter } from "./proxy/upstream-adapter.js"; import { createAuthRoutes } from "./routes/auth.js"; import { createAccountRoutes } from "./routes/accounts.js"; import { createChatRoutes } from "./routes/chat.js"; @@ -35,7 +38,6 @@ import { UpstreamRouter } from "./proxy/upstream-router.js"; import { OpenAIUpstream } from "./proxy/openai-upstream.js"; import { AnthropicUpstream } from "./proxy/anthropic-upstream.js"; import { GeminiUpstream } from "./proxy/gemini-upstream.js"; -import type { UpstreamAdapter } from "./proxy/upstream-adapter.js"; import { ApiKeyPool } from "./auth/api-key-pool.js"; import { createApiKeyRoutes } from "./routes/api-keys.js"; import { createAdapterForEntry } from "./proxy/adapter-factory.js"; @@ -97,6 +99,7 @@ export async function startServer(options?: StartOptions): Promise app.use("*", logger); app.use("*", errorHandler); app.use("*", dashboardAuth); + app.use("*", logCapture); // Build upstream router from config const cfg = getConfig(); diff --git a/src/logs/entry.ts b/src/logs/entry.ts new file mode 100644 index 00000000..62835bdb --- /dev/null +++ b/src/logs/entry.ts @@ -0,0 +1,23 @@ +import { randomUUID } from "crypto"; +import { logStore, type LogDirection } from "./store.js"; + +export function enqueueLogEntry(entry: { + requestId: string; + direction: LogDirection; + method: string; + path: string; + model?: string | null; + provider?: string | null; + status?: number | null; + latencyMs?: number | null; + stream?: boolean | null; + error?: string | null; + request?: unknown; + response?: unknown; +}): void { + logStore.enqueue({ + id: randomUUID(), + ts: new Date().toISOString(), + ...entry, + }); +} diff --git a/src/logs/redact.ts b/src/logs/redact.ts new file mode 100644 index 00000000..d02d8ef1 --- /dev/null +++ b/src/logs/redact.ts @@ -0,0 +1,44 @@ +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +const SECRET_KEY_RE = /(authorization|x-api-key|api_key|apikey|token|refresh_token|access_token|cookie|set-cookie|session|secret)/i; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function redactString(value: string): string { + if (!value) return value; + if (value.length <= 8) return "***"; + return `${value.slice(0, 3)}***${value.slice(-2)}`; +} + +export function redactJson(value: unknown, depth = 0): JsonValue { + if (depth > 6) return "***"; + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.map((v) => redactJson(v, depth + 1)); + if (isRecord(value)) { + const out: Record = {}; + for (const [key, v] of Object.entries(value)) { + if (SECRET_KEY_RE.test(key)) { + if (typeof v === "string") out[key] = redactString(v); + else out[key] = "***"; + } else if (key.toLowerCase() === "headers" && isRecord(v)) { + const headersOut: Record = {}; + for (const [hKey, hVal] of Object.entries(v)) { + if (SECRET_KEY_RE.test(hKey)) { + headersOut[hKey] = typeof hVal === "string" ? redactString(hVal) : "***"; + } else { + headersOut[hKey] = redactJson(hVal, depth + 1); + } + } + out[key] = headersOut; + } else { + out[key] = redactJson(v, depth + 1); + } + } + return out; + } + return String(value); +} diff --git a/src/logs/request-summary.test.ts b/src/logs/request-summary.test.ts new file mode 100644 index 00000000..ae6f77a5 --- /dev/null +++ b/src/logs/request-summary.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { summarizeRequestForLog } from "./request-summary.js"; +import { ConfigSchema } from "../config-schema.js"; +import { resetConfigForTesting, setConfigForTesting } from "../config.js"; + +describe("summarizeRequestForLog", () => { + beforeEach(() => { + resetConfigForTesting(); + setConfigForTesting(ConfigSchema.parse({})); + }); + + afterEach(() => { + resetConfigForTesting(); + }); + + it("summarizes chat requests without copying large payloads", () => { + const summary = summarizeRequestForLog("chat", { + model: "gpt-5.2-codex", + stream: true, + max_tokens: 1024, + reasoning_effort: "high", + messages: [{ role: "user", content: "x".repeat(10_000) }], + tools: [{ type: "function" }], + previous_response_id: "resp_123", + response_format: { type: "json_schema", schema: { type: "object" } }, + }, { + ip: "127.0.0.1", + headers: { + authorization: "Bearer secret", + "x-api-key": "topsecret", + }, + }); + + expect(summary).toMatchObject({ + body_type: "chat.completions", + model: "gpt-5.2-codex", + stream: true, + max_tokens: 1024, + reasoning_effort: "high", + messages: 1, + tools: 1, + previous_response_id: "resp_123", + response_format: "json_schema", + ip: "127.0.0.1", + }); + expect(JSON.stringify(summary)).not.toContain("x".repeat(100)); + expect(JSON.stringify(summary)).not.toContain("Bearer secret"); + expect(JSON.stringify(summary)).not.toContain("topsecret"); + }); + + it("summarizes responses requests", () => { + const summary = summarizeRequestForLog("responses", { + model: "codex", + stream: false, + input: [{ role: "user", content: "hello" }], + instructions: "be helpful", + tools: [{ type: "function" }], + previous_response_id: "resp_456", + text: { format: { type: "json_schema" } }, + }); + + expect(summary).toMatchObject({ + body_type: "responses", + model: "codex", + stream: false, + input_items: 1, + instructions_bytes: 10, + tools: 1, + previous_response_id: "resp_456", + text_format: "json_schema", + }); + }); + + it("captures redacted request bodies when capture_body is enabled", () => { + setConfigForTesting(ConfigSchema.parse({ logs: { capture_body: true } })); + + const summary = summarizeRequestForLog("messages", { + model: "claude-sonnet", + stream: true, + messages: [{ role: "user", content: "secret prompt" }], + api_key: "topsecret", + }, { + headers: { + authorization: "Bearer secret", + }, + }); + + expect(summary).toMatchObject({ + body_type: "anthropic.messages", + model: "claude-sonnet", + stream: true, + messages: 1, + body: { + model: "claude-sonnet", + stream: true, + messages: [{ role: "user", content: "secret prompt" }], + api_key: "top***et", + }, + headers: { + authorization: "Bea***et", + }, + }); + }); + + it("does not include body when capture_body is disabled", () => { + setConfigForTesting(ConfigSchema.parse({ logs: { capture_body: false } })); + + const summary = summarizeRequestForLog("messages", { + model: "claude-sonnet", + messages: [{ role: "user", content: "secret prompt" }], + }); + + expect(summary).not.toHaveProperty("body"); + expect(summary).toMatchObject({ + body_type: "anthropic.messages", + model: "claude-sonnet", + messages: 1, + }); + }); +}); diff --git a/src/logs/request-summary.ts b/src/logs/request-summary.ts new file mode 100644 index 00000000..c9003660 --- /dev/null +++ b/src/logs/request-summary.ts @@ -0,0 +1,81 @@ +import { getConfig } from "../config.js"; +import { redactJson } from "./redact.js"; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function toCount(value: unknown): number | undefined { + return Array.isArray(value) ? value.length : undefined; +} + +function summarizeHeaders(headers: Record): Record { + return redactJson(headers) as Record; +} + +function shouldCaptureBody(): boolean { + try { + return getConfig().logs.capture_body; + } catch { + return false; + } +} + +function withBodyOrSummary(summary: Record, body: unknown): Record { + if (!shouldCaptureBody()) return summary; + return { + ...summary, + body: redactJson(body), + }; +} + +export function summarizeRequestForLog(route: string, body: unknown, meta: Record = {}): Record { + const summary: Record = redactJson(meta) as Record; + + if (route === "chat") { + if (isRecord(body)) { + summary.body_type = "chat.completions"; + summary.messages = toCount(body.messages); + summary.model = typeof body.model === "string" ? body.model : undefined; + summary.stream = typeof body.stream === "boolean" ? body.stream : undefined; + summary.max_tokens = typeof body.max_tokens === "number" ? body.max_tokens : undefined; + summary.reasoning_effort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : undefined; + summary.tools = toCount(body.tools); + summary.response_format = isRecord(body.response_format) ? body.response_format.type : undefined; + summary.previous_response_id = typeof body.previous_response_id === "string" ? body.previous_response_id : undefined; + summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; + } + return withBodyOrSummary(summary, body); + } + + if (route === "messages") { + if (isRecord(body)) { + summary.body_type = "anthropic.messages"; + summary.messages = toCount(body.messages); + summary.model = typeof body.model === "string" ? body.model : undefined; + summary.stream = typeof body.stream === "boolean" ? body.stream : undefined; + summary.max_tokens = typeof body.max_tokens === "number" ? body.max_tokens : undefined; + summary.thinking = isRecord(body.thinking) ? body.thinking.type : undefined; + summary.tools = toCount(body.tools); + summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; + } + return withBodyOrSummary(summary, body); + } + + if (route === "responses") { + if (isRecord(body)) { + summary.body_type = "responses"; + summary.input_items = toCount(body.input); + summary.model = typeof body.model === "string" ? body.model : undefined; + summary.stream = typeof body.stream === "boolean" ? body.stream : undefined; + summary.instructions_bytes = typeof body.instructions === "string" ? body.instructions.length : undefined; + summary.tools = toCount(body.tools); + summary.previous_response_id = typeof body.previous_response_id === "string" ? body.previous_response_id : undefined; + summary.text_format = isRecord(body.text) && isRecord(body.text.format) ? body.text.format.type : undefined; + summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; + } + return withBodyOrSummary(summary, body); + } + + return withBodyOrSummary(redactJson(summary) as Record, body); +} diff --git a/src/logs/store.test.ts b/src/logs/store.test.ts new file mode 100644 index 00000000..7c78735e --- /dev/null +++ b/src/logs/store.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { LogStore } from "./store.js"; + +describe("LogStore", () => { + let store: LogStore; + + beforeEach(() => { + store = new LogStore(10); + }); + + it("returns newest records first when listing", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/a", + }); + store.enqueue({ + id: "2", + requestId: "r2", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/b", + }); + + await Promise.resolve(); + const result = store.list({ limit: 10, offset: 0 }); + expect(result.records.map((r) => r.id)).toEqual(["2", "1"]); + }); + + it("paginates from newest records first across pages", async () => { + for (const id of ["1", "2", "3", "4"]) { + store.enqueue({ + id, + requestId: `r${id}`, + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: `/${id}`, + }); + } + + await Promise.resolve(); + + const page0 = store.list({ limit: 2, offset: 0 }); + const page1 = store.list({ limit: 2, offset: 2 }); + + expect(page0.records.map((r) => r.id)).toEqual(["4", "3"]); + expect(page1.records.map((r) => r.id)).toEqual(["2", "1"]); + }); + + it("filters by direction and search", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/v1/messages", + model: "claude", + }); + store.enqueue({ + id: "2", + requestId: "r2", + direction: "egress", + ts: new Date().toISOString(), + method: "GET", + path: "/health", + provider: "codex", + }); + + await Promise.resolve(); + const filtered = store.list({ direction: "egress", search: "codex", limit: 10, offset: 0 }); + expect(filtered.total).toBe(1); + expect(filtered.records.map((r) => r.id)).toEqual(["2"]); + }); + + it("normalizes invalid pagination values", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/a", + }); + + await Promise.resolve(); + const result = store.list({ limit: Number.NaN, offset: Number.NaN }); + expect(result.limit).toBe(50); + expect(result.offset).toBe(0); + }); + + it("redacts request payloads on flush", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/a", + request: { + headers: { authorization: "Bearer secret" }, + nested: { token: "abc" }, + }, + }); + + await Promise.resolve(); + const result = store.list({ limit: 10, offset: 0 }); + expect(result.records[0].request).toMatchObject({ + headers: { authorization: "Bea***et" }, + nested: { token: "***" }, + }); + }); + + it("trims existing records when capacity is lowered", async () => { + for (const id of ["1", "2", "3", "4"]) { + store.enqueue({ + id, + requestId: `r${id}`, + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: `/${id}`, + }); + } + + await Promise.resolve(); + + const state = store.setState({ capacity: 2 }); + const result = store.list({ limit: 10, offset: 0 }); + + expect(state.capacity).toBe(2); + expect(state.size).toBe(2); + expect(state.dropped).toBe(2); + expect(result.records.map((r) => r.id)).toEqual(["4", "3"]); + }); +}); diff --git a/src/logs/store.ts b/src/logs/store.ts new file mode 100644 index 00000000..8297d448 --- /dev/null +++ b/src/logs/store.ts @@ -0,0 +1,164 @@ +import { redactJson } from "./redact.js"; + +export type LogDirection = "ingress" | "egress"; + +export interface LogRecord { + id: string; + requestId: string; + direction: LogDirection; + ts: string; + method: string; + path: string; + model?: string | null; + provider?: string | null; + status?: number | null; + latencyMs?: number | null; + stream?: boolean | null; + sizeBytes?: number | null; + error?: string | null; + tags?: string[]; + request?: unknown; + response?: unknown; + meta?: Record; +} + +export interface LogState { + enabled: boolean; + paused: boolean; + dropped: number; + size: number; + capacity: number; +} + +interface LogStateUpdate { + enabled?: boolean; + paused?: boolean; + capacity?: number; +} + +export interface LogQuery { + direction?: LogDirection | "all"; + search?: string | null; + limit?: number; + offset?: number; +} + +const DEFAULT_CAPACITY = 2000; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +function normalizeLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_LIMIT; + return Math.min(Math.max(1, Math.trunc(limit)), MAX_LIMIT); +} + +function normalizeOffset(offset: number | undefined): number { + if (offset === undefined || !Number.isFinite(offset)) return 0; + return Math.max(0, Math.trunc(offset)); +} + +export class LogStore { + private records: LogRecord[] = []; + private capacity: number; + private enabled = true; + private paused = false; + private dropped = 0; + private queue: LogRecord[] = []; + private flushScheduled = false; + + constructor(capacity = DEFAULT_CAPACITY) { + this.capacity = capacity; + } + + getState(): LogState { + return { + enabled: this.enabled, + paused: this.paused, + dropped: this.dropped, + size: this.records.length, + capacity: this.capacity, + }; + } + + setState(next: LogStateUpdate): LogState { + if (typeof next.enabled === "boolean") { + this.enabled = next.enabled; + if (next.enabled) this.paused = false; + } + if (typeof next.paused === "boolean") this.paused = next.paused; + if (typeof next.capacity === "number" && Number.isFinite(next.capacity)) { + this.capacity = Math.max(1, Math.trunc(next.capacity)); + this.trimToCapacity(); + } + return this.getState(); + } + + clear(): void { + this.records = []; + this.dropped = 0; + } + + enqueue(record: LogRecord): void { + if (!this.enabled || this.paused) return; + this.queue.push(record); + if (!this.flushScheduled) { + this.flushScheduled = true; + queueMicrotask(() => this.flush()); + } + } + + list(query: LogQuery): { records: LogRecord[]; total: number; offset: number; limit: number } { + const direction = query.direction ?? "all"; + const search = (query.search ?? "").trim().toLowerCase(); + let results = this.records; + + if (direction !== "all") { + results = results.filter((r) => r.direction === direction); + } + + if (search) { + results = results.filter((r) => { + const hay = `${r.method} ${r.path} ${r.model ?? ""} ${r.provider ?? ""} ${r.status ?? ""}`.toLowerCase(); + return hay.includes(search); + }); + } + + const total = results.length; + const limit = normalizeLimit(query.limit); + const offset = normalizeOffset(query.offset); + const newestFirst = [...results].reverse(); + const sliced = newestFirst.slice(offset, offset + limit); + + return { records: sliced, total, offset, limit }; + } + + get(id: string): LogRecord | null { + return this.records.find((r) => r.id === id) ?? null; + } + + private flush(): void { + this.flushScheduled = false; + if (!this.queue.length) return; + + const batch = this.queue.splice(0, this.queue.length); + for (const record of batch) { + const redacted: LogRecord = { + ...record, + request: record.request !== undefined ? redactJson(record.request) : undefined, + response: record.response !== undefined ? redactJson(record.response) : undefined, + }; + this.records.push(redacted); + } + + this.trimToCapacity(); + } + + private trimToCapacity(): void { + if (this.records.length <= this.capacity) return; + const over = this.records.length - this.capacity; + this.records.splice(0, over); + this.dropped += over; + } +} + +export const logStore = new LogStore(); diff --git a/src/middleware/log-capture.ts b/src/middleware/log-capture.ts new file mode 100644 index 00000000..ef4912c7 --- /dev/null +++ b/src/middleware/log-capture.ts @@ -0,0 +1,37 @@ +import type { Context, Next } from "hono"; +import { getConfig } from "../config.js"; +import { enqueueLogEntry } from "../logs/entry.js"; + +const KNOWN_LLM_PATHS = [ + /^\/v1\/chat\/completions$/, + /^\/v1\/messages$/, + /^\/v1\/responses(?:\/compact)?$/, + /^\/v1\/models(?:\/.*)?$/, + /^\/v1beta\/models(?:\/.*)?$/, +]; + +export function isKnownLlmPath(path: string): boolean { + return KNOWN_LLM_PATHS.some((pattern) => pattern.test(path)); +} + +export function shouldCaptureRequest(c: Context): boolean { + const config = getConfig(); + if (!config.logs.llm_only) return true; + if (c.get("logForwarded") === true) return true; + return isKnownLlmPath(c.req.path); +} + +export async function logCapture(c: Context, next: Next): Promise { + const startMs = Date.now(); + await next(); + if (!shouldCaptureRequest(c)) return; + + enqueueLogEntry({ + requestId: c.get("requestId") ?? "-", + direction: "ingress", + method: c.req.method, + path: c.req.path, + status: c.res.status, + latencyMs: Date.now() - startMs, + }); +} diff --git a/src/routes/admin/logs.ts b/src/routes/admin/logs.ts new file mode 100644 index 00000000..a6bd3fe6 --- /dev/null +++ b/src/routes/admin/logs.ts @@ -0,0 +1,76 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { getLocalConfigPath, reloadAllConfigs } from "../../config.js"; +import { logStore, type LogDirection } from "../../logs/store.js"; +import { mutateYaml } from "../../utils/yaml-mutate.js"; + +const ListLogsQuerySchema = z.object({ + limit: z.preprocess((value) => value === undefined ? undefined : Number(value), z.number().int().min(1).max(200).optional()), + offset: z.preprocess((value) => value === undefined ? undefined : Number(value), z.number().int().min(0).optional()), +}); + +function parseDirection(raw: string | null | undefined): LogDirection | "all" { + if (raw === "ingress" || raw === "egress" || raw === "all") return raw; + return "all"; +} + +export function createLogRoutes(): Hono { + const app = new Hono(); + + app.get("/admin/logs", (c) => { + const parsed = ListLogsQuerySchema.safeParse({ + limit: c.req.query("limit"), + offset: c.req.query("offset"), + }); + if (!parsed.success) { + c.status(400); + return c.json({ error: "Invalid request", details: parsed.error.issues }); + } + + const direction = parseDirection(c.req.query("direction")); + const search = c.req.query("search"); + const data = logStore.list({ + direction, + search, + limit: parsed.data.limit, + offset: parsed.data.offset, + }); + return c.json(data); + }); + + app.get("/admin/logs/state", (c) => { + return c.json(logStore.getState()); + }); + + app.post("/admin/logs/state", async (c) => { + const body = await c.req.json().catch(() => ({} as Record)); + const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined; + const paused = typeof body.paused === "boolean" ? body.paused : undefined; + + if (enabled !== undefined) { + mutateYaml(getLocalConfigPath(), (data) => { + if (!data.logs) data.logs = {}; + (data.logs as Record).enabled = enabled; + }); + reloadAllConfigs(); + } + + return c.json(logStore.setState({ enabled, paused })); + }); + + app.post("/admin/logs/clear", (c) => { + logStore.clear(); + return c.json({ ok: true }); + }); + + app.get("/admin/logs/:id", (c) => { + const rec = logStore.get(c.req.param("id")); + if (!rec) { + c.status(404); + return c.json({ error: "not_found" }); + } + return c.json(rec); + }); + + return app; +} diff --git a/src/routes/admin/settings.ts b/src/routes/admin/settings.ts index fc050d94..b46440f5 100644 --- a/src/routes/admin/settings.ts +++ b/src/routes/admin/settings.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { getConnInfo } from "@hono/node-server/conninfo"; import { getConfig, getLocalConfigPath, reloadAllConfigs, ROTATION_STRATEGIES } from "../../config.js"; +import { logStore } from "../../logs/store.js"; import { mutateYaml } from "../../utils/yaml-mutate.js"; import { isLocalhostRequest } from "../../utils/is-localhost.js"; @@ -109,6 +110,10 @@ export function createSettingsRoutes(): Hono { request_interval_ms: config.auth.request_interval_ms, auto_update: config.update.auto_update, auto_download: config.update.auto_download, + logs_enabled: config.logs.enabled, + logs_capacity: config.logs.capacity, + logs_capture_body: config.logs.capture_body, + logs_llm_only: config.logs.llm_only, }); }); @@ -140,6 +145,10 @@ export function createSettingsRoutes(): Hono { request_interval_ms?: number | null; auto_update?: boolean; auto_download?: boolean; + logs_enabled?: boolean; + logs_capacity?: number; + logs_capture_body?: boolean; + logs_llm_only?: boolean; }; // --- validation --- @@ -198,6 +207,13 @@ export function createSettingsRoutes(): Hono { } } + if (body.logs_capacity !== undefined) { + if (!Number.isInteger(body.logs_capacity) || body.logs_capacity < 1) { + c.status(400); + return c.json({ error: "logs_capacity must be an integer >= 1" }); + } + } + const oldPort = config.server.port; const oldDefaultModel = config.model.default; @@ -258,9 +274,32 @@ export function createSettingsRoutes(): Hono { if (!data.update) data.update = {}; (data.update as Record).auto_download = body.auto_download; } + if (body.logs_enabled !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).enabled = body.logs_enabled; + } + if (body.logs_capacity !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).capacity = body.logs_capacity; + } + if (body.logs_capture_body !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).capture_body = body.logs_capture_body; + } + if (body.logs_llm_only !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).llm_only = body.logs_llm_only; + } }); reloadAllConfigs(); + if (body.logs_enabled !== undefined || body.logs_capacity !== undefined) { + logStore.setState({ + enabled: body.logs_enabled, + capacity: body.logs_capacity, + }); + } + const updated = getConfig(); const restartRequired = (body.port !== undefined && body.port !== oldPort) || @@ -281,6 +320,10 @@ export function createSettingsRoutes(): Hono { request_interval_ms: updated.auth.request_interval_ms, auto_update: updated.update.auto_update, auto_download: updated.update.auto_download, + logs_enabled: updated.logs?.enabled ?? false, + logs_capacity: updated.logs?.capacity ?? 2000, + logs_capture_body: updated.logs?.capture_body ?? false, + logs_llm_only: updated.logs?.llm_only ?? true, restart_required: restartRequired, }); }); diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 4cf6368f..c9929949 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -10,12 +10,16 @@ import { } from "../translation/codex-to-openai.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName, getModelAliases, getModelInfo } from "../models/model-store.js"; +import { enqueueLogEntry } from "../logs/entry.js"; +import { getRealClientIp } from "../utils/get-real-client-ip.js"; +import { randomUUID } from "crypto"; import { handleProxyRequest, handleDirectRequest, type FormatAdapter, } from "./shared/proxy-handler.js"; import type { UpstreamRouter } from "../proxy/upstream-router.js"; +import { summarizeRequestForLog } from "../logs/request-summary.js"; function makeOpenAIFormat(wantReasoning: boolean): FormatAdapter { return { @@ -126,6 +130,20 @@ export function createChatRoutes( tupleSchema, }; + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + enqueueLogEntry({ + requestId, + direction: "ingress", + method: c.req.method, + path: c.req.path, + model: req.model, + stream: !!req.stream, + request: summarizeRequestForLog("chat", req, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), + headers: Object.fromEntries(c.req.raw.headers.entries()), + }), + }); + if (routeMatch.kind === "api-key" || routeMatch.kind === "adapter") { const directReq = { ...proxyReq, diff --git a/src/routes/messages.ts b/src/routes/messages.ts index d16eab68..d0c6e00b 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -17,6 +17,9 @@ import { } from "../translation/codex-to-anthropic.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName } from "../models/model-store.js"; +import { enqueueLogEntry } from "../logs/entry.js"; +import { getRealClientIp } from "../utils/get-real-client-ip.js"; +import { randomUUID } from "crypto"; import { handleProxyRequest, handleDirectRequest, @@ -26,6 +29,7 @@ import { } from "./shared/proxy-handler.js"; import { extractAnthropicClientConversationId } from "./shared/anthropic-session-id.js"; import type { UpstreamRouter } from "../proxy/upstream-router.js"; +import { summarizeRequestForLog } from "../logs/request-summary.js"; function makeError( type: AnthropicErrorType, @@ -142,6 +146,20 @@ export function createMessagesRoutes( }; const fmt = makeAnthropicFormat(wantThinking); + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + enqueueLogEntry({ + requestId, + direction: "ingress", + method: c.req.method, + path: c.req.path, + model: req.model, + stream: !!req.stream, + request: summarizeRequestForLog("messages", req, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), + headers: Object.fromEntries(c.req.raw.headers.entries()), + }), + }); + if (routeMatch?.kind === "api-key" || routeMatch?.kind === "adapter") { const directReq = { ...proxyReq, diff --git a/src/routes/responses.ts b/src/routes/responses.ts index a36478b4..be142dc6 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -13,6 +13,10 @@ import type { CookieJar } from "../proxy/cookie-jar.js"; import type { ProxyPool } from "../proxy/proxy-pool.js"; import { CodexApi, CodexApiError } from "../proxy/codex-api.js"; import type { CodexResponsesRequest, CodexCompactRequest, CodexInputItem } from "../proxy/codex-api.js"; +import { enqueueLogEntry } from "../logs/entry.js"; +import { summarizeRequestForLog } from "../logs/request-summary.js"; +import { getRealClientIp } from "../utils/get-real-client-ip.js"; +import { randomUUID } from "crypto"; import type { UpstreamAdapter } from "../proxy/upstream-adapter.js"; import { getConfig } from "../config.js"; import { prepareSchema } from "../translation/shared-utils.js"; @@ -640,6 +644,20 @@ export function createResponsesRoutes( tupleSchema, }; + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + enqueueLogEntry({ + requestId, + direction: "ingress", + method: c.req.method, + path: c.req.path, + model: rawModel, + stream: clientWantsStream, + request: summarizeRequestForLog("responses", body, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), + headers: Object.fromEntries(c.req.raw.headers.entries()), + }), + }); + if (routeMatch?.kind === "api-key" || routeMatch?.kind === "adapter") { // Use raw model name so adapter's extractModelId can strip the provider prefix const directReq = { ...proxyReq, codexRequest: { ...codexRequest, model: rawModel } }; @@ -676,6 +694,20 @@ export function createResponsesRoutes( const authErr = checkAuth(c, accountPool, allowUnauthenticated); if (authErr) return authErr; + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + enqueueLogEntry({ + requestId, + direction: "ingress", + method: c.req.method, + path: c.req.path, + model: rawModel, + stream: false, + request: summarizeRequestForLog("responses", body, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), + headers: Object.fromEntries(c.req.raw.headers.entries()), + }), + }); + return handleCompact(c, accountPool, cookieJar, proxyPool, body, upstreamRouter); }; diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index 76aff7a6..f6929c2e 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -32,6 +32,8 @@ import { parseRateLimitHeaders, rateLimitToQuota, type ParsedRateLimit } from ". import { getConfig } from "../../config.js"; import { jitterInt } from "../../utils/jitter.js"; import { getSessionAffinityMap, type SessionAffinityMap } from "../../auth/session-affinity.js"; +import { enqueueLogEntry } from "../../logs/entry.js"; +import { randomUUID } from "crypto"; import { deriveStableConversationKey } from "./stable-conversation-key.js"; /** Data prepared by each route after parsing and translating the request. */ @@ -177,7 +179,10 @@ export async function handleProxyRequest( fmt: FormatAdapter, proxyPool?: ProxyPool, ): Promise { + c.set("logForwarded", true); + const affinityMap = getSessionAffinityMap(); + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); if (!Array.isArray(req.codexRequest.input)) { req.codexRequest.input = []; } @@ -346,10 +351,28 @@ export async function handleProxyRequest( } }; + const startMs = Date.now(); const rawResponse = await withRetry( () => codexApi.createResponse(req.codexRequest, abortController.signal, applyRateLimits), { tag: fmt.tag }, ); + const status: number | null = rawResponse.status; + enqueueLogEntry({ + requestId, + direction: "egress", + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, + }); // Capture upstream turn-state for sticky routing const upstreamTurnState = rawResponse.headers.get("x-codex-turn-state") ?? undefined; @@ -428,12 +451,18 @@ export async function handleProxyRequest( // ── Non-streaming path (with empty-response retry) ── return await handleNonStreaming( - c, accountPool, cookieJar, req, fmt, proxyPool, + c, + accountPool, + cookieJar, + req, + fmt, + proxyPool, codexApi, rawResponse, entryId, abortController, released, + requestId, affinityMap, chainConversationId, upstreamTurnState, @@ -527,6 +556,7 @@ async function handleNonStreaming( initialEntryId: string, abortController: AbortController, released: Set, + requestId: string, affinityMap?: SessionAffinityMap, conversationId?: string, turnState?: string, @@ -596,13 +626,48 @@ async function handleNonStreaming( currentEntryId = newAcquired.entryId; currentApi = buildCodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, proxyPool); + const retryStartMs = Date.now(); try { currentRawResponse = await withRetry( () => currentApi.createResponse(req.codexRequest, abortController.signal), { tag: fmt.tag }, ); + enqueueLogEntry({ + requestId, + direction: "egress", + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status: currentRawResponse.status, + latencyMs: Date.now() - retryStartMs, + stream: req.isStreaming, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, + }); } catch (retryErr) { releaseAccount(accountPool, currentEntryId, undefined, released); + const msg = retryErr instanceof Error ? retryErr.message : "Upstream request failed"; + enqueueLogEntry({ + requestId, + direction: "egress", + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status: retryErr instanceof CodexApiError ? retryErr.status : null, + latencyMs: Date.now() - retryStartMs, + stream: req.isStreaming, + error: msg, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, + }); if (retryErr instanceof CodexApiError) { const code = toErrorStatus(retryErr.status); c.status(code); @@ -646,10 +711,45 @@ export async function handleDirectRequest( const abortController = new AbortController(); c.req.raw.signal.addEventListener("abort", () => abortController.abort(), { once: true }); + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + const startMs = Date.now(); let rawResponse: Response; try { rawResponse = await upstream.createResponse(req.codexRequest, abortController.signal); + enqueueLogEntry({ + requestId, + direction: "egress", + method: "POST", + path: "/v1/responses", + model: req.model, + provider: upstream.tag, + status: rawResponse.status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + }, + }); } catch (err) { + const msg = err instanceof Error ? err.message : "Upstream request failed"; + const status = err instanceof CodexApiError ? err.status : 502; + enqueueLogEntry({ + requestId, + direction: "egress", + method: "POST", + path: "/v1/responses", + model: req.model, + provider: upstream.tag, + status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + error: msg, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + }, + }); if (err instanceof CodexApiError) { const code = toErrorStatus(err.status) as StatusCode; c.status(code); @@ -665,7 +765,6 @@ export async function handleDirectRequest( } return c.json(fmt.formatError(code, err.message)); } - const msg = err instanceof Error ? err.message : "Upstream request failed"; c.status(502); return c.json(fmt.formatError(502, msg)); } diff --git a/src/routes/web.ts b/src/routes/web.ts index d47a9a20..1ae206a9 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -9,6 +9,7 @@ import { createUpdateRoutes } from "./admin/update.js"; import { createConnectionRoutes } from "./admin/connection.js"; import { createSettingsRoutes } from "./admin/settings.js"; import { createUsageStatsRoutes } from "./admin/usage-stats.js"; +import { createLogRoutes } from "./admin/logs.js"; import type { UsageStatsStore } from "../auth/usage-stats.js"; export function createWebRoutes(accountPool: AccountPool, usageStats: UsageStatsStore): Hono { @@ -45,6 +46,7 @@ export function createWebRoutes(accountPool: AccountPool, usageStats: UsageStats app.route("/", createConnectionRoutes(accountPool)); app.route("/", createSettingsRoutes()); app.route("/", createUsageStatsRoutes(accountPool, usageStats)); + app.route("/", createLogRoutes()); return app; } diff --git a/src/utils/get-real-client-ip.ts b/src/utils/get-real-client-ip.ts index 2051b9a4..f11688a3 100644 --- a/src/utils/get-real-client-ip.ts +++ b/src/utils/get-real-client-ip.ts @@ -10,8 +10,16 @@ import { getConnInfo } from "@hono/node-server/conninfo"; * address if no header is present. */ export function getRealClientIp(c: Context, trustProxy: boolean): string { + const socketAddress = (() => { + try { + return getConnInfo(c).remote.address ?? ""; + } catch { + return ""; + } + })(); + if (!trustProxy) { - return getConnInfo(c).remote.address ?? ""; + return socketAddress; } // X-Forwarded-For: client, proxy1, proxy2 — take leftmost (original client) diff --git a/tests/unit/middleware/log-capture.test.ts b/tests/unit/middleware/log-capture.test.ts new file mode 100644 index 00000000..8a37bb48 --- /dev/null +++ b/tests/unit/middleware/log-capture.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + enqueueLogEntry: vi.fn(), + getConfig: vi.fn(() => ({ logs: { llm_only: true } })), +})); + +vi.mock("@src/logs/entry.js", () => ({ + enqueueLogEntry: mocks.enqueueLogEntry, +})); + +vi.mock("@src/config.js", () => ({ + getConfig: mocks.getConfig, +})); + +import { isKnownLlmPath, logCapture } from "@src/middleware/log-capture.js"; + +function createContext(path = "/v1/messages", extraGet: Record = {}) { + const headers = new Map(); + return { + get: vi.fn((key: string) => { + if (key === "requestId") return "req-123"; + return extraGet[key]; + }), + header: vi.fn((key: string, value: string) => { + headers.set(key, value); + }), + req: { method: "POST", path }, + res: { status: 201 }, + } as unknown as Parameters[0]; +} + +describe("logCapture middleware", () => { + beforeEach(() => { + mocks.enqueueLogEntry.mockClear(); + mocks.getConfig.mockReturnValue({ logs: { llm_only: true } }); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-15T00:00:00.000Z")); + }); + + it("recognizes known LLM paths", () => { + expect(isKnownLlmPath("/v1/chat/completions")).toBe(true); + expect(isKnownLlmPath("/v1/messages")).toBe(true); + expect(isKnownLlmPath("/v1beta/models/gemini-2.5-pro:generateContent")).toBe(true); + expect(isKnownLlmPath("/admin/settings")).toBe(false); + }); + + it("enqueues an ingress log for LLM paths", async () => { + const c = createContext("/v1/messages"); + const next = vi.fn(async () => { + vi.setSystemTime(new Date("2026-04-15T00:00:00.025Z")); + }); + + await logCapture(c, next as never); + + expect(next).toHaveBeenCalledTimes(1); + expect(mocks.enqueueLogEntry).toHaveBeenCalledWith(expect.objectContaining({ + requestId: "req-123", + direction: "ingress", + method: "POST", + path: "/v1/messages", + status: 201, + latencyMs: 25, + })); + }); + + it("skips unrelated requests in llm-only mode", async () => { + const c = createContext("/admin/settings"); + + await logCapture(c, vi.fn(async () => {}) as never); + + expect(mocks.enqueueLogEntry).not.toHaveBeenCalled(); + }); + + it("captures forwarded requests even when path is unrelated", async () => { + const c = createContext("/custom/provider", { logForwarded: true }); + + await logCapture(c, vi.fn(async () => {}) as never); + + expect(mocks.enqueueLogEntry).toHaveBeenCalledOnce(); + }); + + it("captures all requests when llm-only mode is disabled", async () => { + mocks.getConfig.mockReturnValue({ logs: { llm_only: false } }); + const c = createContext("/admin/settings"); + + await logCapture(c, vi.fn(async () => {}) as never); + + expect(mocks.enqueueLogEntry).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/routes/general-settings.test.ts b/tests/unit/routes/general-settings.test.ts index cf056be3..5feb6a72 100644 --- a/tests/unit/routes/general-settings.test.ts +++ b/tests/unit/routes/general-settings.test.ts @@ -1,24 +1,33 @@ /** * Tests for general settings endpoints. - * GET /admin/general-settings — read current server/tls config - * POST /admin/general-settings — update server/tls config */ import { describe, it, expect, vi, beforeEach } from "vitest"; -// --- Mocks (before any imports) --- - const mockConfig = { server: { port: 8080, proxy_api_key: null as string | null }, tls: { proxy_url: null as string | null, force_http11: false }, - model: { default: "gpt-5.3-codex", default_reasoning_effort: null as string | null, inject_desktop_context: false, suppress_desktop_directives: true }, + model: { + default: "gpt-5.3-codex", + default_reasoning_effort: null as string | null, + inject_desktop_context: false, + suppress_desktop_directives: true, + }, quota: { refresh_interval_minutes: 5, warning_thresholds: { primary: [80, 90], secondary: [80, 90] }, skip_exhausted: true, }, - auth: { rotation_strategy: "least_used", refresh_enabled: true, refresh_margin_seconds: 300, refresh_concurrency: 2, max_concurrent_per_account: 3 as number | null, request_interval_ms: 50 as number | null }, - update: { auto_update: true }, + auth: { + rotation_strategy: "least_used", + refresh_enabled: true, + refresh_margin_seconds: 300, + refresh_concurrency: 2, + max_concurrent_per_account: 3 as number | null, + request_interval_ms: 50 as number | null, + }, + update: { auto_update: true, auto_download: false }, + logs: { enabled: false, capacity: 2000, capture_body: false, llm_only: true }, }; vi.mock("@src/config.js", () => ({ @@ -37,10 +46,18 @@ vi.mock("@src/paths.js", () => ({ isEmbedded: vi.fn(() => false), })); +const mockLogStore = vi.hoisted(() => ({ + setState: vi.fn(), +})); + vi.mock("@src/utils/yaml-mutate.js", () => ({ mutateYaml: vi.fn(), })); +vi.mock("@src/logs/store.js", () => ({ + logStore: mockLogStore, +})); + vi.mock("@src/tls/transport.js", () => ({ getTransport: vi.fn(), getTransportInfo: vi.fn(() => ({})), @@ -93,16 +110,10 @@ function makeApp() { describe("GET /admin/general-settings", () => { beforeEach(() => { vi.clearAllMocks(); - mockConfig.server.port = 8080; - mockConfig.server.proxy_api_key = null; - mockConfig.tls.proxy_url = null; - mockConfig.tls.force_http11 = false; - mockConfig.model.inject_desktop_context = false; - mockConfig.model.suppress_desktop_directives = true; - mockConfig.update.auto_update = true; + mockConfig.logs.llm_only = true; }); - it("returns current values", async () => { + it("returns current values including logs_llm_only", async () => { const app = makeApp(); const res = await app.request("/admin/general-settings"); expect(res.status).toBe(200); @@ -111,16 +122,14 @@ describe("GET /admin/general-settings", () => { port: 8080, proxy_url: null, force_http11: false, - inject_desktop_context: false, - suppress_desktop_directives: true, default_model: "gpt-5.3-codex", - default_reasoning_effort: null, refresh_enabled: true, - refresh_margin_seconds: 300, - refresh_concurrency: 2, - max_concurrent_per_account: 3, - request_interval_ms: 50, auto_update: true, + auto_download: false, + logs_enabled: false, + logs_capacity: 2000, + logs_capture_body: false, + logs_llm_only: true, }); }); }); @@ -128,248 +137,34 @@ describe("GET /admin/general-settings", () => { describe("POST /admin/general-settings", () => { beforeEach(() => { vi.clearAllMocks(); - mockConfig.server.port = 8080; - mockConfig.server.proxy_api_key = null; - mockConfig.tls.proxy_url = null; - mockConfig.tls.force_http11 = false; - mockConfig.model.inject_desktop_context = false; - mockConfig.model.suppress_desktop_directives = true; - mockConfig.update.auto_update = true; - }); - - it("changing port sets restart_required: true", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: 9090 }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(true); - expect(mutateYaml).toHaveBeenCalledOnce(); - expect(reloadAllConfigs).toHaveBeenCalledOnce(); - }); - - it("changing proxy_url sets restart_required: false", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxy_url: "http://127.0.0.1:7890" }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(mutateYaml).toHaveBeenCalledOnce(); - }); - - it("changing force_http11 sets restart_required: false", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force_http11: true }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - }); - - it("rejects port out of range", async () => { - const app = makeApp(); - - const res1 = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: 0 }), - }); - expect(res1.status).toBe(400); - - const res2 = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: 70000 }), - }); - expect(res2.status).toBe(400); + mockConfig.logs.llm_only = true; }); - it("rejects invalid proxy_url format", async () => { + it("persists logs_llm_only without requiring restart", async () => { const app = makeApp(); const res = await app.request("/admin/general-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxy_url: "not-a-url" }), + body: JSON.stringify({ logs_llm_only: false }), }); - expect(res.status).toBe(400); - }); - it("changing inject_desktop_context sets restart_required: false", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inject_desktop_context: true }), - }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); expect(data.restart_required).toBe(false); - expect(data.inject_desktop_context).toBe(false); // mockConfig unchanged expect(mutateYaml).toHaveBeenCalledOnce(); expect(reloadAllConfigs).toHaveBeenCalledOnce(); }); - it("changing suppress_desktop_directives sets restart_required: false", async () => { + it("syncs log store when logs_enabled changes", async () => { const app = makeApp(); const res = await app.request("/admin/general-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ suppress_desktop_directives: false }), + body: JSON.stringify({ logs_enabled: true }), }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(data.suppress_desktop_directives).toBe(true); // mockConfig unchanged - expect(mutateYaml).toHaveBeenCalledOnce(); - expect(reloadAllConfigs).toHaveBeenCalledOnce(); - }); - it("changing auto_update persists to local.yaml", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ auto_update: false }), - }); expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(mutateYaml).toHaveBeenCalledOnce(); - expect(reloadAllConfigs).toHaveBeenCalledOnce(); - }); - - it("accepts valid max_concurrent_per_account", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ max_concurrent_per_account: 3 }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(mutateYaml).toHaveBeenCalledOnce(); - }); - - it("accepts null max_concurrent_per_account (reset to default)", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ max_concurrent_per_account: null }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - }); - - it("rejects invalid max_concurrent_per_account", async () => { - const app = makeApp(); - - for (const bad of [0, -1, 1.5]) { - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ max_concurrent_per_account: bad }), - }); - expect(res.status).toBe(400); - } - }); - - it("accepts valid request_interval_ms", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_interval_ms: 500 }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - }); - - it("accepts 0 request_interval_ms (disable)", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_interval_ms: 0 }), - }); - expect(res.status).toBe(200); - }); - - it("rejects negative request_interval_ms", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_interval_ms: -1 }), - }); - expect(res.status).toBe(400); - }); - - it("accepts null default_reasoning_effort to disable reasoning", async () => { - mockConfig.model.default_reasoning_effort = "medium"; - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ default_reasoning_effort: null }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(mutateYaml).toHaveBeenCalledOnce(); - }); - - it("rejects invalid default_reasoning_effort", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ default_reasoning_effort: "ultra" }), - }); - expect(res.status).toBe(400); - }); - - it("requires auth when proxy_api_key is set", async () => { - mockConfig.server.proxy_api_key = "my-secret"; - const app = makeApp(); - - // No auth -> 401 - const res1 = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force_http11: true }), - }); - expect(res1.status).toBe(401); - - // With auth -> 200 - const res2 = await app.request("/admin/general-settings", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer my-secret", - }, - body: JSON.stringify({ force_http11: true }), - }); - expect(res2.status).toBe(200); + expect(mockLogStore.setState).toHaveBeenCalledWith({ enabled: true }); }); }); diff --git a/tests/unit/routes/logs.test.ts b/tests/unit/routes/logs.test.ts new file mode 100644 index 00000000..5b18f492 --- /dev/null +++ b/tests/unit/routes/logs.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const mockConfig = vi.hoisted(() => ({ + getLocalConfigPath: vi.fn(() => "/tmp/test/local.yaml"), + reloadAllConfigs: vi.fn(), +})); + +const mockYaml = vi.hoisted(() => ({ + mutateYaml: vi.fn(), +})); + +const store = vi.hoisted(() => ({ + list: vi.fn(), + get: vi.fn(), + clear: vi.fn(), + setState: vi.fn(), +})); + +vi.mock("@src/config.js", () => ({ + getLocalConfigPath: mockConfig.getLocalConfigPath, + reloadAllConfigs: mockConfig.reloadAllConfigs, +})); + +vi.mock("@src/utils/yaml-mutate.js", () => ({ + mutateYaml: mockYaml.mutateYaml, +})); + +vi.mock("@src/logs/store.js", () => ({ + logStore: store, +})); + +import { createLogRoutes } from "@src/routes/admin/logs.js"; + +describe("log routes", () => { + beforeEach(() => { + store.list.mockReset(); + store.get.mockReset(); + store.clear.mockReset(); + store.setState.mockReset(); + mockConfig.getLocalConfigPath.mockReset(); + mockConfig.getLocalConfigPath.mockReturnValue("/tmp/test/local.yaml"); + mockConfig.reloadAllConfigs.mockReset(); + mockYaml.mutateYaml.mockReset(); + }); + + it("returns paginated logs", async () => { + store.list.mockReturnValue({ + total: 2, + offset: 0, + limit: 1, + records: [ + { + id: "1", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "POST", + path: "/v1/messages", + status: 200, + latencyMs: 10, + }, + ], + }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs?limit=1&offset=0&direction=egress&search=messages"); + expect(res.status).toBe(200); + + expect(store.list).toHaveBeenCalledWith({ direction: "egress", search: "messages", limit: 1, offset: 0 }); + const body = await res.json(); + expect(body.total).toBe(2); + expect(body.records).toHaveLength(1); + expect(body.records[0].id).toBe("1"); + }); + + it("rejects invalid pagination params", async () => { + const app = new Hono(); + app.route("/", createLogRoutes()); + + const badLimit = await app.request("/admin/logs?limit=abc"); + expect(badLimit.status).toBe(400); + expect(store.list).not.toHaveBeenCalled(); + + const badOffset = await app.request("/admin/logs?offset=-1"); + expect(badOffset.status).toBe(400); + expect(store.list).not.toHaveBeenCalled(); + }); + + it("falls back to all when direction is unknown", async () => { + store.list.mockReturnValue({ total: 0, offset: 0, limit: 50, records: [] }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs?direction=weird"); + expect(res.status).toBe(200); + expect(store.list).toHaveBeenCalledWith({ direction: "all", search: undefined, limit: undefined, offset: undefined }); + }); + + it("returns a selected log entry", async () => { + store.get.mockReturnValue({ + id: "abc", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "GET", + path: "/health", + status: 200, + }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs/abc"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ id: "abc", path: "/health" }); + }); + + it("persists enabled state changes while keeping paused in memory", async () => { + store.setState.mockReturnValue({ enabled: false, paused: true, dropped: 0, size: 0, capacity: 2000 }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false, paused: true }), + }); + + expect(res.status).toBe(200); + expect(mockYaml.mutateYaml).toHaveBeenCalledOnce(); + expect(mockConfig.reloadAllConfigs).toHaveBeenCalledOnce(); + expect(store.setState).toHaveBeenCalledWith({ enabled: false, paused: true }); + }); + + it("resets paused when enabling logs", async () => { + store.setState.mockReturnValue({ enabled: true, paused: false, dropped: 0, size: 0, capacity: 2000 }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true, paused: true }), + }); + + expect(res.status).toBe(200); + expect(store.setState).toHaveBeenCalledWith({ enabled: true, paused: true }); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index feb2f252..526c8794 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,7 @@ import { ApiKeyManager } from "./components/ApiKeyManager"; import { ProxySettings } from "./pages/ProxySettings"; import { AccountManagement } from "./pages/AccountManagement"; import { UsageStats } from "./pages/UsageStats"; +import { LogsPage } from "./pages/LogsPage"; import { useAccounts } from "../../shared/hooks/use-accounts"; import { useProxies } from "../../shared/hooks/use-proxies"; import { useStatus } from "../../shared/hooks/use-status"; @@ -59,6 +60,7 @@ const TABS: Array<{ hash: string; label: TranslationKey }> = [ { hash: "#/api-keys", label: "apiKeys" }, { hash: "#/proxies", label: "proxySettings" }, { hash: "#/usage-stats", label: "usageStats" }, + { hash: "#/logs", label: "logs" }, { hash: "#/settings", label: "settings" }, ]; @@ -184,6 +186,10 @@ function Dashboard() { )} + {activeTab === "#/logs" && ( + + )} + {activeTab === "#/settings" && ( (null); + const [draftLogsCapacity, setDraftLogsCapacity] = useState(null); + const [draftLogsCaptureBody, setDraftLogsCaptureBody] = useState(null); + const [draftLogsLlmOnly, setDraftLogsLlmOnly] = useState(null); + const [collapsed, setCollapsed] = useState(true); + + const currentLogsEnabled = gs.data?.logs_enabled ?? false; + const currentLogsCapacity = gs.data?.logs_capacity ?? 2000; + const currentLogsCaptureBody = gs.data?.logs_capture_body ?? false; + const currentLogsLlmOnly = gs.data?.logs_llm_only ?? true; + + const displayLogsEnabled = draftLogsEnabled ?? currentLogsEnabled; + const displayLogsCapacity = draftLogsCapacity ?? String(currentLogsCapacity); + const displayLogsCaptureBody = draftLogsCaptureBody ?? currentLogsCaptureBody; + const displayLogsLlmOnly = draftLogsLlmOnly ?? currentLogsLlmOnly; + + const isDirty = + draftLogsEnabled !== null || + draftLogsCapacity !== null || + draftLogsCaptureBody !== null || + draftLogsLlmOnly !== null; + + const handleSave = useCallback(async () => { + const patch: Record = {}; + + if (draftLogsEnabled !== null) { + patch.logs_enabled = draftLogsEnabled; + } + + if (draftLogsCapacity !== null) { + const val = parseInt(draftLogsCapacity, 10); + if (isNaN(val) || val < 1) return; + patch.logs_capacity = val; + } + + if (draftLogsCaptureBody !== null) { + patch.logs_capture_body = draftLogsCaptureBody; + } + + if (draftLogsLlmOnly !== null) { + patch.logs_llm_only = draftLogsLlmOnly; + } + + await gs.save(patch); + setDraftLogsEnabled(null); + setDraftLogsCapacity(null); + setDraftLogsCaptureBody(null); + setDraftLogsLlmOnly(null); + }, [draftLogsEnabled, draftLogsCapacity, draftLogsCaptureBody, draftLogsLlmOnly, gs]); + + const inputCls = + "w-full px-3 py-2 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-700 dark:text-text-main outline-none focus:ring-1 focus:ring-primary"; + + return ( +
+ + + {!collapsed && ( +
+
+
+ setDraftLogsEnabled((e.target as HTMLInputElement).checked)} + class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" + /> + +
+

{t("logsEnabledHint")}

+
+ +
+ +

{t("logsCapacityHint")}

+ setDraftLogsCapacity((e.target as HTMLInputElement).value)} + /> +
+ +
+
+ setDraftLogsCaptureBody((e.target as HTMLInputElement).checked)} + class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" + /> + +
+

{t("logsCaptureBodyHint")}

+
+ +
+
+ setDraftLogsLlmOnly((e.target as HTMLInputElement).checked)} + class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" + /> + +
+

{t("logsLlmOnlyHint")}

+
+ +
+ + {gs.saved && ( + {t("quotaSaved")} + )} + {gs.error && ( + {gs.error} + )} +
+
+ )} +
+ ); +} diff --git a/web/src/components/SettingsTab.tsx b/web/src/components/SettingsTab.tsx index c981f0fa..7c34429b 100644 --- a/web/src/components/SettingsTab.tsx +++ b/web/src/components/SettingsTab.tsx @@ -1,4 +1,5 @@ import { GeneralSettings } from "./GeneralSettings"; +import { LogsSettings } from "./LogsSettings"; import { QuotaSettings } from "./QuotaSettings"; import { RotationSettings } from "./RotationSettings"; import { SettingsPanel } from "./SettingsPanel"; @@ -24,6 +25,7 @@ export function SettingsTab(props: SettingsTabProps) { return (
+ diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx new file mode 100644 index 00000000..d18e6b7f --- /dev/null +++ b/web/src/pages/LogsPage.tsx @@ -0,0 +1,152 @@ +import { useMemo } from "preact/hooks"; +import { useT } from "../../../shared/i18n/context"; +import { useLogs } from "../../../shared/hooks/use-logs"; +import { useSettings } from "../../../shared/hooks/use-settings"; +import { useGeneralSettings } from "../../../shared/hooks/use-general-settings"; + +export function LogsPage({ embedded = false }: { embedded?: boolean }) { + const t = useT(); + const logs = useLogs(); + const settings = useSettings(); + const gs = useGeneralSettings(settings.apiKey); + const logsLlmOnly = gs.data?.logs_llm_only ?? true; + + const toggleLogsMode = async () => { + await gs.save({ logs_llm_only: !logsLlmOnly }); + }; + + const list = useMemo(() => { + return logs.records.map((r) => ({ + ...r, + time: new Date(r.ts).toLocaleTimeString(), + })); + }, [logs.records]); + + const pageStart = logs.total === 0 ? 0 : logs.page * logs.pageSize + 1; + const pageEnd = logs.total === 0 ? 0 : Math.min(logs.total, (logs.page + 1) * logs.pageSize); + const pageInfo = `${pageStart}-${pageEnd}`; + + return ( +
+
+ + + +
+ {(["all", "ingress", "egress"] as const).map((dir) => ( + + ))} +
+ + + + logs.setSearch((e.target as HTMLInputElement).value)} + placeholder={t("logsSearch")} + /> + +
+ {t("logsCount", { count: logs.total })} +
+
+ +
+
+
+
+
{t("logsTime")}
+
{t("logsDirection")}
+
{t("logsPath")}
+
{t("logsStatus")}
+
{t("logsLatency")}
+
+ {logs.loading && ( +
{t("logsLoading")}
+ )} + {!logs.loading && list.length === 0 && ( +
{t("logsEmpty")}
+ )} +
+ {list.map((row) => ( + + ))} +
+
+ + {t("logsPageSummary", { total: logs.total, range: pageInfo })} + +
+
+
+ +
+
+
+ {t("logsDetails")} +
+
+ {logs.selected ? JSON.stringify(logs.selected, null, 2) : t("logsSelectHint")} +
+
+
+
+
+ ); +} diff --git a/web/src/pages/__tests__/logs.test.ts b/web/src/pages/__tests__/logs.test.ts new file mode 100644 index 00000000..99c91f5c --- /dev/null +++ b/web/src/pages/__tests__/logs.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/preact"; + +const mockLogs = vi.hoisted(() => ({ + useLogs: vi.fn(), +})); + +const mockT = vi.hoisted(() => ({ + useT: vi.fn(), +})); + +const mockSettings = vi.hoisted(() => ({ + useSettings: vi.fn(() => ({ apiKey: null })), +})); + +const mockGeneralSettings = vi.hoisted(() => ({ + useGeneralSettings: vi.fn(), +})); + +vi.mock("../../../shared/hooks/use-logs", () => ({ + useLogs: mockLogs.useLogs, +})); + +vi.mock("../../../shared/hooks/use-settings", () => ({ + useSettings: mockSettings.useSettings, +})); + +vi.mock("../../../shared/hooks/use-general-settings", () => ({ + useGeneralSettings: mockGeneralSettings.useGeneralSettings, +})); + +vi.mock("../../../shared/i18n/context", () => ({ + useT: () => mockT.useT(), +})); + +import { LogsPage } from "../LogsPage"; + +function makeGeneralSettings(overrides: Record = {}) { + return { + data: { logs_llm_only: true }, + saving: false, + save: vi.fn(), + ...overrides, + }; +} + +function makeLogsState(overrides: Partial> = {}) { + return { + records: [ + { + id: "1", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "POST", + path: "/v1/messages", + status: 200, + latencyMs: 10, + }, + ], + total: 1, + loading: false, + state: { enabled: true, paused: false }, + setLogState: vi.fn(), + selected: null, + selectLog: vi.fn(), + direction: "all", + setDirection: vi.fn(), + search: "", + setSearch: vi.fn(), + page: 0, + pageSize: 50, + prevPage: vi.fn(), + nextPage: vi.fn(), + hasPrev: false, + hasNext: true, + ...overrides, + }; +} + +describe("LogsPage", () => { + it("renders pagination controls and invokes page handlers", () => { + const nextPage = vi.fn(); + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + mockLogs.useLogs.mockReturnValue(makeLogsState({ nextPage, hasNext: true })); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings()); + + render(); + + expect(screen.getByText("1 logs")).toBeTruthy(); + expect(screen.getByText("1 total · 1-1")).toBeTruthy(); + fireEvent.click(screen.getByText("Next")); + expect(nextPage).toHaveBeenCalledTimes(1); + }); + + it("shows selected log details and clears to hint when nothing is selected", () => { + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings()); + + mockLogs.useLogs.mockReturnValue(makeLogsState({ selected: { id: "1", path: "/v1/messages" } })); + const { rerender } = render(); + expect(screen.getByText(/"path": "\/v1\/messages"/)).toBeTruthy(); + + mockLogs.useLogs.mockReturnValue(makeLogsState({ selected: null })); + rerender(); + expect(screen.getByText("logsSelectHint")).toBeTruthy(); + }); + + it("renders zero latency as 0ms", () => { + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + mockLogs.useLogs.mockReturnValue( + makeLogsState({ + records: [ + { + id: "1", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "GET", + path: "/v1/models", + status: 200, + latencyMs: 0, + }, + ], + }), + ); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings()); + + render(); + + expect(screen.getByText("0ms")).toBeTruthy(); + }); + + it("renders and toggles the logs mode button", () => { + const save = vi.fn(); + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + mockLogs.useLogs.mockReturnValue(makeLogsState()); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings({ save })); + + render(); + + fireEvent.click(screen.getByText("logsModeLlmOnlyToggle")); + expect(save).toHaveBeenCalledWith({ logs_llm_only: false }); + }); +});