@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.
- package/package.json +8 -6
- package/src/_internal/define-app.ts +20 -9
- package/src/_internal/page-responses.ts +5 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-source-contracts.ts +17 -2
- package/src/ai/code-source-tools.ts +1 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +89 -13
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/memories.ts +48 -1
- package/src/ai/memory-learning.ts +17 -6
- package/src/ai/memory-tool.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +17 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/navigation-search.ts +4 -1
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/outbox.ts +13 -6
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +29 -3
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/shared/theme.ts +41 -20
- package/src/ssr/AnnouncementBanners.tsx +30 -0
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +6 -6
- package/src/ssr/CloudShell.island.tsx +91 -0
- package/src/ssr/GlobalSearchTrigger.tsx +51 -0
- package/src/ssr/Layout.tsx +56 -85
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.tsx +2 -57
- package/src/ssr/LayoutHelpTrigger.tsx +51 -0
- package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +10 -7
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
- package/src/ssr/ShellRuntime.island.tsx +170 -0
- package/src/ssr/TimezoneCookie.island.tsx +3 -19
- package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
- package/src/ssr/announcements-dialog.tsx +66 -0
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
- package/src/ssr/busy-action.ts +19 -0
- package/src/ssr/islands/index.ts +9 -6
- package/src/ssr/launchpad-surface.tsx +15 -0
- package/src/ssr/layout-help-registry.tsx +60 -0
- package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/preference-controller.ts +30 -21
- package/src/ssr/profile-actions.ts +26 -15
- package/src/ssr/profile-preferences-messages.ts +8 -4
- package/src/ssr/rail-context.ts +8 -12
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/ssr/shell-data.tsx +48 -0
- package/src/ssr/timezone-cookie.ts +22 -0
- package/src/ssr/workspace-navigation.ts +25 -2
- package/src/styles/approvals.css +390 -0
- package/src/styles/global.css +22 -7
- package/src/styles/utilities-navigation.css +13 -0
- package/src/workflows/language/compiler.ts +1 -1
- package/src/ssr/AppWorkspaceController.island.tsx +0 -21
- package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
- package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
- package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- package/src/ssr/PageTrailRow.island.tsx +0 -20
- package/src/ssr/PageTrailRuntime.island.tsx +0 -26
- package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
- /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
|
+
};
|
package/src/ai/protocol.ts
CHANGED
|
@@ -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
|
}
|
package/src/ai/provider.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
};
|