Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
Expand Down
45 changes: 14 additions & 31 deletions src/handlers/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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;
}
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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");
}
}
};
Expand Down
15 changes: 5 additions & 10 deletions src/handlers/completions/streaming.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -140,13 +139,9 @@ export async function handleStreaming(
logger.info("Compacting context...");
break;

case "session.compaction_complete": {
const cd = event.data as Record<string, unknown>;
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}`);
Expand Down
1 change: 0 additions & 1 deletion src/handlers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ export const LEVEL_PRIORITY = {
info: 3,
debug: 4,
all: 5,
} as const;
} as const satisfies Record<string, number>;

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<string, unknown>;
return `${String(cd["preCompactionTokens"])} → ${String(cd["postCompactionTokens"])} tokens`;
}

export class Logger {
Expand Down
3 changes: 2 additions & 1 deletion src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down
5 changes: 5 additions & 0 deletions src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
35 changes: 1 addition & 34 deletions test/logger.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down
12 changes: 0 additions & 12 deletions test/utils/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!");
Expand Down Expand Up @@ -75,10 +71,6 @@ describe("extractContentText", () => {
});
});

// ---------------------------------------------------------------------------
// formatPrompt
// ---------------------------------------------------------------------------

describe("formatPrompt", () => {
it("basic user assistant interaction", () => {
const messages: Message[] = [
Expand Down Expand Up @@ -286,10 +278,6 @@ describe("formatPrompt", () => {
});
});

// ---------------------------------------------------------------------------
// filterExcludedFiles
// ---------------------------------------------------------------------------

describe("filterExcludedFiles", () => {
const fence = "```";
const mockPatterns = ["mock"];
Expand Down