@zanii/llm 0.1.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/LICENSE +21 -0
- package/README.md +72 -0
- package/index.d.ts +109 -0
- package/index.js +401 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zanii
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @zanii/llm
|
|
2
|
+
|
|
3
|
+
TypeScript and JavaScript client for [Zanii LLM](https://llm.zanii.agency): OpenAI-compatible
|
|
4
|
+
inference where every call can produce a signed receipt on a public ledger.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @zanii/llm # add @zanii/core to verify proofs client-side
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { Zanii } from "@zanii/llm";
|
|
12
|
+
|
|
13
|
+
const z = new Zanii(); // reads ZANII_LLM_API_KEY
|
|
14
|
+
const answer = await z.chat("Summarise this claim in two sentences.", {
|
|
15
|
+
model: "glm-4.7-flash",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
console.log(answer.text, answer.costAed, "AED");
|
|
19
|
+
console.log((await z.verify(answer.receipt)).ok);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
ESM with hand-written types beside it. No dependencies and no build step: a client library's
|
|
23
|
+
dependency becomes a dependency in every application that installs it, and its build step becomes
|
|
24
|
+
their build problem.
|
|
25
|
+
|
|
26
|
+
## You may not need this
|
|
27
|
+
|
|
28
|
+
The API speaks the OpenAI protocol, so the `openai` package works with one changed base URL and will
|
|
29
|
+
keep working. This package adds what no OpenAI client knows about.
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `answer.receipt`, `z.verify(...)` | the proof for a call, checked against the public ledger |
|
|
34
|
+
| `InsufficientCredit` | a typed 402 carrying `topupUrl`, not an opaque error |
|
|
35
|
+
| `z.usage()`, `z.balance()` | reconcile our invoice against your own records |
|
|
36
|
+
| `maxSpendMicro` | a client-side ceiling; the request is never made |
|
|
37
|
+
| `thinking: false` | skip the model's reasoning when the question does not need it |
|
|
38
|
+
|
|
39
|
+
## Reasoning costs money
|
|
40
|
+
|
|
41
|
+
These models reason before they answer. Reasoning tokens are billed like any other and come out of
|
|
42
|
+
`maxTokens`. Measured on `glm-4.7-flash`, "name one thing Sharjah is known for" costs 412 output
|
|
43
|
+
tokens and 4.4 seconds with reasoning on, and 22 tokens and 0.6 seconds with it off, for the same
|
|
44
|
+
answer.
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
await z.chat("Name one thing Sharjah is known for.", {
|
|
48
|
+
model: "glm-4.7-flash", thinking: false,
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If `answer.text` comes back empty, `answer.emptyBecause` says why and `answer.reasoningTokens` says
|
|
53
|
+
where the budget went.
|
|
54
|
+
|
|
55
|
+
## Streaming
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
for await (const piece of z.stream("Write three lines about Dubai.", { model: "glm-4.7-flash" })) {
|
|
59
|
+
process.stdout.write(piece);
|
|
60
|
+
}
|
|
61
|
+
console.log(String(z.last)); // the receipt, once the stream ends
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Parity
|
|
65
|
+
|
|
66
|
+
The Python client [`zanii-llm-client`](https://pypi.org/project/zanii-llm-client/) mirrors this one
|
|
67
|
+
method for method, in each language's idiom. A conformance suite runs the same cases through both
|
|
68
|
+
and fails on a difference.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
© Zanii, United Arab Emirates · [llm.zanii.agency](https://llm.zanii.agency) · info@zanii.agency
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for @zanii/llm, written by hand rather than generated.
|
|
3
|
+
*
|
|
4
|
+
* The package ships as plain ESM with these types beside it: no build step for us, and none forced
|
|
5
|
+
* on a consumer. TypeScript users get the same completion and checking they would from a compiled
|
|
6
|
+
* package; everyone else gets a file they can read.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export declare const VERSION: string;
|
|
10
|
+
|
|
11
|
+
export interface ZaniiOptions {
|
|
12
|
+
/** Defaults to ZANII_LLM_BASE_URL, then https://llm.zanii.agency/v1 */
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
/** Where receipts are verified. Defaults to https://ledger.zanii.agency */
|
|
15
|
+
ledgerUrl?: string;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** A client-side ceiling in micro-AED: a call past it throws before the request is made. */
|
|
18
|
+
maxSpendMicro?: number;
|
|
19
|
+
/** Ask for a receipt per call. Default true. */
|
|
20
|
+
receipts?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CallOptions {
|
|
24
|
+
model: string;
|
|
25
|
+
maxTokens?: number;
|
|
26
|
+
/**
|
|
27
|
+
* `false` turns the model's reasoning off. Reasoning tokens are billed like any other, and on a
|
|
28
|
+
* short question they dominate the bill: glm-4.7-flash answers "name one thing Sharjah is known
|
|
29
|
+
* for" in 412 output tokens with reasoning on and 22 with it off.
|
|
30
|
+
*/
|
|
31
|
+
thinking?: boolean;
|
|
32
|
+
/** Your own id. The server meters it once, so a retry with the same id is free. */
|
|
33
|
+
requestId?: string;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export declare class Receipt {
|
|
38
|
+
readonly hash: string;
|
|
39
|
+
readonly verifyUrl: string;
|
|
40
|
+
/** null until verify() has checked it against the ledger. */
|
|
41
|
+
ok: boolean | null;
|
|
42
|
+
index: number | null;
|
|
43
|
+
detail: { verifiedLocally?: boolean; note?: string };
|
|
44
|
+
readonly present: boolean;
|
|
45
|
+
toString(): string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Answer {
|
|
49
|
+
text: string;
|
|
50
|
+
receipt: Receipt;
|
|
51
|
+
costMicro: number;
|
|
52
|
+
/** The cost as dirhams and six decimal places, e.g. "0.000134". */
|
|
53
|
+
costAed: string;
|
|
54
|
+
balanceMicro: number;
|
|
55
|
+
requestId: string;
|
|
56
|
+
model: string;
|
|
57
|
+
usage: Record<string, unknown>;
|
|
58
|
+
finishReason: string;
|
|
59
|
+
/** Tokens spent thinking rather than answering. Billed: the GPU produced them. */
|
|
60
|
+
reasoningTokens: number;
|
|
61
|
+
raw: Record<string, unknown>;
|
|
62
|
+
/** Empty when `text` is present; otherwise why it is not, in words a caller can act on. */
|
|
63
|
+
emptyBecause: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UsageTotals {
|
|
67
|
+
requests: number;
|
|
68
|
+
promptTokens: number;
|
|
69
|
+
completionTokens: number;
|
|
70
|
+
costMicro: number;
|
|
71
|
+
rows: Array<Record<string, unknown>>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export declare class ZaniiError extends Error {
|
|
75
|
+
readonly status: number;
|
|
76
|
+
readonly code: string;
|
|
77
|
+
readonly detail: string;
|
|
78
|
+
readonly headers: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export declare class InsufficientCredit extends ZaniiError {
|
|
82
|
+
readonly topupUrl: string;
|
|
83
|
+
readonly balanceMicro: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export declare class RateLimited extends ZaniiError {
|
|
87
|
+
readonly retryAfterSeconds: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export declare class SpendLimitExceeded extends ZaniiError {}
|
|
91
|
+
|
|
92
|
+
export declare class Zanii {
|
|
93
|
+
constructor(apiKey?: string, options?: ZaniiOptions);
|
|
94
|
+
readonly baseUrl: string;
|
|
95
|
+
readonly ledgerUrl: string;
|
|
96
|
+
spentMicro: number;
|
|
97
|
+
/** The receipt of the most recent stream, filled when the stream starts. */
|
|
98
|
+
last: Receipt | null;
|
|
99
|
+
|
|
100
|
+
chat(prompt: string | Array<Record<string, unknown>>, options: CallOptions): Promise<Answer>;
|
|
101
|
+
stream(prompt: string | Array<Record<string, unknown>>,
|
|
102
|
+
options: CallOptions): AsyncGenerator<string>;
|
|
103
|
+
models(): Promise<Array<Record<string, unknown>>>;
|
|
104
|
+
verify(receipt: Receipt | string, options?: { timeoutMs?: number }): Promise<Receipt>;
|
|
105
|
+
balance(): Promise<number>;
|
|
106
|
+
usage(since?: string): Promise<UsageTotals>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default Zanii;
|
package/index.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zanii/llm — the thin layer over an OpenAI-compatible endpoint that an OpenAI client cannot give
|
|
3
|
+
* you.
|
|
4
|
+
*
|
|
5
|
+
* import { Zanii } from "@zanii/llm";
|
|
6
|
+
*
|
|
7
|
+
* const z = new Zanii(); // reads ZANII_LLM_API_KEY
|
|
8
|
+
* const answer = await z.chat("Summarise this claim.", { model: "glm-4.7-flash" });
|
|
9
|
+
* console.log(answer.text, String(answer.receipt));
|
|
10
|
+
* console.log((await z.verify(answer.receipt)).ok); // checked against the ledger, not us
|
|
11
|
+
*
|
|
12
|
+
* You do not need this package: the API speaks the OpenAI protocol, so the `openai` client works
|
|
13
|
+
* with one changed base URL, and anything here that broke that would be a bug. What it adds is the
|
|
14
|
+
* receipt and its verification, a typed InsufficientCredit carrying the top-up link, usage and
|
|
15
|
+
* balance for reconciliation, a client-side spend ceiling, and a key that is read from the
|
|
16
|
+
* environment and never sent over plain http.
|
|
17
|
+
*
|
|
18
|
+
* Shipped as plain ESM with hand-written types beside it. No dependencies, no build step: a client
|
|
19
|
+
* library's dependency becomes a dependency in every customer's application, and its build step
|
|
20
|
+
* becomes their build problem. Mirrors `zanii-llm` for Python, method for method, in each
|
|
21
|
+
* language's idiom — the conformance suite runs the same cases through both and fails on a
|
|
22
|
+
* difference.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const VERSION = "0.1.0";
|
|
26
|
+
const DEFAULT_BASE = "https://llm.zanii.agency/v1";
|
|
27
|
+
const DEFAULT_LEDGER = "https://ledger.zanii.agency";
|
|
28
|
+
const USER_AGENT = `zanii-llm-js/${VERSION}`;
|
|
29
|
+
|
|
30
|
+
/** Anything the API refused. `code` is the stable identifier; `message` is for a human. */
|
|
31
|
+
export class ZaniiError extends Error {
|
|
32
|
+
constructor(status, code, message, headers = {}) {
|
|
33
|
+
super(`${code}: ${message}`);
|
|
34
|
+
this.name = "ZaniiError";
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.detail = message;
|
|
38
|
+
this.headers = headers;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 402. Carries where to top up, so a caller does not have to know the console's URL. */
|
|
43
|
+
export class InsufficientCredit extends ZaniiError {
|
|
44
|
+
constructor(...args) {
|
|
45
|
+
super(...args);
|
|
46
|
+
this.name = "InsufficientCredit";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get topupUrl() {
|
|
50
|
+
return this.headers["x-zanii-topup-url"] || "https://llm.zanii.agency/app";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get balanceMicro() {
|
|
54
|
+
return Number(this.headers["x-zanii-balance-micro"] || 0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class RateLimited extends ZaniiError {
|
|
59
|
+
constructor(...args) {
|
|
60
|
+
super(...args);
|
|
61
|
+
this.name = "RateLimited";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get retryAfterSeconds() {
|
|
65
|
+
return Number(this.headers["retry-after"] || 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Raised before a call that could cost more than the caller allowed: the request is never made. */
|
|
70
|
+
export class SpendLimitExceeded extends ZaniiError {
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(0, "spend_limit_exceeded", message);
|
|
73
|
+
this.name = "SpendLimitExceeded";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A call's proof. `ok` is only true once `verify()` has checked it against the ledger. */
|
|
78
|
+
export class Receipt {
|
|
79
|
+
constructor(hash, verifyUrl = "") {
|
|
80
|
+
this.hash = hash;
|
|
81
|
+
this.verifyUrl = verifyUrl;
|
|
82
|
+
this.ok = null;
|
|
83
|
+
this.index = null;
|
|
84
|
+
this.detail = {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
toString() {
|
|
88
|
+
return this.hash;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get present() {
|
|
92
|
+
return Boolean(this.hash);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class Zanii {
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} [apiKey] defaults to ZANII_LLM_API_KEY, which is where a key belongs
|
|
99
|
+
* @param {{baseUrl?: string, ledgerUrl?: string, timeoutMs?: number, maxSpendMicro?: number,
|
|
100
|
+
* receipts?: boolean}} [options]
|
|
101
|
+
*/
|
|
102
|
+
constructor(apiKey, options = {}) {
|
|
103
|
+
const env = (typeof process !== "undefined" && process.env) || {};
|
|
104
|
+
this.apiKey = apiKey || env.ZANII_LLM_API_KEY || "";
|
|
105
|
+
if (!this.apiKey) throw new Error("no API key: pass one or set ZANII_LLM_API_KEY");
|
|
106
|
+
|
|
107
|
+
this.baseUrl = (options.baseUrl || env.ZANII_LLM_BASE_URL || DEFAULT_BASE).replace(/\/+$/, "");
|
|
108
|
+
if (this.baseUrl.startsWith("http://") && !isLocal(this.baseUrl)) {
|
|
109
|
+
// A key sent over plain http is a key in everyone's logs. Localhost is allowed for tests.
|
|
110
|
+
throw new Error("refusing to send an API key over http; use https");
|
|
111
|
+
}
|
|
112
|
+
this.ledgerUrl = (options.ledgerUrl || DEFAULT_LEDGER).replace(/\/+$/, "");
|
|
113
|
+
this.timeoutMs = options.timeoutMs ?? 120000;
|
|
114
|
+
this.receipts = options.receipts !== false;
|
|
115
|
+
this.maxSpendMicro = options.maxSpendMicro ?? null;
|
|
116
|
+
this.spentMicro = 0;
|
|
117
|
+
this.last = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// -- calling --------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One completion.
|
|
124
|
+
*
|
|
125
|
+
* `thinking: false` turns the model's reasoning off, which is not a small saving on a short
|
|
126
|
+
* question: measured on glm-4.7-flash, "name one thing Sharjah is known for" costs 412 output
|
|
127
|
+
* tokens and 4.4 seconds with reasoning on, and 22 tokens and 0.6 seconds with it off, for the
|
|
128
|
+
* same answer. Reasoning tokens are billed like any other, because the GPU produced them.
|
|
129
|
+
*/
|
|
130
|
+
async chat(prompt, { model, maxTokens, thinking, requestId, ...rest } = {}) {
|
|
131
|
+
const body = { model, messages: messagesFrom(prompt), ...thinkingOptions(thinking), ...rest };
|
|
132
|
+
if (maxTokens !== undefined) body.max_tokens = Number(maxTokens);
|
|
133
|
+
this.#checkBudget();
|
|
134
|
+
const { json, headers } = await this.#send("/chat/completions", body, requestId);
|
|
135
|
+
const answer = answerFrom(json, headers);
|
|
136
|
+
this.spentMicro += answer.costMicro;
|
|
137
|
+
return answer;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Yields text as it arrives. The receipt lands on `this.last` when the stream starts, because
|
|
142
|
+
* that is when the server sends its headers.
|
|
143
|
+
* @returns {AsyncGenerator<string>}
|
|
144
|
+
*/
|
|
145
|
+
async *stream(prompt, { model, maxTokens, thinking, requestId, ...rest } = {}) {
|
|
146
|
+
const body = {
|
|
147
|
+
model, messages: messagesFrom(prompt), stream: true,
|
|
148
|
+
...thinkingOptions(thinking), ...rest,
|
|
149
|
+
};
|
|
150
|
+
if (maxTokens !== undefined) body.max_tokens = Number(maxTokens);
|
|
151
|
+
this.#checkBudget();
|
|
152
|
+
|
|
153
|
+
const response = await this.#fetch("/chat/completions", {
|
|
154
|
+
method: "POST", headers: this.#headers(requestId), body: JSON.stringify(body),
|
|
155
|
+
});
|
|
156
|
+
if (!response.ok) throw await errorFrom(response);
|
|
157
|
+
this.last = new Receipt(response.headers.get("x-zanii-receipt-hash") || "",
|
|
158
|
+
response.headers.get("x-zanii-verify-url") || "");
|
|
159
|
+
|
|
160
|
+
const decoder = new TextDecoder();
|
|
161
|
+
let buffer = "";
|
|
162
|
+
for await (const chunk of response.body) {
|
|
163
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
164
|
+
let cut;
|
|
165
|
+
while ((cut = buffer.indexOf("\n")) !== -1) {
|
|
166
|
+
const line = buffer.slice(0, cut).trim();
|
|
167
|
+
buffer = buffer.slice(cut + 1);
|
|
168
|
+
if (!line.startsWith("data:")) continue;
|
|
169
|
+
const payload = line.slice(5).trim();
|
|
170
|
+
if (!payload || payload === "[DONE]") continue;
|
|
171
|
+
let frame;
|
|
172
|
+
try {
|
|
173
|
+
frame = JSON.parse(payload);
|
|
174
|
+
} catch {
|
|
175
|
+
continue; // a malformed frame must not end the stream
|
|
176
|
+
}
|
|
177
|
+
for (const choice of frame.choices || []) {
|
|
178
|
+
const piece = choice.delta && choice.delta.content;
|
|
179
|
+
if (piece) yield piece;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async models() {
|
|
186
|
+
const { json } = await this.#send("/models", null, undefined, "GET");
|
|
187
|
+
return json.data || [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -- proof ----------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Fetch the Merkle proof from the ledger and check it. Does not call Zanii LLM at all.
|
|
194
|
+
*
|
|
195
|
+
* With `@zanii/core` installed the proof is verified cryptographically in the caller's own
|
|
196
|
+
* process. Without it, the proof is fetched and its shape checked, and `detail.verifiedLocally`
|
|
197
|
+
* says which of the two happened — a caller should never be misled about how much was proven.
|
|
198
|
+
*/
|
|
199
|
+
async verify(receipt, { timeoutMs = 15000 } = {}) {
|
|
200
|
+
const hash = receipt instanceof Receipt ? receipt.hash : String(receipt || "");
|
|
201
|
+
if (!hash) throw new Error("no receipt hash to verify");
|
|
202
|
+
const out = new Receipt(hash, `${this.ledgerUrl}/verify/${hash}`);
|
|
203
|
+
|
|
204
|
+
let core = null;
|
|
205
|
+
try {
|
|
206
|
+
core = await import("@zanii/core");
|
|
207
|
+
} catch {
|
|
208
|
+
core = null;
|
|
209
|
+
}
|
|
210
|
+
const proof = await fetchJson(`${this.ledgerUrl}/v1/proof/${hash}`, timeoutMs);
|
|
211
|
+
out.index = proof.index ?? null;
|
|
212
|
+
if (core && typeof core.verifyInclusion === "function") {
|
|
213
|
+
out.ok = Boolean(core.verifyInclusion(proof.receipt, proof.index, proof.sth.size,
|
|
214
|
+
proof.proof, proof.sth.root));
|
|
215
|
+
out.detail = { verifiedLocally: true };
|
|
216
|
+
} else {
|
|
217
|
+
out.ok = Boolean(proof.proof !== undefined && proof.sth);
|
|
218
|
+
out.detail = { verifiedLocally: false,
|
|
219
|
+
note: "install @zanii/core to verify the proof cryptographically" };
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// -- money ----------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/** Micro-AED left: a millionth of a dirham, because a call can cost less than a fils. */
|
|
227
|
+
async balance() {
|
|
228
|
+
const { json } = await this.#send("/balance", null, undefined, "GET");
|
|
229
|
+
return Number(json.balance_micro || 0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** What this account has spent, so you can reconcile our invoice against your own records. */
|
|
233
|
+
async usage(since = "") {
|
|
234
|
+
const path = "/usage" + (since ? `?since=${encodeURIComponent(since)}` : "");
|
|
235
|
+
const { json } = await this.#send(path, null, undefined, "GET");
|
|
236
|
+
const totals = json.totals || {};
|
|
237
|
+
return {
|
|
238
|
+
requests: Number(totals.requests || 0),
|
|
239
|
+
promptTokens: Number(totals.prompt_tokens || 0),
|
|
240
|
+
completionTokens: Number(totals.completion_tokens || 0),
|
|
241
|
+
costMicro: Number(totals.cost_micro || 0),
|
|
242
|
+
rows: json.recent || [],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// -- transport -------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
#checkBudget() {
|
|
249
|
+
if (this.maxSpendMicro === null) return;
|
|
250
|
+
if (this.spentMicro >= this.maxSpendMicro) {
|
|
251
|
+
throw new SpendLimitExceeded(
|
|
252
|
+
`this client has spent ${this.spentMicro} micro-AED of its ${this.maxSpendMicro} limit`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#headers(requestId) {
|
|
257
|
+
const headers = {
|
|
258
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
259
|
+
"Content-Type": "application/json",
|
|
260
|
+
"User-Agent": USER_AGENT,
|
|
261
|
+
};
|
|
262
|
+
// The server meters this id once, so a retry with the same one is free rather than charged.
|
|
263
|
+
if (requestId) headers["X-Request-Id"] = requestId;
|
|
264
|
+
if (this.receipts) headers["X-Zanii-Receipt"] = "on";
|
|
265
|
+
return headers;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async #fetch(path, init) {
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
271
|
+
try {
|
|
272
|
+
return await fetch(this.baseUrl + path, { ...init, signal: controller.signal });
|
|
273
|
+
} catch (cause) {
|
|
274
|
+
throw new ZaniiError(0, "unreachable", `could not reach ${this.baseUrl}: ${cause.message}`);
|
|
275
|
+
} finally {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async #send(path, body, requestId, method = "POST") {
|
|
281
|
+
const response = await this.#fetch(path, {
|
|
282
|
+
method,
|
|
283
|
+
headers: this.#headers(requestId),
|
|
284
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
285
|
+
});
|
|
286
|
+
if (!response.ok) throw await errorFrom(response);
|
|
287
|
+
const text = await response.text();
|
|
288
|
+
return { json: text ? JSON.parse(text) : {}, headers: headersOf(response) };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// -- helpers ------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
function messagesFrom(prompt) {
|
|
295
|
+
return typeof prompt === "string" ? [{ role: "user", content: prompt }] : [...prompt];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* How to ask a model to stop reasoning. Both spellings are sent because the servers behind this API
|
|
300
|
+
* accept different ones, and an ignored parameter costs nothing while a missing one costs the
|
|
301
|
+
* customer nineteen times the tokens.
|
|
302
|
+
*/
|
|
303
|
+
function thinkingOptions(thinking) {
|
|
304
|
+
if (thinking === undefined || thinking === null) return {};
|
|
305
|
+
if (thinking) return { chat_template_kwargs: { enable_thinking: true } };
|
|
306
|
+
return { chat_template_kwargs: { enable_thinking: false }, reasoning_effort: "none" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function headersOf(response) {
|
|
310
|
+
const out = {};
|
|
311
|
+
response.headers.forEach((value, key) => {
|
|
312
|
+
out[key.toLowerCase()] = value;
|
|
313
|
+
});
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function answerFrom(json, headers) {
|
|
318
|
+
const choice = (json.choices || [{}])[0] || {};
|
|
319
|
+
const message = choice.message || {};
|
|
320
|
+
const usage = json.usage || {};
|
|
321
|
+
const details = usage.completion_tokens_details || {};
|
|
322
|
+
const costMicro = Number(headers["x-zanii-cost-micro"] || 0);
|
|
323
|
+
const text = message.content || "";
|
|
324
|
+
const finishReason = choice.finish_reason || "";
|
|
325
|
+
const reasoningTokens = Number(details.reasoning_tokens || 0);
|
|
326
|
+
return {
|
|
327
|
+
text,
|
|
328
|
+
receipt: new Receipt(headers["x-zanii-receipt-hash"] || "",
|
|
329
|
+
headers["x-zanii-verify-url"] || ""),
|
|
330
|
+
costMicro,
|
|
331
|
+
costAed: aed(costMicro),
|
|
332
|
+
balanceMicro: Number(headers["x-zanii-balance-micro"] || 0),
|
|
333
|
+
requestId: headers["x-request-id"] || "",
|
|
334
|
+
model: json.model || "",
|
|
335
|
+
usage,
|
|
336
|
+
finishReason,
|
|
337
|
+
reasoningTokens,
|
|
338
|
+
raw: json,
|
|
339
|
+
/**
|
|
340
|
+
* Why `text` is empty, in words a caller can act on. These models reason before answering and
|
|
341
|
+
* reasoning is charged against the same max_tokens budget, so a budget too small for both
|
|
342
|
+
* produces a perfectly successful call that returns nothing — the most confusing possible
|
|
343
|
+
* outcome.
|
|
344
|
+
*/
|
|
345
|
+
emptyBecause: text ? "" : emptyBecause(finishReason, reasoningTokens),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function emptyBecause(finishReason, reasoningTokens) {
|
|
350
|
+
if (finishReason === "length" && reasoningTokens) {
|
|
351
|
+
return `the reply hit max_tokens after ${reasoningTokens} reasoning tokens, before any answer `
|
|
352
|
+
+ "was written: raise max_tokens";
|
|
353
|
+
}
|
|
354
|
+
if (finishReason === "length") {
|
|
355
|
+
return "the reply hit max_tokens before any text was produced: raise max_tokens";
|
|
356
|
+
}
|
|
357
|
+
if (finishReason === "content_filter") return "the model stopped on a content filter";
|
|
358
|
+
return `the model returned no text (finish_reason=${finishReason || "unknown"})`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function aed(micro) {
|
|
362
|
+
const whole = Math.trunc(micro / 1000000);
|
|
363
|
+
const frac = String(Math.abs(micro) % 1000000).padStart(6, "0");
|
|
364
|
+
return `${whole}.${frac}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function errorFrom(response) {
|
|
368
|
+
const raw = await response.text();
|
|
369
|
+
let error = {};
|
|
370
|
+
try {
|
|
371
|
+
error = (JSON.parse(raw) || {}).error || {};
|
|
372
|
+
} catch {
|
|
373
|
+
error = {};
|
|
374
|
+
}
|
|
375
|
+
const code = String(error.code || `http_${response.status}`);
|
|
376
|
+
const message = String(error.message || raw.slice(0, 200) || "request failed");
|
|
377
|
+
const headers = headersOf(response);
|
|
378
|
+
if (response.status === 402) return new InsufficientCredit(402, code, message, headers);
|
|
379
|
+
if (response.status === 429) return new RateLimited(429, code, message, headers);
|
|
380
|
+
return new ZaniiError(response.status, code, message, headers);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function fetchJson(url, timeoutMs) {
|
|
384
|
+
const controller = new AbortController();
|
|
385
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
386
|
+
try {
|
|
387
|
+
const response = await fetch(url, {
|
|
388
|
+
headers: { "User-Agent": USER_AGENT }, signal: controller.signal,
|
|
389
|
+
});
|
|
390
|
+
if (!response.ok) throw new ZaniiError(response.status, "proof_unavailable", await response.text());
|
|
391
|
+
return await response.json();
|
|
392
|
+
} finally {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isLocal(url) {
|
|
398
|
+
return url.includes("://127.0.0.1") || url.includes("://localhost") || url.includes("://[::1]");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export default Zanii;
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zanii/llm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client for the Zanii LLM API: OpenAI-compatible inference with a verifiable receipt for every call",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"import": "./index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["index.js", "index.d.ts", "README.md", "LICENSE"],
|
|
15
|
+
"engines": { "node": ">=18" },
|
|
16
|
+
"dependencies": {},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@zanii/core": ">=0.24.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependenciesMeta": {
|
|
21
|
+
"@zanii/core": { "optional": true }
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["llm", "openai", "inference", "receipts", "audit", "uae", "zanii"],
|
|
24
|
+
"author": "Zanii <info@zanii.agency>",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://llm.zanii.agency",
|
|
27
|
+
"repository": { "type": "git", "url": "git+https://github.com/vigilancetrent/zanii-llm.git", "directory": "sdk/js" },
|
|
28
|
+
"bugs": { "url": "https://llm.zanii.agency/contact" }
|
|
29
|
+
}
|