@plurnk/plurnk-providers 1.3.4 → 1.3.6

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 (101) hide show
  1. package/.env.defaults +41 -52
  2. package/README.md +44 -53
  3. package/SPEC.md +215 -354
  4. package/dist/AiSdkProvider.d.ts +78 -0
  5. package/dist/AiSdkProvider.d.ts.map +1 -0
  6. package/dist/AiSdkProvider.js +591 -0
  7. package/dist/AiSdkProvider.js.map +1 -0
  8. package/dist/Mock.d.ts +1 -1
  9. package/dist/Mock.d.ts.map +1 -1
  10. package/dist/Mock.js +1 -1
  11. package/dist/Mock.js.map +1 -1
  12. package/dist/OpenAICompat.d.ts +3 -5
  13. package/dist/OpenAICompat.d.ts.map +1 -1
  14. package/dist/OpenAICompat.js +44 -133
  15. package/dist/OpenAICompat.js.map +1 -1
  16. package/dist/Pool.d.ts +1 -1
  17. package/dist/Pool.d.ts.map +1 -1
  18. package/dist/Pool.js +1 -1
  19. package/dist/Pool.js.map +1 -1
  20. package/dist/ProviderRegistry.d.ts.map +1 -1
  21. package/dist/ProviderRegistry.js +37 -24
  22. package/dist/ProviderRegistry.js.map +1 -1
  23. package/dist/aiSdkTransport.d.ts +52 -0
  24. package/dist/aiSdkTransport.d.ts.map +1 -0
  25. package/dist/aiSdkTransport.js +294 -0
  26. package/dist/aiSdkTransport.js.map +1 -0
  27. package/dist/catalogProvider.d.ts +15 -0
  28. package/dist/catalogProvider.d.ts.map +1 -0
  29. package/dist/catalogProvider.js +103 -0
  30. package/dist/catalogProvider.js.map +1 -0
  31. package/dist/compatibleProvider.d.ts +3 -0
  32. package/dist/compatibleProvider.d.ts.map +1 -0
  33. package/dist/compatibleProvider.js +146 -0
  34. package/dist/compatibleProvider.js.map +1 -0
  35. package/dist/discover.d.ts.map +1 -1
  36. package/dist/discover.js.map +1 -1
  37. package/dist/env.d.ts +1 -0
  38. package/dist/env.d.ts.map +1 -1
  39. package/dist/env.js +13 -6
  40. package/dist/env.js.map +1 -1
  41. package/dist/index.d.ts +5 -7
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +4 -8
  44. package/dist/index.js.map +1 -1
  45. package/dist/ollama.d.ts +3 -0
  46. package/dist/ollama.d.ts.map +1 -0
  47. package/dist/ollama.js +39 -0
  48. package/dist/ollama.js.map +1 -0
  49. package/dist/openai.d.ts +2 -4
  50. package/dist/openai.d.ts.map +1 -1
  51. package/dist/openai.js +1 -2
  52. package/dist/openai.js.map +1 -1
  53. package/dist/sdkModels.d.ts +13 -0
  54. package/dist/sdkModels.d.ts.map +1 -0
  55. package/dist/sdkModels.js +153 -0
  56. package/dist/sdkModels.js.map +1 -0
  57. package/dist/standardProviders.d.ts +0 -1
  58. package/dist/standardProviders.d.ts.map +1 -1
  59. package/dist/standardProviders.js +9 -11
  60. package/dist/standardProviders.js.map +1 -1
  61. package/dist/telemetry.d.ts.map +1 -1
  62. package/dist/telemetry.js +20 -9
  63. package/dist/telemetry.js.map +1 -1
  64. package/dist/types.d.ts +4 -3
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/usage.d.ts +1 -1
  67. package/dist/usage.d.ts.map +1 -1
  68. package/dist/usage.js +4 -2
  69. package/dist/usage.js.map +1 -1
  70. package/package.json +19 -8
  71. package/src/{OpenAICompat.test.ts → AiSdkProvider.test.ts} +202 -177
  72. package/src/{OpenAICompat.ts → AiSdkProvider.ts} +89 -144
  73. package/src/Mock.test.ts +3 -3
  74. package/src/Mock.ts +1 -1
  75. package/src/Pool.test.ts +3 -3
  76. package/src/Pool.ts +1 -1
  77. package/src/ProviderRegistry.test.ts +40 -27
  78. package/src/ProviderRegistry.ts +35 -24
  79. package/src/aiSdkTransport.test.ts +253 -0
  80. package/src/aiSdkTransport.ts +369 -0
  81. package/src/boundaries.test.ts +2 -2
  82. package/src/catalogProvider.test.ts +100 -0
  83. package/src/catalogProvider.ts +151 -0
  84. package/src/compatibleProvider.test.ts +44 -0
  85. package/src/compatibleProvider.ts +205 -0
  86. package/src/discover.test.ts +12 -12
  87. package/src/discover.ts +3 -6
  88. package/src/env.ts +14 -6
  89. package/src/index.ts +7 -11
  90. package/src/ollama.ts +63 -0
  91. package/src/openai.ts +2 -8
  92. package/src/sdkModels.test.ts +47 -0
  93. package/src/sdkModels.ts +194 -0
  94. package/src/telemetry.test.ts +17 -10
  95. package/src/telemetry.ts +22 -14
  96. package/src/types.ts +10 -13
  97. package/src/usage.test.ts +8 -10
  98. package/src/usage.ts +7 -3
  99. package/src/openaiStream.ts +0 -310
  100. package/src/standardProviders.test.ts +0 -949
  101. package/src/standardProviders.ts +0 -635
