@plurnk/plurnk-providers 1.10.1 → 1.12.0

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.
Files changed (45) hide show
  1. package/.env.defaults +6 -0
  2. package/SPEC.md +69 -32
  3. package/dist/AiSdkProvider.d.ts +6 -2
  4. package/dist/AiSdkProvider.d.ts.map +1 -1
  5. package/dist/AiSdkProvider.js +42 -3
  6. package/dist/AiSdkProvider.js.map +1 -1
  7. package/dist/accounting.d.ts.map +1 -1
  8. package/dist/accounting.js +13 -3
  9. package/dist/accounting.js.map +1 -1
  10. package/dist/aiSdkTransport.d.ts +1 -0
  11. package/dist/aiSdkTransport.d.ts.map +1 -1
  12. package/dist/aiSdkTransport.js +40 -11
  13. package/dist/aiSdkTransport.js.map +1 -1
  14. package/dist/catalogProvider.d.ts.map +1 -1
  15. package/dist/catalogProvider.js +96 -35
  16. package/dist/catalogProvider.js.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/notices.d.ts +1 -1
  22. package/dist/notices.d.ts.map +1 -1
  23. package/dist/sdkModels.d.ts +13 -1
  24. package/dist/sdkModels.d.ts.map +1 -1
  25. package/dist/sdkModels.js +103 -5
  26. package/dist/sdkModels.js.map +1 -1
  27. package/dist/types.d.ts +2 -8
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/types.js.map +1 -1
  30. package/package.json +7 -6
  31. package/src/AiSdkProvider.test.ts +21 -4
  32. package/src/AiSdkProvider.ts +55 -7
  33. package/src/ProviderRegistry.test.ts +6 -5
  34. package/src/accounting.test.ts +39 -0
  35. package/src/accounting.ts +13 -4
  36. package/src/aiSdkTransport.test.ts +41 -1
  37. package/src/aiSdkTransport.ts +38 -9
  38. package/src/catalogProvider.test.ts +163 -4
  39. package/src/catalogProvider.ts +131 -39
  40. package/src/discover.test.ts +1 -1
  41. package/src/index.ts +2 -1
  42. package/src/notices.ts +1 -1
  43. package/src/sdkModels.test.ts +42 -1
  44. package/src/sdkModels.ts +144 -7
  45. package/src/types.ts +4 -15
@@ -2,7 +2,11 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { APICallError } from "ai";
4
4
  import { ProviderTimeoutError } from "./errors.ts";
5
- import { executeOpenAICompatible, normalizeRetryAttemptError } from "./aiSdkTransport.ts";
5
+ import {
6
+ executeOpenAICompatible,
7
+ normalizeRetryAttemptError,
8
+ transportFailureOutputObserved,
9
+ } from "./aiSdkTransport.ts";
6
10
 
