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 +48 -0
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/index.cjs +264 -0
- package/dist/index.d.cts +191 -0
- package/dist/index.d.ts +191 -0
- package/dist/index.js +236 -0
- package/package.json +61 -0
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
|
+
[](https://www.npmjs.com/package/marginfuse)
|
|
4
|
+
[](https://github.com/marginfuse/marginfuse-node/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|