@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,205 @@
1
+ import AiSdkProvider, { type GrammarStyle, type ReasoningStyle } from "./AiSdkProvider.ts";
2
+ import {
3
+ contextWindowFromEnv,
4
+ dataCaptureFromEnv,
5
+ envelopeFromEnv,
6
+ parseOptionalFloat,
7
+ parseOptionalInt,
8
+ parseRequiredFloat,
9
+ parseRequiredInt,
10
+ promptCacheKeyFromEnv,
11
+ reasoningFromEnv,
12
+ } from "./env.ts";
13
+ import { providerSource } from "./telemetry.ts";
14
+ import type { Provider } from "./types.ts";
15
+ import { emitWarningOnce } from "./warnings.ts";
16
+
17
+ type EndpointProbe = {
18
+ nCtx: number | null;
19
+ llamaServer: boolean;
20
+ servedModel: string | null;
21
+ failed: boolean;
22
+ };
23
+
24
+ const chatUrl = (
25
+ provider: "openai" | "plurnk",
26
+ env: NodeJS.ProcessEnv,
27
+ override?: string,
28
+ ): string => {
29
+ const configured = override
30
+ ?? (provider === "openai"
31
+ ? env.OPENAI_BASE_URL ?? env.OPENAI_API_BASE
32
+ : env.PLURNK_BASE_URL);
33
+ if (configured === undefined || configured.length === 0) {
34
+ throw new Error(`${provider} provider: ${provider === "openai" ? "OPENAI_BASE_URL or OPENAI_API_BASE" : "PLURNK_BASE_URL"} must be set`);
35
+ }
36
+ const base = configured.replace(/\/+$/, "");
37
+ if (base.endsWith("/chat/completions")) return base;
38
+ if (provider === "openai") return `${base.replace(/\/v1$/, "")}/v1/chat/completions`;
39
+ return `${base}/chat/completions`;
40
+ };
41
+
42
+ const probeModels = async (
43
+ url: string,
44
+ headers: Record<string, string>,
45
+ model: string,
46
+ timeout: number,
47
+ ): Promise<EndpointProbe> => {
48
+ const modelsUrl = url.replace(/\/chat\/completions$/, "/models");
49
+ try {
50
+ const response = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(timeout) });
51
+ if (!response.ok) return { nCtx: null, llamaServer: false, servedModel: null, failed: true };
52
+ const data = await response.json() as {
53
+ data?: Array<{ id?: string; n_ctx?: number; meta?: { n_ctx?: number } }>;
54
+ };
55
+ const rows = data.data ?? [];
56
+ const row = rows.find((candidate) => candidate.id === model) ?? rows[0];
57
+ const nCtx = row?.meta?.n_ctx ?? row?.n_ctx;
58
+ return {
59
+ nCtx: typeof nCtx === "number" && nCtx > 0 ? nCtx : null,
60
+ llamaServer: row?.meta !== undefined,
61
+ servedModel: typeof row?.id === "string" && row.id.length > 0 ? row.id : null,
62
+ failed: false,
63
+ };
64
+ } catch {
65
+ return { nCtx: null, llamaServer: false, servedModel: null, failed: true };
66
+ }
67
+ };
68
+
69
+ const probeModelsRetrying = async (
70
+ url: string,
71
+ headers: Record<string, string>,
72
+ model: string,
73
+ timeout: number,
74
+ attempts: number,
75
+ delay: number,
76
+ ): Promise<EndpointProbe> => {
77
+ let result: EndpointProbe = { nCtx: null, llamaServer: false, servedModel: null, failed: true };
78
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
79
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, delay * 2 ** (attempt - 1)));
80
+ result = await probeModels(url, headers, model, timeout);
81
+ if (!result.failed) return result;
82
+ }
83
+ return result;
84
+ };
85
+
86
+ const probeProps = async (
87
+ url: string,
88
+ headers: Record<string, string>,
89
+ timeout: number,
90
+ ): Promise<{ slotCount: number | null; eosText: string | null }> => {
91
+ try {
92
+ const response = await fetch(url.replace(/\/v1\/chat\/completions$/, "/props"), {
93
+ headers,
94
+ signal: AbortSignal.timeout(timeout),
95
+ });
96
+ if (!response.ok) return { slotCount: null, eosText: null };
97
+ const data = await response.json() as { total_slots?: number; eos_token?: string };
98
+ return {
99
+ slotCount: typeof data.total_slots === "number" && data.total_slots > 0 ? data.total_slots : null,
100
+ eosText: typeof data.eos_token === "string" && data.eos_token.length > 0 ? data.eos_token : null,
101
+ };
102
+ } catch {
103
+ return { slotCount: null, eosText: null };
104
+ }
105
+ };
106
+
107
+ export const compatibleProviderFromEnv = async (
108
+ provider: "openai" | "plurnk",
109
+ env: NodeJS.ProcessEnv,
110
+ model: string,
111
+ baseUrlOverride?: string,
112
+ ): Promise<Provider> => {
113
+ const url = chatUrl(provider, env, baseUrlOverride);
114
+ const apiKey = provider === "openai" ? env.OPENAI_API_KEY : env.PLURNK_API_KEY;
115
+ const headers: Record<string, string> = apiKey === undefined || apiKey.length === 0
116
+ ? {}
117
+ : { Authorization: `Bearer ${apiKey}` };
118
+ const timeout = parseRequiredInt(env.PLURNK_PROVIDERS_FETCH_TIMEOUT, "PLURNK_PROVIDERS_FETCH_TIMEOUT", provider);
119
+ const attempts = parseRequiredInt(env.PLURNK_PROVIDERS_PROBE_ATTEMPTS, "PLURNK_PROVIDERS_PROBE_ATTEMPTS", provider);
120
+ const probe = await probeModelsRetrying(
121
+ url,
122
+ headers,
123
+ model,
124
+ timeout,
125
+ attempts,
126
+ parseRequiredInt(env.PLURNK_PROVIDERS_PROBE_DELAY, "PLURNK_PROVIDERS_PROBE_DELAY", provider),
127
+ );
128
+ let contextWindow = contextWindowFromEnv(env, provider) ?? probe.nCtx;
129
+ const pinRaw = env.PLURNK_PROVIDERS_LLAMA_SERVER;
130
+ if (pinRaw !== undefined && pinRaw !== "" && pinRaw !== "0" && pinRaw !== "1") {
131
+ throw new Error(`${provider} provider: PLURNK_PROVIDERS_LLAMA_SERVER must be "1", "0", or unset`);
132
+ }
133
+ const pinned = pinRaw === undefined || pinRaw === "" ? null : pinRaw === "1";
134
+ const llamaServer = provider === "openai" && (pinned ?? probe.llamaServer);
135
+ if (probe.failed && pinned === null && provider === "openai") {
136
+ emitWarningOnce(
137
+ `${provider} provider: llama-server detection failed after ${attempts} attempts; pin PLURNK_PROVIDERS_LLAMA_SERVER=1 when this is a llama-server`,
138
+ "PLURNK_PROBE_FAILED",
139
+ );
140
+ }
141
+ if (contextWindow === null) {
142
+ emitWarningOnce(
143
+ `${provider} provider: context window underivable for "${model}"; set PLURNK_PROVIDERS_CONTEXT_WINDOW`,
144
+ "PLURNK_CONTEXT_UNKNOWN",
145
+ );
146
+ }
147
+
148
+ let grammarStyle: GrammarStyle = "none";
149
+ let reasoningStyle: ReasoningStyle = provider === "openai" ? "think" : "none";
150
+ let slotCount: number | null = null;
151
+ let eosText: string | undefined;
152
+ let tokenizeUrl: string | undefined;
153
+ if (llamaServer) {
154
+ grammarStyle = "llamacpp";
155
+ reasoningStyle = "template";
156
+ const props = await probeProps(url, headers, timeout);
157
+ slotCount = props.slotCount;
158
+ eosText = props.eosText ?? undefined;
159
+ tokenizeUrl = url.replace(/\/v1\/chat\/completions$/, "/tokenize");
160
+ }
161
+
162
+ emitWarningOnce(
163
+ `${provider} provider: countTokens is a chars/2 upper bound — exact counts come from the mimetypes tokenizer seam or tokenize()`,
164
+ "PLURNK_TOKENIZER_HEURISTIC",
165
+ );
166
+ const { reasoningReserve, completionReserve } = envelopeFromEnv(env, provider);
167
+ return new AiSdkProvider({
168
+ model,
169
+ url,
170
+ headers,
171
+ contextWindow,
172
+ fetchTimeoutMs: timeout,
173
+ streamIdleTimeoutMs: parseRequiredInt(env.PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT, "PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT", provider),
174
+ reasoning: reasoningFromEnv(env, provider),
175
+ reasoningStyle,
176
+ temperature: parseRequiredFloat(env.PLURNK_PROVIDERS_TEMPERATURE, "PLURNK_PROVIDERS_TEMPERATURE", provider, 0),
177
+ repeatPenalty: parseRequiredFloat(env.PLURNK_PROVIDERS_REPEAT_PENALTY, "PLURNK_PROVIDERS_REPEAT_PENALTY", provider, 0),
178
+ frequencyPenalty: parseRequiredFloat(env.PLURNK_PROVIDERS_FREQUENCY_PENALTY, "PLURNK_PROVIDERS_FREQUENCY_PENALTY", provider, 0),
179
+ dryMultiplier: parseOptionalFloat(env.PLURNK_PROVIDERS_DRY_MULTIPLIER, "PLURNK_PROVIDERS_DRY_MULTIPLIER", provider, 0) ?? undefined,
180
+ dryBase: parseOptionalFloat(env.PLURNK_PROVIDERS_DRY_BASE, "PLURNK_PROVIDERS_DRY_BASE", provider, 0) ?? undefined,
181
+ dryAllowedLength: parseOptionalInt(env.PLURNK_PROVIDERS_DRY_ALLOWED_LENGTH, "PLURNK_PROVIDERS_DRY_ALLOWED_LENGTH", provider) ?? undefined,
182
+ repeatLastN: parseOptionalInt(env.PLURNK_PROVIDERS_REPEAT_LAST_N, "PLURNK_PROVIDERS_REPEAT_LAST_N", provider) ?? undefined,
183
+ reasoningReserve,
184
+ completionReserve,
185
+ tuningFloors: provider !== "plurnk",
186
+ retryAttempts: parseRequiredInt(env.PLURNK_PROVIDERS_RETRY_ATTEMPTS, "PLURNK_PROVIDERS_RETRY_ATTEMPTS", provider),
187
+ promptCacheKey: promptCacheKeyFromEnv(env, provider),
188
+ source: providerSource(provider),
189
+ grammarStyle,
190
+ gbnfDebug: env.PLURNK_PROVIDERS_GBNF_DEBUG !== undefined
191
+ && env.PLURNK_PROVIDERS_GBNF_DEBUG !== ""
192
+ && env.PLURNK_PROVIDERS_GBNF_DEBUG !== "0",
193
+ ...dataCaptureFromEnv(env, provider),
194
+ firstPartyMetadata: provider === "plurnk",
195
+ apiKeyRejectedMessage: provider === "plurnk"
196
+ ? "PLURNK_API_KEY was rejected by plurnk.ai (invalid or expired)."
197
+ : undefined,
198
+ supportsSlotPinning: llamaServer,
199
+ slotCount,
200
+ eosText,
201
+ tokenizeUrl,
202
+ servedModel: probe.servedModel ?? undefined,
203
+ requiresMaxTokens: llamaServer || undefined,
204
+ });
205
+ };
@@ -26,14 +26,14 @@ const buildModules = async (t: TestContext, packages: Record<string, unknown>):
26
26
 
