@k2b/cloud 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/package.json +8 -6
  2. package/src/_internal/define-app.ts +20 -9
  3. package/src/_internal/page-responses.ts +5 -1
  4. package/src/ai/app-skills.ts +1 -7
  5. package/src/ai/audio-tool.ts +2 -1
  6. package/src/ai/browser.ts +109 -0
  7. package/src/ai/capabilities.ts +6 -2
  8. package/src/ai/chart-block.ts +431 -81
  9. package/src/ai/chat/action-sentences.ts +13 -7
  10. package/src/ai/chat/blocks.tsx +11 -7
  11. package/src/ai/chat/builtin-tools.tsx +9 -7
  12. package/src/ai/chat/chart-block.tsx +218 -44
  13. package/src/ai/chat/explorer-block.tsx +276 -0
  14. package/src/ai/chat/file-tools.tsx +40 -1
  15. package/src/ai/chat/message-actions.tsx +6 -0
  16. package/src/ai/chat/message-utils.ts +7 -0
  17. package/src/ai/chat/messages.ts +16 -0
  18. package/src/ai/chat/presentation.tsx +13 -3
  19. package/src/ai/chat/reference-text.tsx +34 -0
  20. package/src/ai/chat/tool-groups.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +14 -0
  22. package/src/ai/chat/turn-view.tsx +17 -7
  23. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  24. package/src/ai/chat/view-block.tsx +417 -0
  25. package/src/ai/chat/visual-tools.tsx +32 -53
  26. package/src/ai/client-tool-results.ts +9 -0
  27. package/src/ai/code-source-contracts.ts +17 -2
  28. package/src/ai/code-source-tools.ts +1 -0
  29. package/src/ai/default-tool-contracts.ts +334 -91
  30. package/src/ai/default-tools.ts +89 -13
  31. package/src/ai/executor.ts +173 -31
  32. package/src/ai/file-error.ts +2 -0
  33. package/src/ai/file-tools.ts +27 -7
  34. package/src/ai/files-store.ts +60 -0
  35. package/src/ai/html-pdf-tool.ts +3 -2
  36. package/src/ai/http.ts +37 -12
  37. package/src/ai/index.ts +90 -0
  38. package/src/ai/local-tool-contracts.ts +244 -0
  39. package/src/ai/malformed-tool-call-repair.ts +144 -0
  40. package/src/ai/markdown-pdf-tool.ts +2 -1
  41. package/src/ai/memories.ts +48 -1
  42. package/src/ai/memory-learning.ts +17 -6
  43. package/src/ai/memory-tool.ts +1 -0
  44. package/src/ai/migrate.ts +15 -0
  45. package/src/ai/openai-api-messages.ts +53 -0
  46. package/src/ai/openai-api-slots.ts +40 -0
  47. package/src/ai/openai-api.ts +383 -0
  48. package/src/ai/protocol.ts +6 -2
  49. package/src/ai/provider.ts +17 -4
  50. package/src/ai/request-context.ts +33 -0
  51. package/src/ai/routes.ts +96 -57
  52. package/src/ai/runtime.ts +48 -12
  53. package/src/ai/settings.ts +5 -0
  54. package/src/ai/skill-catalog.ts +20 -5
  55. package/src/ai/skill-format.ts +17 -7
  56. package/src/ai/skill-tool.ts +15 -5
  57. package/src/ai/skills-routes.ts +20 -6
  58. package/src/ai/skills.ts +26 -7
  59. package/src/ai/store.ts +25 -0
  60. package/src/ai/system-prompt.ts +69 -10
  61. package/src/ai/timeline.ts +3 -1
  62. package/src/ai/tools.ts +3 -0
  63. package/src/ai/types.ts +9 -0
  64. package/src/ai/view-block.ts +941 -0
  65. package/src/ai/vision-tool.ts +2 -1
  66. package/src/api/admin-identity.ts +6 -1
  67. package/src/api/app-approval-actions.ts +36 -0
  68. package/src/api/app-approval.ts +10 -2
  69. package/src/api/approvals-loader.ts +15 -0
  70. package/src/api/approvals.ts +120 -0
  71. package/src/api/index.ts +3 -0
  72. package/src/api/me.ts +12 -3
  73. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  74. package/src/approvals/Approvals.tsx +621 -0
  75. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  76. package/src/approvals/messages.ts +95 -0
  77. package/src/approvals/ui.ts +8 -0
  78. package/src/browser/app-approval.ts +32 -0
  79. package/src/browser/choose-files.tsx +26 -13
  80. package/src/browser/commands.ts +13 -4
  81. package/src/browser/file-provider-error.ts +11 -0
  82. package/src/browser/file-providers.ts +3 -12
  83. package/src/browser/files.tsx +1 -2
  84. package/src/browser/live-connection.ts +2 -3
  85. package/src/browser/navigation-search.ts +4 -1
  86. package/src/browser/provider-list.ts +2 -1
  87. package/src/browser/save-files.tsx +13 -2
  88. package/src/cli/account.ts +21 -2
  89. package/src/cli/commands.ts +4 -2
  90. package/src/contracts/app-approval.ts +120 -0
  91. package/src/contracts/approval-paths.ts +4 -0
  92. package/src/contracts/approvals.ts +81 -0
  93. package/src/contracts/command-path.ts +17 -0
  94. package/src/contracts/commands.ts +5 -15
  95. package/src/contracts/shared.ts +5 -0
  96. package/src/events/live-protocol.ts +2 -1
  97. package/src/events/live-server-message.ts +40 -0
  98. package/src/events/testing.ts +88 -0
  99. package/src/server/index.ts +2 -0
  100. package/src/server/middleware/auth.ts +11 -1
  101. package/src/server/request-timeout.ts +22 -0
  102. package/src/server/services/access.ts +4 -3
  103. package/src/services/app-approval.ts +103 -15
  104. package/src/services/approvals/cursor.ts +15 -0
  105. package/src/services/approvals/device.ts +112 -0
  106. package/src/services/approvals/index.ts +27 -11
  107. package/src/services/approvals/list.ts +120 -0
  108. package/src/services/approvals/migrate.ts +9 -1
  109. package/src/services/identity/models-only-auth.ts +25 -0
  110. package/src/services/notifications/email-frame.ts +16 -3
  111. package/src/services/outbox.ts +13 -6
  112. package/src/services/service-account-credentials.ts +22 -10
  113. package/src/services/settings/core-settings.ts +27 -0
  114. package/src/shared/ai-platform-prompt.ts +29 -3
  115. package/src/shared/email-html.ts +4 -0
  116. package/src/shared/index.ts +6 -1
  117. package/src/shared/markdown/extensions/code.ts +14 -0
  118. package/src/shared/markdown/index.ts +4 -4
  119. package/src/shared/theme.ts +41 -20
  120. package/src/ssr/AnnouncementBanners.tsx +30 -0
  121. package/src/ssr/AppBadge.tsx +4 -4
  122. package/src/ssr/AppLaunchpadPanel.tsx +6 -6
  123. package/src/ssr/CloudShell.island.tsx +91 -0
  124. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  125. package/src/ssr/Layout.tsx +56 -85
  126. package/src/ssr/LayoutHeader.tsx +31 -77
  127. package/src/ssr/LayoutHelp.tsx +2 -57
  128. package/src/ssr/LayoutHelpTrigger.tsx +51 -0
  129. package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
  130. package/src/ssr/LayoutRail.tsx +15 -69
  131. package/src/ssr/MinimalLayout.tsx +2 -3
  132. package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
  133. package/src/ssr/MobileNavigation.tsx +13 -8
  134. package/src/ssr/MobileProfileActions.tsx +10 -7
  135. package/src/ssr/PageTrailRow.tsx +14 -0
  136. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
  137. package/src/ssr/PwaLayout.tsx +0 -2
  138. package/src/ssr/PwaRuntime.island.tsx +1 -1
  139. package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
  140. package/src/ssr/ShellRuntime.island.tsx +170 -0
  141. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  142. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  143. package/src/ssr/announcements-dialog.tsx +66 -0
  144. package/src/ssr/app-badges.ts +50 -6
  145. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  146. package/src/ssr/busy-action.ts +19 -0
  147. package/src/ssr/islands/index.ts +9 -6
  148. package/src/ssr/launchpad-surface.tsx +15 -0
  149. package/src/ssr/layout-help-registry.tsx +60 -0
  150. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  151. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  152. package/src/ssr/preference-controller.ts +30 -21
  153. package/src/ssr/profile-actions.ts +26 -15
  154. package/src/ssr/profile-preferences-messages.ts +8 -4
  155. package/src/ssr/rail-context.ts +8 -12
  156. package/src/ssr/rail-navigation.ts +5 -3
  157. package/src/ssr/runtime.ts +6 -1
  158. package/src/ssr/shell-data.tsx +48 -0
  159. package/src/ssr/timezone-cookie.ts +22 -0
  160. package/src/ssr/workspace-navigation.ts +25 -2
  161. package/src/styles/approvals.css +390 -0
  162. package/src/styles/global.css +22 -7
  163. package/src/styles/utilities-navigation.css +13 -0
  164. package/src/workflows/language/compiler.ts +1 -1
  165. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  166. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  167. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  170. package/src/ssr/PageTrailRow.island.tsx +0 -20
  171. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  172. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  173. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
