@vellumai/assistant 0.10.7-dev.202607082329.50e70cf → 0.10.7-dev.202607090141.ed32fa2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.7-dev.202607082329.50e70cf",
3
+ "version": "0.10.7-dev.202607090141.ed32fa2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -192,6 +192,8 @@ describe("uploadAttachment", () => {
192
192
  expect(() => uploadAttachment("ok.txt", "text/plain", "AAA")).not.toThrow();
193
193
  });
194
194
 
195
+ // Inserting the 100 MB payload into SQLite can take several seconds on
196
+ // slow CI runners, so this test needs more than the default 5s timeout.
195
197
  test("accepts payload exactly at MAX_UPLOAD_BYTES", () => {
196
198
  // MAX_UPLOAD_BYTES (100 MB) is divisible by 3, so (MAX/3)*4 base64 chars
197
199
  // decodes to exactly MAX bytes with no padding.
@@ -201,7 +203,7 @@ describe("uploadAttachment", () => {
201
203
  expect(() =>
202
204
  uploadAttachment("exact.bin", "application/octet-stream", exactData),
203
205
  ).not.toThrow();
204
- });
206
+ }, 30_000);
205
207
  });
206
208
 
207
209
  // ---------------------------------------------------------------------------
@@ -1622,6 +1622,17 @@ describe("OpenRouterProvider reasoning", () => {
1622
1622
  expect(lastCreateParams!.reasoning).toBeUndefined();
1623
1623
  });
1624
1624
 
