@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.
- package/package.json +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- 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 +64 -39
- 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/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -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 +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- 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/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- 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/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -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/migrate.ts +120 -2
- 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 +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- 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 +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- 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/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- 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/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- 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/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- 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/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- 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 +3 -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/server/widget-request.ts +27 -0
- 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 +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- 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 +26 -1
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- 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/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- 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
|
+
};
|
package/src/ai/protocol.ts
CHANGED
|
@@ -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?: {
|
|
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
|
}
|
package/src/ai/provider.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
};
|