@vellumai/assistant 0.11.3-dev.202608102016.71d1d5b → 0.11.3-dev.202608102215.87736ca
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/openapi.yaml +3 -0
- package/package.json +1 -1
- package/src/__tests__/turn-events-store.test.ts +43 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +23 -1
- package/src/config/__tests__/default-provider.test.ts +6 -0
- package/src/config/default-profile-catalog.ts +79 -8
- package/src/config/default-profile-names.ts +4 -1
- package/src/config/default-provider-resolution.ts +4 -0
- package/src/config/schemas/llm.ts +9 -4
- package/src/live-voice/live-voice-archive.ts +2 -0
- package/src/persistence/llm-usage-store.ts +5 -3
- package/src/persistence/real-user-turn-filter.ts +27 -3
- package/src/plugins/defaults/memory/buffer-format.ts +5 -4
- package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
- package/src/plugins/defaults/memory/indexer.ts +3 -1
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
- package/src/plugins/defaults/memory/v3/prune.ts +2 -0
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
- package/src/runtime/pre-first-message-gate.ts +4 -0
- package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
- package/src/runtime/routes/default-provider-routes.ts +15 -0
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
- package/src/workspace/byok-default-profile-ensure.ts +8 -5
package/openapi.yaml
CHANGED
|
@@ -4826,6 +4826,7 @@ paths:
|
|
|
4826
4826
|
- gemini
|
|
4827
4827
|
- fireworks
|
|
4828
4828
|
- openrouter
|
|
4829
|
+
- chatgpt
|
|
4829
4830
|
- vellum
|
|
4830
4831
|
- together
|
|
4831
4832
|
- vercel-ai-gateway
|
|
@@ -34540,6 +34541,7 @@ components:
|
|
|
34540
34541
|
- gemini
|
|
34541
34542
|
- fireworks
|
|
34542
34543
|
- openrouter
|
|
34544
|
+
- chatgpt
|
|
34543
34545
|
- vellum
|
|
34544
34546
|
- together
|
|
34545
34547
|
- vercel-ai-gateway
|
|
@@ -34917,6 +34919,7 @@ components:
|
|
|
34917
34919
|
- gemini
|
|
34918
34920
|
- fireworks
|
|
34919
34921
|
- openrouter
|
|
34922
|
+
- chatgpt
|
|
34920
34923
|
- vellum
|
|
34921
34924
|
- together
|
|
34922
34925
|
- vercel-ai-gateway
|
package/package.json
CHANGED
|
@@ -331,3 +331,46 @@ describe("queryUnreportedTurnEvents", () => {
|
|
|
331
331
|
expect(() => stampTurnOutcome("no-such-message", "failed")).not.toThrow();
|
|
332
332
|
});
|
|
333
333
|
});
|
|
334
|
+
|
|
335
|
+
describe("unfinalized rows and turn accounting", () => {
|
|
336
|
+
test("an in-flight grouped tool-result row is not a real user turn", async () => {
|
|
337
|
+
const conv = createConversation({ conversationType: "standard" });
|
|
338
|
+
const first = await insertUserMessageAt(conv.id, "real question", 1000);
|
|
339
|
+
// The grouped tool-result row: user-role, reserved unfinalized while
|
|
340
|
+
// tool output streams, content a { ref } that does not contain the
|
|
341
|
+
// tool_result text the content exclusions match. Without the finalized
|
|
342
|
+
// predicate it counts as a phantom user turn until the fold.
|
|
343
|
+
const inflight = await insertUserMessageAt(
|
|
344
|
+
conv.id,
|
|
345
|
+
JSON.stringify({ ref: "conversations/conv/inflight/x.jsonl" }),
|
|
346
|
+
2000,
|
|
347
|
+
);
|
|
348
|
+
getDb().run(`UPDATE messages SET finalized = 0 WHERE id = '${inflight}'`);
|
|
349
|
+
const second = await insertUserMessageAt(conv.id, "second question", 3000);
|
|
350
|
+
|
|
351
|
+
const events = queryUnreportedTurnEvents(0, undefined, 100);
|
|
352
|
+
const ids = events
|
|
353
|
+
.filter((e) => e.conversationId === conv.id)
|
|
354
|
+
.map((e) => e.id);
|
|
355
|
+
|
|
356
|
+
expect(ids).toEqual([first, second]);
|
|
357
|
+
const byId = Object.fromEntries(events.map((e) => [e.id, e]));
|
|
358
|
+
expect(byId[first].turnIndex).toBe(1);
|
|
359
|
+
expect(byId[second].turnIndex).toBe(2);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a persisted empty-display user row still counts as a turn", async () => {
|
|
363
|
+
const conv = createConversation({ conversationType: "standard" });
|
|
364
|
+
const real = await insertUserMessageAt(conv.id, "real question", 1000);
|
|
365
|
+
// A hidden send persists its user row with [] content (displayContent "")
|
|
366
|
+
// and still runs a turn, so [] rows must stay in turn accounting.
|
|
367
|
+
const hidden = await insertUserMessageAt(conv.id, "[]", 2000);
|
|
368
|
+
|
|
369
|
+
const events = queryUnreportedTurnEvents(0, undefined, 100);
|
|
370
|
+
const ids = events
|
|
371
|
+
.filter((e) => e.conversationId === conv.id)
|
|
372
|
+
.map((e) => e.id);
|
|
373
|
+
|
|
374
|
+
expect(ids).toEqual([real, hidden]);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CODE_DEFAULT_PROFILE_ENTRIES,
|
|
8
8
|
getEffectiveProfile,
|
|
9
9
|
getEffectiveProfiles,
|
|
10
|
+
getEffectiveProfilesForProvider,
|
|
10
11
|
PROFILE_IMPLS,
|
|
11
12
|
resolveDefaultProfileForProvider,
|
|
12
13
|
} from "../default-profile-catalog.js";
|
|
@@ -333,7 +334,7 @@ describe("resolveDefaultProfileForProvider", () => {
|
|
|
333
334
|
expect(typeof entry?.model).toBe("string");
|
|
334
335
|
expect(entry?.provider).toBeDefined();
|
|
335
336
|
// Identity columns stamp no connection; BYOK columns always do.
|
|
336
|
-
if (entry?.provider === "vellum") {
|
|
337
|
+
if (entry?.provider === "vellum" || entry?.provider === "chatgpt") {
|
|
337
338
|
expect(entry?.provider_connection).toBeUndefined();
|
|
338
339
|
} else {
|
|
339
340
|
expect(entry?.provider_connection).toBeDefined();
|
|
@@ -343,6 +344,27 @@ describe("resolveDefaultProfileForProvider", () => {
|
|
|
343
344
|
}
|
|
344
345
|
});
|
|
345
346
|
|
|
347
|
+
test("the chatgpt column resolves Codex-pinned models with no connection stamp", () => {
|
|
348
|
+
const byKey: Record<string, string> = {
|
|
349
|
+
balanced: "gpt-5.6-luna",
|
|
350
|
+
"quality-optimized": "gpt-5.6-sol",
|
|
351
|
+
"cost-optimized": "gpt-5.6-luna",
|
|
352
|
+
"latency-optimized": "gpt-5.6-luna",
|
|
353
|
+
};
|
|
354
|
+
const effective = getEffectiveProfilesForProvider(undefined, dp("chatgpt"));
|
|
355
|
+
for (const [key, model] of Object.entries(byKey)) {
|
|
356
|
+
const entry = effective[key];
|
|
357
|
+
expect(entry?.provider).toBe("chatgpt");
|
|
358
|
+
expect(entry?.model).toBe(model);
|
|
359
|
+
expect(entry?.provider_connection).toBeUndefined();
|
|
360
|
+
expect(entry?.source).toBe("managed");
|
|
361
|
+
}
|
|
362
|
+
// Cost and Speed both opt fully out of reasoning.
|
|
363
|
+
expect(effective["cost-optimized"]?.effort).toBe("none");
|
|
364
|
+
expect(effective["latency-optimized"]?.effort).toBe("none");
|
|
365
|
+
expect(effective.balanced?.thinking?.enabled).toBe(true);
|
|
366
|
+
});
|
|
367
|
+
|
|
346
368
|
test("a provider without a named matrix column materializes from the shared BYOK templates", () => {
|
|
347
369
|
const entry = resolveDefaultProfileForProvider(
|
|
348
370
|
undefined,
|
|
@@ -157,6 +157,12 @@ describe("resolveDefaultConnectionName", () => {
|
|
|
157
157
|
);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
test("chatgpt resolves to the canonical subscription connection name", () => {
|
|
161
|
+
expect(resolveDefaultConnectionName({ provider: "chatgpt" })).toBe(
|
|
162
|
+
"chatgpt-subscription",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
160
166
|
test("every other provider resolves to its personal connection", () => {
|
|
161
167
|
expect(resolveDefaultConnectionName({ provider: "anthropic" })).toBe(
|
|
162
168
|
"anthropic-personal",
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isModelInCatalog,
|
|
5
5
|
} from "../providers/model-catalog.js";
|
|
6
6
|
import { resolveModelIntent } from "../providers/model-intents.js";
|
|
7
|
+
import { isCodexSubscriptionModel } from "../providers/openai/codex-models.js";
|
|
7
8
|
import type { ModelIntent } from "../providers/types.js";
|
|
8
9
|
import { getManagedUpstream } from "../providers/vellum-model-routing.js";
|
|
9
10
|
import {
|
|
@@ -31,7 +32,8 @@ import {
|
|
|
31
32
|
* structured as an intent × provider matrix: each default profile is an
|
|
32
33
|
* intent, and each provider that can serve default profiles has a concrete
|
|
33
34
|
* implementation of that intent (model, token budget, effort, thinking).
|
|
34
|
-
* The `vellum` column is the platform-managed implementation
|
|
35
|
+
* The `vellum` column is the platform-managed implementation and the
|
|
36
|
+
* `chatgpt` column is the ChatGPT-subscription implementation; the other
|
|
35
37
|
* columns are the BYOK implementations resolved through `llm.defaultProvider`
|
|
36
38
|
* on off-platform installs.
|
|
37
39
|
*
|
|
@@ -147,6 +149,69 @@ const VELLUM_PROFILE_IMPLS: ProfileImpls = {
|
|
|
147
149
|
},
|
|
148
150
|
};
|
|
149
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The `chatgpt` column: ChatGPT-subscription implementations, stamped
|
|
154
|
+
* `provider: "chatgpt"` so dispatch routes through the canonical
|
|
155
|
+
* `chatgpt-subscription` row via `resolveRoutingIdentity` with no pinned
|
|
156
|
+
* connection. Models are pinned (never intents): the intent tables are
|
|
157
|
+
* keyed by concrete dispatch providers, and the Codex endpoint serves only
|
|
158
|
+
* `CODEX_SUBSCRIPTION_MODEL_IDS`. Cost and Speed are identical
|
|
159
|
+
* implementations here: the subscription serves no tier cheaper or faster
|
|
160
|
+
* than luna, and both profiles advertise reasoning off.
|
|
161
|
+
*/
|
|
162
|
+
const CHATGPT_PROFILE_IMPLS: ProfileImpls = {
|
|
163
|
+
balanced: {
|
|
164
|
+
model: "gpt-5.6-luna",
|
|
165
|
+
provider: "chatgpt",
|
|
166
|
+
source: "managed",
|
|
167
|
+
label: "Balanced",
|
|
168
|
+
description: "Good balance of quality, cost, and speed",
|
|
169
|
+
// Matches the vellum column's Balanced (same model): the Codex path
|
|
170
|
+
// sends no max_output_tokens, so this only sizes internal budgeting.
|
|
171
|
+
maxTokens: 32000,
|
|
172
|
+
effort: "high",
|
|
173
|
+
thinking: { enabled: true, streamThinking: true },
|
|
174
|
+
contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS },
|
|
175
|
+
},
|
|
176
|
+
"quality-optimized": {
|
|
177
|
+
model: "gpt-5.6-sol",
|
|
178
|
+
provider: "chatgpt",
|
|
179
|
+
source: "managed",
|
|
180
|
+
label: "Quality",
|
|
181
|
+
description: "Best results with the most capable model",
|
|
182
|
+
maxTokens: 32000,
|
|
183
|
+
effort: "high",
|
|
184
|
+
thinking: { enabled: true, streamThinking: true },
|
|
185
|
+
contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS },
|
|
186
|
+
},
|
|
187
|
+
"cost-optimized": {
|
|
188
|
+
model: "gpt-5.6-luna",
|
|
189
|
+
provider: "chatgpt",
|
|
190
|
+
source: "managed",
|
|
191
|
+
label: "Cost",
|
|
192
|
+
description: "Cheapest responses, for high-volume work",
|
|
193
|
+
maxTokens: 8192,
|
|
194
|
+
effort: "none",
|
|
195
|
+
thinking: { enabled: false, streamThinking: false },
|
|
196
|
+
contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS },
|
|
197
|
+
},
|
|
198
|
+
"latency-optimized": {
|
|
199
|
+
model: "gpt-5.6-luna",
|
|
200
|
+
provider: "chatgpt",
|
|
201
|
+
source: "managed",
|
|
202
|
+
label: "Speed",
|
|
203
|
+
description: "Fastest responses, with reasoning turned off",
|
|
204
|
+
maxTokens: 8192,
|
|
205
|
+
// Explicit reasoning opt-out, matching the other columns: this profile
|
|
206
|
+
// advertises reasoning as off, and OpenAI-compat APIs default reasoning
|
|
207
|
+
// to "medium" when the field is omitted, so the opt-out has to be stated
|
|
208
|
+
// rather than implied.
|
|
209
|
+
effort: "none",
|
|
210
|
+
thinking: { enabled: false, streamThinking: false },
|
|
211
|
+
contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS },
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
150
215
|
/**
|
|
151
216
|
* The BYOK implementation of each default profile intent, shared by every
|
|
152
217
|
* non-vellum provider. The concrete model resolves per provider from the
|
|
@@ -219,7 +284,9 @@ export const PROFILE_IMPLS: Record<
|
|
|
219
284
|
provider,
|
|
220
285
|
provider === "vellum"
|
|
221
286
|
? VELLUM_PROFILE_IMPLS[key]
|
|
222
|
-
:
|
|
287
|
+
: provider === "chatgpt"
|
|
288
|
+
? CHATGPT_PROFILE_IMPLS[key]
|
|
289
|
+
: { ...BYOK_PROFILE_IMPLS[key], provider },
|
|
223
290
|
]),
|
|
224
291
|
) as Record<DefaultProfileProvider, DefaultProfileTemplate>,
|
|
225
292
|
]),
|
|
@@ -372,21 +439,23 @@ for (const key of DEFAULT_PROFILE_KEYS) {
|
|
|
372
439
|
`PROFILE_IMPLS[${key}][${provider}] must set exactly one of \`intent\` or \`model\`.`,
|
|
373
440
|
);
|
|
374
441
|
}
|
|
375
|
-
if (impl.provider
|
|
442
|
+
if (ROUTING_IDENTITY_PROVIDERS.has(impl.provider) && impl.model == null) {
|
|
376
443
|
throw new Error(
|
|
377
|
-
`PROFILE_IMPLS[${key}][${provider}] must pin a \`model\`:
|
|
378
|
-
`
|
|
444
|
+
`PROFILE_IMPLS[${key}][${provider}] must pin a \`model\`: routing ` +
|
|
445
|
+
`identities have no intent table, and the model selects the route.`,
|
|
379
446
|
);
|
|
380
447
|
}
|
|
381
448
|
if (impl.model != null) {
|
|
382
449
|
const routable =
|
|
383
450
|
impl.provider === "vellum"
|
|
384
451
|
? getManagedUpstream(impl.model) !== null
|
|
385
|
-
:
|
|
452
|
+
: impl.provider === "chatgpt"
|
|
453
|
+
? isCodexSubscriptionModel(impl.model)
|
|
454
|
+
: isModelInCatalog(impl.provider, impl.model);
|
|
386
455
|
if (!routable) {
|
|
387
456
|
throw new Error(
|
|
388
457
|
`PROFILE_IMPLS[${key}][${provider}] references model "${impl.model}" ` +
|
|
389
|
-
`which is not ${impl.provider === "vellum" ? "served by any managed upstream" : `in PROVIDER_CATALOG for provider "${impl.provider}"`}. ` +
|
|
458
|
+
`which is not ${impl.provider === "vellum" ? "served by any managed upstream" : impl.provider === "chatgpt" ? "in CODEX_SUBSCRIPTION_MODEL_IDS" : `in PROVIDER_CATALOG for provider "${impl.provider}"`}. ` +
|
|
390
459
|
`Update model-catalog.ts or default-profile-catalog.ts.`,
|
|
391
460
|
);
|
|
392
461
|
}
|
|
@@ -517,7 +586,9 @@ function resolveAgainstBody(
|
|
|
517
586
|
* Non-obvious rules:
|
|
518
587
|
*
|
|
519
588
|
* - The `vellum` column stamps `provider: "vellum"` with no connection —
|
|
520
|
-
* dispatch derives the upstream from the model per-request.
|
|
589
|
+
* dispatch derives the upstream from the model per-request. The `chatgpt`
|
|
590
|
+
* column likewise stamps its routing identity with no connection;
|
|
591
|
+
* dispatch resolves the canonical subscription row per-request.
|
|
521
592
|
* - A default provider without a named matrix column materializes from the
|
|
522
593
|
* shared `BYOK_PROFILE_IMPLS` templates, with `resolveModelIntent`
|
|
523
594
|
* falling back to the provider's catalog `defaultModel`.
|
|
@@ -38,7 +38,9 @@ export const OS_BETA_PROFILE_KEY = "os-beta";
|
|
|
38
38
|
/**
|
|
39
39
|
* The named columns of the intent × provider matrix. `vellum` is the
|
|
40
40
|
* platform-managed column (routed through the single `vellum` connection to
|
|
41
|
-
* an underlying provider per profile)
|
|
41
|
+
* an underlying provider per profile) and `chatgpt` is the
|
|
42
|
+
* ChatGPT-subscription column (routed through the `chatgpt-subscription`
|
|
43
|
+
* connection to the Codex endpoint); the rest are BYOK columns whose
|
|
42
44
|
* models resolve per provider via `resolveModelIntent`. The full set of
|
|
43
45
|
* providers that can back `llm.defaultProvider` is wider, see
|
|
44
46
|
* `DEFAULT_PROVIDER_CHOICES` in `schemas/llm.ts`.
|
|
@@ -53,6 +55,7 @@ export const DEFAULT_PROFILE_PROVIDERS = [
|
|
|
53
55
|
"gemini",
|
|
54
56
|
"fireworks",
|
|
55
57
|
"openrouter",
|
|
58
|
+
"chatgpt",
|
|
56
59
|
"vellum",
|
|
57
60
|
] as const;
|
|
58
61
|
export type DefaultProfileProvider = (typeof DEFAULT_PROFILE_PROVIDERS)[number];
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* conveniences (`getDefaultProvider()` without an argument,
|
|
7
7
|
* `setDefaultProvider`) live in `default-provider.ts`.
|
|
8
8
|
*/
|
|
9
|
+
import { CHATGPT_SUBSCRIPTION_CONNECTION_NAME } from "../providers/inference/auth.js";
|
|
9
10
|
import { VELLUM_MANAGED_CONNECTION_NAME } from "../providers/vellum-model-routing.js";
|
|
10
11
|
import type { DefaultProviderConfig } from "./schemas/llm.js";
|
|
11
12
|
import type { AssistantConfig } from "./types.js";
|
|
@@ -29,5 +30,8 @@ export function resolveDefaultConnectionName(
|
|
|
29
30
|
if (dp.provider === "vellum") {
|
|
30
31
|
return VELLUM_MANAGED_CONNECTION_NAME;
|
|
31
32
|
}
|
|
33
|
+
if (dp.provider === "chatgpt") {
|
|
34
|
+
return CHATGPT_SUBSCRIPTION_CONNECTION_NAME;
|
|
35
|
+
}
|
|
32
36
|
return `${dp.provider}-personal`;
|
|
33
37
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
PROVIDERS_REQUIRING_BASE_URL_AND_MODELS,
|
|
5
|
+
ROUTING_IDENTITY_PROVIDERS,
|
|
6
|
+
} from "../../providers/inference/auth.js";
|
|
4
7
|
import { PROVIDER_CATALOG } from "../../providers/model-catalog.js";
|
|
5
8
|
import { isCodexSubscriptionModel } from "../../providers/openai/codex-models.js";
|
|
6
9
|
import {
|
|
@@ -84,13 +87,15 @@ export function isDefaultProviderChoice(value: string): value is LLMProvider {
|
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
89
|
* Default-provider choices whose profiles materialize from the shared BYOK
|
|
87
|
-
* templates: every choice except the
|
|
88
|
-
* defaults are pinned
|
|
90
|
+
* templates: every choice except the routing identities (`vellum`,
|
|
91
|
+
* `chatgpt`), whose defaults are pinned models on code-owned columns.
|
|
89
92
|
*/
|
|
90
93
|
export function isByokDefaultProviderChoice(
|
|
91
94
|
value: string,
|
|
92
95
|
): value is LLMProvider {
|
|
93
|
-
return
|
|
96
|
+
return (
|
|
97
|
+
!ROUTING_IDENTITY_PROVIDERS.has(value) && isDefaultProviderChoice(value)
|
|
98
|
+
);
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
const DefaultProviderEnum = z.enum(
|
|
@@ -240,6 +240,8 @@ function isArtifactMetadata(
|
|
|
240
240
|
function readMessageMetadata(
|
|
241
241
|
messageId: string,
|
|
242
242
|
): MessageMetadataState | "not_found" | "invalid_metadata" {
|
|
243
|
+
// Any-state read, deliberately: metadata exists from row insert and the id
|
|
244
|
+
// names a row this archive flow already owns; completeness is irrelevant.
|
|
243
245
|
const row = rawGet<{ metadata: string | null }>(
|
|
244
246
|
"liveVoice:readMessageMetadata",
|
|
245
247
|
`SELECT metadata FROM messages WHERE id = ?`,
|
|
@@ -10,7 +10,10 @@ import type {
|
|
|
10
10
|
import { APP_VERSION } from "../version.js";
|
|
11
11
|
import { getDb } from "./db-connection.js";
|
|
12
12
|
import { rawAll } from "./raw-query.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
realUserTurnContentFilter,
|
|
15
|
+
realUserTurnContentFilterSql,
|
|
16
|
+
} from "./real-user-turn-filter.js";
|
|
14
17
|
import {
|
|
15
18
|
buildScheduleAttributionSubquery,
|
|
16
19
|
buildScheduleRunWindowExists,
|
|
@@ -882,8 +885,7 @@ export function getUsageGroupBreakdown(
|
|
|
882
885
|
SELECT COUNT(*) FROM messages AS m2
|
|
883
886
|
WHERE m2.conversation_id = e.conversation_id
|
|
884
887
|
AND m2.role = 'user'
|
|
885
|
-
AND m2
|
|
886
|
-
AND m2.content NOT LIKE '%"type":"web\\_search\\_tool\\_result"%' ESCAPE '\\'
|
|
888
|
+
AND ${realUserTurnContentFilterSql("m2")}
|
|
887
889
|
)
|
|
888
890
|
END AS turn_count
|
|
889
891
|
FROM llm_usage_events e
|
|
@@ -19,8 +19,32 @@ import { sql } from "drizzle-orm";
|
|
|
19
19
|
export function realUserTurnContentFilter(
|
|
20
20
|
alias: string,
|
|
21
21
|
): ReturnType<typeof sql> {
|
|
22
|
-
return sql.raw(
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
return sql.raw(realUserTurnContentFilterSql(alias));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Raw-string form of {@link realUserTurnContentFilter}, for queries built as
|
|
27
|
+
* plain SQL template strings (`rawAll` sites). Same fragment, one source.
|
|
28
|
+
*
|
|
29
|
+
* `finalized = 1`: the grouped tool-result row is a USER-role row reserved
|
|
30
|
+
* in-flight (`ensureToolResultRowReserved`), and while it streams its content
|
|
31
|
+
* is a `{ ref }` pointer that does not contain the tool_result text the
|
|
32
|
+
* exclusions match. Without this predicate it counts as a real user turn
|
|
33
|
+
* mid-tool-execution and stops counting once the content folds inline.
|
|
34
|
+
*
|
|
35
|
+
* Bare `[]` content is deliberately NOT excluded: a hidden send persists its
|
|
36
|
+
* user row with empty display content (`processMessage` with
|
|
37
|
+
* `displayContent: ""`) and still runs a turn, so `[]` rows are legitimate
|
|
38
|
+
* turns. The cost is a narrow pre-existing window in the writer-creation
|
|
39
|
+
* fallback, where the tool-result row is born finalized with a `[]`
|
|
40
|
+
* placeholder and counts until the first content write lands; that window is
|
|
41
|
+
* rare (writer creation failure only) and transient, and closing it needs a
|
|
42
|
+
* signal that distinguishes the placeholder from a real empty-display turn.
|
|
43
|
+
*/
|
|
44
|
+
export function realUserTurnContentFilterSql(alias: string): string {
|
|
45
|
+
return (
|
|
46
|
+
`${alias}.finalized = 1 ` +
|
|
47
|
+
`AND ${alias}.content NOT LIKE '%"type":"tool\\_result"%' ESCAPE '\\' ` +
|
|
48
|
+
`AND ${alias}.content NOT LIKE '%"type":"web\\_search\\_tool\\_result"%' ESCAPE '\\'`
|
|
25
49
|
);
|
|
26
50
|
}
|
|
@@ -37,10 +37,11 @@ export interface BufferEntryStart {
|
|
|
37
37
|
* Anchored at column 0, requiring a space after the dash, and spelling the
|
|
38
38
|
* timestamp with the single spaces {@link formatBufferTimestamp} emits.
|
|
39
39
|
* Everything else in the file is a continuation line belonging to the entry
|
|
40
|
-
* above it, which is how a multiline fact keeps its body: `remember()`
|
|
41
|
-
* the
|
|
42
|
-
* a bullet carrying other bracketed text (a `- [ ]` checklist
|
|
43
|
-
* `- [[wikilink]]` bullet) read as continuation rather than as a new
|
|
40
|
+
* above it, which is how a multiline fact keeps its body: `remember()` writes
|
|
41
|
+
* only the opening line at column 0 and nests the body beneath it. That rule
|
|
42
|
+
* is what makes a bullet carrying other bracketed text (a `- [ ]` checklist
|
|
43
|
+
* item, a `- [[wikilink]]` bullet) read as continuation rather than as a new
|
|
44
|
+
* fact, and it is why a body line can never imitate an opening.
|
|
44
45
|
*
|
|
45
46
|
* Matching the writer's exact shape is the conservative choice in both
|
|
46
47
|
* directions: a line the writer could not have produced stays body text, so an
|
|
@@ -17,6 +17,9 @@ export async function loadImageRefData(
|
|
|
17
17
|
const msg = db
|
|
18
18
|
.select({ content: messages.content })
|
|
19
19
|
.from(messages)
|
|
20
|
+
// No completeness predicate: refs come from the extracted memory graph,
|
|
21
|
+
// and extraction reads finalized rows only, so an unfinalized id cannot
|
|
22
|
+
// reach here. Do not copy this shape for ids with other provenance.
|
|
20
23
|
.where(eq(messages.id, ref.messageId))
|
|
21
24
|
.get();
|
|
22
25
|
if (!msg) {
|
|
@@ -123,7 +123,9 @@ export async function indexMessageNow(
|
|
|
123
123
|
// memory_segments has no cross-file FK to messages, so this call must not
|
|
124
124
|
// leave pieces for a message with no row. Skip early when the source
|
|
125
125
|
// row is already gone, the common case of a backfill job running after the
|
|
126
|
-
// message was deleted.
|
|
126
|
+
// message was deleted. Any-state existence check, deliberately: a
|
|
127
|
+
// streaming row exists, and orphan prevention is about deletion, not
|
|
128
|
+
// completeness. A post-write re-check below closes the narrower window
|
|
127
129
|
// where the delete lands mid-transaction.
|
|
128
130
|
const sourceMessage = getDb()
|
|
129
131
|
.select({ id: messages.id })
|
|
@@ -1365,7 +1365,9 @@ function resolveConversationTimestamp(conversationId: string): number | null {
|
|
|
1365
1365
|
const db = getDb();
|
|
1366
1366
|
// Use the last message timestamp, not the conversation creation time.
|
|
1367
1367
|
// A conversation can span hours/days — memories should be timestamped
|
|
1368
|
-
// to when the relevant content was actually discussed.
|
|
1368
|
+
// to when the relevant content was actually discussed. Any-state read,
|
|
1369
|
+
// deliberately: only `createdAt` is consumed, and a streaming last row
|
|
1370
|
+
// still marks when discussion happened.
|
|
1369
1371
|
const lastMsg = db
|
|
1370
1372
|
.select({ createdAt: messages.createdAt })
|
|
1371
1373
|
.from(messages)
|
|
@@ -293,6 +293,8 @@ export function collectPersistedV3Cards(conversationId: string): Set<string> {
|
|
|
293
293
|
const rows = getSqliteFrom(getDb())
|
|
294
294
|
.query(
|
|
295
295
|
/*sql*/ `
|
|
296
|
+
-- Any-state scan: only metadata markers are read, and the marker is
|
|
297
|
+
-- written by the injection path on rows it owns regardless of state.
|
|
296
298
|
SELECT metadata FROM messages
|
|
297
299
|
WHERE conversation_id = ? AND metadata LIKE '%' || ? || '%'
|
|
298
300
|
`,
|
|
@@ -97,6 +97,8 @@ function forkSourceIdsOf(messageIds: string[]): string[] {
|
|
|
97
97
|
.query(
|
|
98
98
|
/*sql*/ `
|
|
99
99
|
SELECT json_extract(metadata, '$.forkSourceMessageId') AS src
|
|
100
|
+
-- No completeness predicate: ids come from this store's own selection
|
|
101
|
+
-- log, written for rows the injection pipeline already processed.
|
|
100
102
|
FROM messages
|
|
101
103
|
WHERE id IN (${placeholders})
|
|
102
104
|
`,
|
|
@@ -55,9 +55,13 @@ export function hasReceivedUserMessage(): boolean {
|
|
|
55
55
|
try {
|
|
56
56
|
const row = rawGet<{ one: number }>(
|
|
57
57
|
"preFirstMsg:hasReceivedUserMessage",
|
|
58
|
+
// Finalized user-role rows only: the grouped tool-result row is a
|
|
59
|
+
// user-role row reserved unfinalized mid-turn, and this gate must not
|
|
60
|
+
// read tool plumbing as "the user has messaged this assistant".
|
|
58
61
|
`SELECT 1 AS one FROM messages m
|
|
59
62
|
JOIN conversations c ON m.conversation_id = c.id
|
|
60
63
|
WHERE m.role = 'user'
|
|
64
|
+
AND m.finalized = 1
|
|
61
65
|
AND c.conversation_type = 'standard'
|
|
62
66
|
LIMIT 1`,
|
|
63
67
|
);
|
|
@@ -432,6 +432,31 @@ describe("PUT config/llm/default-provider", () => {
|
|
|
432
432
|
expect(availability(result)).toEqual({ status: "ok" });
|
|
433
433
|
});
|
|
434
434
|
|
|
435
|
+
test("chatgpt persists and resolves the canonical subscription row", async () => {
|
|
436
|
+
seedConnection({
|
|
437
|
+
name: "chatgpt-subscription",
|
|
438
|
+
provider: "chatgpt",
|
|
439
|
+
auth: {
|
|
440
|
+
type: "oauth_subscription",
|
|
441
|
+
credential: "credential/chatgpt/access_token",
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
secureKeyResults["credential/chatgpt/access_token"] = {
|
|
445
|
+
value: "token",
|
|
446
|
+
unreachable: false,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const result = (await put({ provider: "chatgpt" })) as Record<
|
|
450
|
+
string,
|
|
451
|
+
unknown
|
|
452
|
+
>;
|
|
453
|
+
|
|
454
|
+
expect(persistedDefaultProvider()).toEqual({ provider: "chatgpt" });
|
|
455
|
+
expect(result.provider).toBe("chatgpt");
|
|
456
|
+
expect(result.resolvedConnectionName).toBe("chatgpt-subscription");
|
|
457
|
+
expect(availability(result)).toEqual({ status: "ok" });
|
|
458
|
+
});
|
|
459
|
+
|
|
435
460
|
test("persists an explicit connectionName", async () => {
|
|
436
461
|
await put({ provider: "openai", connectionName: "work-openai" });
|
|
437
462
|
expect(persistedDefaultProvider()).toEqual({
|
|
@@ -449,6 +474,42 @@ describe("PUT config/llm/default-provider", () => {
|
|
|
449
474
|
expect(availability(result).status).toBe("missing_connection");
|
|
450
475
|
});
|
|
451
476
|
|
|
477
|
+
test("a noncanonical pin on a routing identity → 400 naming the canonical row", async () => {
|
|
478
|
+
for (const [provider, canonical] of [
|
|
479
|
+
["chatgpt", "chatgpt-subscription"],
|
|
480
|
+
["vellum", "vellum"],
|
|
481
|
+
] as const) {
|
|
482
|
+
const err = await put({
|
|
483
|
+
provider,
|
|
484
|
+
connectionName: "some-other-row",
|
|
485
|
+
}).catch((e: unknown) => e);
|
|
486
|
+
expect(String(err)).toContain(canonical);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("the canonical pin on a routing identity is accepted", async () => {
|
|
491
|
+
seedConnection({
|
|
492
|
+
name: "chatgpt-subscription",
|
|
493
|
+
provider: "chatgpt",
|
|
494
|
+
auth: {
|
|
495
|
+
type: "oauth_subscription",
|
|
496
|
+
credential: "credential/chatgpt/access_token",
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
secureKeyResults["credential/chatgpt/access_token"] = {
|
|
500
|
+
value: "token",
|
|
501
|
+
unreachable: false,
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const result = (await put({
|
|
505
|
+
provider: "chatgpt",
|
|
506
|
+
connectionName: "chatgpt-subscription",
|
|
507
|
+
})) as Record<string, unknown>;
|
|
508
|
+
|
|
509
|
+
expect(result.resolvedConnectionName).toBe("chatgpt-subscription");
|
|
510
|
+
expect(availability(result)).toEqual({ status: "ok" });
|
|
511
|
+
});
|
|
512
|
+
|
|
452
513
|
test("invalid provider → 400 naming the allowed providers", async () => {
|
|
453
514
|
const err = await put({ provider: "not-a-provider" }).catch(
|
|
454
515
|
(e: unknown) => e,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
DEFAULT_PROVIDER_CHOICES,
|
|
26
26
|
DefaultProviderSchema,
|
|
27
27
|
} from "../../config/schemas/llm.js";
|
|
28
|
+
import { ROUTING_IDENTITY_PROVIDERS } from "../../providers/inference/auth.js";
|
|
28
29
|
import {
|
|
29
30
|
computeConnectionAvailability,
|
|
30
31
|
CONNECTION_AVAILABILITY_STATUSES,
|
|
@@ -93,6 +94,20 @@ async function handlePutDefaultProvider({
|
|
|
93
94
|
)}; "connectionName" is optional and must be a non-empty string.`,
|
|
94
95
|
);
|
|
95
96
|
}
|
|
97
|
+
// Routing identities dispatch through their canonical row regardless of
|
|
98
|
+
// any stored pin (`resolveRoutingIdentity`), so a noncanonical
|
|
99
|
+
// connectionName would be judged by availability and the deletion guards
|
|
100
|
+
// while inference uses a different row. Reject it here rather than
|
|
101
|
+
// persisting a pin that status and dispatch disagree about.
|
|
102
|
+
const { provider, connectionName } = result.data;
|
|
103
|
+
if (connectionName != null && ROUTING_IDENTITY_PROVIDERS.has(provider)) {
|
|
104
|
+
const canonical = resolveDefaultConnectionName({ provider });
|
|
105
|
+
if (connectionName !== canonical) {
|
|
106
|
+
throw new BadRequestError(
|
|
107
|
+
`Provider "${provider}" always dispatches through its canonical connection "${canonical}". Omit "connectionName" or pass "${canonical}".`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
96
111
|
setDefaultProvider(result.data);
|
|
97
112
|
return handleGetDefaultProvider();
|
|
98
113
|
}
|
|
@@ -143,6 +143,9 @@ async function handleExport({
|
|
|
143
143
|
? [eq(messages.conversationId, conversationId)]
|
|
144
144
|
: [];
|
|
145
145
|
|
|
146
|
+
// Any-state export, deliberately: this is a diagnostic surface, and an
|
|
147
|
+
// in-flight row (finalized = 0, content a { ref } into the turn's delta
|
|
148
|
+
// file) is often exactly what a bug report needs to show.
|
|
146
149
|
const messageRows = capRows(
|
|
147
150
|
db
|
|
148
151
|
.select()
|
|
@@ -64,6 +64,9 @@ export async function resolveSurfaceConversation(
|
|
|
64
64
|
const escaped = surfaceId.replace(/[\\%_]/g, "\\$&");
|
|
65
65
|
const row = rawGet<{ conversation_id: string }>(
|
|
66
66
|
"surfaceResolver:resolveConversation",
|
|
67
|
+
// Unfinalized rows cannot match: their content is a { ref } pointer,
|
|
68
|
+
// never inline surface JSON, so the LIKE probes below see finalized
|
|
69
|
+
// content only by shape. No completeness predicate needed.
|
|
67
70
|
`SELECT conversation_id FROM messages
|
|
68
71
|
WHERE content LIKE ? ESCAPE '\\'
|
|
69
72
|
ORDER BY created_at DESC
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type LLMProvider,
|
|
19
19
|
type ProfileEntry,
|
|
20
20
|
} from "../config/schemas/llm.js";
|
|
21
|
+
import { ROUTING_IDENTITY_PROVIDERS } from "../providers/inference/auth.js";
|
|
21
22
|
import { getLogger } from "../util/logger.js";
|
|
22
23
|
import { completedProfileBody } from "./custom-profile-ensure.js";
|
|
23
24
|
|
|
@@ -267,11 +268,13 @@ export function ensureByokDefaultProfiles(workspaceDir: string): void {
|
|
|
267
268
|
const completionBase = LLMConfigBase.safeParse(llm.default ?? {}).data;
|
|
268
269
|
|
|
269
270
|
// The default provider on a BYOK default, or the uniform hatch provider
|
|
270
|
-
// across the complete copy set on a
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
271
|
+
// across the complete copy set on a routing-identity default (vellum,
|
|
272
|
+
// chatgpt); null converts nothing.
|
|
273
|
+
const candidateProvider = ROUTING_IDENTITY_PROVIDERS.has(
|
|
274
|
+
parsedDefault.data.provider,
|
|
275
|
+
)
|
|
276
|
+
? uniformCopyProvider(profiles)
|
|
277
|
+
: parsedDefault.data.provider;
|
|
275
278
|
const convertibleProvider =
|
|
276
279
|
candidateProvider !== null && isByokDefaultProviderChoice(candidateProvider)
|
|
277
280
|
? candidateProvider
|