@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
package/src/Mock.test.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import Mock from "./Mock.ts";
|
|
4
|
+
import type { Provider } from "./types.ts";
|
|
5
|
+
import type { MockResponse } from "./Mock.ts";
|
|
6
|
+
|
|
7
|
+
const build = (responses: MockResponse[] = [{ assistant: { content: "hi", reasoning: null } }]) =>
|
|
8
|
+
new Mock({ contextWindow: 100000, responses });
|
|
9
|
+
|
|
10
|
+
// — Identity (SPEC §10.2, §10.6) —
|
|
11
|
+
|
|
12
|
+
test("Mock: contextWindow and model are stable across reads", () => {
|
|
13
|
+
const m = build();
|
|
14
|
+
assert.equal(m.contextWindow, 100000);
|
|
15
|
+
assert.equal(m.contextWindow, 100000);
|
|
16
|
+
assert.equal(m.model, "mock");
|
|
17
|
+
assert.equal(m.model, "mock");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("Mock: contextWindow passes null through", () => {
|
|
21
|
+
const m = new Mock({ contextWindow: null, responses: [] });
|
|
22
|
+
assert.equal(m.contextWindow, null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// — Tokenomics (SPEC §10.3, §10.4, §10.5) —
|
|
26
|
+
|
|
27
|
+
test("Mock: countTokens('') is 0; non-empty is a positive integer", () => {
|
|
28
|
+
const m = build();
|
|
29
|
+
assert.equal(m.countTokens(""), 0);
|
|
30
|
+
const n = m.countTokens("four");
|
|
31
|
+
assert.ok(Number.isInteger(n) && n > 0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("Mock: costFor zero usage is 0 (free)", () => {
|
|
35
|
+
const m = build();
|
|
36
|
+
assert.equal(m.costFor({ prompt: 0, completion: 0, reasoning: 0, cached: 0, total: 0 }), 0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// — Transport (SPEC §10.7, §10.10) —
|
|
40
|
+
|
|
41
|
+
test("Mock: generate resolves a valid ProviderResponse shape", async () => {
|
|
42
|
+
const m = build([{ assistant: { content: "hello", reasoning: "cot" } }]);
|
|
43
|
+
const { assistant, assistantRaw } = await m.generate({ messages: [] });
|
|
44
|
+
assert.equal(assistant.content, "hello");
|
|
45
|
+
assert.equal(assistant.reasoning, "cot");
|
|
46
|
+
assert.deepEqual(assistant.usage, { prompt: 0, completion: 0, reasoning: 0, cached: 0, total: 0 });
|
|
47
|
+
assert.equal(assistant.finishReason, "stop");
|
|
48
|
+
assert.equal(assistant.model, "mock");
|
|
49
|
+
assert.equal(assistantRaw, null); // present, defaulted
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("Mock: generate applies caller-supplied overrides", async () => {
|
|
53
|
+
const m = build([{
|
|
54
|
+
assistant: {
|
|
55
|
+
content: "x",
|
|
56
|
+
reasoning: null,
|
|
57
|
+
usage: { prompt: 1, completion: 2, reasoning: 0, cached: 0, total: 3 },
|
|
58
|
+
finishReason: "length",
|
|
59
|
+
model: "mock-xl",
|
|
60
|
+
},
|
|
61
|
+
assistantRaw: { wire: true },
|
|
62
|
+
}]);
|
|
63
|
+
const { assistant, assistantRaw } = await m.generate({ messages: [] });
|
|
64
|
+
assert.equal(assistant.finishReason, "length");
|
|
65
|
+
assert.equal(assistant.model, "mock-xl");
|
|
66
|
+
assert.deepEqual(assistant.usage, { prompt: 1, completion: 2, reasoning: 0, cached: 0, total: 3 });
|
|
67
|
+
assert.deepEqual(assistantRaw, { wire: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Mock: ops escape hatch passes through when provided", async () => {
|
|
71
|
+
const ops = [{ kind: "send" }] as never;
|
|
72
|
+
const m = build([{ assistant: { content: "x", reasoning: null, ops } }]);
|
|
73
|
+
const { assistant } = await m.generate({ messages: [] });
|
|
74
|
+
assert.deepEqual(assistant.ops, ops);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("Mock: ops absent when not provided", async () => {
|
|
78
|
+
const m = build();
|
|
79
|
+
const { assistant } = await m.generate({ messages: [] });
|
|
80
|
+
assert.equal("ops" in assistant, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// — Abort (SPEC §10.8) —
|
|
84
|
+
|
|
85
|
+
test("Mock: generate with a pre-aborted signal rejects and consumes no response", async () => {
|
|
86
|
+
const m = build([{ assistant: { content: "untouched", reasoning: null } }]);
|
|
87
|
+
const signal = AbortSignal.abort(new Error("boom"));
|
|
88
|
+
await assert.rejects(() => m.generate({ messages: [], signal }), /boom/);
|
|
89
|
+
assert.equal(m.remaining, 1); // queue untouched — no "wire call" made
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// — Lifecycle —
|
|
93
|
+
|
|
94
|
+
test("Mock: remaining decrements as responses are consumed", async () => {
|
|
95
|
+
const m = build([
|
|
96
|
+
{ assistant: { content: "a", reasoning: null } },
|
|
97
|
+
{ assistant: { content: "b", reasoning: null } },
|
|
98
|
+
]);
|
|
99
|
+
assert.equal(m.remaining, 2);
|
|
100
|
+
await m.generate({ messages: [] });
|
|
101
|
+
assert.equal(m.remaining, 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("Mock: exhausted queue throws a specific error", async () => {
|
|
105
|
+
const m = build([]);
|
|
106
|
+
await assert.rejects(() => m.generate({ messages: [] }), /exhausted/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// -- #507: the reserve surface lives on the Provider CONTRACT, and Mock drives core's partition suite --
|
|
110
|
+
|
|
111
|
+
test("#507 the reserve getters are on the Provider interface (not just the concrete class)", () => {
|
|
112
|
+
// Typing against the CONTRACT is the check a getter-only-on-OpenAICompat surface fails.
|
|
113
|
+
const prevR = process.env.PLURNK_PROVIDERS_REASONING_RESERVE;
|
|
114
|
+
try {
|
|
115
|
+
process.env.PLURNK_PROVIDERS_REASONING_RESERVE = "10%";
|
|
116
|
+
const p: Provider = new Mock({ contextWindow: 49152, responses: [] });
|
|
117
|
+
assert.equal(p.reasoningReserve, 4915); // 10% of 49152, read through the interface type
|
|
118
|
+
} finally {
|
|
119
|
+
if (prevR === undefined) delete process.env.PLURNK_PROVIDERS_REASONING_RESERVE; else process.env.PLURNK_PROVIDERS_REASONING_RESERVE = prevR;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("#507 Mock resolves reserves from PLURNK_PROVIDERS_*_RESERVE against its window (the service partition path)", () => {
|
|
124
|
+
const prevR = process.env.PLURNK_PROVIDERS_REASONING_RESERVE;
|
|
125
|
+
const prevC = process.env.PLURNK_PROVIDERS_COMPLETION_RESERVE;
|
|
126
|
+
try {
|
|
127
|
+
process.env.PLURNK_PROVIDERS_REASONING_RESERVE = "10%";
|
|
128
|
+
process.env.PLURNK_PROVIDERS_COMPLETION_RESERVE = "8192"; // mixed pct + absolute
|
|
129
|
+
const m = new Mock({ contextWindow: 49152, responses: [] });
|
|
130
|
+
assert.equal(m.reasoningReserve, 4915); // 10% of 49152
|
|
131
|
+
assert.equal(m.completionReserve, 8192); // absolute stands
|
|
132
|
+
} finally {
|
|
133
|
+
if (prevR === undefined) delete process.env.PLURNK_PROVIDERS_REASONING_RESERVE; else process.env.PLURNK_PROVIDERS_REASONING_RESERVE = prevR;
|
|
134
|
+
if (prevC === undefined) delete process.env.PLURNK_PROVIDERS_COMPLETION_RESERVE; else process.env.PLURNK_PROVIDERS_COMPLETION_RESERVE = prevC;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("#507 no reserve env → null (the #421 no-cap path; the ~100 bare Mocks unaffected)", () => {
|
|
139
|
+
const m = new Mock({ contextWindow: 49152, responses: [] });
|
|
140
|
+
assert.equal(m.reasoningReserve, null);
|
|
141
|
+
assert.equal(m.completionReserve, null);
|
|
142
|
+
});
|
package/src/Mock.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Mock provider — reference implementation + test fixture.
|
|
2
|
+
//
|
|
3
|
+
// Dual purpose: (a) plurnk-service intg suite uses it for deterministic
|
|
4
|
+
// engine tests; (b) worked example for sibling authors implementing the
|
|
5
|
+
// Provider contract. Production providers don't expose the `ops` escape
|
|
6
|
+
// hatch — that's an intg-only convenience.
|
|
7
|
+
|
|
8
|
+
import type { ChatMessage, FinishReason, Provider, ProviderAssistant, ProviderUsage } from "./types.ts";
|
|
9
|
+
import { resolveEnvelopeFromEnv } from "./env.ts";
|
|
10
|
+
|
|
11
|
+
export type MockAssistant = {
|
|
12
|
+
content: string;
|
|
13
|
+
reasoning: string | null;
|
|
14
|
+
// Partial — omitted fields fall back to DEFAULT_USAGE (e.g. reasoning: 0).
|
|
15
|
+
usage?: Partial<ProviderUsage>;
|
|
16
|
+
finishReason?: FinishReason;
|
|
17
|
+
model?: string;
|
|
18
|
+
// #482: sealed relay reasoning items (id-keyed, widened per client), so core's
|
|
19
|
+
// sealed-reasoning conformance test can drive the shape through Mock.
|
|
20
|
+
reasoningEncrypted?: ReadonlyArray<{ id: string | null; subtype: string; encrypted: ReadonlyArray<{ data: string; format: string | null }> }>;
|
|
21
|
+
// Pre-parsed ops — intg-only escape hatch. Typed `unknown[]` so the
|
|
22
|
+
// framework carries NO @plurnk/plurnk-grammar dependency; plurnk-service
|
|
23
|
+
// casts these to PlurnkStatement[] on its side. Production providers never
|
|
24
|
+
// include this field.
|
|
25
|
+
ops?: unknown[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type MockResponse = {
|
|
29
|
+
assistant: MockAssistant;
|
|
30
|
+
assistantRaw?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Returned shape: ProviderAssistant + pre-parsed ops visible for tests.
|
|
34
|
+
export type MockReturnedAssistant = ProviderAssistant & { ops?: unknown[] };
|
|
35
|
+
|
|
36
|
+
const DEFAULT_USAGE: ProviderUsage = { prompt: 0, completion: 0, reasoning: 0, cached: 0, total: 0 };
|
|
37
|
+
|
|
38
|
+
export default class Mock implements Provider {
|
|
39
|
+
#contextWindow: number | null;
|
|
40
|
+
#reasoningReserve: number | null;
|
|
41
|
+
#completionReserve: number | null;
|
|
42
|
+
#queue: MockResponse[];
|
|
43
|
+
|
|
44
|
+
// #507: reserves resolve the SAME way a real provider's do — the TOLERANT env
|
|
45
|
+
// read (the service's partition/budget suite sets PLURNK_PROVIDERS_*_RESERVE
|
|
46
|
+
// and Mock reflects it, resolved against contextWindow). Tolerant, NOT the
|
|
47
|
+
// fail-hard envelopeFromEnv: Mock is the universal fixture, constructed
|
|
48
|
+
// without the reserves in most base tests, so absent env → null → the
|
|
49
|
+
// consumer's no-cap path, and the ~100 `new Mock({ contextWindow, responses })`
|
|
50
|
+
// sites stay untouched. Mock has no alias identity, so it reads the BARE knobs.
|
|
51
|
+
constructor({ contextWindow, responses }: { contextWindow: number | null; responses: MockResponse[] }) {
|
|
52
|
+
this.#contextWindow = contextWindow;
|
|
53
|
+
const env = resolveEnvelopeFromEnv(process.env, contextWindow);
|
|
54
|
+
this.#reasoningReserve = env.reasoningReserve;
|
|
55
|
+
this.#completionReserve = env.completionReserve;
|
|
56
|
+
this.#queue = [...responses];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get contextWindow(): number | null { return this.#contextWindow; }
|
|
60
|
+
get reasoningReserve(): number | null { return this.#reasoningReserve; }
|
|
61
|
+
get completionReserve(): number | null { return this.#completionReserve; }
|
|
62
|
+
get model(): string { return "mock"; }
|
|
63
|
+
|
|
64
|
+
// Heuristic tokenizer (chars/2 upper bound, matching the framework's
|
|
65
|
+
// fallback). Mock is test-only; real provider siblings ship exact counts.
|
|
66
|
+
countTokens(text: string): number {
|
|
67
|
+
return text.length === 0 ? 0 : Math.ceil(text.length / 2);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Mock is free.
|
|
71
|
+
costFor(_usage: ProviderUsage): number { return 0; }
|
|
72
|
+
|
|
73
|
+
async generate({ signal }: { messages: ChatMessage[]; workerId?: string; signal?: AbortSignal }): Promise<{ assistant: MockReturnedAssistant; assistantRaw: unknown }> {
|
|
74
|
+
// Honor abort before consuming the queue — an aborted call makes no
|
|
75
|
+
// "wire call" and must not exhaust a queued response (SPEC §10.8).
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
const next = this.#queue.shift();
|
|
78
|
+
if (next === undefined) throw new Error("Mock provider exhausted: no more queued responses");
|
|
79
|
+
const a = next.assistant;
|
|
80
|
+
const assistant: MockReturnedAssistant = {
|
|
81
|
+
content: a.content,
|
|
82
|
+
reasoning: a.reasoning,
|
|
83
|
+
usage: { ...DEFAULT_USAGE, ...a.usage },
|
|
84
|
+
...(a.reasoningEncrypted !== undefined ? { reasoningEncrypted: a.reasoningEncrypted } : {}), // #482 — sealed blobs ride the contract through Mock too
|
|
85
|
+
finishReason: a.finishReason ?? "stop",
|
|
86
|
+
model: a.model ?? "mock",
|
|
87
|
+
...(a.ops !== undefined ? { ops: a.ops } : {}),
|
|
88
|
+
};
|
|
89
|
+
return { assistant, assistantRaw: next.assistantRaw ?? null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get remaining(): number { return this.#queue.length; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { DEFAULT_USAGE as mockDefaultUsage };
|