@plurnk/plurnk-providers 1.1.1 → 1.3.0

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 (50) hide show
  1. package/.env.defaults +10 -0
  2. package/README.md +50 -60
  3. package/SPEC.md +7 -6
  4. package/dist/OpenAICompat.d.ts +4 -0
  5. package/dist/OpenAICompat.d.ts.map +1 -1
  6. package/dist/OpenAICompat.js +22 -3
  7. package/dist/OpenAICompat.js.map +1 -1
  8. package/dist/ProviderRegistry.js +2 -2
  9. package/dist/ProviderRegistry.js.map +1 -1
  10. package/dist/env.d.ts +1 -0
  11. package/dist/env.d.ts.map +1 -1
  12. package/dist/env.js +15 -3
  13. package/dist/env.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/openaiStream.d.ts.map +1 -1
  19. package/dist/openaiStream.js +12 -0
  20. package/dist/openaiStream.js.map +1 -1
  21. package/dist/standardProviders.d.ts +1 -0
  22. package/dist/standardProviders.d.ts.map +1 -1
  23. package/dist/standardProviders.js +47 -7
  24. package/dist/standardProviders.js.map +1 -1
  25. package/package.json +8 -6
  26. package/src/Mock.test.ts +142 -0
  27. package/src/Mock.ts +95 -0
  28. package/src/OpenAICompat.test.ts +1107 -0
  29. package/src/OpenAICompat.ts +756 -0
  30. package/src/Pool.test.ts +155 -0
  31. package/src/Pool.ts +134 -0
  32. package/src/ProviderRegistry.test.ts +176 -0
  33. package/src/ProviderRegistry.ts +93 -0
  34. package/src/boundaries.test.ts +24 -0
  35. package/src/discover.test.ts +123 -0
  36. package/src/discover.ts +112 -0
  37. package/src/env.test.ts +190 -0
  38. package/src/env.ts +211 -0
  39. package/src/index.ts +51 -0
  40. package/src/lexicon-guard.test.ts +58 -0
  41. package/src/openaiStream.ts +279 -0
  42. package/src/standardProviders.test.ts +925 -0
  43. package/src/standardProviders.ts +618 -0
  44. package/src/telemetry.test.ts +62 -0
  45. package/src/telemetry.ts +108 -0
  46. package/src/types.ts +219 -0
  47. package/src/usage.test.ts +136 -0
  48. package/src/usage.ts +82 -0
  49. package/src/warnings.test.ts +31 -0
  50. package/src/warnings.ts +0 -0
