Skip to main content
Glama
ai.test.ts28.3 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { ContentType } from '@medplum/core'; import type { OperationOutcome, Parameters } from '@medplum/fhirtypes'; import express from 'express'; import request from 'supertest'; import { initApp, shutdownApp } from '../../app'; import { loadTestConfig } from '../../config/loader'; import { createTestProject, initTestAuth } from '../../test.setup'; const app = express(); let accessToken: string; const fhirTools = [ { type: 'function' as const, function: { name: 'fhir_request', description: 'Make a FHIR request to the Medplum server. Use this to search, read, create, update, or delete FHIR resources. For POST/PUT/PATCH requests, include the resource data in the "body" parameter.', parameters: { type: 'object' as const, properties: { method: { type: 'string' as const, enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], description: 'HTTP method for the FHIR request', }, path: { type: 'string' as const, description: 'FHIR resource path (e.g., "Patient?phone=718-564-9483" or "Patient/123" or "Task")', }, body: { type: 'object' as const, description: 'FHIR resource to create or update. Required for POST, PUT, and PATCH requests. Example: {"resourceType": "Task", "status": "requested", "intent": "order", "description": "Fill up chart note"}', }, }, required: ['method', 'path'], additionalProperties: false, }, }, }, ]; describe('AI Operation', () => { beforeAll(async () => { const config = await loadTestConfig(); await initApp(app, config); accessToken = await initTestAuth({ project: { features: ['ai'] } }); }); afterAll(async () => { await shutdownApp(); }); beforeEach(() => { jest.clearAllMocks(); }); test('Happy path', async () => { const mockFetchResponse = { ok: true, status: 200, json: jest.fn().mockResolvedValue({ choices: [ { message: { content: 'Here are the matching patients', tool_calls: [ { id: 'call_123', type: 'function', function: { name: 'fhir_request', arguments: JSON.stringify({ method: 'GET', path: 'Patient?phone=718-564-9483', }), }, }, ], }, }, ], }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Find patient with phone 718-564-9483' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, { name: 'tools', valueString: JSON.stringify(fhirTools), }, ], }); expect(res.status).toBe(200); expect(res.body.resourceType).toBe('Parameters'); expect((res.body as Parameters).parameter).toHaveLength(2); expect((res.body as Parameters).parameter?.[0]?.name).toBe('content'); expect((res.body as Parameters).parameter?.[0]?.valueString).toBe('Here are the matching patients'); expect((res.body as Parameters).parameter?.[1]?.name).toBe('tool_calls'); const toolCalls = JSON.parse((res.body as Parameters).parameter?.[1]?.valueString as string); expect(toolCalls).toHaveLength(1); expect(toolCalls[0].id).toBe('call_123'); expect(toolCalls[0].function.name).toBe('fhir_request'); expect(global.fetch).toHaveBeenCalledWith( 'https://api.openai.com/v1/chat/completions', expect.objectContaining({ method: 'POST', headers: { Authorization: 'Bearer sk-test-key', 'Content-Type': 'application/json', }, body: expect.stringContaining('"tools":'), }) ); }); test('Happy path - AI creates a patient', async () => { const mockFetchResponse = { ok: true, status: 200, json: jest.fn().mockResolvedValue({ choices: [ { message: { content: 'I will create a new patient for you.', tool_calls: [ { id: 'call_patient_create', type: 'function', function: { name: 'fhir_request', arguments: JSON.stringify({ method: 'POST', path: 'Patient', body: { resourceType: 'Patient', name: [ { family: 'Smith', given: ['John'], }, ], gender: 'male', birthDate: '1990-01-01', }, }), }, }, ], }, }, ], }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([ { role: 'user', content: 'Create a new patient named John Smith, male, born 1990-01-01' }, ]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, { name: 'tools', valueString: JSON.stringify(fhirTools), }, ], }); expect(res.status).toBe(200); expect(res.body.resourceType).toBe('Parameters'); const params = res.body as Parameters; const contentParam = params.parameter?.find((p) => p.name === 'content'); const toolCallsParam = params.parameter?.find((p) => p.name === 'tool_calls'); expect(contentParam).toBeDefined(); expect(contentParam?.valueString).toBe('I will create a new patient for you.'); expect(toolCallsParam).toBeDefined(); const toolCalls = JSON.parse(toolCallsParam?.valueString as string); expect(toolCalls).toHaveLength(1); expect(toolCalls[0].id).toBe('call_patient_create'); expect(toolCalls[0].function.name).toBe('fhir_request'); const functionArgs = toolCalls[0].function.arguments; expect(functionArgs.method).toBe('POST'); expect(functionArgs.path).toBe('Patient'); expect(functionArgs.body.resourceType).toBe('Patient'); expect(functionArgs.body.name[0].family).toBe('Smith'); expect(functionArgs.body.name[0].given[0]).toBe('John'); expect(functionArgs.body.gender).toBe('male'); expect(functionArgs.body.birthDate).toBe('1990-01-01'); expect(global.fetch).toHaveBeenCalledWith( 'https://api.openai.com/v1/chat/completions', expect.objectContaining({ method: 'POST', headers: { Authorization: 'Bearer sk-test-key', 'Content-Type': 'application/json', }, body: expect.stringContaining('"tools":'), }) ); }); test('Happy path without tool calls', async () => { const mockFetchResponse = { ok: true, status: 200, json: jest.fn().mockResolvedValue({ choices: [ { message: { content: 'I can help you with FHIR queries.', tool_calls: null, }, }, ], }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'What can you do?' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, { name: 'tools', valueString: JSON.stringify(fhirTools), }, ], }); expect(res.status).toBe(200); expect(res.body.resourceType).toBe('Parameters'); expect((res.body as Parameters).parameter?.[0]?.valueString).toBe('I can help you with FHIR queries.'); // When there are no tool calls, the implementation doesn't return a tool_calls parameter }); test('AI feature not enabled', async () => { // Create a project without AI feature const noAiProject = await createTestProject({ withRepo: true, project: { name: 'No AI Project', features: ['bots'], // AI feature not included }, }); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + noAiProject.accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test message' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(401); }); test('Missing API key', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test message' }]), }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.details?.text).toBe( 'Expected 1 value(s) for input parameter apiKey, but 0 provided' ); }); test('Missing model', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test message' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, ], }); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.details?.text).toBe( 'Expected 1 value(s) for input parameter model, but 0 provided' ); }); test('Missing messages', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.details?.text).toBe( 'Expected 1 value(s) for input parameter messages, but 0 provided' ); }); test('Invalid messages format - not an array', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify({ invalid: 'format' }), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.details?.text).toBe('Messages must be an array'); }); test('Invalid messages JSON', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: 'invalid json', }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.severity).toBe('error'); }); test('Invalid tools JSON', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test message' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, { name: 'tools', valueString: 'invalid json', }, ], }); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.severity).toBe('error'); }); test('Works without tools parameter (optional)', async () => { const mockFetchResponse = { ok: true, status: 200, json: jest.fn().mockResolvedValue({ choices: [ { message: { content: 'I can help you with general questions.', tool_calls: null, }, }, ], }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'What can you do?' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(200); expect(res.body.resourceType).toBe('Parameters'); expect((res.body as Parameters).parameter?.[0]?.valueString).toBe('I can help you with general questions.'); const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; const bodyParam = JSON.parse(fetchCall[1].body); expect(bodyParam.tools).toBeUndefined(); expect(bodyParam.tool_choice).toBeUndefined(); }); test('Unsupported content type', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.TEXT) .send('hello'); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.details?.text).toContain( 'Expected at least 1 value(s) for required input parameter' ); }); test('Incorrect parameters type', async () => { const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Patient', }); expect(res.status).toBe(400); expect((res.body as OperationOutcome).issue?.[0]?.details?.text).toContain( 'Expected at least 1 value(s) for required input parameter' ); }); test('OpenAI API error', async () => { const mockFetchResponse = { ok: false, status: 401, statusText: 'Unauthorized', json: jest.fn().mockResolvedValue({ error: { message: 'Incorrect API key provided', }, }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test message' }]), }, { name: 'apiKey', valueString: 'sk-invalid-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(400); }); test('Handles multiple messages in conversation', async () => { const mockFetchResponse = { ok: true, status: 200, json: jest.fn().mockResolvedValue({ choices: [ { message: { content: 'Based on our conversation...', tool_calls: null, }, }, ], }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const messages = [ { role: 'user', content: 'First message' }, { role: 'assistant', content: 'First response' }, { role: 'user', content: 'Follow up question' }, ]; const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify(messages), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, { name: 'tools', valueString: JSON.stringify(fhirTools), }, ], }); expect(res.status).toBe(200); const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; const bodyParam = JSON.parse(fetchCall[1].body); expect(bodyParam.messages).toEqual(messages); }); test('Handles null content in response', async () => { const mockFetchResponse = { ok: true, status: 200, json: jest.fn().mockResolvedValue({ choices: [ { message: { content: null, tool_calls: [ { id: 'call_456', type: 'function', function: { name: 'fhir_request', arguments: '{}', }, }, ], }, }, ], }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, { name: 'tools', valueString: JSON.stringify(fhirTools), }, ], }); expect(res.status).toBe(200); const params = res.body as Parameters; const contentParam = params.parameter?.find((p) => p.name === 'content'); const toolCallsParam = params.parameter?.find((p) => p.name === 'tool_calls'); expect(contentParam).toBeUndefined(); expect(toolCallsParam).toBeDefined(); expect(toolCallsParam?.valueString).toBeDefined(); }); test('Handles different OpenAI models', async () => { const mockFetchResponse = { ok: true, status: 200, json: jest.fn().mockResolvedValue({ choices: [ { message: { content: 'Response from GPT-3.5', tool_calls: null, }, }, ], }), }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test message' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-3.5-turbo', }, { name: 'tools', valueString: JSON.stringify(fhirTools), }, ], }); expect(res.status).toBe(200); const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; const bodyParam = JSON.parse(fetchCall[1].body); expect(bodyParam.model).toBe('gpt-3.5-turbo'); }); test('Supports streaming through $ai operation', async () => { const mockStreamResponse = { ok: true, status: 200, body: { pipeThrough: jest.fn().mockReturnValue({ getReader: () => ({ read: jest .fn() .mockResolvedValueOnce({ done: false, value: 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n', }) .mockResolvedValueOnce({ done: false, value: 'data: {"choices":[{"delta":{"content":"!"}}]}\n\n', }) .mockResolvedValueOnce({ done: true, value: undefined, }), releaseLock: jest.fn(), }), }), }, }; global.fetch = jest.fn().mockResolvedValue(mockStreamResponse); const res = await request(app) .post(`/fhir/R4/$ai`) .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .set('Accept', 'text/event-stream') .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Say hello' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(200); expect(res.headers['content-type']).toBe('text/event-stream'); // Parse SSE format - should not be Parameters format // SSE responses don't have JSON body (res.body is empty object for text responses) expect(res.body).toEqual({}); expect(res.text).toBeDefined(); // Verify SSE data chunks const sseLines = res.text.split('\n\n').filter((line: string) => line.startsWith('data: ')); expect(sseLines.length).toBeGreaterThan(0); const contentChunks: string[] = []; for (const line of sseLines) { if (line.includes('[DONE]')) { continue; } try { const data = JSON.parse(line.slice(6).trim()); if (data.content) { contentChunks.push(data.content); } } catch (_e) { // Skip malformed lines } } expect(contentChunks).toContain('Hello'); expect(contentChunks).toContain('!'); }); test('Simulates actual progressive streaming behavior', async () => { // Create a mock stream with intentional delays const streamChunks = [ 'data: {"choices":[{"delta":{"content":"Progressive"}}]}\n\n', 'data: {"choices":[{"delta":{"content":" streaming"}}]}\n\n', 'data: {"choices":[{"delta":{"content":" test"}}]}\n\n', ]; let chunkIndex = 0; const mockReadableStream = { pipeThrough: jest.fn().mockReturnValue({ getReader: jest.fn().mockReturnValue({ read: jest.fn().mockImplementation(async () => { if (chunkIndex < streamChunks.length) { // Simulate network delay between chunks await new Promise<void>((resolve) => { setTimeout(() => resolve(), 10); }); const chunk = streamChunks[chunkIndex]; chunkIndex++; return { done: false, value: chunk }; } return { done: true }; }), releaseLock: jest.fn(), }), }), }; const mockFetchResponse = { ok: true, status: 200, body: mockReadableStream, }; global.fetch = jest.fn().mockResolvedValue(mockFetchResponse); // Make a real request to the endpoint and check the progressive streaming const res = await request(app) .post('/fhir/R4/$ai') .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .set('Accept', 'text/event-stream') .send({ resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify([{ role: 'user', content: 'Test' }]), }, { name: 'apiKey', valueString: 'sk-test-key', }, { name: 'model', valueString: 'gpt-4', }, ], }); expect(res.status).toBe(200); expect(res.headers['content-type']).toBe('text/event-stream'); // Verify response is SSE format, not Parameters format // SSE responses don't have JSON body (res.body is empty object for text responses) expect(res.body).toEqual({}); expect(res.text).toBeDefined(); // Parse SSE format explicitly const sseLines = res.text.split('\n\n').filter((line: string) => line.startsWith('data: ')); expect(sseLines.length).toBeGreaterThan(0); const contentChunks: string[] = []; for (const line of sseLines) { if (line.includes('[DONE]')) { continue; } try { const data = JSON.parse(line.slice(6).trim()); if (data.content) { contentChunks.push(data.content); } } catch (_e) { // Skip malformed lines } } expect(contentChunks).toEqual(['Progressive', ' streaming', ' test']); // Verify no OpenAI metadata leaked (no chatcmpl-, finish_reason, role, etc.) expect(res.text).not.toContain('chatcmpl-'); expect(res.text).not.toContain('finish_reason'); expect(res.text).not.toContain('"role"'); // Verify no Parameters format in response expect(res.text).not.toContain('"resourceType":"Parameters"'); expect(res.text).not.toContain('"parameter"'); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server