@plurnk/plurnk-providers 1.16.5 → 1.18.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 (73) hide show
  1. package/.env.defaults +87 -200
  2. package/README.md +2 -1
  3. package/SPEC.md +73 -66
  4. package/dist/AiSdkProvider.d.ts +0 -1
  5. package/dist/AiSdkProvider.d.ts.map +1 -1
  6. package/dist/AiSdkProvider.js +25 -55
  7. package/dist/AiSdkProvider.js.map +1 -1
  8. package/dist/AiSdkRequestBody.d.ts +0 -1
  9. package/dist/AiSdkRequestBody.d.ts.map +1 -1
  10. package/dist/AiSdkRequestBody.js +1 -17
  11. package/dist/AiSdkRequestBody.js.map +1 -1
  12. package/dist/LeadingReasoning.d.ts +16 -0
  13. package/dist/LeadingReasoning.d.ts.map +1 -0
  14. package/dist/LeadingReasoning.js +81 -0
  15. package/dist/LeadingReasoning.js.map +1 -0
  16. package/dist/aiSdkTransport.d.ts +7 -0
  17. package/dist/aiSdkTransport.d.ts.map +1 -1
  18. package/dist/aiSdkTransport.js +19 -5
  19. package/dist/aiSdkTransport.js.map +1 -1
  20. package/dist/catalogProvider.d.ts +3 -1
  21. package/dist/catalogProvider.d.ts.map +1 -1
  22. package/dist/catalogProvider.js +24 -18
  23. package/dist/catalogProvider.js.map +1 -1
  24. package/dist/compatibleProvider.d.ts.map +1 -1
  25. package/dist/compatibleProvider.js +0 -3
  26. package/dist/compatibleProvider.js.map +1 -1
  27. package/dist/env.d.ts.map +1 -1
  28. package/dist/env.js +0 -1
  29. package/dist/env.js.map +1 -1
  30. package/dist/errors.d.ts +6 -0
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js +46 -15
  33. package/dist/errors.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/providerError.d.ts +1 -1
  39. package/dist/providerError.d.ts.map +1 -1
  40. package/dist/providerError.js +3 -0
  41. package/dist/providerError.js.map +1 -1
  42. package/dist/sdkModels.d.ts +1 -13
  43. package/dist/sdkModels.d.ts.map +1 -1
  44. package/dist/sdkModels.js +2 -94
  45. package/dist/sdkModels.js.map +1 -1
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/usage.d.ts.map +1 -1
  48. package/dist/usage.js +18 -9
  49. package/dist/usage.js.map +1 -1
  50. package/docs/models.md +90 -0
  51. package/package.json +6 -6
  52. package/src/AiSdkProvider.test.ts +99 -38
  53. package/src/AiSdkProvider.ts +24 -78
  54. package/src/AiSdkRequestBody.ts +1 -16
  55. package/src/LeadingReasoning.test.ts +34 -0
  56. package/src/LeadingReasoning.ts +87 -0
  57. package/src/ProviderRegistry.test.ts +19 -1
  58. package/src/aiSdkTransport.test.ts +56 -0
  59. package/src/aiSdkTransport.ts +31 -5
  60. package/src/boundaries.test.ts +1 -0
  61. package/src/catalogProvider.test.ts +110 -20
  62. package/src/catalogProvider.ts +40 -18
  63. package/src/compatibleProvider.ts +0 -3
  64. package/src/env.test.ts +1 -0
  65. package/src/env.ts +1 -2
  66. package/src/errors.test.ts +58 -7
  67. package/src/errors.ts +55 -18
  68. package/src/index.ts +2 -2
  69. package/src/providerError.ts +4 -0
  70. package/src/sdkModels.test.ts +1 -42
  71. package/src/sdkModels.ts +1 -123
  72. package/src/types.ts +8 -10
  73. package/src/usage.ts +23 -15
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { APICallError, RetryError } from "ai";
3
+ import { APICallError, JSONParseError, RetryError, TypeValidationError } from "ai";
4
4
  import { ProviderError, ProviderTimeoutError, classifyProviderError, toProviderError } from "./errors.ts";
5
5
  import type { ProviderAttempt } from "./types.ts";
6
6
  import { providerSource } from "./notices.ts";
@@ -15,6 +15,22 @@ const apiError = (statusCode: number, responseBody = "body") => new APICallError
15
15
 
16
16
  const SOURCE_PATTERN = /^[a-z]+(:[a-z][a-z0-9-]*)?$/;
17
17
 