@@ -0,0 +1,383 @@
1
+ import { estimateTokens, type Message, type Provider } from "@k2b/nessi";
2
+ import type { GenerateRequest, StreamEvent, Usage } from "@k2b/nessi/ai";
3
+ import { type Context, Hono, type MiddlewareHandler } from "hono";
4
+ import { bodyLimit } from "hono/body-limit";
5
+ import { streamSSE } from "hono/streaming";
6
+ import { describeRoute } from "hono-openapi";
7
+ import { z } from "zod";
8
+ import { type AuthContext, getLocale, rateLimit } from "../server";
9
+ import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
10
+ import { authenticateModelsOnlyCredential } from "../services/identity/models-only-auth";
11
+ import { coreSettings } from "../services/settings/api";
12
+ import { listAssistantAiModels } from "./assistant-models";
13
+ import { type OpenAiErrorCode, openAiApiMessages } from "./openai-api-messages";
14
+ import { acquire } from "./openai-api-slots";
15
+ import { personalAiModelPolicy } from "./personal-agent";
16
+ import { PROVIDER_TIMEOUTS } from "./provider";
17
+ import { inferenceProvider } from "./quota-provider";
18
+ import { AiQuotaError } from "./quotas";
19
+ import { resolveAiModel } from "./settings";
20
+
21
+ // Leave time for the provider's silence timeout to reach the client as a 502 JSON error.
22
+ const PROVIDER_WAIT_SECONDS = Math.ceil(Math.max(PROVIDER_TIMEOUTS.firstByteMs, PROVIDER_TIMEOUTS.idleMs) / 1000) + 10;
23
+
24
+ type ErrorType =
25
+ | "invalid_request_error"
26
+ | "authentication_error"
27
+ | "permission_error"
28
+ | "insufficient_quota"
29
+ | "rate_limit_error"
30
+ | "api_error";
31
+ type ErrorStatus = 400 | 401 | 403 | 404 | 413 | 429 | 500 | 502;
32
+ const errorBody = (c: Context<AuthContext>, code: OpenAiErrorCode, type: ErrorType, param: string | null = null) => ({
33
+ error: { message: openAiApiMessages(getLocale(c))[code], type, param, code },
34
+ });
35
+ const errorResponse = (c: Context<AuthContext>, status: ErrorStatus, code: OpenAiErrorCode, type: ErrorType, param: string | null = null) =>
36
+ c.json(errorBody(c, code, type, param), status);
37
+
38
+ // OpenAI clients often send `null` for fields they leave unset, so every optional field accepts it as absent.
39
+ const completionSchema = z.object({
40
+ model: z.string().min(1),
41
+ messages: z
42
+ .array(
43
+ z.object({
44
+ role: z.string(),
45
+ content: z.unknown(),
46
+ tool_calls: z.array(z.unknown()).nullish(),
47
+ function_call: z.unknown().nullish(),
48
+ }),
49
+ )
50
+ .min(1),
51
+ temperature: z.number().min(0).max(2).nullish(),
52
+ top_p: z.number().min(0).max(1).nullish(),
53
+ max_tokens: z.number().int().positive().nullish(),
54
+ max_completion_tokens: z.number().int().positive().nullish(),
55
+ stream: z.boolean().nullish(),
56
+ stream_options: z.object({ include_usage: z.boolean().nullish() }).nullish(),
57
+ tools: z.array(z.unknown()).nullish(),
58
+ functions: z.array(z.unknown()).nullish(),
59
+ n: z.number().nullish(),
60
+ response_format: z.object({ type: z.string() }).nullish(),
61
+ audio: z.unknown().nullish(),
62
+ modalities: z.array(z.string()).nullish(),
63
+ });
64
+ const textContent = z.union([z.string(), z.array(z.object({ type: z.literal("text"), text: z.string() }))]);
65
+ const failedFinish = (reason: string | undefined) => reason === "error" || reason === "aborted" || reason === "interrupted";
66
+ const finishReason = (reason: string | undefined) => (reason === "max_tokens" ? "length" : "stop");
67
+ const usageBody = (usage: Usage) => ({
68
+ prompt_tokens: usage.input,
69
+ completion_tokens: usage.output,
70
+ total_tokens: usage.input + usage.output,
71
+ });
72
+
73
+ type Dependencies = {
74
+ limit?: MiddlewareHandler<AuthContext>;
75
+ settings?: Pick<typeof coreSettings, "get">;
76
+ authenticate?: typeof authenticateModelsOnlyCredential;
77
+ listModels?: typeof listAssistantAiModels;
78
+ resolveModel?: typeof resolveAiModel;
79
+ acquire?: typeof acquire;
80
+ inferenceProvider?: typeof inferenceProvider;
81
+ keepaliveMs?: number;
82
+ };
83
+
84
+ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
85
+ const settings = dependencies.settings ?? coreSettings;
86
+ const authenticate = dependencies.authenticate ?? authenticateModelsOnlyCredential;
87
+ const listModels = dependencies.listModels ?? listAssistantAiModels;
88
+ const resolveModel = dependencies.resolveModel ?? resolveAiModel;
89
+ const acquireSlot = dependencies.acquire ?? acquire;
90
+ const wrapProvider = dependencies.inferenceProvider ?? inferenceProvider;
91
+ const limit = dependencies.limit ?? rateLimit();
92
+ const app = new Hono<AuthContext>();
93
+ app.onError((error, c) => {
94
+ if (error instanceof AiQuotaError && error.code !== "quota_conflict")
95
+ return errorResponse(c, 429, "insufficient_quota", "insufficient_quota");
96
+ if (error instanceof Error && "aiError" in error) {
97
+ const parsed = z.object({ code: z.string() }).safeParse(error.aiError);
98
+ if (parsed.success) {
99
+ if (parsed.data.code === "ai_disabled") return errorResponse(c, 403, "ai_disabled", "permission_error");
100
+ if (parsed.data.code === "model_access_denied") return errorResponse(c, 403, "model_access_denied", "permission_error", "model");
101
+ if (parsed.data.code === "model_policy_mismatch") return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
102
+ if (parsed.data.code === "missing_provider_credential") return errorResponse(c, 502, "missing_provider_credential", "api_error");
103
+ }
104
+ }
105
+ return errorResponse(c, 500, "internal_error", "api_error");
106
+ });
107
+ return app
108
+ .use("*", async (c, next) => {
109
+ const response = await limit(c, next);
110
+ if (response?.status === 429) return errorResponse(c, 429, "rate_limit_exceeded", "rate_limit_error");
111
+ return response;
112
+ })
113
+ .use("*", async (c, next) => {
114
+ if (!(await settings.get<boolean>("ai.openai_api_enabled"))) return errorResponse(c, 403, "openai_api_disabled", "permission_error");
115
+ const token = c.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
116
+ const credential = await authenticate(token);
117
+ if (!credential) return errorResponse(c, 401, "invalid_api_key", "authentication_error");
118
+ c.set("user", credential.user);
119
+ c.set("accessSubject", { type: "user", userId: credential.user.id });
120
+ c.header("Cache-Control", "no-store");
121
+ await next();
122
+ })
123
+ .get(
124
+ "/models",
125
+ describeRoute({
126
+ tags: ["AI"],
127
+ summary: "List OpenAI-compatible models",
128
+ description: "Models-only OpenAI-compatible surface: lists the Assistant chat models this person may use.",
129
+ }),
130
+ async (c) => {
131
+ const models = await listModels(c.get("accessSubject"));
132
+ return c.json({
133
+ object: "list",
134
+ data: models
135
+ .filter((model) => !model.capabilities.includes("transcription"))
136
+ .map((model) => ({ id: model.id, object: "model", created: 0, owned_by: "cloud" })),
137
+ });
138
+ },
139
+ )
140
+ .post(
141
+ "/chat/completions",
142
+ describeRoute({
143
+ tags: ["AI"],
144
+ summary: "Create an OpenAI-compatible chat completion",
145
+ description:
146
+ "Models-only OpenAI-compatible text inference using only client messages, without Cloud tools or conversation context. Unknown fields are ignored.",
147
+ }),
148
+ async (c) => {
149
+ const subject = c.get("accessSubject");
150
+ const models = (await listModels(subject)).filter((model) => !model.capabilities.includes("transcription"));
151
+ if (models.length === 0) return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
152
+ const slot = await acquireSlot({
153
+ userId: c.get("user").id,
154
+ limit: await settings.get<number>("ai.openai_api_max_concurrent_requests"),
155
+ });
156
+ if (!slot) {
157
+ c.header("Retry-After", "1");
158
+ return errorResponse(c, 429, "too_many_concurrent_requests", "rate_limit_error");
159
+ }
160
+ let ownsSlot = true;
161
+ try {
162
+ // estimateTokens assumes ~4 characters/token, each up to 4 UTF-8 bytes.
163
+ // An unspecified context counts as Gemini's 1,000,000-token adapter default, the largest supported default.
164
+ const maxSize = 16 * Math.max(0, ...models.map((model) => model.contextWindow ?? 1_000_000));
165
+ let body: unknown;
166
+ const tooLarge = await bodyLimit({
167
+ maxSize,
168
+ onError: () => errorResponse(c, 413, "request_too_large", "invalid_request_error"),
169
+ })(c, async () => {
170
+ body = await c.req.json().catch(() => undefined);
171
+ });
172
+ if (tooLarge) return tooLarge;
173
+ const parsed = completionSchema.safeParse(body);
174
+ if (!parsed.success)
175
+ return errorResponse(c, 400, "invalid_request", "invalid_request_error", String(parsed.error.issues[0]?.path[0] ?? "") || null);
176
+ const data = parsed.data;
177
+ for (const [param, unsupported] of [
178
+ ["tools", Boolean(data.tools?.length)],
179
+ ["functions", Boolean(data.functions?.length)],
180
+ ["n", data.n != null && data.n !== 1],
181
+ ["response_format", data.response_format != null && data.response_format.type !== "text"],
182
+ ["audio", data.audio != null],
183
+ ["modalities", Boolean(data.modalities?.includes("audio"))],
184
+ ] as const) {
185
+ if (unsupported) return errorResponse(c, 400, "unsupported_parameter", "invalid_request_error", param);
186
+ }
187
+ const messages: Message[] = [];
188
+ const system: string[] = [];
189
+ for (const message of data.messages) {
190
+ const content = textContent.safeParse(message.content);
191
+ if (
192
+ !content.success ||
193
+ !["system", "developer", "user", "assistant"].includes(message.role) ||
194
+ (message.role === "assistant" && (Boolean(message.tool_calls?.length) || message.function_call != null))
195
+ )
196
+ return errorResponse(c, 400, "unsupported_content", "invalid_request_error", "messages");
197
+ const text = typeof content.data === "string" ? content.data : content.data.map((part) => part.text).join("");
198
+ if (message.role === "system" || message.role === "developer") system.push(text);
199
+ else if (message.role === "user") messages.push({ role: "user", content: [text] });
200
+ else messages.push({ role: "assistant", content: [{ type: "text", text }] });
201
+ }
202
+ if (!models.some((model) => model.id === data.model))
203
+ return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
204
+ const resolved = await resolveModel(personalAiModelPolicy, data.model);
205
+ const systemPrompt = system.join("\n\n");
206
+ const inputTokens = estimateTokens(messages) + Math.ceil(systemPrompt.length / 4);
207
+ if (resolved.provider.contextWindow !== undefined && inputTokens > resolved.provider.contextWindow)
208
+ return errorResponse(c, 400, "context_length_exceeded", "invalid_request_error", "messages");
209
+ const requested = data.max_completion_tokens ?? data.max_tokens ?? undefined;
210
+ const maxOutputTokens =
211
+ requested === undefined
212
+ ? resolved.profile.maxOutputTokens
213
+ : resolved.profile.maxOutputTokens === undefined
214
+ ? requested
215
+ : Math.min(requested, resolved.profile.maxOutputTokens);
216
+ const controller = new AbortController();
217
+ const request: GenerateRequest = {
218
+ messages,
219
+ systemPrompt,
220
+ temperature: data.temperature ?? undefined,
221
+ maxOutputTokens,
222
+ reasoningEffort: resolved.profile.reasoningEffort,
223
+ signal: AbortSignal.any([c.req.raw.signal, controller.signal]),
224
+ extraBody:
225
+ data.top_p == null
226
+ ? undefined
227
+ : resolved.provider.family === "ollama"
228
+ ? { options: { top_p: data.top_p } }
229
+ : resolved.provider.family === "gemini"
230
+ ? { generationConfig: { topP: data.top_p } }
231
+ : { top_p: data.top_p },
232
+ };
233
+ const provider: Provider = wrapProvider(resolved.provider, resolved.profile, { kind: "chat", task: "openai-api", subject });
234
+ const id = `chatcmpl-${crypto.randomUUID()}`;
235
+ const created = Math.floor(Date.now() / 1000);
236
+ let events: AsyncIterator<StreamEvent> | undefined;
237
+ const kinds = new Map<string, string>();
238
+ let usage: Usage | undefined;
239
+ let reason: string | undefined;
240
+ const readEvent = (event: StreamEvent): string | undefined => {
241
+ if (event.type === "block_start") kinds.set(event.blockId, event.kind);
242
+ if (event.type === "issue" && (event.issue.kind === "provider_error" || event.issue.kind === "timeout"))
243
+ throw new Error("Upstream stream failed");
244
+ if (event.type === "usage") {
245
+ const { input, output } = event.usage;
246
+ if (Number.isSafeInteger(input) && Number.isSafeInteger(output) && input >= 0 && output >= 0 && input + output > 0)
247
+ usage = event.usage;
248
+ reason = event.finishReason;
249
+ if (failedFinish(reason)) throw new Error("Upstream stream failed");
250
+ }
251
+ if (event.type === "block_delta" && kinds.get(event.blockId) === "text" && event.delta) return event.delta;
252
+ };
253
+ if (!data.stream) {
254
+ try {
255
+ request.signal?.throwIfAborted();
256
+ setRequestTimeout(c, PROVIDER_WAIT_SECONDS);
257
+ events = provider.stream(request)[Symbol.asyncIterator]();
258
+ try {
259
+ const text: string[] = [];
260
+ for (;;) {
261
+ request.signal?.throwIfAborted();
262
+ const step = await events.next();
263
+ if (step.done) break;
264
+ setRequestTimeout(c, PROVIDER_WAIT_SECONDS);
265
+ const delta = readEvent(step.value);
266
+ if (delta !== undefined) text.push(delta);
267
+ }
268
+ return c.json({
269
+ id,
270
+ object: "chat.completion",
271
+ created,
272
+ model: resolved.profile.id,
273
+ choices: [
274
+ {
275
+ index: 0,
276
+ message: { role: "assistant", content: text.join("") },
277
+ logprobs: null,
278
+ finish_reason: finishReason(reason),
279
+ },
280
+ ],
281
+ ...(usage ? { usage: usageBody(usage) } : {}),
282
+ });
283
+ } finally {
284
+ await events.return?.();
285
+ }
286
+ } catch (error) {
287
+ if (error instanceof AiQuotaError) throw error;
288
+ return errorResponse(c, 502, "upstream_error", "api_error");
289
+ } finally {
290
+ controller.abort();
291
+ }
292
+ }
293
+ let first: string | undefined;
294
+ let ended = false;
295
+ try {
296
+ request.signal?.throwIfAborted();
297
+ setRequestTimeout(c, PROVIDER_WAIT_SECONDS);
298
+ events = provider.stream(request)[Symbol.asyncIterator]();
299
+ // Admission and failures before text still get an HTTP JSON error, before SSE commits headers.
300
+ while (first === undefined) {
301
+ const step = await events.next();
302
+ if (step.done) {
303
+ ended = true;
304
+ break;
305
+ }
306
+ setRequestTimeout(c, PROVIDER_WAIT_SECONDS);
307
+ first = readEvent(step.value);
308
+ }
309
+ } catch (error) {
310
+ try {
311
+ await events?.return?.();
312
+ } finally {
313
+ controller.abort();
314
+ }
315
+ if (error instanceof AiQuotaError) throw error;
316
+ return errorResponse(c, 502, "upstream_error", "api_error");
317
+ }
318
+ const streamEvents = events;
319
+ // From here the SSE callback releases the slot when the stream ends.
320
+ ownsSlot = false;
321
+ return streamSSE(c, async (stream) => {
322
+ stream.onAbort(() => controller.abort());
323
+ const chunk = (delta: { role?: string; content?: string }, finish: string | null = null) => ({
324
+ id,
325
+ object: "chat.completion.chunk",
326
+ created,
327
+ model: resolved.profile.id,
328
+ choices: [{ index: 0, delta, finish_reason: finish, logprobs: null }],
329
+ });
330
+ let pendingWrites = 0;
331
+ const write = async (value: string) => {
332
+ pendingWrites++;
333
+ try {
334
+ await stream.write(value);
335
+ } finally {
336
+ pendingWrites--;
337
+ }
338
+ };
339
+ const send = (value: unknown) => write(`data: ${JSON.stringify(value)}\n\n`);
340
+ const keepalive = setInterval(() => {
341
+ if (pendingWrites === 0) void write(": keepalive\n\n").catch(() => undefined);
342
+ }, dependencies.keepaliveMs ?? 5_000);
343
+ try {
344
+ await send(chunk({ role: "assistant", content: "" }));
345
+ if (first !== undefined) await send(chunk({ content: first }));
346
+ while (!ended) {
347
+ request.signal?.throwIfAborted();
348
+ const step = await streamEvents.next();
349
+ if (step.done) break;
350
+ const text = readEvent(step.value);
351
+ if (text !== undefined) await send(chunk({ content: text }));
352
+ }
353
+ await send(chunk({}, finishReason(reason)));
354
+ if (data.stream_options?.include_usage && usage)
355
+ await send({
356
+ id,
357
+ object: "chat.completion.chunk",
358
+ created,
359
+ model: resolved.profile.id,
360
+ choices: [],
361
+ usage: usageBody(usage),
362
+ });
363
+ await write("data: [DONE]\n\n");
364
+ } catch {
365
+ if (!request.signal?.aborted) await send(errorBody(c, "upstream_error", "api_error"));
366
+ } finally {
367
+ clearInterval(keepalive);
368
+ try {
369
+ await streamEvents.return?.();
370
+ } finally {
371
+ controller.abort();
372
+ await slot.release();
373
+ }
374
+ }
375
+ });
376
+ } finally {
377
+ setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
378
+ if (ownsSlot) await slot.release();
379
+ }
380
+ },
381
+ )
382
+ .all("*", (c) => errorResponse(c, 404, "not_found", "invalid_request_error"));
383
+ };
@@ -39,6 +39,8 @@ export type AiTurnBlock =
39
39
  name: string;
