From 6ea24c9d4950bcd4e4ff2d2fcb97b43cb73e94ef Mon Sep 17 00:00:00 2001 From: ops Date: Sun, 18 Jan 2026 01:31:36 +0100 Subject: [PATCH 1/3] feat: request logging --- packages/opencode/src/cli/cmd/tui/thread.ts | 1 + packages/opencode/src/cli/cmd/tui/worker.ts | 1 + packages/opencode/src/index.ts | 7 + packages/opencode/src/provider/provider.ts | 204 +++++++++++++++++++- packages/opencode/src/util/log.ts | 136 ++++++++++++- 5 files changed, 339 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 05714268545..53ecfc61098 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -94,6 +94,7 @@ export const TuiThreadCommand = cmd({ env: Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ), + argv: process.argv, }) worker.onerror = (e) => { Log.Default.error(e) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c..b1cee82f123 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -18,6 +18,7 @@ await Log.init({ if (Installation.isLocal()) return "DEBUG" return "INFO" })(), + requestLog: process.argv.includes("--request-log"), }) process.on("unhandledRejection", (e) => { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91e..8dd8778738c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -56,6 +56,10 @@ const cli = yargs(hideBin(process.argv)) type: "string", choices: ["DEBUG", "INFO", "WARN", "ERROR"], }) + .option("request-log", { + describe: "log provider requests and responses to a separate file", + type: "boolean", + }) .middleware(async (opts) => { await Log.init({ print: process.argv.includes("--print-logs"), @@ -65,6 +69,7 @@ const cli = yargs(hideBin(process.argv)) if (Installation.isLocal()) return "DEBUG" return "INFO" })(), + requestLog: process.argv.includes("--request-log"), }) process.env.AGENT = "1" @@ -151,6 +156,8 @@ try { } process.exitCode = 1 } finally { + // Flush any pending request logs before exiting + await Log.flushRequestLog() // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e01c583ff34..80424f66bdd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -991,6 +991,7 @@ export namespace Provider { // Preserve custom fetch if it exists, wrap it with timeout logic const fetchFn = customFetch ?? fetch const opts = init ?? {} + const startTime = Date.now() if (options["timeout"] !== undefined && options["timeout"] !== null) { const signals: AbortSignal[] = [] @@ -1002,6 +1003,11 @@ export namespace Provider { opts.signal = combined } + const url = typeof input === "string" ? input : input.url + + // Generate request ID for correlating request/response + const requestId = Math.random().toString(36).substring(2, 8) + // Strip openai itemId metadata following what codex does // Codex uses #[serde(skip_serializing)] on id fields for all item types: // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall @@ -1020,11 +1026,199 @@ export namespace Provider { } } - return fetchFn(input, { - ...opts, - // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 - timeout: false, - }) + // Log request if enabled + if (Log.isRequestLoggingEnabled()) { + const requestData: any = { + type: "REQUEST", + requestId, + provider: model.providerID, + model: model.id, + url, + method: opts.method ?? "GET", + } + + // Parse and filter the request body + if (opts.body) { + try { + const body = typeof opts.body === "string" ? JSON.parse(opts.body) : opts.body + const filteredBody: any = {} + + // Extract messages (OpenCode format uses 'input', OpenAI uses 'messages') + const messages = body.input || body.messages + if (messages && Array.isArray(messages)) { + filteredBody.messages = messages.map((msg: any) => { + const role = msg.role + let content: string + + // Handle different content formats + if (typeof msg.content === "string") { + content = msg.content + } else if (Array.isArray(msg.content)) { + // Multi-part content (text + images, etc) + const textPart = msg.content.find((p: any) => p.type === "text" || p.text) + content = textPart ? textPart.text || textPart.content || "[complex content]" : "[complex content]" + } else { + content = "[complex content]" + } + + return { role, content } + }) + } + + // Summarize tools if present + if (body.tools && Array.isArray(body.tools)) { + filteredBody.tools_count = body.tools.length + filteredBody.tools_summary = body.tools + .map((t: any) => t.function?.name || t.name) + .filter(Boolean) + .join(", ") + } + + // Keep other useful fields + if (body.model) filteredBody.model = body.model + if (body.max_tokens) filteredBody.max_tokens = body.max_tokens + if (body.max_output_tokens) filteredBody.max_output_tokens = body.max_output_tokens + if (body.temperature !== undefined) filteredBody.temperature = body.temperature + if (body.stream !== undefined) filteredBody.stream = body.stream + + requestData.body = filteredBody + } catch { + requestData.body = "[parse error]" + } + } + + await Log.logRequest(requestData) + } + + try { + const response = await fetchFn(input, { + ...opts, + // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 + timeout: false, + }) + + // Log response if enabled + if (Log.isRequestLoggingEnabled()) { + const clonedResponse = response.clone() + const responseText = await clonedResponse.text().catch(() => "[stream or unreadable]") + const responseData: any = { + type: "RESPONSE", + requestId, + provider: model.providerID, + model: model.id, + url, + status: response.status, + duration: Date.now() - startTime, + } + + // Parse and extract useful information from response + let completion = "" + let tokenUsage: any = null + + // Try to parse as JSON first (non-streaming response) + try { + const jsonResponse = JSON.parse(responseText) + + // Extract completion text + if (jsonResponse.choices && Array.isArray(jsonResponse.choices)) { + const firstChoice = jsonResponse.choices[0] + completion = firstChoice?.message?.content || firstChoice?.text || "" + } else if (jsonResponse.content) { + completion = jsonResponse.content + } else if (jsonResponse.text) { + completion = jsonResponse.text + } + + // Extract token usage + if (jsonResponse.usage) { + tokenUsage = jsonResponse.usage + } + } catch { + // If not JSON, try parsing as SSE stream + if (responseText.includes("data:")) { + const lines = responseText.split("\n") + + for (const line of lines) { + if (!line.startsWith("data:")) continue + + const dataStr = line.substring(5).trim() + if (dataStr === "[DONE]") continue + + try { + const data = JSON.parse(dataStr) + + // OpenAI format: extract delta content + if (data.choices?.[0]?.delta?.content) { + completion += data.choices[0].delta.content + } + + // OpenAI format: final message + if (data.choices?.[0]?.message?.content) { + completion = data.choices[0].message.content + } + + // OpenCode format: extract text from various event types + if (data.type === "response.output_text.done" && data.text) { + completion = data.text + } + if (data.type === "content.delta" && data.delta?.text) { + completion += data.delta.text + } + if (data.type === "response.done" && data.response?.output?.[0]?.content?.[0]?.text) { + completion = data.response.output[0].content[0].text + } + + // Extract token usage from various formats + if (data.usage) { + tokenUsage = data.usage + } + if (data.response?.usage) { + tokenUsage = data.response.usage + } + } catch { + // Skip lines that can't be parsed + } + } + } + } + + // Add completion text (no truncation) + if (completion) { + responseData.completion = completion + } else { + responseData.completion = "[no text extracted]" + } + + // Add token usage if found + if (tokenUsage) { + responseData.input_tokens = tokenUsage.input_tokens || tokenUsage.prompt_tokens + responseData.output_tokens = tokenUsage.output_tokens || tokenUsage.completion_tokens + responseData.total_tokens = + tokenUsage.total_tokens || + (tokenUsage.input_tokens || tokenUsage.prompt_tokens || 0) + + (tokenUsage.output_tokens || tokenUsage.completion_tokens || 0) + } + + await Log.logRequest(responseData) + } + + return response + } catch (error) { + // Log error if enabled + if (Log.isRequestLoggingEnabled()) { + const errorData = { + type: "ERROR", + requestId, + provider: model.providerID, + model: model.id, + url, + error: error instanceof Error ? error.message : String(error), + duration: Date.now() - startTime, + } + await Log.logRequest(errorData) + } + throw error + } } const bundledFn = BUNDLED_PROVIDERS[model.api.npm] diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 6941310bbbd..3d7a93712e3 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -1,5 +1,6 @@ import path from "path" import fs from "fs/promises" +import fsSync from "fs" import { Global } from "../global" import z from "zod" @@ -44,17 +45,27 @@ export namespace Log { print: boolean dev?: boolean level?: Level + requestLog?: boolean } let logpath = "" export function file() { return logpath } - let write = (msg: any) => { + let write = (msg: any): number | Promise => { process.stderr.write(msg) return msg.length } + let requestLogPath = "" + let requestLogEnabled = false + export function requestFile() { + return requestLogPath + } + let requestWrite = (msg: any): number | Promise => { + return 0 + } + export async function init(options: Options) { if (options.level) level = options.level cleanup(Global.Path.log) @@ -71,20 +82,135 @@ export namespace Log { writer.flush() return num } + + if (options.requestLog) { + requestLogEnabled = true + requestLogPath = path.join( + Global.Path.log, + options.dev ? "dev.request.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".request.log", + ) + // Create the file initially + await fs.writeFile(requestLogPath, "").catch(() => {}) + + // Use SYNCHRONOUS file appending to ensure writes complete before process exit + requestWrite = (msg: any) => { + try { + fsSync.appendFileSync(requestLogPath, msg) + return msg.length + } catch (e) { + return 0 + } + } + } + } + + export function isRequestLoggingEnabled() { + return requestLogEnabled + } + + function formatRequestLog(data: any): string { + const timestamp = new Date().toISOString() + const time = timestamp.substring(11, 19) // HH:MM:SS + + // ANSI color codes + const RESET = "\x1b[0m" + const BOLD = "\x1b[1m" + const DIM = "\x1b[2m" + const CYAN = "\x1b[36m" + const GREEN = "\x1b[32m" + const YELLOW = "\x1b[33m" + const RED = "\x1b[31m" + const BLUE = "\x1b[34m" + const MAGENTA = "\x1b[35m" + const GRAY = "\x1b[90m" + const ORANGE = "\x1b[38;5;208m" + + const lines: string[] = [] + const separator = GRAY + "─".repeat(100) + RESET + const requestId = data.requestId ? `${GRAY}[${data.requestId}]${RESET}` : "" + + if (data.type === "REQUEST") { + // Single status line with request ID + const statusLine = `${CYAN}${BOLD}▶ REQUEST${RESET} ${DIM}${time}${RESET} ${requestId} ${GRAY}|${RESET} ${data.provider}${GRAY}/${RESET}${BLUE}${BOLD}${data.model}${RESET} ${GRAY}|${RESET} ${DIM}${data.url}${RESET}` + lines.push(separator) + lines.push(statusLine) + lines.push("") // blank line after status + + if (data.body?.messages) { + for (const msg of data.body.messages) { + const roleColor = msg.role === "user" ? GREEN : msg.role === "assistant" ? BLUE : MAGENTA + const content = msg.content.trim() + + // Show full content at first column, no indentation + lines.push(`${roleColor}${BOLD}[${msg.role}]${RESET} ${content}`) + } + } + + if (data.body?.tools_count) { + lines.push(`${DIM}Tools: ${data.body.tools_count} (${data.body.tools_summary})${RESET}`) + } + } else if (data.type === "RESPONSE") { + // Single status line with request ID + const statusColor = data.status >= 200 && data.status < 300 ? GREEN : RED + const tokenInfo = data.total_tokens + ? `${GRAY}|${RESET} Tokens: ${CYAN}${data.input_tokens}${RESET}/${YELLOW}${data.output_tokens}${RESET}=${BOLD}${data.total_tokens}${RESET}` + : "" + + const statusLine = `${GREEN}${BOLD}◀ RESPONSE${RESET} ${DIM}${time}${RESET} ${requestId} ${GRAY}|${RESET} ${statusColor}${data.status}${RESET} ${GRAY}|${RESET} ${data.duration}ms ${tokenInfo}` + lines.push(separator) + lines.push(statusLine) + lines.push("") // blank line after status + + if (data.completion) { + // Format: [model] completion text at first column + const modelName = data.model || "model" + lines.push(`${BLUE}${BOLD}[${modelName}]${RESET} ${data.completion}`) + } + } else if (data.type === "ERROR") { + // Single status line with request ID + const statusLine = `${RED}${BOLD}✖ ERROR${RESET} ${DIM}${time}${RESET} ${requestId} ${GRAY}|${RESET} ${data.provider}${GRAY}/${RESET}${data.model} ${GRAY}|${RESET} ${data.duration}ms` + lines.push(separator) + lines.push(statusLine) + lines.push("") // blank line after status + lines.push(`${RED}${data.error}${RESET}`) + } + + lines.push("") // blank line after entry + return lines.join("\n") + } + + export async function logRequest(data: any) { + if (!requestLogEnabled) return + requestWrite(formatRequestLog(data)) + } + + export async function flushRequestLog() { + // No-op since we're using synchronous writes } async function cleanup(dir: string) { const glob = new Bun.Glob("????-??-??T??????.log") + const requestGlob = new Bun.Glob("????-??-??T??????.request.log") const files = await Array.fromAsync( glob.scan({ cwd: dir, absolute: true, }), ) - if (files.length <= 5) return - - const filesToDelete = files.slice(0, -10) - await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) + const requestFiles = await Array.fromAsync( + requestGlob.scan({ + cwd: dir, + absolute: true, + }), + ) + if (files.length > 5) { + const filesToDelete = files.slice(0, -10) + await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) + } + if (requestFiles.length > 5) { + const filesToDelete = requestFiles.slice(0, -10) + await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) + } } function formatError(error: Error, depth = 0): string { From d82d976ed153b11db39e8f6e3e7c0ae7a004d543 Mon Sep 17 00:00:00 2001 From: ops Date: Sun, 1 Feb 2026 03:26:53 +0100 Subject: [PATCH 2/3] raw request logging --- packages/opencode/src/provider/provider.ts | 35 ++++++++++++++ packages/opencode/src/util/log.ts | 53 ++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 80424f66bdd..6cdae2d5bd4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1037,6 +1037,17 @@ export namespace Provider { method: opts.method ?? "GET", } + const requestRaw: any = { + type: "REQUEST", + requestId, + provider: model.providerID, + model: model.id, + url, + method: opts.method ?? "GET", + headers: opts.headers, + body: opts.body, + } + // Parse and filter the request body if (opts.body) { try { @@ -1088,6 +1099,7 @@ export namespace Provider { } await Log.logRequest(requestData) + await Log.logRequestRaw(requestRaw) } try { @@ -1111,6 +1123,18 @@ export namespace Provider { duration: Date.now() - startTime, } + const responseRaw: any = { + type: "RESPONSE", + requestId, + provider: model.providerID, + model: model.id, + url, + status: response.status, + duration: Date.now() - startTime, + headers: Object.fromEntries(response.headers.entries()), + body: responseText, + } + // Parse and extract useful information from response let completion = "" let tokenUsage: any = null @@ -1200,6 +1224,7 @@ export namespace Provider { } await Log.logRequest(responseData) + await Log.logRequestRaw(responseRaw) } return response @@ -1215,7 +1240,17 @@ export namespace Provider { error: error instanceof Error ? error.message : String(error), duration: Date.now() - startTime, } + const errorRaw = { + type: "ERROR", + requestId, + provider: model.providerID, + model: model.id, + url, + error: error instanceof Error ? error.message : String(error), + duration: Date.now() - startTime, + } await Log.logRequest(errorData) + await Log.logRequestRaw(errorRaw) } throw error } diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 3d7a93712e3..4f38f7e23e7 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -2,6 +2,7 @@ import path from "path" import fs from "fs/promises" import fsSync from "fs" import { Global } from "../global" +import { iife } from "./iife" import z from "zod" export namespace Log { @@ -66,6 +67,14 @@ export namespace Log { return 0 } + let requestRawLogPath = "" + export function requestRawFile() { + return requestRawLogPath + } + let requestRawWrite = (msg: string): number | Promise => { + return 0 + } + export async function init(options: Options) { if (options.level) level = options.level cleanup(Global.Path.log) @@ -89,8 +98,15 @@ export namespace Log { Global.Path.log, options.dev ? "dev.request.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".request.log", ) + requestRawLogPath = path.join( + Global.Path.log, + options.dev + ? "dev.request.raw.jsonl" + : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".request.raw.jsonl", + ) // Create the file initially await fs.writeFile(requestLogPath, "").catch(() => {}) + await fs.writeFile(requestRawLogPath, "").catch(() => {}) // Use SYNCHRONOUS file appending to ensure writes complete before process exit requestWrite = (msg: any) => { @@ -101,6 +117,14 @@ export namespace Log { return 0 } } + requestRawWrite = (msg: string) => { + try { + fsSync.appendFileSync(requestRawLogPath, msg) + return msg.length + } catch (e) { + return 0 + } + } } } @@ -184,6 +208,24 @@ export namespace Log { requestWrite(formatRequestLog(data)) } + export async function logRequestRaw(data: unknown) { + if (!requestLogEnabled) return + const text = iife(() => { + const seen = new WeakSet() + const json = JSON.stringify(data, (_key, value) => { + if (typeof value === "bigint") return value.toString() + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[circular]" + seen.add(value) + } + return value + }) + if (json !== undefined) return json + return "null" + }) + requestRawWrite(text + "\n") + } + export async function flushRequestLog() { // No-op since we're using synchronous writes } @@ -191,6 +233,7 @@ export namespace Log { async function cleanup(dir: string) { const glob = new Bun.Glob("????-??-??T??????.log") const requestGlob = new Bun.Glob("????-??-??T??????.request.log") + const requestRawGlob = new Bun.Glob("????-??-??T??????.request.raw.jsonl") const files = await Array.fromAsync( glob.scan({ cwd: dir, @@ -203,6 +246,12 @@ export namespace Log { absolute: true, }), ) + const requestRawFiles = await Array.fromAsync( + requestRawGlob.scan({ + cwd: dir, + absolute: true, + }), + ) if (files.length > 5) { const filesToDelete = files.slice(0, -10) await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) @@ -211,6 +260,10 @@ export namespace Log { const filesToDelete = requestFiles.slice(0, -10) await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) } + if (requestRawFiles.length > 5) { + const filesToDelete = requestRawFiles.slice(0, -10) + await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) + } } function formatError(error: Error, depth = 0): string { From ad90651dbb050d8198031eef74bc826341c55792 Mon Sep 17 00:00:00 2001 From: ops Date: Sun, 1 Feb 2026 04:21:05 +0100 Subject: [PATCH 3/3] cleanup --- packages/opencode/src/provider/provider.ts | 20 ++----- packages/opencode/src/util/log.ts | 65 +++++++--------------- 2 files changed, 24 insertions(+), 61 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6cdae2d5bd4..aac1d74fe28 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1005,8 +1005,7 @@ export namespace Provider { const url = typeof input === "string" ? input : input.url - // Generate request ID for correlating request/response - const requestId = Math.random().toString(36).substring(2, 8) + const requestId = Math.random().toString(36).substring(2, 15) // Strip openai itemId metadata following what codex does // Codex uses #[serde(skip_serializing)] on id fields for all item types: @@ -1065,9 +1064,8 @@ export namespace Provider { if (typeof msg.content === "string") { content = msg.content } else if (Array.isArray(msg.content)) { - // Multi-part content (text + images, etc) const textPart = msg.content.find((p: any) => p.type === "text" || p.text) - content = textPart ? textPart.text || textPart.content || "[complex content]" : "[complex content]" + content = textPart?.text ?? textPart?.content ?? "[complex content]" } else { content = "[complex content]" } @@ -1076,12 +1074,11 @@ export namespace Provider { }) } - // Summarize tools if present if (body.tools && Array.isArray(body.tools)) { filteredBody.tools_count = body.tools.length filteredBody.tools_summary = body.tools .map((t: any) => t.function?.name || t.name) - .filter(Boolean) + .filter((name: any): name is string => typeof name === "string" && name.length > 0) .join(", ") } @@ -1240,17 +1237,8 @@ export namespace Provider { error: error instanceof Error ? error.message : String(error), duration: Date.now() - startTime, } - const errorRaw = { - type: "ERROR", - requestId, - provider: model.providerID, - model: model.id, - url, - error: error instanceof Error ? error.message : String(error), - duration: Date.now() - startTime, - } await Log.logRequest(errorData) - await Log.logRequestRaw(errorRaw) + await Log.logRequestRaw(errorData) } throw error } diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 4f38f7e23e7..1ae8f50f549 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -104,16 +104,15 @@ export namespace Log { ? "dev.request.raw.jsonl" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".request.raw.jsonl", ) - // Create the file initially await fs.writeFile(requestLogPath, "").catch(() => {}) await fs.writeFile(requestRawLogPath, "").catch(() => {}) - // Use SYNCHRONOUS file appending to ensure writes complete before process exit requestWrite = (msg: any) => { try { fsSync.appendFileSync(requestLogPath, msg) return msg.length } catch (e) { + console.error("Failed to write request log:", e) return 0 } } @@ -122,6 +121,7 @@ export namespace Log { fsSync.appendFileSync(requestRawLogPath, msg) return msg.length } catch (e) { + console.error("Failed to write raw request log:", e) return 0 } } @@ -134,7 +134,7 @@ export namespace Log { function formatRequestLog(data: any): string { const timestamp = new Date().toISOString() - const time = timestamp.substring(11, 19) // HH:MM:SS + const time = timestamp.substring(11, 19) // ANSI color codes const RESET = "\x1b[0m" @@ -147,25 +147,22 @@ export namespace Log { const BLUE = "\x1b[34m" const MAGENTA = "\x1b[35m" const GRAY = "\x1b[90m" - const ORANGE = "\x1b[38;5;208m" const lines: string[] = [] const separator = GRAY + "─".repeat(100) + RESET const requestId = data.requestId ? `${GRAY}[${data.requestId}]${RESET}` : "" if (data.type === "REQUEST") { - // Single status line with request ID const statusLine = `${CYAN}${BOLD}▶ REQUEST${RESET} ${DIM}${time}${RESET} ${requestId} ${GRAY}|${RESET} ${data.provider}${GRAY}/${RESET}${BLUE}${BOLD}${data.model}${RESET} ${GRAY}|${RESET} ${DIM}${data.url}${RESET}` lines.push(separator) lines.push(statusLine) - lines.push("") // blank line after status + lines.push("") if (data.body?.messages) { for (const msg of data.body.messages) { const roleColor = msg.role === "user" ? GREEN : msg.role === "assistant" ? BLUE : MAGENTA const content = msg.content.trim() - // Show full content at first column, no indentation lines.push(`${roleColor}${BOLD}[${msg.role}]${RESET} ${content}`) } } @@ -174,7 +171,6 @@ export namespace Log { lines.push(`${DIM}Tools: ${data.body.tools_count} (${data.body.tools_summary})${RESET}`) } } else if (data.type === "RESPONSE") { - // Single status line with request ID const statusColor = data.status >= 200 && data.status < 300 ? GREEN : RED const tokenInfo = data.total_tokens ? `${GRAY}|${RESET} Tokens: ${CYAN}${data.input_tokens}${RESET}/${YELLOW}${data.output_tokens}${RESET}=${BOLD}${data.total_tokens}${RESET}` @@ -183,23 +179,21 @@ export namespace Log { const statusLine = `${GREEN}${BOLD}◀ RESPONSE${RESET} ${DIM}${time}${RESET} ${requestId} ${GRAY}|${RESET} ${statusColor}${data.status}${RESET} ${GRAY}|${RESET} ${data.duration}ms ${tokenInfo}` lines.push(separator) lines.push(statusLine) - lines.push("") // blank line after status + lines.push("") if (data.completion) { - // Format: [model] completion text at first column const modelName = data.model || "model" lines.push(`${BLUE}${BOLD}[${modelName}]${RESET} ${data.completion}`) } } else if (data.type === "ERROR") { - // Single status line with request ID const statusLine = `${RED}${BOLD}✖ ERROR${RESET} ${DIM}${time}${RESET} ${requestId} ${GRAY}|${RESET} ${data.provider}${GRAY}/${RESET}${data.model} ${GRAY}|${RESET} ${data.duration}ms` lines.push(separator) lines.push(statusLine) - lines.push("") // blank line after status + lines.push("") lines.push(`${RED}${data.error}${RESET}`) } - lines.push("") // blank line after entry + lines.push("") return lines.join("\n") } @@ -231,39 +225,20 @@ export namespace Log { } async function cleanup(dir: string) { - const glob = new Bun.Glob("????-??-??T??????.log") - const requestGlob = new Bun.Glob("????-??-??T??????.request.log") - const requestRawGlob = new Bun.Glob("????-??-??T??????.request.raw.jsonl") - const files = await Array.fromAsync( - glob.scan({ - cwd: dir, - absolute: true, - }), - ) - const requestFiles = await Array.fromAsync( - requestGlob.scan({ - cwd: dir, - absolute: true, - }), - ) - const requestRawFiles = await Array.fromAsync( - requestRawGlob.scan({ - cwd: dir, - absolute: true, - }), - ) - if (files.length > 5) { - const filesToDelete = files.slice(0, -10) - await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) - } - if (requestFiles.length > 5) { - const filesToDelete = requestFiles.slice(0, -10) - await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) - } - if (requestRawFiles.length > 5) { - const filesToDelete = requestRawFiles.slice(0, -10) - await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) + async function cleanupPattern(pattern: string) { + const glob = new Bun.Glob(pattern) + const files = await Array.fromAsync(glob.scan({ cwd: dir, absolute: true })) + if (files.length > 5) { + const filesToDelete = files.slice(0, -10) + await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) + } } + + await Promise.all([ + cleanupPattern("????-??-??T??????.log"), + cleanupPattern("????-??-??T??????.request.log"), + cleanupPattern("????-??-??T??????.request.raw.jsonl"), + ]) } function formatError(error: Error, depth = 0): string {