@k2b/cloud 0.30.0 → 0.32.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 (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
@@ -0,0 +1,40 @@
1
+ import { redis } from "bun";
2
+ import { logger } from "../services/logging";
3
+
4
+ const LEASE_MS = 120_000;
5
+ const log = logger("ai:openai-api");
6
+ const SLOT_SCRIPT = `
7
+ local time = redis.call("TIME")
8
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
9
+ local key = KEYS[1]
10
+ local slot = ARGV[1]
11
+ local leaseMs = tonumber(ARGV[2])
12
+ redis.call("ZREMRANGEBYSCORE", key, "-inf", now)
13
+ if ARGV[3] == "refresh" then
14
+ if not redis.call("ZSCORE", key, slot) then return 0 end
15
+ elseif redis.call("ZCARD", key) >= tonumber(ARGV[3]) then
16
+ return 0
17
+ end
18
+ redis.call("ZADD", key, now + leaseMs, slot)
19
+ redis.call("PEXPIRE", key, leaseMs)
20
+ return 1
21
+ `;
22
+
23
+ export const acquire = async ({ userId, limit }: { userId: string; limit: number }): Promise<{ release(): Promise<void> } | null> => {
24
+ const key = `cloud:ai:openai-api:slots:${userId}`;
25
+ const slot = crypto.randomUUID();
26
+ const granted = await redis.send("EVAL", [SLOT_SCRIPT, "1", key, slot, String(LEASE_MS), String(limit)]);
27
+ if (granted !== 1) return null;
28
+ const heartbeat = setInterval(() => {
29
+ void redis
30
+ .send("EVAL", [SLOT_SCRIPT, "1", key, slot, String(LEASE_MS), "refresh"])
31
+ .catch(() => log.warn("OpenAI API slot heartbeat failed"));
32
+ }, 30_000);
33
+ heartbeat.unref();
34
+ return {
35
+ async release() {
36
+ clearInterval(heartbeat);
37
+ await redis.send("ZREM", [key, slot]);
38
+ },
39
+ };
40
+ };
@@ -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,12 +39,22 @@ 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;
45
47
  isError?: boolean;
46
48
  /** Present while status is awaiting_approval. */
47
- approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
49
+ approval?: {
50
+ message?: string;
51
+ review?: CapabilityActionReview;
52
+ allowAlways: boolean;
53
+ /** Whether the approval can be remembered for this chat. */
54
+ allowChat?: boolean;
55
+ /** The website origin a chat approval would allow, for an HTTP request a code run makes. */
56
+ website?: string;
57
+ };
48
58
  /**
49
59
  * `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
50
60
  * waited. Either way the approval stays visible as a receipt.
@@ -54,6 +64,8 @@ export type AiTurnBlock =
54
64
  frontendMode?: AiFrontendToolMode;
55
65
  /** Saved Cloud-owned display snapshot for capability calls. */
56
66
  presentation?: AiToolPresentation;
67
+ /** Requests a website approval let through without asking, in order; shown whatever the call's outcome. */
68
+ receipts?: { method: string; url: string }[];
57
69
  }
58
70
  | { id: string; kind: "compaction"; status: "running" | "completed" | "skipped" | "failed"; result?: CompactResult };
59
71
 
@@ -183,7 +195,7 @@ export const reconcileResolvedTurnActions = (
183
195
  };
184
196
 
185
197
  /** Decode complete provider names; hashes and ambiguous separators have no exact inverse. */
186
- const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
198
+ export const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
187
199
  if (name.length === 64 && /__[0-9a-f]{12}$/.test(name)) return name;
188
200
  const decode = (value: string): string | undefined => {
189
201
  const tokens = /[a-zA-Z0-9-]|__|_dot_|_u([0-9a-f]+)_/gy;
@@ -215,6 +227,14 @@ const canonicalToolName = (name: string, presentation?: AiToolPresentation): str
215
227
  return candidates.length === 1 ? candidates[0]! : name;
216
228
  };
217
229
 
