@vellumai/assistant 0.11.5 → 0.11.6-staging.1
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/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates `app_pins`: which apps the user pinned to the sidebar.
|
|
5
|
+
*
|
|
6
|
+
* A pin is a preference about an app rather than part of one, so it is keyed by
|
|
7
|
+
* app id here instead of living in the app's own on-disk record. That is what
|
|
8
|
+
* lets a plugin app be pinned: its record belongs to the plugin that ships it
|
|
9
|
+
* and the daemon does not write to it.
|
|
10
|
+
*
|
|
11
|
+
* `sort_position` is a fractional index, the same idiom `conversation_groups`
|
|
12
|
+
* uses, so a pin can be placed between two others by averaging their positions
|
|
13
|
+
* instead of renumbering the list.
|
|
14
|
+
*
|
|
15
|
+
* A row holds no display fields. Name and icon are read from the app at list
|
|
16
|
+
* time, so a renamed app renames its pin and a pin whose app is gone reaches
|
|
17
|
+
* no client.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent: `IF NOT EXISTS` on the table and the index.
|
|
20
|
+
*/
|
|
21
|
+
export function migrateCreateAppPins(db: DrizzleDb): void {
|
|
22
|
+
const raw = getSqliteFrom(db);
|
|
23
|
+
|
|
24
|
+
raw.exec(/*sql*/ `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS app_pins (
|
|
26
|
+
app_id TEXT PRIMARY KEY,
|
|
27
|
+
sort_position REAL NOT NULL,
|
|
28
|
+
color TEXT,
|
|
29
|
+
created_at INTEGER NOT NULL
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
// The only read path: every pin, in sidebar order.
|
|
34
|
+
raw.exec(
|
|
35
|
+
`CREATE INDEX IF NOT EXISTS idx_app_pins_sort_position ON app_pins(sort_position)`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
|
+
|
|
6
|
+
import { getSqliteFrom } from "../../db-connection.js";
|
|
7
|
+
import * as schema from "../../schema.js";
|
|
8
|
+
import { migrateCreateWatchTimelineEntries } from "../367-create-watch-timeline-entries.js";
|
|
9
|
+
|
|
10
|
+
function createTestDb() {
|
|
11
|
+
const sqlite = new Database(":memory:");
|
|
12
|
+
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
13
|
+
return drizzle(sqlite, { schema });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function columnNames(raw: Database): string[] {
|
|
17
|
+
const rows = raw
|
|
18
|
+
.prepare("PRAGMA table_info(watch_timeline_entries)")
|
|
19
|
+
.all() as Array<{ name: string }>;
|
|
20
|
+
return rows.map((row) => row.name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function indexNames(raw: Database): string[] {
|
|
24
|
+
const rows = raw
|
|
25
|
+
.prepare(
|
|
26
|
+
`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'watch_timeline_entries' AND sql IS NOT NULL ORDER BY name`,
|
|
27
|
+
)
|
|
28
|
+
.all() as Array<{ name: string }>;
|
|
29
|
+
return rows.map((row) => row.name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function insertEntry(raw: Database, id: string, atMs: number): void {
|
|
33
|
+
raw.run(
|
|
34
|
+
`INSERT INTO watch_timeline_entries
|
|
35
|
+
(id, session_id, conversation_id, at_ms, kind, text, ax_tree, ax_diff, screenshot_attachment_id, created_at)
|
|
36
|
+
VALUES (?, 'sess-1', 'conv-1', ?, 'narration', 'hello', NULL, NULL, NULL, 1000)`,
|
|
37
|
+
[id, atMs],
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("migration 367: create watch_timeline_entries", () => {
|
|
42
|
+
test("creates the table with every timeline column", () => {
|
|
43
|
+
const db = createTestDb();
|
|
44
|
+
const raw = getSqliteFrom(db);
|
|
45
|
+
|
|
46
|
+
migrateCreateWatchTimelineEntries(db);
|
|
47
|
+
|
|
48
|
+
expect(columnNames(raw).sort()).toEqual([
|
|
49
|
+
"at_ms",
|
|
50
|
+
"ax_diff",
|
|
51
|
+
"ax_tree",
|
|
52
|
+
"conversation_id",
|
|
53
|
+
"created_at",
|
|
54
|
+
"id",
|
|
55
|
+
"kind",
|
|
56
|
+
"screenshot_attachment_id",
|
|
57
|
+
"session_id",
|
|
58
|
+
"text",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("creates the session and conversation indexes", () => {
|
|
63
|
+
const db = createTestDb();
|
|
64
|
+
const raw = getSqliteFrom(db);
|
|
65
|
+
|
|
66
|
+
migrateCreateWatchTimelineEntries(db);
|
|
67
|
+
|
|
68
|
+
expect(indexNames(raw)).toEqual([
|
|
69
|
+
"idx_watch_timeline_entries_conversation",
|
|
70
|
+
"idx_watch_timeline_entries_session",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("is idempotent and preserves existing rows", () => {
|
|
75
|
+
const db = createTestDb();
|
|
76
|
+
const raw = getSqliteFrom(db);
|
|
77
|
+
|
|
78
|
+
migrateCreateWatchTimelineEntries(db);
|
|
79
|
+
insertEntry(raw, "entry-1", 1000);
|
|
80
|
+
|
|
81
|
+
expect(() => migrateCreateWatchTimelineEntries(db)).not.toThrow();
|
|
82
|
+
|
|
83
|
+
const rows = raw
|
|
84
|
+
.prepare("SELECT id, at_ms FROM watch_timeline_entries")
|
|
85
|
+
.all() as Array<{ id: string; at_ms: number }>;
|
|
86
|
+
expect(rows).toEqual([{ id: "entry-1", at_ms: 1000 }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("rejects a duplicate entry id", () => {
|
|
90
|
+
const db = createTestDb();
|
|
91
|
+
const raw = getSqliteFrom(db);
|
|
92
|
+
|
|
93
|
+
migrateCreateWatchTimelineEntries(db);
|
|
94
|
+
insertEntry(raw, "entry-1", 1000);
|
|
95
|
+
|
|
96
|
+
expect(() => insertEntry(raw, "entry-1", 2000)).toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
|
+
|
|
6
|
+
import { getSqliteFrom } from "../../db-connection.js";
|
|
7
|
+
import * as schema from "../../schema.js";
|
|
8
|
+
import { migrateCreateWatchTimelineEntries } from "../367-create-watch-timeline-entries.js";
|
|
9
|
+
import { migrateWatchTimelineScreenshotBlob } from "../368-watch-timeline-screenshot-blob.js";
|
|
10
|
+
|
|
11
|
+
function createTestDb() {
|
|
12
|
+
const sqlite = new Database(":memory:");
|
|
13
|
+
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
14
|
+
const db = drizzle(sqlite, { schema });
|
|
15
|
+
migrateCreateWatchTimelineEntries(db);
|
|
16
|
+
return db;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function columnNames(raw: Database): string[] {
|
|
20
|
+
const rows = raw
|
|
21
|
+
.prepare("PRAGMA table_info(watch_timeline_entries)")
|
|
22
|
+
.all() as Array<{ name: string }>;
|
|
23
|
+
return rows.map((row) => row.name).sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function insertEntry(raw: Database, id: string, screenshot: Buffer | null) {
|
|
27
|
+
raw.run(
|
|
28
|
+
`INSERT INTO watch_timeline_entries
|
|
29
|
+
(id, session_id, conversation_id, at_ms, kind, text, ax_tree, ax_diff, screenshot, created_at)
|
|
30
|
+
VALUES (?, 'sess-1', 'conv-1', 1000, 'observation', '', NULL, NULL, ?, 1000)`,
|
|
31
|
+
[id, screenshot],
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("migration 368: watch timeline screenshot blob", () => {
|
|
36
|
+
test("adds the blob column and drops the attachment id", () => {
|
|
37
|
+
const db = createTestDb();
|
|
38
|
+
const raw = getSqliteFrom(db);
|
|
39
|
+
|
|
40
|
+
migrateWatchTimelineScreenshotBlob(db);
|
|
41
|
+
|
|
42
|
+
expect(columnNames(raw)).toEqual([
|
|
43
|
+
"at_ms",
|
|
44
|
+
"ax_diff",
|
|
45
|
+
"ax_tree",
|
|
46
|
+
"conversation_id",
|
|
47
|
+
"created_at",
|
|
48
|
+
"id",
|
|
49
|
+
"kind",
|
|
50
|
+
"screenshot",
|
|
51
|
+
"session_id",
|
|
52
|
+
"text",
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("stores frame bytes in the row and reads them back whole", () => {
|
|
57
|
+
const db = createTestDb();
|
|
58
|
+
const raw = getSqliteFrom(db);
|
|
59
|
+
migrateWatchTimelineScreenshotBlob(db);
|
|
60
|
+
|
|
61
|
+
const frame = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x10]);
|
|
62
|
+
insertEntry(raw, "entry-1", frame);
|
|
63
|
+
insertEntry(raw, "entry-2", null);
|
|
64
|
+
|
|
65
|
+
const rows = raw
|
|
66
|
+
.prepare(
|
|
67
|
+
"SELECT id, screenshot, length(screenshot) AS bytes FROM watch_timeline_entries ORDER BY id",
|
|
68
|
+
)
|
|
69
|
+
.all() as Array<{
|
|
70
|
+
id: string;
|
|
71
|
+
screenshot: unknown;
|
|
72
|
+
bytes: number | null;
|
|
73
|
+
}>;
|
|
74
|
+
|
|
75
|
+
expect(rows.map((row) => row.bytes)).toEqual([frame.length, null]);
|
|
76
|
+
expect(Buffer.from(rows[0]?.screenshot as Uint8Array)).toEqual(frame);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("is idempotent and preserves existing rows", () => {
|
|
80
|
+
const db = createTestDb();
|
|
81
|
+
const raw = getSqliteFrom(db);
|
|
82
|
+
|
|
83
|
+
migrateWatchTimelineScreenshotBlob(db);
|
|
84
|
+
const frame = Buffer.from([1, 2, 3]);
|
|
85
|
+
insertEntry(raw, "entry-1", frame);
|
|
86
|
+
|
|
87
|
+
expect(() => migrateWatchTimelineScreenshotBlob(db)).not.toThrow();
|
|
88
|
+
|
|
89
|
+
const rows = raw
|
|
90
|
+
.prepare(
|
|
91
|
+
"SELECT id, length(screenshot) AS bytes FROM watch_timeline_entries",
|
|
92
|
+
)
|
|
93
|
+
.all() as Array<{ id: string; bytes: number }>;
|
|
94
|
+
expect(rows).toEqual([{ id: "entry-1", bytes: frame.length }]);
|
|
95
|
+
expect(columnNames(raw)).toContain("screenshot");
|
|
96
|
+
expect(columnNames(raw)).not.toContain("screenshot_attachment_id");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -91,6 +91,23 @@ export const sharedAppLinks = sqliteTable("shared_app_links", {
|
|
|
91
91
|
expiresAt: integer("expires_at"),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Which apps the user pinned to the sidebar. A pin is a preference about an
|
|
96
|
+
* app rather than part of one, so it lives here rather than in the app's own
|
|
97
|
+
* record: plugin apps are pinnable and their records belong to the plugin that
|
|
98
|
+
* ships them.
|
|
99
|
+
*
|
|
100
|
+
* `sortPosition` is a fractional index, matching `conversation_groups`, so a
|
|
101
|
+
* pin can be placed between two others without renumbering the rest.
|
|
102
|
+
*/
|
|
103
|
+
export const appPins = sqliteTable("app_pins", {
|
|
104
|
+
appId: text("app_id").primaryKey(),
|
|
105
|
+
sortPosition: real("sort_position").notNull(),
|
|
106
|
+
/** An id from the client's pinned-app colour registry; null = uncoloured. */
|
|
107
|
+
color: text("color"),
|
|
108
|
+
createdAt: integer("created_at").notNull(),
|
|
109
|
+
});
|
|
110
|
+
|
|
94
111
|
export const publishedPages = sqliteTable("published_pages", {
|
|
95
112
|
id: text("id").primaryKey(),
|
|
96
113
|
deploymentId: text("deployment_id").notNull().unique(),
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One entry in a watch session's timeline: what the user narrated, or what
|
|
5
|
+
* was on their screen while they narrated it.
|
|
6
|
+
*
|
|
7
|
+
* The timeline is not conversation history. Entries are keyed by session and
|
|
8
|
+
* ordered by `atMs`, the offset from the session's start, and only a rendered
|
|
9
|
+
* summary reaches a model, composed by the retrospective that reads them.
|
|
10
|
+
*
|
|
11
|
+
* `screenshot` holds the frame itself, so the row is an entry's only home: one
|
|
12
|
+
* row, one lifetime, one delete. Reads that do not want the pixels select the
|
|
13
|
+
* other columns and leave this one alone.
|
|
14
|
+
*/
|
|
15
|
+
export const watchTimelineEntries = sqliteTable("watch_timeline_entries", {
|
|
16
|
+
id: text("id").primaryKey(),
|
|
17
|
+
sessionId: text("session_id").notNull(),
|
|
18
|
+
conversationId: text("conversation_id").notNull(),
|
|
19
|
+
/** Milliseconds since the session started. */
|
|
20
|
+
atMs: integer("at_ms").notNull(),
|
|
21
|
+
/** `narration` or `observation`. */
|
|
22
|
+
kind: text("kind").notNull(),
|
|
23
|
+
text: text("text").notNull(),
|
|
24
|
+
axTree: text("ax_tree"),
|
|
25
|
+
axDiff: text("ax_diff"),
|
|
26
|
+
/** JPEG bytes of the screen at `atMs`, or NULL when none was kept. */
|
|
27
|
+
screenshot: blob("screenshot", { mode: "buffer" }),
|
|
28
|
+
createdAt: integer("created_at").notNull(),
|
|
29
|
+
});
|
package/src/persistence/steps.ts
CHANGED
|
@@ -474,6 +474,9 @@ import { migrateBackfillScheduleInferenceProfile } from "./migrations/363-backfi
|
|
|
474
474
|
import { migrateAddScheduleSourceKey } from "./migrations/364-add-schedule-source-key.js";
|
|
475
475
|
import { migrateAddConversationForkStrategy } from "./migrations/365-add-conversation-fork-strategy.js";
|
|
476
476
|
import { migrateChatgptSubscriptionRowIdentity } from "./migrations/366-chatgpt-subscription-row-identity.js";
|
|
477
|
+
import { migrateCreateWatchTimelineEntries } from "./migrations/367-create-watch-timeline-entries.js";
|
|
478
|
+
import { migrateWatchTimelineScreenshotBlob } from "./migrations/368-watch-timeline-screenshot-blob.js";
|
|
479
|
+
import { migrateCreateAppPins } from "./migrations/369-create-app-pins.js";
|
|
477
480
|
import type { MigrationStep } from "./migrations/run-migrations.js";
|
|
478
481
|
|
|
479
482
|
export const migrationSteps: MigrationStep[] = [
|
|
@@ -1582,4 +1585,7 @@ export const migrationSteps: MigrationStep[] = [
|
|
|
1582
1585
|
// permanently checkpoint the no-op.
|
|
1583
1586
|
dependsOn: ["migrateCreateProviderConnections"],
|
|
1584
1587
|
},
|
|
1588
|
+
migrateCreateWatchTimelineEntries,
|
|
1589
|
+
migrateWatchTimelineScreenshotBlob,
|
|
1590
|
+
migrateCreateAppPins,
|
|
1585
1591
|
];
|
|
@@ -291,6 +291,17 @@ export async function reconcilePluginSourcesNow(): Promise<void> {
|
|
|
291
291
|
} catch (err) {
|
|
292
292
|
log.error({ err }, "plugin MCP reconcile failed");
|
|
293
293
|
}
|
|
294
|
+
// Converge sidebar pins the same way: an uninstalled plugin's app stops
|
|
295
|
+
// existing, and its id is a stable path identity, so a pin left behind
|
|
296
|
+
// would return to the sidebar when the plugin does. Lazily imported and
|
|
297
|
+
// self-contained for the same reasons as the two reconciles above.
|
|
298
|
+
try {
|
|
299
|
+
const { reconcileAppPins } =
|
|
300
|
+
await import("../apps/app-pin-reconciler.js");
|
|
301
|
+
reconcileAppPins();
|
|
302
|
+
} catch (err) {
|
|
303
|
+
log.error({ err }, "app pin reconcile failed");
|
|
304
|
+
}
|
|
294
305
|
})().finally(() => {
|
|
295
306
|
reconcileInFlight = null;
|
|
296
307
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import * as retryUtil from "../../util/retry.js";
|
|
4
|
+
|
|
5
|
+
// Instant sleep so backoff delays don't slow the suite; everything else is
|
|
6
|
+
// the real module.
|
|
7
|
+
mock.module("../../util/retry.js", () => ({
|
|
8
|
+
...retryUtil,
|
|
9
|
+
sleep: async () => {},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const { RetryProvider } = await import("../retry.js");
|
|
13
|
+
const { ProviderError } = await import("../../util/errors.js");
|
|
14
|
+
|
|
15
|
+
import type { Message, Provider, ProviderResponse } from "../types.js";
|
|
16
|
+
|
|
17
|
+
const OK_RESPONSE = {
|
|
18
|
+
content: [{ type: "text", text: "ok" }],
|
|
19
|
+
model: "test-model",
|
|
20
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
21
|
+
} as unknown as ProviderResponse;
|
|
22
|
+
|
|
23
|
+
const MESSAGES: Message[] = [
|
|
24
|
+
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Inner provider that throws the queued errors in order, then succeeds. */
|
|
28
|
+
function flakyProvider(errors: unknown[]): {
|
|
29
|
+
provider: Provider;
|
|
30
|
+
calls: () => number;
|
|
31
|
+
} {
|
|
32
|
+
let calls = 0;
|
|
33
|
+
const queue = [...errors];
|
|
34
|
+
const provider: Provider = {
|
|
35
|
+
name: "openai-compatible",
|
|
36
|
+
sendMessage: async () => {
|
|
37
|
+
calls += 1;
|
|
38
|
+
const next = queue.shift();
|
|
39
|
+
if (next !== undefined) {
|
|
40
|
+
throw next;
|
|
41
|
+
}
|
|
42
|
+
return OK_RESPONSE;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return { provider, calls: () => calls };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("RetryProvider network_error handling", () => {
|
|
49
|
+
test("retries a transport failure stamped with reason network_error", async () => {
|
|
50
|
+
const { provider, calls } = flakyProvider([
|
|
51
|
+
new ProviderError(
|
|
52
|
+
"OpenAI-compatible API error (unknown status): Connection error.",
|
|
53
|
+
"openai-compatible",
|
|
54
|
+
undefined,
|
|
55
|
+
{ reason: "network_error" },
|
|
56
|
+
),
|
|
57
|
+
]);
|
|
58
|
+
const result = await new RetryProvider(provider).sendMessage(MESSAGES);
|
|
59
|
+
expect(result).toBe(OK_RESPONSE);
|
|
60
|
+
expect(calls()).toBe(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("still does not retry a statusless reason-less abort-shaped error", async () => {
|
|
64
|
+
// OpenAI catch-sites do not distinguish inner stream deadlines from
|
|
65
|
+
// transport aborts, so their "Request was aborted." must stay
|
|
66
|
+
// non-retryable (see RETRYABLE_TRANSPORT_ABORT_PATTERNS).
|
|
67
|
+
const abortShaped = () =>
|
|
68
|
+
new ProviderError(
|
|
69
|
+
"OpenAI API error (unknown status): Request was aborted.",
|
|
70
|
+
"openai",
|
|
71
|
+
undefined,
|
|
72
|
+
);
|
|
73
|
+
const { provider, calls } = flakyProvider([
|
|
74
|
+
abortShaped(),
|
|
75
|
+
abortShaped(),
|
|
76
|
+
abortShaped(),
|
|
77
|
+
abortShaped(),
|
|
78
|
+
]);
|
|
79
|
+
await expect(
|
|
80
|
+
new RetryProvider(provider).sendMessage(MESSAGES),
|
|
81
|
+
).rejects.toThrow("Request was aborted");
|
|
82
|
+
expect(calls()).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -41,7 +41,10 @@ import {
|
|
|
41
41
|
describeSubscriptionModelIncompatibility,
|
|
42
42
|
isConnectionCompatibleWithModel,
|
|
43
43
|
} from "./connection-model-compat.js";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
ROUTING_IDENTITY_PROVIDERS,
|
|
46
|
+
VALID_CONNECTION_PROVIDERS,
|
|
47
|
+
} from "./inference/auth.js";
|
|
45
48
|
import {
|
|
46
49
|
canonicalVellumConnection,
|
|
47
50
|
getConnection,
|
|
@@ -193,6 +196,25 @@ export function resolveEntryProviderKind(
|
|
|
193
196
|
return entryName ? connectionProviderKind(entryName, model) : null;
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The catalog provider whose model limits judge a profile's (provider,
|
|
201
|
+
* model) pair: routing identities translate to their concrete upstream
|
|
202
|
+
* (chatgpt serves the OpenAI catalog; a vellum profile's bare native model
|
|
203
|
+
* names its managed owner), and an entry-name label resolves to its row's
|
|
204
|
+
* dispatchable kind. Null when no upstream is derivable (the pair stays
|
|
205
|
+
* unjudged). Shared by the profile-route budget validation and profile
|
|
206
|
+
* materialization so the two cannot drift.
|
|
207
|
+
*/
|
|
208
|
+
export function catalogProviderForProfile(
|
|
209
|
+
provider: string,
|
|
210
|
+
model: string,
|
|
211
|
+
): string | null {
|
|
212
|
+
if (ROUTING_IDENTITY_PROVIDERS.has(provider)) {
|
|
213
|
+
return provider === "chatgpt" ? "openai" : getManagedUpstream(model);
|
|
214
|
+
}
|
|
215
|
+
return resolveEntryProviderKind(provider, model) ?? provider;
|
|
216
|
+
}
|
|
217
|
+
|
|
196
218
|
/**
|
|
197
219
|
* Resolve a Provider through a named `provider_connection`.
|
|
198
220
|
*
|
|
@@ -16,6 +16,15 @@ export function hasVisibleText(content: ReadonlyArray<ContentBlock>): boolean {
|
|
|
16
16
|
);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Whether `content` carries nothing a provider can serialize: no blocks, or only blank text. */
|
|
20
|
+
export function isEffectivelyEmptyContent(
|
|
21
|
+
content: ReadonlyArray<ContentBlock>,
|
|
22
|
+
): boolean {
|
|
23
|
+
return content.every(
|
|
24
|
+
(block) => block.type === "text" && block.text.trim().length === 0,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
/** Whether `content` carries at least one tool call. */
|
|
20
29
|
export function hasToolUse(content: ReadonlyArray<ContentBlock>): boolean {
|
|
21
30
|
return content.some((block) => block.type === "tool_use");
|
|
@@ -45,6 +45,14 @@ export interface FetchProviderCatalogEntry {
|
|
|
45
45
|
/** Privacy-policy URL surfaced in marketing data-sharing docs.
|
|
46
46
|
* BYOK providers only. */
|
|
47
47
|
readonly privacyPolicyUrl?: string;
|
|
48
|
+
/**
|
|
49
|
+
* When true, settings UIs show an optional API Base field. Empty /
|
|
50
|
+
* omitted base uses {@link defaultApiBase}.
|
|
51
|
+
*/
|
|
52
|
+
readonly supportsApiBase?: boolean;
|
|
53
|
+
/** Cloud default origin when `supportsApiBase` is true and the user
|
|
54
|
+
* leaves API Base empty. */
|
|
55
|
+
readonly defaultApiBase?: string;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export const FETCH_PROVIDER_CATALOG: readonly FetchProviderCatalogEntry[] = [
|
|
@@ -65,6 +73,17 @@ export const FETCH_PROVIDER_CATALOG: readonly FetchProviderCatalogEntry[] = [
|
|
|
65
73
|
secretKey: "firecrawl",
|
|
66
74
|
privacyPolicyUrl: "https://www.firecrawl.dev/privacy-policy",
|
|
67
75
|
},
|
|
76
|
+
{
|
|
77
|
+
id: "fastcrw",
|
|
78
|
+
displayName: "fastCRW",
|
|
79
|
+
kind: "byok",
|
|
80
|
+
apiKeyPrefix: "crw_live_...",
|
|
81
|
+
envVar: "FASTCRW_API_KEY",
|
|
82
|
+
secretKey: "fastcrw",
|
|
83
|
+
privacyPolicyUrl: "https://fastcrw.com/privacy",
|
|
84
|
+
supportsApiBase: true,
|
|
85
|
+
defaultApiBase: "https://api.fastcrw.com",
|
|
86
|
+
},
|
|
68
87
|
];
|
|
69
88
|
|
|
70
89
|
/** Provider ids accepted by the web-fetch config schema. */
|
|
@@ -720,15 +720,23 @@ export class GeminiProvider implements Provider {
|
|
|
720
720
|
model,
|
|
721
721
|
role,
|
|
722
722
|
);
|
|
723
|
-
|
|
724
|
-
|
|
723
|
+
// Gemini keeps functionResponse parts separate from other parts. Tool
|
|
724
|
+
// result media follows the function response before any remaining text.
|
|
725
|
+
const functionResponseParts = parts.filter(
|
|
726
|
+
(part) => part.functionResponse !== undefined,
|
|
727
|
+
);
|
|
728
|
+
const otherParts = parts.filter(
|
|
729
|
+
(part) => part.functionResponse === undefined,
|
|
730
|
+
);
|
|
731
|
+
if (functionResponseParts.length > 0) {
|
|
732
|
+
result.push({ role, parts: functionResponseParts });
|
|
725
733
|
}
|
|
726
|
-
// Gemini requires that a Content with functionResponse parts must not
|
|
727
|
-
// contain non-functionResponse parts. Emit tool-result images in a
|
|
728
|
-
// separate user Content entry.
|
|
729
734
|
if (toolResultMediaParts.length > 0) {
|
|
730
735
|
result.push({ role: "user", parts: toolResultMediaParts });
|
|
731
736
|
}
|
|
737
|
+
if (otherParts.length > 0) {
|
|
738
|
+
result.push({ role, parts: otherParts });
|
|
739
|
+
}
|
|
732
740
|
}
|
|
733
741
|
|
|
734
742
|
return result;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { testInferenceConnection } from "../endpoint-probe.js";
|
|
4
|
+
|
|
5
|
+
// Keyless auth so the probe never touches the vault; the fetch stub records
|
|
6
|
+
// the request instead of dialing out.
|
|
7
|
+
const CONNECTION = {
|
|
8
|
+
provider: "openai-compatible",
|
|
9
|
+
auth: { type: "none" } as const,
|
|
10
|
+
baseUrl: "https://integrate.api.nvidia.com",
|
|
11
|
+
models: [{ id: "meta/llama-3.1-8b-instruct" }],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function stubFetch(status: number, calls: { url: string; body: unknown }[]) {
|
|
15
|
+
return (async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
16
|
+
calls.push({
|
|
17
|
+
url: String(url),
|
|
18
|
+
body: JSON.parse(String(init?.body)),
|
|
19
|
+
});
|
|
20
|
+
return new Response(status < 400 ? "{}" : "404 page not found", { status });
|
|
21
|
+
}) as typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("testInferenceConnection", () => {
|
|
25
|
+
test("reports ok:false with a base-path hint on 404", async () => {
|
|
26
|
+
const calls: { url: string; body: unknown }[] = [];
|
|
27
|
+
const result = await testInferenceConnection(
|
|
28
|
+
CONNECTION,
|
|
29
|
+
stubFetch(404, calls),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(result).toMatchObject({
|
|
33
|
+
ok: false,
|
|
34
|
+
status: 404,
|
|
35
|
+
resolved_url: "https://integrate.api.nvidia.com/chat/completions",
|
|
36
|
+
error_class: "http_error",
|
|
37
|
+
});
|
|
38
|
+
expect(result?.hint).toContain("/v1");
|
|
39
|
+
expect(calls[0].body).toMatchObject({
|
|
40
|
+
model: "meta/llama-3.1-8b-instruct",
|
|
41
|
+
max_tokens: 1,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("reports ok:true on 200 from a correct base URL", async () => {
|
|
46
|
+
const calls: { url: string; body: unknown }[] = [];
|
|
47
|
+
const result = await testInferenceConnection(
|
|
48
|
+
{ ...CONNECTION, baseUrl: "https://integrate.api.nvidia.com/v1" },
|
|
49
|
+
stubFetch(200, calls),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result).toMatchObject({
|
|
53
|
+
ok: true,
|
|
54
|
+
status: 200,
|
|
55
|
+
resolved_url: "https://integrate.api.nvidia.com/v1/chat/completions",
|
|
56
|
+
});
|
|
57
|
+
expect(result?.hint).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("hints at the credential on 401", async () => {
|
|
61
|
+
const result = await testInferenceConnection(
|
|
62
|
+
CONNECTION,
|
|
63
|
+
stubFetch(401, []),
|
|
64
|
+
);
|
|
65
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
66
|
+
expect(result?.hint).toContain("API key");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("reports a network error_class when the endpoint is unreachable", async () => {
|
|
70
|
+
const failingFetch = (async () => {
|
|
71
|
+
throw new Error("connection refused");
|
|
72
|
+
}) as unknown as typeof fetch;
|
|
73
|
+
const result = await testInferenceConnection(CONNECTION, failingFetch);
|
|
74
|
+
expect(result).toMatchObject({ ok: false, error_class: "network" });
|
|
75
|
+
expect(result?.hint).toContain("connection refused");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("skips when there is no base URL or no model to probe with", async () => {
|
|
79
|
+
const neverFetch = (async () => {
|
|
80
|
+
throw new Error("should not be called");
|
|
81
|
+
}) as unknown as typeof fetch;
|
|
82
|
+
expect(
|
|
83
|
+
await testInferenceConnection(
|
|
84
|
+
{ ...CONNECTION, baseUrl: null },
|
|
85
|
+
neverFetch,
|
|
86
|
+
),
|
|
87
|
+
).toBeNull();
|
|
88
|
+
expect(
|
|
89
|
+
await testInferenceConnection({ ...CONNECTION, models: [] }, neverFetch),
|
|
90
|
+
).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { validateInferenceProfileConfig } from "../../../api/constants/profile-config-validation.js";
|
|
4
|
+
|
|
5
|
+
describe("validateInferenceProfileConfig", () => {
|
|
6
|
+
test("rejects an output budget that consumes the whole context window", () => {
|
|
7
|
+
const issue = validateInferenceProfileConfig({
|
|
8
|
+
maxTokens: 300000,
|
|
9
|
+
modelContextWindowTokens: 300000,
|
|
10
|
+
});
|
|
11
|
+
expect(issue?.field).toBe("maxTokens");
|
|
12
|
+
expect(issue?.message).toContain("context window");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("passes an output budget that leaves input room", () => {
|
|
16
|
+
expect(
|
|
17
|
+
validateInferenceProfileConfig({
|
|
18
|
+
maxTokens: 8000,
|
|
19
|
+
modelContextWindowTokens: 300000,
|
|
20
|
+
}),
|
|
21
|
+
).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects an output budget over the model's output cap", () => {
|
|
25
|
+
const issue = validateInferenceProfileConfig({
|
|
26
|
+
maxTokens: 100000,
|
|
27
|
+
modelMaxOutputTokens: 64000,
|
|
28
|
+
modelContextWindowTokens: 200000,
|
|
29
|
+
});
|
|
30
|
+
expect(issue?.message).toContain("maximum output");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("silent when either side of the judgment is unknown", () => {
|
|
34
|
+
expect(validateInferenceProfileConfig({ maxTokens: 300000 })).toBeNull();
|
|
35
|
+
expect(
|
|
36
|
+
validateInferenceProfileConfig({ modelContextWindowTokens: 300000 }),
|
|
37
|
+
).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|