From bbc6c57690d40e0fae238d2c4c70609ec606392a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:09:34 +0000 Subject: [PATCH 1/3] feat(client,spec)!: express the AI surface that exists, retire the declarations that never did (#3718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `client.ai` and the real AI service were disjoint sets. The namespace held `nlq` / `suggest` / `insights`, whose URLs no repo has ever mounted (deleted in v17), while `service-ai` mounted 12 routes the SDK could not reach at all. v17 closed the first half. This closes the second. The SDK now expresses every AI route meant to be tenant surface: ai.chat / ai.chatStream POST /api/v1/ai/chat (JSON | UI Message Stream) ai.complete POST /api/v1/ai/complete ai.models GET /api/v1/ai/models ai.conversations.{create,list,get,update,delete,addMessage} `chat` forces `stream: false` — the endpoint streams by default, so the JSON method has to say so or `res.json()` throws on the first frame. `chatStream` returns a promise for an async iterable rather than being an async generator, so the request is issued (and an HTTP error thrown) on call, not on first iteration. Its SSE parser reads line-by-line, not frame-by-frame: the encoder also emits single-`\n` `g:` reasoning lines that a `\n\n` split would glue onto the next event. The spec's dead AI declarations go with them — none had an implementation anywhere, and none had a runtime consumer: - Ai{Nlq,Suggest,Insights}{Request,Response}[Schema] → the wire shapes of the routes that exist (AiChat*, AiStreamChunk, AiCompleteRequest, AiModelsResponse, AiConversation, AiMessage, {Create,List,Update}Ai*). The six retired JSON Schemas leave json-schema.manifest.json deliberately (#2978). - DEFAULT_AI_ROUTES → deleted; getDefaultRouteRegistrations() returns 8 groups. Re-declaring the real routes there would recreate the same illusion: they are mounted from another repo, and this table has no runtime consumer. - AiProtocol (aiNlq? / aiSuggest? / aiInsights?) → deleted. Nothing implemented it, nothing dispatched through it. The real server contract is IAIService + IAIConversationService in @objectstack/spec/contracts. Guard: /api/v1/ai/ becomes a bounded prefix exemption in the capstone (#3642) alongside the control plane — only `ai.*` may use it, and the namespace must still be reaching it. The reachability check lives where the routes are, in cloud's ai-route-ledger.conformance.test.ts, which reads buildAIRoutes() and drives this SDK against it. The wildcard-only bound stays 0: these URLs never touch the `* /ai/**` row that certified three dead methods for years. The four replaced client tests mocked fetch and asserted the URL the client BUILT, never that anything answered it. The new ones assert only what this repo can honestly know — verb, path, and the body decisions the SDK makes for the caller — and leave "does it resolve" to the ledger next to the routes. Refs #3718, #3708, #3642, #3563 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN --- .../ai-namespace-expresses-real-surface.md | 65 ++++ content/docs/api/client-sdk.mdx | 49 ++- content/docs/api/plugin-endpoints.mdx | 42 +-- content/docs/references/api/protocol.mdx | 123 ++++++-- .../client/src/client-url-conformance.test.ts | 63 +++- packages/client/src/client.test.ts | 132 +++++++- packages/client/src/index.ts | 287 ++++++++++++++++-- packages/runtime/src/route-ledger.ts | 16 +- packages/spec/json-schema.manifest.json | 17 +- packages/spec/src/api/plugin-rest-api.test.ts | 36 +-- packages/spec/src/api/plugin-rest-api.zod.ts | 97 ++---- packages/spec/src/api/protocol.test.ts | 66 ++-- packages/spec/src/api/protocol.zod.ts | 210 +++++++++---- packages/spec/src/kernel/manifest.zod.ts | 4 +- 14 files changed, 916 insertions(+), 291 deletions(-) create mode 100644 .changeset/ai-namespace-expresses-real-surface.md diff --git a/.changeset/ai-namespace-expresses-real-surface.md b/.changeset/ai-namespace-expresses-real-surface.md new file mode 100644 index 0000000000..e2530f5d65 --- /dev/null +++ b/.changeset/ai-namespace-expresses-real-surface.md @@ -0,0 +1,65 @@ +--- +"@objectstack/client": major +"@objectstack/spec": major +--- + +feat(client,spec)!: the SDK's `ai` namespace now expresses the AI surface that exists (#3718) + +`client.ai` and the AI service were **disjoint sets**. The namespace held three +methods — `nlq`, `suggest`, `insights` — whose URLs no repo has ever mounted +(removed in v17), while `service-ai` mounted 12 routes the SDK could not reach +at all. v17 closed the first half by deleting the dead methods. This closes the +second: the SDK now reaches every route that is meant to be tenant API surface. + +| SDK | Route | +|---|---| +| `ai.chat(request)` | `POST /api/v1/ai/chat` — forces `stream: false`, so the JSON mode is what you get | +| `ai.chatStream(request)` | `POST /api/v1/ai/chat` — `AsyncIterable` of UI Message Stream frames | +| `ai.complete(request)` | `POST /api/v1/ai/complete` | +| `ai.models()` | `GET /api/v1/ai/models` — the ADR-0028 plan-filtered picker list | +| `ai.conversations.create/list/get/update/delete/addMessage` | the six `/api/v1/ai/conversations` routes | + +`ai.chatStream` returns a promise for an async iterable rather than being an +async generator, so the request is issued — and an HTTP error thrown — when you +call it, not when you first iterate. + +**Where the server is.** `service-ai` is a Cloud/EE package in the `cloud` +repo; this repo only proxies `/api/v1/ai/**` and 404s `AI service is not +configured` without it. Check `discovery.services` before calling, exactly as +for any other plugin-provided namespace. For a React chat UI, `useChat()` +(`@ai-sdk/react`) is still the better client — it speaks the same protocol +`ai.chatStream` parses and owns message state; these methods are for callers +that are not components. + +**Breaking — the spec's dead AI declarations are retired.** All three had no +implementation anywhere and no runtime consumer: + +- `Ai{Nlq,Suggest,Insights}{Request,Response}[Schema]` → replaced by the wire + shapes of the real routes: `AiChat{Request,Response}`, `AiStreamChunk`, + `AiCompleteRequest`, `AiModelsResponse`, `AiConversation`, `AiMessage`, + `{Create,List,Update}AiConversation*`. The six retired JSON Schemas are + dropped from `json-schema.manifest.json` (deliberate retirement, #2978). +- `DEFAULT_AI_ROUTES` → deleted, and `getDefaultRouteRegistrations()` returns 8 + groups instead of 9. It declared the three phantom endpoints and had no + runtime consumer; re-declaring the real ones here would recreate the same + illusion, since they are mounted from another repo. +- `AiProtocol` (`aiNlq?` / `aiSuggest?` / `aiInsights?`) → deleted. Nothing + implemented it and nothing dispatched through it. The real server contract is + `IAIService` + `IAIConversationService` in `@objectstack/spec/contracts`. + +**The guard.** `/api/v1/ai/` becomes a bounded prefix exemption in the capstone +(#3642) alongside the control plane — bounded from both ends: only `ai.*` may +use it, and the namespace must still be reaching it. That is not a +wave-through. The reachability check lives where the routes are: +`cloud`'s `packages/service-ai/src/ai-route-ledger.conformance.test.ts` reads +the table `buildAIRoutes()` returns and drives this SDK against it, so an +`ai.*` URL that stops resolving fails a test in the repo that mounts it. The +wildcard-only bound stays **0** — these URLs never touch the `* /ai/**` row, +which is what certified three dead methods for years. + +The four replaced client tests are worth naming: they mocked `fetch` and +asserted the URL the client *built*, never that anything answered it, and +passed for years against endpoints that did not exist. The new ones assert only +what this repo can honestly know — verb, path, and the body decisions the SDK +makes for you (`stream: false` on `chat`, the 204 on `delete`, SSE frame +parsing) — and leave "does it resolve" to the ledger next to the routes. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 615b6eac8d..72b75011ee 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -128,7 +128,7 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe | **storage** | ✅ | 2 | File upload & download | | **i18n** | ✅ | 3 | Internationalization | | **notifications** | ✅ | 3 | List, mark-read, mark-all-read (inbox/receipt spine, ADR-0030) | -| **ai** | ✅ | 3 | AI services (NLQ, suggest, insights) | +| **ai** | ✅ | 10 | Chat (JSON + streaming), completion, model picker, conversation CRUD — the surface `service-ai` (Cloud/EE) mounts | The former `permissions`, `views`, `workflow`, and `realtime` namespaces (and the notifications device/preference helpers) were removed in #3612: no server @@ -312,21 +312,40 @@ await client.notifications.markAllRead(); // through sys_inbox_message and tracks read-state in sys_notification_receipt. // These helpers will be repointed during the objectui bell cut-over. -// AI — the `client.ai` namespace was REMOVED in v17 (#3718). -// -// It held `nlq`, `suggest` and `insights`, which built -// /api/v1/ai/{nlq,suggest,insights}. No server in any repo ever mounted those -// paths, so every call 404ed for the whole life of the namespace. They were -// typed and shipped, which is exactly why they looked usable. -// -// The AI surface that DOES exist is served by `service-ai` (Cloud/EE): -// POST /api/v1/ai/chat and /chat/stream, POST /complete, GET /models, and six -// /conversations routes. The SDK has no method for any of them yet — that is -// tracked on #3718 as new API, not as a rename of what was removed. +// AI — served by `service-ai` (Cloud/EE); 404s "AI service is not configured" +// when the plugin is absent, so check `discovery.services` first. +const answer = await client.ai.chat({ + messages: [{ role: 'user', content: 'How many open orders this quarter?' }], + conversationId, // omit to have one created and echoed back +}); +answer.content; // string +answer.usage?.totalTokens; + +// Streaming — the Vercel UI Message Stream Protocol, frame by frame. +for await (const frame of await client.ai.chatStream({ messages })) { + if (frame.type === 'text-delta') process.stdout.write(frame.delta as string); +} + +await client.ai.complete({ prompt: 'Summarise this account in one line:' }); +await client.ai.models(); // plan-filtered picker list (ADR-0028) + +// Conversations — all six routes, scoped to the authenticated user server-side. +const conv = await client.ai.conversations.create({ title: 'Q3 pipeline' }); +await client.ai.conversations.list({ limit: 20 }); +await client.ai.conversations.get(conv.id); +await client.ai.conversations.addMessage(conv.id, { role: 'user', content: 'hi' }); +await client.ai.conversations.update(conv.id, { title: 'Renamed' }); +await client.ai.conversations.delete(conv.id); + +// In a React chat UI prefer `useChat()` (`@ai-sdk/react`) over `ai.chatStream`: +// it speaks the same protocol and owns message state. These methods are for +// everything that is not a component — server code, jobs, CLIs, tests. // -// For chat, call the endpoint directly with the Vercel AI SDK -// (`useChat()` from `@ai-sdk/react`); it speaks the Data Stream Protocol that -// POST /api/v1/ai/chat serves. +// #3718 history: `client.ai` used to hold `nlq`, `suggest` and `insights`, +// building /api/v1/ai/{nlq,suggest,insights}. No server in any repo ever +// mounted those paths, so every call 404ed for the whole life of the +// namespace. They were typed and shipped, which is exactly why they looked +// usable. v17 removed them; the methods above are the surface that exists. // i18n — Internationalization await client.i18n.getLocales(); diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx index a57315e72f..6ff76350aa 100644 --- a/content/docs/api/plugin-endpoints.mdx +++ b/content/docs/api/plugin-endpoints.mdx @@ -95,19 +95,19 @@ The core dispatcher implements only the list / read / read-all routes above. Dev ### AI (`/ai`) — Plugin Required -These are the routes `service-ai` mounts: - -| Method | Endpoint | Description | -|:-------|:---------|:------------| -| POST | `/ai/chat` | Chat completion (Vercel Data Stream or JSON) | -| POST | `/ai/chat/stream` | SSE streaming chat | -| POST | `/ai/complete` | Text completion | -| GET | `/ai/models` | Models this environment offers (ADR-0028) | -| GET | `/ai/status` | Active adapter provenance | -| GET | `/ai/effective-model` | Resolved model ids and their source | -| POST / GET | `/ai/conversations` | Create / list conversations | -| GET / PATCH / DELETE | `/ai/conversations/:id` | Read / update / delete | -| POST | `/ai/conversations/:id/messages` | Append a message | +These are the routes `service-ai` mounts, and the SDK method that reaches each: + +| Method | Endpoint | SDK | Description | +|:-------|:---------|:----|:------------| +| POST | `/ai/chat` | `ai.chat` / `ai.chatStream` | Chat completion — JSON with `stream: false`, otherwise the Vercel UI Message Stream | +| POST | `/ai/chat/stream` | — | Generic-SSE twin of `/ai/chat`: the same completion without the tool loop or persistence | +| POST | `/ai/complete` | `ai.complete` | Text completion | +| GET | `/ai/models` | `ai.models` | Models this environment offers (ADR-0028) | +| GET | `/ai/status` | — | Active adapter provenance (console diagnostics) | +| GET | `/ai/effective-model` | — | Resolved model ids and their source (console diagnostics) | +| POST / GET | `/ai/conversations` | `ai.conversations.create` / `.list` | Create / list conversations | +| GET / PATCH / DELETE | `/ai/conversations/:id` | `ai.conversations.get` / `.update` / `.delete` | Read / update / delete | +| POST | `/ai/conversations/:id/messages` | `ai.conversations.addMessage` | Append a message | **This table used to be inverted (#3718).** It listed `/ai/nlq`, @@ -116,12 +116,16 @@ and its callout stated "there is no `/ai/chat` route", which was wrong. The three phantom routes were declared in `DEFAULT_AI_ROUTES` and called by `client.ai.nlq/suggest/insights`; every call 404ed. **v17 removed that SDK -namespace** rather than build endpoints for it, so nothing calls them now. - -No route above has a client-SDK method yet — reach them directly, or with -`useChat()` (`@ai-sdk/react`) for chat, which speaks the Data Stream Protocol -`POST /ai/chat` serves. Reviewed dispositions for all 12 live in the `cloud` -repo, `packages/service-ai/src/ai-route-ledger.ts`. +namespace** rather than build endpoints for it, and the same issue then gave +the SDK the surface that does exist — the `ai.*` column above. + +Reviewed dispositions for all 12 routes live in the `cloud` repo, +`packages/service-ai/src/ai-route-ledger.ts`, whose conformance test reads +`buildAIRoutes()` and drives the SDK against it. The three rows with no SDK +method are deliberate: `/status` and `/effective-model` are operator +diagnostics, and `/chat/stream` is superseded by `/chat`'s streaming mode. +For a React chat UI prefer `useChat()` (`@ai-sdk/react`) — it speaks the same +protocol `ai.chatStream` parses and owns message state for you. ### i18n (`/i18n`) — Plugin Required diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a8953e7331..840b1295a6 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -20,87 +20,111 @@ validation. Each entry is a canonical `ActionDescriptorSchema`. ## TypeScript Usage ```typescript -import { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; -import type { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; +import { AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiStreamChunk, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; +import type { AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiStreamChunk, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; // Validate data -const result = AiInsightsRequest.parse(data); +const result = AiChatRequest.parse(data); ``` --- -## AiInsightsRequest +## AiChatRequest ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name to analyze | -| **recordId** | `string` | optional | Specific record to analyze | -| **type** | `Enum<'summary' \| 'trends' \| 'anomalies' \| 'recommendations'>` | optional | Type of insight | +| **messages** | `Record[]` | ✅ | Conversation messages (at least one) | +| **system** | `string` | optional | System prompt, prepended as a system message | +| **model** | `string` | optional | Model id override | +| **temperature** | `number` | optional | Sampling temperature | +| **maxTokens** | `integer` | optional | Maximum tokens to generate | +| **stream** | `boolean` | optional | false → JSON response; otherwise the UI Message Stream Protocol | +| **conversationId** | `string` | optional | Conversation to persist this turn into (auto-created when omitted) | +| **turnId** | `string` | optional | Stable per-turn idempotency key (ADR-0013 D1) | +| **options** | `Record` | optional | Legacy nested request options | --- -## AiInsightsResponse +## AiChatResponse ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **insights** | `{ type: string; title: string; description: string; confidence?: number; … }[]` | ✅ | Generated insights | +| **content** | `string` | ✅ | Generated text | +| **model** | `string` | optional | Model that produced it | +| **toolCalls** | `any[]` | optional | Tool calls the model requested (Vercel `ToolCallPart`) | +| **usage** | `{ promptTokens: number; completionTokens: number; totalTokens: number }` | optional | Token usage | +| **conversationId** | `string` | optional | Conversation the turn was persisted into | --- -## AiNlqRequest +## AiCompleteRequest ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **query** | `string` | ✅ | Natural language query string | -| **object** | `string` | optional | Target object context | -| **conversationId** | `string` | optional | Conversation ID for multi-turn queries | +| **prompt** | `string` | ✅ | Prompt text | +| **options** | `Record` | optional | Request options (model, temperature, maxTokens, …) | --- -## AiNlqResponse +## AiConversation ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **query** | `any` | ✅ | Generated structured query (AST) | -| **explanation** | `string` | optional | Human-readable explanation of the query | -| **confidence** | `number` | optional | Confidence score (0-1) | -| **suggestions** | `string[]` | optional | Suggested follow-up queries | +| **id** | `string` | ✅ | Conversation id | +| **title** | `string` | optional | Title / summary | +| **agentId** | `string` | optional | Agent this conversation is bound to | +| **userId** | `string` | optional | Owning user | +| **messages** | `Record[]` | ✅ | Message history | +| **createdAt** | `string` | ✅ | Creation timestamp (ISO 8601) | +| **updatedAt** | `string` | ✅ | Last update timestamp (ISO 8601) | +| **metadata** | `Record` | optional | Conversation metadata | --- -## AiSuggestRequest +## AiMessage ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object name for context | -| **field** | `string` | optional | Field to suggest values for | -| **recordId** | `string` | optional | Record ID for context | -| **partial** | `string` | optional | Partial input for completion | +| **role** | `Enum<'system' \| 'user' \| 'assistant' \| 'tool'>` | ✅ | Message role | +| **content** | `any` | optional | Message content: a string, or an array of content parts | +| **parts** | `any[]` | optional | Vercel AI SDK v6 message parts (alternative to `content`) | --- -## AiSuggestResponse +## AiModelsResponse ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **suggestions** | `{ value: any; label: string; confidence?: number; reason?: string }[]` | ✅ | Suggested values | +| **models** | `string \| { id: string; label: string; default: boolean }[]` | ✅ | Models this environment offers | +| **defaultModel** | `string` | optional | Default model id, when the service reports one | + + +--- + +## AiStreamChunk + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Frame type (text-delta, tool-input-available, finish, error, …) | --- @@ -196,6 +220,19 @@ const result = AiInsightsRequest.parse(data); | **reason** | `string` | optional | Reason if denied | +--- + +## CreateAiConversationRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string` | optional | Initial title | +| **agentId** | `string` | optional | Agent to bind the conversation to | +| **metadata** | `Record` | optional | Conversation metadata | + + --- ## CreateDataRequest @@ -854,6 +891,30 @@ const result = AiInsightsRequest.parse(data); | **count** | `boolean` | optional | Include total count in response. | +--- + +## ListAiConversationsRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **agentId** | `string` | optional | Filter by agent | +| **limit** | `integer` | optional | Maximum conversations to return | +| **cursor** | `string` | optional | Pagination cursor | + + +--- + +## ListAiConversationsResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **conversations** | `{ id: string; title?: string; agentId?: string; userId?: string; … }[]` | ✅ | Matching conversations | + + --- ## ListNotificationsRequest @@ -1156,6 +1217,18 @@ const result = AiInsightsRequest.parse(data); | **success** | `boolean` | ✅ | Whether unregistration succeeded | +--- + +## UpdateAiConversationRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string` | optional | New title | +| **metadata** | `Record` | optional | New metadata | + + --- ## UpdateDataRequest diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index 211e149430..311042e694 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -151,23 +151,34 @@ function matches(verb: string, path: string): Pattern | undefined { const CONTROL_PLANE = '/api/v1/cloud/'; const CONTROL_PLANE_NAMESPACE = 'projects.'; -/* - * There is no AI exemption any more, and that is the end state — not an - * omission. +/** + * The AI plane. `/api/v1/ai/*` is served by `service-ai`, a Cloud/EE package in + * the sibling `cloud` repo; this repo's dispatcher only PROXIES the prefix to + * whatever `buildAIRoutes()` mounted (or 404s "AI service is not configured"). + * No in-repo ledger can enumerate that table, so — exactly like the control + * plane above — the prefix is exempt HERE and guarded THERE. + * + * READ THE HISTORY BEFORE WIDENING THIS. `/api/v1/ai/` used to be matched by + * the dispatcher's `* /ai/**` wildcard row, and that was worse than no match: + * a wildcard claims the FAMILY, so `ai.nlq` / `ai.suggest` / `ai.insights` + * counted as matched when not one of their URLs was in the real table at all. + * Three SDK methods 404ed for years behind a green row (#3718). v17 deleted + * them; #3718 then expressed the surface that does exist (`ai.chat`, + * `ai.chatStream`, `ai.complete`, `ai.models`, `ai.conversations.*`), which is + * why an exemption is needed again. * - * `/api/v1/ai/` was briefly exempted here the way the control plane still is: - * `service-ai` is a Cloud/EE package in the `cloud` repo, so no ledger here can - * enumerate its table. Before that it was a `* /ai/**` WILDCARD match, which - * was worse — a wildcard claims the family, so all three `ai.*` methods counted - * as matched when none of their URLs was in the real table at all (#3718). + * It is NOT a wave-through, for the same reason the control plane's is not: + * `cloud`'s `packages/service-ai/src/ai-route-ledger.conformance.test.ts` + * reads the table `buildAIRoutes()` returns and drives every `ai.*` method on + * this very SDK against it — so an `ai.*` URL that stops resolving fails a + * test in the repo that mounts the route. What this exemption says is "the + * evidence lives on the other side of the boundary", not "no evidence needed". * - * v17 removed the `ai` namespace outright, so no SDK method targets the prefix - * and there is nothing left to exempt. If an `ai.*` method is ever added back, - * it will match no route here and fail the `unmatched` assertion below — which - * is correct: the real AI surface is ledgered in `cloud` - * (`packages/service-ai/src/ai-route-ledger.ts`), and a new method should be - * verified against it there rather than waved through by a prefix here. + * Bounded from both ends below: only `ai.*` may use it, and the namespace must + * still be reaching it. */ +const AI_PLANE = '/api/v1/ai/'; +const AI_PLANE_NAMESPACE = 'ai.'; // --------------------------------------------------------------------------- // 2. The recorder @@ -347,6 +358,7 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642) const silent: string[] = []; const malformed: string[] = []; const controlPlane: string[] = []; + const aiPlane: string[] = []; const wildcardOnly: string[] = []; for (const name of METHODS) { @@ -368,6 +380,10 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642) } const path = new URL(call.url, BASE).pathname; if (path.startsWith(CONTROL_PLANE)) { controlPlane.push(`${name} → ${call.verb} ${path}`); continue; } + // Checked BEFORE `matches()` on purpose: the dispatcher's `* /ai/**` + // row would otherwise absorb these into `wildcardOnly` — the exact + // false-positive evidence that hid three dead methods (#3718). + if (path.startsWith(AI_PLANE)) { aiPlane.push(`${name} → ${call.verb} ${path}`); continue; } const hit = matches(call.verb, path); if (!hit) { unmatched.push(`${name} → ${call.verb} ${path}`); continue; } if (hit.route.includes('**')) wildcardOnly.push(`${name} → ${call.verb} ${path} (via ${hit.route})`); @@ -402,6 +418,20 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642) ).toEqual([]); expect(controlPlane.length, 'the projects namespace should still be reaching the control plane').toBeGreaterThan(0); + // The AI-plane hole, bounded the same way: only `ai.*` may use it. The + // count assertion is the half that matters most here — if the namespace + // stopped issuing requests, the exemption would silently become a hole + // with nothing behind it, which is how the wildcard row went wrong. + const aiTrespassers = aiPlane.filter((e) => !e.startsWith(AI_PLANE_NAMESPACE)); + expect( + aiTrespassers, + `non-ai methods targeting /api/v1/ai/, which no in-repo ledger can vouch for:\n${aiTrespassers.join('\n')}`, + ).toEqual([]); + expect( + aiPlane.length, + 'the ai namespace should be reaching the AI plane — if it stops, drop the exemption', + ).toBeGreaterThan(0); + // HOW STRONG IS THIS GUARD, HONESTLY. A `**` row asserts only that a prefix // family is CLAIMED, not that the specific URL resolves. That was this @@ -412,8 +442,9 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642) // THAT family (#3718, in `cloud`, where service-ai lives) showed the // wildcard had not been weak evidence but WRONG evidence: none of the three // URLs is in the real table, and nothing in any repo mounts them (#3718). - // v17 removed that namespace outright, so no SDK method targets `/ai/` and - // the bound is 0 with nothing exempted to get there. + // v17 removed that namespace; #3718 then expressed the real one, which is + // reached through the bounded AI-plane exemption above — NOT through the + // wildcard row, so the bound below still stands at 0. // // ZERO IS THE POINT: every remaining matched call rests on an exact route // some ledger enumerated. Raising this bound reintroduces the one kind of diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 1d2a0c6d4f..fb0d45c1e0 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -604,29 +604,131 @@ describe('Notifications namespace', () => { }); }); -describe('AI namespace (removed in v17 — #3718)', () => { +describe('AI namespace (#3718)', () => { /** + * READ THIS BEFORE ADDING A TEST HERE. + * * This block used to hold four passing tests for `ai.nlq`, `ai.suggest` * and `ai.insights`. Every one of them mocked `fetch` and asserted the URL * the client BUILT — never that anything answered it. All three endpoints - * were mounted by nothing, in any repo, for the whole life of those tests. + * were mounted by nothing, in any repo, for the whole life of those tests + * (#3584, #3611, #3636, #3702 are the same shape). * - * That is the shape of test this audit family kept finding behind green - * suites (#3584, #3611, #3636, #3702), so the replacement asserts the one - * thing that is actually true and worth defending: the namespace is gone - * and must not come back without a route behind it. + * So the assertions below are deliberately the *narrow* half — verb, path, + * and the body decisions this SDK makes on the caller's behalf. The claim + * that these paths RESOLVE is not made here and cannot be: the AI service + * is a Cloud/EE package in the `cloud` repo. It is made where the routes + * are, by `packages/service-ai/src/ai-route-ledger.conformance.test.ts`, + * which reads `buildAIRoutes()` and drives this very namespace against it. */ - it('is gone — no method may return without an endpoint to answer it', () => { - const { client } = createMockClient({ success: true, data: {} }); - expect((client as unknown as Record).ai).toBeUndefined(); + it('chat forces stream:false — the endpoint streams by default', async () => { + const { client, fetchMock } = createMockClient({ content: 'hello', model: 'gpt-4o-mini' }); + const result = await client.ai.chat({ messages: [{ role: 'user', content: 'hi' }] }); + expect(result.content).toBe('hello'); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe('http://localhost:3000/api/v1/ai/chat'); + expect(opts.method).toBe('POST'); + // Without this the "JSON" method would come back as an SSE stream and + // `res.json()` would throw on the first frame. + expect(JSON.parse(opts.body).stream).toBe(false); + }); + + it('complete posts the prompt', async () => { + const { client, fetchMock } = createMockClient({ content: '42' }); + const result = await client.ai.complete({ prompt: 'answer:' }); + expect(result.content).toBe('42'); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe('http://localhost:3000/api/v1/ai/complete'); + expect(JSON.parse(opts.body)).toEqual({ prompt: 'answer:' }); + }); + + it('models reads the picker allowlist', async () => { + const { client, fetchMock } = createMockClient({ + models: [{ id: 'gpt-4o-mini', label: 'GPT-4o mini', default: true }], + defaultModel: 'gpt-4o-mini', + }); + const result = await client.ai.models(); + expect(result.defaultModel).toBe('gpt-4o-mini'); + expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/models'); + }); + + it('conversations CRUD targets the six mounted routes', async () => { + const conv = { id: 'c1', messages: [], createdAt: 'now', updatedAt: 'now' }; + + const created = createMockClient(conv); + await created.client.ai.conversations.create({ title: 'Q3' }); + expect(created.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations'); + expect(created.fetchMock.mock.calls[0][1].method).toBe('POST'); + + const listed = createMockClient({ conversations: [conv] }); + const list = await listed.client.ai.conversations.list({ limit: 10 }); + expect(list).toHaveLength(1); + expect(listed.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations?limit=10'); + + const got = createMockClient(conv); + await got.client.ai.conversations.get('c 1'); + expect(got.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations/c%201'); + + const patched = createMockClient(conv); + await patched.client.ai.conversations.update('c1', { title: 'Renamed' }); + expect(patched.fetchMock.mock.calls[0][1].method).toBe('PATCH'); + + const messaged = createMockClient(conv); + await messaged.client.ai.conversations.addMessage('c1', { role: 'user', content: 'hi' }); + expect(messaged.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations/c1/messages'); + }); + + it('conversations.delete reports the 204 the route returns', async () => { + // DELETE answers 204 with no body; unwrapping it would throw in json(). + const { client, fetchMock } = createMockClient(undefined, 204); + expect(await client.ai.conversations.delete('c1')).toEqual({ deleted: true }); + expect(fetchMock.mock.calls[0][1].method).toBe('DELETE'); + }); + + it('chatStream parses the UI Message Stream frames, ignoring [DONE] and `g:` lines', async () => { + const sse = [ + 'data: {"type":"start"}\n\n', + 'data: {"type":"text-delta","id":"0","delta":"Hel"}\n\n', + 'g:{"text":"thinking"}\n', // legacy Data Stream line, single \n + 'data: {"type":"text-delta","id":"0","delta":"lo"}\n\n', + 'data: {"type":"finish","finishReason":"stop"}\n\ndata: [DONE]\n\n', + ]; + const encoder = new TextEncoder(); + let i = 0; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + // Chunk boundaries deliberately fall mid-frame in the last entry. + body: { getReader: () => ({ + read: async () => (i < sse.length + ? { done: false, value: encoder.encode(sse[i++]) } + : { done: true, value: undefined }), + }) }, + }); + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }); + + const frames: any[] = []; + for await (const frame of await client.ai.chatStream({ messages: [{ role: 'user', content: 'hi' }] })) { + frames.push(frame); + } + + expect(frames.map((f) => f.type)).toEqual(['start', 'text-delta', 'text-delta', 'finish']); + expect(frames.filter((f) => f.type === 'text-delta').map((f) => f.delta).join('')).toBe('Hello'); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe('http://localhost:3000/api/v1/ai/chat'); + expect(JSON.parse(opts.body).stream).toBe(true); + expect(opts.headers.Accept).toBe('text/event-stream'); }); - it('still directs chat at the Vercel AI SDK', () => { - // Unchanged guidance, and the reason no `chat` method is being added - // back with the real surface: `useChat()` (`@ai-sdk/react`) speaks the - // Data Stream Protocol against POST /api/v1/ai/chat directly. - const { client } = createMockClient({ success: true, data: {} }); - expect((client as unknown as Record).ai).toBeUndefined(); + it('chatStream fails loudly when the runtime exposes no response body', async () => { + // The request still goes out — the failure is on the first read, which + // is where a fetch polyfill without `Response.body` reveals itself. + const { client, fetchMock } = createMockClient({}); + const stream = await client.ai.chatStream({ messages: [{ role: 'user', content: 'hi' }] }); + expect(fetchMock).toHaveBeenCalledOnce(); + await expect((async () => { for await (const _frame of stream) { /* drain */ } })()) + .rejects.toThrow(/no body/i); }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6d33585942..84e0bc3b30 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -29,10 +29,20 @@ import { ListNotificationsResponse, MarkNotificationsReadResponse, MarkAllNotificationsReadResponse, - // Ai{Nlq,Suggest,Insights}{Request,Response} are no longer imported: the - // `ai` namespace that used them is gone in v17 (#3718). They are still - // RE-EXPORTED below, straight from `@objectstack/spec/api`, so anyone - // holding those types keeps them while the spec still declares them. + // The AI wire types (#3718). `Ai{Nlq,Suggest,Insights}{Request,Response}` + // used to be here; they typed three endpoints nothing has ever mounted and + // went with the methods that called them. These type the routes that exist. + AiMessage, + AiChatRequest, + AiChatResponse, + AiStreamChunk, + AiCompleteRequest, + AiModelsResponse, + AiConversation, + CreateAiConversationRequest, + ListAiConversationsRequest, + ListAiConversationsResponse, + UpdateAiConversationRequest, GetLocalesResponse, GetTranslationsResponse, GetFieldLabelsResponse, @@ -230,6 +240,70 @@ export interface StandardError { details?: Record; } +/** + * Parse an SSE response body into the JSON frames it carries. + * + * Used by `ai.chatStream` (#3718). Both AI streaming routes write one JSON + * object per `data:` line and terminate with `data: [DONE]`, so this reads + * line-by-line rather than splitting on the `\n\n` frame separator: the + * encoder also emits a few single-`\n` `g:`-prefixed lines (the legacy Data + * Stream Protocol form for reasoning deltas) that a frame-split would glue + * onto the next event. Non-`data:` lines are skipped, as is a `data:` payload + * that is not JSON — a malformed frame mid-stream must not destroy the frames + * around it. + * + * A module-level function, not a client method: the SDK's URL-conformance + * sweep enumerates every callable on the client and demands each one either + * issue a request or carry an explicit non-HTTP reason. A parser is neither. + */ +async function* parseEventStream(res: Response): AsyncIterable { + const body = res.body as (ReadableStream & AsyncIterable) | null | undefined; + if (!body) { + throw new Error('Streaming response carried no body — this runtime\'s fetch does not expose `Response.body`'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + const emit = function* (chunk: string): Generator { + buffer += chunk; + let nl: number; + while ((nl = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line.startsWith('data:')) continue; // blank separators, `g:` reasoning frames + const payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + try { + yield JSON.parse(payload) as AiStreamChunk; + } catch { + // A frame the server did not finish writing, or a non-JSON payload. + } + } + }; + + // `getReader()` in the browser and modern Node; async iteration for the + // Node-stream bodies older fetch polyfills hand back. + if (typeof body.getReader === 'function') { + const reader = body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + yield* emit(decoder.decode(value, { stream: true })); + } + } finally { + reader.releaseLock?.(); + } + } else { + for await (const value of body) { + yield* emit(decoder.decode(value as Uint8Array, { stream: true })); + } + } + + yield* emit(decoder.decode()); +} + export class ObjectStackClient { private baseUrl: string; private token?: string; @@ -3466,27 +3540,173 @@ export class ObjectStackClient { } }; - // The `ai` namespace is GONE in v17 (#3718). - // - // It held exactly three methods — `nlq`, `suggest`, `insights` — building - // `/api/v1/ai/{nlq,suggest,insights}`. Nothing in any repo ever mounted those - // paths: they were declared in `DEFAULT_AI_ROUTES` (which has no runtime - // consumer) and typed as optional protocol methods (`aiNlq?` …) nothing - // implements. Every call 404ed, from the first release that shipped them. - // - // What DOES exist is a different surface entirely, served by `service-ai` - // (Cloud/EE): `POST /ai/chat`, `/ai/chat/stream`, `/ai/complete`, - // `GET /ai/models`, and six `/ai/conversations` routes. The SDK expressed - // none of them, so its AI namespace and the real AI surface were disjoint - // sets. Ledgered in `cloud`: `packages/service-ai/src/ai-route-ledger.ts`. - // - // Deliberately removed rather than left deprecated: a typed method that - // always throws is worse than no method — it costs a runtime round-trip to - // discover, where absence is a compile error. Expressing the real surface is - // tracked separately on #3718; it is a new API, not a rename of this one. - // - // For chat specifically the answer is unchanged: use the Vercel AI SDK - // (`useChat()` from `@ai-sdk/react`) directly against the chat endpoint. + /** + * AI Services — the surface `service-ai` really mounts (#3718). + * + * ## What this namespace is, and what it replaced + * + * Until v17 `client.ai` held `nlq`, `suggest` and `insights`, building + * `/api/v1/ai/{nlq,suggest,insights}`. **No repo has ever mounted those + * paths**; every call 404ed from the first release that shipped them. They + * were deleted rather than implemented, because the AI service that was + * actually built serves a different surface entirely — the two sets were + * disjoint. The methods below are that surface: `POST /ai/chat` (JSON or + * streaming), `POST /ai/complete`, `GET /ai/models`, and the six + * `/ai/conversations` routes. + * + * ## Where the server lives + * + * `service-ai` is a **Cloud/EE package in the `cloud` repo**. This repo's + * dispatcher only proxies `/api/v1/ai/**` to whatever `buildAIRoutes()` + * mounted, and 404s `AI service is not configured` when the service is + * absent (the open-source default) — so treat every method here as + * plugin-provided and check `discovery.services` first. + * + * That split is also why the guard for these URLs lives on the other side of + * the repo boundary: `cloud`'s `packages/service-ai/src/ai-route-ledger.ts` + * enumerates the table `buildAIRoutes()` returns and drives this namespace + * against it, so a method here that stops resolving fails a test there. + * + * ## Chat, and `useChat` + * + * `useChat()` from `@ai-sdk/react` remains the right client for a React chat + * UI — it speaks the same UI Message Stream Protocol {@link chatStream} + * parses, and it owns message state. These methods exist for everything that + * is not a React component: server-side callers, jobs, CLIs, tests. + */ + ai = { + /** + * Chat completion, returned as JSON. + * + * Sends `stream: false` — the endpoint streams by default, so the flag is + * forced here rather than left to the caller. Tools are resolved + * server-side before the reply comes back, and the turn is persisted to + * `conversationId` (auto-created and echoed back when omitted). + */ + chat: async (request: AiChatRequest): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/chat`, { + method: 'POST', + body: JSON.stringify({ ...request, stream: false }), + }); + return this.unwrapResponse(res); + }, + + /** + * Chat completion as a stream of {@link AiStreamChunk} frames (the Vercel + * UI Message Stream Protocol). + * + * Returns a promise for an async iterable rather than being an async + * generator itself, so the request is issued — and an HTTP error thrown — + * when you call it, not when you first iterate. + * + * ```ts + * for await (const frame of await client.ai.chatStream({ messages })) { + * if (frame.type === 'text-delta') process.stdout.write(frame.delta); + * } + * ``` + */ + chatStream: async (request: AiChatRequest): Promise> => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/chat`, { + method: 'POST', + headers: { 'Accept': 'text/event-stream' }, + body: JSON.stringify({ ...request, stream: true }), + }); + return parseEventStream(res); + }, + + /** Single-shot text completion. */ + complete: async (request: AiCompleteRequest): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/complete`, { + method: 'POST', + body: JSON.stringify(request), + }); + return this.unwrapResponse(res); + }, + + /** + * Models this environment offers in the chat model picker (ADR-0028) — + * plan-filtered, with the default flagged. Populate a model picker from + * this rather than hard-coding ids. + */ + models: async (): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/models`); + return this.unwrapResponse(res); + }, + + /** + * Persistent conversations. + * + * Every route is scoped to the authenticated user server-side: `create` + * binds the conversation to the caller and the rest 403 on someone else's. + * `userId` in a request body is ignored — it is not a way to act for + * another user. + */ + conversations: { + /** Create a conversation. */ + create: async (request?: CreateAiConversationRequest): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/conversations`, { + method: 'POST', + body: JSON.stringify(request ?? {}), + }); + return this.unwrapResponse(res); + }, + + /** List the caller's conversations, newest first. */ + list: async (options?: ListAiConversationsRequest): Promise => { + const route = this.getRoute('ai'); + const params = new URLSearchParams(); + if (options?.agentId) params.set('agentId', options.agentId); + if (options?.limit !== undefined) params.set('limit', String(options.limit)); + if (options?.cursor) params.set('cursor', options.cursor); + const qs = params.toString(); + const res = await this.fetch(`${this.baseUrl}${route}/conversations${qs ? `?${qs}` : ''}`); + const body = await this.unwrapResponse(res); + return body?.conversations ?? []; + }, + + /** Get one conversation with its full message history. */ + get: async (id: string): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}`); + return this.unwrapResponse(res); + }, + + /** Update mutable fields. At least one of `title` / `metadata` is required. */ + update: async (id: string, patch: UpdateAiConversationRequest): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); + return this.unwrapResponse(res); + }, + + /** Delete a conversation and its messages. */ + delete: async (id: string): Promise<{ deleted: boolean }> => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + if (res.status === 204) return { deleted: true }; + return this.unwrapResponse<{ deleted: boolean }>(res); + }, + + /** Append a message; returns the updated conversation. */ + addMessage: async (id: string, message: AiMessage): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}/messages`, { + method: 'POST', + body: JSON.stringify(message), + }); + return this.unwrapResponse(res); + }, + }, + }; /** * Internationalization Services @@ -4503,12 +4723,17 @@ export type { RegisterDeviceRequest, RegisterDeviceResponse, ListNotificationsResponse, - AiNlqRequest, - AiNlqResponse, - AiSuggestRequest, - AiSuggestResponse, - AiInsightsRequest, - AiInsightsResponse, + AiMessage, + AiChatRequest, + AiChatResponse, + AiStreamChunk, + AiCompleteRequest, + AiModelsResponse, + AiConversation, + CreateAiConversationRequest, + ListAiConversationsRequest, + ListAiConversationsResponse, + UpdateAiConversationRequest, GetLocalesResponse, GetTranslationsResponse, GetFieldLabelsResponse, diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 24958219a6..740b23d9fa 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -41,12 +41,14 @@ * The last 3 were the `ai.*` methods on `* /ai/**`, and enumerating THAT * family settled the question of how much a `**` row is worth: not "weaker * evidence" but, in this case, WRONG evidence. `service-ai` (a Cloud/EE - * package in the `cloud` repo) mounts 12 routes and not one of them is the - * `/nlq` `/suggest` `/insights` the SDK calls — the wildcard had been - * certifying three URLs that nothing anywhere serves (#3718). The capstone now - * exempts `/api/v1/ai/` by prefix like the control plane, the real table is - * ledgered in `cloud`, and the wildcard-only bound is **0**: every matched call - * rests on an exact enumerated route. + * package in the `cloud` repo) mounts 12 routes and not one of them was the + * `/nlq` `/suggest` `/insights` the SDK called — the wildcard had been + * certifying three URLs that nothing anywhere serves (#3718). Those three + * methods are gone and the SDK now expresses the real table instead. The + * capstone exempts `/api/v1/ai/` by prefix like the control plane — bounded to + * the `ai.*` namespace, with the reachability check living in `cloud` next to + * the routes — and the wildcard-only bound is **0**: every matched call rests + * on an exact enumerated route. * * This module is runtime-internal (not exported from the package index): it is * the guard's data, not public API. Promotion to `@objectstack/spec` is a @@ -193,7 +195,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── ai (dynamic route table, owned by another repo) ─────────────────────── { route: '* /ai/**', domain: '/ai', disposition: 'dynamic', - note: 'routes come from service-ai buildAIRoutes() at plugin start — service-ai is a Cloud/EE package in the `cloud` repo, so this repo cannot enumerate them and the dispatcher only proxies (or 404s "AI service is not configured"). Enumerated on the other side of that boundary since #3718: cloud packages/service-ai/src/ai-route-ledger.ts. The previous note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and is FALSE — nothing mounts those three paths (#3718)' }, + note: 'routes come from service-ai buildAIRoutes() at plugin start — service-ai is a Cloud/EE package in the `cloud` repo, so this repo cannot enumerate them and the dispatcher only proxies (or 404s "AI service is not configured"). Enumerated on the other side of that boundary since #3718: cloud packages/service-ai/src/ai-route-ledger.ts, whose conformance test drives client.ai.* against the table buildAIRoutes() really returns. The client now expresses that table — ai.chat / ai.chatStream / ai.complete / ai.models / ai.conversations.* — but do NOT read a `sdk` disposition into this row: it stays `dynamic` because THIS repo still cannot see the routes. An earlier note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and was FALSE — nothing has ever mounted those three paths, and both they and the methods calling them are gone (#3718)' }, // ── meta (legacy chain) ─────────────────────────────────────────────────── { route: 'GET /meta', domain: '/meta', disposition: 'sdk', client: 'meta.getTypes' }, diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 241453face..019cc3b1c5 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -69,12 +69,13 @@ "ai/VectorStore", "ai/VectorStoreProvider", "api/AckMessage", - "api/AiInsightsRequest", - "api/AiInsightsResponse", - "api/AiNlqRequest", - "api/AiNlqResponse", - "api/AiSuggestRequest", - "api/AiSuggestResponse", + "api/AiChatRequest", + "api/AiChatResponse", + "api/AiCompleteRequest", + "api/AiConversation", + "api/AiMessage", + "api/AiModelsResponse", + "api/AiStreamChunk", "api/AnalyticsEndpoint", "api/AnalyticsMetadataResponse", "api/AnalyticsQueryRequest", @@ -139,6 +140,7 @@ "api/CompleteUploadRequest", "api/ConceptListResponse", "api/ConflictResolutionStrategy", + "api/CreateAiConversationRequest", "api/CreateDataRequest", "api/CreateDataResponse", "api/CreateExportJobRequest", @@ -288,6 +290,8 @@ "api/InitiateChunkedUploadResponse", "api/InstallPackageRequest", "api/InstallPackageResponse", + "api/ListAiConversationsRequest", + "api/ListAiConversationsResponse", "api/ListExportJobsRequest", "api/ListExportJobsResponse", "api/ListFlowsRequest", @@ -450,6 +454,7 @@ "api/UnregisterDeviceResponse", "api/UnsubscribeMessage", "api/UnsubscribeRequest", + "api/UpdateAiConversationRequest", "api/UpdateDataRequest", "api/UpdateDataResponse", "api/UpdateFlowRequest", diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index 7f72f0fdac..5411f249fd 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -14,7 +14,6 @@ import { DEFAULT_DATA_CRUD_ROUTES, DEFAULT_BATCH_ROUTES, DEFAULT_NOTIFICATION_ROUTES, - DEFAULT_AI_ROUTES, DEFAULT_I18N_ROUTES, DEFAULT_ANALYTICS_ROUTES, DEFAULT_AUTOMATION_ROUTES, @@ -515,15 +514,18 @@ describe('plugin-rest-api.zod', () => { expect(DEFAULT_NOTIFICATION_ROUTES.endpoints).toHaveLength(7); }); - it('should validate DEFAULT_AI_ROUTES', () => { - expect(DEFAULT_AI_ROUTES.prefix).toBe('/api/v1/ai'); - expect(DEFAULT_AI_ROUTES.service).toBe('ai'); - expect(DEFAULT_AI_ROUTES.category).toBe('ai'); - expect(DEFAULT_AI_ROUTES.methods).toContain('aiNlq'); - // aiChat removed — wire protocol aligned with Vercel AI SDK - expect(DEFAULT_AI_ROUTES.methods).toContain('aiSuggest'); - expect(DEFAULT_AI_ROUTES.methods).toContain('aiInsights'); - expect(DEFAULT_AI_ROUTES.endpoints).toHaveLength(3); + it('declares no AI routes — this table cannot vouch for a Cloud/EE surface (#3718)', () => { + // DEFAULT_AI_ROUTES used to sit here declaring `/nlq`, `/suggest` and + // `/insights`, and this test asserted its shape — three endpoints no repo + // has ever mounted, checked for `toHaveLength(3)`. Shape is not + // existence. The AI service lives in `cloud`; its real table is + // enumerated by the ledger there, from `buildAIRoutes()` itself. + const registrations = getDefaultRouteRegistrations(); + expect(registrations.map((r) => r.prefix)).not.toContain('/api/v1/ai'); + expect( + registrations.flatMap((r) => r.methods ?? []), + 'the three handlers nothing ever implemented must not come back here', + ).not.toEqual(expect.arrayContaining(['aiNlq', 'aiSuggest', 'aiInsights'])); }); it('should validate DEFAULT_I18N_ROUTES', () => { @@ -568,21 +570,20 @@ describe('plugin-rest-api.zod', () => { expect(actionsEndpoint?.responseSchema).toBe('AutomationActionsResponseSchema'); }); - it('should return all 9 default registrations', () => { + it('should return all 8 default registrations', () => { // Permission/View/Workflow/Realtime tables were deleted in #3612 — - // no server ever mounted those routes. + // no server ever mounted those routes. AI went the same way in #3718. const registrations = getDefaultRouteRegistrations(); - expect(registrations).toHaveLength(9); + expect(registrations).toHaveLength(8); expect(registrations[0]).toBe(DEFAULT_DISCOVERY_ROUTES); expect(registrations[1]).toBe(DEFAULT_METADATA_ROUTES); expect(registrations[2]).toBe(DEFAULT_DATA_CRUD_ROUTES); expect(registrations[3]).toBe(DEFAULT_BATCH_ROUTES); expect(registrations[4]).toBe(DEFAULT_NOTIFICATION_ROUTES); - expect(registrations[5]).toBe(DEFAULT_AI_ROUTES); - expect(registrations[6]).toBe(DEFAULT_I18N_ROUTES); - expect(registrations[7]).toBe(DEFAULT_ANALYTICS_ROUTES); - expect(registrations[8]).toBe(DEFAULT_AUTOMATION_ROUTES); + expect(registrations[5]).toBe(DEFAULT_I18N_ROUTES); + expect(registrations[6]).toBe(DEFAULT_ANALYTICS_ROUTES); + expect(registrations[7]).toBe(DEFAULT_AUTOMATION_ROUTES); }); it('should cover all protocol categories', () => { @@ -594,7 +595,6 @@ describe('plugin-rest-api.zod', () => { expect(categories).toContain('data'); expect(categories).toContain('batch'); expect(categories).toContain('notification'); - expect(categories).toContain('ai'); expect(categories).toContain('i18n'); expect(categories).toContain('analytics'); expect(categories).toContain('automation'); diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index fedf82524f..1eded479c2 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -1083,69 +1083,26 @@ export const DEFAULT_NOTIFICATION_ROUTES: RestApiRouteRegistration = { // AI Routes // ========================================== -/** - * Default AI Routes - * Standard routes for AI operations (NLQ, Chat, Suggest, Insights) - */ -export const DEFAULT_AI_ROUTES: RestApiRouteRegistration = { - prefix: '/api/v1/ai', - service: 'ai', - category: 'ai', - methods: ['aiNlq', 'aiSuggest', 'aiInsights'], - authRequired: true, - endpoints: [ - { - method: 'POST', - path: '/nlq', - handler: 'aiNlq', - category: 'ai', - public: false, - summary: 'Natural language query', - description: 'Converts a natural language query to a structured query AST', - tags: ['AI'], - requestSchema: 'AiNlqRequestSchema', - responseSchema: 'AiNlqResponseSchema', - timeout: 30000, - cacheable: false, - }, - // AI chat route removed — wire protocol aligned with Vercel AI SDK. - // The chat endpoint should use Vercel's `toDataStreamResponse()` directly. - { - method: 'POST', - path: '/suggest', - handler: 'aiSuggest', - category: 'ai', - public: false, - summary: 'Get AI-powered suggestions', - description: 'Returns AI-generated field value suggestions based on context', - tags: ['AI'], - requestSchema: 'AiSuggestRequestSchema', - responseSchema: 'AiSuggestResponseSchema', - timeout: 15000, - cacheable: false, - }, - { - method: 'POST', - path: '/insights', - handler: 'aiInsights', - category: 'ai', - public: false, - summary: 'Get AI-generated insights', - description: 'Returns AI-generated insights (summaries, trends, anomalies, recommendations)', - tags: ['AI'], - requestSchema: 'AiInsightsRequestSchema', - responseSchema: 'AiInsightsResponseSchema', - timeout: 60000, - cacheable: false, - }, - ], - middleware: [ - { name: 'auth', type: 'authentication', enabled: true, order: 10 }, - { name: 'validation', type: 'validation', enabled: true, order: 20 }, - { name: 'response_envelope', type: 'transformation', enabled: true, order: 100 }, - { name: 'error_handler', type: 'error', enabled: true, order: 200 }, - ], -}; +// `DEFAULT_AI_ROUTES` is GONE (#3718). +// +// It declared `POST /api/v1/ai/{nlq,suggest,insights}` with handlers +// `aiNlq` / `aiSuggest` / `aiInsights`. Nothing ever mounted those paths and +// nothing ever implemented those handlers, so all three 404ed for the whole +// life of the declaration — while `client.ai.nlq/suggest/insights` called them +// and this table made them look registered. +// +// It could not have been otherwise: this registration table has **no runtime +// consumer**. Only `getDefaultRouteRegistrations()` returned it, and only this +// package's own tests read that. Re-declaring the routes that DO exist here +// would recreate the same illusion, because the AI service is a Cloud/EE +// package (`service-ai`, in the `cloud` repo) and this repo's dispatcher only +// proxies `/api/v1/ai/**` to whatever `buildAIRoutes()` mounted. +// +// The real table is enumerated where it is mounted — `cloud`'s +// `packages/service-ai/src/ai-route-ledger.ts`, whose conformance test reads +// `buildAIRoutes()` directly and drives `client.ai.*` against it. The wire +// shapes are `Ai*Schema` in `protocol.zod.ts`; the docs table is +// `content/docs/api/plugin-endpoints.mdx`. // ========================================== // i18n Routes @@ -1336,16 +1293,19 @@ export const RestApiRouteRegistration = Object.assign(RestApiRouteRegistrationSc * Get all default route registrations. * Returns the complete set of standard REST API routes covering all protocol namespaces. * - * Route groups (13 total): + * Route groups (8 total): * 1. Discovery - API capabilities and routing info * 2. Metadata - Object/field schema CRUD * 3. Data CRUD - Record operations * 4. Batch - Bulk operations * 5. Notification - Push notifications and preferences - * 6. AI - NLQ, chat, suggestions, insights - * 7. i18n - Locales and translations - * 8. Analytics - BI queries and metadata - * 9. Automation - Trigger flows and scripts + * 6. i18n - Locales and translations + * 7. Analytics - BI queries and metadata + * 8. Automation - Trigger flows and scripts + * + * AI is deliberately absent: its routes are mounted by a Cloud/EE package in + * the `cloud` repo, never from here, and the group that used to sit at #6 + * declared three endpoints nothing has ever served (#3718). */ export function getDefaultRouteRegistrations(): RestApiRouteRegistration[] { return [ @@ -1354,7 +1314,6 @@ export function getDefaultRouteRegistrations(): RestApiRouteRegistration[] { DEFAULT_DATA_CRUD_ROUTES, DEFAULT_BATCH_ROUTES, DEFAULT_NOTIFICATION_ROUTES, - DEFAULT_AI_ROUTES, DEFAULT_I18N_ROUTES, DEFAULT_ANALYTICS_ROUTES, DEFAULT_AUTOMATION_ROUTES, diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index d8dd372f4a..753abe0b92 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -50,12 +50,13 @@ import { ListNotificationsResponseSchema, MarkNotificationsReadRequestSchema, // AI - AiNlqRequestSchema, - AiNlqResponseSchema, - AiSuggestRequestSchema, - AiSuggestResponseSchema, - AiInsightsRequestSchema, - AiInsightsResponseSchema, + AiChatRequestSchema, + AiChatResponseSchema, + AiCompleteRequestSchema, + AiModelsResponseSchema, + CreateAiConversationRequestSchema, + ListAiConversationsResponseSchema, + UpdateAiConversationRequestSchema, // i18n GetLocalesResponseSchema, GetTranslationsRequestSchema, @@ -278,21 +279,52 @@ describe('ObjectStack Protocol', () => { expect(MarkNotificationsReadRequestSchema.safeParse({ ids: ['n1', 'n2'] }).success).toBe(true); }); + /** + * These replace the `AiNlq*` / `AiSuggest*` / `AiInsights*` cases (#3718). + * Those parsed cleanly for years against endpoints no repo has ever mounted + * — a schema is a shape, never evidence that anything serves it. What is + * asserted here is the shape of the routes `service-ai` really mounts, which + * `cloud`'s ledger checks against `buildAIRoutes()` itself. + */ it('validates AI operations', () => { - expect(AiNlqRequestSchema.safeParse({ query: 'show me all open tasks', object: 'task' }).success).toBe(true); - expect(AiNlqResponseSchema.safeParse({ - query: { object: 'task', where: { status: 'open' } }, - explanation: 'Find all tasks with open status', confidence: 0.92, + // chat — Vercel `useChat` flat form, and the JSON (stream:false) reply + expect(AiChatRequestSchema.safeParse({ + messages: [{ role: 'user', content: 'how many open orders?' }], + system: 'You are a helpful assistant', model: 'gpt-4o-mini', stream: false, }).success).toBe(true); - // AiChatRequestSchema/AiChatResponseSchema removed — chat protocol aligned with Vercel AI SDK - expect(AiSuggestRequestSchema.safeParse({ object: 'task', field: 'priority', partial: 'hi' }).success).toBe(true); - expect(AiSuggestResponseSchema.safeParse({ - suggestions: [{ value: 'high', label: 'High', confidence: 0.95, reason: 'Matches partial input' }], + // v6 `parts` messages carry no `content` at all — the routes accept them + expect(AiChatRequestSchema.safeParse({ + messages: [{ role: 'assistant', parts: [{ type: 'text', text: 'hi' }] }], }).success).toBe(true); - expect(AiInsightsRequestSchema.safeParse({ object: 'task', type: 'trends' }).success).toBe(true); - expect(AiInsightsResponseSchema.safeParse({ - insights: [{ type: 'trends', title: 'Task Completion Rate', description: 'Completion rate increased by 15% this month', confidence: 0.88 }], + expect(AiChatRequestSchema.safeParse({ messages: [] }).success, 'the routes 400 an empty message list').toBe(false); + expect(AiChatResponseSchema.safeParse({ + content: '42 open orders', model: 'gpt-4o-mini', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, conversationId: 'conv_1', }).success).toBe(true); + + // complete + expect(AiCompleteRequestSchema.safeParse({ prompt: 'Summarise:', options: { maxTokens: 64 } }).success).toBe(true); + expect(AiCompleteRequestSchema.safeParse({}).success).toBe(false); + + // models — both live shapes: the ADR-0028 allowlist and the bare-id fallback + expect(AiModelsResponseSchema.safeParse({ + models: [{ id: 'gpt-4o-mini', label: 'GPT-4o mini', default: true }], defaultModel: 'gpt-4o-mini', + }).success).toBe(true); + expect(AiModelsResponseSchema.safeParse({ models: ['gpt-4o-mini'] }).success).toBe(true); + + // conversations + expect(CreateAiConversationRequestSchema.safeParse({ title: 'Q3 pipeline', metadata: { source: 'sdk' } }).success).toBe(true); + expect(ListAiConversationsResponseSchema.safeParse({ + conversations: [{ + id: 'conv_1', messages: [{ role: 'user', content: 'hi' }], + createdAt: '2026-07-27T10:00:00Z', updatedAt: '2026-07-27T10:00:00Z', + }], + }).success).toBe(true); + expect(UpdateAiConversationRequestSchema.safeParse({ title: 'Renamed' }).success).toBe(true); + expect( + UpdateAiConversationRequestSchema.safeParse({}).success, + 'PATCH with neither title nor metadata is a 400 on the wire', + ).toBe(false); }); it('validates i18n operations', () => { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 3219e6e8c6..ad50d626ba 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -889,55 +889,151 @@ export const MarkAllNotificationsReadResponseSchema = lazySchema(() => z.object( // ========================================== // AI Operations // ========================================== +// +// THESE ARE THE SHAPES OF THE ROUTES THAT EXIST (#3718). +// +// What used to live here — `AiNlq*`, `AiSuggest*`, `AiInsights*` — described +// `/api/v1/ai/{nlq,suggest,insights}`, three endpoints **no repo has ever +// mounted**. They were declared here, registered in `DEFAULT_AI_ROUTES` (a +// table with no runtime consumer), typed as optional protocol methods nothing +// implements, and called by `client.ai.*`. Every call 404ed for the whole life +// of the namespace; the SDK's AI surface and the real one were disjoint sets. +// +// The AI service itself is a Cloud/EE package (`service-ai`, in the `cloud` +// repo) — this repo's dispatcher only proxies `/api/v1/ai/**` to whatever +// `buildAIRoutes()` mounted, or 404s "AI service is not configured". So these +// schemas deliberately describe **the wire**, not a protocol this repo serves: +// they are the one shape `client.ai.*` and cloud's route handlers both read. +// The mounted table's reviewed dispositions live in `cloud` +// (`packages/service-ai/src/ai-route-ledger.ts`), which drives this SDK +// against the routes it really returns. -export const AiNlqRequestSchema = lazySchema(() => z.object({ - query: z.string().describe('Natural language query string'), - object: z.string().optional().describe('Target object context'), - conversationId: z.string().optional().describe('Conversation ID for multi-turn queries'), -})); +/** + * One conversation message on the wire. + * + * Deliberately permissive about `content`: the AI routes accept a plain string + * (legacy), a content-part array, and the Vercel AI SDK v6 `parts` form where + * `content` may be absent entirely. `role` is the part the routes actually + * validate, so it is the part constrained here — narrowing `content` further + * would reject payloads the server accepts. + */ +export const AiMessageSchema = lazySchema(() => z.object({ + role: z.enum(['system', 'user', 'assistant', 'tool']).describe('Message role'), + content: z.unknown().optional().describe('Message content: a string, or an array of content parts'), + parts: z.array(z.unknown()).optional().describe('Vercel AI SDK v6 message parts (alternative to `content`)'), +}).passthrough()); // adapters carry extra per-message fields (id, createdAt, …) -export const AiNlqResponseSchema = lazySchema(() => z.object({ - query: z.unknown().describe('Generated structured query (AST)'), - explanation: z.string().optional().describe('Human-readable explanation of the query'), - confidence: z.number().min(0).max(1).optional().describe('Confidence score (0-1)'), - suggestions: z.array(z.string()).optional().describe('Suggested follow-up queries'), +/** + * `POST /api/v1/ai/chat` — dual-mode. `stream: false` returns + * {@link AiChatResponseSchema} as JSON; anything else streams the Vercel UI + * Message Stream Protocol (SSE with JSON payloads). + * + * Flat `model`/`temperature`/`maxTokens` are the Vercel `useChat` shape and + * take precedence over the same keys nested under `options`. + */ +export const AiChatRequestSchema = lazySchema(() => z.object({ + messages: z.array(AiMessageSchema).min(1).describe('Conversation messages (at least one)'), + system: z.string().optional().describe('System prompt, prepended as a system message'), + model: z.string().optional().describe('Model id override'), + temperature: z.number().optional().describe('Sampling temperature'), + maxTokens: z.number().int().positive().optional().describe('Maximum tokens to generate'), + stream: z.boolean().optional().describe('false → JSON response; otherwise the UI Message Stream Protocol'), + conversationId: z.string().optional().describe('Conversation to persist this turn into (auto-created when omitted)'), + turnId: z.string().optional().describe('Stable per-turn idempotency key (ADR-0013 D1)'), + options: z.record(z.string(), z.unknown()).optional().describe('Legacy nested request options'), +})); + +/** JSON body of a non-streaming chat/completion turn — the `AIResult` shape. */ +export const AiChatResponseSchema = lazySchema(() => z.object({ + content: z.string().describe('Generated text'), + model: z.string().optional().describe('Model that produced it'), + toolCalls: z.array(z.unknown()).optional().describe('Tool calls the model requested (Vercel `ToolCallPart`)'), + usage: z.object({ + promptTokens: z.number().describe('Tokens consumed by the prompt'), + completionTokens: z.number().describe('Tokens generated'), + totalTokens: z.number().describe('prompt + completion'), + }).optional().describe('Token usage'), + conversationId: z.string().optional().describe('Conversation the turn was persisted into'), +})); + +/** `POST /api/v1/ai/complete` — single-shot text completion. */ +export const AiCompleteRequestSchema = lazySchema(() => z.object({ + prompt: z.string().describe('Prompt text'), + options: z.record(z.string(), z.unknown()).optional().describe('Request options (model, temperature, maxTokens, …)'), +})); + +/** `GET /api/v1/ai/models` — the environment's plan-filtered picker list (ADR-0028). */ +export const AiModelsResponseSchema = lazySchema(() => z.object({ + /** + * Objects when the service exposes the ADR-0028 allowlist; bare ids when it + * falls back to the adapter's `listModels()`. Both shapes are live, so both + * are declared rather than one being asserted and the other 404-by-parse. + */ + models: z.array(z.union([ + z.string(), + z.object({ + id: z.string().describe('Model id'), + label: z.string().describe('Display label for the picker'), + default: z.boolean().describe('Whether this is the environment default'), + }), + ])).describe('Models this environment offers'), + defaultModel: z.string().optional().describe('Default model id, when the service reports one'), +})); + +/** A persisted AI conversation, as the `/ai/conversations` routes return it. */ +export const AiConversationSchema = lazySchema(() => z.object({ + id: z.string().describe('Conversation id'), + title: z.string().optional().describe('Title / summary'), + agentId: z.string().optional().describe('Agent this conversation is bound to'), + userId: z.string().optional().describe('Owning user'), + messages: z.array(AiMessageSchema).describe('Message history'), + createdAt: z.string().describe('Creation timestamp (ISO 8601)'), + updatedAt: z.string().describe('Last update timestamp (ISO 8601)'), + metadata: z.record(z.string(), z.unknown()).optional().describe('Conversation metadata'), })); -// AiChatRequestSchema and AiChatResponseSchema have been removed. -// The AI chat wire protocol is now fully aligned with the Vercel AI SDK (`ai`). -// Frontend consumers should use `@ai-sdk/react/useChat` directly. -// See: https://ai-sdk.dev/docs - -export const AiSuggestRequestSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name for context'), - field: z.string().optional().describe('Field to suggest values for'), - recordId: z.string().optional().describe('Record ID for context'), - partial: z.string().optional().describe('Partial input for completion'), +/** + * `POST /api/v1/ai/conversations`. `userId` is NOT accepted from the caller — + * the route overwrites it with the authenticated actor. + */ +export const CreateAiConversationRequestSchema = lazySchema(() => z.object({ + title: z.string().optional().describe('Initial title'), + agentId: z.string().optional().describe('Agent to bind the conversation to'), + metadata: z.record(z.string(), z.unknown()).optional().describe('Conversation metadata'), })); -export const AiSuggestResponseSchema = lazySchema(() => z.object({ - suggestions: z.array(z.object({ - value: z.unknown().describe('Suggested value'), - label: z.string().describe('Display label'), - confidence: z.number().min(0).max(1).optional().describe('Confidence score (0-1)'), - reason: z.string().optional().describe('Reason for this suggestion'), - })).describe('Suggested values'), +/** `GET /api/v1/ai/conversations` query — scoped to the authenticated user. */ +export const ListAiConversationsRequestSchema = lazySchema(() => z.object({ + agentId: z.string().optional().describe('Filter by agent'), + limit: z.number().int().positive().optional().describe('Maximum conversations to return'), + cursor: z.string().optional().describe('Pagination cursor'), })); -export const AiInsightsRequestSchema = lazySchema(() => z.object({ - object: z.string().describe('Object name to analyze'), - recordId: z.string().optional().describe('Specific record to analyze'), - type: z.enum(['summary', 'trends', 'anomalies', 'recommendations']).optional().describe('Type of insight'), +export const ListAiConversationsResponseSchema = lazySchema(() => z.object({ + conversations: z.array(AiConversationSchema).describe('Matching conversations'), })); -export const AiInsightsResponseSchema = lazySchema(() => z.object({ - insights: z.array(z.object({ - type: z.string().describe('Insight type'), - title: z.string().describe('Insight title'), - description: z.string().describe('Detailed description'), - confidence: z.number().min(0).max(1).optional().describe('Confidence score (0-1)'), - data: z.record(z.string(), z.unknown()).optional().describe('Supporting data'), - })).describe('Generated insights'), +/** + * One frame of a streaming chat response. + * + * `POST /api/v1/ai/chat` (streaming mode) speaks the Vercel **UI Message + * Stream Protocol**: SSE lines carrying JSON objects that always have a + * `type` — `start`, `text-start`, `text-delta`, `tool-input-available`, + * `tool-output-available`, `error`, `finish-step`, `finish` — terminated by a + * literal `data: [DONE]`. `POST /api/v1/ai/chat/stream` uses the same SSE + * envelope for raw `TextStreamPart` events. Only `type` is guaranteed across + * both, so only `type` is declared; the rest of each frame passes through. + */ +export const AiStreamChunkSchema = lazySchema(() => z.object({ + type: z.string().describe('Frame type (text-delta, tool-input-available, finish, error, …)'), +}).passthrough()); + +/** `PATCH /api/v1/ai/conversations/:id` — at least one field is required. */ +export const UpdateAiConversationRequestSchema = lazySchema(() => z.object({ + title: z.string().optional().describe('New title'), + metadata: z.record(z.string(), z.unknown()).optional().describe('New metadata'), +}).refine((p) => p.title !== undefined || p.metadata !== undefined, { + message: 'at least one of title or metadata is required', })); // ========================================== @@ -1099,12 +1195,17 @@ export type MarkAllNotificationsReadRequest = z.input; // AI Types -export type AiNlqRequest = z.input; -export type AiNlqResponse = z.infer; -export type AiSuggestRequest = z.input; -export type AiSuggestResponse = z.infer; -export type AiInsightsRequest = z.input; -export type AiInsightsResponse = z.infer; +export type AiMessage = z.input; +export type AiChatRequest = z.input; +export type AiChatResponse = z.infer; +export type AiStreamChunk = z.infer; +export type AiCompleteRequest = z.input; +export type AiModelsResponse = z.infer; +export type AiConversation = z.infer; +export type CreateAiConversationRequest = z.input; +export type ListAiConversationsRequest = z.input; +export type ListAiConversationsResponse = z.infer; +export type UpdateAiConversationRequest = z.input; // i18n Types export type GetLocalesRequest = z.input; @@ -1254,12 +1355,19 @@ export interface NotificationProtocol { markAllNotificationsRead?(request: MarkAllNotificationsReadRequest): Promise; } -/** AI (optional — chat is now handled by the Vercel AI SDK wire protocol). */ -export interface AiProtocol { - aiNlq?(request: AiNlqRequest): Promise; - aiSuggest?(request: AiSuggestRequest): Promise; - aiInsights?(request: AiInsightsRequest): Promise; -} +// `AiProtocol` is GONE (#3718). +// +// It declared exactly three optional methods — `aiNlq?` / `aiSuggest?` / +// `aiInsights?` — and no service in any repo implemented one. Nothing +// dispatched through it either: `/api/v1/ai/**` is proxied straight to the +// route handlers `service-ai` (Cloud/EE, in the `cloud` repo) builds, never +// through a protocol object. An interface whose every member is optional and +// unimplemented does not describe a server — it only makes one look declared. +// +// The real server-side contract for AI is `IAIService` + +// `IAIConversationService` in `@objectstack/spec/contracts` (`ai-service.ts`), +// which `service-ai` actually implements. The wire shapes those routes speak +// are the `Ai*` schemas above. /** Localization (optional). */ export interface I18nProtocol { diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 1164259d08..6ea346d8fc 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -413,7 +413,7 @@ export const ManifestSchema = z.object({ * * @example * routes: [ - * { prefix: '/api/v1/ai', service: 'ai', methods: ['aiNlq', 'aiChat'] } + * { prefix: '/api/v1/i18n', service: 'i18n', methods: ['getLocales', 'getTranslations'] } * ] */ routes: z.array(z.object({ @@ -423,7 +423,7 @@ export const ManifestSchema = z.object({ service: z.string().describe('Service name this plugin provides'), /** Protocol method names implemented */ methods: z.array(z.string()).optional() - .describe('Protocol method names implemented (e.g. ["aiNlq", "aiChat"])'), + .describe('Protocol method names implemented (e.g. ["getLocales", "getTranslations"])'), })).optional().describe('API route contributions to HttpDispatcher'), /** From 4707f0aa0ba2b0dc9bf1ad62cebcfe1319e7270e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:23:44 +0000 Subject: [PATCH 2/3] =?UTF-8?q?chore(docs):=20baseline=20`role`=20in=20the?= =?UTF-8?q?=20client-sdk=20AI=20examples=20=E2=80=94=20it=20is=20the=20wir?= =?UTF-8?q?e=20field=20name=20(#3718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:role-word` (ADR-0090 D3) flagged 2 new occurrences in content/docs/api/client-sdk.mdx: the `{ role: 'user', content: … }` messages in the new `ai.chat` / `ai.conversations.addMessage` examples. Baselined rather than reworded, for the reason the check itself names as legitimate: this is a genuine external boundary. `role` is the field name in the Vercel AI SDK `ModelMessage` shape the AI routes accept — the same class as the better-auth and ARIA occurrences already in the baseline. Renaming it in the example would document a payload the server rejects. Nothing else moved: the baseline gains exactly one line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN --- scripts/role-word-baseline.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index f7bd9f4b19..be66ed04d9 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -1,6 +1,7 @@ { "content/docs/ai/agents.mdx": 5, "content/docs/ai/index.mdx": 2, + "content/docs/api/client-sdk.mdx": 2, "content/docs/api/error-catalog.mdx": 1, "content/docs/automation/approvals.mdx": 1, "content/docs/concepts/architecture.mdx": 4, From 76fd55bd44490907dbd727a129c24aab0907480b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:31:12 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the public API surface snapshot for the AI retirement (#3718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:api-surface` is the ratchet that makes a removed export impossible to ship by accident: 14 breaking (removed/narrowed), 22 added. Both halves are this change and nothing else — removed Ai{Nlq,Suggest,Insights}{Request,Response}[Schema] (12), DEFAULT_AI_ROUTES, AiProtocol added AiChat{Request,Response}[Schema], AiStreamChunk[Schema], AiCompleteRequest[Schema], AiModelsResponse[Schema], AiConversation[Schema], AiMessage[Schema], {Create,List,Update}AiConversation*[Schema] (22) The removals are already declared as a `major` on @objectstack/spec in the changeset, which is what the gate asks for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN --- packages/spec/api-surface.json | 36 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 1cb5316e24..1feb537caf 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2191,19 +2191,20 @@ "./api": [ "AckMessage (type)", "AckMessageSchema (const)", - "AiInsightsRequest (type)", - "AiInsightsRequestSchema (const)", - "AiInsightsResponse (type)", - "AiInsightsResponseSchema (const)", - "AiNlqRequest (type)", - "AiNlqRequestSchema (const)", - "AiNlqResponse (type)", - "AiNlqResponseSchema (const)", - "AiProtocol (interface)", - "AiSuggestRequest (type)", - "AiSuggestRequestSchema (const)", - "AiSuggestResponse (type)", - "AiSuggestResponseSchema (const)", + "AiChatRequest (type)", + "AiChatRequestSchema (const)", + "AiChatResponse (type)", + "AiChatResponseSchema (const)", + "AiCompleteRequest (type)", + "AiCompleteRequestSchema (const)", + "AiConversation (type)", + "AiConversationSchema (const)", + "AiMessage (type)", + "AiMessageSchema (const)", + "AiModelsResponse (type)", + "AiModelsResponseSchema (const)", + "AiStreamChunk (type)", + "AiStreamChunkSchema (const)", "AnalyticsEndpoint (type)", "AnalyticsMetadataResponse (type)", "AnalyticsMetadataResponseSchema (const)", @@ -2335,6 +2336,8 @@ "ConceptListResponse (type)", "ConceptListResponseSchema (const)", "ConflictResolutionStrategy (type)", + "CreateAiConversationRequest (type)", + "CreateAiConversationRequestSchema (const)", "CreateDataRequest (type)", "CreateDataRequestSchema (const)", "CreateDataResponse (type)", @@ -2377,7 +2380,6 @@ "CursorMessageSchema (const)", "CursorPosition (type)", "CursorPositionSchema (const)", - "DEFAULT_AI_ROUTES (const)", "DEFAULT_ANALYTICS_ROUTES (const)", "DEFAULT_AUTOMATION_ROUTES (const)", "DEFAULT_BATCH_ROUTES (const)", @@ -2639,6 +2641,10 @@ "InstallPackageResponse (type)", "InstallPackageResponseSchema (const)", "InstalledPackage (type)", + "ListAiConversationsRequest (type)", + "ListAiConversationsRequestSchema (const)", + "ListAiConversationsResponse (type)", + "ListAiConversationsResponseSchema (const)", "ListExportJobsRequest (type)", "ListExportJobsRequestSchema (const)", "ListExportJobsResponse (type)", @@ -2970,6 +2976,8 @@ "UnsubscribeMessageSchema (const)", "UnsubscribeRequest (type)", "UnsubscribeRequestSchema (const)", + "UpdateAiConversationRequest (type)", + "UpdateAiConversationRequestSchema (const)", "UpdateDataRequest (type)", "UpdateDataRequestSchema (const)", "UpdateDataResponse (type)",