@plurnk/plurnk-providers 1.2.0 → 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.
- package/README.md +50 -60
- package/SPEC.md +6 -6
- package/dist/OpenAICompat.js +2 -2
- package/dist/OpenAICompat.js.map +1 -1
- package/dist/ProviderRegistry.js +2 -2
- package/dist/ProviderRegistry.js.map +1 -1
- package/dist/env.js +3 -3
- package/dist/env.js.map +1 -1
- package/dist/openaiStream.d.ts.map +1 -1
- package/dist/openaiStream.js +12 -0
- package/dist/openaiStream.js.map +1 -1
- package/package.json +7 -6
- package/src/Mock.test.ts +142 -0
- package/src/Mock.ts +95 -0
- package/src/OpenAICompat.test.ts +1107 -0
- package/src/OpenAICompat.ts +756 -0
- package/src/Pool.test.ts +155 -0
- package/src/Pool.ts +134 -0
- package/src/ProviderRegistry.test.ts +176 -0
- package/src/ProviderRegistry.ts +93 -0
- package/src/boundaries.test.ts +24 -0
- package/src/discover.test.ts +123 -0
- package/src/discover.ts +112 -0
- package/src/env.test.ts +190 -0
- package/src/env.ts +211 -0
- package/src/index.ts +51 -0
- package/src/lexicon-guard.test.ts +58 -0
- package/src/openaiStream.ts +279 -0
- package/src/standardProviders.test.ts +925 -0
- package/src/standardProviders.ts +618 -0
- package/src/telemetry.test.ts +62 -0
- package/src/telemetry.ts +108 -0
- package/src/types.ts +219 -0
- package/src/usage.test.ts +136 -0
- package/src/usage.ts +82 -0
- package/src/warnings.test.ts +31 -0
- 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
|
+
});
|
package/src/discover.ts
ADDED
|
@@ -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
|
+
};
|
package/src/env.test.ts
ADDED
|
@@ -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
|
+
};
|