marginfuse 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
+ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.2.0]
8
+
9
+ First release from this repository. Earlier versions were published from a
10
+ private monorepo, which is why the 0.1.0 page on npm links to a repository
11
+ nobody can open. That is what this release fixes, along with everything below.
12
+
13
+ ### Added
14
+
15
+ - `fromOpenRouter()` maps an OpenRouter `usage` object to MarginFuse fields,
16
+ including the gateway's own `cost`, so figures from a gateway are exact
17
+ rather than estimated. It exists because mapping the fields by hand gets two
18
+ things silently wrong: `prompt_tokens` already contains cached reads and
19
+ cache writes, which MarginFuse prices separately, and small costs stringify
20
+ to exponent notation, which the API rejects.
21
+ - `openrouter` in the `provider` union.
22
+ - Published with [npm provenance](https://docs.npmjs.com/generating-provenance-statements),
23
+ so the build that produced this package is publicly verifiable.
24
+
25
+ ### Fixed
26
+
27
+ - **A `block` verdict with no decision id no longer runs the provider call.**
28
+ Enforcement checked the action and the id together, so a decision that
29
+ arrived without an id fell through and the call went out. A missing id costs
30
+ an acknowledgment; it must never turn a block into a provider call. The same
31
+ applied to `topup_required`.
32
+ - **`track()` no longer throws on Node 18.** The auto-generated event id used
33
+ `crypto.randomUUID()`, and `globalThis.crypto` only became a global in Node
34
+ 19, so on Node 18 (which this package supports) omitting `eventId` threw a
35
+ `ReferenceError` synchronously into application code. The id now comes from
36
+ whatever the runtime offers and degrades rather than throwing, so it also
37
+ works on edge runtimes with no `node:crypto`.
38
+
39
+ ### Changed
40
+
41
+ - Verified against [marginfuse/sdk-contract](https://github.com/marginfuse/sdk-contract),
42
+ the shared conformance suite every MarginFuse SDK is held to. Fifteen
43
+ behavioral scenarios and thirteen gateway vectors run in CI against the packed
44
+ artifact, not against the source tree.
45
+
46
+ ## [0.1.0]
47
+
48
+ Initial release. Published from a private monorepo.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pemira Labs
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,157 @@
1
+ # marginfuse
2
+
3
+ [![npm](https://img.shields.io/npm/v/marginfuse)](https://www.npmjs.com/package/marginfuse)
4
+ [![ci](https://github.com/marginfuse/marginfuse-node/actions/workflows/ci.yml/badge.svg)](https://github.com/marginfuse/marginfuse-node/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+
7
+ Server-side SDK for [MarginFuse](https://marginfuse.com): profitability
8
+ guardrails for AI SaaS. Connect revenue to per-request AI cost, see gross margin
9
+ per customer, and stop loss-making requests before they run.
10
+
11
+ - **Metadata only, by construction.** The event shape has no field for prompts
12
+ or responses, so they cannot be sent. Not a policy, an absence.
13
+ - **Never breaks your app.** It does not throw into your code, and it does not
14
+ block your request on MarginFuse being up. If MarginFuse is unreachable, your
15
+ requests proceed unchanged.
16
+ - **Zero dependencies.** Node 18+, Next.js route handlers, serverless functions.
17
+
18
+ > **Server side only.** This SDK carries a secret API key. Never ship it in a
19
+ > browser bundle, a mobile app, or anything else a user can read.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install marginfuse
25
+ ```
26
+
27
+ ## Track an AI call
28
+
29
+ Monitoring. One call after each AI request, metadata only.
30
+
31
+ ```ts
32
+ import { MarginFuse } from "marginfuse";
33
+
34
+ const mf = new MarginFuse({ apiKey: process.env.MARGINFUSE_KEY! });
35
+
36
+ mf.track({
37
+ customerId: "cus_8x2m91", // your Stripe customer id, or your own id
38
+ feature: "ai_chat",
39
+ provider: "openai",
40
+ model: "gpt-4.1",
41
+ usage: { inputTokens: 1204, outputTokens: 388 },
42
+ // or costUsd: "0.0084" when your provider reports the real charge
43
+ });
44
+ ```
45
+
46
+ `track()` is fire and forget with retries. In a script or a background job, call
47
+ `await mf.flush()` before the process exits, or the last events are lost.
48
+
49
+ ## Guard a call
50
+
51
+ Protection. Ask before the call runs, and act on the answer.
52
+
53
+ ```ts
54
+ const out = await mf.guard(
55
+ { customerId: "cus_8x2m91", feature: "ai_chat", provider: "openai", model: "gpt-4.1" },
56
+ async ({ model }) => {
57
+ const r = await openai.chat.completions.create({ model, messages });
58
+ return {
59
+ result: r,
60
+ usage: { inputTokens: r.usage.prompt_tokens, outputTokens: r.usage.completion_tokens },
61
+ };
62
+ },
63
+ );
64
+
65
+ if (out.kind === "completed") use(out.result);
66
+ // out.kind === "blocked" | "topup_required" -> your own UX decides what to show
67
+ ```
68
+
69
+ One wrapper does the whole loop: ask, run with the resolved model, report the
70
+ real cost, acknowledge what your application did. A `downgrade` verdict changes
71
+ the `model` your callback receives, so the cheaper model is actually the one
72
+ that runs.
73
+
74
+ Policies run in dry-run first. You see what protection would have done against
75
+ your real traffic before anything is allowed to act.
76
+
77
+ ## OpenRouter and other gateways
78
+
79
+ Gateways report the real cost of every call. Forward it and your figures are
80
+ exact instead of estimated.
81
+
82
+ ```ts
83
+ import { MarginFuse, fromOpenRouter } from "marginfuse";
84
+
85
+ const r = await openai.chat.completions.create({ model: "anthropic/claude-sonnet-4.5", messages });
86
+
87
+ mf.track({
88
+ customerId: "cus_8x2m91",
89
+ feature: "ai_chat",
90
+ provider: "openrouter",
91
+ model: "anthropic/claude-sonnet-4.5",
92
+ ...fromOpenRouter(r.usage),
93
+ });
94
+ ```
95
+
96
+ Use the helper rather than mapping the fields yourself. OpenRouter's
97
+ `prompt_tokens` already includes cached reads and cache writes, which MarginFuse
98
+ prices separately, so passing it through directly charges every cached token
99
+ twice at the full input rate. The helper also formats the cost as a decimal
100
+ string, because `String(cost)` produces `1.2e-7` for small costs and the API
101
+ rejects that.
102
+
103
+ If a gateway event arrives without a cost, MarginFuse prices it from the
104
+ upstream vendor's list price for the model behind the id and labels the figure
105
+ **EST**, because the gateway's own margin is not in that number.
106
+
107
+ ## Configuration
108
+
109
+ ```ts
110
+ new MarginFuse({
111
+ apiKey: process.env.MARGINFUSE_KEY!,
112
+ baseUrl: "https://api.marginfuse.com", // point at your own deployment in dev
113
+ timeoutMs: 1500, // decide() budget before failing open
114
+ onError: (err, context) => log.warn({ err, context }), // the SDK never throws
115
+ });
116
+ ```
117
+
118
+ ## What it sends
119
+
120
+ Everything, and nothing else:
121
+
122
+ ```
123
+ eventId customerId feature provider model requestedModel
124
+ usage { inputTokens, outputTokens, cachedInputTokens, cacheCreationTokens, images, audioSeconds }
125
+ costUsd occurredAt outcome decisionId retryOfEventId correctsEventId
126
+ ```
127
+
128
+ There is no field for message content anywhere in the wire types. The
129
+ [conformance suite](https://github.com/marginfuse/sdk-contract) checks this
130
+ against the bytes that actually leave the process, on every scenario.
131
+
132
+ ## Conformance
133
+
134
+ This SDK is verified against
135
+ [marginfuse/sdk-contract](https://github.com/marginfuse/sdk-contract), the same
136
+ contract every MarginFuse SDK in every language is held to. It is a submodule
137
+ here, so the pinned commit records exactly which contract a release passed.
138
+
139
+ ```bash
140
+ git clone --recurse-submodules https://github.com/marginfuse/marginfuse-node
141
+ cd marginfuse-node
142
+ npm install
143
+ npm test # unit tests, plus the shared gateway vectors
144
+ npm run build && npm pack
145
+ npm --prefix contract/harness install ../../marginfuse-*.tgz
146
+ npm run conformance # 16 scenarios against the packed artifact
147
+ ```
148
+
149
+ ## Links
150
+
151
+ - [MarginFuse](https://marginfuse.com), product and pricing
152
+ - [Live demo](https://marginfuse.com/demo), a read-only workspace, no signup
153
+ - [API reference](https://api.marginfuse.com/openapi.json)
154
+ - [Security policy](SECURITY.md)
155
+ - [Contributing](CONTRIBUTING.md)
156
+
157
+ MIT, Pemira Labs.
package/dist/index.cjs ADDED
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ MarginFuse: () => MarginFuse,
24
+ fromOpenRouter: () => fromOpenRouter
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/client.ts
29
+ var DEFAULT_BASE_URL = "https://api.marginfuse.com";
30
+ var DEFAULT_TIMEOUT_MS = 1500;
31
+ var TRACK_RETRIES = 3;
32
+ var MarginFuse = class {
33
+ apiKey;
34
+ baseUrl;
35
+ timeoutMs;
36
+ onError;
37
+ fetchImpl;
38
+ pending = /* @__PURE__ */ new Set();
39
+ constructor(options) {
40
+ if (!options.apiKey) throw new Error("MarginFuse: apiKey is required");
41
+ this.apiKey = options.apiKey;
42
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
43
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
44
+ this.onError = options.onError ?? (() => {
45
+ });
46
+ this.fetchImpl = options.fetch ?? fetch;
47
+ }
48
+ /**
49
+ * Pre-request policy check (protection-ready integrations, §13.2).
50
+ * Always resolves. On any failure resolves {action:"allow", degraded:true}.
51
+ */
52
+ async decide(params) {
53
+ const failOpen = (reason) => ({
54
+ action: "allow",
55
+ model: params.model,
56
+ provider: params.provider,
57
+ degraded: true,
58
+ degradedReason: reason
59
+ });
60
+ try {
61
+ const res = await this.post("/v1/decisions", params, this.timeoutMs);
62
+ if (!res.ok) {
63
+ this.onError(new Error(`decide: HTTP ${res.status}`), "decide");
64
+ return failOpen(`server responded ${res.status}`);
65
+ }
66
+ const body = await res.json();
67
+ return {
68
+ id: body.id,
69
+ action: body.action,
70
+ model: body.model ?? params.model,
71
+ provider: body.provider ?? params.provider,
72
+ ...body.topupContext !== void 0 ? { topupContext: body.topupContext } : {},
73
+ degraded: body.degraded ?? false,
74
+ ...body.degradedReason !== void 0 ? { degradedReason: body.degradedReason } : {}
75
+ };
76
+ } catch (err) {
77
+ this.onError(err, "decide");
78
+ return failOpen(err.name === "TimeoutError" ? "timeout" : "unreachable");
79
+ }
80
+ }
81
+ /**
82
+ * Report actual usage after the provider call (monitor-only §13.1 and
83
+ * post-request reconciliation §25.3). Fire-and-forget with retries.
84
+ */
85
+ track(params) {
86
+ const event = {
87
+ eventId: params.eventId ?? cryptoRandomId(),
88
+ occurredAt: (params.occurredAt ?? /* @__PURE__ */ new Date()).toISOString(),
89
+ outcome: params.outcome ?? "success",
90
+ ...params
91
+ };
92
+ this.background(async () => {
93
+ let lastErr;
94
+ for (let attempt = 0; attempt < TRACK_RETRIES; attempt++) {
95
+ try {
96
+ const res = await this.post("/v1/events", { events: [event] }, 5e3);
97
+ if (res.ok) return;
98
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
99
+ this.onError(new Error(`track: HTTP ${res.status} ${await safeText(res)}`), "track");
100
+ return;
101
+ }
102
+ lastErr = new Error(`track: HTTP ${res.status}`);
103
+ } catch (err) {
104
+ lastErr = err;
105
+ }
106
+ await sleep(250 * 2 ** attempt);
107
+ }
108
+ if (lastErr) this.onError(lastErr, "track");
109
+ });
110
+ }
111
+ /** Awaitable variant of track for jobs/scripts that must not exit early. */
112
+ async trackAndWait(params) {
113
+ this.track(params);
114
+ await this.flush();
115
+ }
116
+ /** Tell MarginFuse what your app actually did with a decision (§25.2). */
117
+ acknowledge(decisionId, acknowledgment) {
118
+ this.background(async () => {
119
+ try {
120
+ const res = await this.post(`/v1/decisions/${encodeURIComponent(decisionId)}/ack`, { acknowledgment }, 5e3);
121
+ if (!res.ok) this.onError(new Error(`ack: HTTP ${res.status}`), "acknowledge");
122
+ } catch (err) {
123
+ this.onError(err, "acknowledge");
124
+ }
125
+ });
126
+ }
127
+ /**
128
+ * Full protection loop in one wrapper: decide → run your provider call with
129
+ * the resolved model → report usage → acknowledge.
130
+ *
131
+ * const out = await mf.guard(
132
+ * { customerId, feature: "ai_chat", provider: "openai", model: "gpt-4.1" },
133
+ * async ({ model }) => {
134
+ * const r = await openai.chat.completions.create({ model, messages });
135
+ * return { result: r, usage: { inputTokens: r.usage.prompt_tokens, outputTokens: r.usage.completion_tokens } };
136
+ * },
137
+ * );
138
+ * if (out.kind === "completed") use(out.result);
139
+ * else handle out.kind === "blocked" | "topup_required" with your own UX.
140
+ */
141
+ async guard(params, run) {
142
+ const decision = await this.decide(params);
143
+ if (decision.action === "block") {
144
+ if (decision.id) this.acknowledge(decision.id, "blocked_before_provider_call");
145
+ return { kind: "blocked", decision };
146
+ }
147
+ if (decision.action === "topup_required") {
148
+ if (decision.id) this.acknowledge(decision.id, "presented_topup");
149
+ return { kind: "topup_required", decision };
150
+ }
151
+ const modelToUse = decision.action === "downgrade" ? decision.model : params.model;
152
+ let outcome = "success";
153
+ try {
154
+ const out = await run({ model: modelToUse, provider: decision.provider, decision });
155
+ this.track({
156
+ customerId: params.customerId,
157
+ ...params.feature !== void 0 ? { feature: params.feature } : {},
158
+ provider: params.provider,
159
+ model: modelToUse,
160
+ requestedModel: params.model,
161
+ usage: out.usage,
162
+ ...out.costUsd !== void 0 ? { costUsd: out.costUsd } : {},
163
+ outcome: out.outcome ?? "success",
164
+ ...decision.id !== void 0 ? { decisionId: decision.id } : {}
165
+ });
166
+ if (decision.id) {
167
+ this.acknowledge(
168
+ decision.id,
169
+ decision.action === "downgrade" ? "used_downgrade_model" : "proceeded_as_requested"
170
+ );
171
+ }
172
+ return { kind: "completed", result: out.result, decision };
173
+ } catch (err) {
174
+ outcome = "provider_error";
175
+ this.track({
176
+ customerId: params.customerId,
177
+ ...params.feature !== void 0 ? { feature: params.feature } : {},
178
+ provider: params.provider,
179
+ model: modelToUse,
180
+ requestedModel: params.model,
181
+ usage: {},
182
+ outcome,
183
+ ...decision.id !== void 0 ? { decisionId: decision.id } : {}
184
+ });
185
+ if (decision.id) this.acknowledge(decision.id, "proceeded_as_requested");
186
+ throw err;
187
+ }
188
+ }
189
+ /** Wait for queued track/ack calls (use before process exit). */
190
+ async flush() {
191
+ await Promise.allSettled([...this.pending]);
192
+ }
193
+ background(fn) {
194
+ const p = fn().finally(() => this.pending.delete(p));
195
+ this.pending.add(p);
196
+ }
197
+ post(path, body, timeoutMs) {
198
+ return this.fetchImpl(`${this.baseUrl}${path}`, {
199
+ method: "POST",
200
+ headers: {
201
+ authorization: `Bearer ${this.apiKey}`,
202
+ "content-type": "application/json",
203
+ "user-agent": "marginfuse-node/0.1.0"
204
+ },
205
+ body: JSON.stringify(body),
206
+ signal: AbortSignal.timeout(timeoutMs)
207
+ });
208
+ }
209
+ };
210
+ function cryptoRandomId() {
211
+ return `evt_${uuidV4()}`;
212
+ }
213
+ function uuidV4() {
214
+ const webcrypto = globalThis.crypto;
215
+ if (typeof webcrypto?.randomUUID === "function") return webcrypto.randomUUID();
216
+ const bytes = new Uint8Array(16);
217
+ if (typeof webcrypto?.getRandomValues === "function") {
218
+ webcrypto.getRandomValues(bytes);
219
+ } else {
220
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
221
+ }
222
+ bytes[6] = bytes[6] & 15 | 64;
223
+ bytes[8] = bytes[8] & 63 | 128;
224
+ const hex = [];
225
+ for (const b of bytes) hex.push(b.toString(16).padStart(2, "0"));
226
+ return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
227
+ }
228
+ function sleep(ms) {
229
+ return new Promise((r) => setTimeout(r, ms));
230
+ }
231
+ async function safeText(res) {
232
+ try {
233
+ return (await res.text()).slice(0, 200);
234
+ } catch {
235
+ return "";
236
+ }
237
+ }
238
+
239
+ // src/openrouter.ts
240
+ var int = (n) => typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
241
+ function creditsToUsd(cost) {
242
+ const s = cost.toFixed(9);
243
+ const trimmed = s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s;
244
+ return trimmed === "" || trimmed === "-0" ? "0" : trimmed;
245
+ }
246
+ function fromOpenRouter(usage) {
247
+ const cachedInputTokens = int(usage?.prompt_tokens_details?.cached_tokens);
248
+ const cacheCreationTokens = int(usage?.prompt_tokens_details?.cache_write_tokens);
249
+ const inputTokens = Math.max(0, int(usage?.prompt_tokens) - cachedInputTokens - cacheCreationTokens);
250
+ const out = {};
251
+ if (inputTokens > 0) out.inputTokens = inputTokens;
252
+ const outputTokens = int(usage?.completion_tokens);
253
+ if (outputTokens > 0) out.outputTokens = outputTokens;
254
+ if (cachedInputTokens > 0) out.cachedInputTokens = cachedInputTokens;
255
+ if (cacheCreationTokens > 0) out.cacheCreationTokens = cacheCreationTokens;
256
+ const cost = usage?.cost;
257
+ const hasCost = typeof cost === "number" && Number.isFinite(cost) && cost >= 0;
258
+ return { usage: out, ...hasCost ? { costUsd: creditsToUsd(cost) } : {} };
259
+ }
260
+ // Annotate the CommonJS export names for ESM import in node:
261
+ 0 && (module.exports = {
262
+ MarginFuse,
263
+ fromOpenRouter
264
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Wire types for the MarginFuse SDK. Deliberately: there is NO field for
3
+ * prompt text, responses, or documents - the SDK cannot leak what it cannot
4
+ * carry (spec §5.6, §33).
5
+ */
6
+ interface Usage {
7
+ inputTokens?: number;
8
+ outputTokens?: number;
9
+ cachedInputTokens?: number;
10
+ cacheCreationTokens?: number;
11
+ images?: number;
12
+ audioSeconds?: number;
13
+ }
14
+ type Outcome = "success" | "provider_error" | "app_cancelled" | "timeout";
15
+ interface TrackParams {
16
+ /** Your unique id for this call; reusing one is safe (idempotent). Auto-generated if omitted. */
17
+ eventId?: string;
18
+ /** Your application's id for the end customer (or their Stripe customer id). */
19
+ customerId: string;
20
+ /** Stable feature key, e.g. "ai_chat". */
21
+ feature?: string;
22
+ provider: "openai" | "anthropic" | "openrouter" | (string & {});
23
+ model: string;
24
+ requestedModel?: string;
25
+ usage: Usage;
26
+ /** Actual cost if your provider response includes it (decimal string, e.g. "0.0142"). */
27
+ costUsd?: string;
28
+ occurredAt?: Date;
29
+ outcome?: Outcome;
30
+ /** Link to a prior decide() result for reconciliation. */
31
+ decisionId?: string;
32
+ retryOfEventId?: string;
33
+ correctsEventId?: string;
34
+ }
35
+ interface DecideParams {
36
+ customerId: string;
37
+ feature?: string;
38
+ provider: "openai" | "anthropic" | "openrouter" | (string & {});
39
+ model: string;
40
+ /** Optional expected usage for a better pre-request cost estimate. */
41
+ expectedUsage?: Usage;
42
+ }
43
+ type DecisionAction = "allow" | "downgrade" | "topup_required" | "block";
44
+ interface Decision {
45
+ /** Present when the server produced the decision; absent on fail-open. */
46
+ id?: string;
47
+ action: DecisionAction;
48
+ /** The model your app should use for this request (downgrades change it). */
49
+ model: string;
50
+ provider: string;
51
+ /** For topup_required: pass-through context configured in the policy. */
52
+ topupContext?: string;
53
+ /** True when MarginFuse could not be reached / evaluated - request allowed (fail-open). */
54
+ degraded: boolean;
55
+ degradedReason?: string;
56
+ }
57
+ type Acknowledgment = "proceeded_as_requested" | "used_downgrade_model" | "presented_topup" | "blocked_before_provider_call" | "failed_to_apply";
58
+ interface MarginFuseOptions {
59
+ apiKey: string;
60
+ /** Default https://api.marginfuse.com - point at your own deployment in dev. */
61
+ baseUrl?: string;
62
+ /** Decision timeout. On expiry the SDK fails open (allow). Default 1500 ms. */
63
+ timeoutMs?: number;
64
+ /** Called with transport errors the SDK swallowed (it never throws into your app). */
65
+ onError?: (error: Error, context: string) => void;
66
+ fetch?: typeof fetch;
67
+ }
68
+
69
+ /**
70
+ * MarginFuse Node SDK.
71
+ *
72
+ * Reliability contract (spec §5.5, §29.3): this SDK NEVER throws into
73
+ * application code and NEVER blocks a request on MarginFuse availability.
74
+ * decide() fails open to "allow" on any timeout or error; track()/report()
75
+ * retry in the background and surface problems only via options.onError.
76
+ */
77
+
78
+ declare class MarginFuse {
79
+ private readonly apiKey;
80
+ private readonly baseUrl;
81
+ private readonly timeoutMs;
82
+ private readonly onError;
83
+ private readonly fetchImpl;
84
+ private readonly pending;
85
+ constructor(options: MarginFuseOptions);
86
+ /**
87
+ * Pre-request policy check (protection-ready integrations, §13.2).
88
+ * Always resolves. On any failure resolves {action:"allow", degraded:true}.
89
+ */
90
+ decide(params: DecideParams): Promise<Decision>;
91
+ /**
92
+ * Report actual usage after the provider call (monitor-only §13.1 and
93
+ * post-request reconciliation §25.3). Fire-and-forget with retries.
94
+ */
95
+ track(params: TrackParams): void;
96
+ /** Awaitable variant of track for jobs/scripts that must not exit early. */
97
+ trackAndWait(params: TrackParams): Promise<void>;
98
+ /** Tell MarginFuse what your app actually did with a decision (§25.2). */
99
+ acknowledge(decisionId: string, acknowledgment: Acknowledgment): void;
100
+ /**
101
+ * Full protection loop in one wrapper: decide → run your provider call with
102
+ * the resolved model → report usage → acknowledge.
103
+ *
104
+ * const out = await mf.guard(
105
+ * { customerId, feature: "ai_chat", provider: "openai", model: "gpt-4.1" },
106
+ * async ({ model }) => {
107
+ * const r = await openai.chat.completions.create({ model, messages });
108
+ * return { result: r, usage: { inputTokens: r.usage.prompt_tokens, outputTokens: r.usage.completion_tokens } };
109
+ * },
110
+ * );
111
+ * if (out.kind === "completed") use(out.result);
112
+ * else handle out.kind === "blocked" | "topup_required" with your own UX.
113
+ */
114
+ guard<T>(params: DecideParams, run: (ctx: {
115
+ model: string;
116
+ provider: string;
117
+ decision: Decision;
118
+ }) => Promise<{
119
+ result: T;
120
+ usage: TrackParams["usage"];
121
+ costUsd?: string;
122
+ outcome?: TrackParams["outcome"];
123
+ }>): Promise<{
124
+ kind: "completed";
125
+ result: T;
126
+ decision: Decision;
127
+ } | {
128
+ kind: "blocked";
129
+ decision: Decision;
130
+ } | {
131
+ kind: "topup_required";
132
+ decision: Decision;
133
+ }>;
134
+ /** Wait for queued track/ack calls (use before process exit). */
135
+ flush(): Promise<void>;
136
+ private background;
137
+ private post;
138
+ }
139
+
140
+ /**
141
+ * OpenRouter helper.
142
+ *
143
+ * OpenRouter returns a `usage` object on every response (no opt-in parameter -
144
+ * the old `usage: { include: true }` flag is deprecated and does nothing), and
145
+ * that object carries the provider-final `cost`. Forwarding it is what makes an
146
+ * OpenRouter integration exact rather than estimated: MarginFuse cannot know
147
+ * what a gateway charged, because routing, fees and BYOK terms are not visible
148
+ * in a usage event.
149
+ *
150
+ * Two details this helper exists to get right, both of which silently
151
+ * misstate margin when hand-rolled:
152
+ *
153
+ * 1. `prompt_tokens` is the TOTAL input count - cached reads and cache writes
154
+ * are already inside it. MarginFuse prices inputTokens, cachedInputTokens
155
+ * and cacheCreationTokens as three separate charges and adds them up, so
156
+ * passing `prompt_tokens` straight through double-counts every cached
157
+ * token, at the full uncached rate.
158
+ * 2. `cost` is a JavaScript number, and small ones stringify to exponent
159
+ * notation ("1.2e-7"), which the API rejects as a decimal string.
160
+ */
161
+
162
+ /**
163
+ * The fields this helper reads from an OpenRouter `usage` object. Structural on
164
+ * purpose - it accepts the response of the OpenAI SDK pointed at OpenRouter,
165
+ * or a plain fetch, without either side importing the other's types.
166
+ */
167
+ interface OpenRouterUsage {
168
+ prompt_tokens?: number | null;
169
+ completion_tokens?: number | null;
170
+ cost?: number | null;
171
+ prompt_tokens_details?: {
172
+ cached_tokens?: number | null;
173
+ cache_write_tokens?: number | null;
174
+ audio_tokens?: number | null;
175
+ } | null;
176
+ }
177
+ /**
178
+ * Map an OpenRouter `usage` object to the MarginFuse fields, ready to spread
179
+ * into track() or return from guard().
180
+ *
181
+ * const r = await openai.chat.completions.create({ model, messages });
182
+ * mf.track({ customerId, feature: "ai_chat", provider: "openrouter", model, ...fromOpenRouter(r.usage) });
183
+ *
184
+ * `costUsd` is omitted when the response carried no cost, which lets the event
185
+ * fall through to MarginFuse's own pricing instead of claiming a $0 charge.
186
+ */
187
+ declare function fromOpenRouter(usage: OpenRouterUsage | null | undefined): Pick<TrackParams, "usage"> & {
188
+ costUsd?: string;
189
+ };
190
+
191
+ export { type Acknowledgment, type DecideParams, type Decision, type DecisionAction, MarginFuse, type MarginFuseOptions, type OpenRouterUsage, type Outcome, type TrackParams, type Usage, fromOpenRouter };
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Wire types for the MarginFuse SDK. Deliberately: there is NO field for
3
+ * prompt text, responses, or documents - the SDK cannot leak what it cannot
4
+ * carry (spec §5.6, §33).
5
+ */
6
+ interface Usage {
7
+ inputTokens?: number;
8
+ outputTokens?: number;
9
+ cachedInputTokens?: number;
10
+ cacheCreationTokens?: number;
11
+ images?: number;
12
+ audioSeconds?: number;
13
+ }
14
+ type Outcome = "success" | "provider_error" | "app_cancelled" | "timeout";
15
+ interface TrackParams {
16
+ /** Your unique id for this call; reusing one is safe (idempotent). Auto-generated if omitted. */
17
+ eventId?: string;
18
+ /** Your application's id for the end customer (or their Stripe customer id). */
19
+ customerId: string;
20
+ /** Stable feature key, e.g. "ai_chat". */
21
+ feature?: string;
22
+ provider: "openai" | "anthropic" | "openrouter" | (string & {});
23
+ model: string;
24
+ requestedModel?: string;
25
+ usage: Usage;
26
+ /** Actual cost if your provider response includes it (decimal string, e.g. "0.0142"). */
27
+ costUsd?: string;
28
+ occurredAt?: Date;
29
+ outcome?: Outcome;
30
+ /** Link to a prior decide() result for reconciliation. */
31
+ decisionId?: string;
32
+ retryOfEventId?: string;
33
+ correctsEventId?: string;
34
+ }
35
+ interface DecideParams {
36
+ customerId: string;
37
+ feature?: string;
38
+ provider: "openai" | "anthropic" | "openrouter" | (string & {});
39
+ model: string;
40
+ /** Optional expected usage for a better pre-request cost estimate. */
41
+ expectedUsage?: Usage;
42
+ }
43
+ type DecisionAction = "allow" | "downgrade" | "topup_required" | "block";
44
+ interface Decision {
45
+ /** Present when the server produced the decision; absent on fail-open. */
46
+ id?: string;
47
+ action: DecisionAction;
48
+ /** The model your app should use for this request (downgrades change it). */
49
+ model: string;
50
+ provider: string;
51
+ /** For topup_required: pass-through context configured in the policy. */
52
+ topupContext?: string;
53
+ /** True when MarginFuse could not be reached / evaluated - request allowed (fail-open). */
54
+ degraded: boolean;
55
+ degradedReason?: string;
56
+ }
57
+ type Acknowledgment = "proceeded_as_requested" | "used_downgrade_model" | "presented_topup" | "blocked_before_provider_call" | "failed_to_apply";
58
+ interface MarginFuseOptions {
59
+ apiKey: string;
60
+ /** Default https://api.marginfuse.com - point at your own deployment in dev. */
61
+ baseUrl?: string;
62
+ /** Decision timeout. On expiry the SDK fails open (allow). Default 1500 ms. */
63
+ timeoutMs?: number;
64
+ /** Called with transport errors the SDK swallowed (it never throws into your app). */
65
+ onError?: (error: Error, context: string) => void;
66
+ fetch?: typeof fetch;
67
+ }
68
+
69
+ /**
70
+ * MarginFuse Node SDK.
71
+ *
72
+ * Reliability contract (spec §5.5, §29.3): this SDK NEVER throws into
73
+ * application code and NEVER blocks a request on MarginFuse availability.
74
+ * decide() fails open to "allow" on any timeout or error; track()/report()
75
+ * retry in the background and surface problems only via options.onError.
76
+ */
77
+
78
+ declare class MarginFuse {
79
+ private readonly apiKey;
80
+ private readonly baseUrl;
81
+ private readonly timeoutMs;
82
+ private readonly onError;
83
+ private readonly fetchImpl;
84
+ private readonly pending;
85
+ constructor(options: MarginFuseOptions);
86
+ /**
87
+ * Pre-request policy check (protection-ready integrations, §13.2).
88
+ * Always resolves. On any failure resolves {action:"allow", degraded:true}.
89
+ */
90
+ decide(params: DecideParams): Promise<Decision>;
91
+ /**
92
+ * Report actual usage after the provider call (monitor-only §13.1 and
93
+ * post-request reconciliation §25.3). Fire-and-forget with retries.
94
+ */
95
+ track(params: TrackParams): void;
96
+ /** Awaitable variant of track for jobs/scripts that must not exit early. */
97
+ trackAndWait(params: TrackParams): Promise<void>;
98
+ /** Tell MarginFuse what your app actually did with a decision (§25.2). */
99
+ acknowledge(decisionId: string, acknowledgment: Acknowledgment): void;
100
+ /**
101
+ * Full protection loop in one wrapper: decide → run your provider call with
102
+ * the resolved model → report usage → acknowledge.
103
+ *
104
+ * const out = await mf.guard(
105
+ * { customerId, feature: "ai_chat", provider: "openai", model: "gpt-4.1" },
106
+ * async ({ model }) => {
107
+ * const r = await openai.chat.completions.create({ model, messages });
108
+ * return { result: r, usage: { inputTokens: r.usage.prompt_tokens, outputTokens: r.usage.completion_tokens } };
109
+ * },
110
+ * );
111
+ * if (out.kind === "completed") use(out.result);
112
+ * else handle out.kind === "blocked" | "topup_required" with your own UX.
113
+ */
114
+ guard<T>(params: DecideParams, run: (ctx: {
115
+ model: string;
116
+ provider: string;
117
+ decision: Decision;
118
+ }) => Promise<{
119
+ result: T;
120
+ usage: TrackParams["usage"];
121
+ costUsd?: string;
122
+ outcome?: TrackParams["outcome"];
123
+ }>): Promise<{
124
+ kind: "completed";
125
+ result: T;
126
+ decision: Decision;
127
+ } | {
128
+ kind: "blocked";
129
+ decision: Decision;
130
+ } | {
131
+ kind: "topup_required";
132
+ decision: Decision;
133
+ }>;
134
+ /** Wait for queued track/ack calls (use before process exit). */
135
+ flush(): Promise<void>;
136
+ private background;
137
+ private post;
138
+ }
139
+
140
+ /**
141
+ * OpenRouter helper.
142
+ *
143
+ * OpenRouter returns a `usage` object on every response (no opt-in parameter -
144
+ * the old `usage: { include: true }` flag is deprecated and does nothing), and
145
+ * that object carries the provider-final `cost`. Forwarding it is what makes an
146
+ * OpenRouter integration exact rather than estimated: MarginFuse cannot know
147
+ * what a gateway charged, because routing, fees and BYOK terms are not visible
148
+ * in a usage event.
149
+ *
150
+ * Two details this helper exists to get right, both of which silently
151
+ * misstate margin when hand-rolled:
152
+ *
153
+ * 1. `prompt_tokens` is the TOTAL input count - cached reads and cache writes
154
+ * are already inside it. MarginFuse prices inputTokens, cachedInputTokens
155
+ * and cacheCreationTokens as three separate charges and adds them up, so
156
+ * passing `prompt_tokens` straight through double-counts every cached
157
+ * token, at the full uncached rate.
158
+ * 2. `cost` is a JavaScript number, and small ones stringify to exponent
159
+ * notation ("1.2e-7"), which the API rejects as a decimal string.
160
+ */
161
+
162
+ /**
163
+ * The fields this helper reads from an OpenRouter `usage` object. Structural on
164
+ * purpose - it accepts the response of the OpenAI SDK pointed at OpenRouter,
165
+ * or a plain fetch, without either side importing the other's types.
166
+ */
167
+ interface OpenRouterUsage {
168
+ prompt_tokens?: number | null;
169
+ completion_tokens?: number | null;
170
+ cost?: number | null;
171
+ prompt_tokens_details?: {
172
+ cached_tokens?: number | null;
173
+ cache_write_tokens?: number | null;
174
+ audio_tokens?: number | null;
175
+ } | null;
176
+ }
177
+ /**
178
+ * Map an OpenRouter `usage` object to the MarginFuse fields, ready to spread
179
+ * into track() or return from guard().
180
+ *
181
+ * const r = await openai.chat.completions.create({ model, messages });
182
+ * mf.track({ customerId, feature: "ai_chat", provider: "openrouter", model, ...fromOpenRouter(r.usage) });
183
+ *
184
+ * `costUsd` is omitted when the response carried no cost, which lets the event
185
+ * fall through to MarginFuse's own pricing instead of claiming a $0 charge.
186
+ */
187
+ declare function fromOpenRouter(usage: OpenRouterUsage | null | undefined): Pick<TrackParams, "usage"> & {
188
+ costUsd?: string;
189
+ };
190
+
191
+ export { type Acknowledgment, type DecideParams, type Decision, type DecisionAction, MarginFuse, type MarginFuseOptions, type OpenRouterUsage, type Outcome, type TrackParams, type Usage, fromOpenRouter };
package/dist/index.js ADDED
@@ -0,0 +1,236 @@
1
+ // src/client.ts
2
+ var DEFAULT_BASE_URL = "https://api.marginfuse.com";
3
+ var DEFAULT_TIMEOUT_MS = 1500;
4
+ var TRACK_RETRIES = 3;
5
+ var MarginFuse = class {
6
+ apiKey;
7
+ baseUrl;
8
+ timeoutMs;
9
+ onError;
10
+ fetchImpl;
11
+ pending = /* @__PURE__ */ new Set();
12
+ constructor(options) {
13
+ if (!options.apiKey) throw new Error("MarginFuse: apiKey is required");
14
+ this.apiKey = options.apiKey;
15
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
16
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
17
+ this.onError = options.onError ?? (() => {
18
+ });
19
+ this.fetchImpl = options.fetch ?? fetch;
20
+ }
21
+ /**
22
+ * Pre-request policy check (protection-ready integrations, §13.2).
23
+ * Always resolves. On any failure resolves {action:"allow", degraded:true}.
24
+ */
25
+ async decide(params) {
26
+ const failOpen = (reason) => ({
27
+ action: "allow",
28
+ model: params.model,
29
+ provider: params.provider,
30
+ degraded: true,
31
+ degradedReason: reason
32
+ });
33
+ try {
34
+ const res = await this.post("/v1/decisions", params, this.timeoutMs);
35
+ if (!res.ok) {
36
+ this.onError(new Error(`decide: HTTP ${res.status}`), "decide");
37
+ return failOpen(`server responded ${res.status}`);
38
+ }
39
+ const body = await res.json();
40
+ return {
41
+ id: body.id,
42
+ action: body.action,
43
+ model: body.model ?? params.model,
44
+ provider: body.provider ?? params.provider,
45
+ ...body.topupContext !== void 0 ? { topupContext: body.topupContext } : {},
46
+ degraded: body.degraded ?? false,
47
+ ...body.degradedReason !== void 0 ? { degradedReason: body.degradedReason } : {}
48
+ };
49
+ } catch (err) {
50
+ this.onError(err, "decide");
51
+ return failOpen(err.name === "TimeoutError" ? "timeout" : "unreachable");
52
+ }
53
+ }
54
+ /**
55
+ * Report actual usage after the provider call (monitor-only §13.1 and
56
+ * post-request reconciliation §25.3). Fire-and-forget with retries.
57
+ */
58
+ track(params) {
59
+ const event = {
60
+ eventId: params.eventId ?? cryptoRandomId(),
61
+ occurredAt: (params.occurredAt ?? /* @__PURE__ */ new Date()).toISOString(),
62
+ outcome: params.outcome ?? "success",
63
+ ...params
64
+ };
65
+ this.background(async () => {
66
+ let lastErr;
67
+ for (let attempt = 0; attempt < TRACK_RETRIES; attempt++) {
68
+ try {
69
+ const res = await this.post("/v1/events", { events: [event] }, 5e3);
70
+ if (res.ok) return;
71
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
72
+ this.onError(new Error(`track: HTTP ${res.status} ${await safeText(res)}`), "track");
73
+ return;
74
+ }
75
+ lastErr = new Error(`track: HTTP ${res.status}`);
76
+ } catch (err) {
77
+ lastErr = err;
78
+ }
79
+ await sleep(250 * 2 ** attempt);
80
+ }
81
+ if (lastErr) this.onError(lastErr, "track");
82
+ });
83
+ }
84
+ /** Awaitable variant of track for jobs/scripts that must not exit early. */
85
+ async trackAndWait(params) {
86
+ this.track(params);
87
+ await this.flush();
88
+ }
89
+ /** Tell MarginFuse what your app actually did with a decision (§25.2). */
90
+ acknowledge(decisionId, acknowledgment) {
91
+ this.background(async () => {
92
+ try {
93
+ const res = await this.post(`/v1/decisions/${encodeURIComponent(decisionId)}/ack`, { acknowledgment }, 5e3);
94
+ if (!res.ok) this.onError(new Error(`ack: HTTP ${res.status}`), "acknowledge");
95
+ } catch (err) {
96
+ this.onError(err, "acknowledge");
97
+ }
98
+ });
99
+ }
100
+ /**
101
+ * Full protection loop in one wrapper: decide → run your provider call with
102
+ * the resolved model → report usage → acknowledge.
103
+ *
104
+ * const out = await mf.guard(
105
+ * { customerId, feature: "ai_chat", provider: "openai", model: "gpt-4.1" },
106
+ * async ({ model }) => {
107
+ * const r = await openai.chat.completions.create({ model, messages });
108
+ * return { result: r, usage: { inputTokens: r.usage.prompt_tokens, outputTokens: r.usage.completion_tokens } };
109
+ * },
110
+ * );
111
+ * if (out.kind === "completed") use(out.result);
112
+ * else handle out.kind === "blocked" | "topup_required" with your own UX.
113
+ */
114
+ async guard(params, run) {
115
+ const decision = await this.decide(params);
116
+ if (decision.action === "block") {
117
+ if (decision.id) this.acknowledge(decision.id, "blocked_before_provider_call");
118
+ return { kind: "blocked", decision };
119
+ }
120
+ if (decision.action === "topup_required") {
121
+ if (decision.id) this.acknowledge(decision.id, "presented_topup");
122
+ return { kind: "topup_required", decision };
123
+ }
124
+ const modelToUse = decision.action === "downgrade" ? decision.model : params.model;
125
+ let outcome = "success";
126
+ try {
127
+ const out = await run({ model: modelToUse, provider: decision.provider, decision });
128
+ this.track({
129
+ customerId: params.customerId,
130
+ ...params.feature !== void 0 ? { feature: params.feature } : {},
131
+ provider: params.provider,
132
+ model: modelToUse,
133
+ requestedModel: params.model,
134
+ usage: out.usage,
135
+ ...out.costUsd !== void 0 ? { costUsd: out.costUsd } : {},
136
+ outcome: out.outcome ?? "success",
137
+ ...decision.id !== void 0 ? { decisionId: decision.id } : {}
138
+ });
139
+ if (decision.id) {
140
+ this.acknowledge(
141
+ decision.id,
142
+ decision.action === "downgrade" ? "used_downgrade_model" : "proceeded_as_requested"
143
+ );
144
+ }
145
+ return { kind: "completed", result: out.result, decision };
146
+ } catch (err) {
147
+ outcome = "provider_error";
148
+ this.track({
149
+ customerId: params.customerId,
150
+ ...params.feature !== void 0 ? { feature: params.feature } : {},
151
+ provider: params.provider,
152
+ model: modelToUse,
153
+ requestedModel: params.model,
154
+ usage: {},
155
+ outcome,
156
+ ...decision.id !== void 0 ? { decisionId: decision.id } : {}
157
+ });
158
+ if (decision.id) this.acknowledge(decision.id, "proceeded_as_requested");
159
+ throw err;
160
+ }
161
+ }
162
+ /** Wait for queued track/ack calls (use before process exit). */
163
+ async flush() {
164
+ await Promise.allSettled([...this.pending]);
165
+ }
166
+ background(fn) {
167
+ const p = fn().finally(() => this.pending.delete(p));
168
+ this.pending.add(p);
169
+ }
170
+ post(path, body, timeoutMs) {
171
+ return this.fetchImpl(`${this.baseUrl}${path}`, {
172
+ method: "POST",
173
+ headers: {
174
+ authorization: `Bearer ${this.apiKey}`,
175
+ "content-type": "application/json",
176
+ "user-agent": "marginfuse-node/0.1.0"
177
+ },
178
+ body: JSON.stringify(body),
179
+ signal: AbortSignal.timeout(timeoutMs)
180
+ });
181
+ }
182
+ };
183
+ function cryptoRandomId() {
184
+ return `evt_${uuidV4()}`;
185
+ }
186
+ function uuidV4() {
187
+ const webcrypto = globalThis.crypto;
188
+ if (typeof webcrypto?.randomUUID === "function") return webcrypto.randomUUID();
189
+ const bytes = new Uint8Array(16);
190
+ if (typeof webcrypto?.getRandomValues === "function") {
191
+ webcrypto.getRandomValues(bytes);
192
+ } else {
193
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
194
+ }
195
+ bytes[6] = bytes[6] & 15 | 64;
196
+ bytes[8] = bytes[8] & 63 | 128;
197
+ const hex = [];
198
+ for (const b of bytes) hex.push(b.toString(16).padStart(2, "0"));
199
+ return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
200
+ }
201
+ function sleep(ms) {
202
+ return new Promise((r) => setTimeout(r, ms));
203
+ }
204
+ async function safeText(res) {
205
+ try {
206
+ return (await res.text()).slice(0, 200);
207
+ } catch {
208
+ return "";
209
+ }
210
+ }
211
+
212
+ // src/openrouter.ts
213
+ var int = (n) => typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
214
+ function creditsToUsd(cost) {
215
+ const s = cost.toFixed(9);
216
+ const trimmed = s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s;
217
+ return trimmed === "" || trimmed === "-0" ? "0" : trimmed;
218
+ }
219
+ function fromOpenRouter(usage) {
220
+ const cachedInputTokens = int(usage?.prompt_tokens_details?.cached_tokens);
221
+ const cacheCreationTokens = int(usage?.prompt_tokens_details?.cache_write_tokens);
222
+ const inputTokens = Math.max(0, int(usage?.prompt_tokens) - cachedInputTokens - cacheCreationTokens);
223
+ const out = {};
224
+ if (inputTokens > 0) out.inputTokens = inputTokens;
225
+ const outputTokens = int(usage?.completion_tokens);
226
+ if (outputTokens > 0) out.outputTokens = outputTokens;
227
+ if (cachedInputTokens > 0) out.cachedInputTokens = cachedInputTokens;
228
+ if (cacheCreationTokens > 0) out.cacheCreationTokens = cacheCreationTokens;
229
+ const cost = usage?.cost;
230
+ const hasCost = typeof cost === "number" && Number.isFinite(cost) && cost >= 0;
231
+ return { usage: out, ...hasCost ? { costUsd: creditsToUsd(cost) } : {} };
232
+ }
233
+ export {
234
+ MarginFuse,
235
+ fromOpenRouter
236
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "marginfuse",
3
+ "version": "0.2.0",
4
+ "description": "MarginFuse server-side SDK. AI profitability guardrails: connect revenue to per-request AI cost and stop loss-making requests before they run. Sends usage metadata only, never prompts or responses.",
5
+ "license": "MIT",
6
+ "author": "Pemira Labs",
7
+ "homepage": "https://marginfuse.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/marginfuse/marginfuse-node.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/marginfuse/marginfuse-node/issues"
14
+ },
15
+ "keywords": [
16
+ "ai",
17
+ "llm",
18
+ "cost",
19
+ "margin",
20
+ "profitability",
21
+ "stripe",
22
+ "openai",
23
+ "anthropic",
24
+ "openrouter"
25
+ ],
26
+ "type": "module",
27
+ "main": "./dist/index.cjs",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js",
34
+ "require": "./dist/index.cjs"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "LICENSE",
41
+ "CHANGELOG.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "vitest run",
53
+ "conformance": "npm --prefix contract/harness run conformance node"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.20.1",
57
+ "tsup": "^8.5.0",
58
+ "typescript": "^5.9.2",
59
+ "vitest": "^3.2.0"
60
+ }
61
+ }