@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.
- package/.env.defaults +35 -45
- package/README.md +44 -53
- package/SPEC.md +215 -371
- package/dist/AiSdkProvider.d.ts +78 -0
- package/dist/AiSdkProvider.d.ts.map +1 -0
- package/dist/AiSdkProvider.js +591 -0
- package/dist/AiSdkProvider.js.map +1 -0
- package/dist/OpenAICompat.d.ts +1 -2
- package/dist/OpenAICompat.d.ts.map +1 -1
- package/dist/OpenAICompat.js +39 -117
- package/dist/OpenAICompat.js.map +1 -1
- package/dist/ProviderRegistry.d.ts.map +1 -1
- package/dist/ProviderRegistry.js +37 -24
- package/dist/ProviderRegistry.js.map +1 -1
- package/dist/aiSdkTransport.d.ts +52 -0
- package/dist/aiSdkTransport.d.ts.map +1 -0
- package/dist/aiSdkTransport.js +294 -0
- package/dist/aiSdkTransport.js.map +1 -0
- package/dist/catalogProvider.d.ts +15 -0
- package/dist/catalogProvider.d.ts.map +1 -0
- package/dist/catalogProvider.js +103 -0
- package/dist/catalogProvider.js.map +1 -0
- package/dist/compatibleProvider.d.ts +3 -0
- package/dist/compatibleProvider.d.ts.map +1 -0
- package/dist/compatibleProvider.js +146 -0
- package/dist/compatibleProvider.js.map +1 -0
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js.map +1 -1
- package/dist/env.d.ts +1 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +13 -6
- package/dist/env.js.map +1 -1
- package/dist/index.d.ts +4 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -7
- package/dist/index.js.map +1 -1
- package/dist/ollama.d.ts +3 -0
- package/dist/ollama.d.ts.map +1 -0
- package/dist/ollama.js +39 -0
- package/dist/ollama.js.map +1 -0
- package/dist/openai.d.ts +2 -4
- package/dist/openai.d.ts.map +1 -1
- package/dist/openai.js +1 -2
- package/dist/openai.js.map +1 -1
- package/dist/sdkModels.d.ts +13 -0
- package/dist/sdkModels.d.ts.map +1 -0
- package/dist/sdkModels.js +153 -0
- package/dist/sdkModels.js.map +1 -0
- package/dist/standardProviders.d.ts.map +1 -1
- package/dist/standardProviders.js +0 -1
- package/dist/standardProviders.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +20 -9
- package/dist/telemetry.js.map +1 -1
- package/dist/types.d.ts +3 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -10
- package/src/{OpenAICompat.test.ts → AiSdkProvider.test.ts} +193 -152
- package/src/{OpenAICompat.ts → AiSdkProvider.ts} +83 -127
- package/src/Mock.test.ts +1 -1
- package/src/ProviderRegistry.test.ts +40 -27
- package/src/ProviderRegistry.ts +35 -24
- package/src/aiSdkTransport.test.ts +253 -0
- package/src/aiSdkTransport.ts +369 -0
- package/src/boundaries.test.ts +2 -2
- package/src/catalogProvider.test.ts +100 -0
- package/src/catalogProvider.ts +151 -0
- package/src/compatibleProvider.test.ts +44 -0
- package/src/compatibleProvider.ts +205 -0
- package/src/discover.test.ts +12 -12
- package/src/discover.ts +3 -6
- package/src/env.ts +14 -6
- package/src/index.ts +6 -10
- package/src/ollama.ts +63 -0
- package/src/openai.ts +2 -8
- package/src/sdkModels.test.ts +47 -0
- package/src/sdkModels.ts +194 -0
- package/src/telemetry.test.ts +17 -10
- package/src/telemetry.ts +22 -14
- package/src/types.ts +5 -8
- package/src/aiSdkAdapter.spike.test.ts +0 -242
- package/src/openaiStream.ts +0 -310
- package/src/standardProviders.test.ts +0 -939
- 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
|
+
};
|
package/src/discover.test.ts
CHANGED
|
@@ -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-
|
|
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", "
|
|
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-
|
|
53
|
-
"@acme/rival-
|
|
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: '
|
|
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-
|
|
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-
|
|
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()], ["
|
|
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-
|
|
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", "
|
|
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()], ["
|
|
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", "
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
2
|
-
//
|
|
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
|
-
//
|
|
188
|
-
//
|
|
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/
|