Skip to content

Commit 3098747

Browse files
authored
Merge pull request #1034 from objectstack-ai/copilot/update-ai-chat-api-protocol
Upgrade server AI Chat API protocol for Vercel compatibility
2 parents b25943b + 4cc6068 commit 3098747

File tree

9 files changed

+716
-12
lines changed

9 files changed

+716
-12
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8484
`@ai-sdk/react/useChat` directly
8585
- AI `/chat` endpoint from `DEFAULT_AI_ROUTES` plugin REST API definition
8686

87+
### Added
88+
- `ai` v6 as a dependency of `@objectstack/spec` for type re-exports
89+
- **Vercel AI Data Stream Protocol support on `/api/v1/ai/chat`** — The chat
90+
endpoint now supports dual-mode responses:
91+
- **Streaming (default)**: When `stream` is not `false`, returns Vercel Data
92+
Stream Protocol frames (`0:` text, `9:` tool-call, `d:` finish, etc.),
93+
directly consumable by `@ai-sdk/react/useChat`
94+
- **JSON (legacy)**: When `stream: false`, returns the original JSON response
95+
- Accepts Vercel useChat flat body format (`system`, `model`, `temperature`,
96+
`maxTokens` as top-level fields) alongside the legacy `{ messages, options }`
97+
- `systemPrompt` / `system` field is prepended as a system message
98+
- Message validation now accepts Vercel multi-part array content
99+
- `RouteResponse.vercelDataStream` flag signals HTTP server layer to encode
100+
events using the Vercel Data Stream frame format
101+
- **`VercelLLMAdapter`** — Production adapter wrapping Vercel AI SDK's
102+
`generateText` / `streamText` for any compatible model provider (OpenAI,
103+
Anthropic, Google, Ollama, etc.)
104+
- **`vercel-stream-encoder.ts`** — Utilities (`encodeStreamPart`,
105+
`encodeVercelDataStream`) to convert `TextStreamPart<ToolSet>` events into
106+
Vercel Data Stream wire-format frames
107+
- 176 service-ai tests passing (18 new tests for stream encoder, route
108+
dual-mode, systemPrompt, flat options, array content)
109+
87110
## [4.0.1] — 2026-03-31
88111

89112
### Fixed

packages/services/service-ai/src/__tests__/ai-service.test.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,18 +382,106 @@ describe('AI Routes', () => {
382382
expect(paths).toContain('DELETE /api/v1/ai/conversations/:id');
383383
});
384384

385-
it('POST /api/v1/ai/chat should return chat result', async () => {
385+
it('POST /api/v1/ai/chat should return JSON result when stream=false', async () => {
386+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
387+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
388+
389+
const response = await chatRoute.handler({
390+
body: { messages: [{ role: 'user', content: 'Hi' }], stream: false },
391+
});
392+
393+
expect(response.status).toBe(200);
394+
expect((response.body as any).content).toBe('[memory] Hi');
395+
});
396+
397+
it('POST /api/v1/ai/chat should default to Vercel Data Stream mode', async () => {
386398
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
387399
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
388400

389401
const response = await chatRoute.handler({
390402
body: { messages: [{ role: 'user', content: 'Hi' }] },
391403
});
392404

405+
expect(response.status).toBe(200);
406+
expect(response.stream).toBe(true);
407+
expect(response.vercelDataStream).toBe(true);
408+
expect(response.events).toBeDefined();
409+
410+
// Consume the Vercel Data Stream events
411+
const events: unknown[] = [];
412+
for await (const event of response.events!) {
413+
events.push(event);
414+
}
415+
expect(events.length).toBeGreaterThan(0);
416+
});
417+
418+
it('POST /api/v1/ai/chat should prepend systemPrompt as system message', async () => {
419+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
420+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
421+
422+
const response = await chatRoute.handler({
423+
body: {
424+
messages: [{ role: 'user', content: 'Hello' }],
425+
system: 'You are a helpful assistant',
426+
stream: false,
427+
},
428+
});
429+
430+
expect(response.status).toBe(200);
431+
// MemoryLLMAdapter echoes the last user message
432+
expect((response.body as any).content).toBe('[memory] Hello');
433+
});
434+
435+
it('POST /api/v1/ai/chat should accept deprecated systemPrompt field', async () => {
436+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
437+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
438+
439+
const response = await chatRoute.handler({
440+
body: {
441+
messages: [{ role: 'user', content: 'Hi' }],
442+
systemPrompt: 'Be concise',
443+
stream: false,
444+
},
445+
});
446+
393447
expect(response.status).toBe(200);
394448
expect((response.body as any).content).toBe('[memory] Hi');
395449
});
396450

451+
it('POST /api/v1/ai/chat should accept flat Vercel-style fields (model, temperature)', async () => {
452+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
453+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
454+
455+
const response = await chatRoute.handler({
456+
body: {
457+
messages: [{ role: 'user', content: 'Hi' }],
458+
model: 'gpt-4o',
459+
temperature: 0.5,
460+
stream: false,
461+
},
462+
});
463+
464+
expect(response.status).toBe(200);
465+
// MemoryLLMAdapter uses the model from options when provided
466+
expect((response.body as any).model).toBe('gpt-4o');
467+
});
468+
469+
it('POST /api/v1/ai/chat should accept array content (Vercel multi-part)', async () => {
470+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
471+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
472+
473+
const response = await chatRoute.handler({
474+
body: {
475+
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
476+
stream: false,
477+
},
478+
});
479+
480+
// MemoryLLMAdapter falls back to "(complex content)" for non-string
481+
expect(response.status).toBe(200);
482+
expect((response.body as any).content).toBe('[memory] (complex content)');
483+
});
484+
397485
it('POST /api/v1/ai/chat should return 400 without messages', async () => {
398486
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
399487
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
@@ -531,16 +619,30 @@ describe('AI Routes', () => {
531619
expect((response.body as any).error).toContain('message.role');
532620
});
533621

534-
it('POST /api/v1/ai/chat should return 400 for messages with non-string content', async () => {
622+
it('POST /api/v1/ai/chat should return 400 for messages with non-string/non-array content', async () => {
535623
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
536624
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
537625

626+
// Numeric content should be rejected
538627
const response = await chatRoute.handler({
539628
body: { messages: [{ role: 'user', content: 123 }] },
540629
});
541-
542630
expect(response.status).toBe(400);
543631
expect((response.body as any).error).toContain('content');
632+
633+
// Object content (not an array) should be rejected
634+
const response2 = await chatRoute.handler({
635+
body: { messages: [{ role: 'user', content: { nested: true } }] },
636+
});
637+
expect(response2.status).toBe(400);
638+
expect((response2.body as any).error).toContain('content');
639+
640+
// Boolean content should be rejected
641+
const response3 = await chatRoute.handler({
642+
body: { messages: [{ role: 'user', content: true }] },
643+
});
644+
expect(response3.status).toBe(400);
645+
expect((response3.body as any).error).toContain('content');
544646
});
545647

546648
it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => {
@@ -620,6 +722,7 @@ describe('AI Routes', () => {
620722
{ role: 'assistant', content: '' },
621723
{ role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' },
622724
],
725+
stream: false,
623726
},
624727
});
625728

0 commit comments

Comments
 (0)