@plurnk/plurnk-providers 1.2.0 → 1.3.1

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.
@@ -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 };