@@ -0,0 +1,194 @@
1
+ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
2
+ import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { createDeepInfra } from "@ai-sdk/deepinfra";
4
+ import { createGoogle } from "@ai-sdk/google";
5
+ import { createGroq } from "@ai-sdk/groq";
6
+ import { createMistral } from "@ai-sdk/mistral";
7
+ import { createOpenAI } from "@ai-sdk/openai";
8
+ import { createTogetherAI } from "@ai-sdk/togetherai";
9
+ import { createXai } from "@ai-sdk/xai";
10
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
11
+ import { lookupProvider, type ProviderInfo } from "@plurnk/plurnk-models";
12
+ import type { LanguageModel } from "ai";
13
+
14
+ export type SdkModel = {
15
+ readonly languageModel?: LanguageModel;
16
+ readonly compatible?: {
17
+ readonly url: string;
18
+ readonly headers: Readonly<Record<string, string>>;
19
+ };
20
+ readonly catalog: ProviderInfo | null;
21
+ };
22
+
23
+ const envPrefix = (provider: string): string =>
24
+ provider.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
25
+
26
+ export const configuredProviderInfo = (
27
+ provider: string,
28
+ env: NodeJS.ProcessEnv,
29
+ ): ProviderInfo | null => {
30
+ const prefix = `PLURNK_PROVIDERS_PROVIDER_${envPrefix(provider)}`;
31
+ const npm = env[`${prefix}_NPM`];
32
+ if (npm === undefined || npm.length === 0) return null;
33
+ const keyNames = env[`${prefix}_API_KEY_ENV`]
34
+ ?.split(",")
35
+ .map((name) => name.trim())
36
+ .filter(Boolean) ?? [];
37
+ const api = env[`${prefix}_BASE_URL`];
38
+ return {
39
+ id: provider,
40
+ npm,
41
+ env: keyNames,
42
+ ...(api === undefined || api.length === 0 ? {} : { api }),
43
+ };
44
+ };
45
+
46
+ const firstSet = (env: NodeJS.ProcessEnv, names: readonly string[]): string | undefined => {
47
+ for (const name of names) {
48
+ const value = env[name];
49
+ if (value !== undefined && value.length > 0) return value;
50
+ }
51
+ return undefined;
52
+ };
53
+
54
+ const configuredKeyNames = (
55
+ provider: string,
56
+ env: NodeJS.ProcessEnv,
57
+ catalog: ProviderInfo,
58
+ ): readonly string[] => {
59
+ const configured = env[`PLURNK_PROVIDERS_PROVIDER_${envPrefix(provider)}_API_KEY_ENV`];
60
+ return configured === undefined || configured.length === 0
61
+ ? catalog.env
62
+ : configured.split(",").map((name) => name.trim()).filter(Boolean);
63
+ };
64
+
65
+ const expandEnv = (value: string, env: NodeJS.ProcessEnv, provider: string): string =>
66
+ value.replaceAll(/\$\{([A-Z0-9_]+)\}/g, (_match, name: string) => {
67
+ const replacement = env[name];
68
+ if (replacement === undefined || replacement.length === 0) {
69
+ throw new Error(`${provider} provider: ${name} must be set to resolve the Models.dev API URL`);
70
+ }
71
+ return replacement;
72
+ });
73
+
74
+ const baseUrl = (
75
+ provider: string,
76
+ env: NodeJS.ProcessEnv,
77
+ catalog: ProviderInfo,
78
+ override?: string,
79
+ ): string | undefined => {
80
+ const prefix = envPrefix(provider);
81
+ const configured = env[`PLURNK_PROVIDERS_PROVIDER_${prefix}_BASE_URL`]
82
+ ?? env[`${prefix}_BASE_URL`]
83
+ ?? catalog.api;
84
+ const value = override ?? configured;
85
+ return value === undefined ? undefined : expandEnv(value, env, provider).replace(/\/+$/, "");
86
+ };
87
+
88
+ const apiKey = (
89
+ provider: string,
90
+ env: NodeJS.ProcessEnv,
91
+ catalog: ProviderInfo,
92
+ ): string | undefined => firstSet(env, configuredKeyNames(provider, env, catalog));
93
+
94
+ const requireApiKey = (
95
+ provider: string,
96
+ env: NodeJS.ProcessEnv,
97
+ catalog: ProviderInfo,
98
+ ): string => {
99
+ const names = configuredKeyNames(provider, env, catalog);
100
+ const key = firstSet(env, names);
101
+ if (key === undefined) throw new Error(`${provider} provider: ${names.join(" or ")} must be set`);
102
+ return key;
103
+ };
104
+
105
+ export const createSdkModel = (
106
+ provider: string,
107
+ model: string,
108
+ env: NodeJS.ProcessEnv,
109
+ baseUrlOverride?: string,
110
+ ): SdkModel | null => {
111
+ const catalog = lookupProvider(provider) ?? configuredProviderInfo(provider, env);
112
+ if (catalog === null) return null;
113
+ const url = baseUrl(provider, env, catalog, baseUrlOverride);
114
+
115
+ switch (catalog.npm) {
116
+ case "@ai-sdk/openai":
117
+ return {
118
+ languageModel: createOpenAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).chat(model),
119
+ catalog,
120
+ };
121
+ case "@ai-sdk/groq":
122
+ return {
123
+ languageModel: createGroq({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
124
+ catalog,
125
+ };
126
+ case "@ai-sdk/mistral":
127
+ return {
128
+ languageModel: createMistral({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
129
+ catalog,
130
+ };
131
+ case "@ai-sdk/togetherai":
132
+ return {
133
+ languageModel: createTogetherAI({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
134
+ catalog,
135
+ };
136
+ case "@ai-sdk/deepinfra":
137
+ return {
138
+ languageModel: createDeepInfra({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
139
+ catalog,
140
+ };
141
+ case "@ai-sdk/google":
142
+ return {
143
+ languageModel: createGoogle({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
144
+ catalog,
145
+ };
146
+ case "@ai-sdk/xai":
147
+ return {
148
+ languageModel: createXai({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
149
+ catalog,
150
+ };
151
+ case "@ai-sdk/anthropic":
152
+ return {
153
+ languageModel: createAnthropic({ apiKey: requireApiKey(provider, env, catalog), baseURL: url }).languageModel(model),
154
+ catalog,
155
+ };
156
+ case "@ai-sdk/amazon-bedrock":
157
+ return {
158
+ languageModel: createAmazonBedrock({
159
+ region: env.AWS_REGION ?? env.AWS_DEFAULT_REGION,
160
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
161
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
162
+ sessionToken: env.AWS_SESSION_TOKEN,
163
+ apiKey: env.AWS_BEARER_TOKEN_BEDROCK,
164
+ baseURL: url,
165
+ }).languageModel(model),
166
+ catalog,
167
+ };
168
+ case "@openrouter/ai-sdk-provider":
169
+ return {
170
+ languageModel: createOpenRouter({
171
+ apiKey: requireApiKey(provider, env, catalog),
172
+ baseURL: url,
173
+ headers: {
174
+ ...(env.OPENROUTER_HTTP_REFERER === undefined ? {} : { "HTTP-Referer": env.OPENROUTER_HTTP_REFERER }),
175
+ ...(env.OPENROUTER_X_TITLE === undefined ? {} : { "X-Title": env.OPENROUTER_X_TITLE }),
176
+ },
177
+ }).languageModel(model),
178
+ catalog,
179
+ };
180
+ case "@ai-sdk/openai-compatible":
181
+ if (url === undefined) throw new Error(`${provider} provider: Models.dev supplies no API URL and no base URL was configured`);
182
+ return {
183
+ compatible: {
184
+ url: `${url}/chat/completions`,
185
+ headers: apiKey(provider, env, catalog) === undefined
186
+ ? {}
187
+ : { Authorization: `Bearer ${apiKey(provider, env, catalog)}` },
188
+ },
189
+ catalog,
190
+ };
191
+ default:
192
+ throw new Error(`${provider} provider: Models.dev declares unsupported AI SDK package ${catalog.npm}`);
193
+ }
194
+ };
@@ -1,7 +1,15 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
+ import { APICallError } from "ai";
3
4
  import { ProviderError, classifyProviderError, toProviderError, providerSource } from "./telemetry.ts";
4
- import { OpenAiHttpError } from "./openaiStream.ts";
5
+
6
+ const apiError = (statusCode: number, responseBody = "body") => new APICallError({
7
+ message: `request failed (${statusCode})`,
8
+ url: "https://example.test/v1/chat/completions",
9
+ requestBodyValues: {},
10
+ statusCode,
11
+ responseBody,
12
+ });
5
13
 
6
14
  // Schema pattern for TelemetryEvent.source (from grammar's TelemetryEvent.json).
7
15
  const SOURCE_PATTERN = /^[a-z]+(:[a-z][a-z0-9-]*)?$/;
@@ -13,7 +21,7 @@ test("providerSource produces a schema-valid colon-namespaced source", () => {
13
21
  });
14
22
 
15
23
  test("classifyProviderError maps HTTP status to kind", () => {
16
- const k = (status: number) => classifyProviderError(new OpenAiHttpError(status, "body", null)).kind;
24
+ const k = (status: number) => classifyProviderError(apiError(status)).kind;
17
25
  assert.equal(k(401), "unauthorized");
18
26
  assert.equal(k(403), "unauthorized");
19
27
  assert.equal(k(402), "quota_exceeded");
@@ -24,13 +32,11 @@ test("classifyProviderError maps HTTP status to kind", () => {
24
32
  assert.equal(k(404), "invalid_response");
25
33
  });
26
34
 
27
- test("classifyProviderError: a 422 flagged grammar_invalid is transient (#548); other 422s terminal", () => {
28
- const rejected = new OpenAiHttpError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }), null);
35
+ test("classifyProviderError: a 422 flagged grammar_invalid is distinct (#548); other 422s are invalid responses", () => {
36
+ const rejected = apiError(422, JSON.stringify({ error: { type: "grammar_invalid", message: "non-conforming emission rejected: ..." } }));
29
37
  assert.equal(classifyProviderError(rejected).kind, "grammar_invalid");
30
- // a 422 carrying any other error.type is a malformed request — terminal
31
- assert.equal(classifyProviderError(new OpenAiHttpError(422, JSON.stringify({ error: { type: "invalid_request_error" } }), null)).kind, "invalid_response");
32
- // a non-JSON 422 body (edge/proxy HTML) is terminal, and the sniff does not throw
33
- assert.equal(classifyProviderError(new OpenAiHttpError(422, "<html>Bad</html>", null)).kind, "invalid_response");
38
+ assert.equal(classifyProviderError(apiError(422, JSON.stringify({ error: { type: "invalid_request_error" } }))).kind, "invalid_response");
39
+ assert.equal(classifyProviderError(apiError(422, "<html>Bad</html>")).kind, "invalid_response");
34
40
  });
35
41
 
36
42
  test("classifyProviderError treats non-HTTP errors as network_failure", () => {
@@ -49,11 +55,12 @@ test("ProviderError.toTelemetryEvent emits the canonical envelope", () => {
49
55
  });
50
56
 
51
57
  test("toProviderError classifies and tags an HTTP error with the source + status", () => {
52
- const pe = toProviderError(new OpenAiHttpError(401, "no key", null), "provider:groq");
58
+ const cause = apiError(401, "no key");
59
+ const pe = toProviderError(cause, "provider:groq");
53
60
  assert.equal(pe.kind, "unauthorized");
54
61
  assert.equal(pe.source, "provider:groq");
55
62
  assert.equal(pe.status, 401);
56
- assert.equal(pe.cause instanceof OpenAiHttpError, true); // original preserved
63
+ assert.equal(pe.cause, cause);
57
64
  });
58
65
 
59
66
  test("toProviderError passes an existing ProviderError through unchanged", () => {
package/src/telemetry.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // dependency on grammar (see SPEC §11). Consumers route provider events through
7
7
  // the same `source` + `kind` discriminator as parse/rail events.
8
8
 
9
- import { OpenAiHttpError } from "./openaiStream.ts";
9
+ import { APICallError, RetryError } from "ai";
10
10
 
11
11
  // Required by the schema: source (producer id) + kind (discriminator). message
12
12
  // and position are optional. A transport failure isn't localizable, so it carries
@@ -29,11 +29,9 @@ export type ProviderTelemetryKind =
29
29
  | "invalid_response"
30
30
  | "unauthorized"
31
31
  | "quota_exceeded"
32
- // A 422 whose error.type is "grammar_invalid" (#548): the backend served ONE
33
- // generation and rejected it as non-conforming. Transient, not terminal — a
34
- // fresh sample may conform — so it rides the retry budget. Distinct from
35
- // grammar_unenforced below: that observes a SUCCEEDED exchange; this is a
36
- // thrown transport failure the daemon retries, then fails as a leaf.
32
+ // A 422 whose error.type is "grammar_invalid" (#548): the backend served one
33
+ // generation and rejected it as non-conforming. This identifies the failure;
34
+ // whether to start another model exchange belongs to the engine.
37
35
  | "grammar_invalid"
38
36
  // Output did not conform to the GBNF. ALWAYS an observation, never a throw:
39
37
  // a completed exchange returns its bytes with this event on
@@ -80,18 +78,27 @@ const wireErrorType = (body: string): string | null => {
80
78
  // Map a thrown transport error to a (kind, message). Conservative; the message
81
79
  // is factual, no guidance prose (consumer SPEC §15.1 policy).
82
80
  export const classifyProviderError = (err: unknown): { kind: ProviderTelemetryKind; message: string } => {
83
- if (err instanceof OpenAiHttpError) {
84
- const { status, message } = err;
81
+ if (RetryError.isInstance(err)) return classifyProviderError(err.lastError);
82
+ if (APICallError.isInstance(err)) {
83
+ const status = err.statusCode ?? 0;
84
+ const message = err.message;
85
+ const body = err.responseBody ?? "";
85
86
  if (status === 401 || status === 403) return { kind: "unauthorized", message };
86
87
  if (status === 402) return { kind: "quota_exceeded", message };
87
88
  if (status === 429) return { kind: "rate_limit", message };
88
89
  if (status >= 500) return { kind: "network_failure", message };
89
- // A 422 flagged grammar_invalid is a stochastic output reject (#548) — one
90
- // generation served then rejected, so a fresh sample may conform: transient.
91
- // Any other 422 is a malformed request: terminal (invalid_response).
92
- if (status === 422 && wireErrorType(err.body) === "grammar_invalid") return { kind: "grammar_invalid", message };
90
+ // A flagged 422 identifies a grammar-rejected exchange. It is not
91
+ // transport replay policy; the engine decides whether to sample again.
92
+ if (status === 422 && wireErrorType(body) === "grammar_invalid") return { kind: "grammar_invalid", message };
93
93
  return { kind: "invalid_response", message };
94
94
  }
95
+ const wire = err as { message?: unknown; type?: unknown; status?: unknown };
96
+ if (wire?.type === "grammar_invalid") {
97
+ return {
98
+ kind: "grammar_invalid",
99
+ message: typeof wire.message === "string" ? wire.message : "grammar-invalid response",
100
+ };
101
+ }
95
102
  const e = err as { name?: string; message?: string };
96
103
  const message = (e?.message ?? String(err)) || "request failed";
97
104
  // Timeouts / fetch-level failures are transport, not a usable response.
@@ -102,7 +109,8 @@ export const classifyProviderError = (err: unknown): { kind: ProviderTelemetryKi
102
109
  // Already-classified errors pass through unchanged.
103
110
  export const toProviderError = (err: unknown, source: string): ProviderError => {
104
111
  if (err instanceof ProviderError) return err;
105
- const { kind, message } = classifyProviderError(err);
106
- const status = err instanceof OpenAiHttpError ? err.status : null;
112
+ const underlying = RetryError.isInstance(err) ? err.lastError : err;
113
+ const { kind, message } = classifyProviderError(underlying);
114
+ const status = APICallError.isInstance(underlying) ? underlying.statusCode ?? null : null;
107
115
  return new ProviderError(source, kind, message, { status, cause: err });
108
116
  };
package/src/types.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // is the wire-reported CoT only.
4
4
 
5
5
  import type { TelemetryEvent } from "./telemetry.ts";
6
+ import type { LanguageModel } from "ai";
6
7
 
7
8
  export interface ChatMessage {
8
9
  role: "system" | "user" | "assistant";
@@ -69,9 +70,9 @@ export interface ProviderResponse {
69
70
  readonly assistant: ProviderAssistant;
70
71
  readonly assistantRaw: unknown;
71
72
  // Per-turn provider→client metadata bag: the backend's non-standard top-level
72
- // response fields, passed through verbatim, PLUS validated known keys we hold a
73
- // contract for (e.g. `balancePico` — a finite pico-USD number, from the plurnk
74
- // endpoint). The consumer (service) merges this into its Turn metadata and
73
+ // response fields passed through verbatim. Monetary values carry their own
74
+ // amount and currency; the provider does not reinterpret them. The consumer
75
+ // (service) merges this into its Turn metadata and
75
76
  // filters what reaches the client; it reads `meta`, never mines `assistantRaw`.
76
77
  // Absent when the backend reported no extra fields (#23, generalized).
77
78
  readonly meta?: Record<string, unknown>;
@@ -191,9 +192,9 @@ export interface Provider {
191
192
  // `tokenize === undefined` means the backend can't. Exact-counting
192
193
  // consumers (the tokenizer seam) prefer this over any client-side data.
193
194
  tokenize?(text: string): Promise<number[]>;
194
- // Provider-owned cost calculation. Returns pico-USD (1e-12 USD).
195
+ // Provider-owned estimated cost calculation. Returns USD.
195
196
  // Returns 0 for siblings/models with no known rates.
196
- costFor(usage: ProviderUsage): number;
197
+ calculateCost(usage: ProviderUsage): number;
197
198
  }
198
199
 
199
200
  // ProviderAlias moved to @plurnk/plurnk-aliases (the zero-dep parser, #27);
@@ -208,12 +209,8 @@ export interface ProviderOptions {
208
209
  readonly baseUrl?: string;
209
210
  }
210
211
 
211
- // Each provider package's default export MUST be a factory:
212
- // static fromEnv(env, model, options?) → Provider | Promise<Provider>
213
- // `model` is the second positional arg because PLURNK_MODEL_<alias>=<provider>/<model>
214
- // is parsed by the registry; the resolved model id flows through. `options` is an
215
- // optional third arg (per-alias overrides, e.g. baseUrl); a factory that ignores
216
- // it keeps working unchanged.
217
- export interface ProviderFactory {
218
- fromEnv(env: NodeJS.ProcessEnv, model: string, options?: ProviderOptions): Provider | Promise<Provider>;
212
+ // A discovered provider plugin default-exports an AI SDK provider. PLURNK owns
213
+ // the adapter into Provider; the plugin owns only its protocol binding.
214
+ export interface AiSdkProviderPlugin {
215
+ languageModel(model: string): LanguageModel;
219
216
  }
package/src/usage.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { normalizeUsage, computeCost } from "./usage.ts";
3
+ import { normalizeUsage, calculateCostUsd } from "./usage.ts";
4
4
 
5
5
  // — normalizeUsage —
6
6
 
@@ -115,22 +115,20 @@ test("normalizeUsage: no total reported -> re-split skipped, reasoning stays 0 (
115
115
  assert.deepEqual(u, { prompt: 10, completion: 20, reasoning: 0, cached: 0, total: 30 });
116
116
  });
117
117
 
118
- // — computeCost —
118
+ // — calculateCostUsd —
119
119
 
120
- test("computeCost: bills reasoning at the output rate", () => {
120
+ test("calculateCostUsd: bills reasoning at the USD-per-million output rate", () => {
121
121
  // 100 input, 0 cached, 50 completion + 200 reasoning = 250 output.
122
122
  const usage = { prompt: 100, completion: 50, reasoning: 200, cached: 0, total: 350 };
123
- // input 1 pico/tok, output 10 pico/tok → 100*1 + 250*10 = 2600
124
- assert.equal(computeCost(usage, { input: 1, output: 10, cached: 0 }), 2600);
123
+ assert.equal(calculateCostUsd(usage, { input: 1, output: 10, cached: 0 }), 0.0026);
125
124
  });
126
125
 
127
- test("computeCost: cached prompt billed at the cache rate, remainder at input", () => {
126
+ test("calculateCostUsd: cached prompt billed at the cache rate, remainder at input", () => {
128
127
  const usage = { prompt: 1000, completion: 0, reasoning: 0, cached: 400, total: 1000 };
129
- // 600 non-cached @5 + 400 cached @1 = 3000 + 400 = 3400
130
- assert.equal(computeCost(usage, { input: 5, output: 99, cached: 1 }), 3400);
128
+ assert.equal(calculateCostUsd(usage, { input: 5, output: 99, cached: 1 }), 0.0034);
131
129
  });
132
130
 
133
- test("computeCost: zero rates → 0", () => {
131
+ test("calculateCostUsd: zero rates → 0", () => {
134
132
  const usage = { prompt: 9, completion: 9, reasoning: 9, cached: 9, total: 27 };
135
- assert.equal(computeCost(usage, { input: 0, output: 0, cached: 0 }), 0);
133
+ assert.equal(calculateCostUsd(usage, { input: 0, output: 0, cached: 0 }), 0);
136
134
  });
package/src/usage.ts CHANGED
@@ -69,14 +69,18 @@ export const normalizeUsage = (raw: RawUsage | null | undefined, reasoningText =
69
69
  return { prompt, completion, reasoning, cached, total };
70
70
  };
71
71
 
72
- // Per-token rates in pico-USD (1e-12 USD).
72
+ // Conventional provider pricing: USD per million tokens, matching Models.dev.
73
73
  export type TokenRates = { input: number; output: number; cached: number };
74
74
 
75
75
  // The one cost formula every provider uses: non-cached prompt at the input
76
76
  // rate, cached prompt at the cache rate, and billable output (completion +
77
77
  // reasoning) at the output rate.
78
- export const computeCost = (usage: ProviderUsage, rates: TokenRates): number => {
78
+ export const calculateCostUsd = (usage: ProviderUsage, rates: TokenRates): number => {
79
79
  const nonCachedPrompt = Math.max(0, usage.prompt - usage.cached);
80
80
  const output = usage.completion + usage.reasoning;
81
- return Math.round(nonCachedPrompt * rates.input + usage.cached * rates.cached + output * rates.output);
81
+ return (
82
+ nonCachedPrompt * rates.input
83
+ + usage.cached * rates.cached
84
+ + output * rates.output
85
+ ) / 1_000_000;
82
86
  };