@vellumai/assistant 0.12.5-dev.202609281517.719e2bb → 0.12.5-dev.202609281715.b73f696

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
@@ -8042,8 +8042,9 @@ paths:
8042
8042
  Filter by conversation type. Pass "background" to list background and scheduled conversations together (the
8043
8043
  back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the
8044
8044
  standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with
8045
- archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at)
8046
- descending) like every other list read, and pinned rows are never appended to it.
8045
+ archiveStatus=all for a whole-history read; rows are ordered by last activity (the later of the last message
8046
+ and the time the row was marked done, descending) like every other list read, and pinned rows are never
8047
+ appended to it.
8047
8048
  - name: archiveStatus
8048
8049
  in: query
8049
8050
  required: false
@@ -8083,8 +8084,9 @@ paths:
8083
8084
  description:
8084
8085
  Filter to a single group, so each sidebar section can load independently of the paginated list. Pass
8085
8086
  "system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A
8086
- group-scoped request is recency-ordered like every list read (COALESCE(last_message_at, updated_at)
8087
- descending) and never has pinned rows appended to it. Omit to span every group.
8087
+ group-scoped request is ordered by last activity like every list read (the later of the last message and the
8088
+ time the row was marked done, descending) and never has pinned rows appended to it. Omit to span every
8089
+ group.
8088
8090
  - name: needsAttention
8089
8091
  in: query
8090
8092
  required: false
@@ -8340,10 +8342,14 @@ paths:
8340
8342
  - sortPosition
8341
8343
  - isSystemGroup
8342
8344
  additionalProperties: false
8345
+ orderedBy:
8346
+ type: string
8347
+ const: lastActivity
8343
8348
  required:
8344
8349
  - conversations
8345
8350
  - nextOffset
8346
8351
  - hasMore
8352
+ - orderedBy
8347
8353
  additionalProperties: false
8348
8354
  post:
8349
8355
  operationId: conversations_post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.5-dev.202609281517.719e2bb",
3
+ "version": "0.12.5-dev.202609281715.b73f696",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -302,6 +302,13 @@ describe("ensureByokDefaultProfiles", () => {
302
302
  "cost-optimized",
303
303
  "accounts/fireworks/models/deepseek-v4-flash",
304
304
  ],
305
+ [
306
+ "fireworks",
307
+ "cost-optimized",
308
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
309
+ ],
310
+ ["fireworks", "balanced", "accounts/fireworks/models/deepseek-v4p1-flash"],
311
+ ["fireworks", "quality-optimized", "accounts/fireworks/models/kimi-k2p6"],
305
312
  ] as const)(
306
313
  "a historical intent-era copy converts (%s %s pinned to %s)",
307
314
  (provider, key, model) => {
@@ -1960,10 +1960,10 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
1960
1960
  lastCreateParams = null;
1961
1961
  });
1962
1962
 