7
11
  const request = {
8
12
  url: "https://example.test/v1/chat/completions",
@@ -41,6 +45,42 @@ test("the transport performs exactly one physical request", async () => {
41
45
  assert.equal(calls, 1);
42
46
  });
43
47
 
48
+ test("stream failure evidence distinguishes semantic output from pre-output failure ({§provider-connectivity})", async (t) => {
49
+ const cases = [
50
+ { name: "text", delta: { content: "partial" }, expected: true },
51
+ { name: "reasoning", delta: { reasoning_content: "partial" }, expected: true },
52
+ { name: "empty", delta: {}, expected: false },
53
+ ] as const;
54
+ for (const specimen of cases) {
55
+ await t.test(specimen.name, async () => {
56
+ await assert.rejects(
57
+ executeOpenAICompatible({
58
+ ...request,
59
+ streaming: true,
60
+ fetch: async () => new Response(new ReadableStream({
61
+ start(controller) {
62
+ controller.enqueue(new TextEncoder().encode(
63
+ `data: ${JSON.stringify({
64
+ id: "interrupted",
65
+ object: "chat.completion.chunk",
66
+ created: 1,
67
+ model: "test-model",
68
+ choices: [{ index: 0, delta: specimen.delta, finish_reason: null }],
69
+ })}\n\n`,
70
+ ));
71
+ setTimeout(() => controller.error(new TypeError("terminated")), 10);
72
+ },
73
+ }), { status: 200 }),
74
+ }),
75
+ (error) => {
76
+ assert.equal(transportFailureOutputObserved(error), specimen.expected);
77
+ return true;
78
+ },
79
+ );
80
+ });
81
+ }
82
+ });
83
+
44
84
  test("the adapter preserves PLURNK request extensions and response evidence", async () => {
45
85
  let body: Record<string, unknown> | undefined;
46
86
  const responseBody = {
@@ -1,6 +1,6 @@
1
1
  import { createOpenAICompatible, type ProviderErrorStructure } from "@ai-sdk/openai-compatible";
2
2
  import { APICallError, generateText, streamText, type CallWarning, type JSONValue, type LanguageModel, type LanguageModelUsage } from "ai";
3
- import { z } from "zod/v4";
3
+ import { z } from "zod";
4
4
  import type { ChatMessage, ProviderAttemptFinishReason, ProviderChargeEvidence, ProviderReasoningObserver, ProviderUsage, TokenLogprob } from "./types.ts";
5
5
  import { normalizeUsage, type RawUsage } from "./usage.ts";
6
6
  import { emitWarningOnce } from "./warnings.ts";
@@ -222,13 +222,36 @@ const transportTimeout = (
222
222
  };
223
223
 
224
224
  const streamFailureValues = new WeakMap<object, readonly unknown[]>();
225
+ const streamFailureOutput = new WeakSet<object>();
225
226
 
226
227
  const retainStreamFailureValues = <T extends object>(source: object, target: T): T => {
227
228
  const values = streamFailureValues.get(source);
228
229
  if (values !== undefined) streamFailureValues.set(target, values);
230
+ if (streamFailureOutput.has(source)) streamFailureOutput.add(target);
229
231
  return target;
230
232
  };
231
233
 
234
+ const preserveStreamFailure = (
235
+ error: unknown,
236
+ rawChunks: readonly unknown[],
237
+ outputObserved: boolean,
238
+ ): void => {
239
+ if (typeof error !== "object" || error === null) return;
240
+ streamFailureValues.set(error, [...rawChunks, error]);
241
+ if (outputObserved) streamFailureOutput.add(error);
242
+ };
243
+
244
+ export const transportFailureOutputObserved = (error: unknown): boolean => {
245
+ const seen = new Set<unknown>();
246
+ let current = error;
247
+ while (typeof current === "object" && current !== null && !seen.has(current)) {
248
+ if (streamFailureOutput.has(current)) return true;
249
+ seen.add(current);
250
+ current = (current as { cause?: unknown }).cause;
251
+ }
252
+ return false;
253
+ };
254
+
232
255
  export const normalizeRetryAttemptError = (error: unknown): unknown => {
233
256
  if (!APICallError.isInstance(error)) {
234
257
  // Attempt, first-content, and stream-idle deadlines are retryable network
@@ -410,17 +433,23 @@ const executeModelOnce = async (
410
433
  });
411
434
  const rawChunks: unknown[] = [];
412
435
  let streamError: unknown;
413
- for await (const part of result.fullStream) {
414
- if (part.type === "raw") rawChunks.push(part.rawValue);
415
- if (part.type === "reasoning-delta" && part.text.length > 0) {
416
- request.observeReasoning?.(part.text);
436
+ let outputObserved = false;
437
+ try {
438
+ for await (const part of result.fullStream) {
439
+ if (part.type === "raw") rawChunks.push(part.rawValue);
440
+ if (part.type === "text-delta" && part.text.length > 0) outputObserved = true;
441
+ if (part.type === "reasoning-delta" && part.text.length > 0) {
442
+ outputObserved = true;
443
+ request.observeReasoning?.(part.text);
444
+ }
445
+ if (part.type === "error") streamError ??= part.error;
417
446
  }
418
- if (part.type === "error") streamError ??= part.error;
447
+ } catch (error) {
448
+ preserveStreamFailure(error, rawChunks, outputObserved);
449
+ throw error;
419
450
  }
420
451
  if (streamError !== undefined) {
421
- if (typeof streamError === "object" && streamError !== null) {
422
- streamFailureValues.set(streamError, [...rawChunks, streamError]);
423
- }
452
+ preserveStreamFailure(streamError, rawChunks, outputObserved);
424
453
  throw streamError;
425
454
  }
426
455
  const evidence = extractEvidence(rawChunks);
@@ -48,7 +48,7 @@ test("provider adapters advertise only reasoning policies they can preserve", ()
48
48
  PLURNK_PROVIDERS_REASONING: "adaptive",
49
49
  PLURNK_PROVIDERS_PROVIDER_DEEPSEEK_REASONING_STYLE: "thinking_effort",
50
50
  }, "deepseek-v4-flash");
51
- assert.deepEqual(deepseek?.supportedReasoningPolicies, ["off", "adaptive", "high"]);
51
+ assert.deepEqual(deepseek?.supportedReasoningPolicies, ["off", "adaptive", "low", "high"]);
52
52
 
53
53
  assert.throws(
54
54
  () => catalogProviderFromEnv("deepseek", {
@@ -57,7 +57,7 @@ test("provider adapters advertise only reasoning policies they can preserve", ()
57
57
  PLURNK_PROVIDERS_REASONING: "medium",
58
58
  PLURNK_PROVIDERS_PROVIDER_DEEPSEEK_REASONING_STYLE: "thinking_effort",
59
59
  }, "deepseek-v4-flash"),
60
- /reasoning policy 'medium' is unsupported; supported policies: off, adaptive, high/,
60
+ /reasoning policy 'medium' is unsupported; supported policies: off, adaptive, low, high/,
61
61
  );
62
62
 
63
63
  const mistral = catalogProviderFromEnv("mistral", {
@@ -82,6 +82,115 @@ test("provider adapters advertise only reasoning policies they can preserve", ()
82
82
  assert.deepEqual(gemini?.supportedReasoningPolicies, ["adaptive", "low", "medium", "high"], "Gemini 3's mandatory minimum is not advertised as off");
83
83
  });
84
84
 
85
+ test("Models.dev controls Cloudflare's exact effort vocabulary", async () => {
86
+ const bodies: Record<string, unknown>[] = [];
87
+ mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
88
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
89
+ return new Response([
90
+ `data: ${JSON.stringify({
91
+ id: "cloudflare-effort",
92
+ object: "chat.completion.chunk",
93
+ created: 1,
94
+ model: "@cf/qwen/qwen3.8-27b",
95
+ choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }],
96
+ })}`,
97
+ "data: [DONE]",
98
+ ].join("\n\n"), { headers: { "content-type": "text/event-stream" } });
99
+ });
100
+ const cloudflareEnv = {
101
+ ...env,
102
+ CLOUDFLARE_ACCOUNT_ID: "account",
103
+ CLOUDFLARE_API_KEY: "token",
104
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_REASONING_STYLE: "effort_required",
105
+ };
106
+ const low = catalogProviderFromEnv("cloudflare", {
107
+ ...cloudflareEnv,
108
+ PLURNK_PROVIDERS_REASONING: "low",
109
+ }, "@cf/qwen/qwen3.8-27b");
110
+ assert.deepEqual(low?.supportedReasoningPolicies, ["adaptive", "low", "medium"]);
111
+ await low?.generate({ workerId: "cloudflare-low", messages: [{ role: "user", content: "hello" }] });
112
+
113
+ const adaptive = catalogProviderFromEnv("cloudflare", {
114
+ ...cloudflareEnv,
115
+ PLURNK_PROVIDERS_REASONING: "adaptive",
116
+ }, "@cf/qwen/qwen3.8-27b");
117
+ await adaptive?.generate({ workerId: "cloudflare-adaptive", messages: [{ role: "user", content: "hello" }] });
118
+
119
+ const nonReasoning = catalogProviderFromEnv("cloudflare", {
120
+ ...cloudflareEnv,
121
+ PLURNK_PROVIDERS_REASONING: "adaptive",
122
+ }, "@cf/ibm-granite/granite-4.0-h-micro");
123
+ assert.deepEqual(nonReasoning?.supportedReasoningPolicies, ["off", "adaptive"]);
124
+ await nonReasoning?.generate({ workerId: "cloudflare-granite", messages: [{ role: "user", content: "hello" }] });
125
+
126
+ const ungradedReasoner = catalogProviderFromEnv("cloudflare", {
127
+ ...cloudflareEnv,
128
+ PLURNK_PROVIDERS_REASONING: "adaptive",
129
+ }, "@cf/zai-org/glm-5.3-flash");
130
+ assert.deepEqual(ungradedReasoner?.supportedReasoningPolicies, ["adaptive"]);
131
+ await ungradedReasoner?.generate({ workerId: "cloudflare-ungraded", messages: [{ role: "user", content: "hello" }] });
132
+
133
+ assert.deepEqual(bodies.map((body) => body.reasoning_effort), ["low", "xhigh", undefined, undefined]);
134
+ assert.throws(
135
+ () => catalogProviderFromEnv("cloudflare", cloudflareEnv, "@cf/qwen/qwen3.8-27b"),
136
+ /reasoning policy 'off' is unsupported; supported policies: adaptive, low, medium/,
137
+ );
138
+ assert.throws(
139
+ () => catalogProviderFromEnv("cloudflare", {
140
+ ...cloudflareEnv,
141
+ PLURNK_PROVIDERS_REASONING: "high",
142
+ }, "@cf/qwen/qwen3.8-27b"),
143
+ /reasoning policy 'high' is unsupported; supported policies: adaptive, low, medium/,
144
+ );
145
+ });
146
+
147
+ test("an operator-declared effort vocabulary extends Models.dev's for a provider's reasoning routes (#439)", async () => {
148
+ const bodies: Record<string, unknown>[] = [];
149
+ mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
150
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
151
+ return new Response([
152
+ `data: ${JSON.stringify({
153
+ id: "cloudflare-declared",
154
+ object: "chat.completion.chunk",
155
+ created: 1,
156
+ model: "@cf/zai-org/glm-5.3-flash",
157
+ choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }],
158
+ })}`,
159
+ "data: [DONE]",
160
+ ].join("\n\n"), { headers: { "content-type": "text/event-stream" } });
161
+ });
162
+ const declaredEnv = {
163
+ ...env,
164
+ CLOUDFLARE_ACCOUNT_ID: "account",
165
+ CLOUDFLARE_API_KEY: "token",
166
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_REASONING_STYLE: "effort_required",
167
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_REASONING_EFFORTS: "low, medium,high",
168
+ };
169
+ // Models.dev lists this route as reasoning with no effort vocabulary; the declaration supplies one.
170
+ const low = catalogProviderFromEnv("cloudflare", { ...declaredEnv, PLURNK_PROVIDERS_REASONING: "low" }, "@cf/zai-org/glm-5.3-flash");
171
+ assert.deepEqual(low?.supportedReasoningPolicies, ["adaptive", "low", "medium", "high"]);
172
+ await low?.generate({ workerId: "declared-low", messages: [{ role: "user", content: "hello" }] });
173
+ const adaptive = catalogProviderFromEnv("cloudflare", { ...declaredEnv, PLURNK_PROVIDERS_REASONING: "adaptive" }, "@cf/zai-org/glm-5.3-flash");
174
+ await adaptive?.generate({ workerId: "declared-adaptive", messages: [{ role: "user", content: "hello" }] });
175
+ assert.deepEqual(bodies.map((body) => body.reasoning_effort), ["low", "high"], "a fixed level keeps its name; adaptive takes the strongest declared effort");
176
+ // A declared `none` admits `off` on an effort transport; the catalog's own vocabulary stays in the union.
177
+ const withOff = catalogProviderFromEnv("cloudflare", {
178
+ ...declaredEnv,
179
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_REASONING_EFFORTS: "none,high",
180
+ }, "@cf/qwen/qwen3.8-27b");
181
+ assert.deepEqual(withOff?.supportedReasoningPolicies, ["off", "adaptive", "low", "medium", "high"]);
182
+ // The declaration never turns a non-reasoning route into a reasoning one.
183
+ const nonReasoning = catalogProviderFromEnv("cloudflare", { ...declaredEnv, PLURNK_PROVIDERS_REASONING: "adaptive" }, "@cf/ibm-granite/granite-4.0-h-micro");
184
+ assert.deepEqual(nonReasoning?.supportedReasoningPolicies, ["off", "adaptive"]);
185
+ assert.throws(
186
+ () => catalogProviderFromEnv("cloudflare", {
187
+ ...declaredEnv,
188
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_REASONING_EFFORTS: "low,turbo",
189
+ }, "@cf/zai-org/glm-5.3-flash"),
190
+ /PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_REASONING_EFFORTS has invalid value "turbo"; declarable efforts: none, minimal, low, medium, high, xhigh, max/,
191
+ );
192
+ });
193
+
85
194
  test("an operator context window caps catalog physics and percentage output policy", () => {
86
195
  const provider = catalogProviderFromEnv("openai", {
87
196
  ...env,
@@ -153,7 +262,7 @@ test("official AI SDK provider owns the native request while PLURNK owns call se
153
262
  assert.equal(calls[0]?.body.prompt_cache_key, "worker", "the official OpenAI SDK projects the documented affinity key");
154
263
  });
155
264
 
156
- test("xAI's native chat contract affirmatively requests adaptive high and caps the complete reasoning response", async () => {
265
+ test("xAI's native chat contract requests its strongest cataloged effort and caps the complete reasoning response", async () => {
157
266
  let call: { headers: Headers; body: Record<string, unknown> } | undefined;
158
267
  mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
159
268
  call = {
@@ -206,7 +315,7 @@ test("xAI's native chat contract affirmatively requests adaptive high and caps t
206
315
  });
207
316
 
208
317
  assert.equal(call?.body.max_completion_tokens, 16);
209
- assert.equal(call?.body.reasoning_effort, "high", "adaptive is affirmative on xAI's graded route");
318
+ assert.equal(call?.body.reasoning_effort, "xhigh", "adaptive uses the strongest route-advertised generic effort");
210
319
  assert.equal("max_tokens" in (call?.body ?? {}), false);
211
320
  assert.equal(call?.headers.get("x-grok-conv-id"), "xai-worker");
212
321
  assert.equal(result?.assistant.reasoning, "consider");
@@ -278,6 +387,50 @@ test("Cerebras explicit reasoning activation needs no operator effort or token b
278
387
  assert.equal(result?.accounting[0]?.usage?.outputTokenDetails?.reasoningTokens, 1);
279
388
  });
280
389
 
390
+ test("Meta Muse adaptive reasoning is requested even when the endpoint returns no readable trace", async () => {
391
+ let body: Record<string, unknown> | undefined;
392
+ mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
393
+ body = JSON.parse(String(init?.body)) as Record<string, unknown>;
394
+ return new Response([
395
+ `data: ${JSON.stringify({
396
+ id: "chatcmpl-meta",
397
+ object: "chat.completion.chunk",
398
+ created: 1,
399
+ model: "muse-spark-1.2-contributor",
400
+ choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }],
401
+ })}`,
402
+ `data: ${JSON.stringify({
403
+ id: "chatcmpl-meta",
404
+ object: "chat.completion.chunk",
405
+ created: 2,
406
+ model: "muse-spark-1.2-contributor",
407
+ choices: [],
408
+ usage: {
409
+ prompt_tokens: 2,
410
+ completion_tokens: 38,
411
+ total_tokens: 40,
412
+ completion_tokens_details: { reasoning_tokens: 37 },
413
+ },
414
+ })}`,
415
+ "data: [DONE]",
416
+ ].join("\n\n"), { headers: { "content-type": "text/event-stream" } });
417
+ });
418
+
419
+ const provider = catalogProviderFromEnv("meta", {
420
+ ...env,
421
+ META_MODEL_API_KEY: "test-key",
422
+ PLURNK_PROVIDERS_REASONING: "adaptive",
423
+ }, "muse-spark-1.2-contributor");
424
+ const result = await provider?.generate({
425
+ workerId: "worker",
426
+ messages: [{ role: "user", content: "hello" }],
427
+ });
428
+
429
+ assert.equal(body?.reasoning_effort, "xhigh");
430
+ assert.equal(result?.assistant.reasoning, null);
431
+ assert.equal(result?.accounting[0]?.usage?.outputTokenDetails?.reasoningTokens, 37);
432
+ });
433
+
281
434
  test("Google adaptive reasoning requests and preserves readable thought summaries", async () => {
282
435
  const bodies: Array<{
283
436
  generationConfig?: { thinkingConfig?: { includeThoughts?: boolean; thinkingLevel?: string; thinkingBudget?: number } };
@@ -412,6 +565,7 @@ test("native Anthropic adaptive policy uses adaptive thinking rather than a fixe
412
565
  contextWindow: 16_384,
413
566
  maxOutputTokens: 8_192,
414
567
  reasoning: true,
568
+ reasoningOptions: [{ type: "toggle" }],
415
569
  attachment: true,
416
570
  toolCall: true,
417
571
  modalities: { input: ["text", "image"], output: ["text"] },
@@ -543,8 +697,10 @@ test("cataloged unknown model fails unless its context is explicit", () => {
543
697
  ...env,
544
698
  XAI_API_KEY: "test-key",
545
699
  PLURNK_PROVIDERS_CONTEXT_WINDOW: "8192",
700
+ PLURNK_PROVIDERS_REASONING: "adaptive",
546
701
  }, "not-in-the-catalog");
547
702
  assert.equal(provider?.contextWindow, 8192);
703
+ assert.deepEqual(provider?.supportedReasoningPolicies, ["off", "adaptive"]);
548
704
  });
549
705
 
550
706
  test("Models.dev is the only fallback rate table", async () => {
@@ -570,6 +726,8 @@ test("Models.dev is the only fallback rate table", async () => {
570
726
  const cataloged = catalogProviderFromEnv("deepseek", {
571
727
  ...env,
572
728
  DEEPSEEK_API_KEY: "test-key",
729
+ PLURNK_PROVIDERS_REASONING: "adaptive",
730
+ PLURNK_PROVIDERS_PROVIDER_DEEPSEEK_REASONING_STYLE: "thinking_effort",
573
731
  }, "deepseek-v4-flash");
574
732
  assert.notEqual(cataloged, null);
575
733
  const catalogedResponse = await cataloged!.generate({ workerId: "cataloged", messages: [] });
@@ -583,6 +741,7 @@ test("Models.dev is the only fallback rate table", async () => {
583
741
  ...env,
584
742
  XAI_API_KEY: "test-key",
585
743
  PLURNK_PROVIDERS_CONTEXT_WINDOW: "8192",
744
+ PLURNK_PROVIDERS_REASONING: "adaptive",
586
745
  }, "not-in-the-catalog");
587
746
  const uncatalogedResponse = await uncataloged!.generate({ workerId: "uncataloged", messages: [] });
588
747
  assert.deepEqual(uncatalogedResponse.accounting[0]?.cost, {
@@ -1,4 +1,9 @@
1
- import { lookupProvider, resolveModel, type ModelInfo } from "@plurnk/plurnk-models";
1
+ import {
2
+ lookupProvider,
3
+ resolveModel,
4
+ type ModelInfo,
5
+ type ModelReasoningEffort,
6
+ } from "@plurnk/plurnk-models";
2
7
  import {
3
8
  contextWindowFromEnv,
4
9
  effectiveContextWindow,
@@ -12,7 +17,13 @@ import {
12
17
  reasoningFromEnv,
13
18
  reasoningResponseStyleFromEnv,
14
19
  } from "./env.ts";
15
- import AiSdkProvider, { type AiSdkProviderConfig, type GrammarStyle, type ReasoningStyle } from "./AiSdkProvider.ts";
20
+ import AiSdkProvider, {
21
+ type AiSdkProviderConfig,
22
+ type CompatibleReasoningEffort,
23
+ type GrammarStyle,
24
+ type NativeReasoningEffort,
25
+ type ReasoningStyle,
26
+ } from "./AiSdkProvider.ts";
16
27
  import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
17
28
  import { providerSource } from "./notices.ts";
18
29
  import type { Provider, ProviderCostNormalizer } from "./types.ts";
@@ -32,7 +43,7 @@ const reasoningStyleFromEnv = (
32
43
  if (value === undefined || value.length === 0) return undefined;
33
44
  const styles: readonly ReasoningStyle[] = [
34
45
  "none", "think", "include_reasoning", "effort",
35
- "effort_explicit", "thinking_effort", "template", "anthropic",
46
+ "effort_explicit", "effort_required", "thinking_effort", "template", "anthropic",
36
47
  ];
37
48
  if (!styles.includes(value as ReasoningStyle)) {
38
49
  throw new Error(`${name} provider: PLURNK_PROVIDERS_PROVIDER_${prefix}_REASONING_STYLE has invalid value "${value}"`);
@@ -40,23 +51,92 @@ const reasoningStyleFromEnv = (
40
51
  return value as ReasoningStyle;
41
52
  };
42
53
 
54
+ // {§provider-reasoning-policy} — the operator's affirmative declaration of efforts a provider's
55
+ // reasoning routes accept beyond Models.dev; the daemon adds none on its own (#439).
56
+ const DECLARABLE_EFFORTS: ReadonlySet<ModelReasoningEffort> = new Set([
57
+ "none", "minimal", "low", "medium", "high", "xhigh", "max",
58
+ ]);
59
+ const declaredEffortsFromEnv = (
60
+ env: NodeJS.ProcessEnv,
61
+ name: string,
62
+ ): readonly ModelReasoningEffort[] => {
63
+ const prefix = name.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
64
+ const key = `PLURNK_PROVIDERS_PROVIDER_${prefix}_REASONING_EFFORTS`;
65
+ const value = env[key];
66
+ if (value === undefined || value.trim().length === 0) return [];
67
+ const efforts = value.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
68
+ const invalid = efforts.find((effort) => !DECLARABLE_EFFORTS.has(effort as ModelReasoningEffort));
69
+ if (invalid !== undefined) {
70
+ throw new Error(`${name} provider: ${key} has invalid value "${invalid}"; declarable efforts: ${[...DECLARABLE_EFFORTS].join(", ")}`);
71
+ }
72
+ return [...new Set(efforts as ModelReasoningEffort[])];
73
+ };
43
74
  const activationPolicies = Object.freeze(["off", "adaptive"] as const);
44
75
  const deepSeekPolicies = Object.freeze(["off", "adaptive", "high"] as const);
45
- const adaptiveOnly = Object.freeze(["adaptive"] as const);
46
76
  const reasoningWithoutOff = Object.freeze(["adaptive", "low", "medium", "high"] as const);
47
77
 
48
- const mistralSupportsEffort = (model: string): boolean =>
49
- model === "mistral-small-latest"
50
- || model === "mistral-small-2603"
51
- || model === "mistral-medium-3"
52
- || model === "mistral-medium-3.5";
78
+ const reasoningEffortOrder = Object.freeze([
79
+ "minimal", "low", "medium", "high", "xhigh", "max",
80
+ ] as const);
81
+ const nativeReasoningEfforts = new Set<NativeReasoningEffort>([
82
+ "minimal", "low", "medium", "high", "xhigh",
83
+ ]);
84
+ const compatibleReasoningEfforts = new Set<CompatibleReasoningEffort>(reasoningEffortOrder);
85
+ const compatibleEffortStyles = new Set<ReasoningStyle>([
86
+ "effort", "effort_explicit", "effort_required", "thinking_effort",
87
+ ]);
88
+ const compatibleToggleStyles = new Set<ReasoningStyle>([
89
+ "think", "include_reasoning", "effort_explicit", "thinking_effort", "template", "anthropic",
90
+ ]);
91
+
92
+ const catalogEfforts = (
93
+ info: ModelInfo,
94
+ declared: readonly ModelReasoningEffort[] = [],
95
+ ): readonly ModelReasoningEffort[] => [
96
+ ...new Set([
97
+ ...(info.reasoningOptions
98
+ ?.filter((option) => option.type === "effort")
99
+ .flatMap((option) => option.values) ?? []),
100
+ ...declared,
101
+ ]),
102
+ ];
53
103
 
54
- const xaiReasoningIsModelFixed = (model: string): boolean =>
55
- /^grok-4\.20(?:-\d{4})?-(?:non-)?reasoning$/.test(model);
104
+ const strongestCatalogEffort = <T extends NativeReasoningEffort | CompatibleReasoningEffort>(
105
+ info: ModelInfo,
106
+ supported: ReadonlySet<T>,
107
+ declared: readonly ModelReasoningEffort[] = [],
108
+ ): T | undefined => {
109
+ const values = new Set(catalogEfforts(info, declared));
110
+ return reasoningEffortOrder.findLast((effort) => supported.has(effort as T) && values.has(effort)) as T | undefined;
111
+ };
56
112
 
57
- const googleReasoningCannotBeOff = (model: string): boolean =>
58
- /^gemini-2\.5-pro(?:-|$)/i.test(model)
59
- || /^gemini-(?:[3-9]|\d{2})[.-]/i.test(model);
113
+ const catalogSupportsToggle = (info: ModelInfo): boolean =>
114
+ info.reasoningOptions?.some((option) => option.type === "toggle") === true;
115
+
116
+ const catalogSupportedReasoningPolicies = ({
117
+ info,
118
+ native,
119
+ style,
120
+ declared,
121
+ }: {
122
+ info: ModelInfo;
123
+ native: boolean;
124
+ style: ReasoningStyle;
125
+ declared: readonly ModelReasoningEffort[];
126
+ }): readonly ReasoningPolicy[] => {
127
+ if (!info.reasoning) return activationPolicies;
128
+ const efforts = new Set(catalogEfforts(info, declared));
129
+ const effortTransport = native || compatibleEffortStyles.has(style);
130
+ const off = native
131
+ ? efforts.has("none") || catalogSupportsToggle(info)
132
+ : (efforts.has("none") && compatibleEffortStyles.has(style))
133
+ || (catalogSupportsToggle(info) && compatibleToggleStyles.has(style));
134
+ return REASONING_POLICIES.filter((policy) => policy === "adaptive"
135
+ || policy === "off" && off
136
+ || (policy === "low" || policy === "medium" || policy === "high")
137
+ && effortTransport
138
+ && efforts.has(policy));
139
+ };
60
140
 
61
141
  const anthropicSupportsAdaptiveThinking = (model: string): boolean =>
62
142
  /claude-(?:opus-(?:4-[678]|5)|sonnet-(?:4-6|5)|fable-5)/.test(model);
@@ -65,30 +145,20 @@ const supportedReasoningPolicies = ({
65
145
  info,
66
146
  native,
67
147
  style,
68
- sdkPackage,
69
- model,
148
+ declared,
70
149
  }: {
71
150
  info?: ModelInfo;
72
151
  native: boolean;
73
152
  style: ReasoningStyle;
74
- sdkPackage?: string;
75
- model: string;
153
+ declared: readonly ModelReasoningEffort[];
76
154
  }): readonly ReasoningPolicy[] => {
77
155
  if (info !== undefined && info.reasoning !== true) return activationPolicies;
78
- if (native) {
79
- if (sdkPackage === "@ai-sdk/mistral") {
80
- return mistralSupportsEffort(model) ? deepSeekPolicies : adaptiveOnly;
81
- }
82
- if (sdkPackage === "@ai-sdk/xai") {
83
- if (xaiReasoningIsModelFixed(model)) return adaptiveOnly;
84
- if (model === "grok-4.6") return reasoningWithoutOff;
85
- }
86
- if (sdkPackage === "@ai-sdk/google" && googleReasoningCannotBeOff(model)) {
87
- return reasoningWithoutOff;
88
- }
89
- return REASONING_POLICIES;
156
+ if (info?.reasoningOptions !== undefined) {
157
+ return catalogSupportedReasoningPolicies({ info, native, style, declared });
90
158
  }
159
+ if (native) return activationPolicies;
91
160
  if (style === "effort" || style === "effort_explicit") return REASONING_POLICIES;
161
+ if (style === "effort_required") return reasoningWithoutOff;
92
162
  if (style === "thinking_effort") return deepSeekPolicies;
93
163
  return activationPolicies;
94
164
  };
@@ -97,10 +167,14 @@ const adaptiveReasoningProjection = ({
97
167
  sdkPackage,
98
168
  model,
99
169
  reasoningCapable,
170
+ info,
171
+ declared,
100
172
  }: {
101
173
  sdkPackage?: string;
102
174
  model: string;
103
175
  reasoningCapable: boolean;
176
+ info?: ModelInfo;
177
+ declared: readonly ModelReasoningEffort[];
104
178
  }): Pick<AiSdkProviderConfig, "adaptiveReasoning" | "adaptiveReasoningProviderOptions"> => {
105
179
  if (!reasoningCapable) return { adaptiveReasoning: "provider-default" };
106
180
  if (sdkPackage === "@ai-sdk/google" && /^gemini-2\.5(?:-|$)/i.test(model)) {
@@ -127,13 +201,12 @@ const adaptiveReasoningProjection = ({
127
201
  },
128
202
  };
129
203
  }
130
- if (sdkPackage === "@ai-sdk/mistral" && !mistralSupportsEffort(model)) {
131
- return { adaptiveReasoning: "provider-default" };
132
- }
133
- if (sdkPackage === "@ai-sdk/xai" && xaiReasoningIsModelFixed(model)) {
134
- return { adaptiveReasoning: "provider-default" };
204
+ if (info?.reasoningOptions !== undefined) {
205
+ return {
206
+ adaptiveReasoning: strongestCatalogEffort(info, nativeReasoningEfforts, declared) ?? "provider-default",
207
+ };
135
208
  }
136
- return { adaptiveReasoning: "high" };
209
+ return { adaptiveReasoning: "provider-default" };
137
210
  };
138
211
 
139
212
  export const providerFromSdkModel = ({
@@ -189,13 +262,31 @@ export const providerFromSdkModel = ({
189
262
  maxOutputTokens,
190
263
  );
191
264
  const reasoning = reasoningFromEnv(env, name, envelope.reasoningBudget);
192
- const reasoningStyle = reasoningStyleFromEnv(env, name) ?? "none";
193
265
  const reasoningCapable = info?.reasoning === true;
266
+ const declaredEfforts = declaredEffortsFromEnv(env, name);
267
+ const declaredReasoningStyle = info?.reasoning === false
268
+ ? "none"
269
+ : reasoningStyleFromEnv(env, name) ?? "none";
270
+ const reasoningStyle = info?.reasoningOptions !== undefined
271
+ && (declaredReasoningStyle === "effort" || declaredReasoningStyle === "effort_required")
272
+ && catalogEfforts(info, declaredEfforts).length === 0
273
+ ? "none"
274
+ : declaredReasoningStyle;
194
275
  const adaptiveReasoning = adaptiveReasoningProjection({
195
276
  sdkPackage,
196
277
  model,
197
278
  reasoningCapable,
279
+ info,
280
+ declared: declaredEfforts,
198
281
  });
282
+ const compatibleAdaptiveReasoning = languageModel !== undefined || info?.reasoningOptions === undefined
283
+ ? undefined
284
+ : strongestCatalogEffort(info, compatibleReasoningEfforts, declaredEfforts) ?? "provider-default";
285
+ const compatibleOffReasoning = languageModel !== undefined
286
+ || info === undefined
287
+ || !catalogEfforts(info, declaredEfforts).includes("none")
288
+ ? undefined
289
+ : "none" as const;
199
290
 
200
291
  const catalogCost = info?.cost;
201
292
  const rates = catalogCost === undefined ? null : {
@@ -232,10 +323,11 @@ export const providerFromSdkModel = ({
232
323
  info,
233
324
  native: languageModel !== undefined,
234
325
  style: reasoningStyle,
235
- sdkPackage,
236
- model,
326
+ declared: declaredEfforts,
237
327
  }),
238
328
  ...adaptiveReasoning,
329
+ ...(compatibleAdaptiveReasoning === undefined ? {} : { compatibleAdaptiveReasoning }),
330
+ ...(compatibleOffReasoning === undefined ? {} : { compatibleOffReasoning }),
239
331
  ...(additiveReasoningProvider === undefined || !reasoningCapable
240
332
  ? {}
241
333
  : { additiveReasoningProvider }),
@@ -30,7 +30,7 @@ test("discover: the node_modules scan is scope-agnostic — third-party scopes a
30
30
  "@acme/acme-provider-foo": { name: "@acme/acme-provider-foo", plurnk: { kind: "provider", name: "foo" } },
31
31
  "unscoped-provider-bar": { name: "unscoped-provider-bar", plurnk: { kind: "provider", name: "bar" } },
32
32
  "left-pad": { name: "left-pad" }, // no plurnk block → ignored
33
- "@plurnk/plurnk-execs-git": { name: "@plurnk/plurnk-execs-git", plurnk: { kind: "exec", runtimes: [] } }, // wrong kind → ignored
33
+ "@acme/acme-execs-wrong-kind": { name: "@acme/acme-execs-wrong-kind", plurnk: { kind: "exec", runtimes: [] } }, // wrong kind → ignored
34
34
  });
35
35
  const { registry } = await discover({ cwd: root });
36
36
  assert.deepEqual([...registry.keys()].sort(), ["bar", "foo", "native"]);
package/src/index.ts CHANGED
@@ -45,7 +45,8 @@ export {
45
45
  loadActiveProvider,
46
46
  resetDiscoveryCache,
47
47
  } from "./ProviderRegistry.ts";
48
- export { providerReadiness } from "./sdkModels.ts";
48
+ export { createEmbeddingModel, providerReadiness } from "./sdkModels.ts";
49
+ export type { EmbeddingModelResolution } from "./sdkModels.ts";
49
50
 
50
51
  // Scope-agnostic plugin discovery ({§plugin-family-kind}).
51
52
  export { discover } from "./discover.ts";
package/src/notices.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ProviderNoticeKind = "grammar_unenforced" | "provider_warning";
1
+ export type ProviderNoticeKind = "grammar_unenforced" | "provider_warning" | "provider_retry";
2
2
 
3
3
  // Observations about a completed model exchange. These never represent a
4
4
  // failed provider operation; transport failures throw ProviderError with an