@plurnk/plurnk-providers 1.3.5 → 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 (84) hide show
  1. package/.env.defaults +35 -45
  2. package/README.md +44 -53
  3. package/SPEC.md +215 -371
  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/OpenAICompat.d.ts +1 -2
  9. package/dist/OpenAICompat.d.ts.map +1 -1
  10. package/dist/OpenAICompat.js +39 -117
  11. package/dist/OpenAICompat.js.map +1 -1
  12. package/dist/ProviderRegistry.d.ts.map +1 -1
  13. package/dist/ProviderRegistry.js +37 -24
  14. package/dist/ProviderRegistry.js.map +1 -1
  15. package/dist/aiSdkTransport.d.ts +52 -0
  16. package/dist/aiSdkTransport.d.ts.map +1 -0
  17. package/dist/aiSdkTransport.js +294 -0
  18. package/dist/aiSdkTransport.js.map +1 -0
  19. package/dist/catalogProvider.d.ts +15 -0
  20. package/dist/catalogProvider.d.ts.map +1 -0
  21. package/dist/catalogProvider.js +103 -0
  22. package/dist/catalogProvider.js.map +1 -0
  23. package/dist/compatibleProvider.d.ts +3 -0
  24. package/dist/compatibleProvider.d.ts.map +1 -0
  25. package/dist/compatibleProvider.js +146 -0
  26. package/dist/compatibleProvider.js.map +1 -0
  27. package/dist/discover.d.ts.map +1 -1
  28. package/dist/discover.js.map +1 -1
  29. package/dist/env.d.ts +1 -0
  30. package/dist/env.d.ts.map +1 -1
  31. package/dist/env.js +13 -6
  32. package/dist/env.js.map +1 -1
  33. package/dist/index.d.ts +4 -6
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -7
  36. package/dist/index.js.map +1 -1
  37. package/dist/ollama.d.ts +3 -0
  38. package/dist/ollama.d.ts.map +1 -0
  39. package/dist/ollama.js +39 -0
  40. package/dist/ollama.js.map +1 -0
  41. package/dist/openai.d.ts +2 -4
  42. package/dist/openai.d.ts.map +1 -1
  43. package/dist/openai.js +1 -2
  44. package/dist/openai.js.map +1 -1
  45. package/dist/sdkModels.d.ts +13 -0
  46. package/dist/sdkModels.d.ts.map +1 -0
  47. package/dist/sdkModels.js +153 -0
  48. package/dist/sdkModels.js.map +1 -0
  49. package/dist/standardProviders.d.ts.map +1 -1
  50. package/dist/standardProviders.js +0 -1
  51. package/dist/standardProviders.js.map +1 -1
  52. package/dist/telemetry.d.ts.map +1 -1
  53. package/dist/telemetry.js +20 -9
  54. package/dist/telemetry.js.map +1 -1
  55. package/dist/types.d.ts +3 -2
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +18 -10
  58. package/src/{OpenAICompat.test.ts → AiSdkProvider.test.ts} +193 -152
  59. package/src/{OpenAICompat.ts → AiSdkProvider.ts} +83 -127
  60. package/src/Mock.test.ts +1 -1
  61. package/src/ProviderRegistry.test.ts +40 -27
  62. package/src/ProviderRegistry.ts +35 -24
  63. package/src/aiSdkTransport.test.ts +253 -0
  64. package/src/aiSdkTransport.ts +369 -0
  65. package/src/boundaries.test.ts +2 -2
  66. package/src/catalogProvider.test.ts +100 -0
  67. package/src/catalogProvider.ts +151 -0
  68. package/src/compatibleProvider.test.ts +44 -0
  69. package/src/compatibleProvider.ts +205 -0
  70. package/src/discover.test.ts +12 -12
  71. package/src/discover.ts +3 -6
  72. package/src/env.ts +14 -6
  73. package/src/index.ts +6 -10
  74. package/src/ollama.ts +63 -0
  75. package/src/openai.ts +2 -8
  76. package/src/sdkModels.test.ts +47 -0
  77. package/src/sdkModels.ts +194 -0
  78. package/src/telemetry.test.ts +17 -10
  79. package/src/telemetry.ts +22 -14
  80. package/src/types.ts +5 -8
  81. package/src/aiSdkAdapter.spike.test.ts +0 -242
  82. package/src/openaiStream.ts +0 -310
  83. package/src/standardProviders.test.ts +0 -939
  84. package/src/standardProviders.ts +0 -631
