@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
@@ -3,18 +3,19 @@
3
3
  // overrides) lives in @plurnk/plurnk-aliases — the zero-dep parser shared with
4
4
  // thin clients (#27); this module resolves the active alias to a Provider.
5
5
  //
6
- // Two-tier resolution (SPEC §5): tier 1 is the closed standard-provider table;
7
- // tier 2 is a SCOPE-AGNOSTIC node_modules scan (discover()) for packages
8
- // declaring `plurnk.kind:"provider"` — first-party plugins (installed flat
9
- // via @plurnk/plurnk-providers-all) AND third-party providers under any scope.
10
- // The framework is contract-only — it does NOT depend on its plugins; the
11
- // scan is what surfaces them (#12/#14).
6
+ // Resolution order (SPEC §5): models.dev catalog → PLURNK provider declaration
7
+ // → local protocol adapter → scope-agnostic AI SDK plugin discovery. Generic
8
+ // provider facts belong to models.dev or operator config; PLURNK owns only the
9
+ // stable Provider contract and product-specific local behavior.
12
10
 
13
- import type { Provider, ProviderFactory } from "./types.ts";
14
- import { isStandardProvider, standardProviderFromEnv } from "./standardProviders.ts";
11
+ import type { AiSdkProviderPlugin, Provider } from "./types.ts";
12
+ import { catalogProviderFromEnv, providerFromSdkModel } from "./catalogProvider.ts";
15
13
  import { discover, type DiscoverOptions, type Discovery } from "./discover.ts";
16
14
  import { resolveActiveAlias } from "@plurnk/plurnk-aliases";
17
15
  import { scopeEnvToAlias } from "./env.ts";
16
+ import { ollamaProviderFromEnv } from "./ollama.ts";
17
+ import { compatibleProviderFromEnv } from "./compatibleProvider.ts";
18
+ import { contextWindowFromEnv } from "./env.ts";
18
19
 
19
20
  // Two injectable seams, both defaulting to production behavior and never passed
20
21
  // by real callers: the module importer (tests exercise the bespoke path without
@@ -33,10 +34,8 @@ const providerPackages = async (discoverFn: DiscoverFn, env: NodeJS.ProcessEnv):
33
34
  return discoveredCache;
34
35
  };
35
36
 