40
40
  args?: unknown;
41
41
  status: AiToolBlockStatus;
42
+ /** ISO time the call reached its outcome. */
43
+ at?: string;
42
44
  /** Localized transient host status, without tool payload data. */
43
45
  progress?: string;
44
46
  result?: unknown;
@@ -193,7 +195,7 @@ export const reconcileResolvedTurnActions = (
193
195
  };
194
196
 
195
197
  /** Decode complete provider names; hashes and ambiguous separators have no exact inverse. */
196
- const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
198
+ export const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
197
199
  if (name.length === 64 && /__[0-9a-f]{12}$/.test(name)) return name;
198
200
  const decode = (value: string): string | undefined => {
199
201
  const tokens = /[a-zA-Z0-9-]|__|_dot_|_u([0-9a-f]+)_/gy;
@@ -241,6 +243,7 @@ export const orderedWebsiteReceipts = (
241
243
  export const buildBlocksFromMessages = (
242
244
  messages: {
243
245
  seq: number;
246
+ createdAt?: string;
244
247
  message: Message;
245
248
  meta?: {
246
249
  steerId?: string;
@@ -253,7 +256,7 @@ export const buildBlocksFromMessages = (
253
256
  const blocks: AiTurnBlock[] = [];
254
257
  const toolIndex = new Map<string, number>();
255
258
 
256
- for (const { seq, message, meta } of messages) {
259
+ for (const { seq, message, meta, createdAt } of messages) {
257
260
  if (message.role === "assistant") {
258
261
  message.content.forEach((block, index) => {
259
262
  if (block.type === "text") {
@@ -298,6 +301,7 @@ export const buildBlocksFromMessages = (
298
301
  status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
299
302
  result: message.result,
300
303
  isError: message.isError,
304
+ ...(createdAt ? { at: createdAt } : {}),
301
305
  ...(outcome === "approved" ? { approved: true } : {}),
302
306
  };
303
307
  }
@@ -1,9 +1,17 @@
1
1
  import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
2
- import { anthropic, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
2
+ import { anthropic, completeFromStream, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
3
3
  import type { AiModelProfile } from "./types";
4
4
 
5
- /** Bound provider stalls while allowing long-context requests up to 60s to begin streaming. */
6
- const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
5
+ /** Bound the first response chunk and every gap between chunks to 60s, for chat and complete() calls. */
6
+ export const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
7
+
8
+ // Native complete() receives headers only after the whole answer, so until then Nessi can bound it only by total time
9
+ // (totalMs), which cuts off slow healthy answers or detects a stall late. Streaming bounds the first response chunk and
10
+ // every gap between chunks, as for chat. Any body chunk counts, including SSE keep-alive comments.
11
+ export const completeThroughStream = (provider: Provider): Provider => ({
12
+ ...provider,
13
+ complete: (request) => completeFromStream(provider, request),
14
+ });
7
15
 
8
16
  /**
9
17
  * Reasoning for structured tasks and compaction: the requests every provider accepted before Nessi 0.17. A global
@@ -31,6 +39,10 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
31
39
 
32
40
  export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
33
41
  if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
42
+ return completeThroughStream(createAdapter(profile, apiKey, headers));
43
+ };
44
+
45
+ const createAdapter = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
34
46
  switch (profile.provider) {
35
47
  case "openai":
36
48
  return openai(profile.model, commonOptions(profile, apiKey));
@@ -39,7 +51,8 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string, heade
39
51
  case "anthropic":
40
52
  return anthropic(profile.model, commonOptions(profile, apiKey));
41
53
  case "mistral":
42
- return mistral(profile.model, commonOptions(profile, apiKey));
54
+ // Mistral requires nine alphanumeric characters; map stored and other providers' ids at the transport boundary.
55
+ return mistral(profile.model, { ...commonOptions(profile, apiKey), normalizeToolCallIds: "strict9" });
43
56
  case "gemini":
44
57
  return gemini(profile.model, commonOptions(profile, apiKey));
45
58
  case "ollama":
@@ -0,0 +1,33 @@
1
+ import type { Context } from "hono";
2
+ import { userFromActor } from "../server/actor";
3
+ import { type ApiErrorResponse, respond } from "../server/api";
4
+ import type { AuthContext, RequestActor } from "../server/middleware/auth";
5
+ import { err, fail } from "../server/services";
6
+ import type { AiToolApprovalContext } from "./approvals";
7
+ import { personalAiModelPolicy } from "./personal-agent";
8
+ import type { AiModelPolicy } from "./types";
9
+
10
+ /** Everything a resolved, authorized request needs to run against the shared runtime. */
11
+ export type AiChatRequestContext = {
12
+ actor: RequestActor;
13
+ ownerUserId: string;
14
+ modelPolicy: AiModelPolicy;
15
+ toolApprovalContext: AiToolApprovalContext;
16
+ /** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
17
+ signedInSession: boolean;
18
+ };
19
+
20
+ export const resolveAiChatRequestContext = async <T extends AuthContext>(
21
+ c: Context<T>,
22
+ ): Promise<AiChatRequestContext | ApiErrorResponse> => {
23
+ const actor = c.get("actor");
24
+ const user = userFromActor(actor);
25
+ if (!user) return respond(c, fail(err.forbidden("AI conversations require a user-backed actor.")));
26
+ return {
27
+ actor,
28
+ ownerUserId: user.id,
29
+ modelPolicy: personalAiModelPolicy,
30
+ toolApprovalContext: { actorUserId: user.id },
31
+ signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
32
+ };
33
+ };