@vellumai/assistant 0.8.9 → 0.8.10-staging.1
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 +1 -1
- package/package.json +1 -1
- package/scripts/sync-llm-catalog.ts +2 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/retry-thinking-adaptive-only.test.ts +245 -0
- package/src/providers/model-catalog.ts +57 -0
- package/src/providers/retry.ts +35 -7
package/openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -74,6 +74,8 @@ function projectModel(model: CatalogModel): Record<string, unknown> {
|
|
|
74
74
|
projected.longContextMode = model.longContextMode;
|
|
75
75
|
if (model.supportsThinking !== undefined)
|
|
76
76
|
projected.supportsThinking = model.supportsThinking;
|
|
77
|
+
if (model.adaptiveThinkingOnly !== undefined)
|
|
78
|
+
projected.adaptiveThinkingOnly = model.adaptiveThinkingOnly;
|
|
77
79
|
if (model.supportsCaching !== undefined)
|
|
78
80
|
projected.supportsCaching = model.supportsCaching;
|
|
79
81
|
if (model.supportsVision !== undefined)
|
|
@@ -53,6 +53,7 @@ interface ClientCatalogModel {
|
|
|
53
53
|
longContextPricingThresholdTokens?: number;
|
|
54
54
|
longContextMode?: "native-model" | "provider-request-option" | "unsupported";
|
|
55
55
|
supportsThinking?: boolean;
|
|
56
|
+
adaptiveThinkingOnly?: boolean;
|
|
56
57
|
supportsCaching?: boolean;
|
|
57
58
|
supportsVision?: boolean;
|
|
58
59
|
supportsToolUse?: boolean;
|
|
@@ -194,6 +195,9 @@ describe("LLM catalog parity: daemon vs client", () => {
|
|
|
194
195
|
);
|
|
195
196
|
expect(clientModel.longContextMode).toBe(daemonModel.longContextMode);
|
|
196
197
|
expect(clientModel.supportsThinking).toBe(daemonModel.supportsThinking);
|
|
198
|
+
expect(clientModel.adaptiveThinkingOnly).toBe(
|
|
199
|
+
daemonModel.adaptiveThinkingOnly,
|
|
200
|
+
);
|
|
197
201
|
expect(clientModel.supportsCaching).toBe(daemonModel.supportsCaching);
|
|
198
202
|
expect(clientModel.supportsVision).toBe(daemonModel.supportsVision);
|
|
199
203
|
expect(clientModel.supportsToolUse).toBe(daemonModel.supportsToolUse);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies that `RetryProvider.normalizeSendMessageOptions` drops a disabled
|
|
3
|
+
* thinking config for adaptive-thinking-only models (Claude Fable), preventing
|
|
4
|
+
* an Anthropic 400: Fable always reasons with always-on adaptive thinking and
|
|
5
|
+
* rejects `thinking: { type: "disabled" }`.
|
|
6
|
+
*
|
|
7
|
+
* Effort and other parameters are unaffected, and models that genuinely support
|
|
8
|
+
* disabling thinking (e.g. Opus) keep their disabled config.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
mock.module("../util/logger.js", () => ({
|
|
14
|
+
getLogger: () =>
|
|
15
|
+
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
let mockLlmConfig: Record<string, unknown> = {};
|
|
19
|
+
|
|
20
|
+
mock.module("../config/loader.js", () => ({
|
|
21
|
+
getConfig: () => ({ llm: mockLlmConfig }),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { LLMSchema } from "../config/schemas/llm.js";
|
|
25
|
+
import { RetryProvider } from "../providers/retry.js";
|
|
26
|
+
import type {
|
|
27
|
+
Message,
|
|
28
|
+
Provider,
|
|
29
|
+
ProviderResponse,
|
|
30
|
+
SendMessageOptions,
|
|
31
|
+
} from "../providers/types.js";
|
|
32
|
+
|
|
33
|
+
function setLlmConfig(raw: unknown): void {
|
|
34
|
+
mockLlmConfig = LLMSchema.parse(raw) as Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
mockLlmConfig = LLMSchema.parse({}) as Record<string, unknown>;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function makePipeline(providerName: string): {
|
|
42
|
+
provider: Provider;
|
|
43
|
+
lastConfig: () => Record<string, unknown> | undefined;
|
|
44
|
+
} {
|
|
45
|
+
let captured: Record<string, unknown> | undefined;
|
|
46
|
+
const inner: Provider = {
|
|
47
|
+
name: providerName,
|
|
48
|
+
async sendMessage(
|
|
49
|
+
_messages: Message[],
|
|
50
|
+
options?: SendMessageOptions,
|
|
51
|
+
): Promise<ProviderResponse> {
|
|
52
|
+
captured = options?.config as Record<string, unknown> | undefined;
|
|
53
|
+
return {
|
|
54
|
+
content: [],
|
|
55
|
+
model: "test",
|
|
56
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
57
|
+
stopReason: "stop",
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
provider: new RetryProvider(inner),
|
|
63
|
+
lastConfig: () => captured,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const userMessage: Message = {
|
|
68
|
+
role: "user",
|
|
69
|
+
content: [{ type: "text", text: "hi" }],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe("retry normalization: adaptive-only thinking models", () => {
|
|
73
|
+
test("drops resolved thinking: disabled for Claude Fable", async () => {
|
|
74
|
+
// GIVEN a profile that disables thinking for an adaptive-only Fable model
|
|
75
|
+
setLlmConfig({
|
|
76
|
+
default: {
|
|
77
|
+
provider: "anthropic",
|
|
78
|
+
model: "claude-fable-5",
|
|
79
|
+
thinking: { enabled: false },
|
|
80
|
+
effort: "high",
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const { provider, lastConfig } = makePipeline("anthropic");
|
|
84
|
+
|
|
85
|
+
// WHEN a request resolves through the call-site config
|
|
86
|
+
await provider.sendMessage([userMessage], {
|
|
87
|
+
config: { callSite: "memoryExtraction" },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// THEN the disabled thinking config is dropped so Fable falls back to its
|
|
91
|
+
// always-on adaptive thinking instead of 400-ing
|
|
92
|
+
expect(lastConfig()?.thinking).toBeUndefined();
|
|
93
|
+
// AND effort is still forwarded
|
|
94
|
+
expect(lastConfig()?.effort).toBe("high");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("drops explicit thinking: disabled from pass-through callers for Fable", async () => {
|
|
98
|
+
// GIVEN a pass-through caller supplying the wire-shape disabled config
|
|
99
|
+
const { provider, lastConfig } = makePipeline("anthropic");
|
|
100
|
+
|
|
101
|
+
// WHEN sending against a Fable model
|
|
102
|
+
await provider.sendMessage([userMessage], {
|
|
103
|
+
config: {
|
|
104
|
+
model: "claude-fable-5",
|
|
105
|
+
thinking: { type: "disabled" },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// THEN the disabled thinking config is dropped
|
|
110
|
+
expect(lastConfig()?.thinking).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("drops disabled thinking for OpenRouter-proxied Fable", async () => {
|
|
114
|
+
// GIVEN a profile disabling thinking on the OpenRouter-proxied Fable id
|
|
115
|
+
setLlmConfig({
|
|
116
|
+
default: {
|
|
117
|
+
provider: "openrouter",
|
|
118
|
+
model: "anthropic/claude-fable-5",
|
|
119
|
+
thinking: { enabled: false },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const { provider, lastConfig } = makePipeline("openrouter");
|
|
123
|
+
|
|
124
|
+
// WHEN a request resolves through the call-site config
|
|
125
|
+
await provider.sendMessage([userMessage], {
|
|
126
|
+
config: { callSite: "memoryExtraction" },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// THEN the disabled thinking config is dropped
|
|
130
|
+
expect(lastConfig()?.thinking).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("preserves adaptive thinking for Fable", async () => {
|
|
134
|
+
// GIVEN a profile that enables thinking for a Fable model
|
|
135
|
+
setLlmConfig({
|
|
136
|
+
default: {
|
|
137
|
+
provider: "anthropic",
|
|
138
|
+
model: "claude-fable-5",
|
|
139
|
+
thinking: { enabled: true },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const { provider, lastConfig } = makePipeline("anthropic");
|
|
143
|
+
|
|
144
|
+
// WHEN a request resolves through the call-site config
|
|
145
|
+
await provider.sendMessage([userMessage], {
|
|
146
|
+
config: { callSite: "memoryExtraction" },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// THEN adaptive thinking is preserved (only disabled is dropped)
|
|
150
|
+
expect(lastConfig()?.thinking).toEqual({ type: "adaptive" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("preserves disabled thinking for models that support disabling it", async () => {
|
|
154
|
+
// GIVEN a profile disabling thinking for a non-adaptive-only model
|
|
155
|
+
setLlmConfig({
|
|
156
|
+
default: {
|
|
157
|
+
provider: "anthropic",
|
|
158
|
+
model: "claude-opus-4-7",
|
|
159
|
+
thinking: { enabled: false },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const { provider, lastConfig } = makePipeline("anthropic");
|
|
163
|
+
|
|
164
|
+
// WHEN a request resolves through the call-site config
|
|
165
|
+
await provider.sendMessage([userMessage], {
|
|
166
|
+
config: { callSite: "memoryExtraction" },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// THEN the disabled thinking config is preserved for Opus
|
|
170
|
+
expect(lastConfig()?.thinking).toEqual({ type: "disabled" });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("drops non-1 temperature for Fable when thinking is disabled", async () => {
|
|
174
|
+
// GIVEN a Fable profile that disables thinking and sets a non-1 temperature
|
|
175
|
+
setLlmConfig({
|
|
176
|
+
default: {
|
|
177
|
+
provider: "anthropic",
|
|
178
|
+
model: "claude-fable-5",
|
|
179
|
+
thinking: { enabled: false },
|
|
180
|
+
temperature: 0.7,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const { provider, lastConfig } = makePipeline("anthropic");
|
|
184
|
+
|
|
185
|
+
// WHEN a request resolves through the call-site config
|
|
186
|
+
await provider.sendMessage([userMessage], {
|
|
187
|
+
config: { callSite: "memoryExtraction" },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// THEN the disabled thinking is dropped (Fable falls back to adaptive) AND
|
|
191
|
+
// the non-1 temperature is dropped, since adaptive mode requires
|
|
192
|
+
// temperature: 1 — leaving it would 400 the request
|
|
193
|
+
expect(lastConfig()?.thinking).toBeUndefined();
|
|
194
|
+
expect(lastConfig()?.temperature).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("drops non-1 temperature for Fable with no explicit thinking config", async () => {
|
|
198
|
+
// GIVEN a pass-through caller that sets a non-1 temperature and no thinking
|
|
199
|
+
const { provider, lastConfig } = makePipeline("anthropic");
|
|
200
|
+
|
|
201
|
+
// WHEN sending against a Fable model
|
|
202
|
+
await provider.sendMessage([userMessage], {
|
|
203
|
+
config: {
|
|
204
|
+
model: "claude-fable-5",
|
|
205
|
+
temperature: 0.5,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// THEN the temperature is dropped: Fable is always in adaptive mode, so the
|
|
210
|
+
// temperature: 1 constraint applies even without an explicit thinking config
|
|
211
|
+
expect(lastConfig()?.temperature).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("drops non-1 temperature for OpenRouter-proxied Fable", async () => {
|
|
215
|
+
// GIVEN a pass-through caller on the OpenRouter-proxied Fable id
|
|
216
|
+
const { provider, lastConfig } = makePipeline("openrouter");
|
|
217
|
+
|
|
218
|
+
// WHEN sending with a non-1 temperature and no explicit thinking config
|
|
219
|
+
await provider.sendMessage([userMessage], {
|
|
220
|
+
config: {
|
|
221
|
+
model: "anthropic/claude-fable-5",
|
|
222
|
+
temperature: 0.2,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// THEN the temperature is dropped for the Anthropic-fronted Fable model
|
|
227
|
+
expect(lastConfig()?.temperature).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("preserves temperature: 1 for Fable", async () => {
|
|
231
|
+
// GIVEN a Fable profile with the only adaptive-mode-valid temperature
|
|
232
|
+
const { provider, lastConfig } = makePipeline("anthropic");
|
|
233
|
+
|
|
234
|
+
// WHEN sending against a Fable model
|
|
235
|
+
await provider.sendMessage([userMessage], {
|
|
236
|
+
config: {
|
|
237
|
+
model: "claude-fable-5",
|
|
238
|
+
temperature: 1,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// THEN temperature: 1 is preserved (it is valid in adaptive mode)
|
|
243
|
+
expect(lastConfig()?.temperature).toBe(1);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -41,6 +41,15 @@ export interface CatalogModel {
|
|
|
41
41
|
longContextPricingThresholdTokens?: number;
|
|
42
42
|
longContextMode?: LongContextMode;
|
|
43
43
|
supportsThinking?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* When true, the model always reasons with adaptive (always-on) thinking and
|
|
46
|
+
* rejects an explicit `thinking: { type: "disabled" }` request (Anthropic
|
|
47
|
+
* 400s such calls). Clients hide the enable/disable thinking toggle for these
|
|
48
|
+
* models — effort stays adjustable — and the daemon drops a disabled thinking
|
|
49
|
+
* config (and any non-1 `temperature`, which adaptive mode also rejects)
|
|
50
|
+
* before dispatching. Implies `supportsThinking`.
|
|
51
|
+
*/
|
|
52
|
+
adaptiveThinkingOnly?: boolean;
|
|
44
53
|
supportsCaching?: boolean;
|
|
45
54
|
supportsVision?: boolean;
|
|
46
55
|
supportsToolUse?: boolean;
|
|
@@ -144,6 +153,24 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
|
|
|
144
153
|
linkLabel: "Open Anthropic Console",
|
|
145
154
|
},
|
|
146
155
|
models: [
|
|
156
|
+
{
|
|
157
|
+
id: "claude-fable-5",
|
|
158
|
+
displayName: "Claude Fable 5",
|
|
159
|
+
contextWindowTokens: 1000000,
|
|
160
|
+
maxOutputTokens: 128000,
|
|
161
|
+
longContextPricingThresholdTokens: 200000,
|
|
162
|
+
supportsThinking: true,
|
|
163
|
+
adaptiveThinkingOnly: true,
|
|
164
|
+
supportsCaching: true,
|
|
165
|
+
supportsVision: true,
|
|
166
|
+
supportsToolUse: true,
|
|
167
|
+
pricing: {
|
|
168
|
+
inputPer1mTokens: 10,
|
|
169
|
+
outputPer1mTokens: 50,
|
|
170
|
+
cacheWritePer1mTokens: 12.5,
|
|
171
|
+
cacheReadPer1mTokens: 1,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
147
174
|
{
|
|
148
175
|
id: "claude-opus-4-8",
|
|
149
176
|
displayName: "Claude Opus 4.8",
|
|
@@ -720,6 +747,24 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
|
|
|
720
747
|
// OpenRouter proxies anthropic/* through Anthropic's Messages API, so
|
|
721
748
|
// prompt caching and cache TTL metadata pass through unchanged and
|
|
722
749
|
// billing matches Anthropic's direct rates.
|
|
750
|
+
{
|
|
751
|
+
id: "anthropic/claude-fable-5",
|
|
752
|
+
displayName: "Claude Fable 5",
|
|
753
|
+
contextWindowTokens: 1000000,
|
|
754
|
+
maxOutputTokens: 128000,
|
|
755
|
+
longContextPricingThresholdTokens: 200000,
|
|
756
|
+
supportsThinking: true,
|
|
757
|
+
adaptiveThinkingOnly: true,
|
|
758
|
+
supportsCaching: true,
|
|
759
|
+
supportsVision: true,
|
|
760
|
+
supportsToolUse: true,
|
|
761
|
+
pricing: {
|
|
762
|
+
inputPer1mTokens: 10,
|
|
763
|
+
outputPer1mTokens: 50,
|
|
764
|
+
cacheWritePer1mTokens: 12.5,
|
|
765
|
+
cacheReadPer1mTokens: 1,
|
|
766
|
+
},
|
|
767
|
+
},
|
|
723
768
|
{
|
|
724
769
|
id: "anthropic/claude-opus-4.8",
|
|
725
770
|
displayName: "Claude Opus 4.8",
|
|
@@ -1227,3 +1272,15 @@ export function getCatalogProviderForModel(
|
|
|
1227
1272
|
);
|
|
1228
1273
|
return matches.length === 1 ? matches[0]?.id : undefined;
|
|
1229
1274
|
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Whether the given model only supports adaptive (always-on) thinking, driven
|
|
1278
|
+
* by the `adaptiveThinkingOnly` capability in the catalog. Matches the model ID
|
|
1279
|
+
* across every provider (a model carries the same id under each provider it is
|
|
1280
|
+
* offered by, e.g. `claude-fable-5` and OpenRouter's `anthropic/claude-fable-5`).
|
|
1281
|
+
*/
|
|
1282
|
+
export function isAdaptiveThinkingOnlyModel(modelId: string): boolean {
|
|
1283
|
+
return PROVIDER_CATALOG.some((p) =>
|
|
1284
|
+
p.models.some((m) => m.id === modelId && m.adaptiveThinkingOnly === true),
|
|
1285
|
+
);
|
|
1286
|
+
}
|
package/src/providers/retry.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
sleep,
|
|
15
15
|
} from "../util/retry.js";
|
|
16
16
|
import { resolveLogitBiasPreset } from "./inference/logit-bias.js";
|
|
17
|
+
import { isAdaptiveThinkingOnlyModel } from "./model-catalog.js";
|
|
17
18
|
import {
|
|
18
19
|
isThinkingConfigDisabled,
|
|
19
20
|
normalizeThinkingConfigForWire,
|
|
@@ -324,6 +325,18 @@ function normalizeSendMessageOptions(
|
|
|
324
325
|
}
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
// Claude Fable always reasons with adaptive thinking and rejects an explicit
|
|
329
|
+
// `thinking: { type: "disabled" }` (Anthropic 400s the request). Drop a
|
|
330
|
+
// disabled thinking config for these models so they fall back to their
|
|
331
|
+
// always-on adaptive thinking; effort and other params are unaffected.
|
|
332
|
+
if (
|
|
333
|
+
typeof nextConfig.model === "string" &&
|
|
334
|
+
isAdaptiveThinkingOnlyModel(nextConfig.model) &&
|
|
335
|
+
isThinkingConfigDisabled(nextConfig.thinking)
|
|
336
|
+
) {
|
|
337
|
+
delete nextConfig.thinking;
|
|
338
|
+
}
|
|
339
|
+
|
|
327
340
|
// thinking is Anthropic-specific on the wire; OpenRouter reads it as a
|
|
328
341
|
// signal for its unified reasoning parameter; Gemini reads `level` from it.
|
|
329
342
|
// Strip it for other providers.
|
|
@@ -405,15 +418,30 @@ function normalizeSendMessageOptions(
|
|
|
405
418
|
// strip `temperature` upstream; non-Anthropic OpenRouter reasoning
|
|
406
419
|
// models don't have this exact constraint).
|
|
407
420
|
const isThinkingTemperatureConflict = (() => {
|
|
408
|
-
|
|
409
|
-
|
|
421
|
+
const model = typeof nextConfig.model === "string" ? nextConfig.model : "";
|
|
422
|
+
// Claude Fable always reasons in adaptive mode, so the `temperature: 1`
|
|
423
|
+
// constraint applies even when no explicit `thinking` config is present
|
|
424
|
+
// (a disabled config was already dropped above). For every other model
|
|
425
|
+
// the constraint only applies when thinking is actually enabled.
|
|
426
|
+
if (!isAdaptiveThinkingOnlyModel(model)) {
|
|
427
|
+
if (nextConfig.thinking == null) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
if (isThinkingConfigDisabled(nextConfig.thinking)) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
410
434
|
const temp = nextConfig.temperature;
|
|
411
|
-
if (typeof temp !== "number")
|
|
412
|
-
|
|
413
|
-
|
|
435
|
+
if (typeof temp !== "number") {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
if (temp === 1) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
if (providerName === "anthropic") {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
414
444
|
if (providerName === "openrouter") {
|
|
415
|
-
const model =
|
|
416
|
-
typeof nextConfig.model === "string" ? nextConfig.model : "";
|
|
417
445
|
return model.startsWith("anthropic/");
|
|
418
446
|
}
|
|
419
447
|
return false;
|