From af23ad1cf2ae8827716ed98b1c5f4ce77ba3a513 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:04:35 +0000 Subject: [PATCH 1/5] Initial plan From 8a8476a9901a28374faaca858e8f34cbe498fef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:11:46 +0000 Subject: [PATCH 2/5] Changes before error encountered Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/74cfdaa5-9d61-426c-ba7c-ecaba5f480f2 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../services/service-ai/src/adapters/index.ts | 2 + .../service-ai/src/adapters/vercel-adapter.ts | 117 ++++++++++++++++++ packages/services/service-ai/src/index.ts | 5 + .../service-ai/src/routes/ai-routes.ts | 76 ++++++++++-- .../services/service-ai/src/stream/index.ts | 3 + .../src/stream/vercel-stream-encoder.ts | 102 +++++++++++++++ 6 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 packages/services/service-ai/src/adapters/vercel-adapter.ts create mode 100644 packages/services/service-ai/src/stream/index.ts create mode 100644 packages/services/service-ai/src/stream/vercel-stream-encoder.ts 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..45f71b0af --- /dev/null +++ b/packages/services/service-ai/src/adapters/vercel-adapter.ts @@ -0,0 +1,117 @@ +// 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 { LanguageModelV3 } from '@ai-sdk/provider'; +import { generateText, streamText } from 'ai'; + +/** + * 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, + temperature: options?.temperature, + maxTokens: options?.maxTokens, + }); + + 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, + temperature: options?.temperature, + maxTokens: options?.maxTokens, + }); + + 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, + temperature: options?.temperature, + maxTokens: options?.maxTokens, + }); + + 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..774e1a928 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,8 +91,9 @@ 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'; + // Accept string content (legacy) or array content (Vercel multi-part) + if (typeof msg.content !== 'string' && !Array.isArray(msg.content)) { + return 'message.content must be a string or an array'; } return null; } @@ -112,18 +124,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 +153,48 @@ 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 systemPrompt = (body.system ?? body.systemPrompt) 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; + } + } +} From 68c79e02fd32c736b2871f1381143486fe47592f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:39:49 +0000 Subject: [PATCH 3/5] Fix failing test, add Vercel Data Stream encoder tests, update CHANGELOG - Fix POST /api/v1/ai/chat test: now defaults to streaming mode, legacy JSON mode requires stream: false - Add 18 new tests covering vercel-stream-encoder (encodeStreamPart, encodeVercelDataStream), route dual-mode, systemPrompt, flat options, and array content validation - Update message validation to accept array content for Vercel multi-part - Update CHANGELOG.md with migration notes for Data Stream Protocol - All 176 tests passing (up from 158) Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/fc1d7caa-ef41-4b0b-9ce4-267c262596b4 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- CHANGELOG.md | 20 ++ .../src/__tests__/ai-service.test.ts | 93 ++++++- .../__tests__/vercel-stream-encoder.test.ts | 234 ++++++++++++++++++ 3 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ca5e312..29108f154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 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..87625c1c6 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,7 +619,7 @@ 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')!; @@ -620,6 +708,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..ec937c2da --- /dev/null +++ b/packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts @@ -0,0 +1,234 @@ +// 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); + }); + + 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' }); + }); +}); From 50ec638889bc66c37277438a5ec27c69c2c6a8e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:51:08 +0000 Subject: [PATCH 4/5] Address code review: add round-trip decode assertion, expand content validation tests Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/fc1d7caa-ef41-4b0b-9ce4-267c262596b4 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- .../service-ai/src/__tests__/ai-service.test.ts | 16 +++++++++++++++- .../src/__tests__/vercel-stream-encoder.test.ts | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 87625c1c6..ba721741d 100644 --- a/packages/services/service-ai/src/__tests__/ai-service.test.ts +++ b/packages/services/service-ai/src/__tests__/ai-service.test.ts @@ -623,12 +623,26 @@ describe('AI Routes', () => { 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 () => { 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 index ec937c2da..06961e69b 100644 --- a/packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts +++ b/packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts @@ -19,6 +19,10 @@ describe('encodeStreamPart', () => { 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', () => { From aff215981355cb77c5d50d4f3111182203af8ee0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:53:22 +0000 Subject: [PATCH 5/5] Address all review comments: validate array content shape, validate systemPrompt type, forward all AIRequestOptions to Vercel adapter - validateMessage: structural validation for array content elements (non-null objects with string type, text property for type:text) - systemPrompt: runtime string validation, returns 400 for non-string values - VercelLLMAdapter: buildVercelOptions helper forwards stop, tools, toolChoice to chat/complete/streamChat Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/ac262d54-6f0d-47a1-967b-c1f4b7636378 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../service-ai/src/adapters/vercel-adapter.ts | 45 ++++++++++++++++--- .../service-ai/src/routes/ai-routes.ts | 30 ++++++++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/services/service-ai/src/adapters/vercel-adapter.ts b/packages/services/service-ai/src/adapters/vercel-adapter.ts index 45f71b0af..f15b69b69 100644 --- a/packages/services/service-ai/src/adapters/vercel-adapter.ts +++ b/packages/services/service-ai/src/adapters/vercel-adapter.ts @@ -8,8 +8,42 @@ import type { 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 } from 'ai'; +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. @@ -39,8 +73,7 @@ export class VercelLLMAdapter implements LLMAdapter { const result = await generateText({ model: this.model, messages, - temperature: options?.temperature, - maxTokens: options?.maxTokens, + ...buildVercelOptions(options), }); return { @@ -59,8 +92,7 @@ export class VercelLLMAdapter implements LLMAdapter { const result = await generateText({ model: this.model, prompt, - temperature: options?.temperature, - maxTokens: options?.maxTokens, + ...buildVercelOptions(options), }); return { @@ -81,8 +113,7 @@ export class VercelLLMAdapter implements LLMAdapter { const result = streamText({ model: this.model, messages, - temperature: options?.temperature, - maxTokens: options?.maxTokens, + ...buildVercelOptions(options), }); for await (const part of result.fullStream) { diff --git a/packages/services/service-ai/src/routes/ai-routes.ts b/packages/services/service-ai/src/routes/ai-routes.ts index 774e1a928..edc5fb2cd 100644 --- a/packages/services/service-ai/src/routes/ai-routes.ts +++ b/packages/services/service-ai/src/routes/ai-routes.ts @@ -91,11 +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(', ')}`; } - // Accept string content (legacy) or array content (Vercel multi-part) - if (typeof msg.content !== 'string' && !Array.isArray(msg.content)) { - return 'message.content must be a string or an array'; + 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'; } /** @@ -167,7 +183,11 @@ export function buildAIRoutes( // ── Prepend system prompt ──────────────────────────── // Vercel useChat sends `system` (or the deprecated `systemPrompt`) // as a top-level field. We prepend it as a system message. - const systemPrompt = (body.system ?? body.systemPrompt) as string | undefined; + 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 }]