@k2b/cloud 0.31.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 (119) hide show
  1. package/package.json +6 -4
  2. package/src/_internal/define-app.ts +3 -1
  3. package/src/ai/app-skills.ts +1 -7
  4. package/src/ai/audio-tool.ts +2 -1
  5. package/src/ai/browser.ts +109 -0
  6. package/src/ai/capabilities.ts +6 -2
  7. package/src/ai/chart-block.ts +431 -81
  8. package/src/ai/chat/action-sentences.ts +13 -7
  9. package/src/ai/chat/blocks.tsx +11 -7
  10. package/src/ai/chat/builtin-tools.tsx +9 -7
  11. package/src/ai/chat/chart-block.tsx +218 -44
  12. package/src/ai/chat/explorer-block.tsx +276 -0
  13. package/src/ai/chat/file-tools.tsx +40 -1
  14. package/src/ai/chat/message-actions.tsx +6 -0
  15. package/src/ai/chat/message-utils.ts +7 -0
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +13 -3
  18. package/src/ai/chat/reference-text.tsx +34 -0
  19. package/src/ai/chat/tool-groups.ts +1 -0
  20. package/src/ai/chat/tool-names.ts +14 -0
  21. package/src/ai/chat/turn-view.tsx +17 -7
  22. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  23. package/src/ai/chat/view-block.tsx +417 -0
  24. package/src/ai/chat/visual-tools.tsx +32 -53
  25. package/src/ai/client-tool-results.ts +9 -0
  26. package/src/ai/default-tool-contracts.ts +334 -91
  27. package/src/ai/default-tools.ts +87 -11
  28. package/src/ai/executor.ts +173 -31
  29. package/src/ai/file-error.ts +2 -0
  30. package/src/ai/file-tools.ts +27 -7
  31. package/src/ai/files-store.ts +60 -0
  32. package/src/ai/html-pdf-tool.ts +3 -2
  33. package/src/ai/http.ts +37 -12
  34. package/src/ai/index.ts +90 -0
  35. package/src/ai/local-tool-contracts.ts +244 -0
  36. package/src/ai/malformed-tool-call-repair.ts +144 -0
  37. package/src/ai/markdown-pdf-tool.ts +2 -1
  38. package/src/ai/migrate.ts +7 -0
  39. package/src/ai/openai-api-messages.ts +53 -0
  40. package/src/ai/openai-api-slots.ts +40 -0
  41. package/src/ai/openai-api.ts +383 -0
  42. package/src/ai/protocol.ts +6 -2
  43. package/src/ai/provider.ts +16 -4
  44. package/src/ai/request-context.ts +33 -0
  45. package/src/ai/routes.ts +96 -57
  46. package/src/ai/runtime.ts +48 -12
  47. package/src/ai/settings.ts +5 -0
  48. package/src/ai/skill-catalog.ts +20 -5
  49. package/src/ai/skill-format.ts +17 -7
  50. package/src/ai/skill-tool.ts +15 -5
  51. package/src/ai/skills-routes.ts +20 -6
  52. package/src/ai/skills.ts +26 -7
  53. package/src/ai/store.ts +25 -0
  54. package/src/ai/system-prompt.ts +69 -10
  55. package/src/ai/timeline.ts +3 -1
  56. package/src/ai/tools.ts +3 -0
  57. package/src/ai/types.ts +9 -0
  58. package/src/ai/view-block.ts +941 -0
  59. package/src/ai/vision-tool.ts +2 -1
  60. package/src/api/admin-identity.ts +6 -1
  61. package/src/api/app-approval-actions.ts +36 -0
  62. package/src/api/app-approval.ts +10 -2
  63. package/src/api/approvals-loader.ts +15 -0
  64. package/src/api/approvals.ts +120 -0
  65. package/src/api/index.ts +3 -0
  66. package/src/api/me.ts +12 -3
  67. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  68. package/src/approvals/Approvals.tsx +621 -0
  69. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  70. package/src/approvals/messages.ts +95 -0
  71. package/src/approvals/ui.ts +8 -0
  72. package/src/browser/app-approval.ts +32 -0
  73. package/src/browser/choose-files.tsx +26 -13
  74. package/src/browser/commands.ts +13 -4
  75. package/src/browser/file-provider-error.ts +11 -0
  76. package/src/browser/file-providers.ts +3 -12
  77. package/src/browser/files.tsx +1 -2
  78. package/src/browser/live-connection.ts +2 -3
  79. package/src/browser/provider-list.ts +2 -1
  80. package/src/browser/save-files.tsx +13 -2
  81. package/src/cli/account.ts +21 -2
  82. package/src/cli/commands.ts +4 -2
  83. package/src/contracts/app-approval.ts +120 -0
  84. package/src/contracts/approval-paths.ts +4 -0
  85. package/src/contracts/approvals.ts +81 -0
  86. package/src/contracts/command-path.ts +17 -0
  87. package/src/contracts/commands.ts +5 -15
  88. package/src/contracts/shared.ts +5 -0
  89. package/src/events/live-protocol.ts +2 -1
  90. package/src/events/live-server-message.ts +40 -0
  91. package/src/events/testing.ts +88 -0
  92. package/src/server/index.ts +2 -0
  93. package/src/server/middleware/auth.ts +11 -1
  94. package/src/server/request-timeout.ts +22 -0
  95. package/src/server/services/access.ts +4 -3
  96. package/src/services/app-approval.ts +103 -15
  97. package/src/services/approvals/cursor.ts +15 -0
  98. package/src/services/approvals/device.ts +112 -0
  99. package/src/services/approvals/index.ts +27 -11
  100. package/src/services/approvals/list.ts +120 -0
  101. package/src/services/approvals/migrate.ts +9 -1
  102. package/src/services/identity/models-only-auth.ts +25 -0
  103. package/src/services/notifications/email-frame.ts +16 -3
  104. package/src/services/service-account-credentials.ts +22 -10
  105. package/src/services/settings/core-settings.ts +27 -0
  106. package/src/shared/ai-platform-prompt.ts +26 -1
  107. package/src/shared/email-html.ts +4 -0
  108. package/src/shared/index.ts +6 -1
  109. package/src/shared/markdown/extensions/code.ts +14 -0
  110. package/src/shared/markdown/index.ts +4 -4
  111. package/src/ssr/AppBadge.tsx +4 -4
  112. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  113. package/src/ssr/RailApps.island.tsx +2 -2
  114. package/src/ssr/app-badges.ts +50 -6
  115. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  116. package/src/ssr/rail-navigation.ts +5 -3
  117. package/src/ssr/runtime.ts +6 -1
  118. package/src/styles/approvals.css +390 -0
  119. package/src/styles/global.css +5 -2
