@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321
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/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/openapi.yaml +97 -4
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/channel-readiness-discord.test.ts +8 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
- package/src/__tests__/platform-callback-registration.test.ts +46 -1
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/pricing.test.ts +33 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/skill-load-inline-command.test.ts +35 -0
- package/src/__tests__/skill-load-tool.test.ts +42 -11
- package/src/__tests__/skill-tool-factory.test.ts +50 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/__tests__/validate-input.test.ts +73 -0
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/ask-question-tool.ts +50 -0
- package/src/api/events/tool-result.ts +11 -0
- package/src/api/index.ts +7 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/api/responses/home.ts +27 -0
- package/src/avatar/notification-avatar.test.ts +42 -34
- package/src/avatar/notification-avatar.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
- package/src/calls/voice-escalation-judge.ts +6 -5
- package/src/calls/voice-screen-annotation.ts +44 -0
- package/src/calls/voice-session-bridge.ts +22 -0
- package/src/cli/commands/__tests__/plugins.test.ts +29 -1
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/commands/platform/index.help.ts +2 -1
- package/src/cli/commands/platform/index.ts +8 -2
- package/src/cli/commands/plugins.ts +16 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/cli/lib/inspect-plugin.ts +5 -0
- package/src/cli/lib/plugin-catalog-cache.ts +41 -4
- package/src/cli/lib/plugin-catalog-local.ts +9 -0
- package/src/cli/lib/plugin-catalog-platform.ts +48 -9
- package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
- package/src/cli/lib/plugin-details.ts +10 -7
- package/src/cli/lib/search-plugins.ts +2 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
- package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
- package/src/config/call-site-defaults.ts +17 -11
- package/src/config/default-profile-catalog.ts +118 -19
- package/src/config/default-profile-names.ts +69 -0
- package/src/config/feature-flag-registry.json +25 -9
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +8 -1
- package/src/config/schemas/llm.ts +70 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/config/sync-gated-profiles.ts +96 -44
- package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
- package/src/daemon/auto-profile-router.ts +220 -0
- package/src/daemon/conversation-agent-loop.ts +77 -4
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/dictation-text-processing.ts +4 -4
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/daemon/pkb-reminder-builder.test.ts +10 -3
- package/src/daemon/pkb-reminder-builder.ts +1 -1
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/home/feed-types.ts +2 -0
- package/src/inbound/platform-callback-registration.ts +19 -6
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
- package/src/live-voice/__tests__/protocol.test.ts +22 -0
- package/src/live-voice/continuation-judge.ts +1 -1
- package/src/live-voice/live-voice-session.ts +7 -6
- package/src/live-voice/protocol.ts +16 -5
- package/src/messaging/providers/gmail/client.ts +4 -0
- package/src/notifications/README.md +11 -3
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
- package/src/notifications/conversation-pairing.ts +23 -8
- package/src/notifications/home-feed-mirror.ts +39 -0
- package/src/notifications/home-feed-side-effect.ts +46 -19
- package/src/oauth/AGENTS.md +7 -1
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/persistence/jobs-store.ts +229 -0
- package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
- package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
- package/src/persistence/steps.ts +12 -0
- package/src/platform/sync-avatar.test.ts +29 -0
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/AGENTS.md +5 -3
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
- package/src/plugins/defaults/memory/graph/tools.ts +9 -5
- package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
- package/src/plugins/defaults/memory/job-handlers.ts +8 -2
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
- package/src/plugins/defaults/memory/tools.test.ts +17 -0
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/anthropic/client.ts +2 -2
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/inference/auth.ts +25 -0
- package/src/providers/jev/ask.test.ts +66 -0
- package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/model-catalog.ts +257 -4
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/openai/codex-models.ts +2 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/runtime/routes/platform-routes.ts +6 -1
- package/src/runtime/routes/plugins-routes.ts +5 -2
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/skills/validate-input.ts +75 -11
- package/src/telegram/__tests__/webhook-health.test.ts +8 -0
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
- package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
- package/src/tools/network/__tests__/web-search.test.ts +96 -0
- package/src/tools/network/web-fetch.ts +30 -9
- package/src/tools/network/web-search.ts +235 -4
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +15 -0
- package/src/tools/skills/load.ts +15 -43
- package/src/tools/skills/scaffold-managed.ts +86 -71
- package/src/tools/skills/skill-tool-factory.ts +2 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +146 -0
- package/src/util/worker-pipe.ts +49 -0
- package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -165,8 +165,14 @@ const getCatalogSpy = mock(
|
|
|
165
165
|
},
|
|
166
166
|
);
|
|
167
167
|
|
|
168
|
+
const getInstalledMetadataCatalogSpy = mock(
|
|
169
|
+
(ref: string, deps: SearchPluginsDeps): Promise<PluginCatalog> =>
|
|
170
|
+
getCatalogSpy(ref, deps),
|
|
171
|
+
);
|
|
172
|
+
|
|
168
173
|
mock.module("../../../cli/lib/plugin-catalog-cache.js", () => ({
|
|
169
174
|
getPluginCatalog: getCatalogSpy,
|
|
175
|
+
getPluginCatalogForInstalledMetadata: getInstalledMetadataCatalogSpy,
|
|
170
176
|
}));
|
|
171
177
|
|
|
172
178
|
// Mock uninstallPlugin. The handler's error mapping is the wiring under
|
|
@@ -471,6 +477,7 @@ beforeEach(() => {
|
|
|
471
477
|
describe("GET /v1/plugins", () => {
|
|
472
478
|
beforeEach(() => {
|
|
473
479
|
getCatalogSpy.mockClear();
|
|
480
|
+
getInstalledMetadataCatalogSpy.mockClear();
|
|
474
481
|
// Default to an empty catalog so every installed plugin's category resolves
|
|
475
482
|
// to `null` (bucketed under "system"). Tests that exercise real categories
|
|
476
483
|
// override this.
|
|
@@ -849,6 +856,7 @@ describe("GET /v1/plugins", () => {
|
|
|
849
856
|
);
|
|
850
857
|
|
|
851
858
|
const map = await loadCategoryMapBounded();
|
|
859
|
+
expect(getInstalledMetadataCatalogSpy).toHaveBeenCalledTimes(1);
|
|
852
860
|
expect(map.get("alpha")).toBe("productivity");
|
|
853
861
|
});
|
|
854
862
|
|
|
@@ -31,16 +31,14 @@ import {
|
|
|
31
31
|
import { resolveConversationId } from "../../persistence/conversation-key-store.js";
|
|
32
32
|
import {
|
|
33
33
|
type ConversationListFilter,
|
|
34
|
+
type ConversationListTypeFilter,
|
|
34
35
|
countConversations,
|
|
35
36
|
countConversationSections,
|
|
36
37
|
countUnreadConversations,
|
|
37
38
|
listConversations,
|
|
38
39
|
listPinnedConversations,
|
|
39
40
|
} from "../../persistence/conversation-queries.js";
|
|
40
|
-
import {
|
|
41
|
-
ASSISTANT_INITIATED_GROUP_ID,
|
|
42
|
-
type ConversationType,
|
|
43
|
-
} from "../../persistence/conversation-types.js";
|
|
41
|
+
import { ASSISTANT_INITIATED_GROUP_ID } from "../../persistence/conversation-types.js";
|
|
44
42
|
import { getBindingsForConversations } from "../../persistence/external-conversation-store.js";
|
|
45
43
|
import { listGroups } from "../../persistence/group-crud.js";
|
|
46
44
|
import { UserError } from "../../util/errors.js";
|
|
@@ -252,12 +250,14 @@ function handleListConversations({
|
|
|
252
250
|
const limit = Number(queryParams.limit ?? 50);
|
|
253
251
|
const offset = Number(queryParams.offset ?? 0);
|
|
254
252
|
// "background" is the back-compat umbrella (background + scheduled); newer
|
|
255
|
-
// clients can pass "scheduled" to load only the Scheduled section
|
|
256
|
-
//
|
|
257
|
-
|
|
253
|
+
// clients can pass "scheduled" to load only the Scheduled section, or "all"
|
|
254
|
+
// to page a user's whole history in one cursor. Absent defaults to the
|
|
255
|
+
// standard foreground list.
|
|
256
|
+
const conversationType: ConversationListTypeFilter =
|
|
258
257
|
parseEnumQueryParam(queryParams, "conversationType", [
|
|
259
258
|
"background",
|
|
260
259
|
"scheduled",
|
|
260
|
+
"all",
|
|
261
261
|
]) ?? "standard";
|
|
262
262
|
// Defaults to `active` so sidebar restores no longer pull archived rows.
|
|
263
263
|
// The Archive page opts into `archived` to render only archived rows
|
|
@@ -294,7 +294,9 @@ function handleListConversations({
|
|
|
294
294
|
read withholds them, so they appear in their own section and nowhere
|
|
295
295
|
else. Confined to `standard`: the background and scheduled buckets are
|
|
296
296
|
back-compat umbrellas addressed by conversation type, and narrowing them
|
|
297
|
-
by source would drop rows their callers still page through
|
|
297
|
+
by source would drop rows their callers still page through, while `all`
|
|
298
|
+
is a whole-history read with no second section to defer those rows to,
|
|
299
|
+
so withholding there would simply lose them. Confined to
|
|
298
300
|
active reads too: the Archive view asks `archiveStatus=archived` with no
|
|
299
301
|
`groupId`, and the section only ever lists active rows, so withholding
|
|
300
302
|
there would leave an archived section thread visible nowhere. */
|
|
@@ -584,10 +586,7 @@ function handleMarkUnread({ body = {}, headers }: RouteHandlerArgs) {
|
|
|
584
586
|
}
|
|
585
587
|
}
|
|
586
588
|
|
|
587
|
-
function handleGetConversation({
|
|
588
|
-
pathParams = {},
|
|
589
|
-
headers,
|
|
590
|
-
}: RouteHandlerArgs) {
|
|
589
|
+
function handleGetConversation({ pathParams = {}, headers }: RouteHandlerArgs) {
|
|
591
590
|
const detail = buildConversationDetailResponse(
|
|
592
591
|
pathParams.id!,
|
|
593
592
|
localeFromAcceptLanguage(headers?.["accept-language"]),
|
|
@@ -633,8 +632,11 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
633
632
|
type: "string",
|
|
634
633
|
required: false,
|
|
635
634
|
description:
|
|
636
|
-
'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella),
|
|
637
|
-
schema: {
|
|
635
|
+
'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at) descending) like every other list read, and pinned rows are never appended to it.',
|
|
636
|
+
schema: {
|
|
637
|
+
type: "string",
|
|
638
|
+
enum: ["background", "scheduled", "all"],
|
|
639
|
+
},
|
|
638
640
|
},
|
|
639
641
|
{
|
|
640
642
|
name: "archiveStatus",
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
} from "../../config/schemas/llm.js";
|
|
66
66
|
import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
|
|
67
67
|
import { ServiceModeSchema } from "../../config/schemas/services.js";
|
|
68
|
+
import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
|
|
68
69
|
import {
|
|
69
70
|
describeShadowedConfigSet,
|
|
70
71
|
findSubstrateShadowing,
|
|
@@ -2108,6 +2109,9 @@ async function handleSearchConversations({
|
|
|
2108
2109
|
query: q,
|
|
2109
2110
|
limit,
|
|
2110
2111
|
maxMessagesPerConversation: maxMessages,
|
|
2112
|
+
// Under `sidebar-done` a done chat is filed rather than hidden, so the
|
|
2113
|
+
// agent's own history search reaches it the same way the user's does.
|
|
2114
|
+
includeArchived: isSidebarDoneEnabled(),
|
|
2111
2115
|
});
|
|
2112
2116
|
return { query: q, results };
|
|
2113
2117
|
}
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
supportsHostProxy,
|
|
43
43
|
} from "../../channels/types.js";
|
|
44
44
|
import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
|
|
45
|
-
import {
|
|
45
|
+
import { getConversationProfilesForProvider } from "../../config/default-profile-catalog.js";
|
|
46
46
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
47
47
|
import { getConfig } from "../../config/loader.js";
|
|
48
48
|
import { isModeSessionRecoveryHealthy } from "../../config/session-groups-gate.js";
|
|
@@ -1754,7 +1754,9 @@ export async function handleSendMessage(
|
|
|
1754
1754
|
}
|
|
1755
1755
|
if (requestedInferenceProfile !== undefined) {
|
|
1756
1756
|
const { llm } = getConfig();
|
|
1757
|
-
|
|
1757
|
+
// The conversation view: a sticky pin runs the main agent's turns, so a
|
|
1758
|
+
// profile whose model returns verdicts rather than text is not offered.
|
|
1759
|
+
const profiles = getConversationProfilesForProvider(
|
|
1758
1760
|
llm.profiles,
|
|
1759
1761
|
llm.defaultProvider ?? null,
|
|
1760
1762
|
);
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
getConfiguredProvider,
|
|
23
23
|
userMessage,
|
|
24
24
|
} from "../../providers/provider-send-message.js";
|
|
25
|
-
import { isMaxTokensStopReason } from "../../providers/stop-reasons.js";
|
|
26
25
|
import { getLogger } from "../../util/logger.js";
|
|
27
26
|
import { safeStringSlice } from "../../util/unicode.js";
|
|
28
27
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
@@ -73,56 +72,14 @@ function buildAppMetadataBlock(context: DictationContext): string {
|
|
|
73
72
|
].join("\n");
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
): string {
|
|
80
|
-
// Every line here is read on every hold, so the prompt says each thing
|
|
81
|
-
// once and leaves the model no reasoning to write.
|
|
82
|
-
const sections = [
|
|
83
|
-
"You are a voice input assistant. Given a speech transcription, classify it and, if it is dictation, clean it up.",
|
|
84
|
-
"",
|
|
85
|
-
"## Classification",
|
|
75
|
+
function buildDictationClassificationPrompt(body: DictationBody): string {
|
|
76
|
+
return [
|
|
77
|
+
"Classify the speech transcription without rewriting it.",
|
|
86
78
|
"dictation: the user is composing text to be typed as-is.",
|
|
87
|
-
"action: the user is asking an assistant to do something (send, message, open, search, create, schedule).
|
|
79
|
+
"action: the user is asking an assistant to do something (send, message, open, search, create, schedule).",
|
|
88
80
|
`Cursor in text field: ${body.context.cursorInTextField ? "yes" : "no"}. If yes, lean toward dictation unless the intent to command is clear.`,
|
|
89
|
-
"",
|
|
90
|
-
"## Cleanup",
|
|
91
|
-
"- Fix grammar, punctuation and capitalization; remove filler words",
|
|
92
|
-
"- Rewrite hedging into clear statements, keeping the speaker's meaning",
|
|
93
|
-
"- When the speaker enumerates items, lay them out as a list, one per line",
|
|
94
|
-
"- Keep the user's natural voice; do not over-formalize casual speech",
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
if (stylePrompt) {
|
|
98
|
-
sections.push(
|
|
99
|
-
"",
|
|
100
|
-
"## User Style (highest priority)",
|
|
101
|
-
"The user's own writing preferences. They override the tone guidance below.",
|
|
102
|
-
"",
|
|
103
|
-
stylePrompt,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
sections.push(
|
|
108
|
-
"",
|
|
109
|
-
"## Tone",
|
|
110
|
-
stylePrompt
|
|
111
|
-
? "Fallback guidance where the User Style above is silent:"
|
|
112
|
-
: "Adapt tone to the active application:",
|
|
113
|
-
"- Email: professional but warm, greetings and sign-offs where they fit",
|
|
114
|
-
"- Slack: casual and conversational",
|
|
115
|
-
"- Code editors: technical and concise",
|
|
116
|
-
"- Terminal: terse, command-like",
|
|
117
|
-
"- Messages: very casual, short sentences",
|
|
118
|
-
"- Notes and docs: neutral, clear writing",
|
|
119
|
-
"- Otherwise: the user's natural voice",
|
|
120
|
-
"The window title may name the recipient; adapt formality to the apparent relationship.",
|
|
121
|
-
"",
|
|
122
81
|
buildAppMetadataBlock(body.context),
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
return sections.join("\n");
|
|
82
|
+
].join("\n");
|
|
126
83
|
}
|
|
127
84
|
|
|
128
85
|
function buildCommandPrompt(body: DictationBody, stylePrompt?: string): string {
|
|
@@ -184,44 +141,10 @@ function buildCommandPrompt(body: DictationBody, stylePrompt?: string): string {
|
|
|
184
141
|
|
|
185
142
|
export function computeMaxTokens(inputLength: number): number {
|
|
186
143
|
const estimatedInputTokens = Math.ceil(inputLength / 3);
|
|
187
|
-
//
|
|
188
|
-
// argument, plus a `reasoning` string and the JSON scaffolding around both,
|
|
189
|
-
// so the budget has to cover well more than the input alone. `max_tokens` is
|
|
190
|
-
// a ceiling rather than a target: a call that stops short of it costs no
|
|
191
|
-
// extra latency.
|
|
144
|
+
// Leave room for the transformed selection and tool-call JSON.
|
|
192
145
|
return Math.max(512, estimatedInputTokens * 2 + 256);
|
|
193
146
|
}
|
|
194
147
|
|
|
195
|
-
export type CleanupRejection = "truncated";
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Decide whether the cleanup model's rewrite is safe to use as the payload.
|
|
199
|
-
*
|
|
200
|
-
* The rewrite replaces what the user actually said. A token-cap stop means the
|
|
201
|
-
* tool JSON was cut mid-argument, so however complete the `text` argument
|
|
202
|
-
* happens to look, it is a fragment; the raw transcript is the safer payload
|
|
203
|
-
* then. Unpolished beats wrong.
|
|
204
|
-
*
|
|
205
|
-
* Whether a shorter rewrite still preserved the meaning is the model's call
|
|
206
|
-
* (the prompt asks it to drop fillers and tighten phrasing), so there is no
|
|
207
|
-
* length check here: a deterministic ratio cannot tell a concise cleanup from
|
|
208
|
-
* a summary.
|
|
209
|
-
*/
|
|
210
|
-
export function resolveCleanedDictation(
|
|
211
|
-
raw: string,
|
|
212
|
-
cleaned: string,
|
|
213
|
-
stopReason: string | null | undefined,
|
|
214
|
-
): { text: string; rejected: CleanupRejection | null } {
|
|
215
|
-
const trimmed = cleaned.trim();
|
|
216
|
-
if (!trimmed) {
|
|
217
|
-
return { text: raw, rejected: null };
|
|
218
|
-
}
|
|
219
|
-
if (isMaxTokensStopReason(stopReason)) {
|
|
220
|
-
return { text: raw, rejected: "truncated" };
|
|
221
|
-
}
|
|
222
|
-
return { text: trimmed, rejected: null };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
148
|
interface DictationResult {
|
|
226
149
|
text: string;
|
|
227
150
|
mode: DictationMode;
|
|
@@ -263,174 +186,88 @@ async function handleDictation(body: DictationBody): Promise<DictationResult> {
|
|
|
263
186
|
return handleCommandMode(body, profile, profileMeta, stylePrompt);
|
|
264
187
|
}
|
|
265
188
|
|
|
266
|
-
|
|
267
|
-
|
|
189
|
+
const mode = await classifyDictation(body);
|
|
190
|
+
if (mode === "action") {
|
|
191
|
+
return {
|
|
192
|
+
text: body.transcription,
|
|
193
|
+
mode,
|
|
194
|
+
actionPlan: `User wants to: ${body.transcription}`,
|
|
195
|
+
...profileMeta,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
268
198
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
199
|
+
// Only explicit user-authored replacements may change dictated words.
|
|
200
|
+
const text = applyDictionary(
|
|
201
|
+
expandSnippets(body.transcription, profile.snippets),
|
|
202
|
+
profile.dictionary,
|
|
203
|
+
);
|
|
204
|
+
return { text, mode, ...profileMeta };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function classifyDictation(
|
|
208
|
+
body: DictationBody,
|
|
209
|
+
): Promise<"dictation" | "action"> {
|
|
272
210
|
try {
|
|
273
211
|
const provider = await getConfiguredProvider("interactionClassifier");
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
212
|
+
if (provider) {
|
|
213
|
+
const { signal, cleanup } = createTimeout(
|
|
214
|
+
DICTATION_CLASSIFICATION_TIMEOUT_MS,
|
|
277
215
|
);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
mode,
|
|
296
|
-
...profileMeta,
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const systemPrompt = buildCombinedDictationPrompt(body, stylePrompt);
|
|
301
|
-
const maxTokens = computeMaxTokens(transcription.length);
|
|
302
|
-
const { signal, cleanup } = createTimeout(
|
|
303
|
-
DICTATION_CLASSIFICATION_TIMEOUT_MS,
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
const response = await provider.sendMessage(
|
|
308
|
-
[userMessage(`Transcription: "${transcription}"`)],
|
|
309
|
-
{
|
|
310
|
-
tools: [
|
|
311
|
-
{
|
|
312
|
-
name: "process_dictation",
|
|
313
|
-
description: "Classify the voice input and return cleaned text",
|
|
314
|
-
input_schema: {
|
|
315
|
-
type: "object" as const,
|
|
316
|
-
properties: {
|
|
317
|
-
mode: {
|
|
318
|
-
type: "string",
|
|
319
|
-
enum: ["dictation", "action"],
|
|
320
|
-
description:
|
|
321
|
-
"dictation = user wants text inserted/cleaned up for typing. action = user wants the assistant to perform a task.",
|
|
322
|
-
},
|
|
323
|
-
text: {
|
|
324
|
-
type: "string",
|
|
325
|
-
description:
|
|
326
|
-
"If dictation: the cleaned/formatted text ready for insertion. If action: the raw transcription unchanged.",
|
|
216
|
+
try {
|
|
217
|
+
const response = await provider.sendMessage(
|
|
218
|
+
[userMessage(body.transcription)],
|
|
219
|
+
{
|
|
220
|
+
tools: [
|
|
221
|
+
{
|
|
222
|
+
name: "process_dictation",
|
|
223
|
+
description: "Classify the voice input",
|
|
224
|
+
input_schema: {
|
|
225
|
+
type: "object" as const,
|
|
226
|
+
properties: {
|
|
227
|
+
mode: {
|
|
228
|
+
type: "string",
|
|
229
|
+
enum: ["dictation", "action"],
|
|
230
|
+
description:
|
|
231
|
+
"dictation = user wants text inserted. action = user wants the assistant to perform a task.",
|
|
232
|
+
},
|
|
327
233
|
},
|
|
234
|
+
required: ["mode"],
|
|
328
235
|
},
|
|
329
|
-
required: ["mode", "text"],
|
|
330
236
|
},
|
|
237
|
+
],
|
|
238
|
+
systemPrompt: buildDictationClassificationPrompt(body),
|
|
239
|
+
config: {
|
|
240
|
+
callSite: "interactionClassifier",
|
|
241
|
+
max_tokens: 512,
|
|
242
|
+
tool_choice: { type: "tool" as const, name: "process_dictation" },
|
|
331
243
|
},
|
|
332
|
-
|
|
333
|
-
systemPrompt,
|
|
334
|
-
config: {
|
|
335
|
-
callSite: "interactionClassifier",
|
|
336
|
-
max_tokens: maxTokens,
|
|
337
|
-
tool_choice: {
|
|
338
|
-
type: "tool" as const,
|
|
339
|
-
name: "process_dictation",
|
|
340
|
-
},
|
|
244
|
+
signal,
|
|
341
245
|
},
|
|
342
|
-
signal,
|
|
343
|
-
},
|
|
344
|
-
);
|
|
345
|
-
cleanup();
|
|
346
|
-
|
|
347
|
-
const toolBlock = extractToolUse(response);
|
|
348
|
-
if (toolBlock) {
|
|
349
|
-
const input = toolBlock.input as {
|
|
350
|
-
mode?: string;
|
|
351
|
-
text?: string;
|
|
352
|
-
};
|
|
353
|
-
const mode: DictationMode =
|
|
354
|
-
input.mode === "action" ? "action" : "dictation";
|
|
355
|
-
log.info(
|
|
356
|
-
{
|
|
357
|
-
mode,
|
|
358
|
-
modelMs: Date.now() - modelStartedAt,
|
|
359
|
-
inChars: transcription.length,
|
|
360
|
-
outChars: input.text?.length ?? 0,
|
|
361
|
-
},
|
|
362
|
-
"LLM dictation classify+clean",
|
|
363
246
|
);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
actionPlan: `User wants to: ${body.transcription}`,
|
|
370
|
-
...profileMeta,
|
|
371
|
-
};
|
|
247
|
+
const input = extractToolUse(response)?.input as
|
|
248
|
+
| { mode?: string }
|
|
249
|
+
| undefined;
|
|
250
|
+
if (input?.mode === "action" || input?.mode === "dictation") {
|
|
251
|
+
return input.mode;
|
|
372
252
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
input.text ?? "",
|
|
376
|
-
response.stopReason,
|
|
377
|
-
);
|
|
378
|
-
if (rejected) {
|
|
379
|
-
// Lengths only -- transcript content must never be logged.
|
|
380
|
-
log.warn(
|
|
381
|
-
{
|
|
382
|
-
rejected,
|
|
383
|
-
stopReason: response.stopReason,
|
|
384
|
-
rawChars: transcription.length,
|
|
385
|
-
cleanedChars: input.text?.trim().length ?? 0,
|
|
386
|
-
},
|
|
387
|
-
"Dictation cleanup rejected, using raw transcription",
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
const normalizedText = applyDictionary(cleanedText, profile.dictionary);
|
|
391
|
-
return {
|
|
392
|
-
text: normalizedText,
|
|
393
|
-
mode: "dictation",
|
|
394
|
-
...profileMeta,
|
|
395
|
-
};
|
|
253
|
+
} finally {
|
|
254
|
+
cleanup();
|
|
396
255
|
}
|
|
397
|
-
|
|
398
|
-
log.warn(
|
|
399
|
-
{ modelMs: Date.now() - modelStartedAt },
|
|
400
|
-
"No tool_use block in combined dictation call, using heuristic",
|
|
401
|
-
);
|
|
402
|
-
} finally {
|
|
403
|
-
cleanup();
|
|
404
256
|
}
|
|
405
257
|
} catch (err) {
|
|
406
258
|
const message = err instanceof Error ? err.message : String(err);
|
|
407
259
|
log.warn(
|
|
408
|
-
{ err: message
|
|
409
|
-
"
|
|
260
|
+
{ err: message },
|
|
261
|
+
"Dictation classification failed, using heuristic",
|
|
410
262
|
);
|
|
411
263
|
}
|
|
412
264
|
|
|
413
|
-
|
|
414
|
-
const fallbackMode = detectDictationModeHeuristic({
|
|
265
|
+
const mode = detectDictationModeHeuristic({
|
|
415
266
|
type: "dictation_request",
|
|
416
267
|
transcription: body.transcription,
|
|
417
268
|
context: body.context,
|
|
418
269
|
} as DictationRequest);
|
|
419
|
-
|
|
420
|
-
if (fallbackMode === "action") {
|
|
421
|
-
return {
|
|
422
|
-
text: body.transcription,
|
|
423
|
-
mode: "action",
|
|
424
|
-
actionPlan: `User wants to: ${body.transcription}`,
|
|
425
|
-
...profileMeta,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
const normalizedText = applyDictionary(transcription, profile.dictionary);
|
|
429
|
-
return {
|
|
430
|
-
text: normalizedText,
|
|
431
|
-
mode: fallbackMode,
|
|
432
|
-
...profileMeta,
|
|
433
|
-
};
|
|
270
|
+
return mode === "action" ? "action" : "dictation";
|
|
434
271
|
}
|
|
435
272
|
|
|
436
273
|
async function handleCommandMode(
|
|
@@ -560,7 +397,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
560
397
|
},
|
|
561
398
|
summary: "Process dictation",
|
|
562
399
|
description:
|
|
563
|
-
"
|
|
400
|
+
"Preserve dictated words with explicit dictionary and snippet replacements, classify actions, or edit selected text when instructed.",
|
|
564
401
|
tags: ["diagnostics"],
|
|
565
402
|
requestBody: DictationRequestSchema,
|
|
566
403
|
responseBody: z.object({
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
|
|
13
13
|
import { getConfig } from "../../config/loader.js";
|
|
14
|
+
import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
|
|
14
15
|
import { searchContacts } from "../../contacts/contact-store.js";
|
|
15
16
|
import { searchConversations } from "../../persistence/conversation-queries.js";
|
|
16
17
|
import { getMemorySqlite } from "../../persistence/db-connection.js";
|
|
@@ -123,23 +124,32 @@ function parseCategories(raw: string | undefined): Set<Category> {
|
|
|
123
124
|
* cleaned term as a literal query, so unknown filters are visible to lexical
|
|
124
125
|
* matching rather than silently dropped.
|
|
125
126
|
*
|
|
126
|
-
*
|
|
127
|
+
* `defaultArchived` is what an unfiltered query means. It is `false` by
|
|
128
|
+
* default and `true` under the `sidebar-done` flag, where a done chat is a
|
|
129
|
+
* filed chat rather than a hidden one and search is how the user reaches it.
|
|
130
|
+
* Either way both filters still say exactly what they say, so `is:unarchived`
|
|
131
|
+
* is the opt-out whenever the default is on.
|
|
132
|
+
*
|
|
133
|
+
* Examples (with `defaultArchived: false`):
|
|
127
134
|
* "foo bar" -> { term: "foo bar", archived: false }
|
|
128
135
|
* "is:archived foo" -> { term: "foo", archived: true }
|
|
129
136
|
* "foo is:archived" -> { term: "foo", archived: true }
|
|
130
137
|
* "is:archived" -> { term: "", archived: true }
|
|
131
138
|
* "is:starred foo" -> { term: "is:starred foo", archived: false }
|
|
132
139
|
*/
|
|
133
|
-
function parseSearchQuery(
|
|
140
|
+
function parseSearchQuery(
|
|
141
|
+
rawQ: string | undefined,
|
|
142
|
+
defaultArchived: boolean,
|
|
143
|
+
): {
|
|
134
144
|
term: string;
|
|
135
145
|
archived: boolean;
|
|
136
146
|
} {
|
|
137
147
|
if (!rawQ) {
|
|
138
|
-
return { term: "", archived:
|
|
148
|
+
return { term: "", archived: defaultArchived };
|
|
139
149
|
}
|
|
140
150
|
const tokens = rawQ.split(/\s+/).filter((t) => t.length > 0);
|
|
141
151
|
const termTokens: string[] = [];
|
|
142
|
-
let archived =
|
|
152
|
+
let archived = defaultArchived;
|
|
143
153
|
for (const tok of tokens) {
|
|
144
154
|
const lower = tok.toLowerCase();
|
|
145
155
|
if (
|
|
@@ -291,11 +301,16 @@ async function handleGlobalSearch({
|
|
|
291
301
|
throw new BadRequestError("q query parameter is required");
|
|
292
302
|
}
|
|
293
303
|
|
|
294
|
-
// Pull the archive
|
|
304
|
+
// Pull the archive filter out of the query string itself (`is:archived` /
|
|
295
305
|
// `archive:yes` / etc.) so the user controls it the same way they control
|
|
296
306
|
// every other modifier: by typing it into the search box. The cleaned term
|
|
297
|
-
// is what the backends actually search on.
|
|
298
|
-
|
|
307
|
+
// is what the backends actually search on. Under `sidebar-done` the
|
|
308
|
+
// unfiltered default flips to including done chats, and `is:unarchived`
|
|
309
|
+
// becomes the token that narrows back to the live set.
|
|
310
|
+
const { term, archived: includeArchived } = parseSearchQuery(
|
|
311
|
+
rawQ,
|
|
312
|
+
isSidebarDoneEnabled(),
|
|
313
|
+
);
|
|
299
314
|
|
|
300
315
|
const limit = Math.max(1, Math.min(Number(queryParams.limit ?? 20), 100));
|
|
301
316
|
const categories = parseCategories(queryParams.categories);
|
|
@@ -24,8 +24,10 @@ import {
|
|
|
24
24
|
CHATGPT_SUBSCRIPTION_CONNECTION_NAME,
|
|
25
25
|
type ConnectionModel,
|
|
26
26
|
ConnectionModelSchema,
|
|
27
|
+
type ConnectionProvider,
|
|
27
28
|
ConnectionProviderSchema,
|
|
28
29
|
deriveAuthForProvider,
|
|
30
|
+
normalizeConnectionProvider,
|
|
29
31
|
ProviderConnectionSchema,
|
|
30
32
|
PROVIDERS_ALLOWING_CUSTOM_BASE_URL,
|
|
31
33
|
VALID_CONNECTION_PROVIDERS,
|
|
@@ -379,8 +381,15 @@ async function handleCreateConnection({ body = {} }: RouteHandlerArgs) {
|
|
|
379
381
|
`Provider "chatgpt" is reserved for the "${CHATGPT_SUBSCRIPTION_CONNECTION_NAME}" connection. Run the ChatGPT sign-in flow to connect a subscription.`,
|
|
380
382
|
);
|
|
381
383
|
}
|
|
384
|
+
// Resolved before auth derivation so an opencode.ai endpoint is held to
|
|
385
|
+
// OpenCode's keyed auth rather than the keyless option the generic
|
|
386
|
+
// provider allows.
|
|
387
|
+
const resolvedProvider = normalizeConnectionProvider(
|
|
388
|
+
providerResult.data,
|
|
389
|
+
body.base_url,
|
|
390
|
+
) as ConnectionProvider;
|
|
382
391
|
const authResult = AuthSchema.safeParse(
|
|
383
|
-
auth ?? deriveConnectionAuth(
|
|
392
|
+
auth ?? deriveConnectionAuth(resolvedProvider, body.credential),
|
|
384
393
|
);
|
|
385
394
|
if (!authResult.success) {
|
|
386
395
|
throw new BadRequestError(`Invalid auth: ${authResult.error.message}`);
|
|
@@ -388,7 +397,7 @@ async function handleCreateConnection({ body = {} }: RouteHandlerArgs) {
|
|
|
388
397
|
// Asserted on derived auth as well: derivation pairs every provider
|
|
389
398
|
// correctly except the chatgpt identity, whose fallthrough would mint
|
|
390
399
|
// api_key auth from a bare credential.
|
|
391
|
-
assertAuthMatchesProvider(
|
|
400
|
+
assertAuthMatchesProvider(resolvedProvider, authResult.data);
|
|
392
401
|
|
|
393
402
|
const labelRaw = body.label;
|
|
394
403
|
if (
|
|
@@ -401,18 +410,15 @@ async function handleCreateConnection({ body = {} }: RouteHandlerArgs) {
|
|
|
401
410
|
);
|
|
402
411
|
}
|
|
403
412
|
|
|
404
|
-
const customFields = await parseCustomProviderFields(
|
|
405
|
-
body,
|
|
406
|
-
providerResult.data,
|
|
407
|
-
);
|
|
413
|
+
const customFields = await parseCustomProviderFields(body, resolvedProvider);
|
|
408
414
|
|
|
409
415
|
// Same event-loop turn as the write: no await separates this check from
|
|
410
416
|
// createConnection, so concurrent requests cannot both pass it.
|
|
411
|
-
assertValidCustomProviderIdentity(
|
|
417
|
+
assertValidCustomProviderIdentity(resolvedProvider, labelRaw, name);
|
|
412
418
|
|
|
413
419
|
const result = createConnection(getDb(), {
|
|
414
420
|
name,
|
|
415
|
-
provider:
|
|
421
|
+
provider: resolvedProvider,
|
|
416
422
|
auth: authResult.data,
|
|
417
423
|
...(labelRaw !== undefined ? { label: labelRaw as string | null } : {}),
|
|
418
424
|
...customFields,
|
|
@@ -525,6 +531,17 @@ async function handleUpdateConnection({
|
|
|
525
531
|
);
|
|
526
532
|
}
|
|
527
533
|
const customFields = await parseCustomProviderFields(body, existing.provider);
|
|
534
|
+
// Provider is immutable on PATCH, and profiles bound to this row under the
|
|
535
|
+
// legacy `{provider, provider_connection}` shape declare its kind, so the
|
|
536
|
+
// row cannot be repointed at an endpoint that needs a different kind.
|
|
537
|
+
if (
|
|
538
|
+
normalizeConnectionProvider(existing.provider, customFields.baseUrl) !==
|
|
539
|
+
existing.provider
|
|
540
|
+
) {
|
|
541
|
+
throw new BadRequestError(
|
|
542
|
+
`base_url points at OpenCode, which needs an "opencode" connection. Create a new connection with provider "opencode" instead of repointing "${name}".`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
528
545
|
|
|
529
546
|
// Only a CHANGED label is validated: keeping a stored label — whatever it
|
|
530
547
|
// is — must never block unrelated edits (key rotation, models).
|