@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  openapi: 3.1.0
4
4
  info:
5
5
  title: Vellum Assistant API
6
- version: 0.8.9
6
+ version: 0.8.10
7
7
  description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
8
8
  servers:
9
9
  - url: http://127.0.0.1:7821
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.9",
3
+ "version": "0.8.10-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
+ }
@@ -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
- if (nextConfig.thinking == null) return false;
409
- if (isThinkingConfigDisabled(nextConfig.thinking)) return false;
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") return false;
412
- if (temp === 1) return false;
413
- if (providerName === "anthropic") return true;
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;