@aj-archipelago/cortex
Version:
Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.
625 lines (548 loc) • 21.1 kB
JavaScript
// openai_api.test.js
import test from 'ava';
import got from 'got';
import axios from 'axios';
import serverFactory from '../index.js';
const API_BASE = `http://localhost:${process.env.CORTEX_PORT}/v1`;
let testServer;
test.before(async () => {
process.env.CORTEX_ENABLE_REST = 'true';
const { server, startServer } = await serverFactory();
startServer && await startServer();
testServer = server;
});
test.after.always('cleanup', async () => {
if (testServer) {
await testServer.stop();
}
});
test('GET /models', async (t) => {
const response = await got(`${API_BASE}/models`, { responseType: 'json' });
t.is(response.statusCode, 200);
t.is(response.body.object, 'list');
t.true(Array.isArray(response.body.data));
});
test('POST /completions', async (t) => {
const response = await got.post(`${API_BASE}/completions`, {
json: {
model: 'gpt-3.5-turbo',
prompt: 'Word to your motha!',
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'text_completion');
t.true(Array.isArray(response.body.choices));
});
test('POST /chat/completions', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Hello!' }],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
});
test('POST /chat/completions with multimodal content', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{
role: 'user',
content: [
{
type: 'text',
text: 'What do you see in this image?'
},
{
type: 'image',
image_url: {
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDABQODxIPDRQSEBIXFRQdHx4eHRoaHSQrJyEwPENBMDQ4NDQ0QUJCSkNLS0tNSkpQUFFQR1BTYWNgY2FQYWFQYWj/2wBDARUXFyAeIBohHh4oIiE2LCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='
}
}
]
}],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.choices[0].message.content);
});
async function connectToSSEEndpoint(url, endpoint, payload, t, customAssertions) {
return new Promise(async (resolve, reject) => {
try {
const instance = axios.create({
baseURL: url,
responseType: 'stream',
});
const response = await instance.post(endpoint, payload);
const responseData = response.data;
const incomingMessage = Array.isArray(responseData) && responseData.length > 0 ? responseData[0] : responseData;
let eventCount = 0;
incomingMessage.on('data', data => {
const events = data.toString().split('\n');
events.forEach(event => {
eventCount++;
if (event.trim() === '') return;
if (event.trim() === 'data: [DONE]') {
t.truthy(eventCount > 1);
resolve();
return;
}
const message = event.replace(/^data: /, '');
const messageJson = JSON.parse(message);
customAssertions(t, messageJson);
});
});
} catch (error) {
console.error('Error connecting to SSE endpoint:', error);
reject(error);
}
});
}
test('POST SSE: /v1/completions should send a series of events and a [DONE] event', async (t) => {
const payload = {
model: 'gpt-3.5-turbo',
prompt: 'Word to your motha!',
stream: true,
};
const url = `http://localhost:${process.env.CORTEX_PORT}/v1`;
const completionsAssertions = (t, messageJson) => {
t.truthy(messageJson.id);
t.is(messageJson.object, 'text_completion');
t.truthy(messageJson.choices[0].finish_reason === null || messageJson.choices[0].finish_reason === 'stop');
};
await connectToSSEEndpoint(url, '/completions', payload, t, completionsAssertions);
});
test('POST SSE: /v1/chat/completions should send a series of events and a [DONE] event', async (t) => {
const payload = {
model: 'gpt-4o',
messages: [
{
role: 'user',
content: 'Hello!',
},
],
stream: true,
};
const url = `http://localhost:${process.env.CORTEX_PORT}/v1`;
const chatCompletionsAssertions = (t, messageJson) => {
t.truthy(messageJson.id);
t.is(messageJson.object, 'chat.completion.chunk');
t.truthy(messageJson.choices[0].delta);
t.truthy(messageJson.choices[0].finish_reason === null || messageJson.choices[0].finish_reason === 'stop');
};
await connectToSSEEndpoint(url, '/chat/completions', payload, t, chatCompletionsAssertions);
});
test('POST SSE: /v1/chat/completions with multimodal content should send a series of events and a [DONE] event', async (t) => {
const payload = {
model: 'gpt-4o',
messages: [{
role: 'user',
content: [
{
type: 'text',
text: 'What do you see in this image?'
},
{
type: 'image',
image_url: {
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDABQODxIPDRQSEBIXFRQdHx4eHRoaHSQrJyEwPENBMDQ4NDQ0QUJCSkNLS0tNSkpQUFFQR1BTYWNgY2FQYWFQYWj/2wBDARUXFyAeIBohHh4oIiE2LCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='
}
}
]
}],
stream: true,
};
const url = `http://localhost:${process.env.CORTEX_PORT}/v1`;
const multimodalChatCompletionsAssertions = (t, messageJson) => {
t.truthy(messageJson.id);
t.is(messageJson.object, 'chat.completion.chunk');
t.truthy(messageJson.choices[0].delta);
if (messageJson.choices[0].finish_reason === 'stop') {
t.truthy(messageJson.choices[0].delta);
}
};
await connectToSSEEndpoint(url, '/chat/completions', payload, t, multimodalChatCompletionsAssertions);
});
test('POST /chat/completions should handle multimodal content for non-multimodal model', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{
role: 'user',
content: [
{
type: 'text',
text: 'What do you see in this image?'
},
{
type: 'image',
image_url: {
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...'
}
}
]
}],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.choices[0].message.content);
});
test('POST SSE: /v1/chat/completions should handle streaming multimodal content for non-multimodal model', async (t) => {
const payload = {
model: 'gpt-4o',
messages: [{
role: 'user',
content: [
{
type: 'text',
text: 'What do you see in this image?'
},
{
type: 'image',
image_url: {
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...'
}
}
]
}],
stream: true,
};
const streamingAssertions = (t, messageJson) => {
t.truthy(messageJson.id);
t.is(messageJson.object, 'chat.completion.chunk');
t.truthy(messageJson.choices[0].delta);
if (messageJson.choices[0].finish_reason === 'stop') {
t.truthy(messageJson.choices[0].delta);
}
};
await connectToSSEEndpoint(
`http://localhost:${process.env.CORTEX_PORT}/v1`,
'/chat/completions',
payload,
t,
streamingAssertions
);
});
test('POST /chat/completions should handle malformed multimodal content', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{
role: 'user',
content: [
{
type: 'text',
// Missing text field
},
{
type: 'image',
// Missing image_url
}
]
}],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.choices[0].message.content);
});
test('POST /chat/completions should handle invalid image data', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{
role: 'user',
content: [
{
type: 'text',
text: 'What do you see in this image?'
},
{
type: 'image',
image_url: {
url: 'not-a-valid-base64-image'
}
}
]
}],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.choices[0].message.content);
});
test('POST /completions should handle model parameters', async (t) => {
const response = await got.post(`${API_BASE}/completions`, {
json: {
model: 'gpt-3.5-turbo',
prompt: 'Say this is a test',
temperature: 0.7,
max_tokens: 100,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'text_completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.choices[0].text);
});
test('POST /chat/completions should handle function calling', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{ role: 'user', content: 'What is the weather in Boston?' }],
functions: [{
name: 'get_weather',
description: 'Get the current weather in a given location',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'The city and state, e.g. San Francisco, CA'
},
unit: {
type: 'string',
enum: ['celsius', 'fahrenheit']
}
},
required: ['location']
}
}],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
const choice = response.body.choices[0];
t.true(['function_call', 'stop'].includes(choice.finish_reason));
if (choice.finish_reason === 'function_call') {
t.truthy(choice.message.function_call);
t.truthy(choice.message.function_call.name);
t.truthy(choice.message.function_call.arguments);
}
});
test('POST /chat/completions should validate response format', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Hello!' }],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.id);
t.truthy(response.body.created);
t.truthy(response.body.model);
const choice = response.body.choices[0];
t.is(typeof choice.index, 'number');
t.truthy(choice.message);
t.truthy(choice.message.role);
t.truthy(choice.message.content);
t.truthy(choice.finish_reason);
});
test('POST /chat/completions should handle system messages', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' }
],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.choices[0].message.content);
});
test('POST /chat/completions should handle errors gracefully', async (t) => {
const error = await t.throwsAsync(
() => got.post(`${API_BASE}/chat/completions`, {
json: {
// Missing required model field
messages: [{ role: 'user', content: 'Hello!' }],
},
responseType: 'json',
})
);
t.is(error.response.statusCode, 404);
});
test('POST /chat/completions should handle token limits', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [{
role: 'user',
content: 'Hello!'.repeat(5000) // Very long message
}],
max_tokens: 100,
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
t.truthy(response.body.choices[0].message.content);
});
test('POST /chat/completions should return complete responses from gpt-4o', async (t) => {
const response = await got.post(`${API_BASE}/chat/completions`, {
json: {
model: 'gpt-4o',
messages: [
{ role: 'system', content: 'You are a helpful assistant. Always end your response with the exact string "END_MARKER_XYZ".' },
{ role: 'user', content: 'Say hello and explain why complete responses matter.' }
],
stream: false,
},
responseType: 'json',
});
t.is(response.statusCode, 200);
t.is(response.body.object, 'chat.completion');
t.true(Array.isArray(response.body.choices));
console.log('GPT-4o Response:', JSON.stringify(response.body.choices[0].message.content));
const content = response.body.choices[0].message.content;
t.regex(content, /END_MARKER_XYZ$/);
});
test('POST /chat/completions should handle array content properly', async (t) => {
// This test verifies the functionality in server/rest.js where array content is JSON stringified
// Specifically testing: content: Array.isArray(msg.content) ? msg.content.map(item => JSON.stringify(item)) : msg.content
// Create a request with MultiMessage array content
const testContent = [
{
type: 'text',
text: 'Hello world'
},
{
type: 'text',
text: 'Hello2 world2'
},
{
type: 'image',
url: 'https://example.com/test.jpg'
}
];
try {
// First, check if the API server is running and get available models
let modelToUse = '*'; // Default fallback model
try {
const modelsResponse = await got(`${API_BASE}/models`, { responseType: 'json' });
if (modelsResponse.body && modelsResponse.body.data && modelsResponse.body.data.length > 0) {
const models = modelsResponse.body.data.map(model => model.id);
// Priority 1: Find sonnet with highest version (e.g., claude-3.7-sonnet)
const sonnetVersions = models
.filter(id => id.includes('-sonnet') && id.startsWith('claude-'))
.sort((a, b) => {
// Extract version numbers and compare
const versionA = a.match(/claude-(\d+\.\d+)-sonnet/);
const versionB = b.match(/claude-(\d+\.\d+)-sonnet/);
if (versionA && versionB) {
return parseFloat(versionB[1]) - parseFloat(versionA[1]); // Descending order
}
return 0;
});
if (sonnetVersions.length > 0) {
modelToUse = sonnetVersions[0]; // Use highest version sonnet
} else {
// Priority 2: Any model ending with -sonnet
const anySonnet = models.find(id => id.endsWith('-sonnet'));
if (anySonnet) {
modelToUse = anySonnet;
} else {
// Priority 3: Any model starting with claude-
const anyClaude = models.find(id => id.startsWith('claude-'));
if (anyClaude) {
modelToUse = anyClaude;
} else {
// Fallback: Just use the first available model
modelToUse = models[0];
}
}
}
t.log(`Using model: ${modelToUse}`);
}
} catch (modelError) {
t.log('Could not get available models, using default model');
}
// Make a direct HTTP request to the REST API
const response = await axios.post(`${API_BASE}/chat/completions`, {
model: modelToUse,
messages: [
{
role: 'user',
content: testContent
}
]
});
t.log('Response:', response.data.choices[0].message);
const message = response.data.choices[0].message;
//message should not have anything similar to:
//Execution failed for sys_claude_37_sonnet: HTTP error: 400 Bad Request
//HTTP error:
t.falsy(message.content.startsWith('HTTP error:'));
//400 Bad Request
t.falsy(message.content.startsWith('400 Bad Request'));
//Execution failed
t.falsy(message.content.startsWith('Execution failed'));
//Invalid JSON
t.falsy(message.content.startsWith('Invalid JSON'));
// If the request succeeds, it means the array content was properly processed
// If the JSON.stringify was not applied correctly, the request would fail
t.truthy(response.data);
t.pass('REST API successfully processed array content');
} catch (error) {
// If there's a connection error (e.g., API not running), we'll skip this test
if (error.code === 'ECONNREFUSED') {
t.pass('Skipping test - REST API not available');
} else {
// Check if the error response contains useful information
if (error.response) {
// We got a response from the server, but with an error status
t.log('Server responded with:', error.response.data);
// Skip the test if the server is running but no pathway is configured to handle the request
if (error.response.status === 404 &&
error.response.data.error &&
error.response.data.error.includes('not found')) {
t.pass('Skipping test - No suitable pathway configured for this API endpoint');
} else {
t.fail(`API request failed with status ${error.response.status}: ${error.response.statusText}`);
}
} else {
// No response received
t.fail(`API request failed: ${error.message}`);
}
}
}
});