@@ -0,0 +1,100 @@
1
+ import test, { mock } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { catalogProviderFromEnv } from "./catalogProvider.ts";
4
+ import { resetEmittedWarnings } from "./warnings.ts";
5
+
6
+ const env = {
7
+ OPENAI_API_KEY: "test-key",
8
+ OPENAI_BASE_URL: "https://api.openai.com/v1",
9
+ PLURNK_PROVIDERS_FETCH_TIMEOUT: "1000",
10
+ PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT: "0",
11
+ PLURNK_PROVIDERS_REASONING: "off",
12
+ PLURNK_PROVIDERS_TEMPERATURE: "0.2",
13
+ PLURNK_PROVIDERS_REPEAT_PENALTY: "1.15",
14
+ PLURNK_PROVIDERS_FREQUENCY_PENALTY: "0",
15
+ PLURNK_PROVIDERS_REASONING_RESERVE: "10%",
16
+ PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
17
+ PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0",
18
+ PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "1",
19
+ };
20
+
21
+ test.afterEach(() => {
22
+ mock.restoreAll();
23
+ resetEmittedWarnings();
24
+ });
25
+
26
+ test("catalog provider resolves model physics and Models.dev USD rates", () => {
27
+ const provider = catalogProviderFromEnv("openai", env, "gpt-4.1-mini");
28
+ assert.notEqual(provider, null);
29
+ assert.equal(provider?.model, "gpt-4.1-mini");
30
+ assert.equal(provider?.contextWindow, 1_047_576);
31
+ assert.equal(provider?.reasoningReserve, 16_384);
32
+ assert.equal(provider?.completionReserve, 32_768);
33
+ assert.ok((provider?.calculateCost({
34
+ prompt: 1_000_000,
35
+ completion: 1_000_000,
36
+ reasoning: 0,
37
+ cached: 0,
38
+ total: 2_000_000,
39
+ }) ?? 0) > 0);
40
+ });
41
+
42
+ test("official AI SDK provider owns the native request while PLURNK owns call settings", async () => {
43
+ const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
44
+ mock.method(globalThis, "fetch", async (input: string | URL | Request, init?: RequestInit) => {
45
+ calls.push({
46
+ url: String(input),
47
+ body: JSON.parse(String(init?.body)) as Record<string, unknown>,
48
+ });
49
+ const chunks = [
50
+ `data: ${JSON.stringify({
51
+ id: "chatcmpl-test",
52
+ object: "chat.completion.chunk",
53
+ created: 1,
54
+ model: "gpt-4.1-mini",
55
+ choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }],
56
+ })}`,
57
+ `data: ${JSON.stringify({
58
+ id: "chatcmpl-test",
59
+ object: "chat.completion.chunk",
60
+ created: 2,
61
+ model: "gpt-4.1-mini",
62
+ choices: [],
63
+ usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 },
64
+ })}`,
65
+ "data: [DONE]",
66
+ ].join("\n\n");
67
+ return new Response(chunks, {
68
+ status: 200,
69
+ headers: { "content-type": "text/event-stream" },
70
+ });
71
+ });
72
+
73
+ const provider = catalogProviderFromEnv("openai", env, "gpt-4.1-mini");
74
+ const result = await provider?.generate({
75
+ workerId: "worker",
76
+ messages: [{ role: "user", content: "hello" }],
77
+ maxTokens: 64,
78
+ sampling: { top_p: 0.8, seed: 7 },
79
+ });
80
+
81
+ assert.equal(result?.assistant.content, "done");
82
+ assert.equal(result?.assistant.usage.total, 3);
83
+ assert.equal(calls.length, 1);
84
+ assert.equal(calls[0]?.url, "https://api.openai.com/v1/chat/completions");
85
+ assert.equal(calls[0]?.body.model, "gpt-4.1-mini");
86
+ assert.equal(calls[0]?.body.temperature, 0.2);
87
+ assert.equal(calls[0]?.body.top_p, 0.8);
88
+ assert.equal(calls[0]?.body.seed, 7);
89
+ assert.equal(calls[0]?.body.max_tokens, 64);
90
+ });
91
+
92
+ test("cataloged unknown model fails unless its context is explicit", () => {
93
+ assert.equal(catalogProviderFromEnv("xai", env, "not-in-the-catalog"), null);
94
+ const provider = catalogProviderFromEnv("xai", {
95
+ ...env,
96
+ XAI_API_KEY: "test-key",
97
+ PLURNK_PROVIDERS_CONTEXT_WINDOW: "8192",
98
+ }, "not-in-the-catalog");
99
+ assert.equal(provider?.contextWindow, 8192);
100
+ });
@@ -0,0 +1,151 @@
1
+ import { lookupProvider, resolveModel, type ModelInfo } from "@plurnk/plurnk-models";
2
+ import {
3
+ contextWindowFromEnv,
4
+ dataCaptureFromEnv,
5
+ envelopeFromEnv,
6
+ parseRequiredFloat,
7
+ parseRequiredInt,
8
+ promptCacheKeyFromEnv,
9
+ reasoningFromEnv,
10
+ resolveReserve,
11
+ type ReserveSpec,
12
+ } from "./env.ts";
13
+ import AiSdkProvider, { type ReasoningStyle } from "./AiSdkProvider.ts";
14
+ import { configuredProviderInfo, createSdkModel } from "./sdkModels.ts";
15
+ import { providerSource } from "./telemetry.ts";
16
+ import type { Provider, ProviderUsage } from "./types.ts";
17
+ import { calculateCostUsd } from "./usage.ts";
18
+ import { emitWarningOnce } from "./warnings.ts";
19
+ import type { LanguageModel } from "ai";
20
+
21
+ const reasoningStyleFromEnv = (
22
+ env: NodeJS.ProcessEnv,
23
+ name: string,
24
+ ): ReasoningStyle | undefined => {
25
+ const prefix = name.replaceAll(/[^a-zA-Z0-9]/g, "_").toUpperCase();
26
+ const value = env[`PLURNK_PROVIDERS_PROVIDER_${prefix}_REASONING_STYLE`];
27
+ if (value === undefined || value.length === 0) return undefined;
28
+ const styles: readonly ReasoningStyle[] = [
29
+ "none", "think", "include_reasoning", "effort",
30
+ "effort_explicit", "template", "anthropic",
31
+ ];
32
+ if (!styles.includes(value as ReasoningStyle)) {
33
+ throw new Error(`${name} provider: PLURNK_PROVIDERS_PROVIDER_${prefix}_REASONING_STYLE has invalid value "${value}"`);
34
+ }
35
+ return value as ReasoningStyle;
36
+ };
37
+
38
+ export const providerFromSdkModel = ({
39
+ name,
40
+ env,
41
+ model,
42
+ languageModel,
43
+ url,
44
+ headers,
45
+ contextWindow,
46
+ info,
47
+ }: {
48
+ name: string;
49
+ env: NodeJS.ProcessEnv;
50
+ model: string;
51
+ languageModel?: LanguageModel;
52
+ url?: string;
53
+ headers?: Readonly<Record<string, string>>;
54
+ contextWindow: number;
55
+ info?: ModelInfo;
56
+ }): Provider => {
57
+ emitWarningOnce(
58
+ `${name} provider: countTokens is a chars/2 upper bound — exact counts come from the mimetypes tokenizer seam or tokenize()`,
59
+ "PLURNK_TOKENIZER_HEURISTIC",
60
+ );
61
+
62
+ const reasoning = reasoningFromEnv(env, name);
63
+ const { reasoningReserve: configuredReasoning, completionReserve: configuredCompletion } = envelopeFromEnv(env, name);
64
+ const completionReserve: ReserveSpec = "tokens" in configuredCompletion
65
+ ? configuredCompletion
66
+ : info?.maxOutput === undefined
67
+ ? configuredCompletion
68
+ : { tokens: Math.min(info.maxOutput, Math.round(configuredCompletion.percent * contextWindow)) };
69
+ const completionTokens = resolveReserve(completionReserve, contextWindow);
70
+ const reasoningReserve: ReserveSpec = "tokens" in configuredReasoning
71
+ ? configuredReasoning
72
+ : reasoning.budget !== null
73
+ ? { tokens: reasoning.budget }
74
+ : completionTokens === null
75
+ ? configuredReasoning
76
+ : { tokens: Math.round(completionTokens / 2) };
77
+
78
+ const cost = info?.cost;
79
+ const calculateCost = cost === undefined
80
+ ? undefined
81
+ : (usage: ProviderUsage): number => calculateCostUsd(usage, {
82
+ input: cost.inputPer1M,
83
+ output: cost.outputPer1M,
84
+ cached: cost.cacheReadPer1M ?? cost.inputPer1M,
85
+ });
86
+
87
+ return new AiSdkProvider({
88
+ model,
89
+ ...(languageModel === undefined ? {} : { languageModel }),
90
+ ...(url === undefined ? {} : { url }),
91
+ ...(headers === undefined ? {} : { headers: { ...headers } }),
92
+ contextWindow,
93
+ fetchTimeoutMs: parseRequiredInt(env.PLURNK_PROVIDERS_FETCH_TIMEOUT, "PLURNK_PROVIDERS_FETCH_TIMEOUT", name),
94
+ streamIdleTimeoutMs: parseRequiredInt(env.PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT, "PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT", name),
95
+ reasoning,
96
+ temperature: parseRequiredFloat(env.PLURNK_PROVIDERS_TEMPERATURE, "PLURNK_PROVIDERS_TEMPERATURE", name, 0),
97
+ repeatPenalty: parseRequiredFloat(env.PLURNK_PROVIDERS_REPEAT_PENALTY, "PLURNK_PROVIDERS_REPEAT_PENALTY", name, 0),
98
+ frequencyPenalty: parseRequiredFloat(env.PLURNK_PROVIDERS_FREQUENCY_PENALTY, "PLURNK_PROVIDERS_FREQUENCY_PENALTY", name, 0),
99
+ reasoningReserve,
100
+ completionReserve,
101
+ retryAttempts: parseRequiredInt(env.PLURNK_PROVIDERS_RETRY_ATTEMPTS, "PLURNK_PROVIDERS_RETRY_ATTEMPTS", name),
102
+ reasoningStyle: reasoningStyleFromEnv(env, name),
103
+ promptCacheKey: url === undefined ? false : promptCacheKeyFromEnv(env, name),
104
+ serviceTier: env.PLURNK_PROVIDERS_SERVICE_TIER,
105
+ calculateCost,
106
+ source: providerSource(name),
107
+ gbnfDebug: env.PLURNK_PROVIDERS_GBNF_DEBUG !== undefined
108
+ && env.PLURNK_PROVIDERS_GBNF_DEBUG !== ""
109
+ && env.PLURNK_PROVIDERS_GBNF_DEBUG !== "0",
110
+ ...dataCaptureFromEnv(env, name),
111
+ });
112
+ };
113
+
114
+ export const catalogProviderFromEnv = (
115
+ name: string,
116
+ env: NodeJS.ProcessEnv,
117
+ model: string,
118
+ baseUrlOverride?: string,
119
+ ): Provider | null => {
120
+ const resolved = resolveModel(name, model);
121
+ const contextOverride = contextWindowFromEnv(env, name);
122
+ if (lookupProvider(name) === null && configuredProviderInfo(name, env) === null) return null;
123
+ if (name === "openai" && resolved === null) return null;
124
+ if (resolved === null && contextOverride === null) return null;
125
+ const wireModel = resolved?.id ?? model;
126
+ const sdk = createSdkModel(name, wireModel, env, baseUrlOverride);
127
+ if (sdk === null) return null;
128
+
129
+ emitWarningOnce(
130
+ `${name} provider: countTokens is a chars/2 upper bound — exact counts come from the mimetypes tokenizer seam or tokenize()`,
131
+ "PLURNK_TOKENIZER_HEURISTIC",
132
+ );
133
+
134
+ const info = resolved?.info;
135
+ const contextWindow = contextOverride ?? info?.contextWindow ?? null;
136
+ if (contextWindow === null) {
137
+ throw new Error(
138
+ `${name} provider: context window unresolved for "${wireModel}" — set PLURNK_PROVIDERS_CONTEXT_WINDOW or update the Models.dev snapshot`,
139
+ );
140
+ }
141
+ return providerFromSdkModel({
142
+ name,
143
+ env,
144
+ model: wireModel,
145
+ languageModel: sdk.languageModel,
146
+ url: sdk.compatible?.url,
147
+ headers: sdk.compatible?.headers,
148
+ contextWindow,
149
+ info,
150
+ });
151
+ };
@@ -0,0 +1,44 @@
1
+ import test, { mock } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { compatibleProviderFromEnv } from "./compatibleProvider.ts";
4
+
5
+ const env = {
6
+ OPENAI_BASE_URL: "http://local.test/v1",
7
+ PLURNK_PROVIDERS_FETCH_TIMEOUT: "1000",
8
+ PLURNK_PROVIDERS_STREAM_IDLE_TIMEOUT: "0",
9
+ PLURNK_PROVIDERS_REASONING: "off",
10
+ PLURNK_PROVIDERS_TEMPERATURE: "0.2",
11
+ PLURNK_PROVIDERS_REPEAT_PENALTY: "1.15",
12
+ PLURNK_PROVIDERS_FREQUENCY_PENALTY: "0",
13
+ PLURNK_PROVIDERS_REASONING_RESERVE: "10%",
14
+ PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
15
+ PLURNK_PROVIDERS_RETRY_ATTEMPTS: "0",
16
+ PLURNK_PROVIDERS_PROBE_ATTEMPTS: "1",
17
+ PLURNK_PROVIDERS_PROBE_DELAY: "0",
18
+ PLURNK_PROVIDERS_PROMPT_CACHE_KEY: "1",
19
+ };
20
+
21
+ test.afterEach(() => mock.restoreAll());
22
+
23
+ test("compatible endpoints preserve configured prompt-cache affinity", async () => {
24
+ let body: Record<string, unknown> | undefined;
25
+ mock.method(globalThis, "fetch", async (input: string | URL | Request, init?: RequestInit) => {
26
+ if (String(input).endsWith("/models")) {
27
+ return new Response(JSON.stringify({ data: [{ id: "local", n_ctx: 8192 }] }));
28
+ }
29
+ body = JSON.parse(String(init?.body)) as Record<string, unknown>;
30
+ return new Response(JSON.stringify({
31
+ model: "local",
32
+ choices: [{ message: { content: "ok" }, finish_reason: "stop" }],
33
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
34
+ }), { headers: { "content-type": "application/json" } });
35
+ });
36
+
37
+ const provider = await compatibleProviderFromEnv("openai", env, "local");
38
+ await provider.generate({
39
+ workerId: "worker-affinity",
40
+ messages: [{ role: "user", content: "hello" }],
41
+ });
42
+
43
+ assert.equal(body?.prompt_cache_key, "worker-affinity");
44
+ });
@@ -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/