230
+ /** The receipts of one tool call, keyed by their index, in the order the requests went out. */
231
+ export const orderedWebsiteReceipts = (
232
+ receipts: Record<string, { method: string; url: string }> | undefined,
233
+ ): { method: string; url: string }[] =>
234
+ Object.entries(receipts ?? {})
235
+ .sort(([a], [b]) => Number(a) - Number(b))
236
+ .map(([, receipt]) => receipt);
237
+
218
238
  /**
219
239
  * Convert persisted loop messages into the block model. Shared by the executor
220
240
  * (baseline rebuild on claim) and the client (rendering finished turns), so a
@@ -223,18 +243,20 @@ const canonicalToolName = (name: string, presentation?: AiToolPresentation): str
223
243
  export const buildBlocksFromMessages = (
224
244
  messages: {
225
245
  seq: number;
246
+ createdAt?: string;
226
247
  message: Message;
227
248
  meta?: {
228
249
  steerId?: string;
229
250
  toolPresentations?: Record<string, AiToolPresentation>;
230
251
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
252
+ websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
231
253
  } | null;
232
254
  }[],
233
255
  ): AiTurnBlock[] => {
234
256
  const blocks: AiTurnBlock[] = [];
235
257
  const toolIndex = new Map<string, number>();
236
258
 
237
- for (const { seq, message, meta } of messages) {
259
+ for (const { seq, message, meta, createdAt } of messages) {
238
260
  if (message.role === "assistant") {
239
261
  message.content.forEach((block, index) => {
240
262
  if (block.type === "text") {
@@ -243,6 +265,7 @@ export const buildBlocksFromMessages = (
243
265
  blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
244
266
  } else if (block.type === "tool_call") {
245
267
  const outcome = meta?.toolOutcomes?.[block.id];
268
+ const receipts = orderedWebsiteReceipts(meta?.websiteReceipts?.[block.id]);
246
269
  toolIndex.set(block.id, blocks.length);
247
270
  blocks.push({
248
271
  id: toolBlockId(block.id),
@@ -255,6 +278,7 @@ export const buildBlocksFromMessages = (
255
278
  // A turn that ended before an approved call returned records the approval on the call's message, and an
256
279
  // approval it left waiting as expired.
257
280
  ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
281
+ ...(receipts.length ? { receipts } : {}),
258
282
  });
259
283
  }
260
284
  });
@@ -277,6 +301,7 @@ export const buildBlocksFromMessages = (
277
301
  status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
278
302
  result: message.result,
279
303
  isError: message.isError,
304
+ ...(createdAt ? { at: createdAt } : {}),
280
305
  ...(outcome === "approved" ? { approved: true } : {}),
281
306
  };
282
307
  }
@@ -1,9 +1,16 @@
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 token 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
+ // Non-streaming calls receive headers only after the whole answer, so a first-byte timeout would cap total generation.
9
+ // Streaming makes the timeouts bound the first token and gaps between chunks, as for chat.
10
+ export const completeThroughStream = (provider: Provider): Provider => ({
11
+ ...provider,
12
+ complete: (request) => completeFromStream(provider, request),
13
+ });
7
14
 
8
15
  /**
9
16
  * Reasoning for structured tasks and compaction: the requests every provider accepted before Nessi 0.17. A global
@@ -31,6 +38,10 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
31
38
 
32
39
  export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
33
40
  if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
41
+ return completeThroughStream(createAdapter(profile, apiKey, headers));
42
+ };
43
+
44
+ const createAdapter = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
34
45
  switch (profile.provider) {
35
46
  case "openai":
36
47
  return openai(profile.model, commonOptions(profile, apiKey));
@@ -39,7 +50,8 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string, heade
39
50
  case "anthropic":
40
51
  return anthropic(profile.model, commonOptions(profile, apiKey));
41
52
  case "mistral":
42
- return mistral(profile.model, commonOptions(profile, apiKey));
53
+ // Mistral requires nine alphanumeric characters; map stored and other providers' ids at the transport boundary.
54
+ return mistral(profile.model, { ...commonOptions(profile, apiKey), normalizeToolCallIds: "strict9" });
43
55
  case "gemini":
44
56
  return gemini(profile.model, commonOptions(profile, apiKey));
45
57
  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
+ };