@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.3-dev.202608102016.71d1d5b",
3
+ "version": "0.11.3-dev.202608102215.87736ca",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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; the other
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
- : { ...BYOK_PROFILE_IMPLS[key], provider },
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 === "vellum" && impl.model == null) {
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\`: the vellum ` +
378
- `column has no intent table, and the model selects the upstream.`,
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
- : isModelInCatalog(impl.provider, impl.model);
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); the rest are BYOK columns whose
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 { PROVIDERS_REQUIRING_BASE_URL_AND_MODELS } from "../../providers/inference/auth.js";
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 `vellum` routing identity, whose
88
- * defaults are pinned managed models.
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 value !== "vellum" && isDefaultProviderChoice(value);
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 { realUserTurnContentFilter } from "./real-user-turn-filter.js";
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.content NOT LIKE '%"type":"tool\\_result"%' ESCAPE '\\'
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
- `${alias}.content NOT LIKE '%"type":"tool\\_result"%' ESCAPE '\\' ` +
24
- `AND ${alias}.content NOT LIKE '%"type":"web\\_search\\_tool\\_result"%' ESCAPE '\\'`,
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()` stores
41
- * the body verbatim after the timestamped first line. That rule is what makes
42
- * a bullet carrying other bracketed text (a `- [ ]` checklist item, a
43
- * `- [[wikilink]]` bullet) read as continuation rather than as a new fact.
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. A post-write re-check below closes the narrower window
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 vellum default; null converts nothing.
271
- const candidateProvider =
272
- parsedDefault.data.provider !== "vellum"
273
- ? parsedDefault.data.provider
274
- : uniformCopyProvider(profiles);
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