@plurnk/plurnk-providers 1.16.5 → 1.18.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/.env.defaults +87 -200
- package/README.md +2 -1
- package/SPEC.md +73 -66
- package/dist/AiSdkProvider.d.ts +0 -1
- package/dist/AiSdkProvider.d.ts.map +1 -1
- package/dist/AiSdkProvider.js +25 -55
- package/dist/AiSdkProvider.js.map +1 -1
- package/dist/AiSdkRequestBody.d.ts +0 -1
- package/dist/AiSdkRequestBody.d.ts.map +1 -1
- package/dist/AiSdkRequestBody.js +1 -17
- package/dist/AiSdkRequestBody.js.map +1 -1
- package/dist/LeadingReasoning.d.ts +16 -0
- package/dist/LeadingReasoning.d.ts.map +1 -0
- package/dist/LeadingReasoning.js +81 -0
- package/dist/LeadingReasoning.js.map +1 -0
- package/dist/aiSdkTransport.d.ts +7 -0
- package/dist/aiSdkTransport.d.ts.map +1 -1
- package/dist/aiSdkTransport.js +19 -5
- package/dist/aiSdkTransport.js.map +1 -1
- package/dist/catalogProvider.d.ts +3 -1
- package/dist/catalogProvider.d.ts.map +1 -1
- package/dist/catalogProvider.js +24 -18
- package/dist/catalogProvider.js.map +1 -1
- package/dist/compatibleProvider.d.ts.map +1 -1
- package/dist/compatibleProvider.js +0 -3
- package/dist/compatibleProvider.js.map +1 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +0 -1
- package/dist/env.js.map +1 -1
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +46 -15
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/providerError.d.ts +1 -1
- package/dist/providerError.d.ts.map +1 -1
- package/dist/providerError.js +3 -0
- package/dist/providerError.js.map +1 -1
- package/dist/sdkModels.d.ts +1 -13
- package/dist/sdkModels.d.ts.map +1 -1
- package/dist/sdkModels.js +2 -94
- package/dist/sdkModels.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +18 -9
- package/dist/usage.js.map +1 -1
- package/docs/models.md +90 -0
- package/package.json +6 -6
- package/src/AiSdkProvider.test.ts +99 -38
- package/src/AiSdkProvider.ts +24 -78
- package/src/AiSdkRequestBody.ts +1 -16
- package/src/LeadingReasoning.test.ts +34 -0
- package/src/LeadingReasoning.ts +87 -0
- package/src/ProviderRegistry.test.ts +19 -1
- package/src/aiSdkTransport.test.ts +56 -0
- package/src/aiSdkTransport.ts +31 -5
- package/src/boundaries.test.ts +1 -0
- package/src/catalogProvider.test.ts +110 -20
- package/src/catalogProvider.ts +40 -18
- package/src/compatibleProvider.ts +0 -3
- package/src/env.test.ts +1 -0
- package/src/env.ts +1 -2
- package/src/errors.test.ts +58 -7
- package/src/errors.ts +55 -18
- package/src/index.ts +2 -2
- package/src/providerError.ts +4 -0
- package/src/sdkModels.test.ts +1 -42
- package/src/sdkModels.ts +1 -123
- package/src/types.ts +8 -10
- package/src/usage.ts +23 -15
package/docs/models.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Models and generation
|
|
2
|
+
|
|
3
|
+
Use a catalog route directly, or give it an alias for reusable tuning:
|
|
4
|
+
|
|
5
|
+
```dotenv
|
|
6
|
+
PLURNK_MODEL_fast=openrouter/qwen/qwen3-coder
|
|
7
|
+
PLURNK_MODEL=fast
|
|
8
|
+
PLURNK_PROVIDERS_REASONING_fast=adaptive
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The first route segment is the provider; everything after it is the model id.
|
|
12
|
+
Models.dev supplies cataloged providers' endpoints, credential-variable names,
|
|
13
|
+
model limits, reasoning capabilities, and rates. The operator supplies credentials
|
|
14
|
+
to the service environment. A client's environment does not configure a remote daemon.
|
|
15
|
+
|
|
16
|
+
## Scope
|
|
17
|
+
|
|
18
|
+
| Form | Meaning |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `PLURNK_MODEL=provider/model` | Select a route without an alias. |
|
|
21
|
+
| `PLURNK_MODEL_<alias>=provider/model` | Declare a named route and tuning scope. |
|
|
22
|
+
| `PLURNK_BASEURL_<alias>=https://endpoint/v1` | Override that declared alias's endpoint. |
|
|
23
|
+
| `PLURNK_PROVIDERS_<KNOB>_<alias>=value` | Nonempty per-alias override of the bare knob; alias case is ignored. |
|
|
24
|
+
| `PLURNK_PROVIDERS_PROVIDER_<NAME>_*` | Provider-wide declaration, not per-alias tuning. |
|
|
25
|
+
|
|
26
|
+
The [defaults reference](../.env.defaults) lists supported knobs and values.
|
|
27
|
+
Worker model/reasoning selections persist: changing a startup default is not a
|
|
28
|
+
command to retarget existing Workers. Clients expose explicit model, reasoning,
|
|
29
|
+
and child-model controls; their available effort choices come from the selected route.
|
|
30
|
+
|
|
31
|
+
## Capacity and reasoning
|
|
32
|
+
|
|
33
|
+
```mermaid
|
|
34
|
+
flowchart TD
|
|
35
|
+
Limits[Model context and input/output limits] --> Input[Available input capacity]
|
|
36
|
+
Output[Total output envelope, including reasoning] --> Input
|
|
37
|
+
Input --> Context[Core's calibrated context allowance]
|
|
38
|
+
Output --> Reasoning[Optional smaller reasoning budget]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- **Effort** and **budget** are independent. `adaptive` requests native dynamic
|
|
42
|
+
reasoning where supported, otherwise the supported high posture. Fixed effort
|
|
43
|
+
names must be supported by the route; an unsupported request is not silently downgraded.
|
|
44
|
+
- **Output** accepts positive tokens or a percentage of context; known model
|
|
45
|
+
limits cap it. An explicit reasoning budget must be smaller than total output.
|
|
46
|
+
Leaving the reasoning budget unset does not disable reasoning.
|
|
47
|
+
- **Context** is derived from the live endpoint or catalog. An operator cap can
|
|
48
|
+
shrink known capacity or declare unknown capacity, never enlarge known limits.
|
|
49
|
+
Prompt projection and initialization reasoning READ limits are separate Core policy.
|
|
50
|
+
|
|
51
|
+
## Local OpenAI-compatible endpoints
|
|
52
|
+
|
|
53
|
+
```dotenv
|
|
54
|
+
PLURNK_MODEL_local=openai/model-name-from-endpoint
|
|
55
|
+
PLURNK_BASEURL_local=http://127.0.0.1:8080/v1
|
|
56
|
+
PLURNK_MODEL=local
|
|
57
|
+
# Optional: your own GBNF grammar, carried verbatim to this llama-server route:
|
|
58
|
+
# PLURNK_PROVIDERS_GBNF_local=~/.config/plurnk/local.gbnf
|
|
59
|
+
# Optional explicit generation allowances, in tokens:
|
|
60
|
+
# PLURNK_PROVIDERS_OUTPUT_BUDGET_local=8192
|
|
61
|
+
# PLURNK_PROVIDERS_REASONING_BUDGET_local=4096
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Endpoint probing supplies served-model capacity and llama-server capabilities.
|
|
65
|
+
Pin `PLURNK_PROVIDERS_LLAMA_SERVER_local=1` only for a known llama-server that
|
|
66
|
+
cannot be fingerprinted reliably. GBNF is optional and yours: the service ships
|
|
67
|
+
no grammar profile and never validates or grades one; the file's text reaches
|
|
68
|
+
the route verbatim, and configuring it on a route without grammar transport is
|
|
69
|
+
an error. Parsing always applies.
|
|
70
|
+
|
|
71
|
+
Sampling stays at endpoint defaults unless configured. Repetition penalties can
|
|
72
|
+
fight exact source copying and grammar repetition. Measure local tuning; it is
|
|
73
|
+
not a portable cloud policy. `think-tags` response interpretation is a separate
|
|
74
|
+
opt-in for endpoints that emit a leading `<think>` envelope, not a reasoning switch.
|
|
75
|
+
|
|
76
|
+
## Connectivity, caching, and cost
|
|
77
|
+
|
|
78
|
+
| Concern | Boundary |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| Attempt, first-content, idle deadlines | Provider transport; a timeout surfaces a failure, not a fabricated response. |
|
|
81
|
+
| Provider-directed waits | Bounded retries honor `Retry-After`; other recoverable failures return to Core. |
|
|
82
|
+
| Loop recovery | Core reissues within its recovery window, then parks for a prompt or wake. |
|
|
83
|
+
| Cache affinity | Stable Worker identity through documented provider controls; not a cache-hit guarantee. |
|
|
84
|
+
| Explicit cache writes | Separate policy on supported routes; can affect billing. |
|
|
85
|
+
| Service tier | Explicit route choice that may change price and availability. |
|
|
86
|
+
| Cost | Provider monetary evidence wins; known usage and catalog rates yield an estimate, not a settled charge. |
|
|
87
|
+
|
|
88
|
+
For uncataloged compatible endpoints, declare the SDK, URL, and credential-variable
|
|
89
|
+
name together; the examples are in the defaults reference. A provider plugin is
|
|
90
|
+
needed only when the protocol cannot be expressed by the installed adapters.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plurnk/plurnk-providers",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "PLURNK's stable model-provider contract and AI SDK adapter.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"plurnk",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"dist/**/*",
|
|
55
55
|
"README.md",
|
|
56
56
|
"SPEC.md",
|
|
57
|
+
"docs/**/*.md",
|
|
57
58
|
".env.defaults"
|
|
58
59
|
],
|
|
59
60
|
"scripts": {
|
|
@@ -84,11 +85,10 @@
|
|
|
84
85
|
"@ai-sdk/togetherai": "^3.0.0",
|
|
85
86
|
"@ai-sdk/xai": "^4.0.0",
|
|
86
87
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
87
|
-
"@plurnk/
|
|
88
|
-
"@plurnk/plurnk-
|
|
89
|
-
"@plurnk/plurnk-
|
|
90
|
-
"@plurnk/plurnk-
|
|
91
|
-
"@plurnk/plurnk-models": "1.16.5",
|
|
88
|
+
"@plurnk/plurnk-aliases": "1.18.0",
|
|
89
|
+
"@plurnk/plurnk-contracts": "1.18.0",
|
|
90
|
+
"@plurnk/plurnk-meta": "1.18.0",
|
|
91
|
+
"@plurnk/plurnk-models": "1.18.0",
|
|
92
92
|
"ai": "^7.0.37",
|
|
93
93
|
"zod": "^4.4.3"
|
|
94
94
|
},
|
|
@@ -1061,6 +1061,45 @@ test("generate aggregates reasoning deltas under multiple field names", async ()
|
|
|
1061
1061
|
assert.equal("reasoningEncrypted" in assistant, false); // open reasoning only -> field absent
|
|
1062
1062
|
});
|
|
1063
1063
|
|
|
1064
|
+
for (const style of ["think-tags", "template-think", "template-channel"] as const) test(`{§provider-reasoning-observer} ${style} reasoning arrives while the response is still open`, async () => {
|
|
1065
|
+
const template = style !== "think-tags";
|
|
1066
|
+
const opening = style === "template-channel" ? "<|channel>thought\n" : template ? "<think>\n" : "<think>";
|
|
1067
|
+
const closing = style === "template-channel" ? "<channel|>" : "</think>";
|
|
1068
|
+
const observed: string[] = [];
|
|
1069
|
+
const ready = Promise.withResolvers<void>();
|
|
1070
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
1071
|
+
const send = (text: string, finish: string | null = null): void => controller.enqueue(new TextEncoder().encode(
|
|
1072
|
+
`data: ${JSON.stringify({ id: "paced", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta: { content: text }, finish_reason: finish }] })}\n\n`,
|
|
1073
|
+
));
|
|
1074
|
+
const fetch: typeof globalThis.fetch = async () => new Response(new ReadableStream<Uint8Array>({ start(stream) {
|
|
1075
|
+
controller = stream;
|
|
1076
|
+
send(opening.slice(0, 3));
|
|
1077
|
+
send(`${opening.slice(3)}Thinking 🧠${closing.slice(0, 3)}`);
|
|
1078
|
+
} }), { headers: { "Content-Type": "text/event-stream" } });
|
|
1079
|
+
const provider = testProvider({ ...injectedBase, fetch, rawBody: true, ...(template
|
|
1080
|
+
? { reasoningStyle: "template" as const, grammarStyle: "llamacpp" as const, reasoning: { mode: "adaptive" as const, budget: null } }
|
|
1081
|
+
: { reasoningResponseStyle: "think-tags" as const }) });
|
|
1082
|
+
const result = provider.generate({ workerId: "paced", messages: [], ...(template ? { grammar: 'root ::= "x"' } : {}), observeReasoning(delta) {
|
|
1083
|
+
observed.push(delta);
|
|
1084
|
+
if (observed.join("") === "Thinking 🧠") ready.resolve();
|
|
1085
|
+
} });
|
|
1086
|
+
const timer = setTimeout(() => ready.reject(new Error("reasoning was buffered until response completion")), 1500);
|
|
1087
|
+
try {
|
|
1088
|
+
await ready.promise;
|
|
1089
|
+
assert.deepEqual(observed, ["Thinking 🧠"], "an incomplete closing delimiter is not reasoning text");
|
|
1090
|
+
} finally {
|
|
1091
|
+
clearTimeout(timer);
|
|
1092
|
+
send(`${closing.slice(3)}Answer. <think>literal suffix</think>`, "stop");
|
|
1093
|
+
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
|
|
1094
|
+
controller.close();
|
|
1095
|
+
const response = await result;
|
|
1096
|
+
assert.equal(response.assistant.reasoning, "Thinking 🧠");
|
|
1097
|
+
assert.equal(response.assistant.content, "Answer. <think>literal suffix</think>");
|
|
1098
|
+
assert.equal(observed.join(""), "Thinking 🧠", "completion does not replay streamed reasoning");
|
|
1099
|
+
if (template) assert.equal(response.grammarEvidence?.input, `${opening}Thinking 🧠${closing}Answer. <think>literal suffix</think>`);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1064
1103
|
test("{§provider-tagged-reasoning} explicit think-tags project content without estimating token attribution", async () => {
|
|
1065
1104
|
const config = { ...injectedBase, reasoningResponseStyle: "think-tags" as const, rawBody: true };
|
|
1066
1105
|
const p = testProvider(config);
|
|
@@ -1089,6 +1128,17 @@ test("{§provider-tagged-reasoning} explicit think-tags project content without
|
|
|
1089
1128
|
assert.match(JSON.stringify(response.rawBody), /nk>12345/);
|
|
1090
1129
|
});
|
|
1091
1130
|
|
|
1131
|
+
test("{§provider-tagged-reasoning} structured streamed reasoning keeps tagged content literal", async () => {
|
|
1132
|
+
installFetch([{ choices: [{ delta: { content: "<think>literal</think>", reasoning_content: "Native thought." }, finish_reason: "stop" }] }]);
|
|
1133
|
+
const observed: string[] = [];
|
|
1134
|
+
const response = await testProvider({ ...injectedBase, reasoningResponseStyle: "think-tags" }).generate({
|
|
1135
|
+
workerId: "native-control", messages: [], observeReasoning: (delta) => observed.push(delta),
|
|
1136
|
+
});
|
|
1137
|
+
assert.equal(response.assistant.reasoning, "Native thought.");
|
|
1138
|
+
assert.equal(response.assistant.content, "<think>literal</think>");
|
|
1139
|
+
assert.deepEqual(observed, ["Native thought."]);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1092
1142
|
test("{§provider-tagged-reasoning} explicit think-tags projects one buffered leading envelope", async () => {
|
|
1093
1143
|
installFetchJson({
|
|
1094
1144
|
model: "m",
|
|
@@ -1201,7 +1251,7 @@ test("{§provider-tagged-reasoning} verbatim, non-leading, and structured-reason
|
|
|
1201
1251
|
});
|
|
1202
1252
|
|
|
1203
1253
|
test("{§provider-tagged-reasoning} grammar evidence retains the exact pre-projection tagged sentence", async () => {
|
|
1204
|
-
const content = "<think>🧠reason</think
|
|
1254
|
+
const content = "<think>🧠reason</think>```SEND\ndone\n```\n```TASK\n[{\"content\":\"Task completed.\",\"status\":\"completed\"}]\n```";
|
|
1205
1255
|
const config = {
|
|
1206
1256
|
...injectedBase,
|
|
1207
1257
|
contextWindow: 640,
|
|
@@ -1219,7 +1269,7 @@ test("{§provider-tagged-reasoning} grammar evidence retains the exact pre-proje
|
|
|
1219
1269
|
});
|
|
1220
1270
|
|
|
1221
1271
|
assert.equal(response.assistant.reasoning, "🧠reason");
|
|
1222
|
-
assert.equal(response.assistant.content, "
|
|
1272
|
+
assert.equal(response.assistant.content, "```SEND\ndone\n```\n```TASK\n[{\"content\":\"Task completed.\",\"status\":\"completed\"}]\n```");
|
|
1223
1273
|
assert.deepEqual(response.grammarEvidence, {
|
|
1224
1274
|
input: content,
|
|
1225
1275
|
contentStart: [..."<think>🧠reason</think>"].length,
|
|
@@ -1516,7 +1566,7 @@ test("sampling passthrough guards contract invariants: n/tools/caps stripped, pl
|
|
|
1516
1566
|
assert.equal(body.service_tier, "flex");
|
|
1517
1567
|
});
|
|
1518
1568
|
|
|
1519
|
-
test("template reasoning returns the exact pre-projection grammar sentence ({§
|
|
1569
|
+
test("template reasoning returns the exact pre-projection grammar sentence ({§provider-grammar-evidence})", async () => {
|
|
1520
1570
|
const p = testProvider({ model: "m", url: "http://x/v1/chat/completions", contextWindow: 640, outputBudget: 224, reasoningBudget: 64, fetchTimeoutMs: 5000, temperature: 0.2, repeatPenalty: 1.15, reasoning: { mode: "adaptive", budget: 64 }, retryAttempts: 0, reasoningStyle: "template", grammarStyle: "llamacpp" });
|
|
1521
1571
|
const grammarInput = "<|channel>thought\ncon🙂sider<channel|>x";
|
|
1522
1572
|
const calls = installFetch([{ choices: [{ delta: { content: grammarInput } }] }]);
|
|
@@ -1724,7 +1774,7 @@ test("grammar transport 'none' (default): the grammar is never sent — no silen
|
|
|
1724
1774
|
assert.equal("response_format" in body, false);
|
|
1725
1775
|
});
|
|
1726
1776
|
|
|
1727
|
-
// — exact pre-projection grammar evidence ({§
|
|
1777
|
+
// — exact pre-projection grammar evidence ({§provider-grammar-evidence}) —
|
|
1728
1778
|
|
|
1729
1779
|
const grammarProvider = () => testProvider({ model: "m", url: "http://x/v1/chat/completions", fetchTimeoutMs: 5000, temperature: 0.2, repeatPenalty: 1.15, reasoning: { mode: "off", budget: null }, retryAttempts: 0, grammarStyle: "llamacpp", source: "provider:test" });
|
|
1730
1780
|
const streamingContent = (content: string) => installFetch([{ choices: [{ delta: { content }, finish_reason: "stop" }] }]);
|
|
@@ -1776,39 +1826,24 @@ test("provider evidence does not depend on the local validator understanding the
|
|
|
1776
1826
|
assert.equal(res.notices, undefined);
|
|
1777
1827
|
});
|
|
1778
1828
|
|
|
1779
|
-
// —
|
|
1780
|
-
|
|
1781
|
-
test("
|
|
1782
|
-
const
|
|
1783
|
-
const
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
});
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
assert.equal(res.assistant.content, "xon-conforming output");
|
|
1798
|
-
assert.deepEqual(res.grammarEvidence, { input: "xon-conforming output", contentStart: 0, transported: false });
|
|
1799
|
-
assert.equal(res.notices, undefined);
|
|
1800
|
-
const body = JSON.parse(calls[0].init.body as string);
|
|
1801
|
-
assert.equal("grammar" in body, false);
|
|
1802
|
-
});
|
|
1803
|
-
|
|
1804
|
-
test("gbnfDebug: an INVALID grammar throws before any wire call — it never reaches the model", async () => {
|
|
1805
|
-
const p = testProvider({ model: "m", url: "http://x/v1/chat/completions", fetchTimeoutMs: 5000, temperature: 0.2, repeatPenalty: 1.15, reasoning: { mode: "off", budget: null }, retryAttempts: 0, grammarStyle: "llamacpp", gbnfDebug: true });
|
|
1806
|
-
const calls = installFetch([{ choices: [{ delta: { content: "x" } }] }]);
|
|
1807
|
-
await assert.rejects(
|
|
1808
|
-
() => p.generate({ workerId: "r", messages: [], grammar: 'foo ::= "a"' }), // no `root` rule → invalid GBNF
|
|
1809
|
-
/grammar validation \(PLURNK_PROVIDERS_GBNF_DEBUG\): invalid GBNF/,
|
|
1810
|
-
);
|
|
1811
|
-
assert.equal(calls.length, 0); // fail-hard before the fetch — grammar never transported
|
|
1829
|
+
// — {§provider-grammar-transport}: the operator's grammar rides a llama-style route verbatim, never another —
|
|
1830
|
+
|
|
1831
|
+
test("an operator grammar reaches the request body verbatim under llama style and is absent under every other style", async () => {
|
|
1832
|
+
const grammar = 'root ::= "ok" | "fine"\n# an operator-written rail\n';
|
|
1833
|
+
const styles = ["none", "llamacpp"] as const;
|
|
1834
|
+
for (const grammarStyle of styles) {
|
|
1835
|
+
const calls = installFetchJson({ ...jsonChoice, choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] });
|
|
1836
|
+
const p = testProvider({ model: "m", url: "http://x/v1/chat/completions", fetchTimeoutMs: 5000, temperature: 0.2, repeatPenalty: 1.15, reasoning: { mode: "off", budget: null }, retryAttempts: 0, streaming: false, grammarStyle });
|
|
1837
|
+
const res = await p.generate({ workerId: "r", messages: [], grammar });
|
|
1838
|
+
const body = JSON.parse(String(calls[0]!.init.body)) as { grammar?: unknown };
|
|
1839
|
+
if (grammarStyle === "llamacpp") {
|
|
1840
|
+
assert.equal(body.grammar, grammar, "the file's text, byte for byte");
|
|
1841
|
+
assert.deepEqual(res.grammarEvidence, { input: "ok", contentStart: 0, transported: true });
|
|
1842
|
+
} else {
|
|
1843
|
+
assert.equal("grammar" in body, false, "no grammar field on a route without transport");
|
|
1844
|
+
assert.equal(res.grammarEvidence, undefined);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1812
1847
|
});
|
|
1813
1848
|
|
|
1814
1849
|
// — meta bag: verbatim provider metadata —
|
|
@@ -2020,7 +2055,7 @@ test("generate fail-hards on a missing or empty workerId", async () => {
|
|
|
2020
2055
|
await assert.rejects(() => (p.generate as (a: object) => Promise<unknown>)({ messages: [] }), /workerId is required/);
|
|
2021
2056
|
});
|
|
2022
2057
|
|
|
2023
|
-
test("messages pass through verbatim — the provider injects no turn (
|
|
2058
|
+
test("messages pass through verbatim — the provider injects no turn (turn structure belongs to the grammar, never provider prefill)", async () => {
|
|
2024
2059
|
const p = testProvider({ model: "m", url: "http://x/v1/chat/completions", fetchTimeoutMs: 5000, temperature: 0.2, repeatPenalty: 1.15, reasoning: { mode: "off", budget: null }, retryAttempts: 0 });
|
|
2025
2060
|
const calls = installFetch([{ choices: [{ delta: { content: "out" } }] }]);
|
|
2026
2061
|
const input = [{ role: "user" as const, content: "hi" }];
|
|
@@ -2927,3 +2962,29 @@ test("a manual-reasoning model rejects an envelope below its provider minimum be
|
|
|
2927
2962
|
);
|
|
2928
2963
|
assert.equal(calls, 0);
|
|
2929
2964
|
});
|
|
2965
|
+
|
|
2966
|
+
// {§provider-usage-refusal} (#580) — through the provider: the response is delivered, its
|
|
2967
|
+
// accounting is an honest unknown, and the refused counters are durable evidence, not a 503.
|
|
2968
|
+
test("a response whose usage counters contradict each other is delivered with unknown cost, never retried as a network failure", async () => {
|
|
2969
|
+
const usage = { prompt_tokens: 1, completion_tokens: 5, total_tokens: 6, completion_tokens_details: { reasoning_tokens: 7 } };
|
|
2970
|
+
installFetchJson({
|
|
2971
|
+
id: "response-1", object: "chat.completion", created: 1, model: "served-model",
|
|
2972
|
+
choices: [{ index: 0, message: { role: "assistant", content: "the answer" }, finish_reason: "stop" }],
|
|
2973
|
+
usage,
|
|
2974
|
+
});
|
|
2975
|
+
const p = testProvider({
|
|
2976
|
+
model: "m", url: "http://x/v1/chat/completions", fetchTimeoutMs: 1000, temperature: null, repeatPenalty: null, reasoning: { mode: "off", budget: null }, retryAttempts: 0,
|
|
2977
|
+
streaming: false,
|
|
2978
|
+
estimateCost: (known) => known === undefined
|
|
2979
|
+
? { kind: "unknown", reason: "the provider response reported no normalized usage" }
|
|
2980
|
+
: { kind: "estimated", amount: { amount: "1", currency: "USD" }, source: "test estimator" },
|
|
2981
|
+
});
|
|
2982
|
+
const response = await p.generate({ workerId: "accounted", messages: [{ role: "user", content: "q" }] });
|
|
2983
|
+
assert.equal(response.assistant.content, "the answer");
|
|
2984
|
+
assert.equal(response.accounting.length, 1);
|
|
2985
|
+
assert.equal(response.accounting[0]?.outcome, "response");
|
|
2986
|
+
assert.equal(response.accounting[0]?.usage, undefined, "no counters are reported as known");
|
|
2987
|
+
assert.deepEqual(response.accounting[0]?.cost, { kind: "unknown", reason: "the provider response reported no normalized usage" });
|
|
2988
|
+
const raw = response.assistantRaw as { usageRefusal?: { reason: string; usage: unknown } };
|
|
2989
|
+
assert.deepEqual(raw.usageRefusal, { reason: "provider usage.outputTokenDetails.textTokens must be a non-negative safe integer", usage }, "the durable response carries the refused counters");
|
|
2990
|
+
});
|
package/src/AiSdkProvider.ts
CHANGED
|
@@ -27,12 +27,12 @@ import { validateProviderUsage } from "./usage.ts";
|
|
|
27
27
|
import { assessRequestCapacity, effectiveInputCapacity, effectiveOutputBudget, effectiveReasoningBudget } from "./capacity.ts";
|
|
28
28
|
import { nativeFixedEffort } from "./reasoning-effort.ts";
|
|
29
29
|
import AiSdkRequestBody from "./AiSdkRequestBody.ts";
|
|
30
|
+
import LeadingReasoning from "./LeadingReasoning.ts";
|
|
30
31
|
|
|
31
32
|
export type ProviderFetch = typeof globalThis.fetch;
|
|
32
33
|
|
|
33
34
|
// {§provider-connectivity} — an AbortSignal is advisory: a wedged transport that never observes it can
|
|
34
|
-
// hang the await past the deadline (#505).
|
|
35
|
-
// bounds a wedged adapter (#463): once the signal fires, a well-behaved transport unwinds and settles its
|
|
35
|
+
// hang the await past the deadline (#505). Once the signal fires, a well-behaved transport unwinds and settles its
|
|
36
36
|
// own attempt (and accounting) within a short grace, and that path wins the race untouched; only a
|
|
37
37
|
// transport still wedged after the grace is force-rejected with the signal's own reason, so the existing
|
|
38
38
|
// operation/cancellation classification is unchanged. The grace is unwind slack, not a second deadline.
|
|
@@ -125,7 +125,6 @@ export type AiSdkProviderConfig = {
|
|
|
125
125
|
// Optional provider-configured service tier. Unlike caller sampling, this is
|
|
126
126
|
// a fixed deployment choice and therefore wins on every request.
|
|
127
127
|
serviceTier?: string;
|
|
128
|
-
gbnfDebug?: boolean; // PLURNK_PROVIDERS_GBNF_DEBUG: validate the grammar locally + throw on invalid, but DON'T transport it (run unconstrained); default false
|
|
129
128
|
streaming?: boolean; // SSE transport (default true); false → one non-streamed JSON
|
|
130
129
|
firstPartyMetadata?: boolean; // forward per-turn attributions + client as Plurnk-* headers (plurnk only); default false
|
|
131
130
|
apiKeyRejectedMessage?: string; // friendly hint when a present key is 401/403-rejected (distinct from unset); default undefined
|
|
@@ -239,66 +238,6 @@ const stripTrailingSpecial = (content: string, marker: string): string => {
|
|
|
239
238
|
return out;
|
|
240
239
|
};
|
|
241
240
|
|
|
242
|
-
type TaggedReasoningProjection = {
|
|
243
|
-
readonly content: string;
|
|
244
|
-
readonly reasoning: string;
|
|
245
|
-
readonly projected: boolean;
|
|
246
|
-
readonly contentStart: number;
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const projectLeadingReasoning = (
|
|
250
|
-
content: string,
|
|
251
|
-
structuredReasoning: string,
|
|
252
|
-
opening: string,
|
|
253
|
-
closing: string,
|
|
254
|
-
): TaggedReasoningProjection => {
|
|
255
|
-
if (structuredReasoning.length > 0 || !content.startsWith(opening)) {
|
|
256
|
-
return { content, reasoning: structuredReasoning, projected: false, contentStart: 0 };
|
|
257
|
-
}
|
|
258
|
-
const closingIndex = content.indexOf(closing, opening.length);
|
|
259
|
-
if (closingIndex === -1) {
|
|
260
|
-
return {
|
|
261
|
-
content: "",
|
|
262
|
-
reasoning: content.slice(opening.length),
|
|
263
|
-
projected: true,
|
|
264
|
-
contentStart: [...content].length,
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
const suffixStart = closingIndex + closing.length;
|
|
268
|
-
return {
|
|
269
|
-
content: content.slice(suffixStart),
|
|
270
|
-
reasoning: content.slice(opening.length, closingIndex),
|
|
271
|
-
projected: true,
|
|
272
|
-
contentStart: [...content.slice(0, suffixStart)].length,
|
|
273
|
-
};
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
// {§provider-tagged-reasoning} Only the model-contract position is structural:
|
|
277
|
-
// one exact leading envelope. Parsing after stream assembly keeps SSE and JSON
|
|
278
|
-
// on one path and leaves later literal tags in the visible suffix untouched.
|
|
279
|
-
const projectTaggedReasoning = (
|
|
280
|
-
content: string,
|
|
281
|
-
structuredReasoning: string,
|
|
282
|
-
style: ReasoningResponseStyle,
|
|
283
|
-
): TaggedReasoningProjection => style === "think-tags"
|
|
284
|
-
? projectLeadingReasoning(content, structuredReasoning, "<think>", "</think>")
|
|
285
|
-
: { content, reasoning: structuredReasoning, projected: false, contentStart: 0 };
|
|
286
|
-
|
|
287
|
-
// llama-server's template reasoning parser can project either supported leading
|
|
288
|
-
// reasoning envelope out of the OpenAI-compatible response. Grammar evidence
|
|
289
|
-
// needs the sentence before that lossy projection, so constrained template turns
|
|
290
|
-
// request it verbatim and split the observed enclosure here.
|
|
291
|
-
const projectTemplateReasoning = (content: string): TaggedReasoningProjection => {
|
|
292
|
-
for (const [opening, closing] of [
|
|
293
|
-
["<|channel>thought\n", "<channel|>"],
|
|
294
|
-
["<think>\n", "</think>"],
|
|
295
|
-
] as const) {
|
|
296
|
-
if (content.startsWith(opening)) return projectLeadingReasoning(content, "", opening, closing);
|
|
297
|
-
}
|
|
298
|
-
return { content, reasoning: "", projected: false, contentStart: 0 };
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
|
|
302
241
|
const providerWarningMessage = (warning: CallWarning): string => {
|
|
303
242
|
switch (warning.type) {
|
|
304
243
|
case "unsupported":
|
|
@@ -356,7 +295,6 @@ export default class AiSdkProvider implements Provider {
|
|
|
356
295
|
#systemCacheProviderOptions: AiSdkProviderOptions | undefined;
|
|
357
296
|
#reasoningResponseProviderOptions: AiSdkProviderOptions | undefined;
|
|
358
297
|
#serviceTier: string | undefined;
|
|
359
|
-
#gbnfDebug: boolean;
|
|
360
298
|
#streaming: boolean;
|
|
361
299
|
#firstPartyMetadata: boolean;
|
|
362
300
|
#supportsSlotPinning: boolean;
|
|
@@ -479,7 +417,6 @@ export default class AiSdkProvider implements Provider {
|
|
|
479
417
|
throw new Error(`${this.#source}: reasoning response options conflict with cache-affinity option ${this.#cacheAffinity.provider}.${this.#cacheAffinity.name}`);
|
|
480
418
|
}
|
|
481
419
|
this.#serviceTier = config.serviceTier;
|
|
482
|
-
this.#gbnfDebug = config.gbnfDebug ?? false;
|
|
483
420
|
this.#streaming = config.streaming ?? true;
|
|
484
421
|
this.#firstPartyMetadata = config.firstPartyMetadata ?? false;
|
|
485
422
|
this.#apiKeyRejectedMessage = config.apiKeyRejectedMessage;
|
|
@@ -686,11 +623,10 @@ export default class AiSdkProvider implements Provider {
|
|
|
686
623
|
// ({§provider-failure-normalization}).
|
|
687
624
|
signal?.throwIfAborted();
|
|
688
625
|
|
|
689
|
-
// Grammar
|
|
690
|
-
//
|
|
626
|
+
// Grammar transport ({§provider-grammar-transport}): an operator's grammar rides a
|
|
627
|
+
// llama-style route verbatim and is never sent on any other style.
|
|
691
628
|
const wantGrammar = grammar !== undefined && this.#grammarStyle !== "none";
|
|
692
|
-
|
|
693
|
-
const sendGrammar = wantGrammar && !this.#gbnfDebug ? grammar : undefined;
|
|
629
|
+
const sendGrammar = wantGrammar ? grammar : undefined;
|
|
694
630
|
const preserveGrammarSentence = wantGrammar
|
|
695
631
|
&& this.#reasoningStyle === "template";
|
|
696
632
|
|
|
@@ -767,12 +703,25 @@ export default class AiSdkProvider implements Provider {
|
|
|
767
703
|
let recoveredAfterOutput = false;
|
|
768
704
|
const executeRequest = async () => {
|
|
769
705
|
let requestReasoningStream = "";
|
|
706
|
+
let structuredReasoning = false;
|
|
707
|
+
const envelopes = preserveGrammarSentence ? LeadingReasoning.TEMPLATE
|
|
708
|
+
: this.#reasoningResponseStyle === "think-tags" ? LeadingReasoning.THINK : [];
|
|
709
|
+
const liveProjection = emitReasoning !== undefined && envelopes.length > 0 ? new LeadingReasoning(envelopes) : undefined;
|
|
770
710
|
const observeRequestReasoning = emitReasoning === undefined
|
|
771
711
|
? undefined
|
|
772
712
|
: (delta: string): void => {
|
|
773
713
|
requestReasoningStream += delta;
|
|
774
714
|
emitReasoning(delta);
|
|
775
715
|
};
|
|
716
|
+
const observers = {
|
|
717
|
+
...(observeRequestReasoning === undefined ? {} : { observeReasoning: (delta: string): void => {
|
|
718
|
+
structuredReasoning = true;
|
|
719
|
+
observeRequestReasoning(delta);
|
|
720
|
+
} }),
|
|
721
|
+
...(liveProjection === undefined ? {} : { observeText: (delta: string): void => {
|
|
722
|
+
if (!structuredReasoning) observeRequestReasoning!(liveProjection.push(delta));
|
|
723
|
+
} }),
|
|
724
|
+
};
|
|
776
725
|
let settle: ProviderRequestSettlement | undefined;
|
|
777
726
|
try {
|
|
778
727
|
settle = await observeRequest?.({
|
|
@@ -839,7 +788,7 @@ export default class AiSdkProvider implements Provider {
|
|
|
839
788
|
streamIdleTimeoutMs: this.#streamIdleTimeoutMs,
|
|
840
789
|
streaming: this.#streaming,
|
|
841
790
|
captureRawBody: this.#rawBody,
|
|
842
|
-
...
|
|
791
|
+
...observers,
|
|
843
792
|
})
|
|
844
793
|
: await executeAiSdkModel({
|
|
845
794
|
languageModel: this.#languageModel,
|
|
@@ -853,7 +802,7 @@ export default class AiSdkProvider implements Provider {
|
|
|
853
802
|
streamIdleTimeoutMs: this.#streamIdleTimeoutMs,
|
|
854
803
|
streaming: this.#streaming,
|
|
855
804
|
captureRawBody: this.#rawBody,
|
|
856
|
-
...
|
|
805
|
+
...observers,
|
|
857
806
|
temperature: this.#tuningFloors
|
|
858
807
|
? (typeof sampling?.temperature === "number" ? sampling.temperature : this.#temperature ?? undefined)
|
|
859
808
|
: typeof sampling?.temperature === "number" ? sampling.temperature : undefined,
|
|
@@ -887,6 +836,7 @@ export default class AiSdkProvider implements Provider {
|
|
|
887
836
|
);
|
|
888
837
|
throw error;
|
|
889
838
|
}
|
|
839
|
+
if (!structuredReasoning && liveProjection !== undefined) observeRequestReasoning!(liveProjection.finish());
|
|
890
840
|
successfulReasoningStream = requestReasoningStream;
|
|
891
841
|
await settleAccounting(
|
|
892
842
|
"response",
|
|
@@ -950,13 +900,9 @@ export default class AiSdkProvider implements Provider {
|
|
|
950
900
|
if (this.#eosText !== undefined) raw.content = stripTrailingSpecial(raw.content, this.#eosText);
|
|
951
901
|
|
|
952
902
|
const grammarInput = raw.content;
|
|
953
|
-
const projectedReasoning =
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
raw.content,
|
|
957
|
-
raw.reasoning,
|
|
958
|
-
this.#reasoningResponseStyle,
|
|
959
|
-
);
|
|
903
|
+
const projectedReasoning = LeadingReasoning.project(raw.content, raw.reasoning,
|
|
904
|
+
preserveGrammarSentence && !raw.reasoningProjected ? LeadingReasoning.TEMPLATE
|
|
905
|
+
: this.#reasoningResponseStyle === "think-tags" ? LeadingReasoning.THINK : []);
|
|
960
906
|
|
|
961
907
|
// Preserve the exact pre-projection response. Constrained template turns
|
|
962
908
|
// request `reasoning_format: "none"`, so even an empty channel and any
|
package/src/AiSdkRequestBody.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import type { ProviderCallKind, ReasoningPolicy } from "./types.ts";
|
|
3
3
|
import type { JSONValue } from "ai";
|
|
4
4
|
import { type Reasoning } from "./env.ts";
|
|
5
|
-
import { validateGbnf } from "@plurnk/gbnf";
|
|
6
5
|
import { fixedEffort } from "./reasoning-effort.ts";
|
|
7
6
|
import type { ReasoningStyle, CompatibleReasoningEffort, GrammarStyle, CacheAffinity, AiSdkProviderOptions } from "./AiSdkProvider.ts";
|
|
8
7
|
|
|
@@ -224,7 +223,7 @@ export default class AiSdkRequestBody {
|
|
|
224
223
|
// bookkeeping so a long-lived daemon never grows the map unboundedly —
|
|
225
224
|
// an evicted-and-returning run simply re-pins, worst case one cold prefill.
|
|
226
225
|
|
|
227
|
-
// Optional local llama-server GBNF transport ({§
|
|
226
|
+
// Optional local llama-server GBNF transport ({§provider-grammar-transport}). Unsupported
|
|
228
227
|
// backends receive no grammar-related field.
|
|
229
228
|
grammarBody(grammar: string | undefined): Record<string, unknown> {
|
|
230
229
|
if (grammar === undefined) return {};
|
|
@@ -401,19 +400,5 @@ export default class AiSdkRequestBody {
|
|
|
401
400
|
}
|
|
402
401
|
|
|
403
402
|
|
|
404
|
-
// PLURNK_PROVIDERS_GBNF_DEBUG ({§gbnf-response-observation}): validate the supplied GBNF locally and fail
|
|
405
|
-
// hard if it's malformed, BEFORE any wire call — and the grammar is NOT
|
|
406
|
-
// transported, so the request runs unconstrained. A debug aid to catch invalid
|
|
407
|
-
// grammars (e.g. while editing the plurnk grammar) without a model round-trip;
|
|
408
|
-
// off in production. `validateGbnf(grammar, "")` parses the grammar + resolves
|
|
409
|
-
// its root, throwing iff the grammar itself is invalid (the empty input's
|
|
410
|
-
// verdict is irrelevant — we only care that parsing succeeded).
|
|
411
|
-
assertGrammarValid(grammar: string): void {
|
|
412
|
-
try {
|
|
413
|
-
validateGbnf(grammar, "");
|
|
414
|
-
} catch (cause) {
|
|
415
|
-
throw new Error(`grammar validation (PLURNK_PROVIDERS_GBNF_DEBUG): invalid GBNF — ${(cause as Error).message}`, { cause });
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
403
|
|
|
419
404
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import LeadingReasoning from "./LeadingReasoning.ts";
|
|
4
|
+
|
|
5
|
+
test("{§provider-tagged-reasoning}: every delimiter split preserves exact reasoning and the suffix", () => {
|
|
6
|
+
for (const envelopes of [LeadingReasoning.THINK, LeadingReasoning.TEMPLATE]) {
|
|
7
|
+
for (const [opening, closing] of envelopes) {
|
|
8
|
+
const thought = "Decision 🧠\nA < B.\n";
|
|
9
|
+
const suffix = "Answer with literal <think>tags</think>.";
|
|
10
|
+
for (const ended of [true, false]) {
|
|
11
|
+
const input = `${opening}${thought}${ended ? closing + suffix : closing.slice(0, 3)}`;
|
|
12
|
+
const expected = thought + (ended ? "" : closing.slice(0, 3));
|
|
13
|
+
for (let split = 0; split <= input.length; split++) {
|
|
14
|
+
const parser = new LeadingReasoning(envelopes);
|
|
15
|
+
const observed = parser.push(input.slice(0, split)) + parser.push(input.slice(split)) + parser.finish();
|
|
16
|
+
assert.equal(observed, expected, `${opening}: split ${split}`);
|
|
17
|
+
}
|
|
18
|
+
const settled = LeadingReasoning.project(input, "", envelopes);
|
|
19
|
+
assert.equal(settled.reasoning, expected);
|
|
20
|
+
assert.equal(settled.content, ended ? suffix : "");
|
|
21
|
+
assert.equal(settled.contentStart, [...input].length - [...settled.content].length);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("{§provider-tagged-reasoning}: an incomplete opener, later tags, and structured reasoning stay literal", () => {
|
|
28
|
+
for (const input of ["<thi", " <think>text</think>", "prefix <think>text</think>", ""]) {
|
|
29
|
+
assert.deepEqual(LeadingReasoning.project(input, "", LeadingReasoning.THINK), { content: input, reasoning: "", projected: false, contentStart: 0 });
|
|
30
|
+
}
|
|
31
|
+
assert.deepEqual(LeadingReasoning.project("<think>literal</think>", "native", LeadingReasoning.THINK), {
|
|
32
|
+
content: "<think>literal</think>", reasoning: "native", projected: false, contentStart: 0,
|
|
33
|
+
});
|
|
34
|
+
});
|