36
- // Two-tier resolution (SPEC §5): tier 1 standard table → tier 2 discovered
37
- // package (scope-agnostic scan, trust-gated) → fail-hard. The standard table is
38
- // authoritative — a scanned package whose name duplicates a standard one is
39
- // shadowed here (never reached), since tier 1 returns first.
37
+ // Catalog and explicit declarations are authoritative. Discovery is the
38
+ // extensibility seam for an AI SDK provider that neither source describes.
40
39
  export const instantiateProvider = async (
41
40
  name: string,
42
41
  env: NodeJS.ProcessEnv,
@@ -46,14 +45,13 @@ export const instantiateProvider = async (
46
45
  baseUrl?: string, // per-alias endpoint override (PLURNK_BASEURL_<alias>); threaded to both tiers
47
46
  alias?: string, // the alias this instantiation serves — scopes PLURNK_PROVIDERS_<KNOB>_<alias> overrides
48
47
  ): Promise<Provider> => {
49
- // Per-alias knob scoping: overlay any _<alias>-suffixed knob onto its bare
50
- // name so both tiers (and every fromEnv) read plain vars, per-alias-resolved.
48
+ // Per-alias knob scoping overlays _<alias>-suffixed knobs onto their bare
49
+ // names before any resolver reads them.
51
50
  if (alias !== undefined) env = scopeEnvToAlias(env, alias);
52
- if (isStandardProvider(name)) {
53
- const standard = await standardProviderFromEnv(name, env, model, baseUrl);
54
- if (standard === null) throw new Error(`provider "${name}": standard registry resolution failed`);
55
- return standard;
56
- }
51
+ const catalog = catalogProviderFromEnv(name, env, model, baseUrl);
52
+ if (catalog !== null) return catalog;
53
+ if (name === "ollama") return ollamaProviderFromEnv(env, model, baseUrl === undefined ? undefined : { baseUrl });
54
+ if (name === "openai" || name === "plurnk") return compatibleProviderFromEnv(name, env, model, baseUrl);
57
55
  const { registry, skipped } = await providerPackages(discoverFn, env);
58
56
  const specifier = registry.get(name);
59
57
  if (specifier === undefined) {
@@ -61,7 +59,7 @@ export const instantiateProvider = async (
61
59
  if (declined !== undefined) {
62
60
  throw new Error(`provider "${name}" resolves to ${declined}, but it is untrusted under PLURNK_PLUGINS_TRUSTED_ONLY — add it to the allowlist (or publish under @plurnk/)`);
63
61
  }
64
- throw new Error(`unknown provider "${name}": not a standard provider, and no installed package declares plurnk.kind:"provider" with name "${name}"`);
62
+ throw new Error(`unknown provider "${name}": absent from models.dev, operator declarations, local adapters, and installed AI SDK provider plugins`);
65
63
  }
66
64
  let mod: unknown;
67
65
  try {
@@ -69,11 +67,24 @@ export const instantiateProvider = async (
69
67
  } catch (cause) {
70
68
  throw new Error(`provider "${name}" resolves to ${specifier}, but importing it failed`, { cause });
71
69
  }
72
- const factory = (mod as { default?: ProviderFactory }).default;
73
- if (factory === undefined || typeof factory.fromEnv !== "function") {
74
- throw new Error(`${specifier} default export is not a Provider factory (missing static fromEnv)`);
70
+ const sdkProvider = (mod as { default?: AiSdkProviderPlugin }).default;
71
+ if (sdkProvider === undefined || typeof sdkProvider.languageModel !== "function") {
72
+ throw new Error(`${specifier} default export is not an AI SDK provider (missing languageModel)`);
73
+ }
74
+ if (baseUrl !== undefined) {
75
+ throw new Error(`${specifier}: PLURNK_BASEURL_${alias ?? "<alias>"} cannot reconfigure an installed AI SDK provider; declare the provider through PLURNK_PROVIDERS_PROVIDER_* instead`);
76
+ }
77
+ const contextWindow = contextWindowFromEnv(env, name);
78
+ if (contextWindow === null) {
79
+ throw new Error(`${specifier}: PLURNK_PROVIDERS_CONTEXT_WINDOW must be set because Models.dev has no metadata for provider "${name}"`);
75
80
  }
76
- return await factory.fromEnv(env, model, baseUrl !== undefined ? { baseUrl } : undefined);
81
+ return providerFromSdkModel({
82
+ name,
83
+ env,
84
+ model,
85
+ languageModel: sdkProvider.languageModel(model),
86
+ contextWindow,
87
+ });
77
88
  };
78
89
 
79
90
  // Test-only: drop the memoized discovery so a fresh scan/injection runs next.
@@ -0,0 +1,253 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { executeOpenAICompatible } from "./aiSdkTransport.ts";
4
+
5
+ const request = {
6
+ url: "https://example.test/v1/chat/completions",
7
+ model: "test-model",
8
+ headers: {},
9
+ body: {},
10
+ messages: [{ role: "user" as const, content: "question" }],
11
+ fetchTimeoutMs: 1_000,
12
+ retryAttempts: 2,
13
+ streaming: false,
14
+ captureRawBody: false,
15
+ };
16
+
17
+ test("X-Should-Retry:false prevents nested retries for a normally retryable status", async () => {
18
+ let calls = 0;
19
+ await assert.rejects(executeOpenAICompatible({
20
+ ...request,
21
+ fetch: async () => {
22
+ calls += 1;
23
+ return new Response(
24
+ JSON.stringify({ error: { message: "upstream attempts exhausted" } }),
25
+ {
26
+ status: 503,
27
+ headers: {
28
+ "content-type": "application/json",
29
+ "x-should-retry": "false",
30
+ },
31
+ },
32
+ );
33
+ },
34
+ }));
35
+ assert.equal(calls, 1);
36
+ });
37
+
38
+ test("the adapter preserves PLURNK request extensions and response evidence", async () => {
39
+ let body: Record<string, unknown> | undefined;
40
+ const responseBody = {
41
+ id: "response-1",
42
+ object: "chat.completion",
43
+ created: 1,
44
+ model: "served-model",
45
+ choices: [{
46
+ index: 0,
47
+ message: {
48
+ role: "assistant",
49
+ content: "answer",
50
+ reasoning_content: "because",
51
+ },
52
+ finish_reason: "stop",
53
+ logprobs: {
54
+ content: [{ token: "answer", logprob: -0.1, top_logprobs: [] }],
55
+ },
56
+ }],
57
+ usage: {
58
+ prompt_tokens: 3,
59
+ completion_tokens: 5,
60
+ total_tokens: 8,
61
+ completion_tokens_details: { reasoning_tokens: 2 },
62
+ },
63
+ balance: { amount: 1.25, currency: "USD" },
64
+ };
65
+ const result = await executeOpenAICompatible({
66
+ ...request,
67
+ retryAttempts: 0,
68
+ captureRawBody: true,
69
+ body: {
70
+ grammar: "root ::= \"answer\"",
71
+ id_slot: 2,
72
+ },
73
+ fetch: async (_input, init) => {
74
+ body = JSON.parse(String(init?.body)) as Record<string, unknown>;
75
+ return new Response(JSON.stringify(responseBody), {
76
+ headers: { "content-type": "application/json" },
77
+ });
78
+ },
79
+ });
80
+
81
+ assert.equal(body?.grammar, "root ::= \"answer\"");
82
+ assert.equal(body?.id_slot, 2);
83
+ assert.equal(result.model, "served-model");
84
+ assert.equal(result.content, "answer");
85
+ assert.equal(result.reasoning, "because");
86
+ assert.equal(result.finishReason, "stop");
87
+ assert.deepEqual(result.usage, {
88
+ prompt: 3,
89
+ completion: 3,
90
+ reasoning: 2,
91
+ cached: 0,
92
+ total: 8,
93
+ });
94
+ assert.equal(result.logprobs[0]?.token, "answer");
95
+ assert.deepEqual(result.metadata.balance, { amount: 1.25, currency: "USD" });
96
+ assert.deepEqual(result.rawBody, responseBody);
97
+ });
98
+
99
+ test("the adapter maps leading system messages to AI SDK instructions", async () => {
100
+ const calls: Record<string, unknown>[] = [];
101
+ const fetch: typeof globalThis.fetch = async (_url, init) => {
102
+ calls.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
103
+ return new Response(JSON.stringify({
104
+ model: "m",
105
+ choices: [{ message: { content: "ok" }, finish_reason: "stop" }],
106
+ usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 },
107
+ }), { status: 200, headers: { "content-type": "application/json" } });
108
+ };
109
+ await executeOpenAICompatible({
110
+ url: "https://example.test/v1/chat/completions",
111
+ model: "m",
112
+ headers: {},
113
+ body: {},
114
+ messages: [
115
+ { role: "system", content: "system contract" },
116
+ { role: "user", content: "hello" },
117
+ ],
118
+ fetchTimeoutMs: 1000,
119
+ retryAttempts: 0,
120
+ streaming: false,
121
+ captureRawBody: false,
122
+ fetch,
123
+ });
124
+ assert.deepEqual(calls[0]?.messages, [
125
+ { role: "system", content: "system contract" },
126
+ { role: "user", content: "hello" },
127
+ ]);
128
+ });
129
+
130
+ test("the adapter preserves nonstandard reasoning accounting after SDK parsing", async (t) => {
131
+ const execute = (responseBody: object) => executeOpenAICompatible({
132
+ ...request,
133
+ retryAttempts: 0,
134
+ fetch: async () => new Response(JSON.stringify(responseBody), {
135
+ headers: { "content-type": "application/json" },
136
+ }),
137
+ });
138
+ const response = (
139
+ message: Record<string, unknown>,
140
+ usage: Record<string, number>,
141
+ ) => ({
142
+ id: "response-1",
143
+ object: "chat.completion",
144
+ created: 1,
145
+ model: "served-model",
146
+ choices: [{ index: 0, message: { role: "assistant", ...message }, finish_reason: "stop" }],
147
+ usage,
148
+ });
149
+
150
+ await t.test("Gemini-style total gap becomes reasoning", async () => {
151
+ const result = await execute(response(
152
+ { content: "answer" },
153
+ { prompt_tokens: 2, completion_tokens: 3, total_tokens: 9 },
154
+ ));
155
+ assert.deepEqual(result.usage, {
156
+ prompt: 2,
157
+ completion: 3,
158
+ reasoning: 4,
159
+ cached: 0,
160
+ total: 9,
161
+ });
162
+ });
163
+
164
+ await t.test("Fireworks-style unitemized output is split by returned channels", async () => {
165
+ const result = await execute(response(
166
+ { content: "aa", reasoning_content: "bbbbbb" },
167
+ { prompt_tokens: 2, completion_tokens: 10, total_tokens: 12 },
168
+ ));
169
+ assert.deepEqual(result.usage, {
170
+ prompt: 2,
171
+ completion: 2,
172
+ reasoning: 8,
173
+ cached: 0,
174
+ total: 12,
175
+ });
176
+ });
177
+
178
+ await t.test("streamed Gemini-style total gap is preserved", async () => {
179
+ const chunks = [
180
+ {
181
+ id: "response-1",
182
+ object: "chat.completion.chunk",
183
+ created: 1,
184
+ model: "served-model",
185
+ choices: [{ index: 0, delta: { content: "answer" }, finish_reason: null }],
186
+ },
187
+ {
188
+ id: "response-1",
189
+ object: "chat.completion.chunk",
190
+ created: 1,
191
+ model: "served-model",
192
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
193
+ usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 9 },
194
+ },
195
+ ];
196
+ const result = await executeOpenAICompatible({
197
+ ...request,
198
+ retryAttempts: 0,
199
+ streaming: true,
200
+ fetch: async () => new Response(
201
+ `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`,
202
+ { headers: { "content-type": "text/event-stream" } },
203
+ ),
204
+ });
205
+ assert.deepEqual(result.usage, {
206
+ prompt: 2,
207
+ completion: 3,
208
+ reasoning: 4,
209
+ cached: 0,
210
+ total: 9,
211
+ });
212
+ });
213
+
214
+ await t.test("streamed Fireworks-style channels preserve the output split", async () => {
215
+ const chunks = [
216
+ {
217
+ id: "response-1",
218
+ object: "chat.completion.chunk",
219
+ created: 1,
220
+ model: "served-model",
221
+ choices: [{
222
+ index: 0,
223
+ delta: { reasoning_content: "bbbbbb", content: "aa" },
224
+ finish_reason: null,
225
+ }],
226
+ },
227
+ {
228
+ id: "response-1",
229
+ object: "chat.completion.chunk",
230
+ created: 1,
231
+ model: "served-model",
232
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
233
+ usage: { prompt_tokens: 2, completion_tokens: 10, total_tokens: 12 },
234
+ },
235
+ ];
236
+ const result = await executeOpenAICompatible({
237
+ ...request,
238
+ retryAttempts: 0,
239
+ streaming: true,
240
+ fetch: async () => new Response(
241
+ `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`,
242
+ { headers: { "content-type": "text/event-stream" } },
243
+ ),
244
+ });
245
+ assert.deepEqual(result.usage, {
246
+ prompt: 2,
247
+ completion: 2,
248
+ reasoning: 8,
249
+ cached: 0,
250
+ total: 12,
251
+ });
252
+ });
253
+ });