@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 +10 -4
- package/package.json +1 -1
- package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
- package/src/__tests__/openai-provider.test.ts +11 -11
- package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
- package/src/config/default-profile-catalog.ts +1 -1
- package/src/persistence/conversation-queries.ts +15 -8
- package/src/plugin-api/vision-support.test.ts +12 -12
- package/src/providers/model-catalog.ts +44 -67
- package/src/providers/model-intents.ts +4 -4
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
- package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
- package/src/runtime/routes/conversation-list-routes.ts +10 -2
- package/src/runtime/routes/user-route-dispatcher.ts +5 -1
- package/src/workspace/byok-default-profile-ensure.ts +11 -0
- package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
- package/src/workspace/migrations/registry.ts +2 -0
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
|
|
8046
|
-
descending) like every other list read, and pinned rows are never
|
|
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
|
|
8087
|
-
descending) and never has pinned rows appended to it. Omit to span every
|
|
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
|
@@ -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
|
|
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-
|
|
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
|
|
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-
|
|
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('
|
|
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/
|
|
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-
|
|
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/
|
|
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/
|
|
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-
|
|
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-
|
|
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-
|
|
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/
|
|
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-
|
|
241
|
-
// supportsVision:
|
|
242
|
-
//
|
|
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: "
|
|
245
|
-
expect(doesSupportVision(profile("cost-optimized"))).toBe(
|
|
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-
|
|
250
|
+
// vellum column (deepseek-v4p1-flash, vision-capable).
|
|
251
251
|
setMockConfig({});
|
|
252
|
-
expect(doesSupportVision(profile("cost-optimized"))).toBe(
|
|
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: "
|
|
269
|
+
{ provider: "openrouter" },
|
|
270
270
|
);
|
|
271
|
-
// The "cost-optimized" arm resolves to the
|
|
272
|
-
//
|
|
273
|
-
expect(doesSupportVision(profile("mix-profile"))).toBe(
|
|
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
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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:
|
|
1163
|
+
supportsVision: true,
|
|
1187
1164
|
supportsToolUse: true,
|
|
1188
1165
|
maxEffort: "max",
|
|
1189
1166
|
pricing: {
|
|
1190
|
-
inputPer1mTokens: 0.
|
|
1191
|
-
outputPer1mTokens:
|
|
1192
|
-
cacheReadPer1mTokens: 0.
|
|
1167
|
+
inputPer1mTokens: 0.3,
|
|
1168
|
+
outputPer1mTokens: 1.2,
|
|
1169
|
+
cacheReadPer1mTokens: 0.006,
|
|
1193
1170
|
},
|
|
1194
1171
|
},
|
|
1195
1172
|
],
|
|
1196
|
-
defaultModel: "accounts/fireworks/models/deepseek-
|
|
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-
|
|
55
|
-
"latency-optimized": "accounts/fireworks/models/deepseek-
|
|
56
|
-
"quality-optimized": "accounts/fireworks/models/kimi-
|
|
57
|
-
"vision-optimized": "accounts/fireworks/models/kimi-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
];
|