@agentic-kit/metering 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/LICENSE +23 -0
- package/README.md +152 -0
- package/esm/gateway.d.ts +85 -0
- package/esm/gateway.js +76 -0
- package/esm/identity.d.ts +37 -0
- package/esm/identity.js +39 -0
- package/esm/index.d.ts +13 -0
- package/esm/index.js +13 -0
- package/esm/reporter.d.ts +38 -0
- package/esm/reporter.js +67 -0
- package/esm/usage-report.d.ts +56 -0
- package/esm/usage-report.js +48 -0
- package/gateway.d.ts +85 -0
- package/gateway.js +82 -0
- package/identity.d.ts +37 -0
- package/identity.js +43 -0
- package/index.d.ts +13 -0
- package/index.js +29 -0
- package/package.json +39 -0
- package/reporter.d.ts +38 -0
- package/reporter.js +72 -0
- package/usage-report.d.ts +56 -0
- package/usage-report.js +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
|
|
4
|
+
Copyright (c) 2025 Constructive <developers@constructive.io>
|
|
5
|
+
Copyright (c) 2020-present, Interweb, Inc.
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<p align="center" width="100%">
|
|
2
|
+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# @agentic-kit/metering
|
|
6
|
+
|
|
7
|
+
Metering for an agent run: the Constructive gateway as a model endpoint, the run's identity headers, and usage reporting — none of it tied to a particular harness.
|
|
8
|
+
|
|
9
|
+
A run's model calls have to end up in `inference_log` under the same identity as every other platform inference call. *How* a harness is told which endpoint to call is vendor-specific; *what* that endpoint is, and who the run is, is not. This package owns the second part, so an adapter is left with a translation:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
resolveMeteredGateway({ gatewayUrl, identity, models })
|
|
13
|
+
│
|
|
14
|
+
└─► MeteredGateway ──► the harness's own provider config
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install @agentic-kit/metering
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## The two lanes
|
|
24
|
+
|
|
25
|
+
| lane | authority |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| model calls leave through the gateway (`resolveMeteredGateway`) | the gateway — the agent cannot under-report |
|
|
28
|
+
| the host owns the provider keys and reports afterwards (`UsageReporter`) | self-reported; good for reconciliation, not for billing |
|
|
29
|
+
|
|
30
|
+
Cloud runs use the first. A laptop run on the user's own key uses the second.
|
|
31
|
+
|
|
32
|
+
## Gateway
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { resolveMeteredGateway } from '@agentic-kit/metering';
|
|
36
|
+
|
|
37
|
+
const gateway = resolveMeteredGateway({
|
|
38
|
+
gatewayUrl: 'https://agentic.example.com', // the gateway root, NOT the /v1 path
|
|
39
|
+
identity: {
|
|
40
|
+
databaseId: process.env.CONSTRUCTIVE_DATABASE_ID!,
|
|
41
|
+
entityId: process.env.CONSTRUCTIVE_OWNER_ID,
|
|
42
|
+
actorId: runActorId,
|
|
43
|
+
runToken // run-scoped, never an account token
|
|
44
|
+
},
|
|
45
|
+
models: [{ id: 'anthropic/claude-sonnet-4', contextWindow: 200000, maxTokens: 8192, input: ['text', 'image'] }]
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **Identity** travels as `X-Database-Id` / `X-Entity-Id` / `X-Actor-Id`, the headers `agentic-server` already reads, plus `Authorization: Bearer <runToken>` when one is given. Extra `headers` are merged but can never shadow identity.
|
|
50
|
+
- **Headers are only as trustworthy as the network.** In-cluster, or behind an ingress that pins identity from the bearer, they are authoritative; a host outside that boundary must send `runToken` and let the ingress pin it.
|
|
51
|
+
- **Misconfiguration fails at construction**, not at the first turn: a blank `databaseId`, a relative or non-http `gatewayUrl`, a `gatewayUrl` already ending in `/v1` (harnesses append their own, so the request would 404), or an empty model list.
|
|
52
|
+
- **Cost fields default to zero.** The gateway is the pricing authority; zeros mean "not priced client-side", not free.
|
|
53
|
+
|
|
54
|
+
## Usage reporting
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { httpUsageSink, toUsageReport, UsageReporter } from '@agentic-kit/metering';
|
|
58
|
+
|
|
59
|
+
const reporter = new UsageReporter({
|
|
60
|
+
identity,
|
|
61
|
+
sink: httpUsageSink({ gatewayUrl: 'https://agentic.example.com', identity })
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
reporter.enqueue(toUsageReport(message, identity));
|
|
65
|
+
await reporter.flush();
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`toUsageReport` reads a harness-neutral assistant-message slice (`provider`, `model`, token counts, `stopReason`), so an adapter passes its own message through without a translation layer. Reports queue and are drained on `flush()`, which a run's shutdown path awaits.
|
|
69
|
+
|
|
70
|
+
## Testing
|
|
71
|
+
|
|
72
|
+
`pnpm test` — gateway and identity resolution are asserted directly and the reporter is driven against a fake sink, so no gateway or model credentials are needed.
|
|
73
|
+
|
|
74
|
+
## Related
|
|
75
|
+
|
|
76
|
+
- `@agentic-kit/harness` — the neutral harness contracts (adapter, run gate, approvals)
|
|
77
|
+
- `@agentic-kit/run-log` — the append-only run log
|
|
78
|
+
- `@agentic-kit/pi` — the pi adapter, which turns a `MeteredGateway` into pi's provider config
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Education and Tutorials
|
|
83
|
+
|
|
84
|
+
1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
|
|
85
|
+
Get started with modular databases in minutes. Install prerequisites and deploy your first module.
|
|
86
|
+
|
|
87
|
+
2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
|
|
88
|
+
Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
|
|
89
|
+
|
|
90
|
+
3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
|
|
91
|
+
Master the workflow for adding, organizing, and managing database changes with pgpm.
|
|
92
|
+
|
|
93
|
+
4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
|
|
94
|
+
Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
|
|
95
|
+
|
|
96
|
+
5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
|
|
97
|
+
Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
|
|
98
|
+
|
|
99
|
+
6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
|
|
100
|
+
Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
|
|
101
|
+
|
|
102
|
+
7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
|
|
103
|
+
Common issues and solutions for pgpm, PostgreSQL, and testing.
|
|
104
|
+
|
|
105
|
+
## Related Constructive Tooling
|
|
106
|
+
|
|
107
|
+
### 📦 Package Management
|
|
108
|
+
|
|
109
|
+
* [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
|
|
110
|
+
|
|
111
|
+
### 🧪 Testing
|
|
112
|
+
|
|
113
|
+
* [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
|
|
114
|
+
* [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
|
|
115
|
+
* [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
|
|
116
|
+
* [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
|
|
117
|
+
* [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
|
|
118
|
+
* [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
|
|
119
|
+
|
|
120
|
+
### 🧠 Parsing & AST
|
|
121
|
+
|
|
122
|
+
* [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
|
|
123
|
+
* [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
|
|
124
|
+
* [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
|
|
125
|
+
* [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
|
|
126
|
+
* [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
|
|
127
|
+
* [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
|
|
128
|
+
|
|
129
|
+
### 📚 Documentation & Skills
|
|
130
|
+
|
|
131
|
+
* [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
|
|
132
|
+
|
|
133
|
+
Install skills for AI coding agents:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# All platform skills (security, blueprints, codegen, billing, etc.)
|
|
137
|
+
npx skills add constructive-io/constructive-skills
|
|
138
|
+
|
|
139
|
+
# Individual repo skills (pgpm, testing, CLI, search, etc.)
|
|
140
|
+
npx skills add https://github.com/constructive-io/constructive --skill pgpm
|
|
141
|
+
npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Credits
|
|
145
|
+
|
|
146
|
+
**🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
|
|
147
|
+
|
|
148
|
+
## Disclaimer
|
|
149
|
+
|
|
150
|
+
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
151
|
+
|
|
152
|
+
No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
package/esm/gateway.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The metered gateway as a resolved model endpoint.
|
|
3
|
+
*
|
|
4
|
+
* `agentic-server` speaks OpenAI's `/v1/chat/completions`, so routing a harness
|
|
5
|
+
* through it needs no custom streaming code — only an endpoint whose `baseUrl` is
|
|
6
|
+
* the gateway and whose headers carry the run's identity. Every harness worth
|
|
7
|
+
* adapting can already talk to an OpenAI-compatible endpoint, which is why this
|
|
8
|
+
* resolution is neutral and only the registration is vendor-specific: an adapter
|
|
9
|
+
* turns `MeteredGateway` into whatever its harness calls a provider.
|
|
10
|
+
*/
|
|
11
|
+
import { type MeteredIdentity } from './identity';
|
|
12
|
+
/** The api the gateway exposes. */
|
|
13
|
+
export declare const GATEWAY_API = "openai-completions";
|
|
14
|
+
/** Default provider name; also the prefix a model picker shows. */
|
|
15
|
+
export declare const DEFAULT_PROVIDER_NAME = "constructive-gateway";
|
|
16
|
+
/** Per-token cost, for client-side display only. */
|
|
17
|
+
export interface MeteredModelCost {
|
|
18
|
+
input: number;
|
|
19
|
+
output: number;
|
|
20
|
+
cacheRead: number;
|
|
21
|
+
cacheWrite: number;
|
|
22
|
+
}
|
|
23
|
+
export interface MeteredModelSpec {
|
|
24
|
+
/** Model id as the gateway routes it, e.g. `anthropic/claude-sonnet-4`. */
|
|
25
|
+
id: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
contextWindow: number;
|
|
28
|
+
maxTokens: number;
|
|
29
|
+
reasoning?: boolean;
|
|
30
|
+
input?: ('text' | 'image')[];
|
|
31
|
+
/**
|
|
32
|
+
* Per-token cost for the host's own display. The gateway is the billing
|
|
33
|
+
* authority, so zeros here mean "not priced client-side", never "free".
|
|
34
|
+
*/
|
|
35
|
+
cost?: MeteredModelCost;
|
|
36
|
+
}
|
|
37
|
+
export interface MeteredGatewayOptions {
|
|
38
|
+
/** Gateway root, e.g. `https://agentic.example.com` — not a `/v1` path. */
|
|
39
|
+
gatewayUrl: string;
|
|
40
|
+
identity: MeteredIdentity;
|
|
41
|
+
models: readonly MeteredModelSpec[];
|
|
42
|
+
/** Provider name to register under. Defaults to `constructive-gateway`. */
|
|
43
|
+
providerName?: string;
|
|
44
|
+
/** Display name in the host's UI. */
|
|
45
|
+
displayName?: string;
|
|
46
|
+
/** Extra headers, e.g. `X-LLM-Provider` to pin the upstream provider. */
|
|
47
|
+
headers?: Record<string, string>;
|
|
48
|
+
/**
|
|
49
|
+
* API key for the gateway itself. Identity travels in headers, so this is
|
|
50
|
+
* usually unnecessary; when the ingress wants a bearer, prefer
|
|
51
|
+
* `identity.runToken`.
|
|
52
|
+
*/
|
|
53
|
+
apiKey?: string;
|
|
54
|
+
}
|
|
55
|
+
/** A gateway endpoint an adapter can register with its harness. */
|
|
56
|
+
export interface MeteredGateway {
|
|
57
|
+
providerName: string;
|
|
58
|
+
displayName: string;
|
|
59
|
+
baseUrl: string;
|
|
60
|
+
api: typeof GATEWAY_API;
|
|
61
|
+
headers: Record<string, string>;
|
|
62
|
+
apiKey: string;
|
|
63
|
+
models: readonly MeteredModel[];
|
|
64
|
+
}
|
|
65
|
+
/** A model as the gateway serves it, with every default resolved. */
|
|
66
|
+
export interface MeteredModel {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
reasoning: boolean;
|
|
70
|
+
input: ('text' | 'image')[];
|
|
71
|
+
cost: MeteredModelCost;
|
|
72
|
+
contextWindow: number;
|
|
73
|
+
maxTokens: number;
|
|
74
|
+
}
|
|
75
|
+
/** Normalize the gateway root: absolute http(s), no trailing slash, no `/v1`. */
|
|
76
|
+
export declare function normalizeGatewayUrl(gatewayUrl: string): string;
|
|
77
|
+
export declare function resolveMeteredModel(spec: MeteredModelSpec): MeteredModel;
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the gateway endpoint for a run.
|
|
80
|
+
*
|
|
81
|
+
* Identity headers are computed once here rather than per request: a run's
|
|
82
|
+
* identity is fixed for its lifetime, and harnesses generally expose no
|
|
83
|
+
* per-request header hook a provider registration participates in.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveMeteredGateway(options: MeteredGatewayOptions): MeteredGateway;
|
package/esm/gateway.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The metered gateway as a resolved model endpoint.
|
|
3
|
+
*
|
|
4
|
+
* `agentic-server` speaks OpenAI's `/v1/chat/completions`, so routing a harness
|
|
5
|
+
* through it needs no custom streaming code — only an endpoint whose `baseUrl` is
|
|
6
|
+
* the gateway and whose headers carry the run's identity. Every harness worth
|
|
7
|
+
* adapting can already talk to an OpenAI-compatible endpoint, which is why this
|
|
8
|
+
* resolution is neutral and only the registration is vendor-specific: an adapter
|
|
9
|
+
* turns `MeteredGateway` into whatever its harness calls a provider.
|
|
10
|
+
*/
|
|
11
|
+
import { buildIdentityHeaders } from './identity';
|
|
12
|
+
/** The api the gateway exposes. */
|
|
13
|
+
export const GATEWAY_API = 'openai-completions';
|
|
14
|
+
/** Default provider name; also the prefix a model picker shows. */
|
|
15
|
+
export const DEFAULT_PROVIDER_NAME = 'constructive-gateway';
|
|
16
|
+
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
17
|
+
/** Normalize the gateway root: absolute http(s), no trailing slash, no `/v1`. */
|
|
18
|
+
export function normalizeGatewayUrl(gatewayUrl) {
|
|
19
|
+
const raw = gatewayUrl?.trim();
|
|
20
|
+
if (!raw)
|
|
21
|
+
throw new Error('metered model: gatewayUrl is required');
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = new URL(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`metered model: gatewayUrl must be an absolute URL, got ${raw}`);
|
|
28
|
+
}
|
|
29
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
30
|
+
throw new Error(`metered model: gatewayUrl must be http(s), got ${parsed.protocol}`);
|
|
31
|
+
}
|
|
32
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
33
|
+
// An OpenAI-compatible client appends `/v1/chat/completions`, so a baseUrl that
|
|
34
|
+
// already ends in `/v1` would request `/v1/v1/chat/completions` and 404 at the
|
|
35
|
+
// first model turn.
|
|
36
|
+
if (/\/v1$/.test(path))
|
|
37
|
+
throw new Error(`metered model: gatewayUrl must be the gateway root, not ${raw} (drop the /v1)`);
|
|
38
|
+
return `${parsed.origin}${path}`;
|
|
39
|
+
}
|
|
40
|
+
export function resolveMeteredModel(spec) {
|
|
41
|
+
if (!spec.id?.trim())
|
|
42
|
+
throw new Error('metered model: model id is required');
|
|
43
|
+
return {
|
|
44
|
+
id: spec.id,
|
|
45
|
+
name: spec.name ?? spec.id,
|
|
46
|
+
reasoning: spec.reasoning ?? false,
|
|
47
|
+
input: spec.input ? [...spec.input] : ['text'],
|
|
48
|
+
cost: spec.cost ?? { ...ZERO_COST },
|
|
49
|
+
contextWindow: spec.contextWindow,
|
|
50
|
+
maxTokens: spec.maxTokens
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the gateway endpoint for a run.
|
|
55
|
+
*
|
|
56
|
+
* Identity headers are computed once here rather than per request: a run's
|
|
57
|
+
* identity is fixed for its lifetime, and harnesses generally expose no
|
|
58
|
+
* per-request header hook a provider registration participates in.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveMeteredGateway(options) {
|
|
61
|
+
if (options.models.length === 0)
|
|
62
|
+
throw new Error('metered model: at least one model is required');
|
|
63
|
+
const headers = { ...options.headers, ...buildIdentityHeaders(options.identity) };
|
|
64
|
+
return {
|
|
65
|
+
providerName: options.providerName ?? DEFAULT_PROVIDER_NAME,
|
|
66
|
+
displayName: options.displayName ?? 'Constructive (metered)',
|
|
67
|
+
baseUrl: normalizeGatewayUrl(options.gatewayUrl),
|
|
68
|
+
api: GATEWAY_API,
|
|
69
|
+
headers,
|
|
70
|
+
// Harnesses typically require an apiKey once models are declared; the gateway
|
|
71
|
+
// authenticates by identity headers, so a placeholder satisfies that
|
|
72
|
+
// validation without implying a real secret.
|
|
73
|
+
apiKey: options.apiKey ?? headers.Authorization?.replace(/^Bearer /, '') ?? 'unused',
|
|
74
|
+
models: options.models.map(resolveMeteredModel)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a metered request belongs to.
|
|
3
|
+
*
|
|
4
|
+
* These are exactly the headers `agentic-server` reads (`X-Database-Id`,
|
|
5
|
+
* `X-Entity-Id`, `X-Actor-Id`) — the same identity lane the platform's own
|
|
6
|
+
* clients use — so a pi session lands in `inference_log` beside every other
|
|
7
|
+
* metered call rather than in a parallel accounting scheme.
|
|
8
|
+
*
|
|
9
|
+
* Headers are only trustworthy where the gateway is not reachable by the
|
|
10
|
+
* untrusted party: in-cluster, or behind an authenticated ingress that pins the
|
|
11
|
+
* identity from the bearer. Off-cluster hosts must send `runToken` and let the
|
|
12
|
+
* ingress do the pinning.
|
|
13
|
+
*/
|
|
14
|
+
export interface MeteredIdentity {
|
|
15
|
+
/** Tenant database the usage is billed to. Required by the gateway. */
|
|
16
|
+
databaseId: string;
|
|
17
|
+
/** Owning entity (organization/user) when the platform tracks one. */
|
|
18
|
+
entityId?: string;
|
|
19
|
+
/** The actor on whose behalf the run executes. */
|
|
20
|
+
actorId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Bearer token for the gateway's ingress — run-scoped, not an account token.
|
|
23
|
+
* Sent as `Authorization: Bearer <token>`.
|
|
24
|
+
*/
|
|
25
|
+
runToken?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const DATABASE_ID_HEADER = "X-Database-Id";
|
|
28
|
+
export declare const ENTITY_ID_HEADER = "X-Entity-Id";
|
|
29
|
+
export declare const ACTOR_ID_HEADER = "X-Actor-Id";
|
|
30
|
+
/**
|
|
31
|
+
* Build the identity headers for a metered request.
|
|
32
|
+
*
|
|
33
|
+
* Throws on a missing/blank `databaseId`: the gateway would reject the call with
|
|
34
|
+
* a 400 at the first model turn, which surfaces as an opaque agent failure much
|
|
35
|
+
* later than the misconfiguration.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildIdentityHeaders(identity: MeteredIdentity): Record<string, string>;
|
package/esm/identity.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a metered request belongs to.
|
|
3
|
+
*
|
|
4
|
+
* These are exactly the headers `agentic-server` reads (`X-Database-Id`,
|
|
5
|
+
* `X-Entity-Id`, `X-Actor-Id`) — the same identity lane the platform's own
|
|
6
|
+
* clients use — so a pi session lands in `inference_log` beside every other
|
|
7
|
+
* metered call rather than in a parallel accounting scheme.
|
|
8
|
+
*
|
|
9
|
+
* Headers are only trustworthy where the gateway is not reachable by the
|
|
10
|
+
* untrusted party: in-cluster, or behind an authenticated ingress that pins the
|
|
11
|
+
* identity from the bearer. Off-cluster hosts must send `runToken` and let the
|
|
12
|
+
* ingress do the pinning.
|
|
13
|
+
*/
|
|
14
|
+
export const DATABASE_ID_HEADER = 'X-Database-Id';
|
|
15
|
+
export const ENTITY_ID_HEADER = 'X-Entity-Id';
|
|
16
|
+
export const ACTOR_ID_HEADER = 'X-Actor-Id';
|
|
17
|
+
/**
|
|
18
|
+
* Build the identity headers for a metered request.
|
|
19
|
+
*
|
|
20
|
+
* Throws on a missing/blank `databaseId`: the gateway would reject the call with
|
|
21
|
+
* a 400 at the first model turn, which surfaces as an opaque agent failure much
|
|
22
|
+
* later than the misconfiguration.
|
|
23
|
+
*/
|
|
24
|
+
export function buildIdentityHeaders(identity) {
|
|
25
|
+
const databaseId = identity.databaseId?.trim();
|
|
26
|
+
if (!databaseId)
|
|
27
|
+
throw new Error('metered model: identity.databaseId is required');
|
|
28
|
+
const headers = { [DATABASE_ID_HEADER]: databaseId };
|
|
29
|
+
const entityId = identity.entityId?.trim();
|
|
30
|
+
if (entityId)
|
|
31
|
+
headers[ENTITY_ID_HEADER] = entityId;
|
|
32
|
+
const actorId = identity.actorId?.trim();
|
|
33
|
+
if (actorId)
|
|
34
|
+
headers[ACTOR_ID_HEADER] = actorId;
|
|
35
|
+
const runToken = identity.runToken?.trim();
|
|
36
|
+
if (runToken)
|
|
37
|
+
headers.Authorization = `Bearer ${runToken}`;
|
|
38
|
+
return headers;
|
|
39
|
+
}
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@agentic-kit/metering` — harness-neutral metering for an agent run: the
|
|
3
|
+
* Constructive gateway as a resolved model endpoint, the run identity that
|
|
4
|
+
* travels with every call, and the `/v1/usage` report a self-reporting run
|
|
5
|
+
* delivers.
|
|
6
|
+
*
|
|
7
|
+
* No harness SDK is imported here. An adapter registers `MeteredGateway` with
|
|
8
|
+
* its own harness and feeds `toUsageReport()` from its own message events.
|
|
9
|
+
*/
|
|
10
|
+
export { DEFAULT_PROVIDER_NAME, GATEWAY_API, type MeteredGateway, type MeteredGatewayOptions, type MeteredModel, type MeteredModelCost, type MeteredModelSpec, normalizeGatewayUrl, resolveMeteredGateway, resolveMeteredModel } from './gateway';
|
|
11
|
+
export { ACTOR_ID_HEADER, buildIdentityHeaders, DATABASE_ID_HEADER, ENTITY_ID_HEADER, type MeteredIdentity } from './identity';
|
|
12
|
+
export { httpUsageSink, type HttpUsageSinkOptions, UsageReporter, type UsageReporterOptions, type UsageSink } from './reporter';
|
|
13
|
+
export { type AssistantUsageMessage, isAssistantMessage, toUsageReport, type ToUsageReportOptions, type UsageReport } from './usage-report';
|
package/esm/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@agentic-kit/metering` — harness-neutral metering for an agent run: the
|
|
3
|
+
* Constructive gateway as a resolved model endpoint, the run identity that
|
|
4
|
+
* travels with every call, and the `/v1/usage` report a self-reporting run
|
|
5
|
+
* delivers.
|
|
6
|
+
*
|
|
7
|
+
* No harness SDK is imported here. An adapter registers `MeteredGateway` with
|
|
8
|
+
* its own harness and feeds `toUsageReport()` from its own message events.
|
|
9
|
+
*/
|
|
10
|
+
export { DEFAULT_PROVIDER_NAME, GATEWAY_API, normalizeGatewayUrl, resolveMeteredGateway, resolveMeteredModel } from './gateway';
|
|
11
|
+
export { ACTOR_ID_HEADER, buildIdentityHeaders, DATABASE_ID_HEADER, ENTITY_ID_HEADER } from './identity';
|
|
12
|
+
export { httpUsageSink, UsageReporter } from './reporter';
|
|
13
|
+
export { isAssistantMessage, toUsageReport } from './usage-report';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery of usage reports: a serialized queue plus the default HTTP sink.
|
|
3
|
+
*
|
|
4
|
+
* Reports are queued rather than awaited inside the harness's event handler — a
|
|
5
|
+
* network round trip per assistant message would sit directly in the agent's turn
|
|
6
|
+
* latency. Failures are therefore not lost: the first one is kept and rethrown
|
|
7
|
+
* from `flush()`, so a host that flushes at shutdown still fails loudly.
|
|
8
|
+
*/
|
|
9
|
+
import { type MeteredIdentity } from './identity';
|
|
10
|
+
import type { UsageReport } from './usage-report';
|
|
11
|
+
export type UsageSink = (report: UsageReport) => Promise<void>;
|
|
12
|
+
export interface HttpUsageSinkOptions {
|
|
13
|
+
/** Gateway root; `/v1/usage` is appended. */
|
|
14
|
+
gatewayUrl: string;
|
|
15
|
+
identity: MeteredIdentity;
|
|
16
|
+
/** Injectable for tests and for hosts with a custom agent/proxy. */
|
|
17
|
+
fetch?: typeof globalThis.fetch;
|
|
18
|
+
}
|
|
19
|
+
export declare function httpUsageSink(options: HttpUsageSinkOptions): UsageSink;
|
|
20
|
+
export interface UsageReporterOptions {
|
|
21
|
+
sink: UsageSink;
|
|
22
|
+
/** Called instead of retaining the error for `flush()` to rethrow. */
|
|
23
|
+
onError?: (error: unknown, report: UsageReport) => void;
|
|
24
|
+
}
|
|
25
|
+
export declare class UsageReporter {
|
|
26
|
+
private readonly sink;
|
|
27
|
+
private readonly onError;
|
|
28
|
+
private tail;
|
|
29
|
+
private failure;
|
|
30
|
+
private sent;
|
|
31
|
+
constructor(options: UsageReporterOptions);
|
|
32
|
+
/** Reports delivered successfully so far. */
|
|
33
|
+
get delivered(): number;
|
|
34
|
+
/** Queue a report. Never rejects; the failure surfaces from `flush()`. */
|
|
35
|
+
enqueue(report: UsageReport): void;
|
|
36
|
+
/** Wait for the queue to drain, then rethrow the first retained failure. */
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
}
|
package/esm/reporter.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery of usage reports: a serialized queue plus the default HTTP sink.
|
|
3
|
+
*
|
|
4
|
+
* Reports are queued rather than awaited inside the harness's event handler — a
|
|
5
|
+
* network round trip per assistant message would sit directly in the agent's turn
|
|
6
|
+
* latency. Failures are therefore not lost: the first one is kept and rethrown
|
|
7
|
+
* from `flush()`, so a host that flushes at shutdown still fails loudly.
|
|
8
|
+
*/
|
|
9
|
+
import { normalizeGatewayUrl } from './gateway';
|
|
10
|
+
import { buildIdentityHeaders } from './identity';
|
|
11
|
+
export function httpUsageSink(options) {
|
|
12
|
+
const url = `${normalizeGatewayUrl(options.gatewayUrl)}/v1/usage`;
|
|
13
|
+
const headers = { 'Content-Type': 'application/json', ...buildIdentityHeaders(options.identity) };
|
|
14
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
15
|
+
if (!doFetch)
|
|
16
|
+
throw new Error('usage report: no fetch implementation available; pass options.fetch');
|
|
17
|
+
return async (report) => {
|
|
18
|
+
const response = await doFetch(url, { method: 'POST', headers, body: JSON.stringify(report) });
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const body = await response.text().catch(() => '');
|
|
21
|
+
throw new Error(`usage report: gateway rejected the report (${response.status}) ${body}`.trim());
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export class UsageReporter {
|
|
26
|
+
sink;
|
|
27
|
+
onError;
|
|
28
|
+
tail = Promise.resolve();
|
|
29
|
+
failure;
|
|
30
|
+
sent = 0;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.sink = options.sink;
|
|
33
|
+
this.onError = options.onError;
|
|
34
|
+
}
|
|
35
|
+
/** Reports delivered successfully so far. */
|
|
36
|
+
get delivered() {
|
|
37
|
+
return this.sent;
|
|
38
|
+
}
|
|
39
|
+
/** Queue a report. Never rejects; the failure surfaces from `flush()`. */
|
|
40
|
+
enqueue(report) {
|
|
41
|
+
this.tail = this.tail.then(async () => {
|
|
42
|
+
try {
|
|
43
|
+
await this.sink(report);
|
|
44
|
+
this.sent += 1;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (this.onError) {
|
|
48
|
+
this.onError(error, report);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Keep the first failure: it is the one with the original cause, and a
|
|
52
|
+
// later cascade usually says less about what broke.
|
|
53
|
+
if (this.failure === undefined)
|
|
54
|
+
this.failure = error;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Wait for the queue to drain, then rethrow the first retained failure. */
|
|
59
|
+
async flush() {
|
|
60
|
+
await this.tail;
|
|
61
|
+
const failure = this.failure;
|
|
62
|
+
if (failure !== undefined) {
|
|
63
|
+
this.failure = undefined;
|
|
64
|
+
throw failure;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an assistant message's usage into a `POST /v1/usage` body.
|
|
3
|
+
*
|
|
4
|
+
* A harness reports richer usage than the gateway's own proxy path sees — cache
|
|
5
|
+
* reads, cache writes, and its own cost breakdown — so the whole `usage` object is
|
|
6
|
+
* sent as `raw_usage` while the four scalar columns carry what billing
|
|
7
|
+
* aggregates.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The slice of an assistant message this module reads. An adapter maps its
|
|
11
|
+
* harness's message onto it; the field names are the ones every provider SDK
|
|
12
|
+
* already uses.
|
|
13
|
+
*/
|
|
14
|
+
export interface AssistantUsageMessage {
|
|
15
|
+
role: string;
|
|
16
|
+
provider?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
responseModel?: string;
|
|
19
|
+
responseId?: string;
|
|
20
|
+
stopReason?: string;
|
|
21
|
+
errorMessage?: string;
|
|
22
|
+
usage?: {
|
|
23
|
+
input?: number;
|
|
24
|
+
output?: number;
|
|
25
|
+
cacheRead?: number;
|
|
26
|
+
cacheWrite?: number;
|
|
27
|
+
totalTokens?: number;
|
|
28
|
+
cost?: Record<string, number>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The gateway's `/v1/usage` payload (snake_case, as the endpoint reads it). */
|
|
32
|
+
export interface UsageReport {
|
|
33
|
+
model: string;
|
|
34
|
+
provider: string;
|
|
35
|
+
service: 'chat';
|
|
36
|
+
operation: string;
|
|
37
|
+
input_tokens: number;
|
|
38
|
+
output_tokens: number;
|
|
39
|
+
total_tokens: number;
|
|
40
|
+
latency_ms: number;
|
|
41
|
+
status: 'ok' | 'error';
|
|
42
|
+
error_type?: string;
|
|
43
|
+
raw_usage?: unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface ToUsageReportOptions {
|
|
46
|
+
/** Free-form label for the row; defaults to `agent/chat`. */
|
|
47
|
+
operation?: string;
|
|
48
|
+
/** Turn latency, when the host measured it. */
|
|
49
|
+
latencyMs?: number;
|
|
50
|
+
}
|
|
51
|
+
export declare function isAssistantMessage(message: unknown): message is AssistantUsageMessage;
|
|
52
|
+
/**
|
|
53
|
+
* `undefined` when the message carries no usage at all (aborted before the
|
|
54
|
+
* provider answered), which is a row worth nothing rather than a row of zeros.
|
|
55
|
+
*/
|
|
56
|
+
export declare function toUsageReport(message: AssistantUsageMessage, options?: ToUsageReportOptions): UsageReport | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an assistant message's usage into a `POST /v1/usage` body.
|
|
3
|
+
*
|
|
4
|
+
* A harness reports richer usage than the gateway's own proxy path sees — cache
|
|
5
|
+
* reads, cache writes, and its own cost breakdown — so the whole `usage` object is
|
|
6
|
+
* sent as `raw_usage` while the four scalar columns carry what billing
|
|
7
|
+
* aggregates.
|
|
8
|
+
*/
|
|
9
|
+
export function isAssistantMessage(message) {
|
|
10
|
+
return typeof message === 'object' && message !== null && message.role === 'assistant';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `undefined` when the message carries no usage at all (aborted before the
|
|
14
|
+
* provider answered), which is a row worth nothing rather than a row of zeros.
|
|
15
|
+
*/
|
|
16
|
+
export function toUsageReport(message, options = {}) {
|
|
17
|
+
const usage = message.usage;
|
|
18
|
+
if (!usage)
|
|
19
|
+
return undefined;
|
|
20
|
+
const input = num(usage.input);
|
|
21
|
+
const cacheRead = num(usage.cacheRead);
|
|
22
|
+
const cacheWrite = num(usage.cacheWrite);
|
|
23
|
+
const output = num(usage.output);
|
|
24
|
+
// Every prompt token the provider processed, cached or not: `input` alone
|
|
25
|
+
// excludes cache hits, which would under-report a long agent session
|
|
26
|
+
// dramatically. The split survives in `raw_usage`.
|
|
27
|
+
const inputTokens = input + cacheRead + cacheWrite;
|
|
28
|
+
const totalTokens = usage.totalTokens === undefined ? inputTokens + output : num(usage.totalTokens);
|
|
29
|
+
const failed = message.stopReason === 'error';
|
|
30
|
+
const report = {
|
|
31
|
+
model: message.responseModel ?? message.model ?? 'unknown',
|
|
32
|
+
provider: message.provider ?? 'unknown',
|
|
33
|
+
service: 'chat',
|
|
34
|
+
operation: options.operation ?? 'agent/chat',
|
|
35
|
+
input_tokens: inputTokens,
|
|
36
|
+
output_tokens: output,
|
|
37
|
+
total_tokens: totalTokens,
|
|
38
|
+
latency_ms: options.latencyMs === undefined ? 0 : Math.max(0, Math.round(options.latencyMs)),
|
|
39
|
+
status: failed ? 'error' : 'ok',
|
|
40
|
+
raw_usage: usage
|
|
41
|
+
};
|
|
42
|
+
if (failed)
|
|
43
|
+
report.error_type = message.errorMessage ?? 'error';
|
|
44
|
+
return report;
|
|
45
|
+
}
|
|
46
|
+
function num(value) {
|
|
47
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
48
|
+
}
|
package/gateway.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The metered gateway as a resolved model endpoint.
|
|
3
|
+
*
|
|
4
|
+
* `agentic-server` speaks OpenAI's `/v1/chat/completions`, so routing a harness
|
|
5
|
+
* through it needs no custom streaming code — only an endpoint whose `baseUrl` is
|
|
6
|
+
* the gateway and whose headers carry the run's identity. Every harness worth
|
|
7
|
+
* adapting can already talk to an OpenAI-compatible endpoint, which is why this
|
|
8
|
+
* resolution is neutral and only the registration is vendor-specific: an adapter
|
|
9
|
+
* turns `MeteredGateway` into whatever its harness calls a provider.
|
|
10
|
+
*/
|
|
11
|
+
import { type MeteredIdentity } from './identity';
|
|
12
|
+
/** The api the gateway exposes. */
|
|
13
|
+
export declare const GATEWAY_API = "openai-completions";
|
|
14
|
+
/** Default provider name; also the prefix a model picker shows. */
|
|
15
|
+
export declare const DEFAULT_PROVIDER_NAME = "constructive-gateway";
|
|
16
|
+
/** Per-token cost, for client-side display only. */
|
|
17
|
+
export interface MeteredModelCost {
|
|
18
|
+
input: number;
|
|
19
|
+
output: number;
|
|
20
|
+
cacheRead: number;
|
|
21
|
+
cacheWrite: number;
|
|
22
|
+
}
|
|
23
|
+
export interface MeteredModelSpec {
|
|
24
|
+
/** Model id as the gateway routes it, e.g. `anthropic/claude-sonnet-4`. */
|
|
25
|
+
id: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
contextWindow: number;
|
|
28
|
+
maxTokens: number;
|
|
29
|
+
reasoning?: boolean;
|
|
30
|
+
input?: ('text' | 'image')[];
|
|
31
|
+
/**
|
|
32
|
+
* Per-token cost for the host's own display. The gateway is the billing
|
|
33
|
+
* authority, so zeros here mean "not priced client-side", never "free".
|
|
34
|
+
*/
|
|
35
|
+
cost?: MeteredModelCost;
|
|
36
|
+
}
|
|
37
|
+
export interface MeteredGatewayOptions {
|
|
38
|
+
/** Gateway root, e.g. `https://agentic.example.com` — not a `/v1` path. */
|
|
39
|
+
gatewayUrl: string;
|
|
40
|
+
identity: MeteredIdentity;
|
|
41
|
+
models: readonly MeteredModelSpec[];
|
|
42
|
+
/** Provider name to register under. Defaults to `constructive-gateway`. */
|
|
43
|
+
providerName?: string;
|
|
44
|
+
/** Display name in the host's UI. */
|
|
45
|
+
displayName?: string;
|
|
46
|
+
/** Extra headers, e.g. `X-LLM-Provider` to pin the upstream provider. */
|
|
47
|
+
headers?: Record<string, string>;
|
|
48
|
+
/**
|
|
49
|
+
* API key for the gateway itself. Identity travels in headers, so this is
|
|
50
|
+
* usually unnecessary; when the ingress wants a bearer, prefer
|
|
51
|
+
* `identity.runToken`.
|
|
52
|
+
*/
|
|
53
|
+
apiKey?: string;
|
|
54
|
+
}
|
|
55
|
+
/** A gateway endpoint an adapter can register with its harness. */
|
|
56
|
+
export interface MeteredGateway {
|
|
57
|
+
providerName: string;
|
|
58
|
+
displayName: string;
|
|
59
|
+
baseUrl: string;
|
|
60
|
+
api: typeof GATEWAY_API;
|
|
61
|
+
headers: Record<string, string>;
|
|
62
|
+
apiKey: string;
|
|
63
|
+
models: readonly MeteredModel[];
|
|
64
|
+
}
|
|
65
|
+
/** A model as the gateway serves it, with every default resolved. */
|
|
66
|
+
export interface MeteredModel {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
reasoning: boolean;
|
|
70
|
+
input: ('text' | 'image')[];
|
|
71
|
+
cost: MeteredModelCost;
|
|
72
|
+
contextWindow: number;
|
|
73
|
+
maxTokens: number;
|
|
74
|
+
}
|
|
75
|
+
/** Normalize the gateway root: absolute http(s), no trailing slash, no `/v1`. */
|
|
76
|
+
export declare function normalizeGatewayUrl(gatewayUrl: string): string;
|
|
77
|
+
export declare function resolveMeteredModel(spec: MeteredModelSpec): MeteredModel;
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the gateway endpoint for a run.
|
|
80
|
+
*
|
|
81
|
+
* Identity headers are computed once here rather than per request: a run's
|
|
82
|
+
* identity is fixed for its lifetime, and harnesses generally expose no
|
|
83
|
+
* per-request header hook a provider registration participates in.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveMeteredGateway(options: MeteredGatewayOptions): MeteredGateway;
|
package/gateway.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The metered gateway as a resolved model endpoint.
|
|
4
|
+
*
|
|
5
|
+
* `agentic-server` speaks OpenAI's `/v1/chat/completions`, so routing a harness
|
|
6
|
+
* through it needs no custom streaming code — only an endpoint whose `baseUrl` is
|
|
7
|
+
* the gateway and whose headers carry the run's identity. Every harness worth
|
|
8
|
+
* adapting can already talk to an OpenAI-compatible endpoint, which is why this
|
|
9
|
+
* resolution is neutral and only the registration is vendor-specific: an adapter
|
|
10
|
+
* turns `MeteredGateway` into whatever its harness calls a provider.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.DEFAULT_PROVIDER_NAME = exports.GATEWAY_API = void 0;
|
|
14
|
+
exports.normalizeGatewayUrl = normalizeGatewayUrl;
|
|
15
|
+
exports.resolveMeteredModel = resolveMeteredModel;
|
|
16
|
+
exports.resolveMeteredGateway = resolveMeteredGateway;
|
|
17
|
+
const identity_1 = require("./identity");
|
|
18
|
+
/** The api the gateway exposes. */
|
|
19
|
+
exports.GATEWAY_API = 'openai-completions';
|
|
20
|
+
/** Default provider name; also the prefix a model picker shows. */
|
|
21
|
+
exports.DEFAULT_PROVIDER_NAME = 'constructive-gateway';
|
|
22
|
+
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
23
|
+
/** Normalize the gateway root: absolute http(s), no trailing slash, no `/v1`. */
|
|
24
|
+
function normalizeGatewayUrl(gatewayUrl) {
|
|
25
|
+
const raw = gatewayUrl?.trim();
|
|
26
|
+
if (!raw)
|
|
27
|
+
throw new Error('metered model: gatewayUrl is required');
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error(`metered model: gatewayUrl must be an absolute URL, got ${raw}`);
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
36
|
+
throw new Error(`metered model: gatewayUrl must be http(s), got ${parsed.protocol}`);
|
|
37
|
+
}
|
|
38
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
39
|
+
// An OpenAI-compatible client appends `/v1/chat/completions`, so a baseUrl that
|
|
40
|
+
// already ends in `/v1` would request `/v1/v1/chat/completions` and 404 at the
|
|
41
|
+
// first model turn.
|
|
42
|
+
if (/\/v1$/.test(path))
|
|
43
|
+
throw new Error(`metered model: gatewayUrl must be the gateway root, not ${raw} (drop the /v1)`);
|
|
44
|
+
return `${parsed.origin}${path}`;
|
|
45
|
+
}
|
|
46
|
+
function resolveMeteredModel(spec) {
|
|
47
|
+
if (!spec.id?.trim())
|
|
48
|
+
throw new Error('metered model: model id is required');
|
|
49
|
+
return {
|
|
50
|
+
id: spec.id,
|
|
51
|
+
name: spec.name ?? spec.id,
|
|
52
|
+
reasoning: spec.reasoning ?? false,
|
|
53
|
+
input: spec.input ? [...spec.input] : ['text'],
|
|
54
|
+
cost: spec.cost ?? { ...ZERO_COST },
|
|
55
|
+
contextWindow: spec.contextWindow,
|
|
56
|
+
maxTokens: spec.maxTokens
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the gateway endpoint for a run.
|
|
61
|
+
*
|
|
62
|
+
* Identity headers are computed once here rather than per request: a run's
|
|
63
|
+
* identity is fixed for its lifetime, and harnesses generally expose no
|
|
64
|
+
* per-request header hook a provider registration participates in.
|
|
65
|
+
*/
|
|
66
|
+
function resolveMeteredGateway(options) {
|
|
67
|
+
if (options.models.length === 0)
|
|
68
|
+
throw new Error('metered model: at least one model is required');
|
|
69
|
+
const headers = { ...options.headers, ...(0, identity_1.buildIdentityHeaders)(options.identity) };
|
|
70
|
+
return {
|
|
71
|
+
providerName: options.providerName ?? exports.DEFAULT_PROVIDER_NAME,
|
|
72
|
+
displayName: options.displayName ?? 'Constructive (metered)',
|
|
73
|
+
baseUrl: normalizeGatewayUrl(options.gatewayUrl),
|
|
74
|
+
api: exports.GATEWAY_API,
|
|
75
|
+
headers,
|
|
76
|
+
// Harnesses typically require an apiKey once models are declared; the gateway
|
|
77
|
+
// authenticates by identity headers, so a placeholder satisfies that
|
|
78
|
+
// validation without implying a real secret.
|
|
79
|
+
apiKey: options.apiKey ?? headers.Authorization?.replace(/^Bearer /, '') ?? 'unused',
|
|
80
|
+
models: options.models.map(resolveMeteredModel)
|
|
81
|
+
};
|
|
82
|
+
}
|
package/identity.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a metered request belongs to.
|
|
3
|
+
*
|
|
4
|
+
* These are exactly the headers `agentic-server` reads (`X-Database-Id`,
|
|
5
|
+
* `X-Entity-Id`, `X-Actor-Id`) — the same identity lane the platform's own
|
|
6
|
+
* clients use — so a pi session lands in `inference_log` beside every other
|
|
7
|
+
* metered call rather than in a parallel accounting scheme.
|
|
8
|
+
*
|
|
9
|
+
* Headers are only trustworthy where the gateway is not reachable by the
|
|
10
|
+
* untrusted party: in-cluster, or behind an authenticated ingress that pins the
|
|
11
|
+
* identity from the bearer. Off-cluster hosts must send `runToken` and let the
|
|
12
|
+
* ingress do the pinning.
|
|
13
|
+
*/
|
|
14
|
+
export interface MeteredIdentity {
|
|
15
|
+
/** Tenant database the usage is billed to. Required by the gateway. */
|
|
16
|
+
databaseId: string;
|
|
17
|
+
/** Owning entity (organization/user) when the platform tracks one. */
|
|
18
|
+
entityId?: string;
|
|
19
|
+
/** The actor on whose behalf the run executes. */
|
|
20
|
+
actorId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Bearer token for the gateway's ingress — run-scoped, not an account token.
|
|
23
|
+
* Sent as `Authorization: Bearer <token>`.
|
|
24
|
+
*/
|
|
25
|
+
runToken?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const DATABASE_ID_HEADER = "X-Database-Id";
|
|
28
|
+
export declare const ENTITY_ID_HEADER = "X-Entity-Id";
|
|
29
|
+
export declare const ACTOR_ID_HEADER = "X-Actor-Id";
|
|
30
|
+
/**
|
|
31
|
+
* Build the identity headers for a metered request.
|
|
32
|
+
*
|
|
33
|
+
* Throws on a missing/blank `databaseId`: the gateway would reject the call with
|
|
34
|
+
* a 400 at the first model turn, which surfaces as an opaque agent failure much
|
|
35
|
+
* later than the misconfiguration.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildIdentityHeaders(identity: MeteredIdentity): Record<string, string>;
|
package/identity.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Who a metered request belongs to.
|
|
4
|
+
*
|
|
5
|
+
* These are exactly the headers `agentic-server` reads (`X-Database-Id`,
|
|
6
|
+
* `X-Entity-Id`, `X-Actor-Id`) — the same identity lane the platform's own
|
|
7
|
+
* clients use — so a pi session lands in `inference_log` beside every other
|
|
8
|
+
* metered call rather than in a parallel accounting scheme.
|
|
9
|
+
*
|
|
10
|
+
* Headers are only trustworthy where the gateway is not reachable by the
|
|
11
|
+
* untrusted party: in-cluster, or behind an authenticated ingress that pins the
|
|
12
|
+
* identity from the bearer. Off-cluster hosts must send `runToken` and let the
|
|
13
|
+
* ingress do the pinning.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.ACTOR_ID_HEADER = exports.ENTITY_ID_HEADER = exports.DATABASE_ID_HEADER = void 0;
|
|
17
|
+
exports.buildIdentityHeaders = buildIdentityHeaders;
|
|
18
|
+
exports.DATABASE_ID_HEADER = 'X-Database-Id';
|
|
19
|
+
exports.ENTITY_ID_HEADER = 'X-Entity-Id';
|
|
20
|
+
exports.ACTOR_ID_HEADER = 'X-Actor-Id';
|
|
21
|
+
/**
|
|
22
|
+
* Build the identity headers for a metered request.
|
|
23
|
+
*
|
|
24
|
+
* Throws on a missing/blank `databaseId`: the gateway would reject the call with
|
|
25
|
+
* a 400 at the first model turn, which surfaces as an opaque agent failure much
|
|
26
|
+
* later than the misconfiguration.
|
|
27
|
+
*/
|
|
28
|
+
function buildIdentityHeaders(identity) {
|
|
29
|
+
const databaseId = identity.databaseId?.trim();
|
|
30
|
+
if (!databaseId)
|
|
31
|
+
throw new Error('metered model: identity.databaseId is required');
|
|
32
|
+
const headers = { [exports.DATABASE_ID_HEADER]: databaseId };
|
|
33
|
+
const entityId = identity.entityId?.trim();
|
|
34
|
+
if (entityId)
|
|
35
|
+
headers[exports.ENTITY_ID_HEADER] = entityId;
|
|
36
|
+
const actorId = identity.actorId?.trim();
|
|
37
|
+
if (actorId)
|
|
38
|
+
headers[exports.ACTOR_ID_HEADER] = actorId;
|
|
39
|
+
const runToken = identity.runToken?.trim();
|
|
40
|
+
if (runToken)
|
|
41
|
+
headers.Authorization = `Bearer ${runToken}`;
|
|
42
|
+
return headers;
|
|
43
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@agentic-kit/metering` — harness-neutral metering for an agent run: the
|
|
3
|
+
* Constructive gateway as a resolved model endpoint, the run identity that
|
|
4
|
+
* travels with every call, and the `/v1/usage` report a self-reporting run
|
|
5
|
+
* delivers.
|
|
6
|
+
*
|
|
7
|
+
* No harness SDK is imported here. An adapter registers `MeteredGateway` with
|
|
8
|
+
* its own harness and feeds `toUsageReport()` from its own message events.
|
|
9
|
+
*/
|
|
10
|
+
export { DEFAULT_PROVIDER_NAME, GATEWAY_API, type MeteredGateway, type MeteredGatewayOptions, type MeteredModel, type MeteredModelCost, type MeteredModelSpec, normalizeGatewayUrl, resolveMeteredGateway, resolveMeteredModel } from './gateway';
|
|
11
|
+
export { ACTOR_ID_HEADER, buildIdentityHeaders, DATABASE_ID_HEADER, ENTITY_ID_HEADER, type MeteredIdentity } from './identity';
|
|
12
|
+
export { httpUsageSink, type HttpUsageSinkOptions, UsageReporter, type UsageReporterOptions, type UsageSink } from './reporter';
|
|
13
|
+
export { type AssistantUsageMessage, isAssistantMessage, toUsageReport, type ToUsageReportOptions, type UsageReport } from './usage-report';
|
package/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@agentic-kit/metering` — harness-neutral metering for an agent run: the
|
|
4
|
+
* Constructive gateway as a resolved model endpoint, the run identity that
|
|
5
|
+
* travels with every call, and the `/v1/usage` report a self-reporting run
|
|
6
|
+
* delivers.
|
|
7
|
+
*
|
|
8
|
+
* No harness SDK is imported here. An adapter registers `MeteredGateway` with
|
|
9
|
+
* its own harness and feeds `toUsageReport()` from its own message events.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.toUsageReport = exports.isAssistantMessage = exports.UsageReporter = exports.httpUsageSink = exports.ENTITY_ID_HEADER = exports.DATABASE_ID_HEADER = exports.buildIdentityHeaders = exports.ACTOR_ID_HEADER = exports.resolveMeteredModel = exports.resolveMeteredGateway = exports.normalizeGatewayUrl = exports.GATEWAY_API = exports.DEFAULT_PROVIDER_NAME = void 0;
|
|
13
|
+
var gateway_1 = require("./gateway");
|
|
14
|
+
Object.defineProperty(exports, "DEFAULT_PROVIDER_NAME", { enumerable: true, get: function () { return gateway_1.DEFAULT_PROVIDER_NAME; } });
|
|
15
|
+
Object.defineProperty(exports, "GATEWAY_API", { enumerable: true, get: function () { return gateway_1.GATEWAY_API; } });
|
|
16
|
+
Object.defineProperty(exports, "normalizeGatewayUrl", { enumerable: true, get: function () { return gateway_1.normalizeGatewayUrl; } });
|
|
17
|
+
Object.defineProperty(exports, "resolveMeteredGateway", { enumerable: true, get: function () { return gateway_1.resolveMeteredGateway; } });
|
|
18
|
+
Object.defineProperty(exports, "resolveMeteredModel", { enumerable: true, get: function () { return gateway_1.resolveMeteredModel; } });
|
|
19
|
+
var identity_1 = require("./identity");
|
|
20
|
+
Object.defineProperty(exports, "ACTOR_ID_HEADER", { enumerable: true, get: function () { return identity_1.ACTOR_ID_HEADER; } });
|
|
21
|
+
Object.defineProperty(exports, "buildIdentityHeaders", { enumerable: true, get: function () { return identity_1.buildIdentityHeaders; } });
|
|
22
|
+
Object.defineProperty(exports, "DATABASE_ID_HEADER", { enumerable: true, get: function () { return identity_1.DATABASE_ID_HEADER; } });
|
|
23
|
+
Object.defineProperty(exports, "ENTITY_ID_HEADER", { enumerable: true, get: function () { return identity_1.ENTITY_ID_HEADER; } });
|
|
24
|
+
var reporter_1 = require("./reporter");
|
|
25
|
+
Object.defineProperty(exports, "httpUsageSink", { enumerable: true, get: function () { return reporter_1.httpUsageSink; } });
|
|
26
|
+
Object.defineProperty(exports, "UsageReporter", { enumerable: true, get: function () { return reporter_1.UsageReporter; } });
|
|
27
|
+
var usage_report_1 = require("./usage-report");
|
|
28
|
+
Object.defineProperty(exports, "isAssistantMessage", { enumerable: true, get: function () { return usage_report_1.isAssistantMessage; } });
|
|
29
|
+
Object.defineProperty(exports, "toUsageReport", { enumerable: true, get: function () { return usage_report_1.toUsageReport; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentic-kit/metering",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"author": "Constructive <developers@constructive.io>",
|
|
5
|
+
"description": "harness-neutral metering for an agent run — the Constructive gateway as a model endpoint, run identity headers, and usage reporting",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "esm/index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"homepage": "https://github.com/constructive-io/constructive",
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"directory": "dist"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/constructive-io/constructive"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/constructive-io/constructive/issues"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"clean": "makage clean",
|
|
24
|
+
"prepack": "npm run build",
|
|
25
|
+
"build": "makage build",
|
|
26
|
+
"build:dev": "makage build --dev",
|
|
27
|
+
"lint": "eslint . --fix",
|
|
28
|
+
"test": "jest",
|
|
29
|
+
"test:watch": "jest --watch"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"agentic-kit",
|
|
33
|
+
"metering",
|
|
34
|
+
"usage",
|
|
35
|
+
"gateway",
|
|
36
|
+
"constructive"
|
|
37
|
+
],
|
|
38
|
+
"gitHead": "44a670631c98f7174db0e4c25dc30a3c81fd4437"
|
|
39
|
+
}
|
package/reporter.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery of usage reports: a serialized queue plus the default HTTP sink.
|
|
3
|
+
*
|
|
4
|
+
* Reports are queued rather than awaited inside the harness's event handler — a
|
|
5
|
+
* network round trip per assistant message would sit directly in the agent's turn
|
|
6
|
+
* latency. Failures are therefore not lost: the first one is kept and rethrown
|
|
7
|
+
* from `flush()`, so a host that flushes at shutdown still fails loudly.
|
|
8
|
+
*/
|
|
9
|
+
import { type MeteredIdentity } from './identity';
|
|
10
|
+
import type { UsageReport } from './usage-report';
|
|
11
|
+
export type UsageSink = (report: UsageReport) => Promise<void>;
|
|
12
|
+
export interface HttpUsageSinkOptions {
|
|
13
|
+
/** Gateway root; `/v1/usage` is appended. */
|
|
14
|
+
gatewayUrl: string;
|
|
15
|
+
identity: MeteredIdentity;
|
|
16
|
+
/** Injectable for tests and for hosts with a custom agent/proxy. */
|
|
17
|
+
fetch?: typeof globalThis.fetch;
|
|
18
|
+
}
|
|
19
|
+
export declare function httpUsageSink(options: HttpUsageSinkOptions): UsageSink;
|
|
20
|
+
export interface UsageReporterOptions {
|
|
21
|
+
sink: UsageSink;
|
|
22
|
+
/** Called instead of retaining the error for `flush()` to rethrow. */
|
|
23
|
+
onError?: (error: unknown, report: UsageReport) => void;
|
|
24
|
+
}
|
|
25
|
+
export declare class UsageReporter {
|
|
26
|
+
private readonly sink;
|
|
27
|
+
private readonly onError;
|
|
28
|
+
private tail;
|
|
29
|
+
private failure;
|
|
30
|
+
private sent;
|
|
31
|
+
constructor(options: UsageReporterOptions);
|
|
32
|
+
/** Reports delivered successfully so far. */
|
|
33
|
+
get delivered(): number;
|
|
34
|
+
/** Queue a report. Never rejects; the failure surfaces from `flush()`. */
|
|
35
|
+
enqueue(report: UsageReport): void;
|
|
36
|
+
/** Wait for the queue to drain, then rethrow the first retained failure. */
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
}
|
package/reporter.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Delivery of usage reports: a serialized queue plus the default HTTP sink.
|
|
4
|
+
*
|
|
5
|
+
* Reports are queued rather than awaited inside the harness's event handler — a
|
|
6
|
+
* network round trip per assistant message would sit directly in the agent's turn
|
|
7
|
+
* latency. Failures are therefore not lost: the first one is kept and rethrown
|
|
8
|
+
* from `flush()`, so a host that flushes at shutdown still fails loudly.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.UsageReporter = void 0;
|
|
12
|
+
exports.httpUsageSink = httpUsageSink;
|
|
13
|
+
const gateway_1 = require("./gateway");
|
|
14
|
+
const identity_1 = require("./identity");
|
|
15
|
+
function httpUsageSink(options) {
|
|
16
|
+
const url = `${(0, gateway_1.normalizeGatewayUrl)(options.gatewayUrl)}/v1/usage`;
|
|
17
|
+
const headers = { 'Content-Type': 'application/json', ...(0, identity_1.buildIdentityHeaders)(options.identity) };
|
|
18
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
19
|
+
if (!doFetch)
|
|
20
|
+
throw new Error('usage report: no fetch implementation available; pass options.fetch');
|
|
21
|
+
return async (report) => {
|
|
22
|
+
const response = await doFetch(url, { method: 'POST', headers, body: JSON.stringify(report) });
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const body = await response.text().catch(() => '');
|
|
25
|
+
throw new Error(`usage report: gateway rejected the report (${response.status}) ${body}`.trim());
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
class UsageReporter {
|
|
30
|
+
sink;
|
|
31
|
+
onError;
|
|
32
|
+
tail = Promise.resolve();
|
|
33
|
+
failure;
|
|
34
|
+
sent = 0;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.sink = options.sink;
|
|
37
|
+
this.onError = options.onError;
|
|
38
|
+
}
|
|
39
|
+
/** Reports delivered successfully so far. */
|
|
40
|
+
get delivered() {
|
|
41
|
+
return this.sent;
|
|
42
|
+
}
|
|
43
|
+
/** Queue a report. Never rejects; the failure surfaces from `flush()`. */
|
|
44
|
+
enqueue(report) {
|
|
45
|
+
this.tail = this.tail.then(async () => {
|
|
46
|
+
try {
|
|
47
|
+
await this.sink(report);
|
|
48
|
+
this.sent += 1;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (this.onError) {
|
|
52
|
+
this.onError(error, report);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Keep the first failure: it is the one with the original cause, and a
|
|
56
|
+
// later cascade usually says less about what broke.
|
|
57
|
+
if (this.failure === undefined)
|
|
58
|
+
this.failure = error;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Wait for the queue to drain, then rethrow the first retained failure. */
|
|
63
|
+
async flush() {
|
|
64
|
+
await this.tail;
|
|
65
|
+
const failure = this.failure;
|
|
66
|
+
if (failure !== undefined) {
|
|
67
|
+
this.failure = undefined;
|
|
68
|
+
throw failure;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.UsageReporter = UsageReporter;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn an assistant message's usage into a `POST /v1/usage` body.
|
|
3
|
+
*
|
|
4
|
+
* A harness reports richer usage than the gateway's own proxy path sees — cache
|
|
5
|
+
* reads, cache writes, and its own cost breakdown — so the whole `usage` object is
|
|
6
|
+
* sent as `raw_usage` while the four scalar columns carry what billing
|
|
7
|
+
* aggregates.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The slice of an assistant message this module reads. An adapter maps its
|
|
11
|
+
* harness's message onto it; the field names are the ones every provider SDK
|
|
12
|
+
* already uses.
|
|
13
|
+
*/
|
|
14
|
+
export interface AssistantUsageMessage {
|
|
15
|
+
role: string;
|
|
16
|
+
provider?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
responseModel?: string;
|
|
19
|
+
responseId?: string;
|
|
20
|
+
stopReason?: string;
|
|
21
|
+
errorMessage?: string;
|
|
22
|
+
usage?: {
|
|
23
|
+
input?: number;
|
|
24
|
+
output?: number;
|
|
25
|
+
cacheRead?: number;
|
|
26
|
+
cacheWrite?: number;
|
|
27
|
+
totalTokens?: number;
|
|
28
|
+
cost?: Record<string, number>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The gateway's `/v1/usage` payload (snake_case, as the endpoint reads it). */
|
|
32
|
+
export interface UsageReport {
|
|
33
|
+
model: string;
|
|
34
|
+
provider: string;
|
|
35
|
+
service: 'chat';
|
|
36
|
+
operation: string;
|
|
37
|
+
input_tokens: number;
|
|
38
|
+
output_tokens: number;
|
|
39
|
+
total_tokens: number;
|
|
40
|
+
latency_ms: number;
|
|
41
|
+
status: 'ok' | 'error';
|
|
42
|
+
error_type?: string;
|
|
43
|
+
raw_usage?: unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface ToUsageReportOptions {
|
|
46
|
+
/** Free-form label for the row; defaults to `agent/chat`. */
|
|
47
|
+
operation?: string;
|
|
48
|
+
/** Turn latency, when the host measured it. */
|
|
49
|
+
latencyMs?: number;
|
|
50
|
+
}
|
|
51
|
+
export declare function isAssistantMessage(message: unknown): message is AssistantUsageMessage;
|
|
52
|
+
/**
|
|
53
|
+
* `undefined` when the message carries no usage at all (aborted before the
|
|
54
|
+
* provider answered), which is a row worth nothing rather than a row of zeros.
|
|
55
|
+
*/
|
|
56
|
+
export declare function toUsageReport(message: AssistantUsageMessage, options?: ToUsageReportOptions): UsageReport | undefined;
|
package/usage-report.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Turn an assistant message's usage into a `POST /v1/usage` body.
|
|
4
|
+
*
|
|
5
|
+
* A harness reports richer usage than the gateway's own proxy path sees — cache
|
|
6
|
+
* reads, cache writes, and its own cost breakdown — so the whole `usage` object is
|
|
7
|
+
* sent as `raw_usage` while the four scalar columns carry what billing
|
|
8
|
+
* aggregates.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.isAssistantMessage = isAssistantMessage;
|
|
12
|
+
exports.toUsageReport = toUsageReport;
|
|
13
|
+
function isAssistantMessage(message) {
|
|
14
|
+
return typeof message === 'object' && message !== null && message.role === 'assistant';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `undefined` when the message carries no usage at all (aborted before the
|
|
18
|
+
* provider answered), which is a row worth nothing rather than a row of zeros.
|
|
19
|
+
*/
|
|
20
|
+
function toUsageReport(message, options = {}) {
|
|
21
|
+
const usage = message.usage;
|
|
22
|
+
if (!usage)
|
|
23
|
+
return undefined;
|
|
24
|
+
const input = num(usage.input);
|
|
25
|
+
const cacheRead = num(usage.cacheRead);
|
|
26
|
+
const cacheWrite = num(usage.cacheWrite);
|
|
27
|
+
const output = num(usage.output);
|
|
28
|
+
// Every prompt token the provider processed, cached or not: `input` alone
|
|
29
|
+
// excludes cache hits, which would under-report a long agent session
|
|
30
|
+
// dramatically. The split survives in `raw_usage`.
|
|
31
|
+
const inputTokens = input + cacheRead + cacheWrite;
|
|
32
|
+
const totalTokens = usage.totalTokens === undefined ? inputTokens + output : num(usage.totalTokens);
|
|
33
|
+
const failed = message.stopReason === 'error';
|
|
34
|
+
const report = {
|
|
35
|
+
model: message.responseModel ?? message.model ?? 'unknown',
|
|
36
|
+
provider: message.provider ?? 'unknown',
|
|
37
|
+
service: 'chat',
|
|
38
|
+
operation: options.operation ?? 'agent/chat',
|
|
39
|
+
input_tokens: inputTokens,
|
|
40
|
+
output_tokens: output,
|
|
41
|
+
total_tokens: totalTokens,
|
|
42
|
+
latency_ms: options.latencyMs === undefined ? 0 : Math.max(0, Math.round(options.latencyMs)),
|
|
43
|
+
status: failed ? 'error' : 'ok',
|
|
44
|
+
raw_usage: usage
|
|
45
|
+
};
|
|
46
|
+
if (failed)
|
|
47
|
+
report.error_type = message.errorMessage ?? 'error';
|
|
48
|
+
return report;
|
|
49
|
+
}
|
|
50
|
+
function num(value) {
|
|
51
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
52
|
+
}
|