304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
|
|
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
||
|
|
import { OPENROUTER_MODELS } from "../src/types.js";
|
||
|
|
import type { AnthropicRequest, AnthropicResponse, OpenRouterModel } from "../src/types.js";
|
||
|
|
import { createProxyServer } from "../src/proxy-server.js";
|
||
|
|
import type { ProxyServer } from "../src/types.js";
|
||
|
|
|
||
|
|
// Load .env file
|
||
|
|
import { join } from "node:path";
|
||
|
|
const envPath = join(import.meta.dir, "..", ".env");
|
||
|
|
const envFile = await Bun.file(envPath).text();
|
||
|
|
for (const line of envFile.split("\n")) {
|
||
|
|
if (line.startsWith("#") || !line.includes("=")) continue;
|
||
|
|
const [key, ...values] = line.split("=");
|
||
|
|
process.env[key.trim()] = values.join("=").trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test configuration
|
||
|
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||
|
|
if (!OPENROUTER_API_KEY) {
|
||
|
|
throw new Error("OPENROUTER_API_KEY not found in .env file");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test models (top 5 priority)
|
||
|
|
const TEST_MODELS: OpenRouterModel[] = [
|
||
|
|
"x-ai/grok-code-fast-1",
|
||
|
|
"openai/gpt-5-codex",
|
||
|
|
"minimax/minimax-m2",
|
||
|
|
"z-ai/glm-4.6", // Correct OpenRouter ID (not zhipu-ai)
|
||
|
|
"qwen/qwen3-vl-235b-a22b-instruct",
|
||
|
|
];
|
||
|
|
|
||
|
|
// Active proxy servers (for cleanup)
|
||
|
|
const activeProxies: ProxyServer[] = [];
|
||
|
|
|
||
|
|
// Helper: Start proxy server
|
||
|
|
async function startTestProxy(
|
||
|
|
model: OpenRouterModel,
|
||
|
|
port: number
|
||
|
|
): Promise<ProxyServer> {
|
||
|
|
const proxy = await createProxyServer(port, OPENROUTER_API_KEY!, model);
|
||
|
|
activeProxies.push(proxy);
|
||
|
|
return proxy;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper: Make Anthropic API request to proxy (handles both streaming and non-streaming)
|
||
|
|
async function makeAnthropicRequest(
|
||
|
|
proxyUrl: string,
|
||
|
|
messages: Array<{ role: "user" | "assistant"; content: string }>
|
||
|
|
): Promise<AnthropicResponse> {
|
||
|
|
const request: AnthropicRequest = {
|
||
|
|
model: "claude-sonnet-4.5", // Fake model name - proxy will use OpenRouter model
|
||
|
|
messages,
|
||
|
|
max_tokens: 500,
|
||
|
|
temperature: 0.7,
|
||
|
|
stream: true, // Use streaming (proxy always uses streaming to OpenRouter)
|
||
|
|
};
|
||
|
|
|
||
|
|
const response = await fetch(`${proxyUrl}/v1/messages`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
"anthropic-version": "2023-06-01",
|
||
|
|
},
|
||
|
|
body: JSON.stringify(request),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const error = await response.text();
|
||
|
|
throw new Error(`Proxy request failed: ${response.status} ${error}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle streaming response (SSE)
|
||
|
|
const contentType = response.headers.get("content-type") || "";
|
||
|
|
if (contentType.includes("text/event-stream")) {
|
||
|
|
// Parse SSE stream to build response
|
||
|
|
const reader = response.body?.getReader();
|
||
|
|
if (!reader) throw new Error("No response body");
|
||
|
|
|
||
|
|
const decoder = new TextDecoder();
|
||
|
|
let buffer = "";
|
||
|
|
let messageId = "";
|
||
|
|
let content: any[] = [];
|
||
|
|
let usage = { input_tokens: 0, output_tokens: 0 };
|
||
|
|
let stopReason = null;
|
||
|
|
let textContent = "";
|
||
|
|
|
||
|
|
while (true) {
|
||
|
|
const { done, value } = await reader.read();
|
||
|
|
if (done) break;
|
||
|
|
|
||
|
|
buffer += decoder.decode(value, { stream: true });
|
||
|
|
const lines = buffer.split("\n");
|
||
|
|
buffer = lines.pop() || "";
|
||
|
|
|
||
|
|
for (const line of lines) {
|
||
|
|
if (!line.trim() || line.startsWith(":")) continue;
|
||
|
|
const dataMatch = line.match(/^(?:event: \w+\n)?data: (.*)$/);
|
||
|
|
if (!dataMatch) continue;
|
||
|
|
|
||
|
|
const dataStr = dataMatch[1];
|
||
|
|
if (dataStr === "[DONE]") break;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const event = JSON.parse(dataStr);
|
||
|
|
|
||
|
|
if (event.type === "message_start") {
|
||
|
|
messageId = event.message.id;
|
||
|
|
} else if (event.type === "content_block_delta") {
|
||
|
|
if (event.delta.type === "text_delta") {
|
||
|
|
textContent += event.delta.text;
|
||
|
|
}
|
||
|
|
} else if (event.type === "message_delta") {
|
||
|
|
stopReason = event.delta.stop_reason;
|
||
|
|
if (event.usage) {
|
||
|
|
usage.input_tokens = event.usage.input_tokens || 0;
|
||
|
|
usage.output_tokens = event.usage.output_tokens || 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
// Skip unparseable chunks
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
content.push({ type: "text", text: textContent });
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: messageId,
|
||
|
|
type: "message",
|
||
|
|
role: "assistant",
|
||
|
|
content,
|
||
|
|
model: "test-model",
|
||
|
|
stop_reason: stopReason,
|
||
|
|
usage,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback to JSON response (if OpenRouter returns non-streaming)
|
||
|
|
return (await response.json()) as AnthropicResponse;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cleanup after each test
|
||
|
|
afterEach(async () => {
|
||
|
|
for (const proxy of activeProxies) {
|
||
|
|
await proxy.shutdown();
|
||
|
|
}
|
||
|
|
activeProxies.length = 0;
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Claudish Integration Tests", () => {
|
||
|
|
test("should have valid OpenRouter API key", () => {
|
||
|
|
expect(OPENROUTER_API_KEY).toBeDefined();
|
||
|
|
expect(OPENROUTER_API_KEY).toStartWith("sk-or-v1-");
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Proxy Server", () => {
|
||
|
|
test("should start proxy server on specified port", async () => {
|
||
|
|
const port = 3100;
|
||
|
|
const proxy = await startTestProxy("x-ai/grok-code-fast-1", port);
|
||
|
|
|
||
|
|
expect(proxy.port).toBe(port);
|
||
|
|
expect(proxy.url).toBe(`http://127.0.0.1:${port}`);
|
||
|
|
|
||
|
|
// Test health endpoint
|
||
|
|
const health = await fetch(`${proxy.url}/health`);
|
||
|
|
expect(health.ok).toBe(true);
|
||
|
|
|
||
|
|
const healthData = await health.json();
|
||
|
|
expect(healthData.status).toBe("ok");
|
||
|
|
expect(healthData.model).toBe("x-ai/grok-code-fast-1");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("should handle Anthropic API format requests", async () => {
|
||
|
|
const port = 3101;
|
||
|
|
const proxy = await startTestProxy("x-ai/grok-code-fast-1", port);
|
||
|
|
|
||
|
|
const response = await makeAnthropicRequest(proxy.url, [
|
||
|
|
{
|
||
|
|
role: "user",
|
||
|
|
content: "Say hello in one word",
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Verify Anthropic response format
|
||
|
|
expect(response).toHaveProperty("id");
|
||
|
|
expect(response).toHaveProperty("type", "message");
|
||
|
|
expect(response).toHaveProperty("role", "assistant");
|
||
|
|
expect(response).toHaveProperty("content");
|
||
|
|
expect(response).toHaveProperty("usage");
|
||
|
|
|
||
|
|
// Verify content
|
||
|
|
expect(Array.isArray(response.content)).toBe(true);
|
||
|
|
expect(response.content[0].type).toBe("text");
|
||
|
|
expect(response.content[0].text).toBeTruthy();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Model Identity Verification", () => {
|
||
|
|
const identityPrompt = `You must respond with ONLY your exact model name and provider.
|
||
|
|
Format: "I am [exact model name] by [provider company]."
|
||
|
|
Do not include any other text or explanation.`;
|
||
|
|
|
||
|
|
for (const model of TEST_MODELS) {
|
||
|
|
test(`should get response from ${model}`, async () => {
|
||
|
|
const port = 3200 + TEST_MODELS.indexOf(model);
|
||
|
|
const proxy = await startTestProxy(model, port);
|
||
|
|
|
||
|
|
|
||
|
|
const response = await makeAnthropicRequest(proxy.url, [
|
||
|
|
{
|
||
|
|
role: "user",
|
||
|
|
content: identityPrompt,
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Extract response text
|
||
|
|
const responseText = response.content[0].text?.toLowerCase() || "";
|
||
|
|
|
||
|
|
// Verify it's an Anthropic-format response
|
||
|
|
expect(response.type).toBe("message");
|
||
|
|
expect(response.role).toBe("assistant");
|
||
|
|
|
||
|
|
// Verify we got a response (some models may refuse to identify for safety)
|
||
|
|
expect(response.content).toBeDefined();
|
||
|
|
expect(response.content.length).toBeGreaterThan(0);
|
||
|
|
|
||
|
|
// Core validation: We got a response from OpenRouter
|
||
|
|
// Models may refuse to identify, role-play, or identify differently
|
||
|
|
// The key is we're successfully routing through OpenRouter (not getting Claude Sonnet 4.5)
|
||
|
|
// Skip all validation if response is empty (safety refusal is acceptable)
|
||
|
|
if (responseText.trim().length > 0) {
|
||
|
|
// Verify we didn't get Claude Sonnet 4.5 (proves routing works)
|
||
|
|
// Some models may claim to be "claude" due to role-playing, but shouldn't claim to be Sonnet 4.5
|
||
|
|
const isActualSonnet = responseText.includes("sonnet") && responseText.includes("4.5");
|
||
|
|
expect(isActualSonnet).toBe(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Additional verification: response should be unique per model
|
||
|
|
expect(response.usage.input_tokens).toBeGreaterThan(0);
|
||
|
|
expect(response.usage.output_tokens).toBeGreaterThan(0);
|
||
|
|
}, 30000); // 30 second timeout for API calls
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Multiple Models Comparison", () => {
|
||
|
|
test("should get different responses from different models", async () => {
|
||
|
|
const question =
|
||
|
|
"In exactly 5 words, what is your model name?";
|
||
|
|
const responses: Record<string, string> = {};
|
||
|
|
|
||
|
|
// Test first 3 models for speed
|
||
|
|
const modelsToTest = TEST_MODELS.slice(0, 3);
|
||
|
|
|
||
|
|
for (const model of modelsToTest) {
|
||
|
|
const port = 3300 + modelsToTest.indexOf(model);
|
||
|
|
const proxy = await startTestProxy(model, port);
|
||
|
|
|
||
|
|
const response = await makeAnthropicRequest(proxy.url, [
|
||
|
|
{
|
||
|
|
role: "user",
|
||
|
|
content: question,
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
responses[model] = response.content[0].text || "";
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify we got responses from all models (some may be empty due to safety filters)
|
||
|
|
for (const model of modelsToTest) {
|
||
|
|
expect(responses[model]).toBeDefined();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter out empty responses and verify diversity
|
||
|
|
const nonEmptyResponses = Object.values(responses).filter(r => r.trim().length > 0);
|
||
|
|
const uniqueResponses = new Set(nonEmptyResponses);
|
||
|
|
|
||
|
|
// At least 2 different non-empty responses expected
|
||
|
|
expect(uniqueResponses.size).toBeGreaterThanOrEqual(1);
|
||
|
|
}, 60000); // 60 second timeout for multiple API calls
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("API Translation", () => {
|
||
|
|
test("should correctly translate Anthropic request to OpenRouter format", async () => {
|
||
|
|
const port = 3400;
|
||
|
|
const proxy = await startTestProxy("x-ai/grok-code-fast-1", port);
|
||
|
|
|
||
|
|
const response = await makeAnthropicRequest(proxy.url, [
|
||
|
|
{
|
||
|
|
role: "user",
|
||
|
|
content: "Say 'test successful' and nothing else",
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Verify response structure
|
||
|
|
expect(response.type).toBe("message");
|
||
|
|
expect(response.role).toBe("assistant");
|
||
|
|
expect(response.content).toBeDefined();
|
||
|
|
expect(response.usage).toBeDefined();
|
||
|
|
expect(response.usage.input_tokens).toBeGreaterThan(0);
|
||
|
|
expect(response.usage.output_tokens).toBeGreaterThan(0);
|
||
|
|
}, 30000);
|
||
|
|
});
|
||
|
|
});
|