@k2b/cloud 0.6.0-rc.0 → 0.7.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/README.md +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/provider.ts
CHANGED
|
@@ -10,12 +10,11 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
|
|
|
10
10
|
baseURL: profile.baseURL,
|
|
11
11
|
contextWindow: profile.contextWindow,
|
|
12
12
|
temperature: profile.temperature,
|
|
13
|
-
creditsPerInputToken: profile.creditsPerInputToken,
|
|
14
|
-
creditsPerOutputToken: profile.creditsPerOutputToken,
|
|
15
13
|
timeouts: PROVIDER_TIMEOUTS,
|
|
16
14
|
});
|
|
17
15
|
|
|
18
16
|
export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Provider => {
|
|
17
|
+
if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
|
|
19
18
|
switch (profile.provider) {
|
|
20
19
|
case "openai":
|
|
21
20
|
return openai(profile.model, commonOptions(profile, apiKey));
|
|
@@ -32,8 +31,6 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
|
|
|
32
31
|
baseURL: profile.baseURL,
|
|
33
32
|
contextWindow: profile.contextWindow,
|
|
34
33
|
temperature: profile.temperature,
|
|
35
|
-
creditsPerInputToken: profile.creditsPerInputToken,
|
|
36
|
-
creditsPerOutputToken: profile.creditsPerOutputToken,
|
|
37
34
|
timeouts: PROVIDER_TIMEOUTS,
|
|
38
35
|
});
|
|
39
36
|
case "vllm":
|
|
@@ -44,8 +41,6 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
|
|
|
44
41
|
apiKey,
|
|
45
42
|
contextWindow: profile.contextWindow,
|
|
46
43
|
temperature: profile.temperature,
|
|
47
|
-
creditsPerInputToken: profile.creditsPerInputToken,
|
|
48
|
-
creditsPerOutputToken: profile.creditsPerOutputToken,
|
|
49
44
|
timeouts: PROVIDER_TIMEOUTS,
|
|
50
45
|
compat: {
|
|
51
46
|
toolCallIdPolicy: "passthrough",
|
|
@@ -65,8 +60,6 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
|
|
|
65
60
|
apiKey,
|
|
66
61
|
contextWindow: profile.contextWindow,
|
|
67
62
|
temperature: profile.temperature,
|
|
68
|
-
creditsPerInputToken: profile.creditsPerInputToken,
|
|
69
|
-
creditsPerOutputToken: profile.creditsPerOutputToken,
|
|
70
63
|
timeouts: PROVIDER_TIMEOUTS,
|
|
71
64
|
});
|
|
72
65
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { isAssistantChatTurn } from "./assistant-models";
|
|
2
|
+
import { estimateTokens, type Provider } from "@k2b/nessi";
|
|
3
|
+
import type { AccessSubject } from "../server/services/access";
|
|
4
|
+
import { sql } from "bun";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { AiBackgroundAdmissionError, beginAiCall, finishAiCall, type AiCallContext } from "./inference-calls";
|
|
7
|
+
import type { AiModelProfile } from "./types";
|
|
8
|
+
import { logger } from "../services/logging";
|
|
9
|
+
const log = logger("ai:quotas");
|
|
10
|
+
|
|
11
|
+
/** One wrapper owns call accounting. complete() can have a separate compaction purpose. */
|
|
12
|
+
const callLifecycle = {
|
|
13
|
+
begin: beginAiCall,
|
|
14
|
+
finish: finishAiCall,
|
|
15
|
+
heartbeat: async (id: string) => {
|
|
16
|
+
await sql`UPDATE ai.inference_calls SET lease_expires_at=now()+interval '2 minutes' WHERE id=${id}::uuid AND finished_at IS NULL`;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export function inferenceProvider(
|
|
20
|
+
provider: Provider,
|
|
21
|
+
profile: AiModelProfile,
|
|
22
|
+
context: AiCallContext,
|
|
23
|
+
completeContext = context,
|
|
24
|
+
lifecycle = callLifecycle,
|
|
25
|
+
): Provider {
|
|
26
|
+
const begin = async (request: Parameters<Provider["complete"]>[0], ctx: AiCallContext) => {
|
|
27
|
+
const messages = request.messages.map((message) =>
|
|
28
|
+
message.role === "user"
|
|
29
|
+
? {
|
|
30
|
+
...message,
|
|
31
|
+
content: message.content.filter((part) => typeof part === "string" || part.type !== "file"),
|
|
32
|
+
}
|
|
33
|
+
: message,
|
|
34
|
+
);
|
|
35
|
+
const inputTokens =
|
|
36
|
+
estimateTokens(messages) +
|
|
37
|
+
Math.ceil(
|
|
38
|
+
((request.systemPrompt?.length ?? 0) +
|
|
39
|
+
JSON.stringify(request.tools ?? []).length +
|
|
40
|
+
JSON.stringify(request.responseFormat ?? {}).length) /
|
|
41
|
+
4,
|
|
42
|
+
);
|
|
43
|
+
// Wait up to one reservation lease before delegating to the caller's retry policy.
|
|
44
|
+
// No provider request or new reservation exists while waiting; cancellation stays active.
|
|
45
|
+
const deadline = Date.now() + 120_000;
|
|
46
|
+
const admit = async () => {
|
|
47
|
+
for (;;) {
|
|
48
|
+
request.signal?.throwIfAborted();
|
|
49
|
+
try {
|
|
50
|
+
return await lifecycle.begin(
|
|
51
|
+
profile,
|
|
52
|
+
ctx,
|
|
53
|
+
inputTokens,
|
|
54
|
+
request.maxOutputTokens ?? profile.maxOutputTokens,
|
|
55
|
+
// Nessi's Anthropic adapter defaults to 1024; other adapters use the model default.
|
|
56
|
+
provider.family === "anthropic" ? 1024 : provider.contextWindow,
|
|
57
|
+
);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (!(error instanceof AiBackgroundAdmissionError) || !error.retryable || Date.now() >= deadline) throw error;
|
|
60
|
+
await delay(1_000, undefined, { signal: request.signal });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const call = await admit();
|
|
65
|
+
const heartbeat = setInterval(() => {
|
|
66
|
+
void lifecycle.heartbeat(call.id).catch(() => log.warn("AI accounting heartbeat failed", { callId: call.id }));
|
|
67
|
+
}, 30_000);
|
|
68
|
+
return { ...call, inputTokens, stop: () => clearInterval(heartbeat) };
|
|
69
|
+
};
|
|
70
|
+
const finish = async (id: string, usage: Parameters<typeof finishAiCall>[1], status: "ok" | "failed") => {
|
|
71
|
+
try {
|
|
72
|
+
await lifecycle.finish(id, usage, status);
|
|
73
|
+
} catch {
|
|
74
|
+
log.error("AI cost booking failed", { callId: id, code: "ai_cost_booking_failed" });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
name: provider.name,
|
|
79
|
+
family: provider.family,
|
|
80
|
+
model: provider.model,
|
|
81
|
+
contextWindow: provider.contextWindow,
|
|
82
|
+
capabilities: provider.capabilities,
|
|
83
|
+
complete: async (request) => {
|
|
84
|
+
const call = await begin(request, completeContext);
|
|
85
|
+
let usage: Parameters<typeof finishAiCall>[1];
|
|
86
|
+
let status: "ok" | "failed" = "failed";
|
|
87
|
+
try {
|
|
88
|
+
const result = await provider.complete({ ...request, maxOutputTokens: call.maxOutputTokens });
|
|
89
|
+
if (
|
|
90
|
+
result.usage &&
|
|
91
|
+
[result.usage.input, result.usage.output].every((n) => Number.isSafeInteger(n) && n >= 0) &&
|
|
92
|
+
result.usage.input + result.usage.output > 0
|
|
93
|
+
)
|
|
94
|
+
usage = { input: result.usage.input, output: result.usage.output };
|
|
95
|
+
status = ["error", "aborted", "interrupted"].includes(result.finishReason) ? "failed" : "ok";
|
|
96
|
+
return result;
|
|
97
|
+
} finally {
|
|
98
|
+
call.stop();
|
|
99
|
+
if (!usage && status === "failed") usage = { input: call.inputTokens, output: 0, estimated: true };
|
|
100
|
+
await finish(call.id, usage, status);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
stream: async function* (request) {
|
|
104
|
+
const call = await begin(request, context);
|
|
105
|
+
const id = call.id;
|
|
106
|
+
let usage: { input: number; output: number; estimated?: boolean } | undefined;
|
|
107
|
+
let completed = false;
|
|
108
|
+
let failed = false;
|
|
109
|
+
const outputBlocks = new Map<string, number>();
|
|
110
|
+
let generated = false;
|
|
111
|
+
try {
|
|
112
|
+
for await (const event of provider.stream({ ...request, maxOutputTokens: call.maxOutputTokens })) {
|
|
113
|
+
if (event.type === "issue" && event.issue.kind === "provider_error") failed = true;
|
|
114
|
+
if (event.type === "block_delta") outputBlocks.set(event.blockId, (outputBlocks.get(event.blockId) ?? 0) + event.delta.length);
|
|
115
|
+
if (event.type === "block_end") {
|
|
116
|
+
const block = event.block;
|
|
117
|
+
const size =
|
|
118
|
+
block.type === "text"
|
|
119
|
+
? block.text.length
|
|
120
|
+
: block.type === "thinking"
|
|
121
|
+
? block.thinking.length
|
|
122
|
+
: block.name.length + JSON.stringify(block.args).length;
|
|
123
|
+
outputBlocks.set(event.blockId, size);
|
|
124
|
+
}
|
|
125
|
+
if (event.type === "block_start" || event.type === "block_delta" || event.type === "block_end") generated = true;
|
|
126
|
+
if (event.type === "issue" && event.issue.kind === "provider_error" && event.issue.contextOverflow && !generated && !usage)
|
|
127
|
+
usage = { input: 0, output: 0 };
|
|
128
|
+
if (event.type === "usage") {
|
|
129
|
+
if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error") failed = true;
|
|
130
|
+
const { input, output } = event.usage;
|
|
131
|
+
// Some adapters synthesize all-zero usage when the endpoint reports none.
|
|
132
|
+
if (Number.isSafeInteger(input) && Number.isSafeInteger(output) && input >= 0 && output >= 0 && input + output > 0)
|
|
133
|
+
usage = { input, output };
|
|
134
|
+
}
|
|
135
|
+
yield event;
|
|
136
|
+
}
|
|
137
|
+
completed = true;
|
|
138
|
+
} finally {
|
|
139
|
+
call.stop();
|
|
140
|
+
if (id) {
|
|
141
|
+
try {
|
|
142
|
+
// Interrupted adapters commonly omit their final usage event. Charge an
|
|
143
|
+
// explicitly labelled estimate, never invent a measured zero.
|
|
144
|
+
const interrupted = !completed || failed || request.signal?.aborted;
|
|
145
|
+
if (!usage && interrupted) {
|
|
146
|
+
// Encoded files are not text tokens. Their provider-specific costs
|
|
147
|
+
// cannot be reconstructed after interruption.
|
|
148
|
+
const messages = request.messages.map((message) =>
|
|
149
|
+
message.role === "user"
|
|
150
|
+
? {
|
|
151
|
+
...message,
|
|
152
|
+
content: message.content.filter((part) => typeof part === "string" || part.type !== "file"),
|
|
153
|
+
}
|
|
154
|
+
: message,
|
|
155
|
+
);
|
|
156
|
+
const instructionSize =
|
|
157
|
+
(request.systemPrompt?.length ?? 0) +
|
|
158
|
+
JSON.stringify(request.tools ?? []).length +
|
|
159
|
+
JSON.stringify(request.responseFormat ?? {}).length;
|
|
160
|
+
usage = {
|
|
161
|
+
input: estimateTokens(messages) + Math.ceil(instructionSize / 4),
|
|
162
|
+
output: Math.ceil(Array.from(outputBlocks.values()).reduce((sum, size) => sum + size, 0) / 4),
|
|
163
|
+
estimated: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
await finish(id, usage, completed && !failed && !request.signal?.aborted ? "ok" : "failed");
|
|
167
|
+
} catch (error) {
|
|
168
|
+
log.warn("Chat usage booking failed", { code: "quota_booking_failed", turnId: context.turnId, callId: id });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function assistantQuotaProvider(
|
|
177
|
+
provider: Provider,
|
|
178
|
+
config: Parameters<typeof isAssistantChatTurn>[0],
|
|
179
|
+
subject: AccessSubject | null,
|
|
180
|
+
profile: AiModelProfile,
|
|
181
|
+
turnId: string,
|
|
182
|
+
conversationId: string,
|
|
183
|
+
) {
|
|
184
|
+
const common = { subject, turnId, conversationId };
|
|
185
|
+
return inferenceProvider(
|
|
186
|
+
provider,
|
|
187
|
+
profile,
|
|
188
|
+
{ ...common, kind: isAssistantChatTurn(config) ? "chat" : "background", task: isAssistantChatTurn(config) ? "chat" : "agent-turn" },
|
|
189
|
+
{ ...common, kind: "background", task: "chat-compaction" },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
2
|
+
import { readAiSettingsState } from "./settings";
|
|
3
|
+
import { toPgTextArray } from "../services/postgres";
|
|
4
|
+
import { sql } from "bun";
|
|
5
|
+
import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
|
|
6
|
+
import { AiQuotaReportQuerySchema, type AiQuotaReport, type AiQuotaReportQuery } from "../shared/ai-quotas";
|
|
7
|
+
import { resolveDisplayNames } from "../server/services/access";
|
|
8
|
+
import { aiQuotas } from "./quotas";
|
|
9
|
+
|
|
10
|
+
/** Resolve display metadata only for administration, never on the inference path. */
|
|
11
|
+
export async function quotaAdminConfig() {
|
|
12
|
+
const config = await aiQuotas.config();
|
|
13
|
+
const grants = await resolveDisplayNames(config.rules.flatMap((rule) => rule.grants));
|
|
14
|
+
let offset = 0;
|
|
15
|
+
return {
|
|
16
|
+
...config,
|
|
17
|
+
rules: config.rules.map((rule) => ({
|
|
18
|
+
...rule,
|
|
19
|
+
grants: rule.grants.map(() => {
|
|
20
|
+
const grant = grants[offset++]!;
|
|
21
|
+
return { principal: grant.principal, limit: grant.limit, displayName: grant.displayName };
|
|
22
|
+
}),
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One database snapshot: aggregate and filter the cohort before paginating identities. */
|
|
28
|
+
export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now = new Date()): Promise<AiQuotaReport> {
|
|
29
|
+
const query = AiQuotaReportQuerySchema.parse(input);
|
|
30
|
+
const until = new Date(Math.min(Date.parse(query.until ?? now.toISOString()), now.getTime()));
|
|
31
|
+
const days = { "24h": 1, "7d": 7, "30d": 30, "90d": 90 }[query.range];
|
|
32
|
+
const since = new Date(until.getTime() - days * 86400000);
|
|
33
|
+
if (query.view === "rules")
|
|
34
|
+
return {
|
|
35
|
+
query,
|
|
36
|
+
since: since.toISOString(),
|
|
37
|
+
until: until.toISOString(),
|
|
38
|
+
asOf: now.toISOString(),
|
|
39
|
+
overview: { cost: null, accounts: 0, input: 0, output: 0, calls: 0, measured: 0, estimated: 0, unknown: 0 },
|
|
40
|
+
timeline: [],
|
|
41
|
+
models: [],
|
|
42
|
+
items: [],
|
|
43
|
+
selected: null,
|
|
44
|
+
total: 0,
|
|
45
|
+
page: 1,
|
|
46
|
+
perPage: 25,
|
|
47
|
+
};
|
|
48
|
+
const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id);
|
|
49
|
+
const bucket = query.range === "24h" ? "hour" : "day";
|
|
50
|
+
const order = query.sort === "label" ? sql`label` : query.sort === "cost" ? sql`cost` : sql`last_used`;
|
|
51
|
+
const direction = query.direction === "asc" ? sql`ASC` : sql`DESC`;
|
|
52
|
+
// JSON grant cardinality is unknown to the planner; JIT compilation otherwise
|
|
53
|
+
// dominates small admin reports. Scope this setting to this read transaction.
|
|
54
|
+
const [data] = await sql.begin(async (db) => {
|
|
55
|
+
await db`SET LOCAL jit = off`;
|
|
56
|
+
return db<Omit<AiQuotaReport, "query" | "since" | "until" | "asOf">[]>`
|
|
57
|
+
WITH config AS MATERIALIZED (SELECT enabled,rules FROM ai.cost_config WHERE singleton),
|
|
58
|
+
rules AS MATERIALIZED (
|
|
59
|
+
SELECT r->>'scope' AS scope,r->'grants' AS grants,
|
|
60
|
+
(r->>'anchor')::timestamptz + floor(extract(epoch FROM (${now}::timestamptz-(r->>'anchor')::timestamptz)) / ((r->>'hours')::int*3600)) * ((r->>'hours')::int*interval '1 hour') AS starts
|
|
61
|
+
FROM config,jsonb_array_elements(rules) r WHERE (${query.model}='' OR (r->>'scope' IN ('*',${query.model}) AND ${query.model}=ANY(${toPgTextArray(pricedModels)}::text[]))) AND (r->>'scope'='*' OR r->>'scope'=ANY(${toPgTextArray(pricedModels)}::text[]))
|
|
62
|
+
), accounts AS (
|
|
63
|
+
SELECT 'user'::text AS type,u.id,COALESCE(NULLIF(u.display_name,''),u.uid) AS label,u.id AS user_id,NULL::uuid AS service_id
|
|
64
|
+
FROM auth.users u WHERE ${query.search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id)
|
|
65
|
+
OR EXISTS(SELECT 1 FROM ai.conversations c JOIN ai.turns t ON t.conversation_id=c.id WHERE c.created_by_user_id=u.id AND t.run_config->>'assistantChat'='true' AND t.run_config->'mandate' IS NULL)
|
|
66
|
+
UNION ALL SELECT 'service_account',id,name,NULL::uuid,id FROM auth.service_accounts s
|
|
67
|
+
WHERE ${query.search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND service_account_id=s.id)
|
|
68
|
+
), identities AS MATERIALIZED (
|
|
69
|
+
SELECT a.*,ARRAY(SELECT group_id FROM (${recursiveGroupIdsSubquery(sql`a.user_id`)}) groups) AS groups
|
|
70
|
+
FROM accounts a WHERE position(lower(${query.search}) in lower(label))>0 OR id::text=${query.search}
|
|
71
|
+
), calls AS MATERIALIZED (
|
|
72
|
+
SELECT c.*,COALESCE(c.user_id,c.service_account_id) AS identity_id,
|
|
73
|
+
CASE WHEN c.user_id IS NOT NULL THEN 'user' ELSE 'service_account' END AS identity_type,
|
|
74
|
+
(COALESCE((c.pricing->>'inputPerMillion')::numeric,0)>0 OR COALESCE((c.pricing->>'outputPerMillion')::numeric,0)>0) AND c.cost IS NULL AND (c.finished_at IS NOT NULL OR (c.lease_expires_at<=${now} OR (c.turn_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt AND t.status='running' AND t.lease_expires_at>${now})))) AS unknown
|
|
75
|
+
FROM ai.inference_calls c WHERE c.kind='chat' AND c.started_at<=${now}
|
|
76
|
+
), periods AS MATERIALIZED (
|
|
77
|
+
SELECT * FROM calls WHERE started_at>=${since} AND started_at<${until}
|
|
78
|
+
AND (${query.model}='' OR model_profile_id=${query.model})
|
|
79
|
+
), allowances AS MATERIALIZED (
|
|
80
|
+
SELECT a.id,a.type,r.scope,
|
|
81
|
+
CASE WHEN bool_or((g->>'limit') IS NULL) FILTER(WHERE g IS NOT NULL) THEN NULL ELSE COALESCE(max((g->>'limit')::float8),0) END AS amount,
|
|
82
|
+
r.starts
|
|
83
|
+
FROM identities a CROSS JOIN rules r
|
|
84
|
+
LEFT JOIN LATERAL (SELECT g FROM jsonb_array_elements(r.grants) g WHERE
|
|
85
|
+
g->'principal'->>'type'='authenticated'
|
|
86
|
+
OR (g->'principal'->>'userId')::uuid=a.user_id
|
|
87
|
+
OR (g->'principal'->>'serviceAccountId')::uuid=a.service_id
|
|
88
|
+
OR (g->'principal'->>'groupId')::uuid=ANY(a.groups)) grant_match ON true
|
|
89
|
+
GROUP BY a.id,a.type,r.scope,r.starts
|
|
90
|
+
), balances AS MATERIALIZED (
|
|
91
|
+
SELECT a.*,COALESCE(sum(c.cost),0)::float8 AS used,count(c.id) FILTER(WHERE c.unknown)::int AS unknown
|
|
92
|
+
FROM allowances a LEFT JOIN calls c ON c.identity_id=a.id AND c.identity_type=a.type
|
|
93
|
+
AND (a.scope='*' OR c.model_profile_id=a.scope) AND c.started_at>=a.starts
|
|
94
|
+
AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z
|
|
95
|
+
WHERE COALESCE(z.user_id,z.service_account_id)=a.id AND (z.user_id IS NOT NULL)=(a.type='user') AND z.scope=a.scope),'-infinity'::timestamptz)
|
|
96
|
+
GROUP BY a.id,a.type,a.scope,a.amount,a.starts
|
|
97
|
+
), states AS MATERIALIZED (
|
|
98
|
+
SELECT id,type,count(*)::int AS scopes,
|
|
99
|
+
count(*) FILTER(WHERE amount IS NOT NULL AND used>=amount)::int AS exhausted,
|
|
100
|
+
CASE WHEN bool_or(scope='*' AND amount IS NULL) THEN 'unlimited'
|
|
101
|
+
WHEN bool_or(amount IS NOT NULL AND unknown>0) THEN 'unknown'
|
|
102
|
+
WHEN bool_or(amount IS NOT NULL AND used>=amount) THEN 'exhausted'
|
|
103
|
+
WHEN bool_or(amount IS NOT NULL) THEN 'available' ELSE 'unlimited' END AS status
|
|
104
|
+
FROM balances GROUP BY id,type
|
|
105
|
+
), summary AS MATERIALIZED (
|
|
106
|
+
SELECT a.id,a.type,a.label,
|
|
107
|
+
CASE WHEN NOT COALESCE((SELECT enabled FROM config),false) THEN 'disabled' ELSE COALESCE(s.status,'unlimited') END AS status,
|
|
108
|
+
COALESCE(s.scopes,0) AS scopes,CASE WHEN s.status='unlimited' OR NOT COALESCE((SELECT enabled FROM config),false) THEN 0 ELSE COALESCE(s.exhausted,0) END AS exhausted,
|
|
109
|
+
(SELECT max(started_at) FROM calls c WHERE c.identity_id=a.id AND c.identity_type=a.type AND (${query.model}='' OR c.model_profile_id=${query.model})) AS last_used,
|
|
110
|
+
sum(p.cost)::float8 AS cost,COALESCE(sum(p.input),0)::float8 AS input,COALESCE(sum(p.output),0)::float8 AS output,
|
|
111
|
+
count(p.id)::int AS calls,count(p.id) FILTER(WHERE p.input IS NOT NULL AND p.output IS NOT NULL)::int AS measured,count(p.id) FILTER(WHERE p.estimated)::int AS estimated,count(p.id) FILTER(WHERE p.unknown)::int AS unknown
|
|
112
|
+
FROM identities a LEFT JOIN states s ON s.id=a.id AND s.type=a.type
|
|
113
|
+
LEFT JOIN periods p ON p.identity_id=a.id AND p.identity_type=a.type
|
|
114
|
+
GROUP BY a.id,a.type,a.label,s.status,s.scopes,s.exhausted
|
|
115
|
+
), cohort AS MATERIALIZED (
|
|
116
|
+
SELECT * FROM summary WHERE ${query.status}='all' OR status=${query.status}
|
|
117
|
+
), selected_calls AS (
|
|
118
|
+
SELECT p.* FROM periods p JOIN cohort a ON a.id=p.identity_id AND a.type=p.identity_type
|
|
119
|
+
), pagination AS (
|
|
120
|
+
SELECT count(*)::int AS total,least(${query.page},greatest(1,ceil(count(*)/25.0)))::int AS page,25 AS "perPage" FROM cohort
|
|
121
|
+
), page_rows AS (
|
|
122
|
+
SELECT id,type,label,cost,input,output,calls,measured,estimated,unknown,status,scopes,exhausted,
|
|
123
|
+
COALESCE((SELECT jsonb_agg(jsonb_build_object('scope',b.scope,'limit',b.amount,'used',b.used,'unknown',b.unknown,
|
|
124
|
+
'bypassed', NOT COALESCE((SELECT enabled FROM config),false) OR (b.scope<>'*' AND EXISTS(SELECT 1 FROM balances w WHERE w.id=b.id AND w.type=b.type AND w.scope='*' AND w.amount IS NULL))) ORDER BY b.scope)
|
|
125
|
+
FROM balances b WHERE b.id=cohort.id AND b.type=cohort.type),'[]') AS balances,
|
|
126
|
+
to_char(last_used AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastUsed"
|
|
127
|
+
FROM cohort ORDER BY ${order} ${direction} NULLS LAST,id,type LIMIT 25 OFFSET ((SELECT page FROM pagination)-1)*25
|
|
128
|
+
), timeline AS (
|
|
129
|
+
SELECT date_trunc(${bucket},started_at AT TIME ZONE 'UTC') AS bucket,
|
|
130
|
+
sum(cost)::float8 AS cost,COALESCE(sum(input),0)::float8 AS input,COALESCE(sum(output),0)::float8 AS output,count(*)::int AS calls,count(*) FILTER(WHERE input IS NOT NULL AND output IS NOT NULL)::int AS measured,count(*) FILTER(WHERE unknown)::int AS unknown
|
|
131
|
+
FROM selected_calls GROUP BY 1 ORDER BY 1
|
|
132
|
+
), models AS (
|
|
133
|
+
SELECT model_profile_id AS model,sum(cost)::float8 AS cost,COALESCE(sum(input),0)::float8 AS input,COALESCE(sum(output),0)::float8 AS output,
|
|
134
|
+
count(*)::int AS calls,count(*) FILTER(WHERE input IS NOT NULL AND output IS NOT NULL)::int AS measured,count(*) FILTER(WHERE unknown)::int AS unknown
|
|
135
|
+
FROM selected_calls GROUP BY 1 ORDER BY sum(cost) DESC NULLS LAST,model_profile_id LIMIT 20
|
|
136
|
+
) SELECT pagination.*,
|
|
137
|
+
(SELECT jsonb_build_object('cost',sum(cost),'accounts',count(*) FILTER(WHERE calls>0),'input',COALESCE(sum(input),0),'output',COALESCE(sum(output),0),
|
|
138
|
+
'calls',COALESCE(sum(calls),0),'measured',COALESCE(sum(measured),0),'estimated',COALESCE(sum(estimated),0),'unknown',COALESCE(sum(unknown),0)) FROM cohort) AS overview,
|
|
139
|
+
(SELECT jsonb_build_object('id',id,'type',type,'label',label,'lastUsed',NULL) FROM accounts WHERE id=${query.identity ?? null}::uuid AND type=${query.identityType}) AS selected,
|
|
140
|
+
COALESCE((SELECT jsonb_agg(page_rows) FROM page_rows),'[]') AS items,
|
|
141
|
+
COALESCE((SELECT jsonb_agg(jsonb_build_object('cost',cost,'at',to_char(bucket,'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),'input',input,'output',output,'calls',calls,'measured',measured,'unknown',unknown)) FROM timeline),'[]') AS timeline,
|
|
142
|
+
COALESCE((SELECT jsonb_agg(models) FROM models),'[]') AS models FROM pagination`;
|
|
143
|
+
});
|
|
144
|
+
if (!data) throw new Error("Quota report returned no snapshot");
|
|
145
|
+
return {
|
|
146
|
+
...data,
|
|
147
|
+
query: { ...query, until: until.toISOString(), page: data.page },
|
|
148
|
+
since: since.toISOString(),
|
|
149
|
+
until: until.toISOString(),
|
|
150
|
+
asOf: now.toISOString(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
export async function migrateAiQuotas() {
|
|
3
|
+
await sql.begin(async (db) => {
|
|
4
|
+
await db`CREATE TABLE IF NOT EXISTS ai.cost_config (
|
|
5
|
+
singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK(singleton), enabled BOOLEAN NOT NULL DEFAULT false,
|
|
6
|
+
revision INTEGER NOT NULL DEFAULT 0, rules JSONB NOT NULL DEFAULT '[]',
|
|
7
|
+
unit TEXT NOT NULL DEFAULT 'EUR', background JSONB, background_stopped_at TIMESTAMPTZ, background_warned BOOLEAN NOT NULL DEFAULT false
|
|
8
|
+
)`;
|
|
9
|
+
await db`CREATE TABLE IF NOT EXISTS ai.cost_alerts (
|
|
10
|
+
id UUID PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('warning','stop')), cost NUMERIC(30,12) NOT NULL,
|
|
11
|
+
unit TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
|
12
|
+
)`;
|
|
13
|
+
await db`INSERT INTO ai.cost_config(singleton) VALUES(true) ON CONFLICT DO NOTHING`;
|
|
14
|
+
await db`CREATE TABLE IF NOT EXISTS ai.inference_calls (
|
|
15
|
+
id UUID PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
16
|
+
service_account_id UUID REFERENCES auth.service_accounts(id) ON DELETE SET NULL,
|
|
17
|
+
model_profile_id TEXT NOT NULL, turn_id UUID, turn_attempt INTEGER, started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
18
|
+
finished_at TIMESTAMPTZ, input BIGINT, output BIGINT,
|
|
19
|
+
kind TEXT NOT NULL CHECK(kind IN ('chat','background')), task TEXT NOT NULL,
|
|
20
|
+
provider_model TEXT NOT NULL, app_id TEXT, conversation_id UUID, workflow_run_id UUID,
|
|
21
|
+
step_key TEXT, trace_id TEXT, workflow_id UUID, workflow_name TEXT, workflow_version INTEGER, pricing JSONB, cost NUMERIC(30,12), reserved NUMERIC(30,12) NOT NULL DEFAULT 0,
|
|
22
|
+
lease_expires_at TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'running', error_code TEXT,
|
|
23
|
+
CHECK(cost IS NULL OR cost>=0), CHECK(reserved>=0),
|
|
24
|
+
CHECK(input IS NULL OR input>=0), CHECK(output IS NULL OR output>=0)
|
|
25
|
+
)`;
|
|
26
|
+
await db`ALTER TABLE ai.inference_calls ADD COLUMN IF NOT EXISTS estimated BOOLEAN NOT NULL DEFAULT false`;
|
|
27
|
+
await db`CREATE INDEX IF NOT EXISTS inference_calls_workflow ON ai.inference_calls(workflow_run_id,started_at)`;
|
|
28
|
+
await db`CREATE INDEX IF NOT EXISTS inference_calls_started ON ai.inference_calls(started_at)`;
|
|
29
|
+
await db`CREATE INDEX IF NOT EXISTS inference_calls_turn ON ai.inference_calls(turn_id,started_at,id) WHERE kind='chat'`;
|
|
30
|
+
await db`CREATE INDEX IF NOT EXISTS inference_calls_user ON ai.inference_calls(user_id,started_at)`;
|
|
31
|
+
await db`CREATE INDEX IF NOT EXISTS inference_calls_service ON ai.inference_calls(service_account_id,started_at)`;
|
|
32
|
+
await db`CREATE TABLE IF NOT EXISTS ai.cost_resets (
|
|
33
|
+
request_id UUID PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
34
|
+
service_account_id UUID REFERENCES auth.service_accounts(id) ON DELETE CASCADE,
|
|
35
|
+
scope TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
|
36
|
+
actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
37
|
+
CHECK(num_nonnulls(user_id,service_account_id)=1)
|
|
38
|
+
)`;
|
|
39
|
+
await db`CREATE INDEX IF NOT EXISTS cost_resets_user ON ai.cost_resets(user_id,service_account_id,scope,created_at)`;
|
|
40
|
+
await db`CREATE TABLE IF NOT EXISTS ai.cost_changes (
|
|
41
|
+
revision INTEGER PRIMARY KEY, actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
42
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), config JSONB NOT NULL
|
|
43
|
+
)`;
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/ai/quotas.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
2
|
+
import { readAiSettingsState } from "./settings";
|
|
3
|
+
import { sql, type SQL } from "bun";
|
|
4
|
+
import { buildAccessPrincipalCondition, type AccessSubject } from "../server/services/access";
|
|
5
|
+
import { AiQuotaConfigSchema, type AiQuotaConfig, type AiQuotaSnapshot, type AiQuotaIdentity } from "../shared/ai-quotas";
|
|
6
|
+
|
|
7
|
+
export class AiQuotaError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public code: "quota_exhausted" | "quota_usage_unknown" | "quota_conflict",
|
|
10
|
+
message: string,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const ids = (s: AccessSubject) => ({
|
|
16
|
+
user: s.type === "user" ? s.userId : null,
|
|
17
|
+
service: s.type === "service_account" ? s.serviceAccountId : null,
|
|
18
|
+
});
|
|
19
|
+
export const quotaWindow = (anchor: string, hours: number, now: Date) => {
|
|
20
|
+
const width = hours * 3_600_000,
|
|
21
|
+
start = Date.parse(anchor);
|
|
22
|
+
const from = start + Math.floor((now.getTime() - start) / width) * width;
|
|
23
|
+
return { from: new Date(from), until: new Date(from + width) };
|
|
24
|
+
};
|
|
25
|
+
export const aiQuotas = {
|
|
26
|
+
async config(db: SQL = sql): Promise<AiQuotaConfig> {
|
|
27
|
+
const [row] = await db<AiQuotaConfig[]>`SELECT enabled,revision,rules,unit,background FROM ai.cost_config WHERE singleton`;
|
|
28
|
+
return row ? { ...row, background: row.background ?? undefined } : { enabled: false, revision: 0, rules: [], unit: "EUR" };
|
|
29
|
+
},
|
|
30
|
+
async save(input: AiQuotaConfig, actorId: string) {
|
|
31
|
+
const config = AiQuotaConfigSchema.parse(input);
|
|
32
|
+
return sql.begin(async (db) => {
|
|
33
|
+
const [old] = await db<{ revision: number; unit: string }[]>`SELECT revision,unit FROM ai.cost_config WHERE singleton FOR UPDATE`;
|
|
34
|
+
if (!old || old.revision !== config.revision)
|
|
35
|
+
throw new AiQuotaError("quota_conflict", "Quota settings changed. Reload before saving.");
|
|
36
|
+
if ((config.unit ?? "EUR") !== old.unit) {
|
|
37
|
+
const [used] = await db`SELECT 1 FROM ai.inference_calls WHERE pricing IS NOT NULL LIMIT 1`;
|
|
38
|
+
if (used) throw new AiQuotaError("quota_conflict", "The accounting unit cannot change after priced usage has been recorded.");
|
|
39
|
+
}
|
|
40
|
+
const revision = config.revision + 1;
|
|
41
|
+
await db`UPDATE ai.cost_config SET enabled=${config.enabled},revision=${revision},rules=(${JSON.stringify(config.rules)}::text)::jsonb,unit=${config.unit ?? "EUR"},background=(${config.background ? JSON.stringify(config.background) : null}::text)::jsonb WHERE singleton`;
|
|
42
|
+
await db`INSERT INTO ai.cost_changes(revision,actor_id,config) VALUES(${revision},${actorId}::uuid,(${JSON.stringify(config)}::text)::jsonb)`;
|
|
43
|
+
return { ...config, revision };
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
async snapshot(subject: AccessSubject, model?: string, now = new Date(), db: SQL = sql): Promise<AiQuotaSnapshot> {
|
|
47
|
+
const config = await aiQuotas.config(db);
|
|
48
|
+
const identity = ids(subject);
|
|
49
|
+
const pricedModels = new Set((await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id));
|
|
50
|
+
const matches = buildAccessPrincipalCondition({
|
|
51
|
+
subject,
|
|
52
|
+
columns: {
|
|
53
|
+
userId: sql`NULLIF(g->'principal'->>'userId','')::uuid`,
|
|
54
|
+
groupId: sql`NULLIF(g->'principal'->>'groupId','')::uuid`,
|
|
55
|
+
serviceAccountId: sql`NULLIF(g->'principal'->>'serviceAccountId','')::uuid`,
|
|
56
|
+
authenticatedOnly: sql`(g->'principal'->>'type'='authenticated')`,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const balances: AiQuotaSnapshot["balances"] = [];
|
|
60
|
+
for (const rule of config.rules.filter((r) => !model || r.scope === "*" || r.scope === model)) {
|
|
61
|
+
const grants = await db<
|
|
62
|
+
{ limit: number | null; principal: AiQuotaConfig["rules"][number]["grants"][number]["principal"]; label: string }[]
|
|
63
|
+
>`
|
|
64
|
+
SELECT (g->>'limit')::float8 AS limit, g->'principal' AS principal,
|
|
65
|
+
COALESCE(u.display_name,u.uid,gr.name,sa.name,'Authenticated users') AS label
|
|
66
|
+
FROM jsonb_array_elements((${JSON.stringify(rule.grants)}::text)::jsonb) g
|
|
67
|
+
LEFT JOIN auth.users u ON u.id=NULLIF(g->'principal'->>'userId','')::uuid
|
|
68
|
+
LEFT JOIN auth.groups gr ON gr.id=NULLIF(g->'principal'->>'groupId','')::uuid
|
|
69
|
+
LEFT JOIN auth.service_accounts sa ON sa.id=NULLIF(g->'principal'->>'serviceAccountId','')::uuid
|
|
70
|
+
WHERE ${matches}`;
|
|
71
|
+
const limit = grants.some((g) => g.limit === null) ? null : Math.max(0, ...grants.map((g) => g.limit ?? 0));
|
|
72
|
+
const window = quotaWindow(rule.anchor, rule.hours, now);
|
|
73
|
+
const [counts] = await db<{ input: number; output: number; used: number; unknown: number; estimated: number }[]>`
|
|
74
|
+
SELECT COALESCE(sum(c.cost),0)::float8 AS used, COALESCE(sum(c.input),0)::float8 AS input, COALESCE(sum(c.output),0)::float8 AS output, count(*) FILTER(WHERE c.estimated)::int AS estimated,
|
|
75
|
+
count(*) FILTER(WHERE (COALESCE((c.pricing->>'inputPerMillion')::numeric,0)>0 OR COALESCE((c.pricing->>'outputPerMillion')::numeric,0)>0) AND c.cost IS NULL AND
|
|
76
|
+
(c.finished_at IS NOT NULL OR (c.lease_expires_at <= now() OR (c.turn_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt AND t.status='running' AND t.lease_expires_at>now())))))::int AS unknown
|
|
77
|
+
FROM ai.inference_calls c
|
|
78
|
+
WHERE c.kind='chat' AND c.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND c.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid
|
|
79
|
+
AND (${rule.scope}='*' OR c.model_profile_id=${rule.scope})
|
|
80
|
+
AND c.started_at>=${window.from} AND c.started_at<${window.until}
|
|
81
|
+
AND c.started_at>COALESCE((SELECT max(r.created_at) FROM ai.cost_resets r
|
|
82
|
+
WHERE r.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND r.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid AND r.scope=${rule.scope}),'-infinity'::timestamptz)`;
|
|
83
|
+
balances.push({
|
|
84
|
+
scope: rule.scope,
|
|
85
|
+
limit,
|
|
86
|
+
input: counts?.input ?? 0,
|
|
87
|
+
output: counts?.output ?? 0,
|
|
88
|
+
used: counts?.used ?? 0,
|
|
89
|
+
unknown: counts?.unknown ?? 0,
|
|
90
|
+
estimated: counts?.estimated ?? 0,
|
|
91
|
+
resetsAt: window.until.toISOString(),
|
|
92
|
+
sources: grants.filter((g) => g.limit === limit).map((g) => g.label),
|
|
93
|
+
sourceDetails: grants.filter((g) => g.limit === limit).map((g) => ({ principal: g.principal, displayName: g.label })),
|
|
94
|
+
bypassed: false,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const unlimited = balances.some((b) => b.scope === "*" && b.limit === null);
|
|
98
|
+
for (const b of balances)
|
|
99
|
+
b.bypassed =
|
|
100
|
+
!config.enabled ||
|
|
101
|
+
(model !== undefined && !pricedModels.has(model)) ||
|
|
102
|
+
(b.scope !== "*" && !pricedModels.has(b.scope)) ||
|
|
103
|
+
(unlimited && b.scope !== "*");
|
|
104
|
+
const usage = model
|
|
105
|
+
? []
|
|
106
|
+
: await db<
|
|
107
|
+
{ model: string; input: number; output: number; unknown: number }[]
|
|
108
|
+
>`SELECT model_profile_id AS model,COALESCE(sum(input),0)::float8 AS input,COALESCE(sum(output),0)::float8 AS output,count(*) FILTER(WHERE finished_at IS NOT NULL AND input IS NULL)::int AS unknown FROM ai.inference_calls
|
|
109
|
+
WHERE kind='chat' AND user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid
|
|
110
|
+
GROUP BY model_profile_id ORDER BY model_profile_id`;
|
|
111
|
+
return { enabled: config.enabled, unit: config.unit ?? "EUR", balances, usage };
|
|
112
|
+
},
|
|
113
|
+
async assertAllowed(subject: AccessSubject, model: string) {
|
|
114
|
+
// Fast default path: no additional identity/usage queries on unrestricted installations.
|
|
115
|
+
const pricing = (await readAiSettingsState()).profiles.find((profile) => profile.id === model)?.pricing;
|
|
116
|
+
if (!hasBillableAiPricing(pricing)) return false;
|
|
117
|
+
const config = await aiQuotas.config();
|
|
118
|
+
if (!config.enabled || !config.rules.some((r) => r.scope === "*" || r.scope === model)) return false;
|
|
119
|
+
const snapshot = await aiQuotas.snapshot(subject, model);
|
|
120
|
+
for (const b of snapshot.balances) {
|
|
121
|
+
if (b.bypassed || b.limit === null) continue;
|
|
122
|
+
if (b.unknown)
|
|
123
|
+
throw new AiQuotaError(
|
|
124
|
+
"quota_usage_unknown",
|
|
125
|
+
"Chat usage could not be measured. Contact an administrator or wait for the quota reset.",
|
|
126
|
+
);
|
|
127
|
+
if (b.used >= b.limit) throw new AiQuotaError("quota_exhausted", `Chat cost limit reached. Resets at ${b.resetsAt}.`);
|
|
128
|
+
}
|
|
129
|
+
return snapshot.balances.some((b) => !b.bypassed && b.limit !== null);
|
|
130
|
+
},
|
|
131
|
+
async prune() {
|
|
132
|
+
// Preserve every supported quota window, including rules added while limits were off.
|
|
133
|
+
// One bounded batch per runtime sweep avoids long deletes on busy installations.
|
|
134
|
+
await sql`DELETE FROM ai.inference_calls WHERE id IN (
|
|
135
|
+
SELECT c.id FROM ai.inference_calls c WHERE c.started_at < now() - interval '8760 hours'
|
|
136
|
+
AND (c.turn_id IS NOT NULL OR c.finished_at IS NOT NULL OR c.lease_expires_at<=now())
|
|
137
|
+
AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt
|
|
138
|
+
AND t.status='running' AND t.lease_expires_at>now())
|
|
139
|
+
ORDER BY c.started_at LIMIT 1000 FOR UPDATE SKIP LOCKED)`;
|
|
140
|
+
},
|
|
141
|
+
async reset(subject: AccessSubject, scope: string, requestId: string, actorId: string) {
|
|
142
|
+
const identity = ids(subject);
|
|
143
|
+
await sql.begin(async (db) => {
|
|
144
|
+
// The unique key makes transport retries safe; do not accept reuse for another target.
|
|
145
|
+
await db`INSERT INTO ai.cost_resets(request_id,user_id,service_account_id,scope,actor_id)
|
|
146
|
+
VALUES(${requestId}::uuid,${identity.user}::uuid,${identity.service}::uuid,${scope},${actorId}::uuid) ON CONFLICT DO NOTHING`;
|
|
147
|
+
const [row] = await db`SELECT 1 FROM ai.cost_resets WHERE request_id=${requestId}::uuid AND scope=${scope}
|
|
148
|
+
AND user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid AND actor_id=${actorId}::uuid`;
|
|
149
|
+
if (!row) throw new AiQuotaError("quota_conflict", "Reset request was already used for another target.");
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
async users(search: string, page: number): Promise<{ items: AiQuotaIdentity[]; total: number; page: number; perPage: number }> {
|
|
153
|
+
const source = sql`SELECT 'user'::text AS type,u.id,COALESCE(NULLIF(u.display_name,''),u.uid) AS label,
|
|
154
|
+
(SELECT max(started_at) FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id) AS last_used
|
|
155
|
+
FROM auth.users u WHERE (${search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id) OR EXISTS(SELECT 1 FROM ai.conversations c JOIN ai.turns t ON t.conversation_id=c.id WHERE c.created_by_user_id=u.id AND t.run_config->>'assistantChat'='true' AND t.run_config->'mandate' IS NULL))
|
|
156
|
+
UNION ALL SELECT 'service_account',s.id,s.name,(SELECT max(started_at) FROM ai.inference_calls WHERE kind='chat' AND service_account_id=s.id)
|
|
157
|
+
FROM auth.service_accounts s WHERE (${search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND service_account_id=s.id))`;
|
|
158
|
+
const filtered = sql`SELECT * FROM (${source}) users WHERE position(lower(${search}) in lower(label))>0 OR id::text=${search}`;
|
|
159
|
+
const [count] = await sql<{ total: number }[]>`SELECT count(*)::int AS total FROM (${filtered}) q`;
|
|
160
|
+
const total = count?.total ?? 0,
|
|
161
|
+
perPage = 25,
|
|
162
|
+
current = Math.min(page, Math.max(1, Math.ceil(total / perPage)));
|
|
163
|
+
const items = await sql<
|
|
164
|
+
AiQuotaIdentity[]
|
|
165
|
+
>`SELECT type,id,label,to_char(last_used AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastUsed" FROM (${filtered}) q ORDER BY last_used DESC NULLS LAST,label,id LIMIT ${perPage} OFFSET ${(current - 1) * perPage}`;
|
|
166
|
+
return { items, total, page: current, perPage };
|
|
167
|
+
},
|
|
168
|
+
};
|