@k2b/cloud 0.42.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 +13 -5
- package/scripts/app-favicon.ts +7 -6
- package/scripts/build.ts +3 -0
- package/scripts/installable-assets.ts +61 -0
- package/scripts/preload.ts +3 -0
- package/src/_internal/app-access-path.ts +12 -0
- 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 +185 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +10 -5
- package/src/_internal/define-app.ts +159 -45
- package/src/_internal/installable-assets.ts +90 -0
- package/src/_internal/installable-head.ts +22 -0
- 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/page-responses.ts +34 -3
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +15 -1
- package/src/_internal/registry.ts +44 -1
- package/src/_internal/runtime-context.ts +9 -1
- package/src/_internal/runtime-watcher.ts +8 -0
- package/src/_internal/status-preserving-ssr.ts +12 -1
- package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
- package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
- package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
- package/src/access/AppAccessEditor.tsx +380 -0
- package/src/access/PermissionEditor.browser-harness.tsx +22 -2
- package/src/access/PermissionEditor.tsx +386 -77
- package/src/access/PrincipalAvatar.tsx +63 -0
- package/src/access/PrincipalPicker.tsx +29 -8
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-adapter.ts +51 -0
- package/src/access/app-access-messages.ts +172 -0
- package/src/access/messages.ts +14 -0
- package/src/access/ui.ts +4 -1
- package/src/account/Avatar.tsx +21 -3
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +8 -1
- 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/capability-execution.ts +5 -2
- 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/code-capability-routes.ts +1 -0
- 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 +38 -27
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +6 -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-slots.ts +10 -2
- package/src/ai/openai-api.ts +83 -16
- 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 +138 -0
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider-retry.ts +1 -1
- package/src/ai/provider.ts +2 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +11 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas-migrate.ts +1 -0
- 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 +29 -46
- 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 +37 -21
- package/src/ai/turn-failure.ts +24 -2
- 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 +40 -16
- 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/admin-directory-shares.ts +55 -0
- package/src/api/admin-identity.ts +81 -2
- package/src/api/app-access.ts +85 -0
- 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 +127 -15
- package/src/api/capability-streams.ts +2 -5
- package/src/api/identity-child-invocation.ts +145 -0
- package/src/api/index.ts +11 -2
- package/src/api/mcp.ts +180 -5
- package/src/api/me.ts +38 -17
- package/src/api/pwa-phone.ts +87 -48
- 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/api/widgets.ts +1 -0
- package/src/app-pwa/authority.ts +75 -0
- package/src/app-pwa/contracts.ts +177 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +23 -0
- package/src/app-pwa/protocol.ts +27 -0
- package/src/app-pwa/schema.ts +59 -0
- package/src/app-pwa/service.ts +861 -0
- package/src/app-pwa/transaction.ts +338 -0
- package/src/app-pwa/transport-input.ts +57 -0
- package/src/app-pwa/transport.ts +235 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/app-access-recovery.ts +44 -0
- package/src/browser/app-session.ts +14 -3
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/live-websocket.ts +5 -0
- package/src/browser/pwa-drafts.ts +57 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/resource-picker.tsx +3 -2
- package/src/capabilities/calendar-sources.ts +302 -0
- package/src/capabilities/executions.ts +10 -3
- package/src/capabilities/migrate.ts +4 -0
- package/src/capabilities/nested.ts +43 -0
- package/src/capabilities/server-invoke.ts +202 -0
- package/src/capabilities/server.ts +26 -187
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/account.ts +57 -0
- package/src/cli/admin/agents.ts +66 -0
- 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 +7 -3
- package/src/cli/app-access.ts +60 -0
- package/src/cli/apps.ts +89 -26
- package/src/cli/avatar.ts +64 -0
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/cli/index.ts +1 -1
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +52 -5
- package/src/contracts/app-approval.ts +8 -1
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +24 -5
- package/src/contracts/calendar-source.ts +274 -0
- package/src/contracts/calendar-view.ts +35 -0
- package/src/contracts/capabilities.ts +164 -3
- package/src/contracts/geo-admin.ts +2 -0
- package/src/contracts/geo-route.ts +0 -11
- package/src/contracts/geo.ts +14 -2
- package/src/contracts/index.ts +6 -0
- package/src/contracts/installable.ts +40 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/profile.ts +32 -0
- package/src/contracts/pwa-builds.ts +14 -0
- package/src/contracts/pwa-location.ts +38 -0
- package/src/contracts/pwa.ts +24 -3
- package/src/contracts/registry.ts +8 -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 +2 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +7 -6
- package/src/server/middleware/auth.ts +168 -66
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +4 -2
- package/src/server/middleware/workload.ts +3 -3
- package/src/server/oauth-scope.ts +10 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +35 -3
- package/src/services/accounts/avatar.ts +342 -35
- package/src/services/accounts/base-group.ts +4 -5
- package/src/services/accounts/directory-shares.ts +157 -0
- package/src/services/accounts/entities.ts +50 -6
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +64 -12
- package/src/services/accounts/local-groups.ts +1 -1
- package/src/services/accounts/users.ts +23 -15
- package/src/services/app-access-management.ts +519 -0
- package/src/services/app-access.ts +25 -7
- package/src/services/app-approval.ts +15 -4
- 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/admin.ts +4 -1
- package/src/services/geo/ledger.ts +47 -14
- package/src/services/geo/route-chain.ts +40 -18
- package/src/services/geo/route-estimate.ts +19 -0
- package/src/services/geo/route-plan.ts +0 -2
- package/src/services/geo/route-service.ts +57 -39
- package/src/services/geo/service.ts +51 -25
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/index.ts +2 -1
- package/src/services/identity/invocation-actor.ts +50 -2
- package/src/services/identity/invocation-authority.ts +38 -1
- package/src/services/identity/invocation-token.ts +255 -50
- package/src/services/identity/issue-invocation.ts +9 -1
- package/src/services/identity/models-only-auth.ts +13 -4
- package/src/services/identity/nested-invocation.ts +128 -0
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +24 -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/service-account-credentials.ts +5 -2
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +91 -47
- 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 +11 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +114 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/AppPwaRuntime.island.tsx +82 -0
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +15 -7
- package/src/ssr/PwaLayout.tsx +73 -8
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/PwaRuntime.island.tsx +85 -6
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/ShellRuntime.island.tsx +5 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-pwa-document.tsx +16 -0
- package/src/ssr/app-pwa-messages.ts +17 -0
- package/src/ssr/index.ts +8 -0
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +10 -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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
|
|
4
|
+
import { toPgTextArray } from "../services/postgres";
|
|
5
|
+
import { type AiDeleteModelInput, type AiModel, type AiModelSlot, type AiProvider, aiReplacementProblem } from "../shared/ai-models";
|
|
6
|
+
import type { WorkflowBoundPlan, WorkflowIrStep, WorkflowJsonValue } from "../workflows/contracts";
|
|
7
|
+
import { hashWorkflowSource } from "../workflows/language/canonical";
|
|
8
|
+
import { publishWorkflowVersion, type WorkflowActivationInput } from "../workflows/store/definitions";
|
|
9
|
+
import { aiModelAccess } from "./model-access";
|
|
10
|
+
import { AiConfigError, aiModelCatalog, assertAiRevision, lockAiModelCatalog, validateAiSlot } from "./model-catalog";
|
|
11
|
+
|
|
12
|
+
export const validateAiReplacement = (source: AiProvider, target: AiProvider, original: AiModel, replacement: AiModel): void => {
|
|
13
|
+
const problem = aiReplacementProblem(source, target, original, replacement);
|
|
14
|
+
if (problem === "private_boundary")
|
|
15
|
+
throw new AiConfigError("invalid_configuration", "A private model cannot be replaced with a hosted model.");
|
|
16
|
+
if (problem === "incompatible_model")
|
|
17
|
+
throw new AiConfigError(
|
|
18
|
+
"invalid_configuration",
|
|
19
|
+
"The replacement must be enabled and support the original model's kind and capabilities.",
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
const referencedAiModels = (value: unknown): string[] => {
|
|
23
|
+
if (Array.isArray(value)) return value.flatMap(referencedAiModels);
|
|
24
|
+
if (value !== null && typeof value === "object") {
|
|
25
|
+
const parsed = z.record(z.string(), z.unknown()).parse(value);
|
|
26
|
+
return Object.entries(parsed).flatMap(([key, entry]) =>
|
|
27
|
+
key === "model"
|
|
28
|
+
? (() => {
|
|
29
|
+
const model = z.object({ id: z.string() }).safeParse(entry);
|
|
30
|
+
return model.success ? [model.data.id] : referencedAiModels(entry);
|
|
31
|
+
})()
|
|
32
|
+
: referencedAiModels(entry),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return [];
|
|
36
|
+
};
|
|
37
|
+
const retargetJson = (value: WorkflowJsonValue, ids: string[], replacement: string): WorkflowJsonValue => {
|
|
38
|
+
if (Array.isArray(value)) return value.map((entry) => retargetJson(entry, ids, replacement));
|
|
39
|
+
if (value !== null && typeof value === "object")
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
Object.entries(value).map(([key, entry]) => [
|
|
42
|
+
key,
|
|
43
|
+
key === "model" &&
|
|
44
|
+
entry !== null &&
|
|
45
|
+
!Array.isArray(entry) &&
|
|
46
|
+
typeof entry === "object" &&
|
|
47
|
+
typeof entry.id === "string" &&
|
|
48
|
+
ids.includes(entry.id)
|
|
49
|
+
? { id: replacement }
|
|
50
|
+
: retargetJson(entry, ids, replacement),
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
return value;
|
|
54
|
+
};
|
|
55
|
+
const retargetSteps = (steps: WorkflowIrStep[], ids: string[], replacement: string): WorkflowIrStep[] =>
|
|
56
|
+
steps.map((step) => {
|
|
57
|
+
if (step.kind === "action")
|
|
58
|
+
return {
|
|
59
|
+
...step,
|
|
60
|
+
config: Object.fromEntries(
|
|
61
|
+
Object.entries(step.config).map(([key, value]) => [
|
|
62
|
+
key,
|
|
63
|
+
key === "model" &&
|
|
64
|
+
value !== null &&
|
|
65
|
+
!Array.isArray(value) &&
|
|
66
|
+
typeof value === "object" &&
|
|
67
|
+
typeof value.id === "string" &&
|
|
68
|
+
ids.includes(value.id)
|
|
69
|
+
? { id: replacement }
|
|
70
|
+
: value,
|
|
71
|
+
]),
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
if (step.kind === "if")
|
|
75
|
+
return { ...step, then: retargetSteps(step.then, ids, replacement), else: retargetSteps(step.else, ids, replacement) };
|
|
76
|
+
if (step.kind === "switch")
|
|
77
|
+
return {
|
|
78
|
+
...step,
|
|
79
|
+
cases: step.cases.map((entry) => ({ ...entry, steps: retargetSteps(entry.steps, ids, replacement) })),
|
|
80
|
+
default: retargetSteps(step.default, ids, replacement),
|
|
81
|
+
};
|
|
82
|
+
return { ...step, steps: retargetSteps(step.steps, ids, replacement) };
|
|
83
|
+
});
|
|
84
|
+
export const deleteAiCatalogEntry = async (
|
|
85
|
+
kind: "provider" | "model" | "variant",
|
|
86
|
+
id: string,
|
|
87
|
+
input: AiDeleteModelInput,
|
|
88
|
+
actorId: string,
|
|
89
|
+
) =>
|
|
90
|
+
sql.begin(async (db) => {
|
|
91
|
+
// One catalog lock orders deletion with assignment and catalog writes.
|
|
92
|
+
await lockAiModelCatalog(db);
|
|
93
|
+
const current =
|
|
94
|
+
kind === "provider"
|
|
95
|
+
? await aiModelCatalog.providerDetail(id, db)
|
|
96
|
+
: kind === "model"
|
|
97
|
+
? await aiModelCatalog.modelDetail(id, db)
|
|
98
|
+
: await aiModelCatalog.variant(id, db, true);
|
|
99
|
+
await assertAiRevision(current, input.revision, db);
|
|
100
|
+
const allModels = await aiModelCatalog.models(db),
|
|
101
|
+
allVariants = await aiModelCatalog.variants(db),
|
|
102
|
+
providers = await aiModelCatalog.providers(db);
|
|
103
|
+
const affectedModels =
|
|
104
|
+
kind === "provider"
|
|
105
|
+
? allModels.filter((model) => model.providerId === id)
|
|
106
|
+
: allModels.filter((model) => model.id === (kind === "model" ? id : allVariants.find((v) => v.id === id)?.modelId));
|
|
107
|
+
if (kind === "variant" && allVariants.find((v) => v.id === id)?.slug === "standard")
|
|
108
|
+
throw new AiConfigError("invalid_configuration", "The standard variant can only be deleted with its model.");
|
|
109
|
+
const affected = allVariants.filter((v) => (kind === "variant" ? v.id === id : affectedModels.some((m) => m.id === v.modelId)));
|
|
110
|
+
const ids = affected.map((v) => v.id),
|
|
111
|
+
pgIds = toPgTextArray(ids);
|
|
112
|
+
const target = (variantId: string, sourceModel: AiModel, slot?: AiModelSlot) => {
|
|
113
|
+
const variant = allVariants.find((v) => v.id === variantId && !ids.includes(v.id));
|
|
114
|
+
const model = allModels.find((m) => m.id === variant?.modelId),
|
|
115
|
+
provider = providers.find((p) => p.id === model?.providerId),
|
|
116
|
+
source = providers.find((p) => p.id === sourceModel.providerId);
|
|
117
|
+
if (!model || !provider || !source)
|
|
118
|
+
throw new AiConfigError("invalid_configuration", "Choose a replacement outside the deleted selection.");
|
|
119
|
+
validateAiReplacement(source, provider, sourceModel, model);
|
|
120
|
+
if (slot) validateAiSlot(slot, model);
|
|
121
|
+
};
|
|
122
|
+
const assignments = (await aiModelCatalog.assignments(db)).filter((a) => a.variantId && ids.includes(a.variantId));
|
|
123
|
+
for (const assignment of assignments) {
|
|
124
|
+
if (!Object.hasOwn(input.replacements, assignment.slot))
|
|
125
|
+
throw new AiConfigError("invalid_configuration", "Every affected assignment needs a replacement or an explicit empty choice.");
|
|
126
|
+
const replacement = input.replacements[assignment.slot];
|
|
127
|
+
const sourceModel = affectedModels.find((m) => m.id === affected.find((v) => v.id === assignment.variantId)?.modelId);
|
|
128
|
+
if (replacement && sourceModel) target(replacement, sourceModel, assignment.slot);
|
|
129
|
+
}
|
|
130
|
+
if (input.retarget && !input.retargetTo) throw new AiConfigError("invalid_configuration", "Retargeting requires a replacement.");
|
|
131
|
+
for (const assignment of assignments)
|
|
132
|
+
await db`UPDATE ai.model_assignments SET variant_id=${input.replacements[assignment.slot] ?? null},revision=revision+1,updated_by=${actorId}::uuid,updated_at=clock_timestamp() WHERE slot=${assignment.slot}`;
|
|
133
|
+
const projects = await db<
|
|
134
|
+
{ id: string; short_id: string; name: string; default_model: { id: string } }[]
|
|
135
|
+
>`SELECT id,short_id,name,default_model FROM ai.projects WHERE default_model->>'id'=ANY(${pgIds}::text[]) FOR UPDATE`;
|
|
136
|
+
const workflows = await db<
|
|
137
|
+
{
|
|
138
|
+
id: string;
|
|
139
|
+
name: string;
|
|
140
|
+
created_by_kind: string;
|
|
141
|
+
created_by_id: string | null;
|
|
142
|
+
source: string;
|
|
143
|
+
plan: WorkflowBoundPlan;
|
|
144
|
+
effect_budget: Record<string, number>;
|
|
145
|
+
}[]
|
|
146
|
+
>`SELECT w.id,w.name,w.created_by_kind,w.created_by_id,v.source,v.plan,v.effect_budget FROM workflows.workflow w JOIN workflows.version v ON v.id=w.active_version_id WHERE EXISTS(SELECT 1 FROM jsonb_path_query(v.plan,'$.**.model.id') ref WHERE ref #>> '{}' = ANY(${pgIds}::text[])) FOR UPDATE OF w`;
|
|
147
|
+
if (input.retarget && input.retargetTo) {
|
|
148
|
+
const references = [
|
|
149
|
+
...projects.map((project) => project.default_model.id),
|
|
150
|
+
...workflows.flatMap((workflow) => referencedAiModels(workflow.plan)),
|
|
151
|
+
];
|
|
152
|
+
for (const model of affectedModels.filter((model) =>
|
|
153
|
+
affected.some((variant) => variant.modelId === model.id && references.includes(variant.id)),
|
|
154
|
+
))
|
|
155
|
+
target(input.retargetTo, model);
|
|
156
|
+
}
|
|
157
|
+
const scheduled = await db<
|
|
158
|
+
{ sponsor_user_id: string; name: string }[]
|
|
159
|
+
>`SELECT task.sponsor_user_id,left(task.prompt,120) AS name FROM ai.chat_tasks task JOIN ai.conversations c ON c.id=task.conversation_id JOIN ai.projects p ON p.id=c.project_id WHERE p.default_model->>'id'=ANY(${pgIds}::text[])`;
|
|
160
|
+
for (const task of scheduled)
|
|
161
|
+
await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${task.sponsor_user_id}::uuid,${task.name},${input.retarget})`;
|
|
162
|
+
for (const project of projects) {
|
|
163
|
+
const owners = await db<
|
|
164
|
+
{ id: string }[]
|
|
165
|
+
>`SELECT DISTINCT u.id FROM ai.project_access p JOIN auth.access a ON a.id=p.access_id JOIN auth.users u ON a.user_id=u.id OR a.authenticated_only OR a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}) WHERE p.project_id=${project.id}::uuid AND a.permission='admin'`;
|
|
166
|
+
for (const owner of owners)
|
|
167
|
+
await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${owner.id}::uuid,${project.name},${input.retarget})`;
|
|
168
|
+
if (input.retarget)
|
|
169
|
+
await db`UPDATE ai.projects SET default_model=${JSON.stringify({ id: input.retargetTo })}::text::jsonb,revision=revision+1,updated_at=clock_timestamp() WHERE id=${project.id}::uuid`;
|
|
170
|
+
}
|
|
171
|
+
for (const workflow of workflows) {
|
|
172
|
+
if (workflow.created_by_kind === "user" && workflow.created_by_id)
|
|
173
|
+
await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${workflow.created_by_id}::uuid,${workflow.name},${input.retarget})`;
|
|
174
|
+
if (!input.retarget || !input.retargetTo) continue;
|
|
175
|
+
const activations = await db<
|
|
176
|
+
(WorkflowActivationInput & { event_type: string; authorization_snapshot: WorkflowJsonValue })[]
|
|
177
|
+
>`SELECT key,event_type,config,enabled,authorization_snapshot FROM workflows.activation WHERE workflow_id=${workflow.id}::uuid`;
|
|
178
|
+
const parsed: WorkflowJsonValue = z.json().parse(Bun.YAML.parse(workflow.source));
|
|
179
|
+
const source = Bun.YAML.stringify(retargetJson(parsed, ids, input.retargetTo));
|
|
180
|
+
const plan: WorkflowBoundPlan = {
|
|
181
|
+
...workflow.plan,
|
|
182
|
+
sourceHash: await hashWorkflowSource(source),
|
|
183
|
+
steps: retargetSteps(workflow.plan.steps, ids, input.retargetTo),
|
|
184
|
+
bindings: Object.fromEntries(
|
|
185
|
+
Object.entries(workflow.plan.bindings).map(([key, value]) => [
|
|
186
|
+
key,
|
|
187
|
+
key === "model" &&
|
|
188
|
+
value !== null &&
|
|
189
|
+
!Array.isArray(value) &&
|
|
190
|
+
typeof value === "object" &&
|
|
191
|
+
typeof value.id === "string" &&
|
|
192
|
+
ids.includes(value.id)
|
|
193
|
+
? { id: input.retargetTo! }
|
|
194
|
+
: retargetJson(value, ids, input.retargetTo!),
|
|
195
|
+
]),
|
|
196
|
+
),
|
|
197
|
+
};
|
|
198
|
+
await publishWorkflowVersion(
|
|
199
|
+
{
|
|
200
|
+
workflowId: workflow.id,
|
|
201
|
+
source,
|
|
202
|
+
plan,
|
|
203
|
+
effectBudget: workflow.effect_budget,
|
|
204
|
+
author: { kind: "user", id: actorId },
|
|
205
|
+
activations: activations.map((a) => ({ ...a, eventType: a.event_type })),
|
|
206
|
+
},
|
|
207
|
+
{ db },
|
|
208
|
+
);
|
|
209
|
+
for (const activation of activations)
|
|
210
|
+
await db`UPDATE workflows.activation SET authorization_snapshot=${JSON.stringify(activation.authorization_snapshot)}::text::jsonb WHERE workflow_id=${workflow.id}::uuid AND key=${activation.key}`;
|
|
211
|
+
}
|
|
212
|
+
if (kind === "variant") await db`DELETE FROM ai.model_variants WHERE id=${id}`;
|
|
213
|
+
else if (kind === "model") await db`DELETE FROM ai.models WHERE id=${id}`;
|
|
214
|
+
else await db`DELETE FROM ai.providers WHERE id=${id}`;
|
|
215
|
+
if (kind !== "variant") {
|
|
216
|
+
const modelIds = affectedModels.map((m) => m.id);
|
|
217
|
+
const [config] = await db<{ rules: WorkflowJsonValue[] }[]>`SELECT rules FROM ai.cost_config WHERE singleton FOR UPDATE`;
|
|
218
|
+
const rules = (config?.rules ?? []).filter(
|
|
219
|
+
(rule) =>
|
|
220
|
+
rule === null ||
|
|
221
|
+
Array.isArray(rule) ||
|
|
222
|
+
typeof rule !== "object" ||
|
|
223
|
+
typeof rule.scope !== "string" ||
|
|
224
|
+
!modelIds.includes(rule.scope),
|
|
225
|
+
);
|
|
226
|
+
await db`UPDATE ai.cost_config SET rules=${JSON.stringify(rules)}::text::jsonb,revision=revision+1 WHERE singleton`;
|
|
227
|
+
await aiModelAccess.syncModels(
|
|
228
|
+
(await aiModelCatalog.models(db)).map((model) => model.id),
|
|
229
|
+
[],
|
|
230
|
+
db,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return { deleted: true };
|
|
234
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AiModelAssignment, AiModelSlot } from "../shared/ai-models";
|
|
2
|
+
import type { AiModelProfile, AiSettingsState } from "./types";
|
|
3
|
+
|
|
4
|
+
/** Fixed snapshots for tests; production reads the catalog. */
|
|
5
|
+
export const aiModelFixture = (overrides: Partial<AiModelProfile> = {}): AiModelProfile => ({
|
|
6
|
+
id: "chat",
|
|
7
|
+
modelId: "chat",
|
|
8
|
+
providerId: "provider",
|
|
9
|
+
label: "Chat",
|
|
10
|
+
variantLabel: "Standard",
|
|
11
|
+
description: "For everyday work",
|
|
12
|
+
icon: { kind: "provider" },
|
|
13
|
+
showInPicker: true,
|
|
14
|
+
provider: "ollama",
|
|
15
|
+
model: "chat",
|
|
16
|
+
enabled: true,
|
|
17
|
+
capabilities: ["tools"],
|
|
18
|
+
dataBoundary: "private",
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
export const aiAssignmentFixture = (slot: AiModelSlot, variantId: string | null): AiModelAssignment => ({
|
|
22
|
+
slot,
|
|
23
|
+
variantId,
|
|
24
|
+
revision: 1,
|
|
25
|
+
updatedAt: "2026-10-16T00:00:00.000Z",
|
|
26
|
+
updatedBy: null,
|
|
27
|
+
});
|
|
28
|
+
export const aiSettingsFixture = (overrides: Partial<AiSettingsState> = {}): AiSettingsState => ({
|
|
29
|
+
ok: true,
|
|
30
|
+
enabled: true,
|
|
31
|
+
profiles: [aiModelFixture()],
|
|
32
|
+
assignments: [aiAssignmentFixture("balanced", "chat")],
|
|
33
|
+
globalInstructions: "",
|
|
34
|
+
localClientInstructions: "",
|
|
35
|
+
compactionInstructions: "",
|
|
36
|
+
maxToolResultChars: 2_000_000,
|
|
37
|
+
firecrawlConfigured: false,
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { JSX } from "solid-js";
|
|
2
|
+
import { type AiModelIcon as AiModelIconSetting, type AiPeopleModelIcon, aiProviderMark } from "../shared/ai-models";
|
|
3
|
+
|
|
4
|
+
/** The icon a person sees for a model: its emoji or image, else its provider's neutral mark. */
|
|
5
|
+
export const aiModelDisplayIcon = (icon: AiModelIconSetting, provider: { id: string; label: string }): AiPeopleModelIcon =>
|
|
6
|
+
icon.kind === "image"
|
|
7
|
+
? { kind: "image", src: icon.dataUrl }
|
|
8
|
+
: icon.kind === "emoji"
|
|
9
|
+
? icon
|
|
10
|
+
: { kind: "mark", ...aiProviderMark(provider) };
|
|
11
|
+
|
|
12
|
+
const sizes = { xs: "size-5 rounded-[5px] text-[9px]", sm: "size-7 rounded-md text-[10px]", md: "size-10 rounded-lg text-xs" } as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A model's icon, the one mark that tells models apart in a chooser. It is decoration: the model's name always sits
|
|
16
|
+
* next to it, so it stays hidden from assistive technology.
|
|
17
|
+
*/
|
|
18
|
+
export function AiModelIcon(props: { icon: AiPeopleModelIcon; size?: keyof typeof sizes; class?: string }): JSX.Element {
|
|
19
|
+
const box = () =>
|
|
20
|
+
`inline-grid shrink-0 place-items-center overflow-hidden leading-none ${sizes[props.size ?? "xs"]} ${props.class ?? ""}`;
|
|
21
|
+
const icon = () => props.icon;
|
|
22
|
+
return (
|
|
23
|
+
<span class={box()} aria-hidden="true" data-ai-model-icon={icon().kind}>
|
|
24
|
+
{(() => {
|
|
25
|
+
const current = icon();
|
|
26
|
+
if (current.kind === "image") return <img src={current.src} alt="" class="size-full object-cover" />;
|
|
27
|
+
if (current.kind === "emoji") return <span class="text-[1.15em]">{current.emoji}</span>;
|
|
28
|
+
return (
|
|
29
|
+
<span class="grid size-full place-items-center font-semibold tracking-wide text-white" style={{ background: current.color }}>
|
|
30
|
+
{current.text}
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
})()}
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { coreSettings } from "../services";
|
|
2
|
+
import { AI_MODEL_CLASSES, type AiModelClass, type AiModelSlot, type AiProvider } from "../shared/ai-models";
|
|
3
|
+
import { aiModelCatalog } from "./model-catalog";
|
|
4
|
+
import { resolveAiModelRef } from "./settings";
|
|
5
|
+
|
|
6
|
+
export type AiAdminOverview = {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
providers: (Pick<
|
|
9
|
+
AiProvider,
|
|
10
|
+
"id" | "type" | "label" | "enabled" | "status" | "statusCode" | "statusMessage" | "statusCheckedAt" | "logo" | "credentialSet"
|
|
11
|
+
> & {
|
|
12
|
+
modelCount: number;
|
|
13
|
+
})[];
|
|
14
|
+
assignments: Record<AiModelSlot, { variantId: string | null; revision: number; updatedAt: string; updatedBy: string | null }>;
|
|
15
|
+
effective: Record<AiModelClass, { class: AiModelClass | null; variantId: string | null }>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** What the AI area's first column and the class rows need, read once for the page and the API. */
|
|
19
|
+
export const readAiAdminOverview = async (): Promise<AiAdminOverview> => {
|
|
20
|
+
const [enabled, providers, models, assignments] = await Promise.all([
|
|
21
|
+
coreSettings.get<boolean>("ai.enabled"),
|
|
22
|
+
aiModelCatalog.providers(),
|
|
23
|
+
aiModelCatalog.models(),
|
|
24
|
+
aiModelCatalog.assignments(),
|
|
25
|
+
]);
|
|
26
|
+
return {
|
|
27
|
+
enabled,
|
|
28
|
+
providers: providers.map((p) => ({
|
|
29
|
+
id: p.id,
|
|
30
|
+
type: p.type,
|
|
31
|
+
label: p.label,
|
|
32
|
+
enabled: p.enabled,
|
|
33
|
+
status: p.status,
|
|
34
|
+
statusCode: p.statusCode,
|
|
35
|
+
statusMessage: p.statusMessage,
|
|
36
|
+
statusCheckedAt: p.statusCheckedAt,
|
|
37
|
+
credentialSet: p.credentialSet,
|
|
38
|
+
modelCount: models.filter((m) => m.providerId === p.id).length,
|
|
39
|
+
logo: p.logo,
|
|
40
|
+
})),
|
|
41
|
+
assignments: Object.fromEntries(assignments.map(({ slot, ...assignment }) => [slot, assignment])) as AiAdminOverview["assignments"],
|
|
42
|
+
effective: Object.fromEntries(
|
|
43
|
+
AI_MODEL_CLASSES.map((modelClass) => {
|
|
44
|
+
try {
|
|
45
|
+
const ref = resolveAiModelRef({ enabled: true, assignments }, { class: modelClass });
|
|
46
|
+
return [modelClass, { class: ref.class ?? null, variantId: ref.id }];
|
|
47
|
+
} catch {
|
|
48
|
+
return [modelClass, { class: null, variantId: null }];
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
) as AiAdminOverview["effective"],
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { toPgTextArray } from "../services/postgres";
|
|
3
|
+
import type { AiModelUsage } from "../shared/ai-models";
|
|
4
|
+
import { aiModelCatalog } from "./model-catalog";
|
|
5
|
+
import { aiPeopleAtLimit } from "./quota-limits";
|
|
6
|
+
import { aiQuotas } from "./quotas";
|
|
7
|
+
|
|
8
|
+
export const aiModelUsage = async (variantIds: string[], modelIds: string[], db = sql): Promise<AiModelUsage> => {
|
|
9
|
+
const ids = toPgTextArray(variantIds);
|
|
10
|
+
const [assignments, projects, workflows, scheduledTasks, counts, stats, config, people] = await Promise.all([
|
|
11
|
+
aiModelCatalog.assignments(db),
|
|
12
|
+
db<
|
|
13
|
+
{ id: string; name: string }[]
|
|
14
|
+
>`SELECT short_id AS id,name FROM ai.projects WHERE default_model->>'id'=ANY(${ids}::text[]) ORDER BY name,id`,
|
|
15
|
+
db<
|
|
16
|
+
{ id: string; name: string }[]
|
|
17
|
+
>`SELECT w.id,w.name FROM workflows.workflow w JOIN workflows.version v ON v.id=w.active_version_id WHERE EXISTS (SELECT 1 FROM jsonb_path_query(v.plan,'$.**.model.id') ref WHERE ref #>> '{}' = ANY(${ids}::text[])) ORDER BY w.name,w.id`,
|
|
18
|
+
db<
|
|
19
|
+
{ id: string; name: string }[]
|
|
20
|
+
>`SELECT task.short_id AS id,left(task.prompt,120) AS name FROM ai.chat_tasks task JOIN ai.conversations c ON c.id=task.conversation_id LEFT JOIN ai.projects p ON p.id=c.project_id WHERE p.default_model->>'id'=ANY(${ids}::text[]) ORDER BY task.id`,
|
|
21
|
+
db<{ chats: number }[]>`SELECT count(DISTINCT conversation_id)::int AS chats FROM ai.turns WHERE model_profile_id=ANY(${ids}::text[])`,
|
|
22
|
+
db<AiModelUsage["stats"][]>`WITH answers AS (
|
|
23
|
+
SELECT COALESCE(turn_id,id) AS answer, CASE WHEN bool_and(cost IS NOT NULL) THEN sum(cost) END AS cost,
|
|
24
|
+
extract(epoch FROM max(finished_at)-min(started_at)) AS seconds
|
|
25
|
+
FROM ai.inference_calls WHERE model_profile_id=ANY(${ids}::text[]) AND started_at>=now()-interval '14 days'
|
|
26
|
+
AND kind='chat' AND status='ok' GROUP BY COALESCE(turn_id,id)
|
|
27
|
+
) SELECT count(*)::int AS "answers14d", CASE WHEN count(*)>=20 THEN avg(cost)::float8 END AS "avgCostPerAnswer", CASE WHEN count(*)>=20 THEN avg(seconds)::float8 END AS "avgSecondsPerAnswer" FROM answers`,
|
|
28
|
+
aiQuotas.config(db),
|
|
29
|
+
// Count exhausted people per rule, then deduplicate global and model scopes.
|
|
30
|
+
aiPeopleAtLimit(db),
|
|
31
|
+
]);
|
|
32
|
+
return {
|
|
33
|
+
assignments: assignments
|
|
34
|
+
.filter((assignment) => assignment.variantId && variantIds.includes(assignment.variantId))
|
|
35
|
+
.map((assignment) => assignment.slot),
|
|
36
|
+
projects,
|
|
37
|
+
workflows,
|
|
38
|
+
scheduledTasks,
|
|
39
|
+
chats: counts[0]?.chats ?? 0,
|
|
40
|
+
budgetRules: config.rules.filter((rule) => modelIds.includes(rule.scope)).map((rule) => ({ id: rule.scope, label: rule.scope })),
|
|
41
|
+
peopleAtLimit: new Set(people.filter((rule) => rule.scope === "*" || modelIds.includes(rule.scope)).flatMap((rule) => rule.people))
|
|
42
|
+
.size,
|
|
43
|
+
stats: stats[0] ?? { answers14d: 0, avgCostPerAnswer: null, avgSecondsPerAnswer: null },
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -14,8 +14,6 @@ const catalog = i18n.define({
|
|
|
14
14
|
context_length_exceeded: "Your messages exceed this model's context window. Shorten them and try again.",
|
|
15
15
|
request_too_large: "The request body exceeds the limit derived from your available models' context windows.",
|
|
16
16
|
ai_disabled: "AI is turned off. Contact an administrator.",
|
|
17
|
-
model_access_denied: "You do not have access to this model. Contact an administrator.",
|
|
18
|
-
missing_provider_credential: "This model's provider is not configured. Contact an administrator.",
|
|
19
17
|
insufficient_quota:
|
|
20
18
|
"Your Assistant usage limit is reached, or your usage could not be measured. Wait for the reset or contact an administrator.",
|
|
21
19
|
too_many_concurrent_requests: "You have too many simultaneous requests. Wait for a request to finish and try again.",
|
|
@@ -35,8 +33,6 @@ const catalog = i18n.define({
|
|
|
35
33
|
context_length_exceeded: "Deine Nachrichten überschreiten das Kontextfenster dieses Modells. Kürze sie und versuche es erneut.",
|
|
36
34
|
request_too_large: "Deine Anfrage überschreitet die aus den Kontextfenstern deiner verfügbaren Modelle abgeleitete Größenbegrenzung.",
|
|
37
35
|
ai_disabled: "KI ist ausgeschaltet. Wende dich an einen Administrator.",
|
|
38
|
-
model_access_denied: "Dieses Modell ist nicht für dich freigegeben. Wende dich an einen Administrator.",
|
|
39
|
-
missing_provider_credential: "Der Anbieter dieses Modells ist nicht eingerichtet. Wende dich an einen Administrator.",
|
|
40
36
|
insufficient_quota:
|
|
41
37
|
"Dein Nutzungslimit im Assistenten ist erreicht, oder deine Nutzung konnte nicht gemessen werden. Warte auf das Zurücksetzen oder wende dich an einen Administrator.",
|
|
42
38
|
too_many_concurrent_requests: "Du hast zu viele gleichzeitige Anfragen. Warte, bis eine Anfrage beendet ist, und versuche es erneut.",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { redis } from "bun";
|
|
2
|
+
import type { AccessSubject } from "../server/services/access";
|
|
2
3
|
import { logger } from "../services/logging";
|
|
3
4
|
|
|
4
5
|
const LEASE_MS = 120_000;
|
|
@@ -20,8 +21,15 @@ const SLOT_SCRIPT = `
|
|
|
20
21
|
return 1
|
|
21
22
|
`;
|
|
22
23
|
|
|
23
|
-
export const acquire = async ({
|
|
24
|
-
|
|
24
|
+
export const acquire = async ({
|
|
25
|
+
subject,
|
|
26
|
+
limit,
|
|
27
|
+
}: {
|
|
28
|
+
subject: AccessSubject;
|
|
29
|
+
limit: number;
|
|
30
|
+
}): Promise<{ release(): Promise<void> } | null> => {
|
|
31
|
+
const principal = subject.type === "user" ? `user:${subject.userId}` : `service_account:${subject.serviceAccountId}`;
|
|
32
|
+
const key = `cloud:ai:openai-api:slots:${principal}`;
|
|
25
33
|
const slot = crypto.randomUUID();
|
|
26
34
|
const granted = await redis.send("EVAL", [SLOT_SCRIPT, "1", key, slot, String(LEASE_MS), String(limit)]);
|
|
27
35
|
if (granted !== 1) return null;
|
package/src/ai/openai-api.ts
CHANGED
|
@@ -9,12 +9,15 @@ import { type AuthContext, getLocale, rateLimit } from "../server";
|
|
|
9
9
|
import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
|
|
10
10
|
import { authenticateModelsOnlyCredential } from "../services/identity/models-only-auth";
|
|
11
11
|
import { coreSettings } from "../services/settings/api";
|
|
12
|
+
import { AI_MODEL_CLASSES, AiModelRequestSchema } from "../shared/ai-models";
|
|
12
13
|
import { listAssistantAiModels } from "./assistant-models";
|
|
14
|
+
import { aiModelAccess } from "./model-access";
|
|
15
|
+
import { aiModelCatalog } from "./model-catalog";
|
|
13
16
|
import { type OpenAiErrorCode, openAiApiMessages } from "./openai-api-messages";
|
|
14
17
|
import { acquire } from "./openai-api-slots";
|
|
15
18
|
import { personalAiModelPolicy } from "./personal-agent";
|
|
16
19
|
import { PROVIDER_TIMEOUTS } from "./provider";
|
|
17
|
-
import { inferenceProvider } from "./quota-provider";
|
|
20
|
+
import { AiProviderAdmissionError, inferenceProvider } from "./quota-provider";
|
|
18
21
|
import { AiQuotaError } from "./quotas";
|
|
19
22
|
import { resolveAiModel } from "./settings";
|
|
20
23
|
|
|
@@ -70,12 +73,16 @@ const usageBody = (usage: Usage) => ({
|
|
|
70
73
|
total_tokens: usage.input + usage.output,
|
|
71
74
|
});
|
|
72
75
|
|
|
76
|
+
class ModelsCredentialRevoked extends AiProviderAdmissionError {}
|
|
77
|
+
|
|
73
78
|
type Dependencies = {
|
|
74
79
|
limit?: MiddlewareHandler<AuthContext>;
|
|
75
80
|
settings?: Pick<typeof coreSettings, "get">;
|
|
76
81
|
authenticate?: typeof authenticateModelsOnlyCredential;
|
|
77
82
|
listModels?: typeof listAssistantAiModels;
|
|
83
|
+
assignments?: typeof aiModelCatalog.assignments;
|
|
78
84
|
resolveModel?: typeof resolveAiModel;
|
|
85
|
+
assertModelAccess?: typeof aiModelAccess.assertAllowed;
|
|
79
86
|
acquire?: typeof acquire;
|
|
80
87
|
inferenceProvider?: typeof inferenceProvider;
|
|
81
88
|
keepaliveMs?: number;
|
|
@@ -85,21 +92,23 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
85
92
|
const settings = dependencies.settings ?? coreSettings;
|
|
86
93
|
const authenticate = dependencies.authenticate ?? authenticateModelsOnlyCredential;
|
|
87
94
|
const listModels = dependencies.listModels ?? listAssistantAiModels;
|
|
95
|
+
const assignments = dependencies.assignments ?? (() => aiModelCatalog.assignments());
|
|
88
96
|
const resolveModel = dependencies.resolveModel ?? resolveAiModel;
|
|
97
|
+
const assertModelAccess = dependencies.assertModelAccess ?? aiModelAccess.assertAllowed;
|
|
89
98
|
const acquireSlot = dependencies.acquire ?? acquire;
|
|
90
99
|
const wrapProvider = dependencies.inferenceProvider ?? inferenceProvider;
|
|
91
100
|
const limit = dependencies.limit ?? rateLimit();
|
|
92
101
|
const app = new Hono<AuthContext>();
|
|
93
102
|
app.onError((error, c) => {
|
|
103
|
+
if (error instanceof ModelsCredentialRevoked) return errorResponse(c, 401, "invalid_api_key", "authentication_error");
|
|
94
104
|
if (error instanceof AiQuotaError && error.code !== "quota_conflict")
|
|
95
105
|
return errorResponse(c, 429, "insufficient_quota", "insufficient_quota");
|
|
96
106
|
if (error instanceof Error && "aiError" in error) {
|
|
97
107
|
const parsed = z.object({ code: z.string() }).safeParse(error.aiError);
|
|
98
108
|
if (parsed.success) {
|
|
99
109
|
if (parsed.data.code === "ai_disabled") return errorResponse(c, 403, "ai_disabled", "permission_error");
|
|
100
|
-
if (parsed.data.code === "
|
|
101
|
-
|
|
102
|
-
if (parsed.data.code === "missing_provider_credential") return errorResponse(c, 502, "missing_provider_credential", "api_error");
|
|
110
|
+
if (parsed.data.code === "model_policy_mismatch" || parsed.data.code === "ai_no_model")
|
|
111
|
+
return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
return errorResponse(c, 500, "internal_error", "api_error");
|
|
@@ -115,8 +124,7 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
115
124
|
const token = c.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
|
|
116
125
|
const credential = await authenticate(token);
|
|
117
126
|
if (!credential) return errorResponse(c, 401, "invalid_api_key", "authentication_error");
|
|
118
|
-
c.set("
|
|
119
|
-
c.set("accessSubject", { type: "user", userId: credential.user.id });
|
|
127
|
+
c.set("accessSubject", credential.subject);
|
|
120
128
|
c.header("Cache-Control", "no-store");
|
|
121
129
|
await next();
|
|
122
130
|
})
|
|
@@ -125,15 +133,22 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
125
133
|
describeRoute({
|
|
126
134
|
tags: ["AI"],
|
|
127
135
|
summary: "List OpenAI-compatible models",
|
|
128
|
-
description: "Models-only OpenAI-compatible surface: lists the Assistant chat models this
|
|
136
|
+
description: "Models-only OpenAI-compatible surface: lists the Assistant chat models this account may use.",
|
|
129
137
|
}),
|
|
130
138
|
async (c) => {
|
|
131
139
|
const models = await listModels(c.get("accessSubject"));
|
|
132
140
|
return c.json({
|
|
133
141
|
object: "list",
|
|
134
|
-
data:
|
|
135
|
-
.filter((model) => !model.capabilities.includes("transcription"))
|
|
136
|
-
.
|
|
142
|
+
data: [
|
|
143
|
+
...models.filter((model) => !model.capabilities.includes("transcription")).map((model) => model.id),
|
|
144
|
+
...(await assignments()).flatMap((assignment) =>
|
|
145
|
+
AI_MODEL_CLASSES.some((cls) => cls === assignment.slot) &&
|
|
146
|
+
assignment.variantId &&
|
|
147
|
+
models.some((model) => model.id === assignment.variantId)
|
|
148
|
+
? [`cloud/${assignment.slot}`]
|
|
149
|
+
: [],
|
|
150
|
+
),
|
|
151
|
+
].map((id) => ({ id, object: "model", created: 0, owned_by: "cloud" })),
|
|
137
152
|
});
|
|
138
153
|
},
|
|
139
154
|
)
|
|
@@ -150,7 +165,7 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
150
165
|
const models = (await listModels(subject)).filter((model) => !model.capabilities.includes("transcription"));
|
|
151
166
|
if (models.length === 0) return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
|
|
152
167
|
const slot = await acquireSlot({
|
|
153
|
-
|
|
168
|
+
subject,
|
|
154
169
|
limit: await settings.get<number>("ai.openai_api_max_concurrent_requests"),
|
|
155
170
|
});
|
|
156
171
|
if (!slot) {
|
|
@@ -199,9 +214,15 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
199
214
|
else if (message.role === "user") messages.push({ role: "user", content: [text] });
|
|
200
215
|
else messages.push({ role: "assistant", content: [{ type: "text", text }] });
|
|
201
216
|
}
|
|
202
|
-
if (
|
|
217
|
+
if (
|
|
218
|
+
!models.some((model) => model.id === data.model) &&
|
|
219
|
+
!(data.model.startsWith("cloud/") && AI_MODEL_CLASSES.some((cls) => `cloud/${cls}` === data.model))
|
|
220
|
+
)
|
|
203
221
|
return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
|
|
204
|
-
const resolved = await resolveModel(
|
|
222
|
+
const resolved = await resolveModel(
|
|
223
|
+
personalAiModelPolicy,
|
|
224
|
+
AiModelRequestSchema.parse(data.model.startsWith("cloud/") ? { class: data.model.slice(6) } : { id: data.model }),
|
|
225
|
+
);
|
|
205
226
|
const systemPrompt = system.join("\n\n");
|
|
206
227
|
const inputTokens = estimateTokens(messages) + Math.ceil(systemPrompt.length / 4);
|
|
207
228
|
if (resolved.provider.contextWindow !== undefined && inputTokens > resolved.provider.contextWindow)
|
|
@@ -230,7 +251,47 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
230
251
|
? { generationConfig: { topP: data.top_p } }
|
|
231
252
|
: { top_p: data.top_p },
|
|
232
253
|
};
|
|
233
|
-
const
|
|
254
|
+
const token = c.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
|
|
255
|
+
const credential = await authenticate(token);
|
|
256
|
+
if (!credential) throw new ModelsCredentialRevoked();
|
|
257
|
+
// Runs inside quota accounting, after admission and immediately before provider dispatch.
|
|
258
|
+
const admit = async () => {
|
|
259
|
+
try {
|
|
260
|
+
const current = await authenticate(token);
|
|
261
|
+
if (
|
|
262
|
+
!current ||
|
|
263
|
+
current.credentialId !== credential.credentialId ||
|
|
264
|
+
JSON.stringify(current.subject) !== JSON.stringify(subject)
|
|
265
|
+
)
|
|
266
|
+
throw new ModelsCredentialRevoked();
|
|
267
|
+
await assertModelAccess(resolved.profile.modelId, subject);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error instanceof ModelsCredentialRevoked) throw error;
|
|
270
|
+
if (error instanceof Error && "aiError" in error)
|
|
271
|
+
throw Object.assign(new AiProviderAdmissionError(error.message), { aiError: error.aiError });
|
|
272
|
+
throw new AiProviderAdmissionError("Model admission could not be checked");
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const guarded: Provider = {
|
|
276
|
+
...resolved.provider,
|
|
277
|
+
complete: async (request) => {
|
|
278
|
+
await admit();
|
|
279
|
+
if (request.signal?.aborted) throw new AiProviderAdmissionError("Request cancelled before dispatch");
|
|
280
|
+
return resolved.provider.complete(request);
|
|
281
|
+
},
|
|
282
|
+
stream: async function* (request) {
|
|
283
|
+
await admit();
|
|
284
|
+
if (request.signal?.aborted) throw new AiProviderAdmissionError("Request cancelled before dispatch");
|
|
285
|
+
yield* resolved.provider.stream(request);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
const provider: Provider = wrapProvider(guarded, resolved.profile, {
|
|
289
|
+
kind: "chat",
|
|
290
|
+
modelRef: resolved.model,
|
|
291
|
+
task: "openai-api",
|
|
292
|
+
subject,
|
|
293
|
+
credentialId: credential.credentialId,
|
|
294
|
+
});
|
|
234
295
|
const id = `chatcmpl-${crypto.randomUUID()}`;
|
|
235
296
|
const created = Math.floor(Date.now() / 1000);
|
|
236
297
|
let events: AsyncIterator<StreamEvent> | undefined;
|
|
@@ -284,7 +345,12 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
284
345
|
await events.return?.();
|
|
285
346
|
}
|
|
286
347
|
} catch (error) {
|
|
287
|
-
if (
|
|
348
|
+
if (
|
|
349
|
+
error instanceof AiQuotaError ||
|
|
350
|
+
error instanceof ModelsCredentialRevoked ||
|
|
351
|
+
(error instanceof Error && "aiError" in error)
|
|
352
|
+
)
|
|
353
|
+
throw error;
|
|
288
354
|
return errorResponse(c, 502, "upstream_error", "api_error");
|
|
289
355
|
} finally {
|
|
290
356
|
controller.abort();
|
|
@@ -312,7 +378,8 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
|
|
|
312
378
|
} finally {
|
|
313
379
|
controller.abort();
|
|
314
380
|
}
|
|
315
|
-
if (error instanceof AiQuotaError
|
|
381
|
+
if (error instanceof AiQuotaError || error instanceof ModelsCredentialRevoked || (error instanceof Error && "aiError" in error))
|
|
382
|
+
throw error;
|
|
316
383
|
return errorResponse(c, 502, "upstream_error", "api_error");
|
|
317
384
|
}
|
|
318
385
|
const streamEvents = events;
|
package/src/ai/personal-agent.ts
CHANGED