diff --git a/eslint.config.js b/eslint.config.js index 5807cde..a94dc4a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,6 +13,11 @@ export default defineConfig( }, }, }, + { + rules: { + "@typescript-eslint/only-throw-error": ["error", { allow: [{ from: "lib", name: "never" }] }], + }, + }, { ignores: ["dist/", "node_modules/", "scripts/", "test/", "config.json5", "eslint.config.js", "vitest.config.ts"], }, diff --git a/src/handlers/completions.ts b/src/handlers/completions.ts index b3c89ce..64b99bd 100644 --- a/src/handlers/completions.ts +++ b/src/handlers/completions.ts @@ -6,7 +6,15 @@ import { formatPrompt } from "../utils/prompt.js"; import { createSessionConfig } from "./completions/session-config.js"; import { handleStreaming } from "./completions/streaming.js"; -/** POST /v1/chat/completions */ +function sendError( + reply: FastifyReply, + status: number, + type: "invalid_request_error" | "api_error", + message: string, +): void { + reply.status(status).send({ error: { message, type } }); +} + export function createCompletionsHandler({ service, logger, config }: AppContext) { let sentMessageCount = 0; @@ -17,12 +25,7 @@ export function createCompletionsHandler({ service, logger, config }: AppContext const parseResult = ChatCompletionRequestSchema.safeParse(request.body); if (!parseResult.success) { const firstIssue = parseResult.error.issues[0]; - reply.status(400).send({ - error: { - message: firstIssue?.message ?? "Invalid request body", - type: "invalid_request_error", - }, - }); + sendError(reply, 400, "invalid_request_error", firstIssue?.message ?? "Invalid request body"); return; } const req = parseResult.data; @@ -34,12 +37,7 @@ export function createCompletionsHandler({ service, logger, config }: AppContext try { systemParts.push(extractContentText(msg.content)); } catch (err) { - reply.status(400).send({ - error: { - message: err instanceof Error ? err.message : String(err), - type: "invalid_request_error", - }, - }); + sendError(reply, 400, "invalid_request_error", err instanceof Error ? err.message : String(err)); return; } } @@ -49,12 +47,7 @@ export function createCompletionsHandler({ service, logger, config }: AppContext try { prompt = formatPrompt(messages.slice(sentMessageCount), config.excludedFilePatterns); } catch (err) { - reply.status(400).send({ - error: { - message: err instanceof Error ? err.message : String(err), - type: "invalid_request_error", - }, - }); + sendError(reply, 400, "invalid_request_error", err instanceof Error ? err.message : String(err)); return; } @@ -92,12 +85,7 @@ export function createCompletionsHandler({ service, logger, config }: AppContext session = await service.getSession(sessionConfig); } catch (err) { logger.error("Getting session failed:", err); - reply.status(500).send({ - error: { - message: "Failed to create session", - type: "api_error", - }, - }); + sendError(reply, 500, "api_error", "Failed to create session"); return; } @@ -108,12 +96,7 @@ export function createCompletionsHandler({ service, logger, config }: AppContext } catch (err) { logger.error("Request failed:", err); if (!reply.sent) { - reply.status(500).send({ - error: { - message: err instanceof Error ? err.message : "Internal error", - type: "api_error", - }, - }); + sendError(reply, 500, "api_error", err instanceof Error ? err.message : "Internal error"); } } }; diff --git a/src/handlers/completions/streaming.ts b/src/handlers/completions/streaming.ts index b5ea78d..8eabb2e 100644 --- a/src/handlers/completions/streaming.ts +++ b/src/handlers/completions/streaming.ts @@ -1,7 +1,6 @@ import type { FastifyReply } from "fastify"; import type { CopilotSession } from "@github/copilot-sdk"; -import type { Logger } from "../../logger.js"; -import { truncate } from "../../logger.js"; +import { formatCompaction, type Logger } from "../../logger.js"; import { currentTimestamp } from "../../schemas.js"; import type { ChatCompletionChunk, Message } from "../../types.js"; @@ -91,7 +90,7 @@ export async function handleStreaming( const d = event.data; toolNames.set(d.toolCallId, d.toolName); logger.debug( - `Running ${d.toolName} (${truncate(d.arguments)})`, + `Running ${d.toolName} (${JSON.stringify(d.arguments)})`, ); return; } @@ -100,7 +99,7 @@ export async function handleStreaming( const name = toolNames.get(d.toolCallId) ?? d.toolCallId; toolNames.delete(d.toolCallId); const detail = d.success - ? truncate(d.result?.content) + ? JSON.stringify(d.result?.content) : d.error?.message ?? "failed"; logger.debug(`${name} done (${detail})`); return; @@ -140,13 +139,9 @@ export async function handleStreaming( logger.info("Compacting context..."); break; - case "session.compaction_complete": { - const cd = event.data as Record; - logger.info( - `Context compacted: ${String(cd.preCompactionTokens)} → ${String(cd.postCompactionTokens)} tokens`, - ); + case "session.compaction_complete": + logger.info(`Context compacted: ${formatCompaction(event.data)}`); break; - } case "session.error": logger.error(`Session error: ${event.data.message}`); diff --git a/src/handlers/models.ts b/src/handlers/models.ts index 6fda406..c3c2f63 100644 --- a/src/handlers/models.ts +++ b/src/handlers/models.ts @@ -3,7 +3,6 @@ import type { AppContext } from "../context.js"; import { currentTimestamp } from "../schemas.js"; import type { ModelsResponse } from "../types.js"; -/** GET /v1/models */ export function createModelsHandler({ service, logger }: AppContext) { return async function handleModels( _request: FastifyRequest, diff --git a/src/logger.ts b/src/logger.ts index cac1f8f..0459676 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -5,14 +5,14 @@ export const LEVEL_PRIORITY = { info: 3, debug: 4, all: 5, -} as const; +} as const satisfies Record; export type LogLevel = keyof typeof LEVEL_PRIORITY; -export function truncate(value: unknown, maxLen = 200): string { - const s = typeof value === "string" ? value : JSON.stringify(value); - if (s.length <= maxLen) return s; - return s.slice(0, maxLen) + "…"; +export function formatCompaction(data: unknown): string { + if (!data || typeof data !== "object") return "compaction data unavailable"; + const cd = data as Record; + return `${String(cd["preCompactionTokens"])} → ${String(cd["postCompactionTokens"])} tokens`; } export class Logger { diff --git a/src/schemas.ts b/src/schemas.ts index f2487b2..3db5969 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -60,7 +60,8 @@ export function currentTimestamp(): number { return Math.floor(Date.now() / 1000); } -/** @throws {Error} on malformed or unsupported content types. */ +// Throws on malformed or unsupported content types so callers can surface +// a 400 error back to the client. export function extractContentText(content: MessageContent | undefined): string { if (content == null) { return ""; diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index bdfd709..f3f5b91 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -63,6 +63,11 @@ export function formatPrompt( case "tool": parts.push(`[Tool result for ${msg.tool_call_id ?? "unknown"}]: ${content}`); break; + + case undefined: + break; + default: + throw msg.role satisfies never; } } diff --git a/test/logger.test.ts b/test/logger.test.ts index bab4006..3ee50f5 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -1,38 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { truncate, Logger } from "../src/logger.js"; - -describe("truncate", () => { - it("returns short strings unchanged", () => { - expect(truncate("hello")).toBe("hello"); - }); - - it("truncates strings longer than maxLen", () => { - const long = "a".repeat(300); - const result = truncate(long); - expect(result).toHaveLength(201); - expect(result.endsWith("…")).toBe(true); - }); - - it("respects custom maxLen", () => { - const result = truncate("abcdefghij", 5); - expect(result).toBe("abcde…"); - }); - - it("serializes non-string values via JSON.stringify", () => { - expect(truncate({ a: 1 })).toBe('{"a":1}'); - }); - - it("truncates long serialized objects", () => { - const obj = { data: "x".repeat(300) }; - const result = truncate(obj, 10); - expect(result).toHaveLength(11); - }); - - it("handles exactly maxLen length", () => { - const exact = "a".repeat(200); - expect(truncate(exact)).toBe(exact); - }); -}); +import { Logger } from "../src/logger.js"; describe("Logger", () => { beforeEach(() => { diff --git a/test/utils/prompt.test.ts b/test/utils/prompt.test.ts index 9603a9f..8dd2a10 100644 --- a/test/utils/prompt.test.ts +++ b/test/utils/prompt.test.ts @@ -3,10 +3,6 @@ import { extractContentText } from "../../src/schemas.js"; import type { Message } from "../../src/types.js"; import { formatPrompt, filterExcludedFiles } from "../../src/utils/prompt.js"; -// --------------------------------------------------------------------------- -// extractContentText -// --------------------------------------------------------------------------- - describe("extractContentText", () => { it("returns string content as-is", () => { expect(extractContentText("Hello, world!")).toBe("Hello, world!"); @@ -75,10 +71,6 @@ describe("extractContentText", () => { }); }); -// --------------------------------------------------------------------------- -// formatPrompt -// --------------------------------------------------------------------------- - describe("formatPrompt", () => { it("basic user assistant interaction", () => { const messages: Message[] = [ @@ -286,10 +278,6 @@ describe("formatPrompt", () => { }); }); -// --------------------------------------------------------------------------- -// filterExcludedFiles -// --------------------------------------------------------------------------- - describe("filterExcludedFiles", () => { const fence = "```"; const mockPatterns = ["mock"];