Keep both Mosaic Telemetry section (from develop) and Matrix Dev Environment section (from feature branch) in .env.example. Regenerate pnpm-lock.yaml with both dependency trees merged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { ServiceUnavailableException } from "@nestjs/common";
|
|
import { LlmService } from "./llm.service";
|
|
import { LlmManagerService } from "./llm-manager.service";
|
|
import { LlmTelemetryTrackerService } from "./llm-telemetry-tracker.service";
|
|
import type { ChatRequestDto, EmbedRequestDto, ChatResponseDto, EmbedResponseDto } from "./dto";
|
|
import type {
|
|
LlmProviderInterface,
|
|
LlmProviderHealthStatus,
|
|
} from "./providers/llm-provider.interface";
|
|
|
|
describe("LlmService", () => {
|
|
let service: LlmService;
|
|
let mockManagerService: {
|
|
getDefaultProvider: ReturnType<typeof vi.fn>;
|
|
};
|
|
let mockTelemetryTracker: {
|
|
trackLlmCompletion: ReturnType<typeof vi.fn>;
|
|
};
|
|
let mockProvider: {
|
|
chat: ReturnType<typeof vi.fn>;
|
|
chatStream: ReturnType<typeof vi.fn>;
|
|
embed: ReturnType<typeof vi.fn>;
|
|
listModels: ReturnType<typeof vi.fn>;
|
|
checkHealth: ReturnType<typeof vi.fn>;
|
|
name: string;
|
|
type: string;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
// Create mock provider
|
|
mockProvider = {
|
|
chat: vi.fn(),
|
|
chatStream: vi.fn(),
|
|
embed: vi.fn(),
|
|
listModels: vi.fn(),
|
|
checkHealth: vi.fn(),
|
|
name: "Test Provider",
|
|
type: "ollama",
|
|
};
|
|
|
|
// Create mock manager service
|
|
mockManagerService = {
|
|
getDefaultProvider: vi.fn().mockResolvedValue(mockProvider),
|
|
};
|
|
|
|
// Create mock telemetry tracker
|
|
mockTelemetryTracker = {
|
|
trackLlmCompletion: vi.fn(),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
LlmService,
|
|
{
|
|
provide: LlmManagerService,
|
|
useValue: mockManagerService,
|
|
},
|
|
{
|
|
provide: LlmTelemetryTrackerService,
|
|
useValue: mockTelemetryTracker,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<LlmService>(LlmService);
|
|
});
|
|
|
|
it("should be defined", () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe("checkHealth", () => {
|
|
it("should delegate to provider and return healthy status", async () => {
|
|
const healthStatus: LlmProviderHealthStatus = {
|
|
healthy: true,
|
|
provider: "ollama",
|
|
endpoint: "http://localhost:11434",
|
|
models: ["llama3.2"],
|
|
};
|
|
mockProvider.checkHealth.mockResolvedValue(healthStatus);
|
|
|
|
const result = await service.checkHealth();
|
|
|
|
expect(mockManagerService.getDefaultProvider).toHaveBeenCalled();
|
|
expect(mockProvider.checkHealth).toHaveBeenCalled();
|
|
expect(result).toEqual(healthStatus);
|
|
});
|
|
|
|
it("should return unhealthy status on error", async () => {
|
|
mockProvider.checkHealth.mockRejectedValue(new Error("Connection failed"));
|
|
|
|
const result = await service.checkHealth();
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.error).toContain("Connection failed");
|
|
});
|
|
|
|
it("should handle manager service failure", async () => {
|
|
mockManagerService.getDefaultProvider.mockRejectedValue(new Error("No provider configured"));
|
|
|
|
const result = await service.checkHealth();
|
|
|
|
expect(result.healthy).toBe(false);
|
|
expect(result.error).toContain("No provider configured");
|
|
});
|
|
});
|
|
|
|
describe("listModels", () => {
|
|
it("should delegate to provider and return models", async () => {
|
|
const models = ["llama3.2", "mistral"];
|
|
mockProvider.listModels.mockResolvedValue(models);
|
|
|
|
const result = await service.listModels();
|
|
|
|
expect(mockManagerService.getDefaultProvider).toHaveBeenCalled();
|
|
expect(mockProvider.listModels).toHaveBeenCalled();
|
|
expect(result).toEqual(models);
|
|
});
|
|
|
|
it("should throw ServiceUnavailableException on error", async () => {
|
|
mockProvider.listModels.mockRejectedValue(new Error("Failed to fetch models"));
|
|
|
|
await expect(service.listModels()).rejects.toThrow(ServiceUnavailableException);
|
|
});
|
|
});
|
|
|
|
describe("chat", () => {
|
|
const request: ChatRequestDto = {
|
|
model: "llama3.2",
|
|
messages: [{ role: "user", content: "Hi" }],
|
|
};
|
|
|
|
it("should delegate to provider and return response", async () => {
|
|
const response: ChatResponseDto = {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: "Hello" },
|
|
done: true,
|
|
totalDuration: 1000,
|
|
};
|
|
mockProvider.chat.mockResolvedValue(response);
|
|
|
|
const result = await service.chat(request);
|
|
|
|
expect(mockManagerService.getDefaultProvider).toHaveBeenCalled();
|
|
expect(mockProvider.chat).toHaveBeenCalledWith(request);
|
|
expect(result).toEqual(response);
|
|
});
|
|
|
|
it("should track telemetry on successful chat", async () => {
|
|
const response: ChatResponseDto = {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: "Hello" },
|
|
done: true,
|
|
promptEvalCount: 10,
|
|
evalCount: 20,
|
|
};
|
|
mockProvider.chat.mockResolvedValue(response);
|
|
|
|
await service.chat(request, "chat");
|
|
|
|
expect(mockTelemetryTracker.trackLlmCompletion).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
model: "llama3.2",
|
|
providerType: "ollama",
|
|
operation: "chat",
|
|
inputTokens: 10,
|
|
outputTokens: 20,
|
|
callingContext: "chat",
|
|
success: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should track telemetry on failed chat", async () => {
|
|
mockProvider.chat.mockRejectedValue(new Error("Chat failed"));
|
|
|
|
await expect(service.chat(request)).rejects.toThrow(ServiceUnavailableException);
|
|
|
|
expect(mockTelemetryTracker.trackLlmCompletion).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
model: "llama3.2",
|
|
operation: "chat",
|
|
success: false,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw ServiceUnavailableException on error", async () => {
|
|
mockProvider.chat.mockRejectedValue(new Error("Chat failed"));
|
|
|
|
await expect(service.chat(request)).rejects.toThrow(ServiceUnavailableException);
|
|
});
|
|
});
|
|
|
|
describe("chatStream", () => {
|
|
const request: ChatRequestDto = {
|
|
model: "llama3.2",
|
|
messages: [{ role: "user", content: "Hi" }],
|
|
stream: true,
|
|
};
|
|
|
|
it("should delegate to provider and yield chunks", async () => {
|
|
async function* mockGenerator(): AsyncGenerator<ChatResponseDto> {
|
|
yield {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: "Hello" },
|
|
done: false,
|
|
};
|
|
yield {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: " world" },
|
|
done: true,
|
|
};
|
|
}
|
|
|
|
mockProvider.chatStream.mockReturnValue(mockGenerator());
|
|
|
|
const chunks: ChatResponseDto[] = [];
|
|
for await (const chunk of service.chatStream(request)) {
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
expect(mockManagerService.getDefaultProvider).toHaveBeenCalled();
|
|
expect(mockProvider.chatStream).toHaveBeenCalledWith(request);
|
|
expect(chunks.length).toBe(2);
|
|
expect(chunks[0].message.content).toBe("Hello");
|
|
expect(chunks[1].message.content).toBe(" world");
|
|
});
|
|
|
|
it("should track telemetry after stream completes", async () => {
|
|
async function* mockGenerator(): AsyncGenerator<ChatResponseDto> {
|
|
yield {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: "Hello" },
|
|
done: false,
|
|
};
|
|
yield {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: " world" },
|
|
done: true,
|
|
promptEvalCount: 5,
|
|
evalCount: 10,
|
|
};
|
|
}
|
|
|
|
mockProvider.chatStream.mockReturnValue(mockGenerator());
|
|
|
|
const chunks: ChatResponseDto[] = [];
|
|
for await (const chunk of service.chatStream(request, "brain")) {
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
expect(mockTelemetryTracker.trackLlmCompletion).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
model: "llama3.2",
|
|
providerType: "ollama",
|
|
operation: "chatStream",
|
|
inputTokens: 5,
|
|
outputTokens: 10,
|
|
callingContext: "brain",
|
|
success: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should estimate tokens when provider does not return counts in stream", async () => {
|
|
async function* mockGenerator(): AsyncGenerator<ChatResponseDto> {
|
|
yield {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: "Hello world" },
|
|
done: false,
|
|
};
|
|
yield {
|
|
model: "llama3.2",
|
|
message: { role: "assistant", content: "" },
|
|
done: true,
|
|
};
|
|
}
|
|
|
|
mockProvider.chatStream.mockReturnValue(mockGenerator());
|
|
|
|
const chunks: ChatResponseDto[] = [];
|
|
for await (const chunk of service.chatStream(request)) {
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
// Should use estimated tokens since no actual counts provided
|
|
expect(mockTelemetryTracker.trackLlmCompletion).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operation: "chatStream",
|
|
success: true,
|
|
// Input estimated from "Hi" -> ceil(2/4) = 1
|
|
inputTokens: 1,
|
|
// Output estimated from "Hello world" -> ceil(11/4) = 3
|
|
outputTokens: 3,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should track telemetry on stream failure", async () => {
|
|
async function* errorGenerator(): AsyncGenerator<ChatResponseDto> {
|
|
throw new Error("Stream failed");
|
|
}
|
|
|
|
mockProvider.chatStream.mockReturnValue(errorGenerator());
|
|
|
|
const generator = service.chatStream(request);
|
|
await expect(generator.next()).rejects.toThrow(ServiceUnavailableException);
|
|
|
|
expect(mockTelemetryTracker.trackLlmCompletion).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operation: "chatStream",
|
|
success: false,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw ServiceUnavailableException on error", async () => {
|
|
async function* errorGenerator(): AsyncGenerator<ChatResponseDto> {
|
|
throw new Error("Stream failed");
|
|
}
|
|
|
|
mockProvider.chatStream.mockReturnValue(errorGenerator());
|
|
|
|
const generator = service.chatStream(request);
|
|
await expect(generator.next()).rejects.toThrow(ServiceUnavailableException);
|
|
});
|
|
});
|
|
|
|
describe("embed", () => {
|
|
const request: EmbedRequestDto = {
|
|
model: "llama3.2",
|
|
input: ["test text"],
|
|
};
|
|
|
|
it("should delegate to provider and return embeddings", async () => {
|
|
const response: EmbedResponseDto = {
|
|
model: "llama3.2",
|
|
embeddings: [[0.1, 0.2, 0.3]],
|
|
totalDuration: 500,
|
|
};
|
|
mockProvider.embed.mockResolvedValue(response);
|
|
|
|
const result = await service.embed(request);
|
|
|
|
expect(mockManagerService.getDefaultProvider).toHaveBeenCalled();
|
|
expect(mockProvider.embed).toHaveBeenCalledWith(request);
|
|
expect(result).toEqual(response);
|
|
});
|
|
|
|
it("should track telemetry on successful embed", async () => {
|
|
const response: EmbedResponseDto = {
|
|
model: "llama3.2",
|
|
embeddings: [[0.1, 0.2, 0.3]],
|
|
totalDuration: 500,
|
|
};
|
|
mockProvider.embed.mockResolvedValue(response);
|
|
|
|
await service.embed(request, "embed");
|
|
|
|
expect(mockTelemetryTracker.trackLlmCompletion).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
model: "llama3.2",
|
|
providerType: "ollama",
|
|
operation: "embed",
|
|
outputTokens: 0,
|
|
callingContext: "embed",
|
|
success: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should track telemetry on failed embed", async () => {
|
|
mockProvider.embed.mockRejectedValue(new Error("Embedding failed"));
|
|
|
|
await expect(service.embed(request)).rejects.toThrow(ServiceUnavailableException);
|
|
|
|
expect(mockTelemetryTracker.trackLlmCompletion).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operation: "embed",
|
|
success: false,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should throw ServiceUnavailableException on error", async () => {
|
|
mockProvider.embed.mockRejectedValue(new Error("Embedding failed"));
|
|
|
|
await expect(service.embed(request)).rejects.toThrow(ServiceUnavailableException);
|
|
});
|
|
});
|
|
});
|