@@ -0,0 +1,144 @@
1
+ import type { NessiIssue, Provider, SessionStore, ToolCallBlock, ToolStreamIssue } from "@k2b/nessi";
2
+ import { AiTurnFailure } from "./turn-failure";
3
+ import type { AiStoredMessage } from "./types";
4
+
5
+ /** At most two automatic repairs per chat turn; user steering does not reset this budget. */
6
+ export const AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS = 2;
7
+
8
+ const excerpt = (text: string, maxChars: number): string => (text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`);
9
+
10
+ /** Never include argsText: code and other tool arguments can be very large. */
11
+ export const malformedToolCallMessage = (issue: ToolStreamIssue): string =>
12
+ `The tool call${issue.name ? ` to ${excerpt(issue.name, 120)}` : ""} could not be used: ${excerpt(issue.message, 512)} (${issue.reason}).`;
13
+
14
+ const REPAIR_REQUEST = "Re-send the call with its exact tool name and complete, valid, properly escaped JSON arguments.";
15
+
16
+ const buildStoredMalformedToolCallRepairText = (name: string): string => `The tool call to ${name} could not be used. ${REPAIR_REQUEST}`;
17
+
18
+ export const buildMalformedToolCallRepairText = (issue: ToolStreamIssue): string => `${malformedToolCallMessage(issue)} ${REPAIR_REQUEST}`;
19
+
20
+ // The prefix lets resumed turns count repairs independently of ordinary tool calls. The id is 34 characters of [a-z0-9_],
21
+ // within OpenAI's 40- and Anthropic's 64-character limits; stricter providers (Mistral's nine alphanumeric characters)
22
+ // map ids at the transport boundary (see provider.ts).
23
+ export const newMalformedToolCallId = (): string => `malformed_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`;
24
+
25
+ export const isMalformedToolCallId = (id: string): boolean => /^malformed_[0-9a-f]{24}$/.test(id);
26
+
27
+ export const malformedToolCallName = (issue: ToolStreamIssue): string =>
28
+ issue.name && /^[A-Za-z0-9_-]{1,64}$/.test(issue.name) ? issue.name : "unknown_tool";
29
+
30
+ // Partial responses (error/interrupted) count too: their stored error result requests the repair on resume, so each
31
+ // saved response with a malformed call spends one repair, as in the live run.
32
+ export const countMalformedToolCallRepairs = (messages: readonly Pick<AiStoredMessage, "message">[]): number =>
33
+ messages.filter(
34
+ ({ message }) =>
35
+ message.role === "assistant" && message.content.some((block) => block.type === "tool_call" && isMalformedToolCallId(block.id)),
36
+ ).length;
37
+
38
+ // Since 0.17.1, nessi also repairs malformed calls with its own user message and counter. Cloud's session store
39
+ // drops user messages nessi appends, and applying Cloud's repair through steering resets nessi's counter.
40
+ // Only Cloud's repair reaches the model and user. Executor unit tests pin Cloud's exhaustion; live integration tests
41
+ // pin the repair results sent to the model, absence of nessi's message in storage and requests, and Cloud's logged failure.
42
+ export const createMalformedToolCallRepair = (previousAttempts: number) => {
43
+ let attempts = previousAttempts;
44
+ let lastIssue: ToolStreamIssue | undefined;
45
+ // Malformed calls of the provider response in flight; pending marks a stored response that still awaits its repair.
46
+ let issues: (ToolStreamIssue & { callId: string })[] = [];
47
+ // A stored response beyond the budget means the run stopped before it could fail the turn.
48
+ let pending = previousAttempts > AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS;
49
+ return {
50
+ startProviderTurn: () => {
51
+ issues = [];
52
+ },
53
+ noteIssue: (issue: NessiIssue): NessiIssue => {
54
+ if (issue.kind !== "malformed_tool_call") return issue;
55
+ const rewritten = { ...issue, callId: newMalformedToolCallId() };
56
+ issues.push(rewritten);
57
+ lastIssue = rewritten;
58
+ return rewritten;
59
+ },
60
+ /** Hands the response's malformed calls to the store once and marks the repair as due. */
61
+ takeResponseIssues: () => {
62
+ const taken = issues;
63
+ issues = [];
64
+ if (taken.length > 0) pending = true;
65
+ return taken;
66
+ },
67
+ takeSteering: (): string | undefined => {
68
+ if (!pending) return undefined;
69
+ pending = false;
70
+ if (attempts >= AI_MALFORMED_TOOL_CALL_REPAIR_ATTEMPTS || !lastIssue) {
71
+ throw new AiTurnFailure(
72
+ "failed",
73
+ lastIssue
74
+ ? malformedToolCallMessage(lastIssue)
75
+ : "A stored response held a tool call that could not be used after the turn's repairs were spent.",
76
+ );
77
+ }
78
+ attempts++;
79
+ return buildMalformedToolCallRepairText(lastIssue);
80
+ },
81
+ };
82
+ };
83
+
84
+ export const withMalformedToolCallRepair = (provider: Provider, repair: ReturnType<typeof createMalformedToolCallRepair>): Provider => ({
85
+ ...provider,
86
+ stream: async function* (request) {
87
+ repair.startProviderTurn();
88
+ for await (const event of provider.stream(request)) {
89
+ yield event.type === "issue" ? { ...event, issue: repair.noteIssue(event.issue) } : event;
90
+ }
91
+ },
92
+ });
93
+
94
+ export const withMalformedToolCallResults = (
95
+ store: SessionStore,
96
+ repair: ReturnType<typeof createMalformedToolCallRepair>,
97
+ ): SessionStore => ({
98
+ ...store,
99
+ load: async () => {
100
+ const entries = await store.load();
101
+ const assistantIndex = entries.findLastIndex(({ message, kind }) => message.role === "assistant" && kind === "message");
102
+ const assistant = entries[assistantIndex]?.message;
103
+ const following = entries.slice(assistantIndex + 1);
104
+ if (assistant?.role !== "assistant" || following.some(({ message }) => message.role === "user")) return entries;
105
+ const resolved = new Set(following.flatMap(({ message }) => (message.role === "tool_result" ? [message.callId] : [])));
106
+ const missing = assistant.content.filter(
107
+ (block): block is ToolCallBlock => block.type === "tool_call" && isMalformedToolCallId(block.id) && !resolved.has(block.id),
108
+ );
109
+ if (missing.length === 0) return entries;
110
+ for (const call of missing) {
111
+ await store.append({
112
+ role: "tool_result",
113
+ callId: call.id,
114
+ name: call.name,
115
+ isError: true,
116
+ result: buildStoredMalformedToolCallRepairText(call.name),
117
+ });
118
+ }
119
+ return store.load();
120
+ },
121
+ append: async (message, opts) => {
122
+ const issues = message.role === "assistant" && opts?.kind !== "summary" ? repair.takeResponseIssues() : [];
123
+ if (message.role !== "assistant" || issues.length === 0) {
124
+ await store.append(message, opts);
125
+ return;
126
+ }
127
+ const calls: ToolCallBlock[] = issues.map((issue) => ({
128
+ type: "tool_call",
129
+ id: issue.callId,
130
+ name: malformedToolCallName(issue),
131
+ args: {},
132
+ }));
133
+ await store.append({ ...message, content: [...message.content, ...calls] }, opts);
134
+ for (const issue of issues) {
135
+ await store.append({
136
+ role: "tool_result",
137
+ callId: issue.callId,
138
+ name: malformedToolCallName(issue),
139
+ result: buildMalformedToolCallRepairText(issue),
140
+ isError: true,
141
+ });
142
+ }
143
+ },
144
+ });
@@ -7,6 +7,7 @@ import {
7
7
  type RenderMarkdownToPdfOptions,
8
8
  renderMarkdownToPdf,
9
9
  } from "../services/pdf";
10
+ import { aiMissingChatFileError } from "./file-error";
10
11
  import { aiProjectFilePathFromMount } from "./file-mount";
11
12
  import { type AiFileContent, aiFileStore, normalizeAiFilePath } from "./files-store";
12
13
  import { withAiPdfConversionSlot } from "./pdf-conversions";
@@ -65,7 +66,7 @@ export const createCloudAiMarkdownToPdfTool = (dependencies: MarkdownPdfToolDepe
65
66
  if (!ctx.conversationId) throw new Error("The markdown_to_pdf tool needs a conversation context.");
66
67
  const sourcePath = conversationPath(input.path);
67
68
  const source = await read({ conversationId: ctx.conversationId, path: sourcePath });
68
- if (!source) throw new Error(`No such file: ${sourcePath}`);
69
+ if (!source) throw aiMissingChatFileError(sourcePath, ctx.localToolsEnabled);
69
70
  if (source.origin !== "assistant") {
70
71
  throw new Error(`Create an assistant-owned Markdown file with write_file before converting ${sourcePath}.`);
71
72
  }
package/src/ai/migrate.ts CHANGED
@@ -649,6 +649,7 @@ export const migrateCloudAi = async (): Promise<void> => {
649
649
  // A code run asks for its nested HTTP requests and Actions; their remembered approval names a website or Action, not the run.
650
650
  await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS remember_tool_name TEXT`.simple();
651
651
  await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS allow_chat BOOLEAN NOT NULL DEFAULT FALSE`.simple();
652
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS consumed_inline BOOLEAN NOT NULL DEFAULT FALSE`.simple();
652
653
 
653
654
  await sql`
654
655
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
@@ -661,6 +662,12 @@ export const migrateCloudAi = async (): Promise<void> => {
661
662
  WHERE status = 'pending'
662
663
  `.simple();
663
664
 
665
+ await sql`
666
+ CREATE INDEX IF NOT EXISTS idx_ai_pending_actions_approval
667
+ ON ai.pending_actions(approval_id)
668
+ WHERE approval_id IS NOT NULL
669
+ `.simple();
670
+
664
671
  await sql`
665
672
  DO $$
666
673
  BEGIN
@@ -0,0 +1,53 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ const catalog = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ openai_api_disabled: "The OpenAI-compatible API is turned off. Contact an administrator.",
8
+ invalid_api_key: "Use an active API key with the access “AI models only”. Create one in your Cloud profile.",
9
+ model_not_found: "This model is unavailable or you do not have access to it.",
10
+ invalid_request: "The request does not match the supported chat completion format.",
11
+ unsupported_content:
12
+ "Only text messages with system, developer, user or assistant roles are supported. Tool calls are not supported.",
13
+ unsupported_parameter: "This parameter is not supported. This API gives access to the models only.",
14
+ context_length_exceeded: "Your messages exceed this model's context window. Shorten them and try again.",
15
+ request_too_large: "The request body exceeds the limit derived from your available models' context windows.",
16
+ ai_disabled: "AI is turned off. Contact an administrator.",
17
+ model_access_denied: "You do not have access to this model. Contact an administrator.",
18
+ missing_provider_credential: "This model's provider is not configured. Contact an administrator.",
19
+ insufficient_quota:
20
+ "Your Assistant usage limit is reached, or your usage could not be measured. Wait for the reset or contact an administrator.",
21
+ too_many_concurrent_requests: "You have too many simultaneous requests. Wait for a request to finish and try again.",
22
+ rate_limit_exceeded: "Too many requests. Wait and try again.",
23
+ upstream_error: "The model provider could not complete your request. Try again later.",
24
+ internal_error: "Cloud could not process your request. Try again later.",
25
+ not_found: "This OpenAI-compatible API route does not exist.",
26
+ },
27
+ de: {
28
+ openai_api_disabled: "Die OpenAI-kompatible API ist ausgeschaltet. Wende dich an einen Administrator.",
29
+ invalid_api_key: "Verwende einen aktiven API-Schlüssel mit dem Zugriff „Nur KI-Modelle“. Du erstellst ihn in deinem Cloud-Profil.",
30
+ model_not_found: "Dieses Modell ist nicht verfügbar oder nicht für dich freigegeben.",
31
+ invalid_request: "Deine Anfrage entspricht nicht dem unterstützten Format für Chat-Vervollständigungen.",
32
+ unsupported_content:
33
+ "Nur Textnachrichten mit den Rollen system, developer, user oder assistant werden unterstützt. Werkzeugaufrufe werden nicht unterstützt.",
34
+ unsupported_parameter: "Dieser Parameter wird nicht unterstützt. Diese API gibt nur Zugriff auf die Modelle.",
35
+ context_length_exceeded: "Deine Nachrichten überschreiten das Kontextfenster dieses Modells. Kürze sie und versuche es erneut.",
36
+ request_too_large: "Deine Anfrage überschreitet die aus den Kontextfenstern deiner verfügbaren Modelle abgeleitete Größenbegrenzung.",
37
+ ai_disabled: "KI ist ausgeschaltet. Wende dich an einen Administrator.",
38
+ model_access_denied: "Dieses Modell ist nicht für dich freigegeben. Wende dich an einen Administrator.",
39
+ missing_provider_credential: "Der Anbieter dieses Modells ist nicht eingerichtet. Wende dich an einen Administrator.",
40
+ insufficient_quota:
41
+ "Dein Nutzungslimit im Assistenten ist erreicht, oder deine Nutzung konnte nicht gemessen werden. Warte auf das Zurücksetzen oder wende dich an einen Administrator.",
42
+ too_many_concurrent_requests: "Du hast zu viele gleichzeitige Anfragen. Warte, bis eine Anfrage beendet ist, und versuche es erneut.",
43
+ rate_limit_exceeded: "Zu viele Anfragen. Warte kurz und versuche es erneut.",
44
+ upstream_error: "Der Modellanbieter konnte deine Anfrage nicht abschließen. Versuche es später erneut.",
45
+ internal_error: "Cloud konnte deine Anfrage nicht verarbeiten. Versuche es später erneut.",
46
+ not_found: "Diese Route der OpenAI-kompatiblen API existiert nicht.",
47
+ },
48
+ },
49
+ });
50
+
51
+ export type OpenAiErrorCode = keyof ReturnType<typeof catalog.resolve>["t"];
52
+ export const openAiApiMessages = (locale: string) => catalog.resolve([locale]).t;
53
+ export const checkOpenAiApiMessages = () => catalog.check();
@@ -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
+ };