27
27
  test("discover: the node_modules scan is scope-agnostic — third-party scopes are found", async (t) => {
28
28
  const root = await buildModules(t, {
29
- "@plurnk/plurnk-providers-openrouter": { name: "@plurnk/plurnk-providers-openrouter", plurnk: { kind: "provider", name: "openrouter" } },
29
+ "@plurnk/plurnk-provider-native": { name: "@plurnk/plurnk-provider-native", plurnk: { kind: "provider", name: "native" } },
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
33
  "@plurnk/plurnk-execs-git": { name: "@plurnk/plurnk-execs-git", plurnk: { kind: "exec", runtimes: [] } }, // wrong kind → ignored
34
34
  });
35
35
  const { registry } = await discover({ cwd: root });
36
- assert.deepEqual([...registry.keys()].sort(), ["bar", "foo", "openrouter"]);
36
+ assert.deepEqual([...registry.keys()].sort(), ["bar", "foo", "native"]);
37
37
  assert.equal(registry.get("foo"), "@acme/acme-provider-foo"); // third-party scope resolves
38
38
  assert.equal(registry.get("bar"), "unscoped-provider-bar"); // unscoped resolves
39
39
  });
@@ -49,12 +49,12 @@ test("discover: a provider package missing plurnk.name is ignored, not crashed",
49
49
 
50
50
  test("discover: a name claimed by two packages is a fail-hard collision", async (t) => {
51
51
  const root = await buildModules(t, {
52
- "@plurnk/plurnk-providers-xai": { name: "@plurnk/plurnk-providers-xai", plurnk: { kind: "provider", name: "xai" } },
53
- "@acme/rival-xai": { name: "@acme/rival-xai", plurnk: { kind: "provider", name: "xai" } },
52
+ "@plurnk/plurnk-provider-native": { name: "@plurnk/plurnk-provider-native", plurnk: { kind: "provider", name: "native" } },
53
+ "@acme/rival-native": { name: "@acme/rival-native", plurnk: { kind: "provider", name: "native" } },
54
54
  });
55
55
  await assert.rejects(
56
56
  () => discover({ cwd: root }),
57
- /provider name collision: 'xai' claimed by both/,
57
+ /provider name collision: 'native' claimed by both/,
58
58
  );
59
59
  });
60
60
 
@@ -64,11 +64,11 @@ test("discover: node_modules entries with no package.json or malformed JSON are
64
64
  await fs.mkdir(path.join(nm, "no-manifest"), { recursive: true }); // a dir, no package.json
65
65
  await fs.mkdir(path.join(nm, "broken"), { recursive: true });
66
66
  await fs.writeFile(path.join(nm, "broken", "package.json"), "{ not json", "utf-8"); // malformed
67
- const good = path.join(nm, "@plurnk", "plurnk-providers-ollama");
67
+ const good = path.join(nm, "@plurnk", "plurnk-provider-native");
68
68
  await fs.mkdir(good, { recursive: true });
69
- await fs.writeFile(path.join(good, "package.json"), JSON.stringify({ name: "@plurnk/plurnk-providers-ollama", plurnk: { kind: "provider", name: "ollama" } }), "utf-8");
69
+ await fs.writeFile(path.join(good, "package.json"), JSON.stringify({ name: "@plurnk/plurnk-provider-native", plurnk: { kind: "provider", name: "native" } }), "utf-8");
70
70
  const { registry } = await discover({ cwd: root });
71
- assert.deepEqual([...registry.keys()], ["ollama"]); // only the well-formed provider survives
71
+ assert.deepEqual([...registry.keys()], ["native"]); // only the well-formed provider survives
72
72
  });