18
+ test("HTTP request rejection preserves its cause without becoming a response-contract strike", () => {
19
+ for (const status of [400, 404, 405, 422]) {
20
+ const message = "The requested model is not available on this endpoint.";
21
+ const cause = apiError(status, JSON.stringify({ error: { message } }));
22
+ Object.defineProperty(cause, "message", { value: message });
23
+ const failure = toProviderError(cause, "provider:example");
24
+ assert.equal(failure.kind, "request_rejected");
25
+ assert.equal(failure.status, status);
26
+ assert.equal(failure.message, message);
27
+ assert.equal(failure.problem.detail, message);
28
+ assert.equal(failure.problem.retryable, false);
29
+ assert.equal(failure.cause, cause);
30
+ }
31
+ assert.equal(classifyProviderError(apiError(200)).kind, "invalid_response", "a malformed successful response still violates the response contract");
32
+ });
33
+
18
34
  test("providerSource produces a schema-valid colon-namespaced source", () => {
19
35
  assert.equal(providerSource("openai"), "provider:openai");
20
36
  assert.match(providerSource("openrouter"), SOURCE_PATTERN);
@@ -34,8 +50,8 @@ test("classifyProviderError maps HTTP status to kind", () => {
34
50
  assert.equal(k(500), "network_failure");
35
51
  assert.equal(k(503), "network_failure");
36
52
  assert.equal(k(413), "capacity_exceeded");
37
- assert.equal(k(400), "invalid_response");
38
- assert.equal(k(404), "invalid_response");
53
+ assert.equal(k(400), "request_rejected");
54
+ assert.equal(k(404), "request_rejected");
39
55
  });
40
56
 
41
57
  test("capacity normalization prefers structured provider codes and keeps generic 400s distinct", () => {
@@ -53,7 +69,7 @@ test("capacity normalization prefers structured provider codes and keeps generic
53
69
  assert.equal(normalized.problem.providerStatus, 400);
54
70
  assert.equal(classifyProviderError(apiError(400, JSON.stringify({
55
71
  error: { type: "invalid_request_error", code: "bad_temperature", message: "bad temperature" },
56
- }))).kind, "invalid_response");
72
+ }))).kind, "request_rejected");
57
73
  });
58
74
 
