@k2b/cloud 0.43.0 → 0.44.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 -2
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +158 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +7 -5
- package/src/_internal/define-app.ts +57 -6
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +11 -1
- package/src/_internal/registry.ts +8 -1
- package/src/_internal/runtime-context.ts +8 -1
- package/src/access/AppAccessEditor.tsx +2 -0
- package/src/access/PermissionEditor.tsx +9 -3
- package/src/access/PrincipalPicker.tsx +14 -4
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-messages.ts +6 -0
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +7 -0
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +4 -4
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +31 -22
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +5 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api.ts +26 -9
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/projects-routes.ts +2 -1
- package/src/ai/projects.ts +13 -12
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +55 -6
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider.ts +1 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +4 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +6 -0
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +17 -18
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +21 -44
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +27 -22
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +16 -0
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +26 -3
- package/src/api/capabilities.ts +28 -4
- package/src/api/index.ts +7 -1
- package/src/api/mcp.ts +175 -2
- package/src/api/me.ts +6 -4
- package/src/api/pwa-phone.ts +75 -26
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/app-pwa/contracts.ts +4 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +1 -0
- package/src/app-pwa/service.ts +91 -0
- package/src/app-pwa/transport.ts +22 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/capabilities/server-invoke.ts +2 -11
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +5 -2
- package/src/cli/apps.ts +89 -26
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +26 -2
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +21 -5
- package/src/contracts/capabilities.ts +143 -3
- package/src/contracts/index.ts +2 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/pwa.ts +17 -1
- package/src/contracts/registry.ts +6 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +4 -0
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +1 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +1 -1
- package/src/server/middleware/auth.ts +21 -7
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +2 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +6 -0
- package/src/services/accounts/entities.ts +14 -3
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +62 -10
- package/src/services/accounts/users.ts +7 -6
- package/src/services/app-access.ts +25 -7
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/ledger.ts +28 -10
- package/src/services/geo/service.ts +43 -24
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/invocation-authority.ts +4 -0
- package/src/services/identity/invocation-token.ts +238 -56
- package/src/services/identity/issue-invocation.ts +6 -1
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +13 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/policy.ts +19 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +71 -43
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared-text/documents.ts +25 -8
- package/src/shared-text/log.ts +1 -1
- package/src/shared-text/text-diff.ts +75 -0
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +9 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +2 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +8 -6
- package/src/ssr/PwaLayout.tsx +16 -1
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +25 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/index.ts +8 -0
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +2 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +2 -0
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/runtime/executor.ts +132 -33
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
package/src/ai/projects.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "../server/services/access";
|
|
13
13
|
import { stripImageMetadata } from "../services/image-metadata";
|
|
14
14
|
import { toPgUuidArray } from "../services/postgres";
|
|
15
|
+
import { type AiModelRequest, AiModelRequestSchema } from "../shared/ai-models";
|
|
15
16
|
import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
|
|
16
17
|
import { isAiImage } from "./file-media-type";
|
|
17
18
|
import { mountAiProjectFilePath } from "./file-mount";
|
|
@@ -42,7 +43,7 @@ export type AiProject = {
|
|
|
42
43
|
description: string;
|
|
43
44
|
icon: string;
|
|
44
45
|
instructions: string;
|
|
45
|
-
|
|
46
|
+
defaultModel: AiModelRequest | null;
|
|
46
47
|
permission: AiProjectPermission;
|
|
47
48
|
revision: number;
|
|
48
49
|
createdAt: string;
|
|
@@ -115,7 +116,7 @@ type ProjectRow = {
|
|
|
115
116
|
description: string;
|
|
116
117
|
icon: string;
|
|
117
118
|
instructions: string;
|
|
118
|
-
|
|
119
|
+
default_model: AiModelRequest | null;
|
|
119
120
|
revision: number;
|
|
120
121
|
created_at: Date | string;
|
|
121
122
|
updated_at: Date | string;
|
|
@@ -213,7 +214,7 @@ const toProject = async (row: ProjectRow, subject: AccessSubject | null, db: SQL
|
|
|
213
214
|
description: row.description,
|
|
214
215
|
icon: row.icon,
|
|
215
216
|
instructions: row.instructions,
|
|
216
|
-
|
|
217
|
+
defaultModel: row.default_model ? AiModelRequestSchema.parse(row.default_model) : null,
|
|
217
218
|
permission,
|
|
218
219
|
revision: Number(row.revision),
|
|
219
220
|
createdAt: iso(row.created_at),
|
|
@@ -417,17 +418,17 @@ export const aiProjects = {
|
|
|
417
418
|
description?: string;
|
|
418
419
|
icon?: string;
|
|
419
420
|
instructions?: string;
|
|
420
|
-
|
|
421
|
+
defaultModel?: AiModelRequest | null;
|
|
421
422
|
}): Promise<AiProject> {
|
|
422
423
|
return sql.begin(async (tx) => {
|
|
423
424
|
const rows = await withAiShortIdForDb(
|
|
424
425
|
tx,
|
|
425
426
|
"idx_ai_projects_short_id",
|
|
426
427
|
(attempt, shortId) => attempt<ProjectRow[]>`
|
|
427
|
-
INSERT INTO ai.projects (short_id, name, description, icon, instructions,
|
|
428
|
+
INSERT INTO ai.projects (short_id, name, description, icon, instructions, default_model)
|
|
428
429
|
VALUES (
|
|
429
430
|
${shortId}, ${input.name.trim()}, ${input.description?.trim() ?? ""}, ${input.icon?.trim() || "ti ti-folders"},
|
|
430
|
-
${input.instructions?.trim() ?? ""}, ${input.
|
|
431
|
+
${input.instructions?.trim() ?? ""}, ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
|
|
431
432
|
)
|
|
432
433
|
RETURNING *
|
|
433
434
|
`,
|
|
@@ -488,9 +489,9 @@ export const aiProjects = {
|
|
|
488
489
|
async update(
|
|
489
490
|
projectId: string,
|
|
490
491
|
subject: AccessSubject | null,
|
|
491
|
-
input: Partial<Pick<AiProject, "name" | "description" | "icon" | "instructions" | "
|
|
492
|
+
input: Partial<Pick<AiProject, "name" | "description" | "icon" | "instructions" | "defaultModel">>,
|
|
492
493
|
): Promise<AiProject | null> {
|
|
493
|
-
const changesTrustedInstructions = Object.hasOwn(input, "instructions") || Object.hasOwn(input, "
|
|
494
|
+
const changesTrustedInstructions = Object.hasOwn(input, "instructions") || Object.hasOwn(input, "defaultModel");
|
|
494
495
|
if (!(await requireProject(projectId, subject, changesTrustedInstructions ? "admin" : "write"))) return null;
|
|
495
496
|
const rows = await sql<ProjectRow[]>`
|
|
496
497
|
UPDATE ai.projects
|
|
@@ -498,9 +499,9 @@ export const aiProjects = {
|
|
|
498
499
|
description = COALESCE(${input.description?.trim() ?? null}, description),
|
|
499
500
|
icon = COALESCE(${input.icon?.trim() ?? null}, icon),
|
|
500
501
|
instructions = COALESCE(${input.instructions?.trim() ?? null}, instructions),
|
|
501
|
-
|
|
502
|
-
WHEN ${Object.hasOwn(input, "
|
|
503
|
-
ELSE
|
|
502
|
+
default_model = CASE
|
|
503
|
+
WHEN ${Object.hasOwn(input, "defaultModel")} THEN ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
|
|
504
|
+
ELSE default_model
|
|
504
505
|
END,
|
|
505
506
|
revision = revision + 1,
|
|
506
507
|
updated_at = now()
|
|
@@ -563,7 +564,7 @@ export const aiProjects = {
|
|
|
563
564
|
instructions: project.instructions,
|
|
564
565
|
context: lines.join("\n").slice(0, AI_PROJECT_PROMPT_MANIFEST_MAX_CHARS),
|
|
565
566
|
references: references.map((reference) => reference.ref),
|
|
566
|
-
|
|
567
|
+
defaultModel: project.defaultModel,
|
|
567
568
|
};
|
|
568
569
|
});
|
|
569
570
|
},
|
package/src/ai/protocol.ts
CHANGED
|
@@ -90,7 +90,7 @@ export type AiTurnFinishedStatus = "completed" | "failed" | "aborted";
|
|
|
90
90
|
export type AiWireEvent =
|
|
91
91
|
| (AiWireEventBase & {
|
|
92
92
|
type: "turn_started";
|
|
93
|
-
|
|
93
|
+
modelId: string;
|
|
94
94
|
providerModel: string;
|
|
95
95
|
/** Authoritative ordered baseline for this attempt. Optional for older senders. */
|
|
96
96
|
blocks?: AiTurnBlock[];
|
|
@@ -116,7 +116,7 @@ export type AiTurnSnapshot = {
|
|
|
116
116
|
/** Last wire seq reflected in `blocks` (attempt-scoped). */
|
|
117
117
|
seq: number;
|
|
118
118
|
blocks: AiTurnBlock[];
|
|
119
|
-
|
|
119
|
+
modelId: string | null;
|
|
120
120
|
createdAt: string;
|
|
121
121
|
/** Time the turn already waited for answered user actions such as approvals. Absent from older servers. */
|
|
122
122
|
actionWaitMs?: number;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { AiProvider } from "../shared/ai-models";
|
|
4
|
+
import { getAiCredential } from "./credentials";
|
|
5
|
+
import { getAiRequestHeaders } from "./request-headers";
|
|
6
|
+
|
|
7
|
+
export type AiProviderTestResult = {
|
|
8
|
+
status: "ok" | "unreachable" | "key_rejected" | "error";
|
|
9
|
+
httpStatus?: number;
|
|
10
|
+
message: string;
|
|
11
|
+
latencyMs?: number;
|
|
12
|
+
};
|
|
13
|
+
export type AiAvailableProviderModel = { providerModel: string; label?: string; kind?: "chat" | "transcription"; contextWindow?: number };
|
|
14
|
+
type Connection = Pick<AiProvider, "type" | "baseUrl"> & { apiKey?: string; headers?: Record<string, string> };
|
|
15
|
+
const defaults = {
|
|
16
|
+
openai: "https://api.openai.com/v1",
|
|
17
|
+
openrouter: "https://openrouter.ai/api/v1",
|
|
18
|
+
anthropic: "https://api.anthropic.com/v1",
|
|
19
|
+
mistral: "https://api.mistral.ai/v1",
|
|
20
|
+
gemini: "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
21
|
+
ollama: "http://localhost:11434",
|
|
22
|
+
vllm: "http://localhost:8000/v1",
|
|
23
|
+
"openai-compatible": "",
|
|
24
|
+
};
|
|
25
|
+
export class AiProviderListError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly code: "unreachable" | "key_rejected" | "list_failed",
|
|
28
|
+
message: string,
|
|
29
|
+
readonly httpStatus?: number,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export const classifyProviderFailure = (error: unknown): AiProviderTestResult => {
|
|
35
|
+
const details = z
|
|
36
|
+
.object({ status: z.number().optional(), statusCode: z.number().optional(), code: z.string().optional(), name: z.string().optional() })
|
|
37
|
+
.passthrough()
|
|
38
|
+
.safeParse(error);
|
|
39
|
+
const httpStatus =
|
|
40
|
+
error instanceof AiProviderListError
|
|
41
|
+
? error.httpStatus
|
|
42
|
+
: details.success
|
|
43
|
+
? (details.data.status ?? details.data.statusCode)
|
|
44
|
+
: undefined;
|
|
45
|
+
const status =
|
|
46
|
+
httpStatus === 401 || httpStatus === 403
|
|
47
|
+
? "key_rejected"
|
|
48
|
+
: (error instanceof AiProviderListError && error.code === "unreachable") ||
|
|
49
|
+
error instanceof TypeError ||
|
|
50
|
+
(details.success && ["TimeoutError", "AbortError"].includes(details.data.name ?? "")) ||
|
|
51
|
+
(details.success && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(details.data.code ?? ""))
|
|
52
|
+
? "unreachable"
|
|
53
|
+
: "error";
|
|
54
|
+
return {
|
|
55
|
+
status,
|
|
56
|
+
...(httpStatus ? { httpStatus } : {}),
|
|
57
|
+
message:
|
|
58
|
+
status === "key_rejected"
|
|
59
|
+
? "The provider rejected the credential."
|
|
60
|
+
: status === "unreachable"
|
|
61
|
+
? "The provider did not respond within 10 seconds or could not be reached."
|
|
62
|
+
: "The provider request failed.",
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
/** Best-effort persistence never rejects: callers await it inside their provider error boundary. */
|
|
66
|
+
export const recordAiProviderStatus = async (id: string, result: AiProviderTestResult, force = false) => {
|
|
67
|
+
try {
|
|
68
|
+
await sql`UPDATE ai.providers SET status=${result.status},status_code=${result.httpStatus?.toString() ?? null},status_message=${result.message},status_checked_at=clock_timestamp() WHERE id=${id} AND (${force} OR status IS DISTINCT FROM ${result.status} OR status_code IS DISTINCT FROM ${result.httpStatus?.toString() ?? null})`;
|
|
69
|
+
} catch {
|
|
70
|
+
// A failed database write must not start another write through the persistent logger.
|
|
71
|
+
console.warn("[ai:providers] Could not persist provider connection status", { providerId: id });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const requestModels = async (provider: Connection, signal?: AbortSignal, after?: string) => {
|
|
75
|
+
const base = (provider.baseUrl ?? defaults[provider.type]).replace(/\/$/, "");
|
|
76
|
+
if (!base) throw new AiProviderListError("list_failed", "The provider has no base URL.");
|
|
77
|
+
const headers = new Headers(provider.headers);
|
|
78
|
+
if (provider.type === "anthropic") {
|
|
79
|
+
headers.set("anthropic-version", "2023-06-01");
|
|
80
|
+
if (provider.apiKey) headers.set("x-api-key", provider.apiKey);
|
|
81
|
+
} else if (provider.apiKey) headers.set("authorization", `Bearer ${provider.apiKey}`);
|
|
82
|
+
let response: Response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetch(
|
|
85
|
+
`${base}${provider.type === "ollama" ? "/api/tags" : `/models${after ? `?after_id=${encodeURIComponent(after)}` : ""}`}`,
|
|
86
|
+
{ headers, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000), redirect: "error" },
|
|
87
|
+
);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new AiProviderListError("unreachable", "The provider could not be reached.");
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
throw new AiProviderListError(
|
|
94
|
+
response.status === 401 || response.status === 403 ? "key_rejected" : "list_failed",
|
|
95
|
+
"The provider could not list models.",
|
|
96
|
+
response.status,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return response;
|
|
100
|
+
};
|
|
101
|
+
/** Cloud's one listing seam; move it to nessi listModels() when that API ships. */
|
|
102
|
+
export const listProviderModels = async (provider: Connection, signal?: AbortSignal): Promise<AiAvailableProviderModel[]> => {
|
|
103
|
+
const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000);
|
|
104
|
+
const models: AiAvailableProviderModel[] = [];
|
|
105
|
+
const seen = new Set<string>();
|
|
106
|
+
let after: string | undefined;
|
|
107
|
+
do {
|
|
108
|
+
const response = await requestModels(provider, deadline, after);
|
|
109
|
+
try {
|
|
110
|
+
const body: unknown = await response.json();
|
|
111
|
+
if (provider.type === "ollama")
|
|
112
|
+
return z
|
|
113
|
+
.object({ models: z.array(z.object({ name: z.string() })) })
|
|
114
|
+
.parse(body)
|
|
115
|
+
.models.map((model) => ({ providerModel: model.name }));
|
|
116
|
+
const page = z
|
|
117
|
+
.object({
|
|
118
|
+
data: z.array(
|
|
119
|
+
z.object({
|
|
120
|
+
id: z.string(),
|
|
121
|
+
name: z.string().optional(),
|
|
122
|
+
display_name: z.string().optional(),
|
|
123
|
+
context_length: z.number().int().positive().optional(),
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
has_more: z.boolean().optional(),
|
|
127
|
+
last_id: z.string().optional(),
|
|
128
|
+
})
|
|
129
|
+
.parse(body);
|
|
130
|
+
models.push(
|
|
131
|
+
...page.data.map((model) => ({
|
|
132
|
+
providerModel: model.id,
|
|
133
|
+
...((model.display_name ?? model.name) ? { label: model.display_name ?? model.name } : {}),
|
|
134
|
+
...(model.context_length ? { contextWindow: model.context_length } : {}),
|
|
135
|
+
})),
|
|
136
|
+
);
|
|
137
|
+
if (provider.type !== "anthropic" || !page.has_more) return models;
|
|
138
|
+
after = page.last_id ?? page.data.at(-1)?.id;
|
|
139
|
+
if (!after || seen.has(after)) throw new Error("Non-advancing model cursor");
|
|
140
|
+
seen.add(after);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new AiProviderListError("list_failed", "The provider returned an invalid model list.");
|
|
143
|
+
}
|
|
144
|
+
} while (!deadline.aborted);
|
|
145
|
+
throw new AiProviderListError("unreachable", "The provider model list timed out.");
|
|
146
|
+
};
|
|
147
|
+
export const storedProviderConnection = async (provider: AiProvider): Promise<Connection> => ({
|
|
148
|
+
...provider,
|
|
149
|
+
apiKey: (await getAiCredential(provider.id)) ?? undefined,
|
|
150
|
+
headers: await getAiRequestHeaders(provider.id),
|
|
151
|
+
});
|
|
152
|
+
export const testAiProvider = async (provider: Connection, signal?: AbortSignal): Promise<AiProviderTestResult> => {
|
|
153
|
+
const start = Date.now();
|
|
154
|
+
try {
|
|
155
|
+
const response = await requestModels(provider, signal);
|
|
156
|
+
await response.body?.cancel();
|
|
157
|
+
return { status: "ok", httpStatus: response.status, message: "The provider connection works.", latencyMs: Date.now() - start };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return { ...classifyProviderFailure(error), latencyMs: Date.now() - start };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { NessiIssue, Provider } from "@k2b/nessi/ai";
|
|
1
|
+
import type { NessiIssue, Provider, StreamEvent } from "@k2b/nessi/ai";
|
|
2
|
+
import { classifyProviderFailure, recordAiProviderStatus } from "./provider-connections";
|
|
3
|
+
import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
|
|
2
4
|
|
|
3
5
|
/** No provider-owned message, name, stack, cause, or extension fields cross this boundary. */
|
|
4
6
|
export class AiProviderError extends Error {
|
|
@@ -63,27 +65,74 @@ const toolStreamReason = (reason: string) => {
|
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
/** Apply before accounting, retry, nessi loops, traces, capabilities, and loggers observe an adapter. */
|
|
66
|
-
export const withProviderErrors = (provider: Provider): Provider => ({
|
|
68
|
+
export const withProviderErrors = (provider: Provider, providerId?: string): Provider => ({
|
|
67
69
|
name: provider.name,
|
|
68
70
|
family: provider.family,
|
|
69
71
|
model: provider.model,
|
|
70
72
|
contextWindow: provider.contextWindow,
|
|
71
73
|
capabilities: provider.capabilities,
|
|
72
74
|
complete: async (request) => {
|
|
75
|
+
const marks: ProviderFetchMarks = {};
|
|
73
76
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const result = await runWithProviderFetchMarks(marks, () => provider.complete(request));
|
|
78
|
+
if (providerId && !request.signal?.aborted)
|
|
79
|
+
await recordAiProviderStatus(
|
|
80
|
+
providerId,
|
|
81
|
+
["error", "aborted", "interrupted"].includes(result.finishReason ?? "")
|
|
82
|
+
? classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : {})
|
|
83
|
+
: { status: "ok", message: "The provider connection works." },
|
|
84
|
+
);
|
|
85
|
+
return result;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (providerId && !request.signal?.aborted)
|
|
88
|
+
await recordAiProviderStatus(
|
|
89
|
+
providerId,
|
|
90
|
+
classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : error),
|
|
91
|
+
);
|
|
76
92
|
throw new AiProviderError(request.signal?.aborted);
|
|
77
93
|
}
|
|
78
94
|
},
|
|
79
95
|
stream: async function* (request) {
|
|
96
|
+
const marks: ProviderFetchMarks = {};
|
|
97
|
+
let iterator: AsyncIterator<StreamEvent> | undefined;
|
|
80
98
|
try {
|
|
81
|
-
|
|
99
|
+
const activeIterator = provider.stream(request)[Symbol.asyncIterator]();
|
|
100
|
+
iterator = activeIterator;
|
|
101
|
+
let failed = false;
|
|
102
|
+
for (;;) {
|
|
103
|
+
const next = await runWithProviderFetchMarks(marks, () => activeIterator.next());
|
|
104
|
+
if (next.done) break;
|
|
105
|
+
const event = next.value;
|
|
106
|
+
if (event.type === "issue" && (event.issue.kind === "provider_error" || event.issue.kind === "timeout")) {
|
|
107
|
+
failed = true;
|
|
108
|
+
if (providerId)
|
|
109
|
+
await recordAiProviderStatus(
|
|
110
|
+
providerId,
|
|
111
|
+
event.issue.kind === "timeout"
|
|
112
|
+
? { status: "unreachable", message: "The provider timed out." }
|
|
113
|
+
: classifyProviderFailure(
|
|
114
|
+
marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : event.issue,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
82
118
|
yield event.type === "issue" ? { type: "issue", issue: providerIssue(event.issue, request.tools) } : event;
|
|
83
119
|
}
|
|
84
|
-
|
|
120
|
+
if (providerId && !failed && !request.signal?.aborted)
|
|
121
|
+
await recordAiProviderStatus(providerId, { status: "ok", message: "The provider connection works." });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (providerId && !request.signal?.aborted)
|
|
124
|
+
await recordAiProviderStatus(
|
|
125
|
+
providerId,
|
|
126
|
+
classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : error),
|
|
127
|
+
);
|
|
85
128
|
// Includes synchronous creation, iteration, and iterator cleanup failures.
|
|
86
129
|
throw new AiProviderError(request.signal?.aborted);
|
|
130
|
+
} finally {
|
|
131
|
+
try {
|
|
132
|
+
await iterator?.return?.();
|
|
133
|
+
} catch {
|
|
134
|
+
throw new AiProviderError(request.signal?.aborted);
|
|
135
|
+
}
|
|
87
136
|
}
|
|
88
137
|
},
|
|
89
138
|
});
|
package/src/ai/provider-fetch.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
13
13
|
*/
|
|
14
14
|
export type ProviderFetchMarks = {
|
|
15
15
|
headersAt?: number;
|
|
16
|
+
httpStatus?: number;
|
|
17
|
+
networkFailure?: boolean;
|
|
16
18
|
firstByteAt?: number;
|
|
17
19
|
/** The request never reached the provider, or the provider answered with a status that says it did not process it. */
|
|
18
20
|
refused?: boolean;
|
|
@@ -74,6 +76,7 @@ const install = (): void => {
|
|
|
74
76
|
try {
|
|
75
77
|
response = await realFetch(input, init);
|
|
76
78
|
} catch (error) {
|
|
79
|
+
for (const store of stores) store.networkFailure = true;
|
|
77
80
|
// A reset, an abort, or a timeout can follow delivery, so only an unsent request counts as refused.
|
|
78
81
|
if (unsent(error))
|
|
79
82
|
for (const store of stores) {
|
|
@@ -85,6 +88,7 @@ const install = (): void => {
|
|
|
85
88
|
const wait = response.ok ? undefined : retryAfterMs(response.headers, headersAt);
|
|
86
89
|
for (const store of stores) {
|
|
87
90
|
store.headersAt = headersAt;
|
|
91
|
+
store.httpStatus = response.status;
|
|
88
92
|
store.refused = refusedStatus(response.status);
|
|
89
93
|
if (wait !== undefined) store.retryAfterMs = wait;
|
|
90
94
|
}
|
package/src/ai/provider.ts
CHANGED
|
@@ -40,7 +40,7 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
|
|
|
40
40
|
|
|
41
41
|
export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
|
|
42
42
|
if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
|
|
43
|
-
return withProviderErrors(completeThroughStream(createAdapter(profile, apiKey, headers)));
|
|
43
|
+
return withProviderErrors(completeThroughStream(createAdapter(profile, apiKey, headers)), profile.providerId);
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
const createAdapter = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
|
|
3
|
+
import { toPgTextArray } from "../services/postgres";
|
|
4
|
+
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
5
|
+
import { readAiSettingsState } from "./settings";
|
|
6
|
+
|
|
7
|
+
/** One row per rule; identities are deduplicated when several model rules apply. */
|
|
8
|
+
export const aiPeopleAtLimit = async (db = sql): Promise<{ scope: string; people: string[] }[]> => {
|
|
9
|
+
const priced = [
|
|
10
|
+
...new Set(
|
|
11
|
+
(await readAiSettingsState()).profiles.filter((profile) => hasBillableAiPricing(profile.pricing)).map((profile) => profile.modelId),
|
|
12
|
+
),
|
|
13
|
+
];
|
|
14
|
+
return db<{ scope: string; people: string[] }[]>`WITH rules AS MATERIALIZED (
|
|
15
|
+
SELECT r->>'scope' AS scope,r->'grants' AS grants,
|
|
16
|
+
(r->>'anchor')::timestamptz + floor(extract(epoch FROM (now()-(r->>'anchor')::timestamptz)) / ((r->>'hours')::int*3600)) * ((r->>'hours')::int*interval '1 hour') AS starts
|
|
17
|
+
FROM ai.cost_config,jsonb_array_elements(rules) r WHERE singleton AND enabled
|
|
18
|
+
AND (r->>'scope'='*' OR r->>'scope'=ANY(${toPgTextArray(priced)}::text[]))
|
|
19
|
+
), people AS MATERIALIZED (
|
|
20
|
+
SELECT u.id FROM auth.users u WHERE EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id)
|
|
21
|
+
OR EXISTS(SELECT 1 FROM ai.conversations c JOIN ai.turns t ON t.conversation_id=c.id
|
|
22
|
+
WHERE c.created_by_user_id=u.id AND t.run_config->>'assistantChat'='true' AND t.run_config->'mandate' IS NULL)
|
|
23
|
+
), allowances AS MATERIALIZED (
|
|
24
|
+
SELECT u.id,r.scope,r.starts,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
|
|
25
|
+
FROM people u CROSS JOIN rules r LEFT JOIN LATERAL (
|
|
26
|
+
SELECT g FROM jsonb_array_elements(r.grants) g WHERE g->'principal'->>'type'='authenticated'
|
|
27
|
+
OR g->'principal'->>'userId'=u.id::text OR (g->'principal'->>'groupId')::uuid IN (${recursiveGroupIdsSubquery(sql`u.id`)})
|
|
28
|
+
) grants ON true GROUP BY u.id,r.scope,r.starts
|
|
29
|
+
), exhausted AS (
|
|
30
|
+
SELECT a.scope,a.id FROM allowances a LEFT JOIN ai.inference_calls c ON c.user_id=a.id AND c.kind='chat'
|
|
31
|
+
AND (a.scope='*' OR c.model_id=a.scope) AND c.started_at>=a.starts
|
|
32
|
+
AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z WHERE z.user_id=a.id AND z.scope=a.scope),'-infinity'::timestamptz)
|
|
33
|
+
WHERE a.amount IS NOT NULL AND NOT EXISTS(SELECT 1 FROM allowances w WHERE w.id=a.id AND w.scope='*' AND w.amount IS NULL)
|
|
34
|
+
GROUP BY a.scope,a.id,a.amount HAVING COALESCE(sum(c.cost),0)>=a.amount
|
|
35
|
+
) SELECT scope,array_agg(id::text ORDER BY id) AS people FROM exhausted GROUP BY scope`;
|
|
36
|
+
};
|
package/src/ai/quota-provider.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { estimateTokens, type Provider } from "@k2b/nessi";
|
|
|
3
3
|
import { sql } from "bun";
|
|
4
4
|
import type { AccessSubject } from "../server/services/access";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
|
+
import type { AiResolvedModelRef } from "../shared/ai-models";
|
|
6
7
|
import { isAssistantChatTurn } from "./assistant-models";
|
|
7
8
|
import { AiBackgroundAdmissionError, type AiCallContext, type AiCallDetails, beginAiCall, finishAiCall } from "./inference-calls";
|
|
8
9
|
import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
|
|
@@ -230,7 +231,7 @@ export function inferenceProvider(
|
|
|
230
231
|
firstByteMs: marks.firstByteAt === undefined ? undefined : marks.firstByteAt - requestStartedAt,
|
|
231
232
|
firstBlockMs: marks.firstBlockAt === undefined ? undefined : marks.firstBlockAt - requestStartedAt,
|
|
232
233
|
});
|
|
233
|
-
} catch
|
|
234
|
+
} catch {
|
|
234
235
|
log.warn("Chat usage booking failed", { code: "quota_booking_failed", turnId: context.turnId, callId: id });
|
|
235
236
|
}
|
|
236
237
|
}
|
|
@@ -246,8 +247,9 @@ export function assistantQuotaProvider(
|
|
|
246
247
|
profile: AiModelProfile,
|
|
247
248
|
turnId: string,
|
|
248
249
|
conversationId: string,
|
|
250
|
+
modelRef: AiResolvedModelRef,
|
|
249
251
|
) {
|
|
250
|
-
const common = { subject, turnId, conversationId };
|
|
252
|
+
const common = { subject, turnId, conversationId, modelRef };
|
|
251
253
|
return inferenceProvider(
|
|
252
254
|
provider,
|
|
253
255
|
profile,
|
package/src/ai/quota-report.ts
CHANGED
|
@@ -4,18 +4,21 @@ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
|
|
|
4
4
|
import { toPgTextArray } from "../services/postgres";
|
|
5
5
|
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
6
6
|
import { type AiQuotaReport, type AiQuotaReportQuery, AiQuotaReportQuerySchema } from "../shared/ai-quotas";
|
|
7
|
+
import { aiPeopleAtLimit } from "./quota-limits";
|
|
7
8
|
import { aiQuotas } from "./quotas";
|
|
8
9
|
import { readAiSettingsState } from "./settings";
|
|
9
10
|
|
|
10
11
|
/** Resolve display metadata only for administration, never on the inference path. */
|
|
11
12
|
export async function quotaAdminConfig() {
|
|
12
13
|
const config = await aiQuotas.config();
|
|
14
|
+
const limits = await aiPeopleAtLimit();
|
|
13
15
|
const grants = await resolveDisplayNames(config.rules.flatMap((rule) => rule.grants));
|
|
14
16
|
let offset = 0;
|
|
15
17
|
return {
|
|
16
18
|
...config,
|
|
17
19
|
rules: config.rules.map((rule) => ({
|
|
18
20
|
...rule,
|
|
21
|
+
peopleAtLimit: limits.find((limit) => limit.scope === rule.scope)?.people.length ?? 0,
|
|
19
22
|
grants: rule.grants.map(() => {
|
|
20
23
|
const grant = grants[offset++]!;
|
|
21
24
|
return {
|
|
@@ -50,7 +53,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
|
|
|
50
53
|
page: 1,
|
|
51
54
|
perPage: 25,
|
|
52
55
|
};
|
|
53
|
-
const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.
|
|
56
|
+
const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.modelId);
|
|
54
57
|
const bucket = query.range === "24h" ? "hour" : "day";
|
|
55
58
|
const order = query.sort === "label" ? sql`label` : query.sort === "cost" ? sql`cost` : sql`last_used`;
|
|
56
59
|
const direction = query.direction === "asc" ? sql`ASC` : sql`DESC`;
|
|
@@ -80,7 +83,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
|
|
|
80
83
|
FROM ai.inference_calls c WHERE c.kind='chat' AND c.started_at<=${now}
|
|
81
84
|
), periods AS MATERIALIZED (
|
|
82
85
|
SELECT * FROM calls WHERE started_at>=${since} AND started_at<${until}
|
|
83
|
-
AND (${query.model}='' OR
|
|
86
|
+
AND (${query.model}='' OR model_id=${query.model})
|
|
84
87
|
), allowances AS MATERIALIZED (
|
|
85
88
|
SELECT a.id,a.type,r.scope,
|
|
86
89
|
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,
|
|
@@ -95,7 +98,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
|
|
|
95
98
|
), balances AS MATERIALIZED (
|
|
96
99
|
SELECT a.*,COALESCE(sum(c.cost),0)::float8 AS used,count(c.id) FILTER(WHERE c.unknown)::int AS unknown
|
|
97
100
|
FROM allowances a LEFT JOIN calls c ON c.identity_id=a.id AND c.identity_type=a.type
|
|
98
|
-
AND (a.scope='*' OR c.
|
|
101
|
+
AND (a.scope='*' OR c.model_id=a.scope) AND c.started_at>=a.starts
|
|
99
102
|
AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z
|
|
100
103
|
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)
|
|
101
104
|
GROUP BY a.id,a.type,a.scope,a.amount,a.starts
|
|
@@ -111,7 +114,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
|
|
|
111
114
|
SELECT a.id,a.type,a.label,
|
|
112
115
|
CASE WHEN NOT COALESCE((SELECT enabled FROM config),false) THEN 'disabled' ELSE COALESCE(s.status,'unlimited') END AS status,
|
|
113
116
|
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,
|
|
114
|
-
(SELECT max(started_at) FROM calls c WHERE c.identity_id=a.id AND c.identity_type=a.type AND (${query.model}='' OR c.
|
|
117
|
+
(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_id=${query.model})) AS last_used,
|
|
115
118
|
sum(p.cost)::float8 AS cost,COALESCE(sum(p.input),0)::float8 AS input,COALESCE(sum(p.output),0)::float8 AS output,
|
|
116
119
|
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
|
|
117
120
|
FROM identities a LEFT JOIN states s ON s.id=a.id AND s.type=a.type
|
package/src/ai/quotas.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type AccessSubject, buildAccessPrincipalCondition } from "../server/ser
|
|
|
3
3
|
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
4
4
|
import { type AiQuotaConfig, AiQuotaConfigSchema, type AiQuotaIdentity, type AiQuotaSnapshot } from "../shared/ai-quotas";
|
|
5
5
|
import { readAiSettingsState } from "./settings";
|
|
6
|
+
import type { AiSettingsState } from "./types";
|
|
6
7
|
|
|
7
8
|
export class AiQuotaError extends Error {
|
|
8
9
|
constructor(
|
|
@@ -43,10 +44,18 @@ export const aiQuotas = {
|
|
|
43
44
|
return { ...config, revision };
|
|
44
45
|
});
|
|
45
46
|
},
|
|
46
|
-
async snapshot(
|
|
47
|
-
|
|
47
|
+
async snapshot(
|
|
48
|
+
subject: AccessSubject,
|
|
49
|
+
model?: string,
|
|
50
|
+
now = new Date(),
|
|
51
|
+
db: SQL = sql,
|
|
52
|
+
context?: { state: AiSettingsState; config: AiQuotaConfig },
|
|
53
|
+
): Promise<AiQuotaSnapshot> {
|
|
54
|
+
const config = context?.config ?? (await aiQuotas.config(db));
|
|
48
55
|
const identity = ids(subject);
|
|
49
|
-
const pricedModels = new Set(
|
|
56
|
+
const pricedModels = new Set(
|
|
57
|
+
(context?.state ?? (await readAiSettingsState())).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.modelId),
|
|
58
|
+
);
|
|
50
59
|
const matches = buildAccessPrincipalCondition({
|
|
51
60
|
subject,
|
|
52
61
|
columns: {
|
|
@@ -76,7 +85,7 @@ export const aiQuotas = {
|
|
|
76
85
|
(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
86
|
FROM ai.inference_calls c
|
|
78
87
|
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.
|
|
88
|
+
AND (${rule.scope}='*' OR c.model_id=${rule.scope})
|
|
80
89
|
AND c.started_at>=${window.from} AND c.started_at<${window.until}
|
|
81
90
|
AND c.started_at>COALESCE((SELECT max(r.created_at) FROM ai.cost_resets r
|
|
82
91
|
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)`;
|
|
@@ -110,13 +119,15 @@ export const aiQuotas = {
|
|
|
110
119
|
GROUP BY model_profile_id ORDER BY model_profile_id`;
|
|
111
120
|
return { enabled: config.enabled, unit: config.unit ?? "EUR", balances, usage };
|
|
112
121
|
},
|
|
113
|
-
async assertAllowed(subject: AccessSubject,
|
|
122
|
+
async assertAllowed(subject: AccessSubject, variantId: string, providedState?: AiSettingsState) {
|
|
123
|
+
const state = providedState ?? (await readAiSettingsState());
|
|
124
|
+
const profile = state.profiles.find((profile) => profile.id === variantId);
|
|
125
|
+
if (!profile || !hasBillableAiPricing(profile.pricing)) return false;
|
|
126
|
+
const model = profile.modelId;
|
|
114
127
|
// 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
128
|
const config = await aiQuotas.config();
|
|
118
129
|
if (!config.enabled || !config.rules.some((r) => r.scope === "*" || r.scope === model)) return false;
|
|
119
|
-
const snapshot = await aiQuotas.snapshot(subject, model);
|
|
130
|
+
const snapshot = await aiQuotas.snapshot(subject, model, undefined, undefined, { state, config });
|
|
120
131
|
for (const b of snapshot.balances) {
|
|
121
132
|
if (b.bypassed || b.limit === null) continue;
|
|
122
133
|
if (b.unknown)
|
|
@@ -26,6 +26,8 @@ import type { AiConversationService, AiRecentResource } from "./types";
|
|
|
26
26
|
export const AiRecentResourceSchema: z.ZodType<AiRecentResource> = z
|
|
27
27
|
.object({
|
|
28
28
|
ref: CloudResourceRefSchema,
|
|
29
|
+
context: LinkedCloudResourceReferenceSchema.shape.context,
|
|
30
|
+
status: LinkedCloudResourceReferenceSchema.shape.status,
|
|
29
31
|
title: LinkedCloudResourceReferenceSchema.shape.title.unwrap(),
|
|
30
32
|
icon: LinkedCloudResourceReferenceSchema.shape.icon,
|
|
31
33
|
href: LinkedCloudResourceReferenceSchema.shape.href,
|
|
@@ -183,6 +185,8 @@ export const listAiRecentResources = async (input: {
|
|
|
183
185
|
ref,
|
|
184
186
|
count,
|
|
185
187
|
title: data.title,
|
|
188
|
+
context: ownRef?.context,
|
|
189
|
+
status: ownRef?.status,
|
|
186
190
|
icon: ownRef?.icon ?? data.icon,
|
|
187
191
|
href,
|
|
188
192
|
preview: recentResourcePreview(ownRef?.preview ?? data.description),
|
|
@@ -229,6 +233,8 @@ export const listAiRecentResources = async (input: {
|
|
|
229
233
|
ref,
|
|
230
234
|
count,
|
|
231
235
|
title: ownRef.title,
|
|
236
|
+
context: ownRef.context,
|
|
237
|
+
status: ownRef.status,
|
|
232
238
|
icon: (ownRef.icon ?? type.icon ?? app.appIcon)?.slice(0, 120),
|
|
233
239
|
href: ownRef.href,
|
|
234
240
|
kind: type.title.slice(0, 120),
|