@@ -0,0 +1,123 @@
1
+ import test, { type TestContext } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { discover } from "./discover.ts";
7
+
8
+ // #551: create a temp dir AND register its removal on the test context, so it's
9
+ // cleaned on a GREEN or RED run. A trailing rm after the assertions leaks the dir
10
+ // whenever one throws — thousands accumulate on a shared box at drill frequency.
11
+ const tempDir = async (t: TestContext, prefix: string): Promise<string> => {
12
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
13
+ t.after(() => fs.rm(dir, { recursive: true, force: true }));
14
+ return dir;
15
+ };
16
+
17
+ const buildModules = async (t: TestContext, packages: Record<string, unknown>): Promise<string> => {
18
+ const root = await tempDir(t, "providers-scan-");
19
+ for (const [rel, pkg] of Object.entries(packages)) {
20
+ const dir = path.join(root, "node_modules", rel);
21
+ await fs.mkdir(dir, { recursive: true });
22
+ await fs.writeFile(path.join(dir, "package.json"), JSON.stringify(pkg), "utf-8");
23
+ }
24
+ return root;
25
+ };
26
+
27
+ test("discover: the node_modules scan is scope-agnostic — third-party scopes are found", async (t) => {
28
+ const root = await buildModules(t, {
29
+ "@plurnk/plurnk-providers-openrouter": { name: "@plurnk/plurnk-providers-openrouter", plurnk: { kind: "provider", name: "openrouter" } },
30
+ "@acme/acme-provider-foo": { name: "@acme/acme-provider-foo", plurnk: { kind: "provider", name: "foo" } },
31
+ "unscoped-provider-bar": { name: "unscoped-provider-bar", plurnk: { kind: "provider", name: "bar" } },
32
+ "left-pad": { name: "left-pad" }, // no plurnk block → ignored
33
+ "@plurnk/plurnk-execs-git": { name: "@plurnk/plurnk-execs-git", plurnk: { kind: "exec", runtimes: [] } }, // wrong kind → ignored
34
+ });
35
+ const { registry } = await discover({ cwd: root });
36
+ assert.deepEqual([...registry.keys()].sort(), ["bar", "foo", "openrouter"]);
37
+ assert.equal(registry.get("foo"), "@acme/acme-provider-foo"); // third-party scope resolves
38
+ assert.equal(registry.get("bar"), "unscoped-provider-bar"); // unscoped resolves
39
+ });
40
+
41
+ test("discover: a provider package missing plurnk.name is ignored, not crashed", async (t) => {
42
+ const root = await buildModules(t, {
43
+ "@acme/headless": { name: "@acme/headless", plurnk: { kind: "provider" } }, // no name
44
+ "@acme/named": { name: "@acme/named", plurnk: { kind: "provider", name: "named" } },
45
+ });
46
+ const { registry } = await discover({ cwd: root });
47
+ assert.deepEqual([...registry.keys()], ["named"]);
48
+ });
49
+
50
+ test("discover: a name claimed by two packages is a fail-hard collision", async (t) => {
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" } },
54
+ });
55
+ await assert.rejects(
56
+ () => discover({ cwd: root }),
57
+ /provider name collision: 'xai' claimed by both/,
58
+ );
59
+ });
60
+
61
+ test("discover: node_modules entries with no package.json or malformed JSON are skipped, not crashed", async (t) => {
62
+ const root = await tempDir(t, "providers-junk-");
63
+ const nm = path.join(root, "node_modules");
64
+ await fs.mkdir(path.join(nm, "no-manifest"), { recursive: true }); // a dir, no package.json
65
+ await fs.mkdir(path.join(nm, "broken"), { recursive: true });
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");
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");
70
+ const { registry } = await discover({ cwd: root });
71
+ assert.deepEqual([...registry.keys()], ["ollama"]); // only the well-formed provider survives
72
+ });
73
+
74
+ test("discover: surfaces plurnk.attribution (string or string[]) for registered providers (#21)", async (t) => {
75
+ const root = await buildModules(t, {
76
+ "@acme/provider-solo": { name: "@acme/provider-solo", plurnk: { kind: "provider", name: "solo", attribution: "@acme/solo" } },
77
+ "@acme/provider-multi": { name: "@acme/provider-multi", plurnk: { kind: "provider", name: "multi", attribution: ["@acme/a", "@acme/b"] } },
78
+ "@acme/provider-none": { name: "@acme/provider-none", plurnk: { kind: "provider", name: "none" } },
79
+ "@acme/provider-bad": { name: "@acme/provider-bad", plurnk: { kind: "provider", name: "bad", attribution: 42 } }, // non-string/array
80
+ });
81
+ const { attributions } = await discover({ cwd: root });
82
+ assert.equal(attributions.get("solo"), "@acme/solo");
83
+ assert.deepEqual(attributions.get("multi"), ["@acme/a", "@acme/b"]);
84
+ assert.equal(attributions.has("none"), false); // declared none → absent
85
+ assert.equal(attributions.has("bad"), false); // non-string/array ignored, not surfaced
86
+ });
87
+
88
+ test("discover: missing node_modules yields an empty registry, not an error", async (t) => {
89
+ const root = await tempDir(t, "providers-empty-");
90
+ const { registry } = await discover({ cwd: root });
91
+ assert.equal(registry.size, 0);
92
+ });
93
+
94
+ // — trust gate (PLURNK_PLUGINS_TRUSTED_ONLY, #15) —
95
+
96
+ const trustFixture = (t: TestContext) => buildModules(t, {
97
+ "@plurnk/plurnk-providers-openrouter": { name: "@plurnk/plurnk-providers-openrouter", plurnk: { kind: "provider", name: "openrouter" } },
98
+ "@acme/acme-provider-foo": { name: "@acme/acme-provider-foo", plurnk: { kind: "provider", name: "foo" } },
99
+ });
100
+
101
+ test("trust gate OFF (unset/empty/0): every provider is trusted", async (t) => {
102
+ const root = await trustFixture(t);
103
+ for (const gate of [undefined, "", "0"]) {
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"]);
106
+ assert.equal(skipped.size, 0);
107
+ }
108
+ });
109
+
110
+ test("trust gate ON: @plurnk/* always trusted; third party declined → skipped, not registered", async (t) => {
111
+ const root = await trustFixture(t);
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
114
+ assert.equal(registry.has("foo"), false); // third party not registered
115
+ assert.equal(skipped.get("foo"), "@acme/acme-provider-foo"); // …recorded for a precise error
116
+ });
117
+
118
+ test("trust gate ON with an allowlist: a named third-party package is trusted", async (t) => {
119
+ const root = await trustFixture(t);
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"]);
122
+ assert.equal(skipped.size, 0);
123
+ });
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import Meta from "@plurnk/plurnk-meta";
4
+
5
+ // Scope-agnostic discovery of installed provider packages (SPEC §5, #12/#14).
6
+ // Parallel to @plurnk/plurnk-execs' discover(): scan every installed package
7
+ // under `<cwd>/node_modules` — scoped (`@scope/name`) and unscoped — and keep
8
+ // the ones declaring `plurnk.kind === "provider"`. Scope-agnostic so a THIRD
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.
12
+ //
13
+ // A provider package maps ONE name → its package specifier (unlike execs, whose
14
+ // runtime tags are a per-package array). The name is the alias-cascade provider
15
+ // segment (PLURNK_MODEL_<alias>=<name>/<model>). A name collision between two
16
+ // installed packages is a FAIL-HARD ambiguity the operator must resolve.
17
+ //
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.
21
+ //
22
+ // Host plugin trust gate (PLURNK_PLUGINS_TRUSTED_ONLY, #15 / plurnk-service#229)
23
+ // — enforced uniformly across the four scope-agnostic families. An untrusted
24
+ // package is discovered-but-declined (recorded in `skipped`, never registered,
25
+ // never thrown), so the consumer can name it in a precise error.
26
+
27
+ export type DiscoverOptions = {
28
+ cwd?: string; // defaults to process.cwd()
29
+ packageDirs?: string[]; // explicit dirs skip the node_modules scan (tests)
30
+ env?: NodeJS.ProcessEnv; // trust-gate env; defaults to process.env
31
+ };
32
+
33
+ // name → package specifier, split by the trust decision.
34
+ export type Discovery = {
35
+ registry: Map<string, string>; // trusted providers, eligible to instantiate
36
+ skipped: Map<string, string>; // declined by the trust gate (untrusted)
37
+ // name → raw `plurnk.attribution` (string | string[]) declared by a registered
38
+ // provider package's manifest, for crediting the provider's author (#21,
39
+ // plurnk-service#249). Surfaced verbatim — the consumer applies the reservation
40
+ // policy (`@plurnk/` tags only from `@plurnk/`-scoped packages). Absent for
41
+ // providers that declare none.
42
+ attributions: Map<string, string | string[]>;
43
+ };
44
+
45
+
46
+ export const discover = async (options: DiscoverOptions = {}): Promise<Discovery> => {
47
+ const dirs = options.packageDirs ?? await defaultPackageDirs(options.cwd ?? process.cwd());
48
+ const env = options.env ?? process.env;
49
+
50
+ const registry = new Map<string, string>();
51
+ const skipped = new Map<string, string>();
52
+ const attributions = new Map<string, string | string[]>();
53
+ for (const dir of dirs) {
54
+ const info = await readProviderInfo(dir);
55
+ if (info === null) continue;
56
+ if (!Meta.isTrusted(info.packageName, env)) {
57
+ skipped.set(info.name, info.packageName); // declined — not a collision candidate
58
+ continue;
59
+ }
60
+ const existing = registry.get(info.name);
61
+ if (existing !== undefined) {
62
+ throw new Error(
63
+ `provider name collision: '${info.name}' claimed by both `
64
+ + `${existing} and ${info.packageName}`,
65
+ );
66
+ }
67
+ registry.set(info.name, info.packageName);
68
+ if (info.attribution !== undefined) attributions.set(info.name, info.attribution);
69
+ }
70
+ return { registry, skipped, attributions };
71
+ };
72
+
73
+ // Enumerate every installed package directory — scoped and unscoped — under
74
+ // `<cwd>/node_modules`. Unreadable node_modules (e.g. nothing installed) → [].
75
+ const defaultPackageDirs = async (cwd: string): Promise<string[]> => {
76
+ const nm = Meta.nearestNodeModules(cwd) ?? path.join(path.resolve(cwd), "node_modules");
77
+ return (await Meta.packageDirs(nm)).map((c) => c.dir).toSorted();
78
+ };
79
+
80
+ // One { name, packageName, attribution? } for a provider package, or null for
81
+ // anything that isn't one (missing/invalid package.json, no plurnk block, wrong
82
+ // kind, no name). `attribution` mirrors `plurnk.attribution` when it's a string
83
+ // or string[] (anything else is ignored).
84
+ type ProviderInfo = { name: string; packageName: string; attribution?: string | string[] };
85
+
86
+ const readProviderInfo = async (dir: string): Promise<ProviderInfo | null> => {
87
+ let raw: string;
88
+ try {
89
+ raw = await fs.readFile(path.join(dir, "package.json"), "utf-8");
90
+ } catch {
91
+ return null;
92
+ }
93
+ let pkg: unknown;
94
+ try {
95
+ pkg = JSON.parse(raw);
96
+ } catch {
97
+ return null;
98
+ }
99
+ if (typeof pkg !== "object" || pkg === null) return null;
100
+ const record = pkg as Record<string, unknown>;
101
+ const plurnk = record.plurnk;
102
+ if (typeof plurnk !== "object" || plurnk === null) return null;
103
+ const plurnkRec = plurnk as Record<string, unknown>;
104
+ if (plurnkRec.kind !== "provider") return null;
105
+ if (typeof plurnkRec.name !== "string" || plurnkRec.name === "") return null;
106
+ if (typeof record.name !== "string" || record.name === "") return null;
107
+ const attr = plurnkRec.attribution;
108
+ const attribution = typeof attr === "string" ? attr
109
+ : Array.isArray(attr) && attr.every((x) => typeof x === "string") ? (attr as string[])
110
+ : undefined;
111
+ return { name: plurnkRec.name, packageName: record.name, attribution };
112
+ };
@@ -0,0 +1,190 @@
1
+ import test from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { parseRequiredInt, parseOptionalInt, requireEnv, reasoningFromEnv } from "./env.ts";
4
+
5
+ test("parseRequiredInt: parses a non-negative integer", () => {
6
+ assert.equal(parseRequiredInt("600000", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), 600000);
7
+ assert.equal(parseRequiredInt("0", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), 0);
8
+ });
9
+
10
+ test("parseRequiredInt: missing value names the env var and provider", () => {
11
+ assert.throws(() => parseRequiredInt(undefined, "PLURNK_PROVIDERS_FETCH_TIMEOUT", "groq"), /groq provider: PLURNK_PROVIDERS_FETCH_TIMEOUT must be set/);
12
+ assert.throws(() => parseRequiredInt("", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "groq"), /must be set/);
13
+ });
14
+
15
+ test("parseRequiredInt: rejects non-numeric, fractional, and negative values", () => {
16
+ assert.throws(() => parseRequiredInt("abc", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), /must be a non-negative integer \(got "abc"\)/);
17
+ assert.throws(() => parseRequiredInt("1.5", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), /must be a non-negative integer \(got "1\.5"\)/);
18
+ assert.throws(() => parseRequiredInt("-1", "PLURNK_PROVIDERS_FETCH_TIMEOUT", "openai"), /must be a non-negative integer \(got "-1"\)/);
19
+ });
20
+
21
+ test("parseOptionalInt: absent → null, present → integer", () => {
22
+ assert.equal(parseOptionalInt(undefined, "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), null);
23
+ assert.equal(parseOptionalInt("", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), null);
24
+ assert.equal(parseOptionalInt("131072", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), 131072);
25
+ });
26
+
27
+ test("parseOptionalInt: rejects fractional and negative values", () => {
28
+ assert.throws(() => parseOptionalInt("3.14", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), /must be a non-negative integer/);
29
+ assert.throws(() => parseOptionalInt("-8", "PLURNK_PROVIDERS_CONTEXT_WINDOW", "openai"), /must be a non-negative integer/);
30
+ });
31
+
32
+ test("reasoningFromEnv: activation modes parse; budget required IFF on; fail-hard on everything else (#33)", () => {
33
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "off" }, "openai"), { mode: "off", budget: null });
34
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai"), { mode: "adaptive", budget: null });
35
+ assert.deepEqual(reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "4096" }, "openai"), { mode: "on", budget: 4096 });
36
+ assert.throws(() => reasoningFromEnv({}, "openai"), /PLURNK_PROVIDERS_REASONING must be set/);
37
+ assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "8192" }, "openai"), /must be one of "off", "adaptive", "on"/); // the old numeric habit fails loudly
38
+ assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on" }, "openai"), /PLURNK_PROVIDERS_REASONING_BUDGET must be set when/);
39
+ assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "0" }, "openai"), /positive integer/);
40
+ assert.throws(() => reasoningFromEnv({ PLURNK_PROVIDERS_REASONING: "on", PLURNK_PROVIDERS_REASONING_BUDGET: "1.5" }, "openai"), /positive integer/);
41
+ });
42
+
43
+ test("requireEnv: returns the value or throws a named error", () => {
44
+ assert.equal(requireEnv("sk-x", "OPENAI_API_KEY", "openai"), "sk-x");
45
+ assert.throws(() => requireEnv(undefined, "GROQ_API_KEY", "groq"), /groq provider: GROQ_API_KEY must be set/);
46
+ assert.throws(() => requireEnv("", "GROQ_API_KEY", "groq"), /must be set/);
47
+ });
48
+
49
+ // — per-alias knob scoping (per-alias scoping doctrine, user 2026-07-03) —
50
+
51
+ test("scopeEnvToAlias: suffixed knob wins, bare is the fallback, other aliases ignored", async () => {
52
+ const { scopeEnvToAlias } = await import("./env.ts");
53
+ const env = {
54
+ PLURNK_PROVIDERS_REASONING: "off",
55
+ PLURNK_PROVIDERS_REASONING_turboderp: "on",
56
+ PLURNK_PROVIDERS_REASONING_BUDGET_TURBODERP: "4096", // case-folds like PLURNK_MODEL_ keys
57
+ PLURNK_PROVIDERS_CONTEXT_WINDOW_turboderp: "8000", // #525 gate knob — the client window cap
58
+ PLURNK_PROVIDERS_COMPLETION_RESERVE_turboderp: "4096",
59
+ PLURNK_PROVIDERS_CONTEXT_WINDOW_other: "1",
60
+ } as NodeJS.ProcessEnv;
61
+ const scoped = scopeEnvToAlias(env, "turboderp");
62
+ assert.equal(scoped.PLURNK_PROVIDERS_REASONING, "on");
63
+ assert.equal(scoped.PLURNK_PROVIDERS_REASONING_BUDGET, "4096");
64
+ assert.equal(scoped.PLURNK_PROVIDERS_CONTEXT_WINDOW, "8000"); // #525: the alias-scoped window cap promotes to bare (the release-gate knob)
65
+ assert.equal(scoped.PLURNK_PROVIDERS_COMPLETION_RESERVE, "4096"); // the #507 reserves promote too
66
+ assert.equal(scopeEnvToAlias(env, "plain").PLURNK_PROVIDERS_REASONING, "off"); // fallback intact
67
+ });
68
+
69
+ test("scopeEnvToAlias: aliases with underscores resolve; a bare knob is never mistaken for a suffix", async () => {
70
+ const { scopeEnvToAlias } = await import("./env.ts");
71
+ const env = {
72
+ PLURNK_PROVIDERS_FETCH_TIMEOUT: "600000",
73
+ PLURNK_PROVIDERS_FETCH_TIMEOUT_my_box: "5000",
74
+ PLURNK_PROVIDERS_REASONING: "off",
75
+ PLURNK_PROVIDERS_REASONING_BUDGET: "4096", // bare budget — NOT a "_capacity" alias override of REASONING
76
+ } as NodeJS.ProcessEnv;
77
+ assert.equal(scopeEnvToAlias(env, "my_box").PLURNK_PROVIDERS_FETCH_TIMEOUT, "5000");
78
+ assert.equal(scopeEnvToAlias(env, "budget").PLURNK_PROVIDERS_REASONING, "off"); // collision guard
79
+ });
80
+
81
+ test("#36 dataCaptureFromEnv: both knobs OFF by default, ON when set (TOP_LOGPROBS = the OpenAI top_logprobs count)", async () => {
82
+ const { dataCaptureFromEnv } = await import("./env.ts");
83
+ assert.deepEqual(dataCaptureFromEnv({} as NodeJS.ProcessEnv, "x"), { topLogprobs: null, rawBody: false });
84
+ assert.deepEqual(dataCaptureFromEnv({ PLURNK_PROVIDERS_RAWBODY: "0" } as NodeJS.ProcessEnv, "x"), { topLogprobs: null, rawBody: false });
85
+ assert.deepEqual(dataCaptureFromEnv({ PLURNK_PROVIDERS_TOP_LOGPROBS: "3", PLURNK_PROVIDERS_RAWBODY: "1" } as NodeJS.ProcessEnv, "x"), { topLogprobs: 3, rawBody: true });
86
+ assert.deepEqual(dataCaptureFromEnv({ PLURNK_PROVIDERS_TOP_LOGPROBS: "0" } as NodeJS.ProcessEnv, "x"), { topLogprobs: 0, rawBody: false }); // set-to-0 = on, chosen-token only
87
+ });
88
+
89
+ test("OpenAI-lexicon shed: a still-set PLURNK_PROVIDERS_LOGPROB fails hard with the rename pointer", async () => {
90
+ const { dataCaptureFromEnv } = await import("./env.ts");
91
+ assert.throws(
92
+ () => dataCaptureFromEnv({ PLURNK_PROVIDERS_LOGPROB: "3" } as NodeJS.ProcessEnv, "openai"),
93
+ /PLURNK_PROVIDERS_LOGPROB was renamed to PLURNK_PROVIDERS_TOP_LOGPROBS/,
94
+ );
95
+ });
96
+
97
+ test("#472 contextWindowFromEnv: reads the new name, sheds CONTEXT_SIZE hard, null when unset", async () => {
98
+ const { contextWindowFromEnv } = await import("./env.ts");
99
+ assert.equal(contextWindowFromEnv({ PLURNK_PROVIDERS_CONTEXT_WINDOW: "131072" } as NodeJS.ProcessEnv, "openai"), 131072);
100
+ assert.equal(contextWindowFromEnv({} as NodeJS.ProcessEnv, "openai"), null);
101
+ assert.throws(
102
+ () => contextWindowFromEnv({ PLURNK_PROVIDERS_CONTEXT_SIZE: "131072" } as NodeJS.ProcessEnv, "openai"),
103
+ /PLURNK_PROVIDERS_CONTEXT_SIZE was renamed to PLURNK_PROVIDERS_CONTEXT_WINDOW/,
104
+ );
105
+ });
106
+
107
+ test("scopeEnvToAlias: a caller-supplied knob list scopes CONSUMER vars (service window partition)", async () => {
108
+ const { scopeEnvToAlias } = await import("./env.ts");
109
+ const SERVICE_KNOBS = ["PLURNK_SERVICE_MAX_TURNS", "PLURNK_SERVICE_LOOP_TIMEOUT", "PLURNK_SERVICE_EXEC_HOLD_MS", "PLURNK_SERVICE_SAFETY"];
110
+ const env = {
111
+ PLURNK_SERVICE_MAX_TURNS: "163840", PLURNK_SERVICE_LOOP_TIMEOUT: "16384", PLURNK_SERVICE_EXEC_HOLD_MS: "49152", PLURNK_SERVICE_SAFETY: "1024",
112
+ PLURNK_SERVICE_MAX_TURNS_turboderp: "78848", PLURNK_SERVICE_LOOP_TIMEOUT_turboderp: "4096", PLURNK_SERVICE_EXEC_HOLD_MS_TURBODERP: "8192", // case-folds
113
+ } as NodeJS.ProcessEnv;
114
+ const gemma = scopeEnvToAlias(env, "turboderp", SERVICE_KNOBS);
115
+ assert.equal(gemma.PLURNK_SERVICE_MAX_TURNS, "78848");
116
+ assert.equal(gemma.PLURNK_SERVICE_LOOP_TIMEOUT, "4096");
117
+ assert.equal(gemma.PLURNK_SERVICE_EXEC_HOLD_MS, "8192");
118
+ assert.equal(gemma.PLURNK_SERVICE_SAFETY, "1024"); // bare fallback intact
119
+ const cloud = scopeEnvToAlias(env, "fireslow", SERVICE_KNOBS);
120
+ assert.equal(cloud.PLURNK_SERVICE_LOOP_TIMEOUT, "16384"); // 64k envelope untouched by gemma overrides
121
+ assert.equal(cloud.PLURNK_SERVICE_EXEC_HOLD_MS, "49152");
122
+ // custom list does NOT scope providers-family knobs (closed-list isolation both ways)
123
+ const mixed = scopeEnvToAlias({ PLURNK_PROVIDERS_REASONING: "off", PLURNK_PROVIDERS_REASONING_turboderp: "on" } as NodeJS.ProcessEnv, "turboderp", SERVICE_KNOBS);
124
+ assert.equal(mixed.PLURNK_PROVIDERS_REASONING, "off");
125
+ });
126
+
127
+ test("#36 capture knobs are per-alias scopable: enable on a scraping alias, serving alias stays clean", async () => {
128
+ const { scopeEnvToAlias, dataCaptureFromEnv } = await import("./env.ts");
129
+ const env = {
130
+ PLURNK_PROVIDERS_TOP_LOGPROBS_fireslow: "3",
131
+ PLURNK_PROVIDERS_RAWBODY_fireslow: "1",
132
+ } as NodeJS.ProcessEnv;
133
+ assert.deepEqual(dataCaptureFromEnv(scopeEnvToAlias(env, "fireslow"), "x"), { topLogprobs: 3, rawBody: true });
134
+ assert.deepEqual(dataCaptureFromEnv(scopeEnvToAlias(env, "grokfast"), "x"), { topLogprobs: null, rawBody: false });
135
+ });
136
+
137
+ // Reader-declares (#44 ecosystem standard): every knob the code reads appears in
138
+ // the shipped .env.defaults — set (the floor) or commented (documented optional).
139
+ // The file IS the operator documentation; this keeps it from drifting off the code.
140
+ test("#44: every PROVIDERS_KNOBS entry appears in the shipped .env.defaults", async () => {
141
+ const { readFileSync } = await import("node:fs");
142
+ const { PROVIDERS_KNOBS } = await import("./env.ts");
143
+ const defaults = readFileSync(new URL("../.env.defaults", import.meta.url), "utf8");
144
+ const missing = PROVIDERS_KNOBS.filter((k) => !defaults.includes(k));
145
+ assert.deepEqual([...missing], [], "knobs read by code but undeclared in .env.defaults");
146
+ assert.ok(defaults.includes("PLURNK_PROVIDERS_GBNF="), "GBNF (service-read, providers-namespace) must be declared with its default");
147
+ });
148
+
149
+ // #399: the family word is REASONING (industry standard). Old names fail hard
150
+ // with the migration pointer — never silently coexist with the new floor.
151
+ test("#399: still-set old THINKING names fail hard with the rename pointer", () => {
152
+ assert.throws(
153
+ () => reasoningFromEnv({ PLURNK_PROVIDERS_THINKING: "on", PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai"),
154
+ /PLURNK_PROVIDERS_THINKING was renamed to PLURNK_PROVIDERS_REASONING \(#399\)/,
155
+ );
156
+ assert.throws(
157
+ () => reasoningFromEnv({ PLURNK_PROVIDERS_THINKING_CAPACITY: "4096", PLURNK_PROVIDERS_REASONING: "adaptive" }, "openai"),
158
+ /PLURNK_PROVIDERS_THINKING_CAPACITY was renamed to PLURNK_PROVIDERS_REASONING_BUDGET \(#399\)/,
159
+ );
160
+ });
161
+
162
+ test("#399: the shipped floor activates reasoning by default (adaptive — owner ruling)", async () => {
163
+ const { readFileSync } = await import("node:fs");
164
+ const defaults = readFileSync(new URL("../.env.defaults", import.meta.url), "utf8");
165
+ assert.ok(defaults.includes("PLURNK_PROVIDERS_REASONING=adaptive"), "floor must ship REASONING=adaptive");
166
+ assert.ok(!defaults.match(/^PLURNK_PROVIDERS_REASONING_BUDGET=/m), "no shipped magnitude — budget is on-mode only");
167
+ });
168
+
169
+ // -- #507: envelope reserves (owner-ruled migration from PLURNK_SERVICE_*) --
170
+
171
+ test("#507 envelopeFromEnv: percentages and absolutes parse; missing/invalid fail hard", async () => {
172
+ const { envelopeFromEnv } = await import("./env.ts");
173
+ assert.deepEqual(
174
+ envelopeFromEnv({ PLURNK_PROVIDERS_REASONING_RESERVE: "10%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "4096" } as NodeJS.ProcessEnv, "x"),
175
+ { reasoningReserve: { percent: 0.1 }, completionReserve: { tokens: 4096 } },
176
+ );
177
+ assert.throws(() => envelopeFromEnv({ PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%" } as NodeJS.ProcessEnv, "x"), /PLURNK_PROVIDERS_REASONING_RESERVE must be set/);
178
+ assert.throws(() => envelopeFromEnv({ PLURNK_PROVIDERS_REASONING_RESERVE: "150%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%" } as NodeJS.ProcessEnv, "x"), /percentage must be in \(0, 100\)/);
179
+ assert.throws(() => envelopeFromEnv({ PLURNK_PROVIDERS_REASONING_RESERVE: "-5", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%" } as NodeJS.ProcessEnv, "x"), /positive integer token count/);
180
+ });
181
+
182
+ test("#507 envelope knobs are per-alias scopable (measured envelope per box)", async () => {
183
+ const { scopeEnvToAlias, envelopeFromEnv } = await import("./env.ts");
184
+ const env = {
185
+ PLURNK_PROVIDERS_REASONING_RESERVE: "10%", PLURNK_PROVIDERS_COMPLETION_RESERVE: "25%",
186
+ PLURNK_PROVIDERS_REASONING_RESERVE_turboderp: "4096", PLURNK_PROVIDERS_COMPLETION_RESERVE_turboderp: "8192",
187
+ } as NodeJS.ProcessEnv;
188
+ assert.deepEqual(envelopeFromEnv(scopeEnvToAlias(env, "turboderp"), "x"), { reasoningReserve: { tokens: 4096 }, completionReserve: { tokens: 8192 } });
189
+ assert.deepEqual(envelopeFromEnv(scopeEnvToAlias(env, "jennifer"), "x"), { reasoningReserve: { percent: 0.1 }, completionReserve: { percent: 0.25 } });
190
+ });
package/src/env.ts ADDED
@@ -0,0 +1,211 @@
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.
4
+
5
+ export const parseRequiredInt = (raw: string | undefined, name: string, label: string): number => {
6
+ if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set`);
7
+ const n = Number(raw);
8
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${label} provider: ${name} must be a non-negative integer (got "${raw}")`);
9
+ return n;
10
+ };
11
+
12
+ export const parseOptionalInt = (raw: string | undefined, name: string, label: string): number | null => {
13
+ if (raw === undefined || raw.length === 0) return null;
14
+ const n = Number(raw);
15
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${label} provider: ${name} must be a non-negative integer (got "${raw}")`);
16
+ return n;
17
+ };
18
+
19
+ export const parseRequiredFloat = (raw: string | undefined, name: string, label: string, min: number): number => {
20
+ if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set`);
21
+ const n = Number(raw);
22
+ if (!Number.isFinite(n) || n < min) throw new Error(`${label} provider: ${name} must be a finite number >= ${min} (got "${raw}")`);
23
+ return n;
24
+ };
25
+
26
+ export const parseOptionalFloat = (raw: string | undefined, name: string, label: string, min: number): number | null => {
27
+ if (raw === undefined || raw.length === 0) return null;
28
+ const n = Number(raw);
29
+ if (!Number.isFinite(n) || n < min) throw new Error(`${label} provider: ${name} must be a finite number >= ${min} (got "${raw}")`);
30
+ return n;
31
+ };
32
+
33
+ export const requireEnv = (raw: string | undefined, name: string, label: string): string => {
34
+ if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set`);
35
+ return raw;
36
+ };
37
+
38
+ // Old-name shed (the #399/#472 lexicon renames): a still-set retired knob fails
39
+ // hard pointing at its successor — never silently coexists with the new floor.
40
+ // The retired names appear ONLY as this function's ARGUMENTS at the call sites
41
+ // (each lexicon-allow), never as a live identifier.
42
+ const shedRenamed = (env: NodeJS.ProcessEnv, oldName: string, newName: string, label: string, ref: string): void => {
43
+ const stale = env[oldName];
44
+ if (stale !== undefined && stale.length > 0) throw new Error(`${label} provider: ${oldName} was renamed to ${newName} (${ref}); update the env`);
45
+ };
46
+
47
+ // Data-capture knobs (#36), read identically by every provider (standard AND
48
+ // plugin) so the opt-in surface is one source of truth. Both OFF by default —
49
+ // the flag is the isolation, so serving turns request and carry nothing.
50
+ // PLURNK_PROVIDERS_TOP_LOGPROBS non-negative int = the OpenAI `top_logprobs`
51
+ // count (set -> request per-token logprobs; unset -> off). Per-alias-scopable.
52
+ // PLURNK_PROVIDERS_RAWBODY truthy (not ""/"0") → attach the verbatim wire
53
+ // body to response.rawBody. Per-alias-scopable.
54
+ export const dataCaptureFromEnv = (env: NodeJS.ProcessEnv, label: string): { topLogprobs: number | null; rawBody: boolean } => {
55
+ shedRenamed(env, "PLURNK_PROVIDERS_LOGPROB", "PLURNK_PROVIDERS_TOP_LOGPROBS", label, "the OpenAI wire term"); // lexicon-allow
56
+ return {
57
+ topLogprobs: parseOptionalInt(env.PLURNK_PROVIDERS_TOP_LOGPROBS, "PLURNK_PROVIDERS_TOP_LOGPROBS", label),
58
+ rawBody: env.PLURNK_PROVIDERS_RAWBODY !== undefined && env.PLURNK_PROVIDERS_RAWBODY !== "" && env.PLURNK_PROVIDERS_RAWBODY !== "0",
59
+ };
60
+ };
61
+
62
+ // The context-window pin (SPEC §4) under its OpenAI-lexicon name (#472) — the
63
+ // industry term is "context window" (OpenAI/Anthropic docs, models.dev
64
+ // contextWindow); CONTEXT_SIZE was home-grown. One reader for base AND
65
+ // plugins, so the shed fires everywhere the knob is honored.
66
+ export const contextWindowFromEnv = (env: NodeJS.ProcessEnv, label: string): number | null => {
67
+ shedRenamed(env, "PLURNK_PROVIDERS_CONTEXT_SIZE", "PLURNK_PROVIDERS_CONTEXT_WINDOW", label, "the industry term, #472"); // lexicon-allow
68
+ return parseOptionalInt(env.PLURNK_PROVIDERS_CONTEXT_WINDOW, "PLURNK_PROVIDERS_CONTEXT_WINDOW", label);
69
+ };
70
+
71
+ // The generation-envelope reserves (#507, owner-ruled migration): how much of a
72
+ // DETECTED context window is reserved for reasoning and for completion — the
73
+ // remainder (minus the consumer's own packing-safety margin) is the prompt
74
+ // budget. Provider-owned: the window is a provider fact and these are amounts OF
75
+ // it; the former PLURNK_SERVICE_{CONTEXT_WINDOW,REASONING,COMPLETION} knobs were
76
+ // provider quantities wearing a service prefix (born as #352 packet parameters
77
+ // before this surface existed). Each knob accepts a percentage of the window
78
+ // ("10%") or an absolute token count ("4096"); the floor ships percentages so
79
+ // every window-advertising endpoint (llama-server n_ctx, the plurnk.ai router,
80
+ // a cataloged cloud model) arrives at sane defaults with ZERO operator tuning.
81
+ // Per-alias suffixes override for measured envelopes; absolutes win over the
82
+ // window derivation entirely.
83
+ export type ReserveSpec = { percent: number } | { tokens: number };
84
+
85
+ const parseReserve = (raw: string | undefined, name: string, label: string): ReserveSpec => {
86
+ if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set (a percentage of the window like "10%", or an absolute token count)`);
87
+ const pct = /^([0-9]+(?:\.[0-9]+)?)%$/.exec(raw);
88
+ if (pct !== null) {
89
+ const p = Number(pct[1]);
90
+ if (!Number.isFinite(p) || p <= 0 || p >= 100) throw new Error(`${label} provider: ${name} percentage must be in (0, 100) (got "${raw}")`);
91
+ return { percent: p / 100 };
92
+ }
93
+ const n = Number(raw);
94
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${label} provider: ${name} must be "<pct>%" or a positive integer token count (got "${raw}")`);
95
+ return { tokens: n };
96
+ };
97
+
98
+ export const envelopeFromEnv = (env: NodeJS.ProcessEnv, label: string): { reasoningReserve: ReserveSpec; completionReserve: ReserveSpec } => ({
99
+ reasoningReserve: parseReserve(env.PLURNK_PROVIDERS_REASONING_RESERVE, "PLURNK_PROVIDERS_REASONING_RESERVE", label),
100
+ completionReserve: parseReserve(env.PLURNK_PROVIDERS_COMPLETION_RESERVE, "PLURNK_PROVIDERS_COMPLETION_RESERVE", label),
101
+ });
102
+
103
+ // Resolve a ReserveSpec against a known window: absolutes stand alone; a
104
+ // percentage needs the window (null when unknown — the underivable/no-cap case).
105
+ export const resolveReserve = (spec: ReserveSpec, window: number | null): number | null =>
106
+ "tokens" in spec ? spec.tokens : window === null ? null : Math.round(spec.percent * window);
107
+
108
+ // TOLERANT envelope read for fixtures + consumers that resolve reserves against
109
+ // a known window and treat ABSENCE as "no claim" (null), never fail-hard —
110
+ // unlike envelopeFromEnv (the REQUIRED provider path, fed by the shipped floor).
111
+ // Mock uses this so the service's partition/budget suite drives the SAME
112
+ // env→reserve resolution a real provider does, without the floor. An invalid
113
+ // value still throws (a malformed reserve is an error, not an absence).
114
+ export const resolveEnvelopeFromEnv = (env: NodeJS.ProcessEnv, window: number | null): { reasoningReserve: number | null; completionReserve: number | null } => {
115
+ const one = (raw: string | undefined, name: string): number | null =>
116
+ raw === undefined || raw.length === 0 ? null : resolveReserve(parseReserve(raw, name, "mock"), window);
117
+ return {
118
+ reasoningReserve: one(env.PLURNK_PROVIDERS_REASONING_RESERVE, "PLURNK_PROVIDERS_REASONING_RESERVE"),
119
+ completionReserve: one(env.PLURNK_PROVIDERS_COMPLETION_RESERVE, "PLURNK_PROVIDERS_COMPLETION_RESERVE"),
120
+ };
121
+ };
122
+
123
+ // The side-channel reasoning knobs (SPEC §4, #32/#33) — ACTIVATION and BUDGET
124
+ // are separate vars, so a numeric budget can never silently flip wire flags:
125
+ // PLURNK_PROVIDERS_REASONING off | adaptive | on (REQUIRED, fail-hard)
126
+ // PLURNK_PROVIDERS_REASONING_BUDGET positive int, REQUIRED iff REASONING=on —
127
+ // the magnitude for tier/budget mapping. On llama.cpp the ENFORCEMENT is the
128
+ // box's --reasoning-budget launch flag (per-request numerics are ignored):
129
+ // env budget and launch flag are the same number, changed together.
130
+ // The provider maps intent to the backend's mechanism; the consumer states
131
+ // intent, never mechanism. (In-DSL PLAN reasoning is a grammar concern.)
132
+ export type ReasoningMode = "off" | "adaptive" | "on";
133
+ export type Reasoning = { mode: ReasoningMode; budget: number | null };
134
+
135
+ export const reasoningFromEnv = (env: NodeJS.ProcessEnv, label: string): Reasoning => {
136
+ shedRenamed(env, "PLURNK_PROVIDERS_THINKING", "PLURNK_PROVIDERS_REASONING", label, "#399"); // lexicon-allow
137
+ shedRenamed(env, "PLURNK_PROVIDERS_THINKING_CAPACITY", "PLURNK_PROVIDERS_REASONING_BUDGET", label, "#399"); // lexicon-allow
138
+ const name = "PLURNK_PROVIDERS_REASONING";
139
+ const raw = env[name];
140
+ if (raw === undefined || raw.length === 0) throw new Error(`${label} provider: ${name} must be set (off | adaptive | on)`);
141
+ if (raw !== "off" && raw !== "adaptive" && raw !== "on") throw new Error(`${label} provider: ${name} must be one of "off", "adaptive", "on" (got "${raw}")`);
142
+ if (raw !== "on") return { mode: raw, budget: null };
143
+ const capName = "PLURNK_PROVIDERS_REASONING_BUDGET";
144
+ const capRaw = env[capName];
145
+ if (capRaw === undefined || capRaw.length === 0) throw new Error(`${label} provider: ${capName} must be set when ${name}=on`);
146
+ const n = Number(capRaw);
147
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${label} provider: ${capName} must be a positive integer (got "${capRaw}")`);
148
+ return { mode: "on", budget: n };
149
+ };
150
+
151
+ // ── Per-alias knob scoping (per-alias scoping doctrine, user 2026-07-03): PLURNK_PROVIDERS_<KNOB>[_<alias>] ──
152
+ // Every plurnk-owned knob accepts a per-alias override — the suffixed form wins,
153
+ // the bare form is the fallback — so two aliases on one provider name (two boxes,
154
+ // two models) stop sharing one global setting. The knob list is CLOSED and parsed
155
+ // exact-prefix-first, so aliases containing underscores stay unambiguous. Vendor
156
+ // facts (API keys, canonical endpoints) remain vendor-named; the per-alias
157
+ // endpoint override stays PLURNK_BASEURL_<alias> (its existing precedent).
158
+ export const PROVIDERS_KNOBS = Object.freeze([
159
+ "PLURNK_PROVIDERS_REASONING_RESERVE",
160
+ "PLURNK_PROVIDERS_COMPLETION_RESERVE",
161
+ "PLURNK_PROVIDERS_REASONING_BUDGET",
162
+ "PLURNK_PROVIDERS_REASONING",
163
+ "PLURNK_PROVIDERS_CONTEXT_WINDOW",
164
+ "PLURNK_PROVIDERS_RETRY_ATTEMPTS",
165
+ "PLURNK_PROVIDERS_FETCH_TIMEOUT",
166
+ "PLURNK_PROVIDERS_LLAMA_SERVER",
167
+ "PLURNK_PROVIDERS_TEMPERATURE",
168
+ "PLURNK_PROVIDERS_REPEAT_PENALTY",
169
+ "PLURNK_PROVIDERS_FREQUENCY_PENALTY",
170
+ "PLURNK_PROVIDERS_REPEAT_LAST_N",
171
+ "PLURNK_PROVIDERS_DRY_MULTIPLIER",
172
+ "PLURNK_PROVIDERS_DRY_BASE",
173
+ "PLURNK_PROVIDERS_DRY_ALLOWED_LENGTH",
174
+ "PLURNK_PROVIDERS_RETRY_DELAY",
175
+ "PLURNK_PROVIDERS_PROBE_ATTEMPTS",
176
+ "PLURNK_PROVIDERS_PROBE_DELAY",
177
+ "PLURNK_PROVIDERS_GBNF_DEBUG",
178
+ "PLURNK_PROVIDERS_TOP_LOGPROBS",
179
+ "PLURNK_PROVIDERS_RAWBODY",
180
+ ]);
181
+
182
+ // Materialize an alias-scoped VIEW of env: for each known knob with a
183
+ // `_<alias>`-suffixed key (suffix case-folds to the alias, matching the
184
+ // PLURNK_MODEL_/PLURNK_BASEURL_ convention), overlay it onto the bare name.
185
+ // Providers keep reading plain vars — scoping is entirely the caller's overlay,
186
+ // so fromEnv implementations (and plugins) need zero changes.
187
+ //
188
+ // `knobs` (optional) lets a CONSUMER scope its OWN closed knob list with this
189
+ // same parser — e.g. the service's window-partition vars (PLURNK_SERVICE_CONTEXT_WINDOW/
190
+ // MAX_TURNS/...), so a 64k cloud envelope and a 12k gemma envelope
191
+ // coexist per-alias without the service reimplementing the suffix/collision
192
+ // rules. Default stays the providers-family list; my call sites pass nothing.
193
+ export const scopeEnvToAlias = (env: NodeJS.ProcessEnv, alias: string, knobs: readonly string[] = PROVIDERS_KNOBS): NodeJS.ProcessEnv => {
194
+ const folded = alias.toLowerCase();
195
+ const out: NodeJS.ProcessEnv = { ...env };
196
+ for (const knob of knobs) {
197
+ for (const [key, value] of Object.entries(env)) {
198
+ if (value === undefined || value.length === 0) continue;
199
+ if (!key.startsWith(knob + "_")) continue;
200
+ // A bare knob can prefix another bare knob (_REASONING prefixes
201
+ // _REASONING_BUDGET, _CONTEXT prefixes a hypothetical _CONTEXT_WINDOW): a key
202
+ // that IS a known knob is never a suffixed override, whatever the
203
+ // alias is named.
204
+ if (knobs.includes(key)) continue;
205
+ if (key.slice(knob.length + 1).toLowerCase() !== folded) continue;
206
+ out[knob] = value;
207
+ break;
208
+ }
209
+ }
210
+ return out;
211
+ };