1625
+ test("disabled thinking with effort none sends the flat opt-out and no nested reasoning", async () => {
1626
+ const provider = new OpenRouterProvider("or-key", "x-ai/grok-4");
1627
+ await provider.sendMessage([userMsg("hi")], {
1628
+ config: { thinking: { type: "disabled" }, effort: "none" },
1629
+ });
1630
+
1631
+ expect(lastCreateParams).toBeTruthy();
1632
+ expect(lastCreateParams!.reasoning).toBeUndefined();
1633
+ expect(lastCreateParams!.reasoning_effort).toBe("none");
1634
+ });
1635
+
1625
1636
  test("omits reasoning when thinking config is absent", async () => {
1626
1637
  const provider = new OpenRouterProvider("or-key", "x-ai/grok-4");
1627
1638
  await provider.sendMessage([userMsg("hi")], {
@@ -193,6 +193,17 @@ describe("OpenRouter provider.only plumbing", () => {
193
193
  expect(extras.reasoning).toBe(undefined);
194
194
  });
195
195
 
196
+ test("disabled thinking with effort none leaves the opt-out to the flat reasoning_effort field", () => {
197
+ const provider = new ProbeOpenRouterProvider(
198
+ "fake-key",
199
+ "x-ai/grok-4.20-beta",
200
+ );
201
+ const extras = provider.probeExtras({
202
+ config: { thinking: { type: "disabled" }, effort: "none" },
203
+ });
204
+ expect(extras).toEqual({});
205
+ });
206
+
196
207
  test("omitting reasoning avoids 400 from reasoning-only models like DeepSeek R1", () => {
197
208
  const provider = new ProbeOpenRouterProvider(
198
209
  "fake-key",
@@ -190,6 +190,18 @@ describe("VercelAIGatewayProvider", () => {
190
190
  ).toEqual({});
191
191
  });
192
192
 
193
+ test("disabled thinking with effort none nests an explicit reasoning opt-out", () => {
194
+ const provider = new ProbeVercelAIGatewayProvider(
195
+ "fake-key",
196
+ "openai/gpt-5.5",
197
+ );
198
+ expect(
199
+ provider.probeExtras({
200
+ config: { thinking: { type: "disabled" }, effort: "none" },
201
+ }),
202
+ ).toEqual({ reasoning: { effort: "none" } });
203
+ });
204
+
193
205
  test("final wire params carry reasoning object and no flat reasoning_effort", async () => {
194
206
  const provider = new VercelAIGatewayProvider(
195
207
  "fake-key",
@@ -85,7 +85,8 @@ export function registerPluginsCommand(program: Command): void {
85
85
  registerCommand(program, {
86
86
  name: "plugins",
87
87
  transport: "local",
88
- description: "Manage external plugins",
88
+ description:
89
+ "List, search, install, and manage external plugins (`list` shows what is installed, `search` queries the marketplace)",
89
90
  build: (plugins) => {
90
91
  plugins.addHelpText(
91
92
  "after",
@@ -95,9 +95,10 @@ const VELLUM_PROFILE_IMPLS: Record<DefaultProfileKey, DefaultProfileTemplate> =
95
95
  label: "Speed",
96
96
  description: "Fastest responses at lower cost (DeepSeek V4 Flash)",
97
97
  maxTokens: 8192,
98
- // Not "low": Fireworks strips the disabled `thinking` config but would
99
- // still send a non-"none" effort as `reasoning_effort`, making this
100
- // profile pay for reasoning despite thinking being off.
98
+ // Explicit reasoning opt-out. OpenAI-compat APIs default reasoning to
99
+ // "medium" when the field is omitted, and effort-driven providers encode
100
+ // disabled thinking through this same knob (see
101
+ // DISABLED_THINKING_USES_EFFORT_PROVIDERS in providers/retry.ts).
101
102
  effort: "none",
102
103
  thinking: { enabled: false, streamThinking: false },
103
104
  contextWindow: {
@@ -543,6 +543,92 @@ describe("RetryProvider — callSite resolution", () => {
543
543
  expect(config.effort).toBe("none");
544
544
  });
545
545
 
546
+ test("disabled thinking forces effort none for OpenRouter and keeps the wire thinking config", async () => {
547
+ setLlmConfig({
548
+ default: {
549
+ provider: "openrouter",
550
+ model: "x-ai/grok-4",
551
+ effort: "high",
552
+ thinking: { enabled: false, streamThinking: false },
553
+ },
554
+ callSites: { mainAgent: {} },
555
+ });
556
+
557
+ let seen: SendMessageOptions | undefined;
558
+ const wrapped = new RetryProvider(
559
+ makeProvider("openrouter", (options) => {
560
+ seen = options;
561
+ }),
562
+ );
563
+
564
+ await wrapped.sendMessage(DUMMY_MESSAGES, {
565
+ config: { callSite: "mainAgent" },
566
+ });
567
+
568
+ const config = seen?.config as Record<string, unknown>;
569
+ expect(config.effort).toBe("none");
570
+ // OpenRouter is thinking-aware: the wire-shape config still flows down so
571
+ // the client can decide its nested `reasoning` emission.
572
+ expect(config.thinking).toEqual({ type: "disabled" });
573
+ });
574
+
575
+ test("disabled thinking forces effort none for the Vercel AI Gateway", async () => {
576
+ setLlmConfig({
577
+ default: {
578
+ provider: "vercel-ai-gateway",
579
+ model: "xai/grok-4",
580
+ effort: "max",
581
+ thinking: { enabled: false, streamThinking: false },
582
+ },
583
+ callSites: { mainAgent: {} },
584
+ });
585
+
586
+ let seen: SendMessageOptions | undefined;
587
+ const wrapped = new RetryProvider(
588
+ makeProvider("vercel-ai-gateway", (options) => {
589
+ seen = options;
590
+ }),
591
+ );
592
+
593
+ await wrapped.sendMessage(DUMMY_MESSAGES, {
594
+ config: { callSite: "mainAgent" },
595
+ });
596
+
597
+ const config = seen?.config as Record<string, unknown>;
598
+ expect(config.effort).toBe("none");
599
+ expect(config.thinking).toEqual({ type: "disabled" });
600
+ });
601
+
602
+ test("disabled thinking keeps effort for gateway-delegated anthropic/* models", async () => {
603
+ setLlmConfig({
604
+ default: {
605
+ provider: "openrouter",
606
+ model: "anthropic/claude-opus-4-8",
607
+ effort: "high",
608
+ thinking: { enabled: false, streamThinking: false },
609
+ },
610
+ callSites: { mainAgent: {} },
611
+ });
612
+
613
+ let seen: SendMessageOptions | undefined;
614
+ const wrapped = new RetryProvider(
615
+ makeProvider("openrouter", (options) => {
616
+ seen = options;
617
+ }),
618
+ );
619
+
620
+ await wrapped.sendMessage(DUMMY_MESSAGES, {
621
+ config: { callSite: "mainAgent" },
622
+ });
623
+
624
+ // The Anthropic Messages delegate honors a disabled `thinking` natively;
625
+ // `effort` keeps its Anthropic meaning and must match the direct
626
+ // `anthropic` provider's behavior.
627
+ const config = seen?.config as Record<string, unknown>;
628
+ expect(config.effort).toBe("high");
629
+ expect(config.thinking).toEqual({ type: "disabled" });
630
+ });
631
+
546
632
  test("preserves thinking + level for Gemini provider", async () => {
547
633
  setLlmConfig({
548
634
  default: {
@@ -528,3 +528,115 @@ describe("OpenAIChatCompletionsProvider reasoning parsing", () => {
528
528
  expect(params.messages[0].reasoning_content).toBe("deepseek thinking");
529
529
  });
530
530
  });
531
+
532
+ describe("reasoning opt-out rejection fallback", () => {
533
+ function stubProviderWithErrors(
534
+ errors: unknown[],
535
+ chunks: MockChunk[],
536
+ ): { provider: OpenAIChatCompletionsProvider; requests: unknown[] } {
537
+ const provider = new OpenAIChatCompletionsProvider(
538
+ "test-key",
539
+ "test-model",
540
+ );
541
+ const requests: unknown[] = [];
542
+ const pending = [...errors];
543
+ (provider as unknown as { client: unknown }).client = {
544
+ chat: {
545
+ completions: {
546
+ create: async (params: unknown) => {
547
+ // Snapshot: the fallback mutates `params` between attempts.
548
+ requests.push(JSON.parse(JSON.stringify(params)));
549
+ const error = pending.shift();
550
+ if (error !== undefined) {
551
+ throw error;
552
+ }
553
+ return makeStream(chunks);
554
+ },
555
+ },
556
+ },
557
+ };
558
+ return { provider, requests };
559
+ }
560
+
561
+ const okChunks: MockChunk[] = [
562
+ {
563
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
564
+ usage: { prompt_tokens: 1, completion_tokens: 1 },
565
+ },
566
+ ];
567
+
568
+ function rejection(message: string, status = 400): Error {
569
+ return Object.assign(new Error(message), { status });
570
+ }
571
+
572
+ test("retries once without reasoning params when a model rejects the explicit opt-out", async () => {
573
+ const { provider, requests } = stubProviderWithErrors(
574
+ [rejection("reasoning_effort 'none' is not supported for this model")],
575
+ okChunks,
576
+ );
577
+
578
+ const response = await provider.sendMessage(
579
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
580
+ { config: { effort: "none" } },
581
+ );
582
+
583
+ expect(requests).toHaveLength(2);
584
+ const first = requests[0] as { reasoning_effort?: string };
585
+ const second = requests[1] as {
586
+ reasoning_effort?: string;
587
+ reasoning?: unknown;
588
+ };
589
+ expect(first.reasoning_effort).toBe("none");
590
+ expect(second.reasoning_effort).toBeUndefined();
591
+ expect(second.reasoning).toBeUndefined();
592
+ const text = response.content.find((b) => b.type === "text") as
593
+ | { type: "text"; text: string }
594
+ | undefined;
595
+ expect(text?.text).toBe("ok");
596
+ });
597
+
598
+ test("does not retry when the request did not opt out of reasoning", async () => {
599
+ const { provider, requests } = stubProviderWithErrors(
600
+ [rejection("reasoning_effort is invalid")],
601
+ okChunks,
602
+ );
603
+
604
+ await expect(
605
+ provider.sendMessage(
606
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
607
+ { config: { effort: "high" } },
608
+ ),
609
+ ).rejects.toThrow();
610
+ expect(requests).toHaveLength(1);
611
+ });
612
+
613
+ test("does not retry a 4xx that does not name reasoning", async () => {
614
+ const { provider, requests } = stubProviderWithErrors(
615
+ [rejection("invalid api key")],
616
+ okChunks,
617
+ );
618
+
619
+ await expect(
620
+ provider.sendMessage(
621
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
622
+ { config: { effort: "none" } },
623
+ ),
624
+ ).rejects.toThrow();
625
+ expect(requests).toHaveLength(1);
626
+ });
627
+
628
+ test("does not retry server errors", async () => {
629
+ const { provider, requests } = stubProviderWithErrors(
630
+ [rejection("reasoning backend unavailable", 500)],
631
+ okChunks,
632
+ );
633
+
634
+ await expect(
635
+ provider.sendMessage(
636
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
637
+ { config: { effort: "none" } },
638
+ ),
639
+ ).rejects.toThrow();
640
+ expect(requests).toHaveLength(1);
641
+ });
642
+ });
@@ -3,6 +3,7 @@ import OpenAI from "openai";
3
3
  import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../prompts/cache-boundary.js";
4
4
  import { isAbortReason } from "../../util/abort-reasons.js";
5
5
  import { ProviderError } from "../../util/errors.js";
6
+ import { getLogger } from "../../util/logger.js";
6
7
  import { extractRetryAfterMs } from "../../util/retry.js";
7
8
  import { escapeXmlAttr } from "../../util/xml.js";
8
9
  import { base64Source, resolveMediaReferences } from "../media-resolve.js";
@@ -150,6 +151,8 @@ export interface OpenAIChatCompletionsProviderOptions {
150
151
  coerceObjectArgsToJsonString?: boolean;
151
152
  }
152
153
 
154
+ const log = getLogger("chat-completions");
155
+
153
156
  /** Wire-level reasoning_effort values. The OpenAI SDK type doesn't include
154
157
  * `"max"`, but Fireworks accepts it for DeepSeek V4; the assignment to
155
158
  * `params.reasoning_effort` casts through this union. */
@@ -188,6 +191,35 @@ export function clampReasoningEffort(
188
191
  : value;
189
192
  }
190
193
 
194
+ /**
195
+ * True when the request carried an explicit reasoning opt-out (`"none"` sent
196
+ * as flat `reasoning_effort` or nested `reasoning.effort`) and the provider
197
+ * rejected it with a 4xx that names the reasoning field. Reasoning-only
198
+ * models (e.g. DeepSeek R1) reject the opt-out rather than ignore it; that
199
+ * one case is worth a single retry with the reasoning params stripped —
200
+ * model-default reasoning beats a hard failure.
201
+ */
202
+ function isReasoningOptOutRejection(error: unknown, params: unknown): boolean {
203
+ const p = params as {
204
+ reasoning_effort?: unknown;
205
+ reasoning?: { effort?: unknown } | null;
206
+ };
207
+ const optedOut =
208
+ p.reasoning_effort === "none" ||
209
+ (typeof p.reasoning === "object" &&
210
+ p.reasoning !== null &&
211
+ p.reasoning.effort === "none");
212
+ if (!optedOut) {
213
+ return false;
214
+ }
215
+ const status = (error as { status?: unknown }).status;
216
+ if (typeof status !== "number" || status < 400 || status >= 500) {
217
+ return false;
218
+ }
219
+ const message = error instanceof Error ? error.message : String(error);
220
+ return /reasoning/i.test(message);
221
+ }
222
+
191
223
  /**
192
224
  * Translate the neutral (Anthropic-shaped) `tool_choice` carried on the call
193
225
  * config into the OpenAI chat-completions wire format. Callers express
@@ -477,12 +509,32 @@ export class OpenAIChatCompletionsProvider implements Provider {
477
509
  ...this.requestHeaders,
478
510
  ...(usageAttributionHeaders ?? {}),
479
511
  };
480
- const stream = await this.client.chat.completions.create(params, {
481
- signal: timeoutSignal,
482
- ...(Object.keys(requestHeaders).length > 0
483
- ? { headers: requestHeaders }
484
- : {}),
485
- });
512
+ const createStream = () =>
513
+ this.client.chat.completions.create(params, {
514
+ signal: timeoutSignal,
515
+ ...(Object.keys(requestHeaders).length > 0
516
+ ? { headers: requestHeaders }
517
+ : {}),
518
+ });
519
+ let stream: Awaited<ReturnType<typeof createStream>>;
520
+ try {
521
+ stream = await createStream();
522
+ } catch (error) {
523
+ if (!isReasoningOptOutRejection(error, params)) {
524
+ throw error;
525
+ }
526
+ log.warn(
527
+ {
528
+ provider: this.name,
529
+ model: modelOverride ?? this.model,
530
+ error: error instanceof Error ? error.message : String(error),
531
+ },
532
+ "Model rejected the explicit reasoning opt-out; retrying without reasoning params",
533
+ );
534
+ delete params.reasoning_effort;
535
+ delete (params as unknown as Record<string, unknown>).reasoning;
536
+ stream = await createStream();
537
+ }
486
538
 
487
539
  for await (const chunk of stream) {
488
540
  const choice = chunk.choices[0];
@@ -59,8 +59,29 @@ const DISABLED_THINKING_USES_EFFORT_PROVIDERS = new Set([
59
59
  "openai",
60
60
  "fireworks",
61
61
  "together",
62
+ "openrouter",
63
+ "vercel-ai-gateway",
62
64
  ]);
63
65
 
66
+ // Whether a disabled `thinking` config must be encoded as `effort: "none"`
67
+ // for this provider/model. Gateway calls that delegate `anthropic/*` models
68
+ // to the Anthropic Messages API are excluded: the delegate honors a disabled
69
+ // `thinking` natively and `effort` keeps its Anthropic meaning there, so
70
+ // forcing it would diverge from the direct `anthropic` provider.
71
+ function disabledThinkingForcesEffortNone(
72
+ providerName: string,
73
+ model: unknown,
74
+ ): boolean {
75
+ if (!DISABLED_THINKING_USES_EFFORT_PROVIDERS.has(providerName)) {
76
+ return false;
77
+ }
78
+ return !(
79
+ isAnthropicDelegatingGateway(providerName) &&
80
+ typeof model === "string" &&
81
+ isAnthropicModel(model)
82
+ );
83
+ }
84
+
64
85
  /**
65
86
  * Providers that consume the `thinking` config. Anthropic uses it directly on
66
87
  * the wire; OpenRouter forwards it on its Anthropic delegate path and
@@ -386,7 +407,7 @@ function normalizeSendMessageOptions(
386
407
 
387
408
  if (
388
409
  isThinkingConfigDisabled(nextConfig.thinking) &&
389
- DISABLED_THINKING_USES_EFFORT_PROVIDERS.has(providerName)
410
+ disabledThinkingForcesEffortNone(providerName, nextConfig.model)
390
411
  ) {
391
412
  nextConfig.effort = "none";
392
413
  }