chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/// <reference types="vitest/globals" />
@@ -0,0 +1,259 @@
describe("Anthropic Adapter - Allowlist Approach", () => {
it("should filter out tool_result messages with no corresponding tool_use ID", () => {
// Setup test data
const validToolUseIds = new Set<string>(["valid-id-1", "valid-id-2"]);
// Messages to filter - valid and invalid ones
const messages = [
{ type: "text", role: "user", content: "Hello" },
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "result1",
},
{
type: "tool_result",
actionExecutionId: "invalid-id",
result: "invalid",
},
{
type: "tool_result",
actionExecutionId: "valid-id-2",
result: "result2",
},
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "duplicate",
}, // Duplicate ID
];
// Apply the allowlist filter approach
const filteredMessages = [];
const processedIds = new Set<string>();
for (const message of messages) {
if (message.type === "tool_result") {
// Skip if no corresponding valid tool_use ID
if (!validToolUseIds.has(message.actionExecutionId)) {
continue;
}
// Skip if we've already processed this ID
if (processedIds.has(message.actionExecutionId)) {
continue;
}
// Mark this ID as processed
processedIds.add(message.actionExecutionId);
}
// Include all non-tool-result messages and valid tool results
filteredMessages.push(message);
}
// Verify results
expect(filteredMessages.length).toBe(3); // text + 2 valid tool results (no duplicates or invalid)
// Valid results should be included
expect(
filteredMessages.some(
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
),
).toBe(true);
expect(
filteredMessages.some(
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-2",
),
).toBe(true);
// Invalid result should be excluded
expect(
filteredMessages.some(
(m) => m.type === "tool_result" && m.actionExecutionId === "invalid-id",
),
).toBe(false);
// Duplicate should be excluded
const validId1Count = filteredMessages.filter(
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
).length;
expect(validId1Count).toBe(1);
});
it("should maintain correct order of messages when filtering", () => {
// Setup test data with specific ordering
const validToolUseIds = new Set<string>(["tool-1", "tool-2", "tool-3"]);
// Messages in a specific order, with some invalid/duplicate results
const messages = [
{ type: "text", role: "user", content: "Initial message" },
{ type: "text", role: "assistant", content: "I'll help with that" },
{ type: "tool_use", id: "tool-1", name: "firstTool" },
{ type: "tool_result", actionExecutionId: "tool-1", result: "result1" },
{ type: "text", role: "assistant", content: "Got the first result" },
{ type: "tool_use", id: "tool-2", name: "secondTool" },
{ type: "tool_result", actionExecutionId: "tool-2", result: "result2" },
{
type: "tool_result",
actionExecutionId: "invalid-id",
result: "invalid-result",
},
{ type: "tool_use", id: "tool-3", name: "thirdTool" },
{
type: "tool_result",
actionExecutionId: "tool-1",
result: "duplicate-result",
}, // Duplicate
{ type: "tool_result", actionExecutionId: "tool-3", result: "result3" },
{ type: "text", role: "user", content: "Final message" },
];
// Apply the allowlist filter approach
const filteredMessages = [];
const processedIds = new Set<string>();
for (const message of messages) {
if (message.type === "tool_result") {
// Skip if no corresponding valid tool_use ID
if (!validToolUseIds.has(message.actionExecutionId)) {
continue;
}
// Skip if we've already processed this ID
if (processedIds.has(message.actionExecutionId)) {
continue;
}
// Mark this ID as processed
processedIds.add(message.actionExecutionId);
}
// Include all non-tool-result messages and valid tool results
filteredMessages.push(message);
}
// Verify results
expect(filteredMessages.length).toBe(10); // 12 original - 2 filtered out
// Check that the order is preserved
expect(filteredMessages[0].type).toBe("text"); // Initial user message
expect(filteredMessages[1].type).toBe("text"); // Assistant response
expect(filteredMessages[2].type).toBe("tool_use"); // First tool
expect(filteredMessages[3].type).toBe("tool_result"); // First result
expect(filteredMessages[3].actionExecutionId).toBe("tool-1"); // First result
expect(filteredMessages[4].type).toBe("text"); // Assistant comment
expect(filteredMessages[5].type).toBe("tool_use"); // Second tool
expect(filteredMessages[6].type).toBe("tool_result"); // Second result
expect(filteredMessages[6].actionExecutionId).toBe("tool-2"); // Second result
expect(filteredMessages[7].type).toBe("tool_use"); // Third tool
expect(filteredMessages[8].type).toBe("tool_result"); // Third result
expect(filteredMessages[8].actionExecutionId).toBe("tool-3"); // Third result
expect(filteredMessages[9].type).toBe("text"); // Final user message
// Each valid tool ID should appear exactly once in the results
const toolResultCounts = {
"tool-1": 0,
"tool-2": 0,
"tool-3": 0,
};
filteredMessages.forEach((message) => {
if (
message.type === "tool_result" &&
message.actionExecutionId in toolResultCounts
) {
toolResultCounts[message.actionExecutionId]++;
}
});
expect(toolResultCounts["tool-1"]).toBe(1);
expect(toolResultCounts["tool-2"]).toBe(1);
expect(toolResultCounts["tool-3"]).toBe(1);
});
it("should handle an empty message array", () => {
const validToolUseIds = new Set<string>(["valid-id-1", "valid-id-2"]);
const messages = [];
// Apply the filtering logic
const filteredMessages = [];
const processedIds = new Set<string>();
for (const message of messages) {
if (message.type === "tool_result") {
if (
!validToolUseIds.has(message.actionExecutionId) ||
processedIds.has(message.actionExecutionId)
) {
continue;
}
processedIds.add(message.actionExecutionId);
}
filteredMessages.push(message);
}
expect(filteredMessages.length).toBe(0);
});
it("should handle edge cases with mixed message types", () => {
// Setup with mixed message types
const validToolUseIds = new Set<string>(["valid-id-1"]);
const messages = [
{ type: "text", role: "user", content: "Hello" },
{ type: "image", url: "https://example.com/image.jpg" }, // Non-tool message type
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "result1",
},
{ type: "custom", data: { key: "value" } }, // Another custom type
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "duplicate",
}, // Duplicate
{ type: "null", value: null }, // Edge case
{ type: "undefined" }, // Edge case
];
// Apply the filtering logic
const filteredMessages = [];
const processedIds = new Set<string>();
for (const message of messages) {
if (message.type === "tool_result") {
if (
!validToolUseIds.has(message.actionExecutionId) ||
processedIds.has(message.actionExecutionId)
) {
continue;
}
processedIds.add(message.actionExecutionId);
}
filteredMessages.push(message);
}
// Should have all non-tool_result messages + 1 valid tool_result
expect(filteredMessages.length).toBe(6); // 7 original - 1 duplicate
// Valid tool_result should be included exactly once
const toolResults = filteredMessages.filter(
(m) => m.type === "tool_result",
);
expect(toolResults.length).toBe(1);
expect(toolResults[0].actionExecutionId).toBe("valid-id-1");
// All other message types should be preserved
expect(filteredMessages.filter((m) => m.type === "text").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "image").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "custom").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "null").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "undefined").length).toBe(
1,
);
});
});
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AnthropicProviderSettings } from "@ai-sdk/anthropic";
import { AnthropicAdapter } from "../../../src/service-adapters/anthropic/anthropic-adapter";
import Anthropic from "@anthropic-ai/sdk";
// Keys from AnthropicProviderSettings that we forward from the Anthropic SDK client.
type ForwardedAnthropicKeys = "baseURL" | "apiKey" | "headers" | "fetch";
// We don't set `name` or `generateId` — they're provider-internal concerns.
// `authToken` is an alternative auth mechanism the adapter does not forward.
type ControlledAnthropicKeys = "name" | "generateId" | "authToken";
// Compile-time exhaustiveness check: every key in AnthropicProviderSettings
// must be accounted for. If this line errors, a new key was added.
type _exhaustive =
Exclude<
keyof AnthropicProviderSettings,
ForwardedAnthropicKeys | ControlledAnthropicKeys
> extends never
? true
: {
error: "AnthropicProviderSettings has unhandled keys";
unhandled: Exclude<
keyof AnthropicProviderSettings,
ForwardedAnthropicKeys | ControlledAnthropicKeys
>;
};
const _check: _exhaustive = true;
const { mockProviderFn, mockCreateAnthropic } = vi.hoisted(() => {
const mockProviderFn = vi.fn().mockReturnValue({ modelId: "test-model" });
const mockCreateAnthropic = vi.fn().mockReturnValue(mockProviderFn);
return { mockProviderFn, mockCreateAnthropic };
});
vi.mock("@ai-sdk/anthropic", async (importOriginal) => {
const actual = await importOriginal<typeof import("@ai-sdk/anthropic")>();
return { ...actual, createAnthropic: mockCreateAnthropic };
});
vi.mock("@anthropic-ai/sdk", () => {
return {
default: class MockAnthropic {
baseURL: string;
apiKey: string;
_options: Record<string, any>;
messages = { create: vi.fn() };
constructor(opts: any = {}) {
this.baseURL = opts.baseURL ?? "https://api.anthropic.com/v1";
this.apiKey = opts.apiKey ?? "default-key";
this._options = {
defaultHeaders: opts.defaultHeaders,
fetch: opts.fetch,
...opts,
};
}
},
};
});
describe("AnthropicAdapter.getLanguageModel()", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("forwards all provider-relevant options from the Anthropic SDK client", () => {
const customFetch = vi.fn();
const anthropic = new Anthropic({
apiKey: "sk-ant-test",
baseURL: "https://proxy.example.com/v1",
defaultHeaders: { "x-custom": "value" },
fetch: customFetch,
});
const adapter = new AnthropicAdapter({
anthropic,
model: "claude-3-5-sonnet-latest",
});
adapter.getLanguageModel();
expect(mockCreateAnthropic).toHaveBeenCalledOnce();
const settings = mockCreateAnthropic.mock.calls[0][0];
expect(settings.baseURL).toBe("https://proxy.example.com/v1");
expect(settings.apiKey).toBe("sk-ant-test");
expect(settings.headers).toEqual({ "x-custom": "value" });
expect(settings.fetch).toBe(customFetch);
expect(mockProviderFn).toHaveBeenCalledWith("claude-3-5-sonnet-latest");
});
it("works with default Anthropic config (no custom options)", () => {
const anthropic = new Anthropic({ apiKey: "sk-ant-default" });
const adapter = new AnthropicAdapter({ anthropic });
adapter.getLanguageModel();
const settings = mockCreateAnthropic.mock.calls[0][0];
expect(settings.baseURL).toBe("https://api.anthropic.com/v1");
expect(settings.apiKey).toBe("sk-ant-default");
});
});
@@ -0,0 +1,687 @@
import { AnthropicAdapter } from "../../../src/service-adapters/anthropic/anthropic-adapter";
import {
TextMessage,
ActionExecutionMessage,
ResultMessage,
Role,
} from "../../../src/graphql/types/converted";
// Mock only the Anthropic SDK, not our adapter
vi.mock("@anthropic-ai/sdk", () => {
return {
default: vi.fn().mockImplementation(() => ({
messages: {
create: vi.fn(),
},
})),
};
});
// Mock the message classes
vi.mock("../../../src/graphql/types/converted", () => {
class MockTextMessage {
content: string;
role: string;
id: string;
constructor(options: { role: string; content: string }) {
this.role = options.role;
this.content = options.content;
this.id = "mock-text-" + Math.random().toString(36).slice(7);
}
isTextMessage() {
return true;
}
isImageMessage() {
return false;
}
isActionExecutionMessage() {
return false;
}
isResultMessage() {
return false;
}
}
class MockActionExecutionMessage {
id: string;
name: string;
arguments: string;
constructor(params: { id: string; name: string; arguments: string }) {
this.id = params.id;
this.name = params.name;
this.arguments = params.arguments;
}
isTextMessage() {
return false;
}
isImageMessage() {
return false;
}
isActionExecutionMessage() {
return true;
}
isResultMessage() {
return false;
}
}
class MockResultMessage {
actionExecutionId: string;
result: string;
id: string;
constructor(params: { actionExecutionId: string; result: string }) {
this.actionExecutionId = params.actionExecutionId;
this.result = params.result;
this.id = "mock-result-" + Math.random().toString(36).slice(7);
}
isTextMessage() {
return false;
}
isImageMessage() {
return false;
}
isActionExecutionMessage() {
return false;
}
isResultMessage() {
return true;
}
}
return {
TextMessage: MockTextMessage,
ActionExecutionMessage: MockActionExecutionMessage,
ResultMessage: MockResultMessage,
Role: {
assistant: "assistant",
developer: "developer",
system: "system",
tool: "tool",
user: "user",
},
};
});
describe("AnthropicAdapter", () => {
let adapter: AnthropicAdapter;
let mockEventSource: any;
let mockAnthropicCreate: any;
beforeEach(() => {
vi.clearAllMocks();
// Create a mock Anthropic instance
const mockAnthropic = {
messages: {
create: vi.fn(),
},
};
// Create adapter with the mocked instance
adapter = new AnthropicAdapter({ anthropic: mockAnthropic as any });
// Mock the create method to capture what's being sent
mockAnthropicCreate = mockAnthropic.messages.create;
mockEventSource = {
stream: vi.fn((callback) => {
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
sendActionExecutionStart: vi.fn(),
sendActionExecutionArgs: vi.fn(),
sendActionExecutionEnd: vi.fn(),
complete: vi.fn(),
};
callback(mockStream);
return Promise.resolve();
}),
};
});
describe("Deduplication Logic", () => {
it("should filter out duplicate result messages", async () => {
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({
role: Role.user,
content: "Set theme to orange",
});
// Tool execution
const toolExecution = new ActionExecutionMessage({
id: "tool-123",
name: "setThemeColor",
arguments: '{"themeColor": "orange"}',
});
// Multiple duplicate results (this was causing the infinite loop)
const result1 = new ResultMessage({
actionExecutionId: "tool-123",
result: "Theme color set to orange",
});
const result2 = new ResultMessage({
actionExecutionId: "tool-123",
result: "Theme color set to orange",
});
const result3 = new ResultMessage({
actionExecutionId: "tool-123",
result: "Theme color set to orange",
});
// Mock Anthropic to return empty stream (simulating the original problem)
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
// Empty stream - no content from Anthropic
},
});
await adapter.process({
threadId: "test-thread",
model: "claude-3-5-sonnet-latest",
messages: [
systemMessage,
userMessage,
toolExecution,
result1,
result2,
result3,
],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Check that only one result message was sent to Anthropic
expect(mockAnthropicCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: [{ type: "text", text: "Set theme to orange" }],
}),
expect.objectContaining({
role: "assistant",
content: [
expect.objectContaining({
type: "tool_use",
id: "tool-123",
name: "setThemeColor",
}),
],
}),
expect.objectContaining({
role: "user",
content: [
expect.objectContaining({
type: "tool_result",
content: "Theme color set to orange",
tool_use_id: "tool-123",
}),
],
}),
]),
}),
);
// Verify only 3 messages sent (user, assistant tool_use, user tool_result) - no duplicates
const sentMessages = mockAnthropicCreate.mock.calls[0][0].messages;
expect(sentMessages).toHaveLength(3);
// Count tool_result messages - should be only 1
const toolResults = sentMessages.filter(
(msg: any) =>
msg.role === "user" &&
msg.content.some((c: any) => c.type === "tool_result"),
);
expect(toolResults).toHaveLength(1);
});
it("should filter out invalid result messages without corresponding tool_use", async () => {
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
// Valid tool execution
const validTool = new ActionExecutionMessage({
id: "valid-tool",
name: "validAction",
arguments: "{}",
});
const validResult = new ResultMessage({
actionExecutionId: "valid-tool",
result: "Valid result",
});
// Invalid result with no corresponding tool_use
const invalidResult = new ResultMessage({
actionExecutionId: "nonexistent-tool",
result: "Invalid result",
});
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {},
});
await adapter.process({
threadId: "test-thread",
messages: [systemMessage, validTool, validResult, invalidResult],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
const sentMessages = mockAnthropicCreate.mock.calls[0][0].messages;
// Should only include the valid tool result
const toolResults = sentMessages.filter(
(msg: any) =>
msg.role === "user" &&
msg.content.some((c: any) => c.type === "tool_result"),
);
expect(toolResults).toHaveLength(1);
expect(toolResults[0].content[0].tool_use_id).toBe("valid-tool");
});
});
describe("Fallback Response Logic", () => {
it("should generate contextual fallback when Anthropic returns no content", async () => {
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({
role: Role.user,
content: "Set theme to orange",
});
const toolExecution = new ActionExecutionMessage({
id: "tool-123",
name: "setThemeColor",
arguments: '{"themeColor": "orange"}',
});
const toolResult = new ResultMessage({
actionExecutionId: "tool-123",
result: "Theme color set to orange",
});
// Mock Anthropic to return empty stream
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
// No content blocks - simulates Anthropic not responding
},
});
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
complete: vi.fn(),
};
mockEventSource.stream.mockImplementation((callback) => {
callback(mockStream);
return Promise.resolve();
});
await adapter.process({
threadId: "test-thread",
messages: [systemMessage, userMessage, toolExecution, toolResult],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Should generate fallback response with the tool result content
expect(mockStream.sendTextMessageStart).toHaveBeenCalled();
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
messageId: expect.any(String),
content: "Theme color set to orange", // Uses the actual result content
});
expect(mockStream.sendTextMessageEnd).toHaveBeenCalled();
});
it("should use generic fallback when no tool result content available", async () => {
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const toolExecution = new ActionExecutionMessage({
id: "tool-123",
name: "someAction",
arguments: "{}",
});
const toolResult = new ResultMessage({
actionExecutionId: "tool-123",
result: "", // Empty result
});
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {},
});
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
complete: vi.fn(),
};
mockEventSource.stream.mockImplementation((callback) => {
callback(mockStream);
return Promise.resolve();
});
await adapter.process({
threadId: "test-thread",
messages: [systemMessage, toolExecution, toolResult],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Should use generic fallback
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
messageId: expect.any(String),
content: "Task completed successfully.",
});
});
});
describe("Unknown Tool Use Handling", () => {
it("should skip unknown tool_use blocks without crashing", async () => {
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({
role: Role.user,
content: "Do something",
});
// Mock Anthropic to return a stream with an unknown tool_use block
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
yield { type: "message_start", message: { id: "msg-1" } };
// Unknown tool_use block — tool name not in the actions list
yield {
type: "content_block_start",
content_block: {
type: "tool_use",
id: "tool-unknown",
name: "nonexistent_tool",
},
};
yield {
type: "content_block_delta",
delta: { type: "input_json_delta", partial_json: '{"query":' },
};
yield {
type: "content_block_delta",
delta: { type: "input_json_delta", partial_json: '"test"}' },
};
yield { type: "content_block_stop" };
// Then a normal text block
yield {
type: "content_block_start",
content_block: { type: "text" },
};
yield {
type: "content_block_delta",
delta: { type: "text_delta", text: "Here is the result." },
};
yield { type: "content_block_stop" };
},
});
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
sendActionExecutionStart: vi.fn(),
sendActionExecutionArgs: vi.fn(),
sendActionExecutionEnd: vi.fn(),
complete: vi.fn(),
};
let streamCallbackDone: Promise<void>;
mockEventSource.stream.mockImplementation((callback: any) => {
streamCallbackDone = callback(mockStream);
});
await adapter.process({
threadId: "test-thread",
messages: [systemMessage, userMessage],
actions: [
{
name: "known_tool",
description: "A known tool",
jsonSchema: '{"type":"object","properties":{}}',
},
],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Wait for async stream processing to complete
await streamCallbackDone!;
// Should NOT have sent action execution events for the unknown tool
expect(mockStream.sendActionExecutionStart).not.toHaveBeenCalled();
expect(mockStream.sendActionExecutionArgs).not.toHaveBeenCalled();
expect(mockStream.sendActionExecutionEnd).not.toHaveBeenCalled();
// Should still process the text block normally
expect(mockStream.sendTextMessageStart).toHaveBeenCalled();
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
messageId: "msg-1",
content: "Here is the result.",
});
expect(mockStream.sendTextMessageEnd).toHaveBeenCalled();
expect(mockStream.complete).toHaveBeenCalled();
});
it("should trigger fallback when only unknown tool_use blocks are returned", async () => {
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({
role: Role.user,
content: "Do something",
});
const toolExecution = new ActionExecutionMessage({
id: "tool-prev",
name: "someAction",
arguments: "{}",
});
const toolResult = new ResultMessage({
actionExecutionId: "tool-prev",
result: "Previous result",
});
// Mock Anthropic to return ONLY an unknown tool_use block
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
yield { type: "message_start", message: { id: "msg-1" } };
yield {
type: "content_block_start",
content_block: {
type: "tool_use",
id: "tool-unknown",
name: "nonexistent_tool",
},
};
yield {
type: "content_block_delta",
delta: { type: "input_json_delta", partial_json: "{}" },
};
yield { type: "content_block_stop" };
},
});
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
sendActionExecutionStart: vi.fn(),
sendActionExecutionArgs: vi.fn(),
sendActionExecutionEnd: vi.fn(),
complete: vi.fn(),
};
let streamCallbackDone: Promise<void>;
mockEventSource.stream.mockImplementation((callback: any) => {
streamCallbackDone = callback(mockStream);
});
await adapter.process({
threadId: "test-thread",
messages: [systemMessage, userMessage, toolExecution, toolResult],
actions: [
{
name: "known_tool",
description: "A known tool",
jsonSchema: '{"type":"object","properties":{}}',
},
],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Wait for async stream processing to complete
await streamCallbackDone!;
// Should NOT have sent action execution events
expect(mockStream.sendActionExecutionStart).not.toHaveBeenCalled();
// Should trigger fallback since hasReceivedContent should be false
expect(mockStream.sendTextMessageStart).toHaveBeenCalled();
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
messageId: expect.any(String),
content: "Previous result",
});
expect(mockStream.sendTextMessageEnd).toHaveBeenCalled();
});
});
});
describe("AnthropicAdapter max_tokens default", () => {
let mockAnthropicCreate: any;
let mockEventSource: any;
beforeEach(() => {
vi.clearAllMocks();
});
it("should default max_tokens to 4096 when not specified", async () => {
const mockAnthropic = {
messages: {
create: vi.fn(),
},
};
const adapter = new AnthropicAdapter({ anthropic: mockAnthropic as any });
mockAnthropicCreate = mockAnthropic.messages.create;
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {},
});
mockEventSource = {
stream: vi.fn((callback) => {
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
sendActionExecutionStart: vi.fn(),
sendActionExecutionArgs: vi.fn(),
sendActionExecutionEnd: vi.fn(),
complete: vi.fn(),
};
callback(mockStream);
return Promise.resolve();
}),
};
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({ role: Role.user, content: "Hello" });
await adapter.process({
threadId: "test-thread",
messages: [systemMessage, userMessage],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
const createCallArgs = mockAnthropicCreate.mock.calls[0][0];
expect(createCallArgs.max_tokens).toBe(4096);
});
it("should use provided maxTokens when specified", async () => {
const mockAnthropic = {
messages: {
create: vi.fn(),
},
};
const adapter = new AnthropicAdapter({ anthropic: mockAnthropic as any });
mockAnthropicCreate = mockAnthropic.messages.create;
mockAnthropicCreate.mockResolvedValue({
[Symbol.asyncIterator]: async function* () {},
});
mockEventSource = {
stream: vi.fn((callback) => {
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
sendActionExecutionStart: vi.fn(),
sendActionExecutionArgs: vi.fn(),
sendActionExecutionEnd: vi.fn(),
complete: vi.fn(),
};
callback(mockStream);
return Promise.resolve();
}),
};
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({ role: Role.user, content: "Hello" });
await adapter.process({
threadId: "test-thread",
messages: [systemMessage, userMessage],
actions: [],
eventSource: mockEventSource,
forwardedParameters: { maxTokens: 8192 },
});
const createCallArgs = mockAnthropicCreate.mock.calls[0][0];
expect(createCallArgs.max_tokens).toBe(8192);
});
});
@@ -0,0 +1,301 @@
import { describe, it, expect } from "vitest";
import { limitMessagesToTokenCount } from "../../../src/service-adapters/anthropic/utils";
// Helper to build messages for testing. The token counter is length/3,
// so we can control token counts via string length.
function textUser(text: string) {
return { role: "user", content: [{ type: "text", text }] };
}
function textAssistant(text: string) {
return { role: "assistant", content: [{ type: "text", text }] };
}
function toolUseAssistant(id: string, name = "my_tool", input = {}) {
return {
role: "assistant",
content: [{ type: "tool_use", id, name, input }],
};
}
function toolResultUser(toolUseId: string, content = "result") {
return {
role: "user",
content: [{ type: "tool_result", tool_use_id: toolUseId, content }],
};
}
function mixedAssistant(blocks: any[]) {
return { role: "assistant", content: blocks };
}
function mixedUser(blocks: any[]) {
return { role: "user", content: blocks };
}
describe("limitMessagesToTokenCount - orphan handling", () => {
// Use a high token limit so trimming doesn't kick in for these tests
const HIGH_LIMIT = 999999;
it("preserves matched tool_use / tool_result pairs", () => {
const messages = [
textUser("hello"),
toolUseAssistant("t1", "tool_a"),
toolResultUser("t1", "done"),
textAssistant("ok"),
];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
// All four messages should survive
expect(result).toHaveLength(4);
// The tool_use and tool_result should still be present
const toolUse = result.find(
(m: any) =>
m.role === "assistant" &&
Array.isArray(m.content) &&
m.content.some((b: any) => b.type === "tool_use"),
);
const toolResult = result.find(
(m: any) =>
m.role === "user" &&
Array.isArray(m.content) &&
m.content.some((b: any) => b.type === "tool_result"),
);
expect(toolUse).toBeDefined();
expect(toolResult).toBeDefined();
});
it("removes orphaned tool_result when tool_use was trimmed", () => {
// Simulate: tool_use message was removed by token trimming, leaving
// a tool_result without a matching tool_use.
const messages = [
textUser("hello"),
// no toolUseAssistant for "t1"
toolResultUser("t1", "orphaned result"),
textAssistant("ok"),
];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
// The orphaned tool_result message should be gone
const hasToolResult = result.some(
(m: any) =>
m.role === "user" &&
Array.isArray(m.content) &&
m.content.some((b: any) => b.type === "tool_result"),
);
expect(hasToolResult).toBe(false);
expect(result).toHaveLength(2); // textUser + textAssistant
});
it("removes orphaned tool_use when tool_result was trimmed", () => {
// Simulate: tool_result message was removed by token trimming, leaving
// a tool_use without a matching tool_result.
const messages = [
textUser("hello"),
toolUseAssistant("t1", "tool_a"),
// no toolResultUser for "t1"
textAssistant("ok"),
];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
// The orphaned tool_use message should be gone
const hasToolUse = result.some(
(m: any) =>
m.role === "assistant" &&
Array.isArray(m.content) &&
m.content.some((b: any) => b.type === "tool_use"),
);
expect(hasToolUse).toBe(false);
expect(result).toHaveLength(2); // textUser + textAssistant
});
it("retains non-orphaned blocks in mixed-content messages", () => {
// Assistant message has both a text block and an orphaned tool_use
const messages = [
textUser("hello"),
mixedAssistant([
{ type: "text", text: "thinking..." },
{ type: "tool_use", id: "t1", name: "tool_a", input: {} },
]),
// no tool_result for t1
textAssistant("done"),
];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
// The assistant message should survive with only the text block
const assistantMixed = result.find(
(m: any) =>
m.role === "assistant" &&
Array.isArray(m.content) &&
m.content.some(
(b: any) => b.type === "text" && b.text === "thinking...",
),
);
expect(assistantMixed).toBeDefined();
expect(assistantMixed.content).toHaveLength(1);
expect(assistantMixed.content[0].type).toBe("text");
});
it("retains non-orphaned blocks in mixed user messages", () => {
// User message has both a text block and an orphaned tool_result
const messages = [
textUser("hello"),
mixedUser([
{ type: "text", text: "here is context" },
{ type: "tool_result", tool_use_id: "t_missing", content: "orphan" },
]),
textAssistant("ok"),
];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
const userMixed = result.find(
(m: any) =>
m.role === "user" &&
Array.isArray(m.content) &&
m.content.some(
(b: any) => b.type === "text" && b.text === "here is context",
),
);
expect(userMixed).toBeDefined();
expect(userMixed.content).toHaveLength(1);
expect(userMixed.content[0].type).toBe("text");
});
it("drops message entirely when all blocks are orphaned", () => {
const messages = [
textUser("hello"),
mixedUser([
{ type: "tool_result", tool_use_id: "t_a", content: "orphan a" },
{ type: "tool_result", tool_use_id: "t_b", content: "orphan b" },
]),
textAssistant("ok"),
];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
expect(result).toHaveLength(2);
expect(result[0].role).toBe("user");
expect(result[1].role).toBe("assistant");
});
it("drops assistant message entirely when all tool_use blocks are orphaned", () => {
const messages = [
textUser("hello"),
mixedAssistant([
{ type: "tool_use", id: "t_x", name: "tool_x", input: {} },
{ type: "tool_use", id: "t_y", name: "tool_y", input: {} },
]),
// no tool_results for either
textAssistant("done"),
];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
expect(result).toHaveLength(2);
});
it("does not mutate the original messages array or message objects", () => {
const originalContent = [
{ type: "text", text: "context" },
{ type: "tool_result", tool_use_id: "t_orphan", content: "orphan" },
];
const userMsg = { role: "user", content: [...originalContent] };
const messages = [textUser("hello"), userMsg, textAssistant("ok")];
const result = limitMessagesToTokenCount(
messages,
[],
"claude-3",
HIGH_LIMIT,
);
// Original message should still have both blocks
expect(userMsg.content).toHaveLength(2);
expect(userMsg.content[1].type).toBe("tool_result");
// Original messages array should still have 3 entries
expect(messages).toHaveLength(3);
// Result should have the filtered version
const filtered = result.find(
(m: any) =>
m.role === "user" &&
Array.isArray(m.content) &&
m.content.some((b: any) => b.text === "context"),
);
expect(filtered).toBeDefined();
expect(filtered.content).toHaveLength(1);
});
it("handles token trimming that creates orphans via cutoff", () => {
// Build messages where token trimming will cut off early messages,
// leaving orphaned tool_result for a tool_use that got trimmed.
// Each char ~0.33 tokens, so 300 chars ~ 100 tokens
const longText = "x".repeat(300);
const messages = [
toolUseAssistant("t_old"),
toolResultUser("t_old", "old result"),
textUser(longText),
textAssistant(longText),
toolUseAssistant("t_new"),
toolResultUser("t_new", "new result"),
];
// Set a limit that keeps only the last few messages, trimming t_old's tool_use
const result = limitMessagesToTokenCount(messages, [], "claude-3", 300);
// t_old's tool_use should have been trimmed by the token limit,
// and then t_old's tool_result should be cleaned up as orphaned
const hasOldResult = result.some(
(m: any) =>
m.role === "user" &&
Array.isArray(m.content) &&
m.content.some(
(b: any) => b.type === "tool_result" && b.tool_use_id === "t_old",
),
);
expect(hasOldResult).toBe(false);
});
});
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OpenAIProviderSettings } from "@ai-sdk/openai";
import { GroqAdapter } from "../../../src/service-adapters/groq/groq-adapter";
import { Groq } from "groq-sdk";
// Groq uses createOpenAI (OpenAI-compatible API), so we check against
// OpenAIProviderSettings. Same exhaustiveness guard as the OpenAI test.
type ForwardedGroqKeys = "baseURL" | "apiKey" | "headers" | "fetch";
// Keys we set ourselves or that don't apply to Groq.
type ControlledGroqKeys = "name" | "organization" | "project";
type _exhaustive =
Exclude<
keyof OpenAIProviderSettings,
ForwardedGroqKeys | ControlledGroqKeys
> extends never
? true
: {
error: "OpenAIProviderSettings has unhandled keys";
unhandled: Exclude<
keyof OpenAIProviderSettings,
ForwardedGroqKeys | ControlledGroqKeys
>;
};
const _check: _exhaustive = true;
const { mockProviderFn, mockCreateOpenAI } = vi.hoisted(() => {
const mockProviderFn = vi.fn().mockReturnValue({ modelId: "test-model" });
const mockCreateOpenAI = vi.fn().mockReturnValue(mockProviderFn);
return { mockProviderFn, mockCreateOpenAI };
});
vi.mock("@ai-sdk/openai", async (importOriginal) => {
const actual = await importOriginal<typeof import("@ai-sdk/openai")>();
return { ...actual, createOpenAI: mockCreateOpenAI };
});
vi.mock("groq-sdk", () => {
return {
Groq: class MockGroq {
baseURL: string;
apiKey: string;
_options: Record<string, any>;
chat = { completions: { create: vi.fn() } };
constructor(opts: any = {}) {
this.baseURL = opts.baseURL ?? "https://api.groq.com";
this.apiKey = opts.apiKey ?? "default-key";
this._options = {
defaultHeaders: opts.defaultHeaders,
fetch: opts.fetch,
...opts,
};
}
},
};
});
describe("GroqAdapter.getLanguageModel()", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("forwards all provider-relevant options from the Groq SDK client", () => {
const customFetch = vi.fn();
const groq = new Groq({
apiKey: "gsk-test",
baseURL: "https://custom-groq.example.com",
defaultHeaders: { "x-groq": "value" },
fetch: customFetch,
});
const adapter = new GroqAdapter({
groq,
model: "llama-3.3-70b-versatile",
});
adapter.getLanguageModel();
expect(mockCreateOpenAI).toHaveBeenCalledOnce();
const settings = mockCreateOpenAI.mock.calls[0][0];
expect(settings.baseURL).toBe("https://custom-groq.example.com");
expect(settings.apiKey).toBe("gsk-test");
expect(settings.headers).toEqual({ "x-groq": "value" });
expect(settings.fetch).toBe(customFetch);
expect(settings.name).toBe("groq");
expect(mockProviderFn).toHaveBeenCalledWith("llama-3.3-70b-versatile");
});
it("works with default Groq config (no custom options)", () => {
const groq = new Groq({ apiKey: "gsk-default" });
const adapter = new GroqAdapter({ groq });
adapter.getLanguageModel();
const settings = mockCreateOpenAI.mock.calls[0][0];
expect(settings.baseURL).toBe("https://api.groq.com");
expect(settings.apiKey).toBe("gsk-default");
expect(settings.name).toBe("groq");
});
});
@@ -0,0 +1,294 @@
describe("OpenAI Adapter - Allowlist Approach", () => {
it("should filter out tool_result messages with no corresponding tool_call ID", () => {
// Setup test data
const validToolCallIds = new Set<string>(["valid-id-1", "valid-id-2"]);
// Messages to filter - valid and invalid ones
const messages = [
{ type: "text", role: "user", content: "Hello" },
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "result1",
},
{
type: "tool_result",
actionExecutionId: "invalid-id",
result: "invalid",
},
{
type: "tool_result",
actionExecutionId: "valid-id-2",
result: "result2",
},
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "duplicate",
}, // Duplicate ID
];
// Implement the filtering logic, similar to the adapter
const filteredMessages = messages.filter((message) => {
if (message.type === "tool_result") {
// Skip if there's no corresponding tool_call
if (!validToolCallIds.has(message.actionExecutionId)) {
return false;
}
// Remove this ID from valid IDs so we don't process duplicates
validToolCallIds.delete(message.actionExecutionId);
}
// Keep all non-tool-result messages
return true;
});
// Verify results
expect(filteredMessages.length).toBe(3); // text + 2 valid tool results (no duplicates or invalid)
// Valid results should be included
expect(
filteredMessages.some(
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
),
).toBe(true);
expect(
filteredMessages.some(
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-2",
),
).toBe(true);
// Invalid result should be excluded
expect(
filteredMessages.some(
(m) => m.type === "tool_result" && m.actionExecutionId === "invalid-id",
),
).toBe(false);
// Duplicate should be excluded - we used a different approach than Anthropic
const validId1Count = filteredMessages.filter(
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
).length;
expect(validId1Count).toBe(1);
});
it("should maintain correct order of messages when filtering", () => {
// Setup test data
const validToolCallIds = new Set<string>(["tool-1", "tool-2", "tool-3"]);
// Test with a complex conversation pattern
const messages = [
{ type: "text", role: "user", content: "Initial message" },
{ type: "text", role: "assistant", content: "I'll help with that" },
{ type: "tool_call", id: "tool-1", name: "firstTool" },
{ type: "tool_result", actionExecutionId: "tool-1", result: "result1" },
{ type: "text", role: "assistant", content: "Got the first result" },
{ type: "tool_call", id: "tool-2", name: "secondTool" },
{ type: "tool_result", actionExecutionId: "tool-2", result: "result2" },
{
type: "tool_result",
actionExecutionId: "invalid-id",
result: "invalid-result",
},
{ type: "tool_call", id: "tool-3", name: "thirdTool" },
{
type: "tool_result",
actionExecutionId: "tool-1",
result: "duplicate-result",
}, // Duplicate
{ type: "tool_result", actionExecutionId: "tool-3", result: "result3" },
{ type: "text", role: "user", content: "Final message" },
];
// Apply OpenAI's filter approach (using filter instead of loop)
const filteredMessages = messages.filter((message) => {
if (message.type === "tool_result") {
// Skip if there's no corresponding tool_call
if (!validToolCallIds.has(message.actionExecutionId)) {
return false;
}
// Remove this ID from valid IDs so we don't process duplicates
validToolCallIds.delete(message.actionExecutionId);
}
// Keep all non-tool-result messages
return true;
});
// Verify results
expect(filteredMessages.length).toBe(10); // 12 original - 2 filtered out
// Check that the message order is preserved
expect(filteredMessages[0].type).toBe("text"); // Initial user message
expect(filteredMessages[0].content).toBe("Initial message");
expect(filteredMessages[1].type).toBe("text"); // Assistant response
expect(filteredMessages[2].type).toBe("tool_call"); // First tool
expect(filteredMessages[3].type).toBe("tool_result"); // First result
expect(filteredMessages[3].actionExecutionId).toBe("tool-1");
expect(filteredMessages[4].type).toBe("text"); // Assistant comment
expect(filteredMessages[5].type).toBe("tool_call"); // Second tool
expect(filteredMessages[6].type).toBe("tool_result"); // Second result
expect(filteredMessages[6].actionExecutionId).toBe("tool-2");
expect(filteredMessages[7].type).toBe("tool_call"); // Third tool
expect(filteredMessages[8].type).toBe("tool_result"); // Third result
expect(filteredMessages[8].actionExecutionId).toBe("tool-3");
expect(filteredMessages[9].type).toBe("text"); // Final user message
// Each valid tool result should appear exactly once
const toolResultCounts = new Map();
filteredMessages.forEach((message) => {
if (message.type === "tool_result") {
const id = message.actionExecutionId;
toolResultCounts.set(id, (toolResultCounts.get(id) || 0) + 1);
}
});
expect(toolResultCounts.size).toBe(3); // Should have 3 different tool results
expect(toolResultCounts.get("tool-1")).toBe(1);
expect(toolResultCounts.get("tool-2")).toBe(1);
expect(toolResultCounts.get("tool-3")).toBe(1);
expect(toolResultCounts.has("invalid-id")).toBe(false);
});
it("should handle empty message array", () => {
const validToolCallIds = new Set<string>(["valid-id-1", "valid-id-2"]);
const messages = [];
// Apply OpenAI's filter approach
const filteredMessages = messages.filter((message) => {
if (message.type === "tool_result") {
if (!validToolCallIds.has(message.actionExecutionId)) {
return false;
}
validToolCallIds.delete(message.actionExecutionId);
}
return true;
});
expect(filteredMessages.length).toBe(0);
});
it("should handle edge cases with mixed message types", () => {
// Setup test data with various message types
const validToolCallIds = new Set<string>(["valid-id-1"]);
const messages = [
{ type: "text", role: "user", content: "Hello" },
{ type: "image", url: "https://example.com/image.jpg" }, // Non-tool message type
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "result1",
},
{ type: "custom", data: { key: "value" } }, // Another custom type
{
type: "tool_result",
actionExecutionId: "valid-id-1",
result: "duplicate",
}, // Duplicate
{ type: "null", value: null }, // Edge case
{ type: "undefined" }, // Edge case
];
// Apply OpenAI's filter approach
const filteredMessages = messages.filter((message) => {
if (message.type === "tool_result") {
if (!validToolCallIds.has(message.actionExecutionId)) {
return false;
}
validToolCallIds.delete(message.actionExecutionId);
}
return true;
});
// Should have all non-tool_result messages + 1 valid tool_result
expect(filteredMessages.length).toBe(6); // 7 original - 1 duplicate
// Valid tool_result should be included exactly once
const toolResults = filteredMessages.filter(
(m) => m.type === "tool_result",
);
expect(toolResults.length).toBe(1);
expect(toolResults[0].actionExecutionId).toBe("valid-id-1");
// All non-tool_result messages should be preserved
expect(filteredMessages.filter((m) => m.type === "text").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "image").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "custom").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "null").length).toBe(1);
expect(filteredMessages.filter((m) => m.type === "undefined").length).toBe(
1,
);
});
it("should properly handle multiple duplicate tool results", () => {
// Setup test data with multiple duplicates
const validToolCallIds = new Set<string>(["tool-1", "tool-2"]);
const messages = [
{ type: "text", role: "user", content: "Initial prompt" },
{ type: "tool_call", id: "tool-1", name: "firstTool" },
{
type: "tool_result",
actionExecutionId: "tool-1",
result: "first-result",
},
{
type: "tool_result",
actionExecutionId: "tool-1",
result: "duplicate-1",
}, // Duplicate 1
{ type: "tool_call", id: "tool-2", name: "secondTool" },
{
type: "tool_result",
actionExecutionId: "tool-1",
result: "duplicate-2",
}, // Duplicate 2
{
type: "tool_result",
actionExecutionId: "tool-2",
result: "second-result",
},
{
type: "tool_result",
actionExecutionId: "tool-2",
result: "duplicate-3",
}, // Duplicate 3
{
type: "tool_result",
actionExecutionId: "tool-1",
result: "duplicate-4",
}, // Duplicate 4
];
// Apply OpenAI's filter approach
const filteredMessages = messages.filter((message) => {
if (message.type === "tool_result") {
if (!validToolCallIds.has(message.actionExecutionId)) {
return false;
}
validToolCallIds.delete(message.actionExecutionId);
}
return true;
});
// Should have text + tool calls + only the first occurrence of each tool result
expect(filteredMessages.length).toBe(5);
// Check that only the first occurrence of each tool result is kept
const toolResults = filteredMessages.filter(
(m) => m.type === "tool_result",
);
expect(toolResults.length).toBe(2);
expect(toolResults[0].actionExecutionId).toBe("tool-1");
expect(toolResults[0].result).toBe("first-result"); // First occurrence should be kept
expect(toolResults[1].actionExecutionId).toBe("tool-2");
expect(toolResults[1].result).toBe("second-result"); // First occurrence should be kept
});
});
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OpenAIProviderSettings } from "@ai-sdk/openai";
import { OpenAIAdapter } from "../../../src/service-adapters/openai/openai-adapter";
import OpenAI from "openai";
// Keys from OpenAIProviderSettings that we forward from the OpenAI SDK client.
// If @ai-sdk/openai adds new keys, the type assertion below will fail at
// compile time, forcing us to decide whether to forward them.
type ForwardedOpenAIKeys =
| "baseURL"
| "apiKey"
| "organization"
| "project"
| "headers"
| "fetch";
// We set `name` ourselves (not forwarded from the SDK client).
type ControlledOpenAIKeys = "name";
// Compile-time exhaustiveness check: every key in OpenAIProviderSettings must
// be accounted for in either ForwardedOpenAIKeys or ControlledOpenAIKeys.
// If this line errors, a new key was added to OpenAIProviderSettings that
// needs to be handled.
type _exhaustive =
Exclude<
keyof OpenAIProviderSettings,
ForwardedOpenAIKeys | ControlledOpenAIKeys
> extends never
? true
: {
error: "OpenAIProviderSettings has unhandled keys";
unhandled: Exclude<
keyof OpenAIProviderSettings,
ForwardedOpenAIKeys | ControlledOpenAIKeys
>;
};
const _check: _exhaustive = true;
const { mockProviderFn, mockCreateOpenAI } = vi.hoisted(() => {
const mockProviderFn = vi.fn().mockReturnValue({ modelId: "test-model" });
const mockCreateOpenAI = vi.fn().mockReturnValue(mockProviderFn);
return { mockProviderFn, mockCreateOpenAI };
});
vi.mock("@ai-sdk/openai", async (importOriginal) => {
const actual = await importOriginal<typeof import("@ai-sdk/openai")>();
return { ...actual, createOpenAI: mockCreateOpenAI };
});
vi.mock("openai", () => {
return {
default: class MockOpenAI {
baseURL: string;
apiKey: string;
organization: string | null;
project: string | null;
_options: Record<string, any>;
beta = { chat: { completions: { stream: vi.fn() } } };
constructor(opts: any = {}) {
this.baseURL = opts.baseURL ?? "https://api.openai.com/v1";
this.apiKey = opts.apiKey ?? "default-key";
this.organization = opts.organization ?? null;
this.project = opts.project ?? null;
this._options = {
defaultHeaders: opts.defaultHeaders,
defaultQuery: opts.defaultQuery,
fetch: opts.fetch,
...opts,
};
}
},
};
});
describe("OpenAIAdapter.getLanguageModel()", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("forwards all provider-relevant options from the OpenAI SDK client", () => {
const customFetch = vi.fn();
const openai = new OpenAI({
apiKey: "azure-key",
baseURL: "https://myinstance.openai.azure.com/openai/deployments/gpt-4o",
organization: "org-123",
project: "proj-456",
defaultHeaders: { "api-key": "azure-key" },
defaultQuery: { "api-version": "2024-04-01-preview" },
fetch: customFetch,
});
const adapter = new OpenAIAdapter({ openai, model: "gpt-4o" });
adapter.getLanguageModel();
expect(mockCreateOpenAI).toHaveBeenCalledOnce();
const settings = mockCreateOpenAI.mock.calls[0][0];
expect(settings.baseURL).toBe(
"https://myinstance.openai.azure.com/openai/deployments/gpt-4o",
);
expect(settings.apiKey).toBe("azure-key");
expect(settings.organization).toBe("org-123");
expect(settings.project).toBe("proj-456");
expect(settings.headers).toEqual({ "api-key": "azure-key" });
expect(settings.fetch).toBe(customFetch);
expect(mockProviderFn).toHaveBeenCalledWith("gpt-4o");
});
it("works with default OpenAI config (no custom options)", () => {
const openai = new OpenAI({ apiKey: "sk-test" });
const adapter = new OpenAIAdapter({ openai });
adapter.getLanguageModel();
const settings = mockCreateOpenAI.mock.calls[0][0];
expect(settings.baseURL).toBe("https://api.openai.com/v1");
expect(settings.apiKey).toBe("sk-test");
expect(settings.organization).toBeUndefined();
expect(settings.project).toBeUndefined();
});
});
@@ -0,0 +1,317 @@
// Mock the modules first
vi.mock("openai", () => {
function MockOpenAI() {}
return { default: MockOpenAI };
});
// Mock the OpenAIAdapter class to avoid the "new OpenAI()" issue
vi.mock("../../../src/service-adapters/openai/openai-adapter", () => {
class MockOpenAIAdapter {
_openai: any;
model: string = "gpt-4o";
keepSystemRole: boolean = false;
disableParallelToolCalls: boolean = false;
constructor() {
this._openai = {
beta: {
chat: {
completions: {
stream: vi.fn(),
},
},
},
};
}
get openai() {
return this._openai;
}
async process(request: any) {
// Mock implementation that calls our event source but doesn't do the actual processing
request.eventSource.stream(async (stream: any) => {
stream.complete();
return Promise.resolve();
});
return { threadId: request.threadId || "mock-thread-id" };
}
}
return { OpenAIAdapter: MockOpenAIAdapter };
});
// Mock the Message classes since they use TypeGraphQL decorators
vi.mock("../../../src/graphql/types/converted", () => {
// Create minimal implementations of the message classes
class MockTextMessage {
content: string;
role: string;
id: string;
constructor(options: { role: string; content: string }) {
this.role = options.role;
this.content = options.content;
this.id = "mock-text-" + Math.random().toString(36).slice(7);
}
isTextMessage() {
return true;
}
isImageMessage() {
return false;
}
isActionExecutionMessage() {
return false;
}
isResultMessage() {
return false;
}
}
class MockActionExecutionMessage {
id: string;
name: string;
arguments: string;
constructor(params: { id: string; name: string; arguments: string }) {
this.id = params.id;
this.name = params.name;
this.arguments = params.arguments;
}
isTextMessage() {
return false;
}
isImageMessage() {
return false;
}
isActionExecutionMessage() {
return true;
}
isResultMessage() {
return false;
}
}
class MockResultMessage {
actionExecutionId: string;
result: string;
id: string;
constructor(params: { actionExecutionId: string; result: string }) {
this.actionExecutionId = params.actionExecutionId;
this.result = params.result;
this.id = "mock-result-" + Math.random().toString(36).slice(7);
}
isTextMessage() {
return false;
}
isImageMessage() {
return false;
}
isActionExecutionMessage() {
return false;
}
isResultMessage() {
return true;
}
}
return {
TextMessage: MockTextMessage,
ActionExecutionMessage: MockActionExecutionMessage,
ResultMessage: MockResultMessage,
Role: {
assistant: "assistant",
developer: "developer",
system: "system",
tool: "tool",
user: "user",
},
};
});
// Now import the modules (vi.mock is hoisted above imports, so these get the mocked versions)
import { OpenAIAdapter } from "../../../src/service-adapters/openai/openai-adapter";
import {
TextMessage,
ActionExecutionMessage,
ResultMessage,
Role,
} from "../../../src/graphql/types/converted";
describe("OpenAIAdapter", () => {
let adapter: OpenAIAdapter;
let mockEventSource: any;
beforeEach(() => {
vi.clearAllMocks();
adapter = new OpenAIAdapter();
mockEventSource = {
stream: vi.fn((callback) => {
const mockStream = {
sendTextMessageStart: vi.fn(),
sendTextMessageContent: vi.fn(),
sendTextMessageEnd: vi.fn(),
sendActionExecutionStart: vi.fn(),
sendActionExecutionArgs: vi.fn(),
sendActionExecutionEnd: vi.fn(),
complete: vi.fn(),
};
callback(mockStream);
}),
};
});
describe("Tool ID handling", () => {
it("should filter out tool_result messages that don't have corresponding tool_call IDs", async () => {
// Create messages including one valid pair and one invalid tool_result
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({
role: Role.user,
content: "User message",
});
// Valid tool execution message
const validToolExecution = new ActionExecutionMessage({
id: "valid-tool-id",
name: "validTool",
arguments: '{"arg":"value"}',
});
// Valid result for the above tool
const validToolResult = new ResultMessage({
actionExecutionId: "valid-tool-id",
result: '{"result":"success"}',
});
// Invalid tool result with no corresponding tool execution
const invalidToolResult = new ResultMessage({
actionExecutionId: "invalid-tool-id",
result: '{"result":"failure"}',
});
// Spy on the process method to test it's called properly
const processSpy = vi.spyOn(adapter, "process");
await adapter.process({
threadId: "test-thread",
model: "gpt-4o",
messages: [
systemMessage,
userMessage,
validToolExecution,
validToolResult,
invalidToolResult,
],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Verify the process method was called
expect(processSpy).toHaveBeenCalledTimes(1);
// Verify the stream function was called
expect(mockEventSource.stream).toHaveBeenCalled();
});
it("should handle duplicate tool IDs by only using each once", async () => {
// Create messages including duplicate tool results for the same ID
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
// Valid tool execution message
const toolExecution = new ActionExecutionMessage({
id: "tool-id-1",
name: "someTool",
arguments: '{"arg":"value"}',
});
// Two results for the same tool ID
const firstToolResult = new ResultMessage({
actionExecutionId: "tool-id-1",
result: '{"result":"first"}',
});
const duplicateToolResult = new ResultMessage({
actionExecutionId: "tool-id-1",
result: '{"result":"duplicate"}',
});
// Spy on the process method to test it's called properly
const processSpy = vi.spyOn(adapter, "process");
await adapter.process({
threadId: "test-thread",
model: "gpt-4o",
messages: [
systemMessage,
toolExecution,
firstToolResult,
duplicateToolResult,
],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Verify the process method was called
expect(processSpy).toHaveBeenCalledTimes(1);
// Verify the stream function was called
expect(mockEventSource.stream).toHaveBeenCalled();
});
it("should call the stream method on eventSource", async () => {
// Create messages
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const userMessage = new TextMessage({
role: Role.user,
content: "User message",
});
await adapter.process({
threadId: "test-thread",
model: "gpt-4o",
messages: [systemMessage, userMessage],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
// Verify the stream function was called
expect(mockEventSource.stream).toHaveBeenCalled();
});
it("should return the provided threadId", async () => {
// Create a message
const systemMessage = new TextMessage({
role: Role.system,
content: "System message",
});
const result = await adapter.process({
threadId: "test-thread",
model: "gpt-4o",
messages: [systemMessage],
actions: [],
eventSource: mockEventSource,
forwardedParameters: {},
});
expect(result.threadId).toBe("test-thread");
});
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from "vitest";
import { getSdkClientOptions } from "../../../src/service-adapters/shared/sdk-client-utils";
describe("getSdkClientOptions()", () => {
it("extracts defaultHeaders and fetch from _options", () => {
const customFetch = vi.fn();
const client = {
_options: {
defaultHeaders: { "x-api-key": "secret" },
fetch: customFetch,
},
};
const result = getSdkClientOptions(client);
expect(result.defaultHeaders).toEqual({ "x-api-key": "secret" });
expect(result.fetch).toBe(customFetch);
});
it("returns empty object when _options is missing", () => {
const client = { baseURL: "https://api.example.com" };
const result = getSdkClientOptions(client);
expect(result).toEqual({});
});
it("returns empty object when _options is null", () => {
const client = { _options: null };
const result = getSdkClientOptions(client);
expect(result).toEqual({});
});
it("returns empty object when _options is a primitive", () => {
const client = { _options: "not-an-object" };
const result = getSdkClientOptions(client);
expect(result).toEqual({});
});
});
+8
View File
@@ -0,0 +1,8 @@
// Import reflect-metadata to support TypeGraphQL
import "reflect-metadata";
import { vi } from "vitest";
// Suppress console output during tests
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node", "vitest/globals"],
"esModuleInterop": true,
"noEmit": true,
"allowJs": true
},
"include": ["./**/*.ts"]
}