1963
- test('DeepSeek V4 Pro accepts "max" unclamped', async () => {
1963
+ test('DeepSeek V4.1 Flash accepts "max" unclamped', async () => {
1964
1964
  const fw = new FireworksProvider(
1965
1965
  "fw-key",
1966
- "accounts/fireworks/models/deepseek-v4-pro-0813",
1966
+ "accounts/fireworks/models/deepseek-v4p1-flash",
1967
1967
  );
1968
1968
  await fw.sendMessage([userMsg("hi")], {
1969
1969
  systemPrompt: "system",
@@ -1972,10 +1972,10 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
1972
1972
  expect(lastCreateParams!.reasoning_effort).toBe("max");
1973
1973
  });
1974
1974
 
1975
- test('DeepSeek V4 Pro accepts "xhigh" unclamped', async () => {
1975
+ test('DeepSeek V4.1 Flash accepts "xhigh" unclamped', async () => {
1976
1976
  const fw = new FireworksProvider(
1977
1977
  "fw-key",
1978
- "accounts/fireworks/models/deepseek-v4-pro-0813",
1978
+ "accounts/fireworks/models/deepseek-v4p1-flash",
1979
1979
  );
1980
1980
  await fw.sendMessage([userMsg("hi")], {
1981
1981
  systemPrompt: "system",
@@ -1984,10 +1984,10 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
1984
1984
  expect(lastCreateParams!.reasoning_effort).toBe("xhigh");
1985
1985
  });
1986
1986
 
1987
- test('Kimi K2.6 clamps "xhigh"/"max" down to "high"', async () => {
1987
+ test('MiniMax M3 clamps "xhigh"/"max" down to "high"', async () => {
1988
1988
  const fw = new FireworksProvider(
1989
1989
  "fw-key",
1990
- "accounts/fireworks/models/kimi-k2p6",
1990
+ "accounts/fireworks/models/minimax-m3",
1991
1991
  );
1992
1992
  await fw.sendMessage([userMsg("hi")], {
1993
1993
  systemPrompt: "system",
@@ -2016,7 +2016,7 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
2016
2016
  test("effort below ceiling is forwarded verbatim", async () => {
2017
2017
  const fw = new FireworksProvider(
2018
2018
  "fw-key",
2019
- "accounts/fireworks/models/deepseek-v4-pro-0813",
2019
+ "accounts/fireworks/models/deepseek-v4p1-flash",
2020
2020
  );
2021
2021
  await fw.sendMessage([userMsg("hi")], {
2022
2022
  systemPrompt: "system",
@@ -2028,7 +2028,7 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
2028
2028
  test('effort: "none" passes through regardless of ceiling', async () => {
2029
2029
  const fw = new FireworksProvider(
2030
2030
  "fw-key",
2031
- "accounts/fireworks/models/kimi-k2p6",
2031
+ "accounts/fireworks/models/minimax-m3",
2032
2032
  );
2033
2033
  await fw.sendMessage([userMsg("hi")], {
2034
2034
  systemPrompt: "system",
@@ -2040,13 +2040,13 @@ describe("FireworksProvider reasoning_effort ceiling", () => {
2040
2040
  test("model override picks ceiling from runtime model, not constructor model", async () => {
2041
2041
  const fw = new FireworksProvider(
2042
2042
  "fw-key",
2043
- "accounts/fireworks/models/kimi-k2p6",
2043
+ "accounts/fireworks/models/minimax-m3",
2044
2044
  );
2045
2045
  await fw.sendMessage([userMsg("hi")], {
2046
2046
  systemPrompt: "system",
2047
2047
  config: {
2048
2048
  effort: "max",
2049
- model: "accounts/fireworks/models/deepseek-v4-pro-0813",
2049
+ model: "accounts/fireworks/models/deepseek-v4p1-flash",
2050
2050
  },
2051
2051
  });
2052
2052
  expect(lastCreateParams!.reasoning_effort).toBe("max");
@@ -2111,7 +2111,7 @@ describe("FireworksProvider sparse reasoning_effort support (GLM 5.3)", () => {
2111
2111
 
2112
2112
  test("models without supportedEfforts are unaffected", async () => {
2113
2113
  expect(
2114
- await send("accounts/fireworks/models/deepseek-v4-pro-0813", "medium"),
2114
+ await send("accounts/fireworks/models/deepseek-v4p1-flash", "medium"),
2115
2115
  ).toBe("medium");
2116
2116
  });
2117
2117
  });
@@ -0,0 +1,262 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { Database } from "bun:sqlite";
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { repairRetiredFireworksModelIdsMigration } from "../workspace/migrations/160-repair-retired-fireworks-model-ids.js";
14
+ import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
15
+ import { assertNotLiveDb } from "./assert-not-live-db.js";
16
+
17
+ const STALE = "accounts/fireworks/models/deepseek-v4-flash-0731";
18
+ const REPLACEMENT = "accounts/fireworks/models/deepseek-v4p1-flash";
19
+
20
+ let workspaceDir: string;
21
+
22
+ function freshWorkspace(): void {
23
+ workspaceDir = join(
24
+ tmpdir(),
25
+ `vellum-migration-160-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
26
+ );
27
+ mkdirSync(workspaceDir, { recursive: true });
28
+ }
29
+
30
+ function writeConfig(data: Record<string, unknown>): void {
31
+ writeFileSync(
32
+ join(workspaceDir, "config.json"),
33
+ JSON.stringify(data, null, 2) + "\n",
34
+ );
35
+ }
36
+
37
+ function readConfig(): Record<string, unknown> {
38
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
39
+ }
40
+
41
+ function seedRows(rows: Array<{ name: string; provider: string }>): void {
42
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
43
+ const db = new Database(join(workspaceDir, "data", "db", "assistant.db"));
44
+ db.run(`CREATE TABLE IF NOT EXISTS provider_connections (
45
+ name TEXT PRIMARY KEY,
46
+ provider TEXT NOT NULL,
47
+ auth TEXT NOT NULL,
48
+ created_at INTEGER NOT NULL,
49
+ updated_at INTEGER NOT NULL
50
+ )`);
51
+ for (const row of rows) {
52
+ db.query(
53
+ `INSERT INTO provider_connections (name, provider, auth, created_at, updated_at) VALUES (?, ?, '{"type":"api_key"}', 1, 1)`,
54
+ ).run(row.name, row.provider);
55
+ }
56
+ db.close();
57
+ }
58
+
59
+ beforeEach(() => {
60
+ freshWorkspace();
61
+ });
62
+
63
+ afterEach(() => {
64
+ if (existsSync(workspaceDir)) {
65
+ assertNotLiveDb(workspaceDir);
66
+ rmSync(workspaceDir, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ describe("160-repair-retired-fireworks-model-ids migration", () => {
71
+ test("has correct migration id and is registered", () => {
72
+ expect(repairRetiredFireworksModelIdsMigration.id).toBe(
73
+ "160-repair-retired-fireworks-model-ids",
74
+ );
75
+ expect(WORKSPACE_MIGRATIONS.map((m) => m.id)).toContain(
76
+ "160-repair-retired-fireworks-model-ids",
77
+ );
78
+ });
79
+
80
+ test("repairs the stale ID in default, call sites, and profiles", () => {
81
+ writeConfig({
82
+ llm: {
83
+ default: { provider: "fireworks", model: STALE },
84
+ callSites: {
85
+ recall: { model: STALE, maxTokens: 4096 },
86
+ heartbeat: { model: `${STALE}-tuned` },
87
+ malformed: STALE,
88
+ },
89
+ profiles: {
90
+ "cost-optimized": { provider: "vellum", model: STALE },
91
+ legacy: { model: STALE },
92
+ },
93
+ },
94
+ });
95
+
96
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
97
+
98
+ const llm = readConfig().llm as Record<string, any>;
99
+ expect(llm.default.model).toBe(REPLACEMENT);
100
+ expect(llm.callSites.recall.model).toBe(REPLACEMENT);
101
+ expect(llm.callSites.recall.maxTokens).toBe(4096);
102
+ // Non-exact matches and malformed leaves are untouched.
103
+ expect(llm.callSites.heartbeat.model).toBe(`${STALE}-tuned`);
104
+ expect(llm.callSites.malformed).toBe(STALE);
105
+ // Managed profiles stamped provider "vellum" carry Fireworks model IDs.
106
+ expect(llm.profiles["cost-optimized"].model).toBe(REPLACEMENT);
107
+ expect(llm.profiles.legacy.model).toBe(REPLACEMENT);
108
+ });
109
+
110
+ test.each([
111
+ [
112
+ "accounts/fireworks/models/deepseek-v4-pro-0813",
113
+ "accounts/fireworks/models/deepseek-v4p1-flash",
114
+ ],
115
+ [
116
+ "accounts/fireworks/models/kimi-k2p6",
117
+ "accounts/fireworks/models/kimi-k3",
118
+ ],
119
+ ])("repairs %s to %s", (stale, replacement) => {
120
+ writeConfig({
121
+ llm: {
122
+ default: { provider: "fireworks", model: stale },
123
+ profiles: {
124
+ managed: { provider: "vellum", model: stale },
125
+ other: { provider: "openai-compatible", model: stale },
126
+ },
127
+ },
128
+ });
129
+
130
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
131
+
132
+ const llm = readConfig().llm as Record<string, unknown>;
133
+ const profiles = llm.profiles as Record<string, Record<string, unknown>>;
134
+ expect((llm.default as Record<string, unknown>).model).toBe(replacement);
135
+ expect(profiles.managed.model).toBe(replacement);
136
+ expect(profiles.other.model).toBe(stale);
137
+ });
138
+
139
+ test("repairs entry-bound fragments whose row kind is fireworks or vellum", () => {
140
+ seedRows([
141
+ { name: "my-fireworks", provider: "fireworks" },
142
+ { name: "managed-alt", provider: "vellum" },
143
+ ]);
144
+ writeConfig({
145
+ llm: {
146
+ profiles: {
147
+ bound: { provider: "my-fireworks", model: STALE },
148
+ managed: { provider: "managed-alt", model: STALE },
149
+ },
150
+ },
151
+ });
152
+
153
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
154
+
155
+ const llm = readConfig().llm as Record<string, any>;
156
+ expect(llm.profiles.bound.model).toBe(REPLACEMENT);
157
+ expect(llm.profiles.managed.model).toBe(REPLACEMENT);
158
+ });
159
+
160
+ test("leaves fragments with an explicit other provider untouched", () => {
161
+ seedRows([{ name: "byo-compat", provider: "openai-compatible" }]);
162
+ writeConfig({
163
+ llm: {
164
+ default: { provider: "openai-compatible", model: STALE },
165
+ profiles: {
166
+ byo: { provider: "openai-compatible", model: STALE },
167
+ // Entry-bound profile whose row kind is not repairable.
168
+ compatBound: { provider: "byo-compat", model: STALE },
169
+ // Entry name with no row: dangling, so nothing proves it is a
170
+ // fireworks-kind route.
171
+ dangling: { provider: "ghost", model: STALE },
172
+ },
173
+ },
174
+ });
175
+
176
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
177
+
178
+ const llm = readConfig().llm as Record<string, any>;
179
+ expect(llm.default.model).toBe(STALE);
180
+ expect(llm.profiles.byo.model).toBe(STALE);
181
+ expect(llm.profiles.compatBound.model).toBe(STALE);
182
+ expect(llm.profiles.dangling.model).toBe(STALE);
183
+ });
184
+
185
+ test("leaves entry-name providers untouched when no DB file exists", () => {
186
+ writeConfig({
187
+ llm: {
188
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
189
+ },
190
+ });
191
+
192
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
193
+
194
+ const llm = readConfig().llm as Record<string, any>;
195
+ expect(llm.profiles.bound.model).toBe(STALE);
196
+ });
197
+
198
+ test("throws when an entry-name provider needs rows and the DB is unreadable", () => {
199
+ // A DB file without the provider_connections table is unqueryable, so
200
+ // the run must fail (and retry later) instead of skipping the profile.
201
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
202
+ writeFileSync(join(workspaceDir, "data", "db", "assistant.db"), "");
203
+ writeConfig({
204
+ llm: {
205
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
206
+ },
207
+ });
208
+
209
+ expect(() =>
210
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir),
211
+ ).toThrow();
212
+ const llm = readConfig().llm as Record<string, any>;
213
+ expect(llm.profiles.bound.model).toBe(STALE);
214
+
215
+ // Vendor and absent providers never need the rows, so the same broken
216
+ // DB does not block their repair.
217
+ writeConfig({
218
+ llm: {
219
+ default: { provider: "fireworks", model: STALE },
220
+ profiles: { legacy: { model: STALE } },
221
+ },
222
+ });
223
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
224
+ const repaired = readConfig().llm as Record<string, any>;
225
+ expect(repaired.default.model).toBe(REPLACEMENT);
226
+ expect(repaired.profiles.legacy.model).toBe(REPLACEMENT);
227
+ });
228
+
229
+ test("is idempotent and a no-op without the stale ID", () => {
230
+ writeConfig({
231
+ llm: {
232
+ default: { provider: "fireworks", model: STALE },
233
+ profiles: { fine: { provider: "fireworks", model: REPLACEMENT } },
234
+ },
235
+ });
236
+
237
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
238
+ const first = readConfig();
239
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
240
+ expect(readConfig()).toEqual(first);
241
+
242
+ const llm = first.llm as Record<string, any>;
243
+ expect(llm.default.model).toBe(REPLACEMENT);
244
+ expect(llm.profiles.fine.model).toBe(REPLACEMENT);
245
+ });
246
+
247
+ test("handles missing config, missing llm block, and invalid JSON", () => {
248
+ // No config.json at all.
249
+ expect(() =>
250
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir),
251
+ ).not.toThrow();
252
+
253
+ writeConfig({ theme: "dark" });
254
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir);
255
+ expect(readConfig()).toEqual({ theme: "dark" });
256
+
257
+ writeFileSync(join(workspaceDir, "config.json"), "{not json");
258
+ expect(() =>
259
+ repairRetiredFireworksModelIdsMigration.run(workspaceDir),
260
+ ).not.toThrow();
261
+ });
262
+ });
@@ -117,7 +117,7 @@ const VELLUM_PROFILE_IMPLS: ProfileImpls = {
117
117
  },
118
118
  },
119
119
  "cost-optimized": {
120
- model: "accounts/fireworks/models/deepseek-v4-flash-0731",
120
+ model: "accounts/fireworks/models/deepseek-v4p1-flash",
121
121
  provider: "vellum",
122
122
  source: "managed",
123
123
  label: "Budget",
@@ -137,6 +137,19 @@ function archiveStatusClause(status: ArchiveStatusFilter) {
137
137
  }
138
138
  }
139
139
 
140
+ /**
141
+ * A conversation's last activity, the key every list read is ordered on: its
142
+ * latest message (its last update, for a row with no messages yet) or the
143
+ * moment it was marked done, whichever is later. Marking a chat done is
144
+ * something the user did to it, so a done chat files under the day it was
145
+ * marked done rather than the day it was last written to. An active row has
146
+ * no `archived_at`, so for every active listing this is plain message recency.
147
+ *
148
+ * SQLite's multi-argument `MAX` is NULL when any argument is, hence the
149
+ * `COALESCE` on each side.
150
+ */
151
+ const lastActivitySql = sql`MAX(COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt}), COALESCE(${conversations.archivedAt}, 0))`;
152
+
140
153
  /**
141
154
  * Raw SQL predicate for "visible in the standard (Recents) listing".
142
155
  *
@@ -532,9 +545,7 @@ export function listConversations(
532
545
  ensureGroupMigration();
533
546
  const db = getDb();
534
547
  const { limit, offset = 0, ...filter } = query;
535
- const recency = desc(
536
- sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
537
- );
548
+ const recency = desc(lastActivitySql);
538
549
  // `id` closes the ordering so the sort is a total order. Rows tied on every
539
550
  // preceding key would otherwise have no defined relative order, which
540
551
  // becomes duplicated and skipped rows once a cursor pages across a tie
@@ -654,11 +665,7 @@ export function listPinnedConversations(
654
665
  ...(archiveCond ? [archiveCond] : []),
655
666
  ),
656
667
  )
657
- .orderBy(
658
- desc(
659
- sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
660
- ),
661
- );
668
+ .orderBy(desc(lastActivitySql));
662
669
  return query.all().map(parseConversation);
663
670
  }
664
671
 
@@ -162,7 +162,7 @@ describe("doesSupportVision", () => {
162
162
  managed: { provider: "vellum", model: "claude-fable-5" },
163
163
  "managed-text": {
164
164
  provider: "vellum",
165
- model: "accounts/fireworks/models/deepseek-v4-flash-0731",
165
+ model: "accounts/fireworks/models/glm-5p3",
166
166
  },
167
167
  });
168
168
  expect(doesSupportVision(profile("managed"))).toBe(true);
@@ -237,19 +237,19 @@ describe("doesSupportVision", () => {
237
237
 
238
238
  describe("doesSupportVision with a BYO default provider", () => {
239
239
  test("judges a default profile against the default provider's column model", () => {
240
- // Managed/vellum column: cost-optimized → deepseek-v4-flash (text-only,
241
- // supportsVision: false). Anthropic column: cost-optimized carries
242
- // intent "latency-optimized" → claude-haiku-4-5 (supportsVision: true).
240
+ // Managed/vellum column: cost-optimized → deepseek-v4p1-flash
241
+ // (supportsVision: true). OpenRouter column: cost-optimized →
242
+ // deepseek/deepseek-v4-flash (text-only, supportsVision: false).
243
243
  // The judged model must be the one the BYO install actually runs.
244
- setMockConfig({}, { provider: "anthropic" });
245
- expect(doesSupportVision(profile("cost-optimized"))).toBe(true);
244
+ setMockConfig({}, { provider: "openrouter" });
245
+ expect(doesSupportVision(profile("cost-optimized"))).toBe(false);
246
246
  });
247
247
 
248
248
  test("without a default provider, a default profile judges the managed column", () => {
249
249
  // Null-reduction: no defaultProvider resolves cost-optimized through the
250
- // vellum column (deepseek-v4-flash, text-only).
250
+ // vellum column (deepseek-v4p1-flash, vision-capable).
251
251
  setMockConfig({});
252
- expect(doesSupportVision(profile("cost-optimized"))).toBe(false);
252
+ expect(doesSupportVision(profile("cost-optimized"))).toBe(true);
253
253
  });
254
254
 
255
255
  test("mix arms naming a default profile resolve through the same column", () => {
@@ -266,11 +266,11 @@ describe("doesSupportVision with a BYO default provider", () => {
266
266
  model: "accounts/fireworks/models/glm-5p2",
267
267
  },
268
268
  },
269
- { provider: "anthropic" },
269
+ { provider: "openrouter" },
270
270
  );
271
- // The "cost-optimized" arm resolves to the anthropic column's
272
- // vision-capable model, so the mix can route to vision.
273
- expect(doesSupportVision(profile("mix-profile"))).toBe(true);
271
+ // The "cost-optimized" arm resolves to the openrouter column's
272
+ // text-only model, so no arm of the mix can route to vision.
273
+ expect(doesSupportVision(profile("mix-profile"))).toBe(false);
274
274
  });
275
275
 
276
276
  test("a user-owned profile is unaffected by the default provider", () => {
@@ -1062,22 +1062,9 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
1062
1062
  cacheReadPer1mTokens: 0.3,
1063
1063
  },
1064
1064
  },
1065
- {
1066
- id: "accounts/fireworks/models/kimi-k2p6",
1067
- displayName: "Kimi K2.6",
1068
- contextWindowTokens: 262144,
1069
- maxOutputTokens: 32768,
1070
- supportsThinking: true,
1071
- supportsCaching: true,
1072
- supportsVision: true,
1073
- supportsToolUse: true,
1074
- maxEffort: "high",
1075
- pricing: {
1076
- inputPer1mTokens: 0.95,
1077
- outputPer1mTokens: 4.0,
1078
- cacheReadPer1mTokens: 0.16,
1079
- },
1080
- },
1065
+ // Kimi K2.6 (accounts/fireworks/models/kimi-k2p6) is intentionally
1066
+ // absent: Fireworks has no serverless deployment for it (the serving
1067
+ // API returns 404). Kimi K3 is its successor.
1081
1068
  {
1082
1069
  id: "accounts/fireworks/models/glm-5p3",
1083
1070
  displayName: "GLM 5.3",
@@ -1160,40 +1147,30 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
1160
1147
  // intentionally absent: Fireworks has no serverless deployment for
1161
1148
  // it (the model page claims serverless support, but the serving API
1162
1149
  // returns 404).
1163
- {
1164
- id: "accounts/fireworks/models/deepseek-v4-pro-0813",
1165
- displayName: "DeepSeek V4 Pro",
1166
- contextWindowTokens: 1040000,
1167
- maxOutputTokens: 131072,
1168
- supportsThinking: true,
1169
- supportsCaching: true,
1170
- supportsVision: false,
1171
- supportsToolUse: true,
1172
- maxEffort: "max",
1173
- pricing: {
1174
- inputPer1mTokens: 1.32,
1175
- outputPer1mTokens: 3.96,
1176
- cacheReadPer1mTokens: 0.044,
1177
- },
1178
- },
1179
- {
1180
- id: "accounts/fireworks/models/deepseek-v4-flash-0731",
1181
- displayName: "DeepSeek V4 Flash",
1182
- contextWindowTokens: 1040000,
1150
+ // DeepSeek V4 Pro (accounts/fireworks/models/deepseek-v4-pro-0813)
1151
+ // and DeepSeek V4 Flash
1152
+ // (accounts/fireworks/models/deepseek-v4-flash-0731) are intentionally
1153
+ // absent: Fireworks has no serverless deployment for them (the
1154
+ // serving API returns 404). DeepSeek V4.1 Flash is the serverless
1155
+ // successor Fireworks names for both.
1156
+ {
1157
+ id: "accounts/fireworks/models/deepseek-v4p1-flash",
1158
+ displayName: "DeepSeek V4.1 Flash",
1159
+ contextWindowTokens: 1048576,
1183
1160
  maxOutputTokens: 131072,
1184
1161
  supportsThinking: true,
1185
1162
  supportsCaching: true,
1186
- supportsVision: false,
1163
+ supportsVision: true,
1187
1164
  supportsToolUse: true,
1188
1165
  maxEffort: "max",
1189
1166
  pricing: {
1190
- inputPer1mTokens: 0.14,
1191
- outputPer1mTokens: 0.28,
1192
- cacheReadPer1mTokens: 0.028,
1167
+ inputPer1mTokens: 0.3,
1168
+ outputPer1mTokens: 1.2,
1169
+ cacheReadPer1mTokens: 0.006,
1193
1170
  },
1194
1171
  },
1195
1172
  ],
1196
- defaultModel: "accounts/fireworks/models/deepseek-v4-flash-0731",
1173
+ defaultModel: "accounts/fireworks/models/deepseek-v4p1-flash",
1197
1174
  apiKeyUrl: "https://fireworks.ai/account/api-keys",
1198
1175
  apiKeyPlaceholder: "fw_...",
1199
1176
  },
@@ -1911,32 +1888,6 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
1911
1888
  },
1912
1889
  },
1913
1890
  // DeepSeek
1914
- {
1915
- id: "deepseek/deepseek-r1-0528",
1916
- displayName: "DeepSeek R1",
1917
- contextWindowTokens: 163840,
1918
- maxOutputTokens: 32000,
1919
- supportsThinking: true,
1920
- supportsCaching: true,
1921
- supportsVision: false,
1922
- supportsToolUse: true,
1923
- pricing: {
1924
- inputPer1mTokens: 0.5,
1925
- outputPer1mTokens: 2.15,
1926
- cacheReadPer1mTokens: 0.35,
1927
- },
1928
- },
1929
- {
1930
- id: "deepseek/deepseek-chat-v3-0324",
1931
- displayName: "DeepSeek V3",
1932
- contextWindowTokens: 163840,
1933
- maxOutputTokens: 32000,
1934
- supportsThinking: false,
1935
- supportsCaching: false,
1936
- supportsVision: false,
1937
- supportsToolUse: true,
1938
- pricing: { inputPer1mTokens: 0.25, outputPer1mTokens: 1.0 },
1939
- },
1940
1891
  {
1941
1892
  id: "deepseek/deepseek-v4-pro",
1942
1893
  displayName: "DeepSeek V4 Pro",
@@ -1971,6 +1922,32 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
1971
1922
  cacheReadPer1mTokens: 0.028,
1972
1923
  },
1973
1924
  },
1925
+ {
1926
+ id: "deepseek/deepseek-r1-0528",
1927
+ displayName: "DeepSeek R1",
1928
+ contextWindowTokens: 163840,
1929
+ maxOutputTokens: 32000,
1930
+ supportsThinking: true,
1931
+ supportsCaching: true,
1932
+ supportsVision: false,
1933
+ supportsToolUse: true,
1934
+ pricing: {
1935
+ inputPer1mTokens: 0.5,
1936
+ outputPer1mTokens: 2.15,
1937
+ cacheReadPer1mTokens: 0.35,
1938
+ },
1939
+ },
1940
+ {
1941
+ id: "deepseek/deepseek-chat-v3-0324",
1942
+ displayName: "DeepSeek V3",
1943
+ contextWindowTokens: 163840,
1944
+ maxOutputTokens: 32000,
1945
+ supportsThinking: false,
1946
+ supportsCaching: false,
1947
+ supportsVision: false,
1948
+ supportsToolUse: true,
1949
+ pricing: { inputPer1mTokens: 0.25, outputPer1mTokens: 1.0 },
1950
+ },
1974
1951
  // Qwen
1975
1952
  {
1976
1953
  id: "qwen/qwen3.5-plus-02-15",
@@ -51,10 +51,10 @@ const PROVIDER_MODEL_INTENTS: Record<string, Record<ModelIntent, string>> = {
51
51
  },
52
52
  fireworks: {
53
53
  balanced: "accounts/fireworks/models/minimax-m3",
54
- "cost-optimized": "accounts/fireworks/models/deepseek-v4-flash-0731",
55
- "latency-optimized": "accounts/fireworks/models/deepseek-v4-flash-0731",
56
- "quality-optimized": "accounts/fireworks/models/kimi-k2p6",
57
- "vision-optimized": "accounts/fireworks/models/kimi-k2p6",
54
+ "cost-optimized": "accounts/fireworks/models/deepseek-v4p1-flash",
55
+ "latency-optimized": "accounts/fireworks/models/deepseek-v4p1-flash",
56
+ "quality-optimized": "accounts/fireworks/models/kimi-k3",
57
+ "vision-optimized": "accounts/fireworks/models/kimi-k3",
58
58
  },
59
59
  openrouter: {
60
60
  balanced: "anthropic/claude-sonnet-4.6",
@@ -71,6 +71,7 @@ interface ListResponse {
71
71
  nextOffset: number;
72
72
  hasMore: boolean;
73
73
  groups?: unknown[];
74
+ orderedBy: string;
74
75
  }
75
76
 
76
77
  function findHandler(routes: RouteDefinition[], operationId: string) {
@@ -1380,6 +1381,41 @@ describe("GET /v1/conversations, conversationType=all", () => {
1380
1381
  ]);
1381
1382
  });
1382
1383
 
1384
+ test("files a done row by when it was marked done when that is later than its last message", async () => {
1385
+ const recent = createConversation("recent-foreground");
1386
+ const doneLater = createConversation("old-chat-marked-done-later");
1387
+ const doneEarlier = createConversation("old-chat-done-before-a-reply");
1388
+ setLastMessageAt(recent.id, 2000);
1389
+ setLastMessageAt(doneLater.id, 1000);
1390
+ setLastMessageAt(doneEarlier.id, 1500);
1391
+ rawRun(
1392
+ "test:markDone",
1393
+ "UPDATE conversations SET archived_at = ? WHERE id = ?",
1394
+ 3000,
1395
+ doneLater.id,
1396
+ );
1397
+ rawRun(
1398
+ "test:markDone",
1399
+ "UPDATE conversations SET archived_at = ? WHERE id = ?",
1400
+ 500,
1401
+ doneEarlier.id,
1402
+ );
1403
+
1404
+ const result = (await invoke({
1405
+ conversationType: "all",
1406
+ archiveStatus: "all",
1407
+ })) as ListResponse;
1408
+
1409
+ expect(result.conversations.map((c) => c.title)).toEqual([
1410
+ "old-chat-marked-done-later",
1411
+ "recent-foreground",
1412
+ "old-chat-done-before-a-reply",
1413
+ ]);
1414
+ // A client paging done rows relies on this to know the order is not
1415
+ // message recency.
1416
+ expect(result.orderedBy).toBe("lastActivity");
1417
+ });
1418
+
1383
1419
  test("excludes legacy private rows and subagent runs", async () => {
1384
1420
  createConversation("foreground-1");
1385
1421
  const legacyPrivate = createConversation("legacy-private");
@@ -252,7 +252,7 @@ describe("inference_send profile usability", () => {
252
252
  const err = await rejection({ ...hermes, status: "disabled" });
253
253
  expect(err).toBeInstanceOf(BadRequestError);
254
254
  expect(err.message).toContain("disabled");
255
- expect(err.message).toContain("deepseek-v4-flash");
255
+ expect(err.message).toContain("deepseek-v4p1-flash");
256
256
  });
257
257
 
258
258
  test("rejects a profile that lost its provider", async () => {
@@ -106,6 +106,18 @@ describe("path traversal", () => {
106
106
  );
107
107
  expect(res.status).toBe(400);
108
108
  });
109
+
110
+ test("rejects backslashes in the route path", async () => {
111
+ // Some URL normalizers treat a backslash as a separator, so a
112
+ // backslash-carrying routePath is refused outright regardless of dots.
113
+ const dispatcher = makeDispatcher();
114
+ for (const routePath of ["..\\config", "foo\\bar", "x\\..\\config"]) {
115
+ const res = await dispatcher.dispatch(routePath, makeRequest("GET"));
116
+ expect(res.status).toBe(400);
117
+ const body = await readErrorBody(res);
118
+ expect(body.error.message).toContain("Path traversal");
119
+ }
120
+ });
109
121
  });
110
122
 
111
123
  // ---------------------------------------------------------------------------
@@ -150,6 +150,13 @@ const listConversationsResponseSchema = z.object({
150
150
  nextOffset: z.number(),
151
151
  hasMore: z.boolean(),
152
152
  groups: z.array(groupSummarySchema).optional(),
153
+ /**
154
+ * The key the rows are ordered on: last activity, the later of a row's
155
+ * last message and the time it was marked done. Stated so a client that
156
+ * pages a list including done rows can tell this order from message
157
+ * recency, which an assistant that omits the field pages by.
158
+ */
159
+ orderedBy: z.literal("lastActivity"),
153
160
  });
154
161
 
155
162
  const conversationDetailResponseSchema = z.object({
@@ -376,6 +383,7 @@ function handleListConversations({
376
383
  ),
377
384
  nextOffset,
378
385
  hasMore: nextOffset < totalCount,
386
+ orderedBy: "lastActivity",
379
387
  };
380
388
 
381
389
  // Include groups array on first page only
@@ -632,7 +640,7 @@ export const ROUTES: RouteDefinition[] = [
632
640
  type: "string",
633
641
  required: false,
634
642
  description:
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.',
643
+ '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 ordered by last activity (the later of the last message and the time the row was marked done, descending) like every other list read, and pinned rows are never appended to it.',
636
644
  schema: {
637
645
  type: "string",
638
646
  enum: ["background", "scheduled", "all"],
@@ -662,7 +670,7 @@ export const ROUTES: RouteDefinition[] = [
662
670
  type: "string",
663
671
  required: false,
664
672
  description:
665
- 'Filter to a single group, so each sidebar section can load independently of the paginated list. Pass "system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A group-scoped request is recency-ordered like every list read (COALESCE(last_message_at, updated_at) descending) and never has pinned rows appended to it. Omit to span every group.',
673
+ 'Filter to a single group, so each sidebar section can load independently of the paginated list. Pass "system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A group-scoped request is ordered by last activity like every list read (the later of the last message and the time the row was marked done, descending) and never has pinned rows appended to it. Omit to span every group.',
666
674
  },
667
675
  {
668
676
  name: "needsAttention",
@@ -135,7 +135,11 @@ export class UserRouteDispatcher {
135
135
  * @returns A Response from the handler, or an error response (404, 405, 500).
136
136
  */
137
137
  async dispatch(routePath: string, request: Request): Promise<Response> {
138
- if (routePath.includes("..")) {
138
+ // ".." covers classic dot-segment traversal. "\" is rejected alongside it
139
+ // because some URL normalizers treat a backslash as a path separator, so
140
+ // a routePath that looks in-namespace after percent-decoding can still
141
+ // resolve outside the routes directory once such a normalizer runs.
142
+ if (routePath.includes("..") || routePath.includes("\\")) {
139
143
  return httpError("BAD_REQUEST", "Path traversal is not allowed", 400);
140
144
  }
141
145
 
@@ -187,6 +187,8 @@ const HISTORICAL_INTENT_MODELS: Record<
187
187
  "accounts/fireworks/models/deepseek-v4-flash",
188
188
  // the ID migration 146 writes over stale deepseek-v4-flash pins.
189
189
  "accounts/fireworks/models/deepseek-v4-flash-0731",
190
+ // the ID migration 160 writes over stale deepseek-v4-flash-0731 pins.
191
+ "accounts/fireworks/models/deepseek-v4p1-flash",
190
192
  ],
191
193
  },
192
194
  "quality-optimized": {
@@ -209,10 +211,15 @@ const HISTORICAL_INTENT_MODELS: Record<
209
211
  fireworks: [
210
212
  // quality intent 2026-05-05 (#29755) to 2026-05-19 (#31068).
211
213
  "accounts/fireworks/models/kimi-k2p5",
214
+ // quality intent 2026-05-19 (#31068) to the 2026-09-28 kimi-k3
215
+ // repoint.
216
+ "accounts/fireworks/models/kimi-k2p6",
212
217
  // migration 136's in-place rewrite of a kimi-k2p5 pin.
213
218
  "accounts/fireworks/models/deepseek-v4-flash",
214
219
  // the ID migration 146 writes over stale deepseek-v4-flash pins.
215
220
  "accounts/fireworks/models/deepseek-v4-flash-0731",
221
+ // the ID migration 160 writes over stale deepseek-v4-flash-0731 pins.
222
+ "accounts/fireworks/models/deepseek-v4p1-flash",
216
223
  ],
217
224
  },
218
225
  "cost-optimized": {
@@ -234,6 +241,10 @@ const HISTORICAL_INTENT_MODELS: Record<
234
241
  // 146 rewrites it to the dated ID, so this survives only in pre-146
235
242
  // backups.
236
243
  "accounts/fireworks/models/deepseek-v4-flash",
244
+ // latency intent 2026-08-17 to the 2026-09-28 deepseek-v4p1-flash
245
+ // repoint. On live configs migration 160 rewrites it, so this
246
+ // survives only in pre-160 backups.
247
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
237
248
  ],
238
249
  },
239
250
  };
@@ -0,0 +1,209 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+
5
+ import type { WorkspaceMigration } from "./types.js";
6
+
7
+ /**
8
+ * Repair the retired Fireworks model IDs in workspace LLM config.
9
+ *
10
+ * Fireworks has no serverless deployment of DeepSeek V4 Flash 0731,
11
+ * DeepSeek V4 Pro 0813 or Kimi K2.6: chat/completions calls fail with a
12
+ * 404 "Model not found, inaccessible, and/or not deployed". Existing
13
+ * configs can pin any of these IDs in `llm.default`, `llm.callSites.*`, and `llm.profiles.*`.
14
+ *
15
+ * Repair those leaves only on an exact stale match, replacing with the
16
+ * successor Fireworks names (see `REPLACEMENT_BY_STALE_MODEL_ID`). Kimi
17
+ * K2.6 has two named successors; Kimi K3 is the one that accepts image
18
+ * input, which Kimi K2.6 does.
19
+ *
20
+ * A replacement can also be an intent template's model, so a repaired
21
+ * hand-edited `custom-*` profile can read as unedited to
22
+ * `ensureByokDefaultProfiles` and collapse into that default. That is
23
+ * acceptable: the collapsed copy routes to the same model this migration
24
+ * writes.
25
+ *
26
+ * Provider guard: the stale IDs belong to the `fireworks` provider and also
27
+ * appear in managed profiles stamped `provider: "vellum"` (which route
28
+ * Fireworks-account model IDs through the managed proxy). Under the entries
29
+ * model (migration 145) `provider` can also hold a `provider_connections`
30
+ * entry name whose row kind drives dispatch. A fragment is repaired when
31
+ * its `provider` is `"fireworks"`, `"vellum"`, absent, or an entry name
32
+ * whose row kind is one of those. Any other provider is left untouched: an
33
+ * `openai-compatible` endpoint may legitimately serve a model by the stale
34
+ * name.
35
+ */
36
+ export const repairRetiredFireworksModelIdsMigration: WorkspaceMigration = {
37
+ id: "160-repair-retired-fireworks-model-ids",
38
+ description: "Repair retired Fireworks model IDs in workspace LLM config",
39
+ run(workspaceDir: string): void {
40
+ const configPath = join(workspaceDir, "config.json");
41
+ if (!existsSync(configPath)) {
42
+ return;
43
+ }
44
+
45
+ // Read outside the parse catch: a transient filesystem error (EIO,
46
+ // EACCES) must reach the runner so the migration retries, while
47
+ // malformed JSON is a permanent state this migration cannot repair.
48
+ const rawText = readFileSync(configPath, "utf-8");
49
+
50
+ let config: Record<string, unknown>;
51
+ try {
52
+ const raw = JSON.parse(rawText);
53
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
54
+ return;
55
+ }
56
+ config = raw as Record<string, unknown>;
57
+ } catch {
58
+ return;
59
+ }
60
+
61
+ const llm = readObject(config.llm);
62
+ if (llm === null) {
63
+ return;
64
+ }
65
+
66
+ // Entry rows load lazily: only a stale fragment whose provider is
67
+ // neither a repairable vendor nor absent needs them. An unreadable DB
68
+ // then fails the run (retried next boot) rather than checkpointing a
69
+ // pass that skips entry-bound profiles.
70
+ let rows: Map<string, string> | null | undefined;
71
+ const isRepairableProvider = (provider: unknown): boolean => {
72
+ if (provider === undefined) {
73
+ return true;
74
+ }
75
+ if (typeof provider !== "string") {
76
+ return false;
77
+ }
78
+ if (REPAIRABLE_PROVIDERS.has(provider)) {
79
+ return true;
80
+ }
81
+ if (rows === undefined) {
82
+ rows = readConnectionRows(workspaceDir);
83
+ }
84
+ if (rows === null) {
85
+ throw new Error(
86
+ "provider_connections is not readable; retrying the model-ID repair on the next run",
87
+ );
88
+ }
89
+ const kind = rows.get(provider);
90
+ return kind !== undefined && REPAIRABLE_PROVIDERS.has(kind);
91
+ };
92
+
93
+ let changed = false;
94
+
95
+ changed =
96
+ repairFragment(readObject(llm.default), isRepairableProvider) || changed;
97
+
98
+ const callSites = readObject(llm.callSites);
99
+ if (callSites !== null) {
100
+ for (const rawConfig of Object.values(callSites)) {
101
+ changed =
102
+ repairFragment(readObject(rawConfig), isRepairableProvider) ||
103
+ changed;
104
+ }
105
+ }
106
+
107
+ const profiles = readObject(llm.profiles);
108
+ if (profiles !== null) {
109
+ for (const rawProfile of Object.values(profiles)) {
110
+ changed =
111
+ repairFragment(readObject(rawProfile), isRepairableProvider) ||
112
+ changed;
113
+ }
114
+ }
115
+
116
+ if (!changed) {
117
+ return;
118
+ }
119
+
120
+ // Write-then-rename so an interrupted write cannot leave config.json
121
+ // truncated: a torn in-place write would parse as invalid JSON on the
122
+ // retry, which the catch above treats as "nothing to do", letting the
123
+ // runner checkpoint the migration as completed against a corrupt file.
124
+ const tmpPath = `${configPath}.migration-160.tmp`;
125
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2) + "\n");
126
+ renameSync(tmpPath, configPath);
127
+ },
128
+ // The exact-match rewrite is idempotent, so a transient failure (full
129
+ // disk, I/O error) is safe to retry on later startups.
130
+ retryFailedCheckpoint: true,
131
+ down(_workspaceDir: string): void {
132
+ // Forward-only: reintroducing a retired model ID would break
133
+ // Fireworks calls.
134
+ },
135
+ };
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Helpers: self-contained per workspace migrations AGENTS.md
139
+ // ---------------------------------------------------------------------------
140
+
141
+ const REPLACEMENT_BY_STALE_MODEL_ID = new Map([
142
+ [
143
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
144
+ "accounts/fireworks/models/deepseek-v4p1-flash",
145
+ ],
146
+ [
147
+ "accounts/fireworks/models/deepseek-v4-pro-0813",
148
+ "accounts/fireworks/models/deepseek-v4p1-flash",
149
+ ],
150
+ ["accounts/fireworks/models/kimi-k2p6", "accounts/fireworks/models/kimi-k3"],
151
+ ]);
152
+ const REPAIRABLE_PROVIDERS = new Set(["fireworks", "vellum"]);
153
+
154
+ function repairFragment(
155
+ fragment: Record<string, unknown> | null,
156
+ isRepairableProvider: (provider: unknown) => boolean,
157
+ ): boolean {
158
+ if (fragment === null) {
159
+ return false;
160
+ }
161
+ if (typeof fragment.model !== "string") {
162
+ return false;
163
+ }
164
+ const replacement = REPLACEMENT_BY_STALE_MODEL_ID.get(fragment.model);
165
+ if (replacement === undefined) {
166
+ return false;
167
+ }
168
+ if (!isRepairableProvider(fragment.provider)) {
169
+ return false;
170
+ }
171
+ fragment.model = replacement;
172
+ return true;
173
+ }
174
+
175
+ /**
176
+ * Connection name -> provider kind, or null when the DB or table is not
177
+ * readable. The caller fails the run on null: entry-name providers must be
178
+ * judged against real rows, never guessed. An absent DB file is a real
179
+ * state (no rows, so every entry name is dangling and stays untouched).
180
+ */
181
+ function readConnectionRows(workspaceDir: string): Map<string, string> | null {
182
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
183
+ if (!existsSync(dbPath)) {
184
+ return new Map();
185
+ }
186
+ let db: Database;
187
+ try {
188
+ db = new Database(dbPath);
189
+ } catch {
190
+ return null;
191
+ }
192
+ try {
193
+ const rows = db
194
+ .query(`SELECT name, provider FROM provider_connections`)
195
+ .all() as Array<{ name: string; provider: string }>;
196
+ return new Map(rows.map((r) => [r.name, r.provider]));
197
+ } catch {
198
+ return null;
199
+ } finally {
200
+ db.close();
201
+ }
202
+ }
203
+
204
+ function readObject(value: unknown): Record<string, unknown> | null {
205
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
206
+ return null;
207
+ }
208
+ return value as Record<string, unknown>;
209
+ }
@@ -157,6 +157,7 @@ import { extractWorkspaceMcpJsonMigration } from "./156-extract-workspace-mcp-js
157
157
  import { addDesktopFilesLauncherMigration } from "./157-add-desktop-files-launcher.js";
158
158
  import { repointOpencodeHostProviderFragmentsMigration } from "./158-repoint-opencode-host-provider-fragments.js";
159
159
  import { renameCollidingJevProfileNameMigration } from "./159-rename-colliding-jev-profile-name.js";
160
+ import { repairRetiredFireworksModelIdsMigration } from "./160-repair-retired-fireworks-model-ids.js";
160
161
  import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
161
162
  import type { WorkspaceMigration } from "./types.js";
162
163
 
@@ -329,4 +330,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
329
330
  addDesktopFilesLauncherMigration,
330
331
  repointOpencodeHostProviderFragmentsMigration,
331
332
  renameCollidingJevProfileNameMigration,
333
+ repairRetiredFireworksModelIdsMigration,
332
334
  ];