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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/llmock",
"version": "1.0.0",
"version": "1.0.1",
"description": "Deterministic mock LLM server for testing (OpenAI, Anthropic, Gemini)",
"license": "MIT",
"packageManager": "pnpm@10.28.2",
Expand Down
110 changes: 108 additions & 2 deletions src/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { matchFixture, getLastMessageByRole } from "../router.js";
import type { ChatCompletionRequest, ChatMessage, Fixture } from "../types.js";
import { matchFixture, getLastMessageByRole, getTextContent } from "../router.js";
import type { ChatCompletionRequest, ChatMessage, ContentPart, Fixture } from "../types.js";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -54,6 +54,57 @@ describe("getLastMessageByRole", () => {
});
});

// ---------------------------------------------------------------------------
// getTextContent
// ---------------------------------------------------------------------------

describe("getTextContent", () => {
it("returns the string as-is for string content", () => {
expect(getTextContent("hello world")).toBe("hello world");
});

it("returns null for null content", () => {
expect(getTextContent(null)).toBeNull();
});

it("extracts text from array-of-parts content", () => {
const parts: ContentPart[] = [{ type: "text", text: "hello world" }];
expect(getTextContent(parts)).toBe("hello world");
});

it("concatenates multiple text parts", () => {
const parts: ContentPart[] = [
{ type: "text", text: "hello " },
{ type: "text", text: "world" },
];
expect(getTextContent(parts)).toBe("hello world");
});

it("ignores non-text parts in array content", () => {
const parts: ContentPart[] = [
{ type: "image_url", image_url: { url: "https://example.com/img.png" } },
{ type: "text", text: "describe this" },
];
expect(getTextContent(parts)).toBe("describe this");
});

it("returns null for array with no text parts", () => {
const parts: ContentPart[] = [
{ type: "image_url", image_url: { url: "https://example.com/img.png" } },
];
expect(getTextContent(parts)).toBeNull();
});

it("returns null for empty array", () => {
expect(getTextContent([])).toBeNull();
});

it("returns null for array with only empty-string text parts", () => {
const parts: ContentPart[] = [{ type: "text", text: "" }];
expect(getTextContent(parts)).toBeNull();
});
});

// ---------------------------------------------------------------------------
// matchFixture — empty / null cases
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -106,6 +157,61 @@ describe("matchFixture — userMessage (string)", () => {
});
});

describe("matchFixture — userMessage (array content)", () => {
it("matches when user content is array-of-parts with matching text", () => {
const fixture = makeFixture({ userMessage: "hello" });
const req = makeReq({
messages: [{ role: "user", content: [{ type: "text", text: "say hello world" }] }],
});
expect(matchFixture([fixture], req)).toBe(fixture);
});

it("does not match when array-of-parts text does not include the string", () => {
const fixture = makeFixture({ userMessage: "goodbye" });
const req = makeReq({
messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }],
});
expect(matchFixture([fixture], req)).toBeNull();
});

it("matches regexp against array-of-parts text", () => {
const fixture = makeFixture({ userMessage: /^hello/i });
const req = makeReq({
messages: [{ role: "user", content: [{ type: "text", text: "Hello world" }] }],
});
expect(matchFixture([fixture], req)).toBe(fixture);
});

it("concatenates multiple text parts for matching", () => {
const fixture = makeFixture({ userMessage: "hello world" });
const req = makeReq({
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello " },
{ type: "text", text: "world" },
],
},
],
});
expect(matchFixture([fixture], req)).toBe(fixture);
});

it("skips array content with no text parts", () => {
const fixture = makeFixture({ userMessage: "hello" });
const req = makeReq({
messages: [
{
role: "user",
content: [{ type: "image_url", image_url: { url: "https://example.com" } }],
},
],
});
expect(matchFixture([fixture], req)).toBeNull();
});
});

describe("matchFixture — userMessage (RegExp)", () => {
it("matches when the last user message satisfies the regexp", () => {
const fixture = makeFixture({ userMessage: /^hello/i });
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export { loadFixtureFile, loadFixturesFromDir } from "./fixture-loader.js";
export { Journal } from "./journal.js";

// Router
export { matchFixture } from "./router.js";
export { matchFixture, getTextContent } from "./router.js";

// Provider handlers
export { handleResponses } from "./responses.js";
Expand All @@ -35,6 +35,7 @@ export { writeSSEStream, writeErrorResponse } from "./sse-writer.js";
export type {
ChatMessage,
ChatCompletionRequest,
ContentPart,
ToolDefinition,
FixtureMatch,
TextResponse,
Expand Down
25 changes: 21 additions & 4 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ChatCompletionRequest, ChatMessage, Fixture } from "./types.js";
import type { ChatCompletionRequest, ChatMessage, ContentPart, Fixture } from "./types.js";

export function getLastMessageByRole(messages: ChatMessage[], role: string): ChatMessage | null {
for (let i = messages.length - 1; i >= 0; i--) {
Expand All @@ -7,6 +7,22 @@ export function getLastMessageByRole(messages: ChatMessage[], role: string): Cha
return null;
}

/**
* Extract the text content from a message's content field.
* Handles both plain string content and array-of-parts content
* (e.g. `[{type: "text", text: "..."}]` as sent by some SDKs).
*/
export function getTextContent(content: string | ContentPart[] | null): string | null {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const texts = content
.filter((p) => p.type === "text" && typeof p.text === "string" && p.text !== "")
.map((p) => p.text as string);
return texts.length > 0 ? texts.join("") : null;
}
return null;
}

export function matchFixture(fixtures: Fixture[], req: ChatCompletionRequest): Fixture | null {
for (const fixture of fixtures) {
const { match } = fixture;
Expand All @@ -19,11 +35,12 @@ export function matchFixture(fixtures: Fixture[], req: ChatCompletionRequest): F
// userMessage — match against the last user message content
if (match.userMessage !== undefined) {
const msg = getLastMessageByRole(req.messages, "user");
if (!msg || typeof msg.content !== "string") continue;
const text = msg ? getTextContent(msg.content) : null;
if (!text) continue;
if (typeof match.userMessage === "string") {
if (!msg.content.includes(match.userMessage)) continue;
if (!text.includes(match.userMessage)) continue;
} else {
if (!match.userMessage.test(msg.content)) continue;
if (!match.userMessage.test(text)) continue;
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
// OpenAI Chat Completion request types (subset we care about)

export interface ContentPart {
type: string;
text?: string;
[key: string]: unknown;
}

export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
content: string | ContentPart[] | null;
name?: string;
tool_calls?: ToolCallMessage[];
tool_call_id?: string;
Expand Down
Loading