diff --git a/CHANGELOG.md b/CHANGELOG.md index d44df3e3e..ab47394ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `@ai-sdk/react/useChat` directly - AI `/chat` endpoint from `DEFAULT_AI_ROUTES` plugin REST API definition +### Added +- `ai` v6 as a dependency of `@objectstack/spec` for type re-exports +- **Vercel AI Data Stream Protocol support on `/api/v1/ai/chat`** — The chat + endpoint now supports dual-mode responses: + - **Streaming (default)**: When `stream` is not `false`, returns Vercel Data + Stream Protocol frames (`0:` text, `9:` tool-call, `d:` finish, etc.), + directly consumable by `@ai-sdk/react/useChat` + - **JSON (legacy)**: When `stream: false`, returns the original JSON response + - Accepts Vercel useChat flat body format (`system`, `model`, `temperature`, + `maxTokens` as top-level fields) alongside the legacy `{ messages, options }` + - `systemPrompt` / `system` field is prepended as a system message + - Message validation now accepts Vercel multi-part array content + - `RouteResponse.vercelDataStream` flag signals HTTP server layer to encode + events using the Vercel Data Stream frame format +- **`VercelLLMAdapter`** — Production adapter wrapping Vercel AI SDK's + `generateText` / `streamText` for any compatible model provider (OpenAI, + Anthropic, Google, Ollama, etc.) +- **`vercel-stream-encoder.ts`** — Utilities (`encodeStreamPart`, + `encodeVercelDataStream`) to convert `TextStreamPart` events into + Vercel Data Stream wire-format frames +- 176 service-ai tests passing (18 new tests for stream encoder, route + dual-mode, systemPrompt, flat options, array content) + ## [4.0.1] — 2026-03-31 ### Fixed diff --git a/packages/services/service-ai/src/__tests__/ai-service.test.ts b/packages/services/service-ai/src/__tests__/ai-service.test.ts index 48f98eec2..ba721741d 100644 --- a/packages/services/service-ai/src/__tests__/ai-service.test.ts +++ b/packages/services/service-ai/src/__tests__/ai-service.test.ts @@ -382,7 +382,19 @@ describe('AI Routes', () => { expect(paths).toContain('DELETE /api/v1/ai/conversations/:id'); }); - it('POST /api/v1/ai/chat should return chat result', async () => { + it('POST /api/v1/ai/chat should return JSON result when stream=false', async () => { + const routes = buildAIRoutes(service, service.conversationService, silentLogger); + const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; + + const response = await chatRoute.handler({ + body: { messages: [{ role: 'user', content: 'Hi' }], stream: false }, + }); + + expect(response.status).toBe(200); + expect((response.body as any).content).toBe('[memory] Hi'); + }); + + it('POST /api/v1/ai/chat should default to Vercel Data Stream mode', async () => { const routes = buildAIRoutes(service, service.conversationService, silentLogger); const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; @@ -390,10 +402,86 @@ describe('AI Routes', () => { body: { messages: [{ role: 'user', content: 'Hi' }] }, }); + expect(response.status).toBe(200); + expect(response.stream).toBe(true); + expect(response.vercelDataStream).toBe(true); + expect(response.events).toBeDefined(); + + // Consume the Vercel Data Stream events + const events: unknown[] = []; + for await (const event of response.events!) { + events.push(event); + } + expect(events.length).toBeGreaterThan(0); + }); + + it('POST /api/v1/ai/chat should prepend systemPrompt as system message', async () => { + const routes = buildAIRoutes(service, service.conversationService, silentLogger); + const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; + + const response = await chatRoute.handler({ + body: { + messages: [{ role: 'user', content: 'Hello' }], + system: 'You are a helpful assistant', + stream: false, + }, + }); + + expect(response.status).toBe(200); + // MemoryLLMAdapter echoes the last user message + expect((response.body as any).content).toBe('[memory] Hello'); + }); + + it('POST /api/v1/ai/chat should accept deprecated systemPrompt field', async () => { + const routes = buildAIRoutes(service, service.conversationService, silentLogger); + const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; + + const response = await chatRoute.handler({ + body: { + messages: [{ role: 'user', content: 'Hi' }], + systemPrompt: 'Be concise', + stream: false, + }, + }); + expect(response.status).toBe(200); expect((response.body as any).content).toBe('[memory] Hi'); }); + it('POST /api/v1/ai/chat should accept flat Vercel-style fields (model, temperature)', async () => { + const routes = buildAIRoutes(service, service.conversationService, silentLogger); + const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; + + const response = await chatRoute.handler({ + body: { + messages: [{ role: 'user', content: 'Hi' }], + model: 'gpt-4o', + temperature: 0.5, + stream: false, + }, + }); + + expect(response.status).toBe(200); + // MemoryLLMAdapter uses the model from options when provided + expect((response.body as any).model).toBe('gpt-4o'); + }); + + it('POST /api/v1/ai/chat should accept array content (Vercel multi-part)', async () => { + const routes = buildAIRoutes(service, service.conversationService, silentLogger); + const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; + + const response = await chatRoute.handler({ + body: { + messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], + stream: false, + }, + }); + + // MemoryLLMAdapter falls back to "(complex content)" for non-string + expect(response.status).toBe(200); + expect((response.body as any).content).toBe('[memory] (complex content)'); + }); + it('POST /api/v1/ai/chat should return 400 without messages', async () => { const routes = buildAIRoutes(service, service.conversationService, silentLogger); const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; @@ -531,16 +619,30 @@ describe('AI Routes', () => { expect((response.body as any).error).toContain('message.role'); }); - it('POST /api/v1/ai/chat should return 400 for messages with non-string content', async () => { + it('POST /api/v1/ai/chat should return 400 for messages with non-string/non-array content', async () => { const routes = buildAIRoutes(service, service.conversationService, silentLogger); const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!; + // Numeric content should be rejected const response = await chatRoute.handler({ body: { messages: [{ role: 'user', content: 123 }] }, }); - expect(response.status).toBe(400); expect((response.body as any).error).toContain('content'); + + // Object content (not an array) should be rejected + const response2 = await chatRoute.handler({ + body: { messages: [{ role: 'user', content: { nested: true } }] }, + }); + expect(response2.status).toBe(400); + expect((response2.body as any).error).toContain('content'); + + // Boolean content should be rejected + const response3 = await chatRoute.handler({ + body: { messages: [{ role: 'user', content: true }] }, + }); + expect(response3.status).toBe(400); + expect((response3.body as any).error).toContain('content'); }); it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => { @@ -620,6 +722,7 @@ describe('AI Routes', () => { { role: 'assistant', content: '' }, { role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' }, ], + stream: false, }, }); diff --git a/packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts b/packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts new file mode 100644 index 000000000..06961e69b --- /dev/null +++ b/packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { TextStreamPart, ToolSet } from '@objectstack/spec/contracts'; +import { encodeStreamPart, encodeVercelDataStream } from '../stream/vercel-stream-encoder.js'; + +// ───────────────────────────────────────────────────────────────── +// encodeStreamPart — individual frame encoding +// ───────────────────────────────────────────────────────────────── + +describe('encodeStreamPart', () => { + it('should encode text-delta as "0:" frame', () => { + const part = { type: 'text-delta', text: 'Hello world' } as TextStreamPart; + expect(encodeStreamPart(part)).toBe('0:"Hello world"\n'); + }); + + it('should JSON-escape text-delta content', () => { + const part = { type: 'text-delta', text: 'say "hi"\nnewline' } as TextStreamPart; + const frame = encodeStreamPart(part); + expect(frame).toBe(`0:${JSON.stringify('say "hi"\nnewline')}\n`); + expect(frame.startsWith('0:')).toBe(true); + + // Verify round-trip: decode the frame payload back to the original text + const decoded = JSON.parse(frame.slice(2).trim()); + expect(decoded).toBe('say "hi"\nnewline'); + }); + + it('should encode tool-call as "9:" frame', () => { + const part = { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'get_weather', + input: { location: 'San Francisco' }, + } as TextStreamPart; + + const frame = encodeStreamPart(part); + expect(frame.startsWith('9:')).toBe(true); + + const payload = JSON.parse(frame.slice(2)); + expect(payload).toEqual({ + toolCallId: 'call_1', + toolName: 'get_weather', + args: { location: 'San Francisco' }, + }); + }); + + it('should encode tool-call-streaming-start as "b:" frame', () => { + const part = { + type: 'tool-call-streaming-start', + toolCallId: 'call_2', + toolName: 'search', + } as TextStreamPart; + + const frame = encodeStreamPart(part); + expect(frame.startsWith('b:')).toBe(true); + + const payload = JSON.parse(frame.slice(2)); + expect(payload).toEqual({ + toolCallId: 'call_2', + toolName: 'search', + }); + }); + + it('should encode tool-call-delta as "c:" frame', () => { + const part = { + type: 'tool-call-delta', + toolCallId: 'call_2', + argsTextDelta: '{"query":', + } as TextStreamPart; + + const frame = encodeStreamPart(part); + expect(frame.startsWith('c:')).toBe(true); + + const payload = JSON.parse(frame.slice(2)); + expect(payload).toEqual({ + toolCallId: 'call_2', + argsTextDelta: '{"query":', + }); + }); + + it('should encode tool-result as "a:" frame', () => { + const part = { + type: 'tool-result', + toolCallId: 'call_1', + toolName: 'get_weather', + result: { temperature: 72 }, + } as TextStreamPart; + + const frame = encodeStreamPart(part); + expect(frame.startsWith('a:')).toBe(true); + + const payload = JSON.parse(frame.slice(2)); + expect(payload).toEqual({ + toolCallId: 'call_1', + result: { temperature: 72 }, + }); + }); + + it('should encode finish as "d:" frame', () => { + const part = { + type: 'finish', + finishReason: 'stop', + totalUsage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 }, + rawFinishReason: 'stop', + } as unknown as TextStreamPart; + + const frame = encodeStreamPart(part); + expect(frame.startsWith('d:')).toBe(true); + + const payload = JSON.parse(frame.slice(2)); + expect(payload.finishReason).toBe('stop'); + expect(payload.usage).toEqual({ promptTokens: 10, completionTokens: 20, totalTokens: 30 }); + }); + + it('should encode step-finish as "e:" frame', () => { + const part = { + type: 'step-finish', + finishReason: 'tool-calls', + totalUsage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 }, + isContinued: true, + } as unknown as TextStreamPart; + + const frame = encodeStreamPart(part); + expect(frame.startsWith('e:')).toBe(true); + + const payload = JSON.parse(frame.slice(2)); + expect(payload.finishReason).toBe('tool-calls'); + expect(payload.isContinued).toBe(true); + }); + + it('should return empty string for unknown event types', () => { + const part = { type: 'unknown-internal' } as unknown as TextStreamPart; + expect(encodeStreamPart(part)).toBe(''); + }); +}); + +// ───────────────────────────────────────────────────────────────── +// encodeVercelDataStream — async iterable transformation +// ───────────────────────────────────────────────────────────────── + +describe('encodeVercelDataStream', () => { + it('should transform stream events into Vercel Data Stream frames', async () => { + async function* source(): AsyncIterable> { + yield { type: 'text-delta', text: 'Hello' } as TextStreamPart; + yield { type: 'text-delta', text: ' world' } as TextStreamPart; + yield { + type: 'finish', + finishReason: 'stop', + totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + rawFinishReason: 'stop', + } as unknown as TextStreamPart; + } + + const frames: string[] = []; + for await (const frame of encodeVercelDataStream(source())) { + frames.push(frame); + } + + expect(frames).toHaveLength(3); + expect(frames[0]).toBe('0:"Hello"\n'); + expect(frames[1]).toBe('0:" world"\n'); + expect(frames[2]).toMatch(/^d:/); + }); + + it('should skip events with no wire format mapping', async () => { + async function* source(): AsyncIterable> { + yield { type: 'text-delta', text: 'Hi' } as TextStreamPart; + yield { type: 'unknown-internal' } as unknown as TextStreamPart; + yield { + type: 'finish', + finishReason: 'stop', + totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + rawFinishReason: 'stop', + } as unknown as TextStreamPart; + } + + const frames: string[] = []; + for await (const frame of encodeVercelDataStream(source())) { + frames.push(frame); + } + + // 'unknown-internal' is silently dropped + expect(frames).toHaveLength(2); + expect(frames[0]).toBe('0:"Hi"\n'); + expect(frames[1]).toMatch(/^d:/); + }); + + it('should handle empty stream', async () => { + async function* source(): AsyncIterable> { + // empty + } + + const frames: string[] = []; + for await (const frame of encodeVercelDataStream(source())) { + frames.push(frame); + } + + expect(frames).toHaveLength(0); + }); + + it('should handle tool-call events in stream', async () => { + async function* source(): AsyncIterable> { + yield { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'search', + input: { query: 'test' }, + } as TextStreamPart; + yield { + type: 'tool-result', + toolCallId: 'call_1', + toolName: 'search', + result: { hits: 42 }, + } as TextStreamPart; + yield { + type: 'finish', + finishReason: 'tool-calls', + totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + rawFinishReason: 'tool_calls', + } as unknown as TextStreamPart; + } + + const frames: string[] = []; + for await (const frame of encodeVercelDataStream(source())) { + frames.push(frame); + } + + expect(frames).toHaveLength(3); + expect(frames[0]).toMatch(/^9:/); + expect(frames[1]).toMatch(/^a:/); + expect(frames[2]).toMatch(/^d:/); + + // Verify tool-call frame content + const toolCallPayload = JSON.parse(frames[0].slice(2)); + expect(toolCallPayload.toolCallId).toBe('call_1'); + expect(toolCallPayload.args).toEqual({ query: 'test' }); + }); +}); diff --git a/packages/services/service-ai/src/adapters/index.ts b/packages/services/service-ai/src/adapters/index.ts index d9a754db7..bec1dc1f2 100644 --- a/packages/services/service-ai/src/adapters/index.ts +++ b/packages/services/service-ai/src/adapters/index.ts @@ -2,3 +2,5 @@ export type { LLMAdapter } from '@objectstack/spec/contracts'; export { MemoryLLMAdapter } from './memory-adapter.js'; +export { VercelLLMAdapter } from './vercel-adapter.js'; +export type { VercelLLMAdapterConfig } from './vercel-adapter.js'; diff --git a/packages/services/service-ai/src/adapters/vercel-adapter.ts b/packages/services/service-ai/src/adapters/vercel-adapter.ts new file mode 100644 index 000000000..f15b69b69 --- /dev/null +++ b/packages/services/service-ai/src/adapters/vercel-adapter.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { + ModelMessage, + AIRequestOptions, + AIResult, + TextStreamPart, + ToolSet, +} from '@objectstack/spec/contracts'; +import type { LLMAdapter } from '@objectstack/spec/contracts'; +import type { AIToolDefinition } from '@objectstack/spec/contracts'; +import type { LanguageModelV3 } from '@ai-sdk/provider'; +import { generateText, streamText, tool as vercelTool, jsonSchema } from 'ai'; + +/** + * Convert ObjectStack `AIRequestOptions` into the subset of Vercel AI SDK + * options supported by `generateText` / `streamText`. + * + * Forwards: temperature, maxTokens, stop (→ stopSequences), tools, toolChoice. + */ +function buildVercelOptions(options?: AIRequestOptions): Record { + if (!options) return {}; + + const opts: Record = {}; + + if (options.temperature != null) opts.temperature = options.temperature; + if (options.maxTokens != null) opts.maxTokens = options.maxTokens; + if (options.stop?.length) opts.stopSequences = options.stop; + + if (options.tools?.length) { + const tools: Record = {}; + for (const t of options.tools as AIToolDefinition[]) { + tools[t.name] = vercelTool({ + description: t.description, + inputSchema: jsonSchema(t.parameters as any), + }); + } + opts.tools = tools; + } + + if (options.toolChoice != null) { + opts.toolChoice = options.toolChoice; + } + + return opts; +} + +/** + * VercelLLMAdapter — Production LLM adapter powered by the Vercel AI SDK. + * + * Wraps `generateText` / `streamText` from the `ai` package, delegating to + * any Vercel AI SDK–compatible model provider (OpenAI, Anthropic, Google, + * Ollama, etc.). + * + * @example + * ```typescript + * import { openai } from '@ai-sdk/openai'; + * import { VercelLLMAdapter } from '@objectstack/service-ai'; + * + * const adapter = new VercelLLMAdapter({ model: openai('gpt-4o') }); + * ``` + */ +export class VercelLLMAdapter implements LLMAdapter { + readonly name = 'vercel'; + + private readonly model: LanguageModelV3; + + constructor(config: VercelLLMAdapterConfig) { + this.model = config.model; + } + + async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise { + const result = await generateText({ + model: this.model, + messages, + ...buildVercelOptions(options), + }); + + return { + content: result.text, + model: result.response?.modelId, + toolCalls: result.toolCalls?.length ? result.toolCalls : undefined, + usage: result.usage ? { + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + } : undefined, + }; + } + + async complete(prompt: string, options?: AIRequestOptions): Promise { + const result = await generateText({ + model: this.model, + prompt, + ...buildVercelOptions(options), + }); + + return { + content: result.text, + model: result.response?.modelId, + usage: result.usage ? { + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + } : undefined, + }; + } + + async *streamChat( + messages: ModelMessage[], + options?: AIRequestOptions, + ): AsyncIterable> { + const result = streamText({ + model: this.model, + messages, + ...buildVercelOptions(options), + }); + + for await (const part of result.fullStream) { + yield part as TextStreamPart; + } + } + + async embed(input: string | string[]): Promise { + // Vercel AI SDK uses a separate EmbeddingModel — not supported via this adapter. + throw new Error( + '[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. ' + + 'Configure an embedding adapter instead.', + ); + } + + async listModels(): Promise { + // Model listing is provider-specific and not available through the base SDK. + return []; + } +} + +/** + * Configuration for the Vercel LLM adapter. + */ +export interface VercelLLMAdapterConfig { + /** + * A Vercel AI SDK–compatible language model instance. + * + * @example `openai('gpt-4o')` or `anthropic('claude-sonnet-4-20250514')` + */ + model: LanguageModelV3; +} diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index df68670e7..3802d277f 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -10,8 +10,13 @@ export type { AIServicePluginOptions } from './plugin.js'; // Adapters export { MemoryLLMAdapter } from './adapters/memory-adapter.js'; +export { VercelLLMAdapter } from './adapters/vercel-adapter.js'; +export type { VercelLLMAdapterConfig } from './adapters/vercel-adapter.js'; export type { LLMAdapter } from '@objectstack/spec/contracts'; +// Vercel Data Stream encoder +export { encodeStreamPart, encodeVercelDataStream } from './stream/vercel-stream-encoder.js'; + // Conversation export { InMemoryConversationService } from './conversation/in-memory-conversation-service.js'; export { ObjectQLConversationService } from './conversation/objectql-conversation-service.js'; diff --git a/packages/services/service-ai/src/routes/ai-routes.ts b/packages/services/service-ai/src/routes/ai-routes.ts index f4fd97a54..edc5fb2cd 100644 --- a/packages/services/service-ai/src/routes/ai-routes.ts +++ b/packages/services/service-ai/src/routes/ai-routes.ts @@ -63,6 +63,14 @@ export interface RouteResponse { stream?: boolean; /** Async iterable of SSE events (when stream=true) */ events?: AsyncIterable; + /** + * When `true`, the HTTP server layer should encode the `events` iterable + * using the Vercel AI Data Stream Protocol frame format (`0:`, `9:`, `d:`, …) + * instead of generic SSE `data:` lines. + * + * @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol + */ + vercelDataStream?: boolean; } /** Valid message roles accepted by the AI routes. */ @@ -71,6 +79,9 @@ const VALID_ROLES = new Set(['system', 'user', 'assistant', 'tool']); /** * Validate that `raw` is a well-formed message. * Returns null on success, or an error string on failure. + * + * Accepts both simple string content (legacy) and Vercel AI SDK array content + * (e.g. `[{ type: 'text', text: '...' }]`). */ function validateMessage(raw: unknown): string | null { if (typeof raw !== 'object' || raw === null) { @@ -80,10 +91,27 @@ function validateMessage(raw: unknown): string | null { if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) { return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`; } - if (typeof msg.content !== 'string') { - return 'message.content must be a string'; + const content = msg.content; + if (typeof content === 'string') { + return null; } - return null; + if (Array.isArray(content)) { + const parts = content as unknown[]; + for (const part of parts) { + if (typeof part !== 'object' || part === null) { + return 'message.content array elements must be non-null objects'; + } + const partObj = part as Record; + if (typeof partObj.type !== 'string') { + return 'each message.content array element must have a string "type" property'; + } + if (partObj.type === 'text' && typeof partObj.text !== 'string') { + return 'message.content elements with type "text" must have a string "text" property'; + } + } + return null; + } + return 'message.content must be a string or an array'; } /** @@ -112,18 +140,26 @@ export function buildAIRoutes( ): RouteDefinition[] { return [ // ── Chat ──────────────────────────────────────────────────── + // + // Dual-mode endpoint compatible with both the legacy ObjectStack + // format (`{ messages, options }`) and the Vercel AI SDK useChat + // flat format (`{ messages, system, model, stream, … }`). + // + // Behaviour: + // • `stream !== false` → Vercel Data Stream Protocol (SSE) + // • `stream === false` → JSON response (legacy) + // { method: 'POST', path: '/api/v1/ai/chat', - description: 'Synchronous chat completion', + description: 'Chat completion (supports Vercel AI Data Stream Protocol)', auth: true, permissions: ['ai:chat'], handler: async (req) => { - const { messages, options } = (req.body ?? {}) as { - messages?: unknown[]; - options?: Record; - }; + const body = (req.body ?? {}) as Record; + // ── Parse messages ─────────────────────────────────── + const messages = body.messages as unknown[] | undefined; if (!Array.isArray(messages) || messages.length === 0) { return { status: 400, body: { error: 'messages array is required' } }; } @@ -133,8 +169,52 @@ export function buildAIRoutes( if (err) return { status: 400, body: { error: err } }; } + // ── Resolve options ────────────────────────────────── + // Accept legacy nested `options` object **or** Vercel-style + // flat fields (`model`, `temperature`, `maxTokens`). + const nested = (body.options ?? {}) as Record; + const resolvedOptions: Record = { + ...nested, + ...(body.model != null && { model: body.model }), + ...(body.temperature != null && { temperature: body.temperature }), + ...(body.maxTokens != null && { maxTokens: body.maxTokens }), + }; + + // ── Prepend system prompt ──────────────────────────── + // Vercel useChat sends `system` (or the deprecated `systemPrompt`) + // as a top-level field. We prepend it as a system message. + const rawSystemPrompt = body.system ?? body.systemPrompt; + if (rawSystemPrompt != null && typeof rawSystemPrompt !== 'string') { + return { status: 400, body: { error: 'system/systemPrompt must be a string' } }; + } + const systemPrompt = rawSystemPrompt as string | undefined; + const finalMessages: ModelMessage[] = [ + ...(systemPrompt + ? [{ role: 'system' as const, content: systemPrompt }] + : []), + ...(messages as ModelMessage[]), + ]; + + // ── Choose response mode ───────────────────────────── + const wantStream = body.stream !== false; + + if (wantStream) { + // Vercel Data Stream Protocol (SSE) + try { + if (!aiService.streamChat) { + return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } }; + } + const events = aiService.streamChat(finalMessages, resolvedOptions as any); + return { status: 200, stream: true, vercelDataStream: true, events }; + } catch (err) { + logger.error('[AI Route] /chat stream error', err instanceof Error ? err : undefined); + return { status: 500, body: { error: 'Internal AI service error' } }; + } + } + + // JSON response (non-streaming) try { - const result = await aiService.chat(messages as ModelMessage[], options as any); + const result = await aiService.chat(finalMessages, resolvedOptions as any); return { status: 200, body: result }; } catch (err) { logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined); diff --git a/packages/services/service-ai/src/stream/index.ts b/packages/services/service-ai/src/stream/index.ts new file mode 100644 index 000000000..5f8765079 --- /dev/null +++ b/packages/services/service-ai/src/stream/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { encodeStreamPart, encodeVercelDataStream } from './vercel-stream-encoder.js'; diff --git a/packages/services/service-ai/src/stream/vercel-stream-encoder.ts b/packages/services/service-ai/src/stream/vercel-stream-encoder.ts new file mode 100644 index 000000000..882821ec8 --- /dev/null +++ b/packages/services/service-ai/src/stream/vercel-stream-encoder.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Vercel Data Stream Encoder + * + * Converts `AsyncIterable>` (the internal ObjectStack + * streaming format, aligned with Vercel AI SDK types) into the Vercel AI SDK + * **Data Stream Protocol** wire format. + * + * Each frame is a single line: `:\n` + * + * | Code | Description | Payload shape | + * |:-----|:-------------------------|:-------------------------------------------------------------| + * | `0` | Text delta | `""` | + * | `9` | Tool call (full) | `{"toolCallId","toolName","args"}` | + * | `b` | Tool call start | `{"toolCallId","toolName"}` | + * | `c` | Tool call delta | `{"toolCallId","argsTextDelta"}` | + * | `a` | Tool result | `{"toolCallId","result"}` | + * | `d` | Finish (message-level) | `{"finishReason","usage"?}` | + * | `e` | Step finish | `{"finishReason","usage"?,"isContinued"?}` | + * + * @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol + */ + +import type { TextStreamPart, ToolSet } from 'ai'; + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Encode a single `TextStreamPart` event into its Vercel Data Stream frame(s). + * + * Returns an empty string for event types that have no wire-format mapping + * (e.g. internal-only events). + */ +export function encodeStreamPart(part: TextStreamPart): string { + switch (part.type) { + // ── Text ────────────────────────────────────────────────── + case 'text-delta': + return `0:${JSON.stringify(part.text)}\n`; + + // ── Tool calling ───────────────────────────────────────── + case 'tool-call': + return `9:${JSON.stringify({ + toolCallId: part.toolCallId, + toolName: part.toolName, + args: part.input, + })}\n`; + + case 'tool-call-streaming-start': + return `b:${JSON.stringify({ + toolCallId: part.toolCallId, + toolName: part.toolName, + })}\n`; + + case 'tool-call-delta': + return `c:${JSON.stringify({ + toolCallId: part.toolCallId, + argsTextDelta: part.argsTextDelta, + })}\n`; + + case 'tool-result': + return `a:${JSON.stringify({ + toolCallId: part.toolCallId, + result: part.result, + })}\n`; + + // ── Finish / Step ──────────────────────────────────────── + case 'finish': + return `d:${JSON.stringify({ + finishReason: part.finishReason, + usage: part.totalUsage ?? undefined, + })}\n`; + + case 'step-finish': + return `e:${JSON.stringify({ + finishReason: part.finishReason, + usage: part.totalUsage ?? undefined, + isContinued: part.isContinued ?? false, + })}\n`; + + // ── Unhandled types (silently skip) ────────────────────── + default: + return ''; + } +} + +/** + * Transform an `AsyncIterable` into an `AsyncIterable` + * where each yielded string is a Vercel Data Stream frame. + * + * Empty frames (from unmapped event types) are silently dropped. + */ +export async function* encodeVercelDataStream( + events: AsyncIterable>, +): AsyncIterable { + for await (const part of events) { + const frame = encodeStreamPart(part); + if (frame) { + yield frame; + } + } +}