59
75
  test("provider retry directives survive HTTP failure normalization", () => {
@@ -69,11 +85,11 @@ test("provider retry directives survive HTTP failure normalization", () => {
69
85
  assert.equal(error.problem.retryable, false);
70
86
  });
71
87
 
72
- test("classifyProviderError: a 422 flagged grammar_invalid is distinct; other 422s are invalid responses", () => {
88
+ test("classifyProviderError: a 422 flagged grammar_invalid is distinct from other request rejections", () => {
73
89
  const rejected = apiError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }));
74
90
  assert.equal(classifyProviderError(rejected).kind, "grammar_invalid");
75
- assert.equal(classifyProviderError(apiError(422, JSON.stringify({ error: { type: "invalid_request_error" } }))).kind, "invalid_response");
76
- assert.equal(classifyProviderError(apiError(422, "<html>Bad</html>")).kind, "invalid_response");
91
+ assert.equal(classifyProviderError(apiError(422, JSON.stringify({ error: { type: "invalid_request_error" } }))).kind, "request_rejected");
92
+ assert.equal(classifyProviderError(apiError(422, "<html>Bad</html>")).kind, "request_rejected");
77
93
  });
78
94
 
79
95
  test("classifyProviderError treats non-HTTP errors as network_failure", () => {
@@ -225,3 +241,38 @@ test("retry exhaustion retains the exact inner deadline phase", () => {
225
241
  assert.equal(error.problem.timeoutPhase, "attempt");
226
242
  assert.equal(error.problem.timeoutMs, 10);
227
243
  });
244
+
245
+ // {§provider-failure-cause} — the SDK wraps every processing failure of a 2xx body in one message;
246
+ // the durable Problem keeps a bounded classification of what actually happened underneath.
247
+ const processingFailure = (cause: Error) => new APICallError({
248
+ message: "Failed to process successful response",
249
+ url: "https://example.test/v1/chat/completions",
250
+ requestBodyValues: {},
251
+ statusCode: 200,
252
+ responseHeaders: { authorization: "Bearer never-copied", "x-request-id": "req_1" },
253
+ cause,
254
+ });
255
+
256
+ test("#593: a terminated body, invalid JSON, a schema-invalid body, and an internal fault are told apart in durable evidence", () => {
257
+ const terminated = toProviderError(processingFailure(new TypeError("terminated")), "provider:test", 512);
258
+ assert.equal(terminated.problem.providerKind, "network_failure");
259
+ assert.deepEqual(terminated.problem.cause, { causeKind: "transport_terminated", causeName: "TypeError", causeMessage: "terminated" });
260
+
261
+ const parse = toProviderError(processingFailure(new JSONParseError({ text: "<html>502 Bad Gateway</html>".repeat(40), cause: new SyntaxError("Unexpected token <") })), "provider:test", 512);
262
+ assert.equal(parse.problem.providerKind, "invalid_response");
263
+ assert.deepEqual(parse.problem.cause, { causeKind: "invalid_json", causeName: "AI_JSONParseError", causeMessage: "JSON parsing failed (1120 characters)" });
264
+ assert.doesNotMatch(JSON.stringify(parse.problem), /Bad Gateway/, "the payload never enters the Problem");
265
+
266
+ const schema = toProviderError(processingFailure(new TypeValidationError({ value: { choices: "not-an-array", secret: "sk-live" }, cause: new Error("Expected array, received string at choices\nmore lines") })), "provider:test", 512);
267
+ assert.equal(schema.problem.providerKind, "invalid_response");
268
+ assert.deepEqual(schema.problem.cause, { causeKind: "schema_invalid", causeName: "AI_TypeValidationError", causeMessage: "Type validation failed: Expected array, received string at choices" });
269
+ assert.doesNotMatch(JSON.stringify(schema.problem), /sk-live|not-an-array/, "the value never enters the Problem");
270
+
271
+ const internal = toProviderError(processingFailure(new RangeError("Invalid array length\n at Array.push (stack frame)")), "provider:test", 512);
272
+ assert.deepEqual(internal.problem.cause, { causeKind: "internal", causeName: "RangeError", causeMessage: "Invalid array length" });
273
+ assert.doesNotMatch(JSON.stringify(internal.problem), /stack frame|authorization|Bearer|req_1/, "no stack, no headers");
274
+
275
+ const bounded = toProviderError(processingFailure(new Error("x".repeat(100))), "provider:test", 8);
276
+ assert.equal((bounded.problem.cause as { causeMessage: string }).causeMessage, "xxxxxxxx...", "the cause message honours the configured detail limit");
277
+ assert.equal(toProviderError(apiError(502), "provider:test", 512).problem.cause, undefined, "an error without a cause reports none");
278
+ });
package/src/errors.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { APICallError, RetryError } from "ai";
1
+ import { APICallError, InvalidResponseDataError, JSONParseError, RetryError, TypeValidationError } from "ai";
2
2
  import { ProviderError } from "./providerError.ts";
3
3
  import type { ProviderErrorKind } from "./providerError.ts";
4
4
  import type { ProviderRequestCapacity } from "./types.ts";
@@ -81,6 +81,34 @@ const preview = (value: unknown, limit: number | undefined): string => {
81
81
  : text;
82
82
  };
83
83
 
84
+ export type ProviderFailureCauseKind = "transport_terminated" | "invalid_json" | "schema_invalid" | "invalid_response_data" | "internal";
85
+
86
+ export interface ProviderFailureCause {
87
+ readonly causeKind: ProviderFailureCauseKind;
88
+ readonly causeName: string;
89
+ readonly causeMessage: string;
90
+ }
91
+
92
+ const firstLine = (text: string): string => text.split("\n")[0]?.trim() ?? "";
93
+
94
+ // {§provider-failure-cause} — what the SDK wrapped. "Failed to process successful response" is
95
+ // the SDK's one message for a body that terminated, a body that was not JSON, a body that failed
96
+ // the response schema, and its own internal faults; the durable Problem keeps a bounded
97
+ // classification of the cause so forensics can tell them apart. The SDK's parse and validation
98
+ // messages embed the payload, so those two report shape, never text.
99
+ const failureCause = (err: APICallError, detailLimit: number | undefined): ProviderFailureCause | null => {
100
+ const cause = err.cause;
101
+ if (!(cause instanceof Error)) return null;
102
+ if (peerTerminated(cause)) return { causeKind: "transport_terminated", causeName: cause.name, causeMessage: preview(firstLine(cause.message), detailLimit) };
103
+ if (JSONParseError.isInstance(cause)) return { causeKind: "invalid_json", causeName: cause.name, causeMessage: `JSON parsing failed (${cause.text.length} characters)` };
104
+ if (TypeValidationError.isInstance(cause)) {
105
+ const inner = cause.cause instanceof Error ? firstLine(cause.cause.message) : "";
106
+ return { causeKind: "schema_invalid", causeName: cause.name, causeMessage: inner === "" ? "Type validation failed" : `Type validation failed: ${preview(inner, detailLimit)}` };
107
+ }
108
+ if (InvalidResponseDataError.isInstance(cause)) return { causeKind: "invalid_response_data", causeName: cause.name, causeMessage: preview(firstLine(cause.message), detailLimit) };
109
+ return { causeKind: "internal", causeName: cause.name, causeMessage: preview(firstLine(cause.message), detailLimit) };
110
+ };
111
+
84
112
  export const classifyProviderError = (
85
113
  err: unknown,
86
114
  detailLimit?: number,
@@ -94,6 +122,31 @@ export const classifyProviderError = (
94
122
  };
95
123
  }
96
124
  if (APICallError.isInstance(err)) {
125
+ const classified = classifyApiCallError(err, detailLimit);
126
+ const cause = failureCause(err, detailLimit);
127
+ return cause === null ? classified : { ...classified, extensions: { ...(classified.extensions ?? {}), cause } };
128
+ }
129
+ const wire = err as { message?: unknown; type?: unknown };
130
+ if (wire?.type === "grammar_invalid") {
131
+ return {
132
+ kind: "grammar_invalid",
133
+ message: typeof wire.message === "string"
134
+ ? preview(wire.message, detailLimit)
135
+ : "The provider rejected the response grammar.",
136
+ };
137
+ }
138
+ const error = err as { message?: string };
139
+ return {
140
+ kind: "network_failure",
141
+ message: preview(
142
+ (error?.message ?? String(err)) || "The provider request failed.",
143
+ detailLimit,
144
+ ),
145
+ };
146
+ };
147
+
148
+ const classifyApiCallError = (err: APICallError, detailLimit: number | undefined): ClassifiedProviderError => {
149
+ {
97
150
  const timeout = providerTimeoutOf(err);
98
151
  if (timeout !== null) {
99
152
  return {
@@ -146,25 +199,9 @@ export const classifyProviderError = (
146
199
  if (status === 422 && wire.type === "grammar_invalid") {
147
200
  return { kind: "grammar_invalid", message };
148
201
  }
202
+ if (status >= 400 && status < 500) return { kind: "request_rejected", message };
149
203
  return { kind: "invalid_response", message };
150
204
  }
151
- const wire = err as { message?: unknown; type?: unknown };
152
- if (wire?.type === "grammar_invalid") {
153
- return {
154
- kind: "grammar_invalid",
155
- message: typeof wire.message === "string"
156
- ? preview(wire.message, detailLimit)
157
- : "The provider rejected the response grammar.",
158
- };
159
- }
160
- const error = err as { message?: string };
161
- return {
162
- kind: "network_failure",
163
- message: preview(
164
- (error?.message ?? String(err)) || "The provider request failed.",
165
- detailLimit,
166
- ),
167
- };
168
205
  };
169
206
 
170
207
  export const toProviderError = (
package/src/index.ts CHANGED
@@ -45,8 +45,8 @@ export {
45
45
  loadActiveProvider,
46
46
  resetDiscoveryCache,
47
47
  } from "./ProviderRegistry.ts";
48
- export { createEmbeddingModel, providerReadiness } from "./sdkModels.ts";
49
- export type { EmbeddingModelResolution } from "./sdkModels.ts";
48
+ export { providerReadiness } from "./sdkModels.ts";
49
+ export { catalogReasoningPolicies } from "./catalogProvider.ts";
50
50
 
51
51
  // Scope-agnostic plugin discovery ({§plugin-family-kind}).
52
52
  export { discover } from "./discover.ts";
@@ -7,6 +7,7 @@ export type ProviderErrorKind =
7
7
  | "network_failure"
8
8
  | "deadline_exceeded"
9
9
  | "model_refused"
10
+ | "request_rejected"
10
11
  | "invalid_response"
11
12
  | "unauthorized"
12
13
  | "quota_exceeded"
@@ -16,6 +17,7 @@ export type ProviderErrorKind =
16
17
 
17
18
  const defaultStatus = (kind: ProviderErrorKind): number => {
18
19
  switch (kind) {
20
+ case "request_rejected": return 400;
19
21
  case "unauthorized": return 401;
20
22
  case "quota_exceeded": return 402;
21
23
  case "capacity_exceeded": return 413;
@@ -36,6 +38,7 @@ const retryable = (kind: ProviderErrorKind): boolean => {
36
38
  return true;
37
39
  case "deadline_exceeded":
38
40
  case "invalid_response":
41
+ case "request_rejected":
39
42
  case "grammar_invalid":
40
43
  case "capacity_exceeded":
41
44
  case "resource_interrupted":
@@ -60,6 +63,7 @@ const buildProblem = (
60
63
  deadline_exceeded: "deadline-exceeded",
61
64
  model_refused: "model-refused",
62
65
  invalid_response: "invalid-response",
66
+ request_rejected: "request-rejected",
63
67
  unauthorized: "unauthorized",
64
68
  quota_exceeded: "quota-exceeded",
65
69
  grammar_invalid: "grammar-invalid",
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { configuredProviderInfo, createEmbeddingModel, createSdkModel, providerReadiness } from "./sdkModels.ts";
3
+ import { configuredProviderInfo, createSdkModel, providerReadiness } from "./sdkModels.ts";
4
4
 
5
5
  test("{§provider-fact-authority} one env declaration holds one credential name", () => {
6
6
  assert.deepEqual(configuredProviderInfo("acme-cloud", {
@@ -301,44 +301,3 @@ test("createSdkModel fails clearly for a declared but unsupported SDK package",
301
301
  /Models.dev declares unsupported AI SDK package @acme\/ai-sdk/,
302
302
  );
303
303
  });
304
-
305
- test("{§provider-embedding-resolution} compatible catalog and declared routes share the standard embedding adapter", () => {
306
- const cloudflare = createEmbeddingModel("cloudflare-workers-ai", "@cf/qwen/qwen3-embedding-0.6b", {
307
- CLOUDFLARE_ACCOUNT_ID: "account",
308
- CLOUDFLARE_API_KEY: "key",
309
- });
310
- assert.equal(cloudflare?.providerId, "cloudflare-workers-ai");
311
- assert.equal(cloudflare?.embeddingModel.provider, "cloudflare-workers-ai.embedding");
312
- assert.equal(cloudflare?.embeddingModel.modelId, "@cf/qwen/qwen3-embedding-0.6b");
313
-
314
- const fireworks = createEmbeddingModel("fireworks-ai", "fireworks/qwen3-embedding-8b", {
315
- FIREWORKS_API_KEY: "key",
316
- });
317
- assert.equal(fireworks?.providerId, "fireworks-ai");
318
- assert.equal(fireworks?.embeddingModel.provider, "fireworks-ai.embedding");
319
-
320
- const local = createEmbeddingModel("local-embed", "Qwen/Qwen3-Embedding-0.6B", {
321
- PLURNK_PROVIDERS_PROVIDER_LOCAL_EMBED_NPM: "@ai-sdk/openai-compatible",
322
- PLURNK_PROVIDERS_PROVIDER_LOCAL_EMBED_BASE_URL: "http://127.0.0.1:8080/v1",
323
- });
324
- assert.equal(local?.providerId, "local-embed");
325
- assert.equal(local?.embeddingModel.provider, "local-embed.embedding");
326
- });
327
-
328
- test("{§provider-embedding-resolution} OpenRouter uses its official embedding model", () => {
329
- const resolved = createEmbeddingModel("openrouter", "openai/text-embedding-3-small", {
330
- OPENROUTER_API_KEY: "key",
331
- OPENROUTER_HTTP_REFERER: "https://github.com/plurnk/plurnk-service",
332
- OPENROUTER_APP_TITLE: "Plurnk",
333
- });
334
- assert.equal(resolved?.providerId, "openrouter");
335
- assert.equal(resolved?.embeddingModel.provider, "openrouter");
336
- assert.equal(resolved?.embeddingModel.modelId, "openai/text-embedding-3-small");
337
- });
338
-
339
- test("{§provider-embedding-resolution} a generation-only SDK fails before transport", () => {
340
- assert.throws(
341
- () => createEmbeddingModel("cerebras", "text-embedding", { CEREBRAS_API_KEY: "key" }),
342
- /@ai-sdk\/cerebras does not expose an embedding model/,
343
- );
344
- });
package/src/sdkModels.ts CHANGED
@@ -6,15 +6,12 @@ import { createGoogle } from "@ai-sdk/google";
6
6
  import { createGroq } from "@ai-sdk/groq";
7
7
  import { createMistral } from "@ai-sdk/mistral";
8
8
  import { createOpenAI } from "@ai-sdk/openai";
9
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
10
9
  import { createTogetherAI } from "@ai-sdk/togetherai";
11
10
  import { createXai } from "@ai-sdk/xai";
12
- import type { EmbeddingModelV4, EmbeddingModelV4Result } from "@ai-sdk/provider";
13
11
  import { createOpenRouter, type OpenRouterChatSettings } from "@openrouter/ai-sdk-provider";
14
12
  import {
15
13
  resolveModel,
16
14
  isProviderCredentialName,
17
- lookup,
18
15
  lookupProvider,
19
16
  providerCatalogSnapshot,
20
17
  type ProviderInfo,
@@ -23,13 +20,10 @@ import {
23
20
  Validator,
24
21
  type ModelReadiness,
25
22
  type ModelReadinessCause,
26
- type ProviderRequestAccounting,
27
- type ProviderUsage,
28
23
  type ReasoningPolicy,
29
24
  } from "@plurnk/plurnk-contracts";
30
25
  import type { LanguageModel } from "ai";
31
- import { providerCostNormalizer, validateProviderRequestAccounting } from "./accounting.ts";
32
- import { estimateProviderCost, resolveProviderCost } from "./cost.ts";
26
+ import { providerCostNormalizer } from "./accounting.ts";
33
27
  import type { AiSdkProviderOptions, CacheAffinity } from "./AiSdkProvider.ts";
34
28
  import type { ProviderCostNormalizer } from "./types.ts";
35
29
 
@@ -47,17 +41,6 @@ export type SdkModel = {
47
41
  readonly catalog: ProviderInfo | null;
48
42
  };
49
43
 
50
- export type EmbeddingModelResolution = {
51
- readonly embeddingModel: EmbeddingModelV4;
52
- readonly providerId: string;
53
- readonly modelId: string;
54
- readonly normalizeAccounting: (evidence: {
55
- readonly outcome: "response" | "error";
56
- readonly status?: number;
57
- readonly result?: EmbeddingModelV4Result;
58
- }) => ProviderRequestAccounting;
59
- };
60
-
61
44
  const cacheControl = { type: "ephemeral" as const };
62
45
  // The release generator admits only packages implemented by this package.
63
46
  // Derive runtime readiness from that same pinned provider projection instead
@@ -485,108 +468,3 @@ export const createSdkModel = (
485
468
  throw new Error(`${provider} provider: Models.dev declares unsupported AI SDK package ${catalog.npm}`);
486
469
  }
487
470
  };
488
-
489
- // {§provider-embedding-resolution} Provider construction owns transport and
490
- // authentication only. Model-space facts stay in the embedding profile.
491
- export const createEmbeddingModel = (
492
- provider: string,
493
- model: string,
494
- env: NodeJS.ProcessEnv,
495
- baseUrlOverride?: string,
496
- ): EmbeddingModelResolution | null => {
497
- const resolved = resolveSdkProvider(provider, env, baseUrlOverride);
498
- if (resolved === null) return null;
499
- const { catalog, url } = resolved;
500
- const directCost = providerCostNormalizer(catalog.npm);
501
- const modelCost = lookup(provider, model)?.cost ?? lookup(catalog.id, model)?.cost;
502
- const rates = modelCost === undefined
503
- ? null
504
- : {
505
- input: modelCost.inputPer1M,
506
- output: modelCost.outputPer1M,
507
- ...(modelCost.reasoningPer1M === undefined ? {} : { reasoning: modelCost.reasoningPer1M }),
508
- ...(modelCost.cacheReadPer1M === undefined ? {} : { cacheRead: modelCost.cacheReadPer1M }),
509
- ...(modelCost.cacheWritePer1M === undefined ? {} : { cacheWrite: modelCost.cacheWritePer1M }),
510
- };
511
- const normalizeAccounting: EmbeddingModelResolution["normalizeAccounting"] = (evidence) => {
512
- const tokens = evidence.result?.usage?.tokens;
513
- const usage: ProviderUsage | undefined = Number.isSafeInteger(tokens) && tokens! >= 0
514
- ? { inputTokens: tokens!, outputTokens: 0, totalTokens: tokens! }
515
- : undefined;
516
- const body = evidence.result?.response?.body;
517
- const rawUsage = typeof body === "object" && body !== null && !Array.isArray(body)
518
- ? (body as Record<string, unknown>).usage
519
- : undefined;
520
- const direct = directCost?.({
521
- providerMetadata: evidence.result?.providerMetadata,
522
- usage: rawUsage,
523
- response: { headers: evidence.result?.response?.headers },
524
- });
525
- const cost = resolveProviderCost(
526
- direct,
527
- estimateProviderCost(
528
- usage,
529
- rates,
530
- `Models.dev ${catalog.id}/${model} rates`,
531
- ),
532
- );
533
- return validateProviderRequestAccounting({
534
- provider: catalog.id,
535
- model,
536
- outcome: evidence.outcome,
537
- ...(evidence.status === undefined ? {} : { status: evidence.status }),
538
- ...(usage === undefined ? {} : { usage }),
539
- cost,
540
- });
541
- };
542
- const result = (embeddingModel: EmbeddingModelV4): EmbeddingModelResolution => ({
543
- embeddingModel,
544
- providerId: catalog.id,
545
- modelId: model,
546
- normalizeAccounting,
547
- });
548
-
549
- switch (catalog.npm) {
550
- case "@ai-sdk/openai":
551
- return result(createOpenAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embedding(model));
552
- case "@ai-sdk/google":
553
- return result(createGoogle({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embedding(model));
554
- case "@ai-sdk/mistral":
555
- return result(createMistral({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embedding(model));
556
- case "@ai-sdk/deepinfra":
557
- return result(createDeepInfra({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embeddingModel(model));
558
- case "@ai-sdk/togetherai":
559
- return result(createTogetherAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).embeddingModel(model));
560
- case "@ai-sdk/amazon-bedrock":
561
- return result(createAmazonBedrock({
562
- region: env.AWS_REGION ?? env.AWS_DEFAULT_REGION,
563
- accessKeyId: env.AWS_ACCESS_KEY_ID,
564
- secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
565
- sessionToken: env.AWS_SESSION_TOKEN,
566
- apiKey: env.AWS_BEARER_TOKEN_BEDROCK,
567
- baseURL: url,
568
- }).embedding(model));
569
- case "@openrouter/ai-sdk-provider":
570
- return result(createOpenRouter({
571
- apiKey: requireApiKey(provider, env, catalog),
572
- baseURL: url,
573
- headers: openRouterHeaders(provider, env, catalog),
574
- }).textEmbeddingModel(model));
575
- case "@ai-sdk/openai-compatible": {
576
- if (url === undefined) {
577
- throw new Error(`${provider} provider: Models.dev supplies no API URL and no base URL was configured`);
578
- }
579
- const keyNames = configuredKeyNames(provider, env, catalog);
580
- const key = keyNames.length === 0 ? undefined : requireApiKey(provider, env, catalog);
581
- return result(createOpenAICompatible({
582
- name: provider,
583
- baseURL: url,
584
- ...(key === undefined ? {} : { apiKey: key }),
585
- }).embeddingModel(model));
586
- }
587
- default:
588
- throw new Error(
589
- `${provider} provider: ${catalog.npm} does not expose an embedding model through Plurnk's AI SDK adapter`,
590
- );
591
- }
592
- };
package/src/types.ts CHANGED
@@ -187,10 +187,10 @@ export interface ProviderAssistant<TFinish extends ProviderAttemptFinishReason =
187
187
  readonly meanLogprob?: number;
188
188
  }
189
189
 
190
+ // {§provider-grammar-transport} — transport evidence for an operator's grammar: the exact
191
+ // pre-projection response, where the projected content starts within it, and whether the
192
+ // grammar reached the wire. Evidence only; nothing grades it.
190
193
  export interface GrammarEvidence {
191
- // Exact pre-projection response represented by the provider. A generated
192
- // rail's response root composes any template prefix before @plurnk/gbnf
193
- // grades it. Offsets are Unicode code points, matching validator verdicts.
194
194
  readonly input: string;
195
195
  readonly contentStart: number;
196
196
  readonly transported: boolean;
@@ -203,7 +203,7 @@ export interface ProviderResponse<TFinish extends ProviderAttemptFinishReason =
203
203
  // failover that preceded this response. {§provider-request-accounting}
204
204
  readonly accounting: readonly ProviderRequestAccounting[];
205
205
  readonly capacity: ProviderRequestCapacity;
206
- // {§gbnf-response-observation} — evidence only; the consumer owns the verdict.
206
+ // {§provider-grammar-transport} — transport evidence; present only when a grammar was configured.
207
207
  readonly grammarEvidence?: GrammarEvidence;
208
208
  // Per-turn provider→client metadata bag: the backend's non-standard top-level
209
209
  // response fields passed through verbatim. Monetary values carry their own
@@ -250,12 +250,10 @@ export interface Provider {
250
250
  // Optional package-authored folksonomy evaluated by the consumer immediately
251
251
  // before a provider emission attempt ({§plugin-attribution}).
252
252
  attributions?(context: PluginAttributionContext): PluginAttribution;
253
- // `grammar` is an optional GBNF string (canonically @plurnk/plurnk-contracts'
254
- // plurnk.gemma.gbnf or plurnk.qwen.gbnf, possibly root-substituted by the consumer). Backends that
255
- // support grammar-constrained sampling attach it verbatim; all others
256
- // ignore it. The provider never chooses or modifies the grammar — whether
257
- // to constrain and which root variant to send is consumer policy
258
- // ({§gbnf-response-observation}).
253
+ // `grammar` is an optional GBNF string, the operator's own file read by the consumer.
254
+ // Backends that support grammar-constrained sampling attach it verbatim; all others
255
+ // ignore it. The provider never chooses or modifies the grammar
256
+ // ({§provider-grammar-transport}).
259
257
  //
260
258
  // `maxOutputTokens` may tighten the provider's configured total output
261
259
  // budget for this call. It includes visible output and hidden reasoning;
package/src/usage.ts CHANGED
@@ -179,16 +179,14 @@ export const normalizeUsage = (raw: RawUsage | null | undefined): ProviderUsage
179
179
  ? inputTokens - cacheReadTokens - cacheWriteTokens
180
180
  : undefined);
181
181
 
182
+ const inputTokenDetails = nonEmptyDetails({ noCacheTokens, cacheReadTokens, cacheWriteTokens });
183
+ const outputTokenDetails = nonEmptyDetails({ textTokens, reasoningTokens: normalizedReasoning });
182
184
  const usage: ProviderUsage = {
183
185
  ...(inputTokens === undefined ? {} : { inputTokens }),
184
186
  ...(outputTokens === undefined ? {} : { outputTokens }),
185
187
  ...(totalTokens === undefined ? {} : { totalTokens }),
186
- ...(nonEmptyDetails({ noCacheTokens, cacheReadTokens, cacheWriteTokens }) === undefined
187
- ? {}
188
- : { inputTokenDetails: nonEmptyDetails({ noCacheTokens, cacheReadTokens, cacheWriteTokens })! }),
189
- ...(nonEmptyDetails({ textTokens, reasoningTokens: normalizedReasoning }) === undefined
190
- ? {}
191
- : { outputTokenDetails: nonEmptyDetails({ textTokens, reasoningTokens: normalizedReasoning })! }),
188
+ ...(inputTokenDetails === undefined ? {} : { inputTokenDetails }),
189
+ ...(outputTokenDetails === undefined ? {} : { outputTokenDetails }),
192
190
  };
193
191
  if (Object.keys(usage).length === 0) return undefined;
194
192
  return validateProviderUsage(usage);
@@ -244,15 +242,25 @@ export const calculateCostUsdDecimal = (
244
242
  if (cacheWriteRate !== rates.input && cacheWriteTokens === undefined) return null;
245
243
  if (reasoningRate !== rates.output && reasoningTokens === undefined) return null;
246
244
 
247
- const parts = [rates.input, rates.output, reasoningRate, cacheReadRate, cacheWriteRate].map(decimalParts);
248
- const rateScale = Math.max(...parts.map(({ scale }) => scale));
249
- const [input, output, reasoning, cacheRead, cacheWrite] = parts.map(({ coefficient, scale }) =>
250
- coefficient * 10n ** BigInt(rateScale - scale));
251
- const coefficient = BigInt(usage.inputTokens) * input!
252
- + BigInt(usage.outputTokens) * output!
253
- + BigInt(reasoningTokens ?? 0) * (reasoning! - output!)
254
- + BigInt(cacheReadTokens ?? 0) * (cacheRead! - input!)
255
- + BigInt(cacheWriteTokens ?? 0) * (cacheWrite! - input!);
245
+ const parts = {
246
+ input: decimalParts(rates.input),
247
+ output: decimalParts(rates.output),
248
+ reasoning: decimalParts(reasoningRate),
249
+ cacheRead: decimalParts(cacheReadRate),
250
+ cacheWrite: decimalParts(cacheWriteRate),
251
+ };
252
+ const rateScale = Math.max(...Object.values(parts).map(({ scale }) => scale));
253
+ const scaled = ({ coefficient, scale }: { coefficient: bigint; scale: number }): bigint => coefficient * 10n ** BigInt(rateScale - scale);
254
+ const input = scaled(parts.input);
255
+ const output = scaled(parts.output);
256
+ const reasoning = scaled(parts.reasoning);
257
+ const cacheRead = scaled(parts.cacheRead);
258
+ const cacheWrite = scaled(parts.cacheWrite);
259
+ const coefficient = BigInt(usage.inputTokens) * input
260
+ + BigInt(usage.outputTokens) * output
261
+ + BigInt(reasoningTokens ?? 0) * (reasoning - output)
262
+ + BigInt(cacheReadTokens ?? 0) * (cacheRead - input)
263
+ + BigInt(cacheWriteTokens ?? 0) * (cacheWrite - input);
256
264
  if (coefficient < 0n) throw new TypeError("provider token-rate details produced a negative estimate");
257
265
  return canonicalDecimal(coefficient, rateScale + 6);
258
266
  };