73
73
 
74
74
  test("discover: surfaces plurnk.attribution (string or string[]) for registered providers (#21)", async (t) => {
@@ -94,7 +94,7 @@ test("discover: missing node_modules yields an empty registry, not an error", as
94
94
  // — trust gate (PLURNK_PLUGINS_TRUSTED_ONLY, #15) —
95
95
 
96
96
  const trustFixture = (t: TestContext) => buildModules(t, {
97
- "@plurnk/plurnk-providers-openrouter": { name: "@plurnk/plurnk-providers-openrouter", plurnk: { kind: "provider", name: "openrouter" } },
97
+ "@plurnk/plurnk-provider-native": { name: "@plurnk/plurnk-provider-native", plurnk: { kind: "provider", name: "native" } },
98
98
  "@acme/acme-provider-foo": { name: "@acme/acme-provider-foo", plurnk: { kind: "provider", name: "foo" } },
99
99
  });
100
100
 
@@ -102,7 +102,7 @@ test("trust gate OFF (unset/empty/0): every provider is trusted", async (t) => {
102
102
  const root = await trustFixture(t);
103
103
  for (const gate of [undefined, "", "0"]) {
104
104
  const { registry, skipped } = await discover({ cwd: root, env: { PLURNK_PLUGINS_TRUSTED_ONLY: gate } as NodeJS.ProcessEnv });
105
- assert.deepEqual([...registry.keys()].sort(), ["foo", "openrouter"]);
105
+ assert.deepEqual([...registry.keys()].sort(), ["foo", "native"]);
106
106
  assert.equal(skipped.size, 0);
107
107
  }
108
108
  });
@@ -110,7 +110,7 @@ test("trust gate OFF (unset/empty/0): every provider is trusted", async (t) => {
110
110
  test("trust gate ON: @plurnk/* always trusted; third party declined → skipped, not registered", async (t) => {
111
111
  const root = await trustFixture(t);
112
112
  const { registry, skipped } = await discover({ cwd: root, env: { PLURNK_PLUGINS_TRUSTED_ONLY: "1" } as NodeJS.ProcessEnv });
113
- assert.deepEqual([...registry.keys()], ["openrouter"]); // @plurnk/* survives
113
+ assert.deepEqual([...registry.keys()], ["native"]); // @plurnk/* survives
114
114
  assert.equal(registry.has("foo"), false); // third party not registered
115
115
  assert.equal(skipped.get("foo"), "@acme/acme-provider-foo"); // …recorded for a precise error
116
116
  });
@@ -118,6 +118,6 @@ test("trust gate ON: @plurnk/* always trusted; third party declined → skipped,
118
118
  test("trust gate ON with an allowlist: a named third-party package is trusted", async (t) => {
119
119
  const root = await trustFixture(t);
120
120
  const { registry, skipped } = await discover({ cwd: root, env: { PLURNK_PLUGINS_TRUSTED_ONLY: "@acme/acme-provider-foo" } as NodeJS.ProcessEnv });
121
- assert.deepEqual([...registry.keys()].sort(), ["foo", "openrouter"]);
121
+ assert.deepEqual([...registry.keys()].sort(), ["foo", "native"]);
122
122
  assert.equal(skipped.size, 0);
123
123
  });
package/src/discover.ts CHANGED
@@ -2,22 +2,19 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import Meta from "@plurnk/plurnk-meta";
4
4
 
5
- // Scope-agnostic discovery of installed provider packages (SPEC §5, #12/#14).
5
+ // Scope-agnostic discovery of installed AI SDK provider packages (SPEC §5).
6
6
  // Parallel to @plurnk/plurnk-execs' discover(): scan every installed package
7
7
  // under `<cwd>/node_modules` — scoped (`@scope/name`) and unscoped — and keep
8
8
  // the ones declaring `plurnk.kind === "provider"`. Scope-agnostic so a THIRD
9
9
  // PARTY can publish a provider under their own scope (`@acme/llm-provider-foo`)
10
- // and have it discovered with no involvement from us; first-party plugins
11
- // hoist flat via @plurnk/plurnk-providers-all so they land in the same scan.
10
+ // and have it discovered without involvement from PLURNK.
12
11
  //
13
12
  // A provider package maps ONE name → its package specifier (unlike execs, whose
14
13
  // runtime tags are a per-package array). The name is the alias-cascade provider
15
14
  // segment (PLURNK_MODEL_<alias>=<name>/<model>). A name collision between two
16
15
  // installed packages is a FAIL-HARD ambiguity the operator must resolve.
17
16
  //
18
- // The standard-provider table (./standardProviders.ts) is a separate, closed
19
- // tier-1 resolved before this scan — discovery covers tier 2 (bespoke + third
20
- // party) only. Precedence and standard-name shadowing live in ProviderRegistry.
17
+ // Cataloged and operator-declared providers resolve before this scan.
21
18
  //
22
19
  // Host plugin trust gate (PLURNK_PLUGINS_TRUSTED_ONLY, #15 / plurnk-service#229)
23
20
  // — enforced uniformly across the four scope-agnostic families. An untrusted
package/src/env.ts CHANGED
@@ -1,6 +1,5 @@
1
- // Env-parsing helpers shared by every provider's fromEnv factory. Each was
2
- // copy-pasted per sibling with only the error-message prefix differing; the
3
- // `label` parameter (the provider name) restores that prefix from one source.
1
+ // Env-parsing helpers shared by provider construction. `label` keeps failures
2
+ // local to the selected provider.
4
3
 
5
4
  export const parseRequiredInt = (raw: string | undefined, name: string, label: string): number => {
6
5
  if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set`);
@@ -35,6 +34,15 @@ export const requireEnv = (raw: string | undefined, name: string, label: string)
35
34
  return raw;
36
35
  };
37
36
 
37
+ export const promptCacheKeyFromEnv = (env: NodeJS.ProcessEnv, label: string): boolean => {
38
+ const name = "PLURNK_PROVIDERS_PROMPT_CACHE_KEY";
39
+ const value = env[name];
40
+ if (value !== "0" && value !== "1") {
41
+ throw new Error(`${label} provider: ${name} must be "0" or "1"`);
42
+ }
43
+ return value === "1";
44
+ };
45
+
38
46
  // Old-name shed (the #399/#472 lexicon renames): a still-set retired knob fails
39
47
  // hard pointing at its successor — never silently coexists with the new floor.
40
48
  // The retired names appear ONLY as this function's ARGUMENTS at the call sites
@@ -169,11 +177,11 @@ export const PROVIDERS_KNOBS = Object.freeze([
169
177
  "PLURNK_PROVIDERS_REPEAT_PENALTY",
170
178
  "PLURNK_PROVIDERS_FREQUENCY_PENALTY",
171
179
  "PLURNK_PROVIDERS_SERVICE_TIER",
180
+ "PLURNK_PROVIDERS_PROMPT_CACHE_KEY",
172
181
  "PLURNK_PROVIDERS_REPEAT_LAST_N",
173
182
  "PLURNK_PROVIDERS_DRY_MULTIPLIER",
174
183
  "PLURNK_PROVIDERS_DRY_BASE",
175
184
  "PLURNK_PROVIDERS_DRY_ALLOWED_LENGTH",
176
- "PLURNK_PROVIDERS_RETRY_DELAY",
177
185
  "PLURNK_PROVIDERS_PROBE_ATTEMPTS",
178
186
  "PLURNK_PROVIDERS_PROBE_DELAY",
179
187
  "PLURNK_PROVIDERS_GBNF_DEBUG",
@@ -184,8 +192,8 @@ export const PROVIDERS_KNOBS = Object.freeze([
184
192
  // Materialize an alias-scoped VIEW of env: for each known knob with a
185
193
  // `_<alias>`-suffixed key (suffix case-folds to the alias, matching the
186
194
  // PLURNK_MODEL_/PLURNK_BASEURL_ convention), overlay it onto the bare name.
187
- // Providers keep reading plain vars — scoping is entirely the caller's overlay,
188
- // so fromEnv implementations (and plugins) need zero changes.
195
+ // Provider construction keeps reading plain vars; scoping is the registry's
196
+ // single overlay.
189
197
  //
190
198
  // `knobs` (optional) lets a CONSUMER scope its OWN closed knob list with this
191
199
  // same parser — e.g. the service's window-partition vars (PLURNK_SERVICE_CONTEXT_WINDOW/
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export type {
3
3
  FinishReason,
4
4
  Provider,
5
5
  ProviderAssistant,
6
- ProviderFactory,
6
+ AiSdkProviderPlugin,
7
7
  ProviderOptions,
8
8
  ProviderResponse,
9
9
  ProviderUsage,
@@ -23,28 +23,24 @@ export {
23
23
  resetDiscoveryCache,
24
24
  } from "./ProviderRegistry.ts";
25
25
 
26
- // Scope-agnostic tier-2 discovery (SPEC §5) — exported so a consumer can list
27
- // installed providers (first-party + third-party) without instantiating them.
26
+ // Scope-agnostic plugin discovery (SPEC §5).
28
27
  export { discover } from "./discover.ts";
29
28
  export type { DiscoverOptions, Discovery } from "./discover.ts";
30
29
 
31
- // Shared OpenAI-compatible transport machinery — the spine every sibling
32
- // extends and the basis for ./standardProviders.ts.
33
- export { default as OpenAICompatProvider, effortFromBudget } from "./OpenAICompat.ts";
34
- export type { OpenAICompatConfig, ReasoningStyle, GrammarStyle } from "./OpenAICompat.ts";
30
+ // Stable PLURNK adapter over AI SDK language models and compatible local URLs.
31
+ export { default as AiSdkProvider, effortFromBudget } from "./AiSdkProvider.ts";
32
+ export type { AiSdkProviderConfig, ReasoningStyle, GrammarStyle } from "./AiSdkProvider.ts";
35
33
  // Capacity pool (SPEC §15): front N interchangeable backends as one Provider -
36
34
  // worker-sticky for KV-cache reuse, overflow to a healthy sibling; the blend
37
35
  // DECISION stays the consumer's, by choosing which pool to call.
38
36
  export { default as Pool } from "./Pool.ts";
39
- export { chatCompletionStream, chatCompletion, OpenAiHttpError, StreamIdleError } from "./openaiStream.ts";
40
- export type { StreamResponse, EncryptedReasoningItem, ProviderFetch } from "./openaiStream.ts";
37
+ export type { ProviderFetch } from "./AiSdkProvider.ts";
41
38
  export { parseRequiredInt, parseOptionalInt, parseRequiredFloat, parseOptionalFloat, requireEnv, reasoningFromEnv, scopeEnvToAlias, dataCaptureFromEnv, contextWindowFromEnv, envelopeFromEnv, resolveReserve } from "./env.ts";
42
39
  export type { Reasoning, ReasoningMode, ReserveSpec } from "./env.ts";
43
- export { normalizeUsage, computeCost } from "./usage.ts";
40
+ export { normalizeUsage, calculateCostUsd } from "./usage.ts";
44
41
  export type { RawUsage, TokenRates } from "./usage.ts";
45
42
  export { ProviderError, classifyProviderError, toProviderError, providerSource } from "./telemetry.ts";
46
43
  export type { TelemetryEvent, ProviderTelemetryKind } from "./telemetry.ts";
47
- export { STANDARD_PROVIDERS, isStandardProvider, standardProviderFromEnv } from "./standardProviders.ts";
48
44
 
49
45
  export { default as Mock } from "./Mock.ts";
50
46
  export type { MockAssistant, MockResponse, MockReturnedAssistant } from "./Mock.ts";
package/src/ollama.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
2
+ import { contextWindowFromEnv, parseRequiredInt, requireEnv } from "./env.ts";
3
+ import { providerFromSdkModel } from "./catalogProvider.ts";
4
+ import type { Provider, ProviderOptions } from "./types.ts";
5
+
6
+ type ShowResponse = {
7
+ model_info?: Record<string, unknown>;
8
+ };
9
+
10
+ const fetchContextWindow = async ({
11
+ baseUrl,
12
+ model,
13
+ timeout,
14
+ }: {
15
+ baseUrl: string;
16
+ model: string;
17
+ timeout: number;
18
+ }): Promise<number> => {
19
+ const response = await fetch(`${baseUrl}/api/show`, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ model }),
23
+ signal: AbortSignal.timeout(timeout),
24
+ });
25
+ if (!response.ok) throw new Error(`ollama provider: /api/show returned ${response.status}`);
26
+ const data = await response.json() as ShowResponse;
27
+ for (const [key, value] of Object.entries(data.model_info ?? {})) {
28
+ if (key.endsWith(".context_length") && typeof value === "number" && value > 0) return value;
29
+ }
30
+ throw new Error(`ollama provider: /api/show has no *.context_length key for "${model}"`);
31
+ };
32
+
33
+ export const ollamaProviderFromEnv = async (
34
+ env: NodeJS.ProcessEnv,
35
+ model: string,
36
+ options?: ProviderOptions,
37
+ ): Promise<Provider> => {
38
+ const configured = options?.baseUrl ?? env.OLLAMA_BASE_URL ?? env.OLLAMA_HOST;
39
+ const baseUrl = requireEnv(
40
+ configured,
41
+ "OLLAMA_BASE_URL or OLLAMA_HOST (or a PLURNK_BASEURL_<alias> override)",
42
+ "ollama",
43
+ ).replace(/\/+$/, "").replace(/\/v1$/, "");
44
+ const timeout = parseRequiredInt(
45
+ env.PLURNK_PROVIDERS_FETCH_TIMEOUT,
46
+ "PLURNK_PROVIDERS_FETCH_TIMEOUT",
47
+ "ollama",
48
+ );
49
+ const contextWindow = contextWindowFromEnv(env, "ollama")
50
+ ?? await fetchContextWindow({ baseUrl, model, timeout });
51
+ const languageModel = createOpenAICompatible({
52
+ name: "ollama",
53
+ baseURL: `${baseUrl}/v1`,
54
+ includeUsage: true,
55
+ }).languageModel(model);
56
+ return providerFromSdkModel({
57
+ name: "ollama",
58
+ env,
59
+ model,
60
+ languageModel,
61
+ contextWindow,
62
+ });
63
+ };
package/src/openai.ts CHANGED
@@ -1,11 +1,5 @@
1
- export { default as OpenAICompatProvider, effortFromBudget } from "./OpenAICompat.ts";
2
- export type { GrammarStyle, OpenAICompatConfig, ReasoningStyle } from "./OpenAICompat.ts";
3
- export { chatCompletion, chatCompletionStream, OpenAiHttpError, StreamIdleError } from "./openaiStream.ts";
4
- export type {
5
- EncryptedReasoningItem,
6
- ProviderFetch,
7
- StreamResponse,
8
- } from "./openaiStream.ts";
1
+ export { default as AiSdkProvider, effortFromBudget } from "./AiSdkProvider.ts";
2
+ export type { AiSdkProviderConfig, GrammarStyle, ProviderFetch, ReasoningStyle } from "./AiSdkProvider.ts";
9
3
  export type {
10
4
  ChatMessage,
11
5
  FinishReason,
@@ -0,0 +1,47 @@
1
+ import test from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
4
+
5
+ test("configuredProviderInfo translates one env declaration into provider facts", () => {
6
+ assert.deepEqual(configuredProviderInfo("acme-cloud", {
7
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_NPM: "@ai-sdk/openai-compatible",
8
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_BASE_URL: "https://api.acme.test/v1",
9
+ PLURNK_PROVIDERS_PROVIDER_ACME_CLOUD_API_KEY_ENV: "ACME_API_KEY, ACME_TOKEN",
10
+ }), {
11
+ id: "acme-cloud",
12
+ npm: "@ai-sdk/openai-compatible",
13
+ env: ["ACME_API_KEY", "ACME_TOKEN"],
14
+ api: "https://api.acme.test/v1",
15
+ });
16
+ });
17
+
18
+ test("createSdkModel uses Models.dev provider facts and operator credentials", () => {
19
+ const sdk = createSdkModel("xai", "grok-build-0.1", { XAI_API_KEY: "test-key" });
20
+ assert.notEqual(sdk, null);
21
+ assert.equal(sdk?.catalog?.npm, "@ai-sdk/xai");
22
+ const languageModel = sdk?.languageModel as { provider: string; modelId: string };
23
+ assert.equal(languageModel.provider, "xai.responses");
24
+ assert.equal(languageModel.modelId, "grok-build-0.1");
25
+ });
26
+
27
+ test("createSdkModel expands catalog endpoint variables without treating them as credentials", () => {
28
+ const sdk = createSdkModel("cloudflare", "@cf/google/gemma-4-26b-a4b-it", {
29
+ CLOUDFLARE_ACCOUNT_ID: "account",
30
+ CLOUDFLARE_API_TOKEN: "token",
31
+ PLURNK_PROVIDERS_PROVIDER_CLOUDFLARE_API_KEY_ENV: "CLOUDFLARE_API_TOKEN,CLOUDFLARE_API_KEY",
32
+ });
33
+ assert.equal(sdk?.languageModel, undefined);
34
+ assert.deepEqual(sdk?.compatible, {
35
+ url: "https://api.cloudflare.com/client/v4/accounts/account/ai/v1/chat/completions",
36
+ headers: { Authorization: "Bearer token" },
37
+ });
38
+ });
39
+
40
+ test("createSdkModel fails clearly for a declared but unsupported SDK package", () => {
41
+ assert.throws(
42
+ () => createSdkModel("acme", "model", {
43
+ PLURNK_PROVIDERS_PROVIDER_ACME_NPM: "@acme/ai-sdk",
44
+ }),
45
+ /Models.dev declares unsupported AI SDK package @acme\/ai-sdk/,
46
+ );
47
+ });