@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
package/src/ai/prefs.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { RequestActor } from "../server";
|
|
3
|
+
import { type AiModelRequest, AiModelRequestSchema } from "../shared/ai-models";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Learning from private chats applies to everyone who has not chosen.
|
|
@@ -12,8 +13,8 @@ export type AiUserPrefs = {
|
|
|
12
13
|
memoryEnabled: boolean;
|
|
13
14
|
/** Effective setting: the user's explicit choice, otherwise the default. */
|
|
14
15
|
memoryLearningEnabled: boolean;
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/** Class or explicit variant choice, preselected for new chats. */
|
|
17
|
+
selection: AiModelRequest | null;
|
|
17
18
|
updatedAt: string;
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -21,7 +22,7 @@ type PrefsRow = {
|
|
|
21
22
|
user_id: string;
|
|
22
23
|
memory_enabled: boolean;
|
|
23
24
|
memory_learning_enabled: boolean | null;
|
|
24
|
-
|
|
25
|
+
model_choice: unknown;
|
|
25
26
|
updated_at: string | Date;
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -29,7 +30,7 @@ const toPrefs = (row: PrefsRow): AiUserPrefs => ({
|
|
|
29
30
|
userId: row.user_id,
|
|
30
31
|
memoryEnabled: row.memory_enabled,
|
|
31
32
|
memoryLearningEnabled: row.memory_learning_enabled ?? AI_MEMORY_LEARNING_DEFAULT_ENABLED,
|
|
32
|
-
|
|
33
|
+
selection: row.model_choice ? AiModelRequestSchema.parse(row.model_choice) : null,
|
|
33
34
|
updatedAt: new Date(row.updated_at).toISOString(),
|
|
34
35
|
});
|
|
35
36
|
|
|
@@ -37,7 +38,7 @@ const emptyPrefs = (userId: string): AiUserPrefs => ({
|
|
|
37
38
|
userId,
|
|
38
39
|
memoryEnabled: true,
|
|
39
40
|
memoryLearningEnabled: AI_MEMORY_LEARNING_DEFAULT_ENABLED,
|
|
40
|
-
|
|
41
|
+
selection: null,
|
|
41
42
|
updatedAt: new Date(0).toISOString(),
|
|
42
43
|
});
|
|
43
44
|
|
|
@@ -59,25 +60,26 @@ export const aiUserPrefs = {
|
|
|
59
60
|
|
|
60
61
|
async update(
|
|
61
62
|
userId: string,
|
|
62
|
-
patch: { memoryEnabled?: boolean; memoryLearningEnabled?: boolean;
|
|
63
|
+
patch: { memoryEnabled?: boolean; memoryLearningEnabled?: boolean; selection?: AiModelRequest | null },
|
|
63
64
|
): Promise<AiUserPrefs> {
|
|
64
65
|
const memoryEnabled = patch.memoryEnabled ?? null;
|
|
65
66
|
const memoryLearningEnabled = patch.memoryLearningEnabled ?? null;
|
|
66
67
|
// Records when the person last saved the learning switch, so a stored "off"
|
|
67
68
|
// from an earlier release stays distinguishable from a choice made since.
|
|
68
69
|
const learningChosen = memoryLearningEnabled !== null;
|
|
69
|
-
const
|
|
70
|
+
const choiceChanged = Object.hasOwn(patch, "selection");
|
|
71
|
+
const modelChoice = patch.selection ? JSON.stringify(AiModelRequestSchema.parse(patch.selection)) : null;
|
|
70
72
|
const rows = (await sql`
|
|
71
|
-
INSERT INTO ai.user_prefs (user_id, memory_enabled, memory_learning_enabled, memory_learning_chosen_at,
|
|
73
|
+
INSERT INTO ai.user_prefs (user_id, memory_enabled, memory_learning_enabled, memory_learning_chosen_at, model_choice, updated_at)
|
|
72
74
|
VALUES (
|
|
73
75
|
${userId}, ${memoryEnabled ?? true}, ${memoryLearningEnabled},
|
|
74
|
-
CASE WHEN ${learningChosen}::boolean THEN now() END, ${
|
|
76
|
+
CASE WHEN ${learningChosen}::boolean THEN now() END, ${modelChoice}::text::jsonb, now()
|
|
75
77
|
)
|
|
76
78
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
77
79
|
memory_enabled = COALESCE(${memoryEnabled}, ai.user_prefs.memory_enabled),
|
|
78
80
|
memory_learning_enabled = COALESCE(${memoryLearningEnabled}, ai.user_prefs.memory_learning_enabled),
|
|
79
81
|
memory_learning_chosen_at = CASE WHEN ${learningChosen}::boolean THEN now() ELSE ai.user_prefs.memory_learning_chosen_at END,
|
|
80
|
-
|
|
82
|
+
model_choice = CASE WHEN ${choiceChanged} THEN ${modelChoice}::text::jsonb ELSE ai.user_prefs.model_choice END,
|
|
81
83
|
updated_at = now()
|
|
82
84
|
RETURNING *
|
|
83
85
|
`) as PrefsRow[];
|
|
@@ -6,6 +6,7 @@ import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
|
6
6
|
import { type AuthContext, auth, err, fail, getLocale, ok, rateLimit, respond, v } from "../server";
|
|
7
7
|
import { noTransform } from "../server/middleware/no-transform";
|
|
8
8
|
import { ImageMetadataError } from "../services/image-metadata";
|
|
9
|
+
import { AiModelRequestSchema } from "../shared/ai-models";
|
|
9
10
|
import { aiChatMessages } from "./chat/messages";
|
|
10
11
|
import { decodeAiFileContent } from "./files-store";
|
|
11
12
|
import {
|
|
@@ -27,7 +28,7 @@ const ProjectFieldsSchema = z.object({
|
|
|
27
28
|
description: z.string().trim().max(AI_PROJECT_DESCRIPTION_MAX_CHARS).default(""),
|
|
28
29
|
icon: z.string().trim().min(1).max(80).default("ti ti-folders"),
|
|
29
30
|
instructions: z.string().trim().max(AI_PROJECT_INSTRUCTIONS_MAX_CHARS).default(""),
|
|
30
|
-
|
|
31
|
+
defaultModel: AiModelRequestSchema.nullable().optional(),
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
const UpdateProjectSchema = ProjectFieldsSchema.partial().refine((input) => Object.keys(input).length > 0, "No changes supplied.");
|
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
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
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";
|
|
4
|
+
|
|
5
|
+
/** No provider-owned message, name, stack, cause, or extension fields cross this boundary. */
|
|
6
|
+
export class AiProviderError extends Error {
|
|
7
|
+
readonly code: "provider_error" | "provider_aborted";
|
|
8
|
+
|
|
9
|
+
constructor(aborted = false) {
|
|
10
|
+
super(aborted ? "AI provider request was canceled." : "AI provider request failed.");
|
|
11
|
+
this.name = aborted ? "AbortError" : "AiProviderError";
|
|
12
|
+
this.code = aborted ? "provider_aborted" : "provider_error";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Keep only the adapter's typed control signals. Text and diagnostic payloads are untrusted. */
|
|
17
|
+
const providerIssue = (issue: NessiIssue, tools: Parameters<Provider["stream"]>[0]["tools"]): NessiIssue => {
|
|
18
|
+
switch (issue.kind) {
|
|
19
|
+
case "provider_error":
|
|
20
|
+
return {
|
|
21
|
+
kind: "provider_error",
|
|
22
|
+
message: "AI provider request failed.",
|
|
23
|
+
retryable: issue.retryable === true,
|
|
24
|
+
contextOverflow: issue.contextOverflow === true,
|
|
25
|
+
overflowRatio:
|
|
26
|
+
typeof issue.overflowRatio === "number" && Number.isFinite(issue.overflowRatio) && issue.overflowRatio > 0
|
|
27
|
+
? issue.overflowRatio
|
|
28
|
+
: undefined,
|
|
29
|
+
};
|
|
30
|
+
case "timeout":
|
|
31
|
+
return {
|
|
32
|
+
kind: "timeout",
|
|
33
|
+
scope: issue.scope === "provider_first_byte" ? "provider_first_byte" : "provider_idle",
|
|
34
|
+
message: "AI provider request timed out.",
|
|
35
|
+
retryable: issue.retryable === true,
|
|
36
|
+
};
|
|
37
|
+
case "malformed_tool_call":
|
|
38
|
+
case "cancelled_tool_call":
|
|
39
|
+
return {
|
|
40
|
+
kind: issue.kind,
|
|
41
|
+
reason: toolStreamReason(issue.reason),
|
|
42
|
+
// Use the application-declared name, never an arbitrary provider diagnostic.
|
|
43
|
+
name: tools?.find((tool) => tool.name === issue.name)?.name,
|
|
44
|
+
message: "AI provider returned an incomplete or invalid tool call.",
|
|
45
|
+
};
|
|
46
|
+
default:
|
|
47
|
+
// Tool execution and runtime issues belong to nessi, not a model adapter.
|
|
48
|
+
return { kind: "provider_error", message: "AI provider request failed.", retryable: false };
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const toolStreamReason = (reason: string) => {
|
|
53
|
+
switch (reason) {
|
|
54
|
+
case "text_during_tool_call":
|
|
55
|
+
case "thinking_during_tool_call":
|
|
56
|
+
case "tool_delta_without_start":
|
|
57
|
+
case "missing_tool_name":
|
|
58
|
+
case "invalid_tool_arguments":
|
|
59
|
+
case "stream_ended_before_tool_call":
|
|
60
|
+
case "provider_error_before_tool_call":
|
|
61
|
+
return reason;
|
|
62
|
+
default:
|
|
63
|
+
return "invalid_tool_arguments";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Apply before accounting, retry, nessi loops, traces, capabilities, and loggers observe an adapter. */
|
|
68
|
+
export const withProviderErrors = (provider: Provider, providerId?: string): Provider => ({
|
|
69
|
+
name: provider.name,
|
|
70
|
+
family: provider.family,
|
|
71
|
+
model: provider.model,
|
|
72
|
+
contextWindow: provider.contextWindow,
|
|
73
|
+
capabilities: provider.capabilities,
|
|
74
|
+
complete: async (request) => {
|
|
75
|
+
const marks: ProviderFetchMarks = {};
|
|
76
|
+
try {
|
|
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
|
+
);
|
|
92
|
+
throw new AiProviderError(request.signal?.aborted);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
stream: async function* (request) {
|
|
96
|
+
const marks: ProviderFetchMarks = {};
|
|
97
|
+
let iterator: AsyncIterator<StreamEvent> | undefined;
|
|
98
|
+
try {
|
|
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
|
+
}
|
|
118
|
+
yield event.type === "issue" ? { type: "issue", issue: providerIssue(event.issue, request.tools) } : event;
|
|
119
|
+
}
|
|
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
|
+
);
|
|
128
|
+
// Includes synchronous creation, iteration, and iterator cleanup failures.
|
|
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
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
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-retry.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const AI_PROVIDER_RETRY_DELAYS_MS: readonly number[] = [1_000, 4_000];
|
|
|
8
8
|
/**
|
|
9
9
|
* The longest Retry-After that is honored, the bound the OpenAI and Anthropic
|
|
10
10
|
* SDKs apply. A provider that asks for a longer wait is unavailable for this
|
|
11
|
-
* turn, so the call fails now with
|
|
11
|
+
* turn, so the call fails now with Cloud's fixed provider error.
|
|
12
12
|
*/
|
|
13
13
|
const AI_PROVIDER_MAX_RETRY_AFTER_MS = 60_000;
|
|
14
14
|
|
package/src/ai/provider.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
|
|
2
2
|
import { anthropic, completeFromStream, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
|
|
3
|
+
import { withProviderErrors } from "./provider-errors";
|
|
3
4
|
import type { AiModelProfile } from "./types";
|
|
4
5
|
|
|
5
6
|
/** Bound the first response chunk and every gap between chunks to 60s, for chat and complete() calls. */
|
|
@@ -39,7 +40,7 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
|
|
|
39
40
|
|
|
40
41
|
export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
|
|
41
42
|
if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
|
|
42
|
-
return completeThroughStream(createAdapter(profile, apiKey, headers));
|
|
43
|
+
return withProviderErrors(completeThroughStream(createAdapter(profile, apiKey, headers)), profile.providerId);
|
|
43
44
|
};
|
|
44
45
|
|
|
45
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,11 +3,15 @@ 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";
|
|
9
10
|
import type { AiModelProfile } from "./types";
|
|
10
11
|
|
|
12
|
+
/** A dispatch guard refused the call before any provider request could leave. */
|
|
13
|
+
export class AiProviderAdmissionError extends Error {}
|
|
14
|
+
|
|
11
15
|
const log = logger("ai:quotas");
|
|
12
16
|
|
|
13
17
|
/** One wrapper owns call accounting. complete() can have a separate compaction purpose. */
|
|
@@ -25,6 +29,8 @@ export function inferenceProvider(
|
|
|
25
29
|
completeContext = context,
|
|
26
30
|
lifecycle = callLifecycle,
|
|
27
31
|
): Provider {
|
|
32
|
+
// createAiProvider normalizes adapter diagnostics before trusted dispatch guards wrap it.
|
|
33
|
+
// Accounting must preserve those guards' error identity and zero-usage semantics.
|
|
28
34
|
const begin = async (request: Parameters<Provider["complete"]>[0], ctx: AiCallContext) => {
|
|
29
35
|
const messages = request.messages.map((message) =>
|
|
30
36
|
message.role === "user"
|
|
@@ -108,6 +114,7 @@ export function inferenceProvider(
|
|
|
108
114
|
if (status === "failed") error = `The provider finished with ${result.finishReason}.`;
|
|
109
115
|
return result;
|
|
110
116
|
} catch (thrown) {
|
|
117
|
+
if (thrown instanceof AiProviderAdmissionError) usage = { input: 0, output: 0 };
|
|
111
118
|
error = thrown instanceof Error ? thrown.message : String(thrown);
|
|
112
119
|
throw thrown;
|
|
113
120
|
} finally {
|
|
@@ -183,6 +190,7 @@ export function inferenceProvider(
|
|
|
183
190
|
}
|
|
184
191
|
completed = true;
|
|
185
192
|
} catch (thrown) {
|
|
193
|
+
if (thrown instanceof AiProviderAdmissionError) usage = { input: 0, output: 0 };
|
|
186
194
|
error ??= thrown instanceof Error ? thrown.message : String(thrown);
|
|
187
195
|
throw thrown;
|
|
188
196
|
} finally {
|
|
@@ -223,7 +231,7 @@ export function inferenceProvider(
|
|
|
223
231
|
firstByteMs: marks.firstByteAt === undefined ? undefined : marks.firstByteAt - requestStartedAt,
|
|
224
232
|
firstBlockMs: marks.firstBlockAt === undefined ? undefined : marks.firstBlockAt - requestStartedAt,
|
|
225
233
|
});
|
|
226
|
-
} catch
|
|
234
|
+
} catch {
|
|
227
235
|
log.warn("Chat usage booking failed", { code: "quota_booking_failed", turnId: context.turnId, callId: id });
|
|
228
236
|
}
|
|
229
237
|
}
|
|
@@ -239,8 +247,9 @@ export function assistantQuotaProvider(
|
|
|
239
247
|
profile: AiModelProfile,
|
|
240
248
|
turnId: string,
|
|
241
249
|
conversationId: string,
|
|
250
|
+
modelRef: AiResolvedModelRef,
|
|
242
251
|
) {
|
|
243
|
-
const common = { subject, turnId, conversationId };
|
|
252
|
+
const common = { subject, turnId, conversationId, modelRef };
|
|
244
253
|
return inferenceProvider(
|
|
245
254
|
provider,
|
|
246
255
|
profile,
|