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/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)", 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'), /** 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,