convex-weflayr 0.1.0 → 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/README.md CHANGED
@@ -1,37 +1,42 @@
1
1
  # convex-weflayr
2
2
 
3
- A Convex component that sends your app's model calls through the
4
- [Weflayr](https://weflayr.com) gateway, and saves what each call cost, per
5
- customer, in your own database.
6
-
7
- Weflayr can show cost, run benchmarks and compare models, but only if every
8
- request says which feature, which call and which customer it belongs to. In a
9
- Convex app you would build that header by hand on every call, and the cost data
10
- would stay in Weflayr's dashboard, out of reach of your own code. This component
11
- adds the header for you and writes each call's usage into tables your app can
12
- query. So your app can act on cost: show a customer their usage, warn at 80% of
13
- a plan, stop at 100%.
14
-
15
- ```mermaid
16
- flowchart LR
17
- A["Convex action<br/>(your AI feature)"] -->|"1. model call + header<br/>feature, call, customer"| G["Weflayr gateway"]
18
- G -->|"2. answer + usage"| A
19
- A -->|"3. track() saves the call"| C[("convex-weflayr tables<br/>calls, daily totals, budgets")]
20
- C -->|"4. cost per customer,<br/>call, feature, day"| Q["Your queries and UI"]
21
- C -->|"5. assertWithinBudget()"| A
22
- ```
3
+ A Convex component for running your app on [Weflayr](https://weflayr.com)
4
+ routing strategies, with every call tagged by the customer it serves. It also
5
+ brings Weflayr's real cost and margin per customer back into your Convex
6
+ database, so your app can show it and act on it.
7
+
8
+ Weflayr is a gateway that decides which model answers each call. You benchmark
9
+ your own calls on other models, save the result as a routing strategy, and your
10
+ code sends `model: "strategy/<id>"`. Weflayr then picks the model per call, and
11
+ its routing overrides can treat a customer differently by their cost or their
12
+ margin. All of that depends on each call saying which customer, feature and call
13
+ it belongs to. This component does that in a Convex action, and keeps what comes
14
+ back where your queries can reach it.
15
+
16
+ ![How a call flows: your Convex action calls a strategy through the Weflayr gateway, the component saves the call, and sync brings Weflayr's cost and margin back](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/flow.png)
23
17
 
24
18
  ## What you get
25
19
 
26
- - **Cost per customer.** Every call is saved with its customer, call name,
27
- feature, plan, model, tokens, cost, latency and status. Totals by customer,
28
- call, feature and day are one query away, and they update live in your UI.
29
- - **Budgets.** Give a customer a monthly dollar budget. Their next call is
30
- stopped before the model runs, and other customers carry on.
31
- - **Agent runs.** Give every step of a run the same run id. You see what the run
32
- cost, step by step, and you can stop it part way.
33
- - **Cheaper models, with proof.** Each call has a fixed name, so Weflayr can
34
- replay your real calls on other models and score the answers.
20
+ - **The customer on every call.** Read from your login or your own function.
21
+ Per-customer routing overrides, cost and margin in Weflayr all depend on it.
22
+ A call without a customer fails by default, so none go untagged.
23
+ - **Strategies instead of model names.** `strategy("support", 3)` pins a version
24
+ in production and `strategy("support")` follows the latest. Changing which
25
+ model answers is a change in the Weflayr dashboard, not a deploy. Each call is
26
+ saved with the model the strategy picked.
27
+ - **Revenue and plan into Weflayr.** Push revenue from any billing system and
28
+ tag customers with their plan, so margin and per-plan views work.
29
+ - **Weflayr's cost and margin in your database.** `sync` saves each customer's
30
+ real cost, revenue, margin and risk status for the month. Show it in your UI
31
+ with a live query, and stop a customer whose margin status you list.
32
+ - **Budgets.** A monthly dollar budget per customer, checked against that real
33
+ cost. The next call is stopped before the model runs, and other customers
34
+ carry on.
35
+ - **Agent runs.** Every step of a run carries one feature run id, so Weflayr
36
+ prices the whole run. You can also read the run's cost step by step and stop
37
+ it part way.
38
+ - **A per-call record.** Every call is saved with customer, call, feature, plan,
39
+ model, tokens, cost estimate, latency and status, for lists in your own UI.
35
40
  - **Calls that survive a gateway failure.** `withFallback()` can run the call
36
41
  direct if the gateway fails. This is optional.
37
42
  - **A Vercel AI SDK model** for the gateway, if you use the AI SDK.
@@ -69,23 +74,23 @@ Open the app and paste your key. Then:
69
74
  An agent run. Four steps share one run id, so the cost of the run is the sum of
70
75
  its steps:
71
76
 
72
- ![An agent run with the cost of each step](docs/images/agent-run.jpg)
77
+ ![An agent run with the cost of each step](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/agent-run.jpg)
73
78
 
74
79
  A budget. After three calls the customer has used $0.000021 of a $0.00002
75
80
  budget, and the fourth call is stopped before the model runs:
76
81
 
77
- ![A customer stopped at their monthly budget](docs/images/budget-block.jpg)
82
+ ![A customer stopped at their monthly budget](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/budget-block.jpg)
78
83
 
79
84
  Spend by customer and by call, and every saved call:
80
85
 
81
- ![Spend by customer](docs/images/customers.jpg)
86
+ ![Spend by customer](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/customers.jpg)
82
87
 
83
- ![The call log](docs/images/calls.jpg)
88
+ ![The call log](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/calls.jpg)
84
89
 
85
90
  The same calls in the Weflayr dashboard, grouped by the feature names the
86
91
  component sent:
87
92
 
88
- ![Cost per feature in Weflayr](docs/images/weflayr-dashboard.jpg)
93
+ ![Cost per feature in Weflayr](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/weflayr-dashboard.jpg)
89
94
 
90
95
  About your key: it is saved in your browser's localStorage and sent with each
91
96
  call so the call can be made. It is never saved in the database. Each browser
@@ -121,13 +126,18 @@ npx convex env set WEFLAYR_CUSTOMERS "*"
121
126
  `WEFLAYR_CUSTOMERS` is a comma separated list of customer ids, or `*` for all.
122
127
  When it is empty, no customer goes through the gateway.
123
128
 
129
+ Give the key the Gateway scope for model calls, and the Management scope as well
130
+ for cost, margin, revenue and tags. To use a separate management key, set
131
+ `WEFLAYR_MANAGEMENT_KEY`.
132
+
124
133
  ## Use it
125
134
 
126
- Create one `Weflayr` instance. Name your calls and the feature each one belongs
127
- to. A benchmark in Weflayr is set up per call name, so keep the names stable.
135
+ Create one `Weflayr` instance. Name your calls, the feature each belongs to, and
136
+ the model or strategy each uses. A benchmark in Weflayr is set up per call name,
137
+ so keep the names stable.
128
138
 
129
139
  ```ts
130
- import { Weflayr } from "convex-weflayr";
140
+ import { Weflayr, strategy } from "convex-weflayr";
131
141
  import { components } from "./_generated/api";
132
142
 
133
143
  export const weflayr = new Weflayr(components.weflayr, {
@@ -136,37 +146,99 @@ export const weflayr = new Weflayr(components.weflayr, {
136
146
  "classify-ticket": "support-replies",
137
147
  "summarise-thread": "thread-summaries",
138
148
  },
139
- // Dollars per million tokens, from your provider's price list.
149
+ // Pin a version in production, follow the latest elsewhere.
150
+ models: {
151
+ "draft-reply": strategy("support", process.env.NODE_ENV === "production" ? 4 : undefined),
152
+ "classify-ticket": "claude-haiku-5-5",
153
+ },
154
+ defaultModel: strategy("default"),
155
+ // Who the call is for. The default is the signed-in user's id.
156
+ customer: async (ctx) => (await ctx.auth.getUserIdentity())?.orgId as string | undefined,
157
+ // Stop a customer whose margin is in one of these states.
158
+ stopOnMargin: ["unprofitable"],
159
+ // Dollars per million tokens, for estimates between syncs.
140
160
  prices: {
141
161
  "claude-haiku-5-5": { inputPerMillion: 0.1, outputPerMillion: 0.5 },
142
162
  },
143
163
  });
144
164
  ```
145
165
 
146
- Call a model through the gateway with the AI SDK:
166
+ Call the model through the gateway with the AI SDK. `callMeta` gives the
167
+ customer and the model for the call:
147
168
 
148
169
  ```ts
149
170
  import { generateText } from "ai";
150
171
  import { weflayrModel } from "convex-weflayr/ai-sdk";
151
172
 
152
- const meta = { customer: workspace.name, plan: workspace.plan, call: "classify-ticket" };
173
+ const meta = await weflayr.callMeta(ctx, { call: "draft-reply", plan: workspace.plan });
153
174
 
154
- const result = await weflayr.track(
155
- ctx,
156
- { ...meta, model: "claude-haiku-5-5" },
157
- () =>
158
- generateText({
159
- model: weflayrModel(weflayr, "claude-haiku-5-5"),
160
- headers: weflayr.headers(meta),
161
- prompt,
162
- }),
175
+ const result = await weflayr.track(ctx, meta, () =>
176
+ generateText({
177
+ model: weflayrModel(weflayr, meta.model),
178
+ headers: weflayr.headers(meta),
179
+ prompt,
180
+ }),
163
181
  );
164
182
  ```
165
183
 
166
- `ctx` is any action context. `track` times the call and saves it. If the call
167
- fails, it saves the failure and throws the error again. Without the AI SDK,
168
- `weflayr.connection()` returns the base URL and key for any OpenAI compatible
169
- client.
184
+ `ctx` is any action context. `track` times the call and saves it, with the model
185
+ that answered when the model is a strategy. If the call fails, it saves the
186
+ failure and throws the error again. When the gateway changed the request, for
187
+ example by removing a setting the chosen model does not take, the warning is
188
+ logged and saved with the call. Without the AI SDK, `weflayr.connection()`
189
+ returns the base URL and key for any OpenAI compatible client.
190
+
191
+ A call with no customer throws, so nothing reaches Weflayr untagged. To log a
192
+ warning and send `unknown` instead, set `missingCustomer: "warn"`.
193
+
194
+ ### Push revenue and plan
195
+
196
+ Weflayr computes margin from revenue. If you bill with Stripe, connect it in
197
+ Weflayr and skip this. For any other billing system, push revenue from your
198
+ billing webhook or a nightly job:
199
+
200
+ ```ts
201
+ await weflayr.sendRevenue([
202
+ { customer_name: workspace.id, amount: 49, month: "2026-11", revenue_id: invoice.id },
203
+ ]);
204
+
205
+ await weflayr.tagCustomers({ [workspace.id]: { plan: "pro" } });
206
+ ```
207
+
208
+ Rows are sent as upserts keyed by `revenue_id`, so a retried webhook does not
209
+ book the same invoice twice, and a temporary Weflayr error is retried. Use one
210
+ revenue source per customer and month: Weflayr adds sources together.
211
+
212
+ ### Bring cost and margin back
213
+
214
+ Run `sync` on a schedule. It reads each customer's cost, and margin when the
215
+ project has revenue, for the month and saves them in the component:
216
+
217
+ ```ts
218
+ // convex/crons.ts
219
+ import { cronJobs } from "convex/server";
220
+ import { internal } from "./_generated/api";
221
+
222
+ const crons = cronJobs();
223
+ crons.interval("sync weflayr", { minutes: 10 }, internal.billing.sync, {});
224
+ export default crons;
225
+
226
+ // convex/billing.ts
227
+ export const sync = internalAction({
228
+ args: {},
229
+ handler: async (ctx) => await weflayr.sync(ctx),
230
+ });
231
+ ```
232
+
233
+ Then read it from a query, so your UI updates live:
234
+
235
+ ```ts
236
+ const status = await weflayr.customerStatus(ctx, "acme");
237
+ // { costUsd, revenue, marginPct, riskStatus, syncedAt } or null before the first sync
238
+ ```
239
+
240
+ `riskStatus` is `healthy`, `watch`, `at_risk` or `unprofitable`. On a project
241
+ without revenue the cost is saved and the margin fields are empty.
170
242
 
171
243
  ### Streaming
172
244
 
@@ -177,13 +249,12 @@ result only has its usage when the stream ends, so record the call yourself in
177
249
  ```ts
178
250
  const started = Date.now();
179
251
  const result = streamText({
180
- model: weflayrModel(weflayr, "claude-haiku-5-5"),
252
+ model: weflayrModel(weflayr, meta.model),
181
253
  headers: weflayr.headers(meta),
182
254
  prompt,
183
255
  onFinish: async ({ usage }) => {
184
256
  await weflayr.record(ctx, {
185
257
  ...meta,
186
- model: "claude-haiku-5-5",
187
258
  inputTokens: usage.inputTokens ?? 0,
188
259
  outputTokens: usage.outputTokens ?? 0,
189
260
  latencyMs: Date.now() - started,
@@ -202,10 +273,10 @@ your own provider key.
202
273
  ```ts
203
274
  const result = await weflayr.withFallback(
204
275
  ctx,
205
- { ...meta, model },
276
+ meta,
206
277
  {
207
- gateway: () => generateText({ model: weflayrModel(weflayr, model), headers: weflayr.headers(meta), prompt }),
208
- direct: () => generateText({ model: yourProvider(model), prompt }),
278
+ gateway: () => generateText({ model: weflayrModel(weflayr, meta.model), headers: weflayr.headers(meta), prompt }),
279
+ direct: () => generateText({ model: yourProvider("claude-haiku-5-5"), prompt }),
209
280
  },
210
281
  );
211
282
  ```
@@ -217,9 +288,10 @@ saved.
217
288
  ### Group the calls of one job
218
289
 
219
290
  Pass the same `runId` (`newRunId()` makes one) to every call of one job. It is
220
- saved with each call and sent to Weflayr as the `run_id` tag.
221
- `weflayr.runCost(ctx, runId)` returns the cost of the whole job and of each
222
- step.
291
+ sent to Weflayr as the feature run id, so Weflayr prices the whole job, and it is
292
+ saved with each call. `weflayr.runCost(ctx, runId)` returns the cost of the whole
293
+ job and of each step. A run id is 32 lowercase hex characters, and anything else
294
+ is rejected.
223
295
 
224
296
  Weflayr also runs an MCP server at `https://mcp.weflayr.com/`, so an agent can
225
297
  read cost and margin as a tool.
@@ -228,12 +300,16 @@ read cost and margin as a tool.
228
300
 
229
301
  ```ts
230
302
  await weflayr.setBudget(ctx, "acme", 25); // 25 dollars a month, UTC calendar month
231
- await weflayr.assertWithinBudget(ctx, "acme"); // throws WeflayrBudgetError once spent
232
- const status = await weflayr.budget(ctx, "acme"); // { limitUsd, spentUsd, remainingUsd, allowed }
303
+ await weflayr.assertWithinBudget(ctx, "acme"); // throws once spent, or on a listed margin status
304
+ const status = await weflayr.budget(ctx, "acme"); // { limitUsd, spentUsd, remainingUsd, allowed, source, riskStatus }
233
305
  ```
234
306
 
235
- A customer without a budget always passes. Spend is the sum of saved cost for
236
- the month. A call already running when the limit is reached can still finish.
307
+ A customer without a budget always passes. After a sync, spend is Weflayr's real
308
+ cost for the month plus the estimated cost of calls made since shortly before the
309
+ sync (`settleMs`, five minutes by default). In that window a call can be counted
310
+ twice, so spend reads slightly high, never low. Before the first sync, spend is
311
+ the sum of your saved estimates and `source` is `"local"`. A call already running
312
+ when the limit is reached can still finish.
237
313
 
238
314
  ### Read the numbers
239
315
 
@@ -253,16 +329,16 @@ updates live in your UI.
253
329
 
254
330
  ## How cost is worked out
255
331
 
256
- The gateway does not send a price back with each response, so the component
257
- uses, in order:
332
+ Weflayr's dashboard is the source of truth for what you were billed, and `sync`
333
+ brings that figure into your database per customer and month. For each saved call
334
+ the gateway does not send a price back, so the component uses, in order:
258
335
 
259
336
  1. A cost you pass: `costUsd` in the call details, or from `extract` in `track`.
260
337
  Saved as `reported`.
261
338
  2. An estimate from your `prices` table. Saved as `estimated`.
262
339
  3. Zero, saved as `none`, when neither is available.
263
340
 
264
- Weflayr's dashboard is the source of truth for what you were billed. Use the
265
- saved cost to compare customers and enforce budgets, and fill `prices` from your
341
+ Per-call costs feed your lists and the gap between syncs. Fill `prices` from your
266
342
  provider's price list.
267
343
 
268
344
  ## What is saved
@@ -272,6 +348,7 @@ provider's price list.
272
348
  | `calls` | model call | until you run `weflayr.purge(ctx, before)` |
273
349
  | `daily` | customer, call and UTC day | always |
274
350
  | `budgets` | customer | until cleared |
351
+ | `synced` | customer and month | replaced by each sync |
275
352
 
276
353
  Queries read the `daily` rows, so they stay fast as `calls` grows. Days and
277
354
  months are UTC.
@@ -280,17 +357,19 @@ months are UTC.
280
357
 
281
358
  - Saving a call never fails the call. If the write fails, the component logs a
282
359
  warning and returns your answer.
283
- - The `plan`, `runId` and any `tags` you pass are sent to Weflayr as tags on the
284
- request.
360
+ - The `plan` and any `tags` you pass are sent to Weflayr as tags on the request,
361
+ and `runId` as its feature run id.
285
362
  - Customer names go to Weflayr as written. Use an id or a name you are happy to
286
363
  see in its dashboard.
287
364
 
288
365
  ## Limitations
289
366
 
290
- - Cost is an estimate from your `prices` table unless you pass the cost of a
291
- call.
292
- - A budget compares saved cost with the limit before a call starts, so a call
293
- already running can push a customer past it.
367
+ - Between syncs, cost is an estimate from your `prices` table unless you pass the
368
+ cost of a call.
369
+ - A budget compares spend with the limit before a call starts, so a call already
370
+ running can push a customer past it.
371
+ - Margin needs revenue in Weflayr, from Stripe, a CSV or `sendRevenue`. On an
372
+ internal project Weflayr has no margin, and `sync` saves cost only.
294
373
  - A customer who is not on the allow list runs the direct call, and that call is
295
374
  not saved.
296
375
  - `usageByCustomer` and the other reports read one `daily` row per customer,
@@ -308,8 +387,15 @@ months are UTC.
308
387
  **Cost shows $0.** The model is missing from `prices`. Add it, in dollars per
309
388
  million tokens.
310
389
 
311
- **A budget never stops anyone.** A budget compares saved cost, so it needs
312
- `prices`, or a `costUsd` on each call.
390
+ **A budget never stops anyone.** Run `sync` on a schedule, or fill `prices`, so
391
+ there is a cost to compare.
392
+
393
+ **`No customer for the call`.** The call has no signed-in user and no `customer`
394
+ function returned one. Pass the customer, or set `missingCustomer: "warn"`.
395
+
396
+ **`unknown_routing_strategy` or `invalid_routing_strategy_version`.** The
397
+ strategy id or version does not exist in the project of your key. List them in
398
+ the Weflayr dashboard.
313
399
 
314
400
  **The gateway returns 402.** Your Weflayr balance is empty. Add credit in the
315
401
  Weflayr dashboard.
@@ -0,0 +1,116 @@
1
+ export declare const MANAGEMENT_URL = "https://app.weflayr.com/api";
2
+ /** A customer's margin status in Weflayr. The thresholds behind each one are set in the Weflayr dashboard. */
3
+ export type RiskStatus = "healthy" | "watch" | "at_risk" | "unprofitable" | (string & {});
4
+ export type Aggregate = "day" | "week" | "month" | "quarter" | "year" | "all";
5
+ export type CostsInput = {
6
+ date_from: string;
7
+ date_to?: string;
8
+ aggregate?: Aggregate;
9
+ group_by?: "customer" | "feature" | "model" | "provider" | "call_name";
10
+ customers?: string[];
11
+ features?: string[];
12
+ call_names?: string[];
13
+ models?: string[];
14
+ providers?: string[];
15
+ customer_tags?: Record<string, string | string[]>;
16
+ run_tags?: Record<string, string | string[]>;
17
+ apply_credits?: boolean;
18
+ limit?: number;
19
+ cursor?: string;
20
+ };
21
+ export type CostRow = {
22
+ date_from: string;
23
+ date_to: string;
24
+ customer?: string;
25
+ feature?: string;
26
+ model?: string;
27
+ provider?: string;
28
+ call_name?: string;
29
+ cost: number;
30
+ avg_feature_run_cost?: number;
31
+ max_feature_run_cost?: number;
32
+ };
33
+ export type MarginInput = {
34
+ date_from: string;
35
+ date_to?: string;
36
+ aggregate?: Aggregate;
37
+ group_by?: "customer";
38
+ customers?: string[];
39
+ call_names?: string[];
40
+ filter_customers_based_on_features?: string[];
41
+ apply_credits?: boolean;
42
+ limit?: number;
43
+ cursor?: string;
44
+ };
45
+ export type MarginRow = {
46
+ date_from: string;
47
+ date_to: string;
48
+ customer?: string;
49
+ revenue: number;
50
+ cost: number;
51
+ margin: number;
52
+ margin_pct: number | null;
53
+ risk_status: RiskStatus;
54
+ };
55
+ export type Page<T> = {
56
+ rows: T[];
57
+ next_cursor: string | null;
58
+ has_more: boolean;
59
+ };
60
+ export type RevenueRow = {
61
+ /** The same customer name your model calls carry. */
62
+ customer_name: string;
63
+ amount: number;
64
+ /** "YYYY-MM". Or set month_start and month_end for a period longer than a month. */
65
+ month?: string;
66
+ month_start?: string;
67
+ month_end?: string;
68
+ /** An id from your billing system. With `upsert`, sending the same id again overwrites the row instead of adding to it. */
69
+ revenue_id?: string;
70
+ };
71
+ export type CustomerTags = {
72
+ customer_name: string;
73
+ /** A string sets the tag, null deletes it. Keys you leave out are not changed. */
74
+ tags: Record<string, string | null>;
75
+ };
76
+ export declare class WeflayrApiError extends Error {
77
+ readonly status: number;
78
+ constructor(status: number, message: string);
79
+ }
80
+ /** The first and last day of a "YYYY-MM" month. */
81
+ export declare function monthRange(month: string): {
82
+ from: string;
83
+ to: string;
84
+ };
85
+ /**
86
+ * A small client for Weflayr's management API: cost, margin, revenue and customer tags.
87
+ * It needs a key with the Management scope.
88
+ */
89
+ export declare class WeflayrApi {
90
+ private readonly options;
91
+ constructor(options?: {
92
+ apiKey?: string;
93
+ baseURL?: string;
94
+ });
95
+ private key;
96
+ private post;
97
+ getCosts(input: CostsInput): Promise<Page<CostRow>>;
98
+ getMargin(input: MarginInput): Promise<Page<MarginRow>>;
99
+ /** Books revenue per customer and month. Sends 1,000 rows per request. */
100
+ setRevenue(rows: RevenueRow[], options?: {
101
+ upsert?: boolean;
102
+ }): Promise<{
103
+ rowsInserted: number;
104
+ }>;
105
+ /** Sets tags such as the plan on customers. Sends 1,000 customers per request. */
106
+ setCustomerTags(customers: CustomerTags[], options?: {
107
+ upsert?: boolean;
108
+ }): Promise<{
109
+ customersUpdated: number;
110
+ }>;
111
+ /** What Weflayr has recorded for one customer in one month, in dollars, at list price. */
112
+ customerCost(customer: string, month: string): Promise<number>;
113
+ /** One customer's margin for one month, or null when Weflayr has no row for them. */
114
+ customerMargin(customer: string, month: string): Promise<MarginRow | null>;
115
+ }
116
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/client/api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,130 @@
1
+ export const MANAGEMENT_URL = "https://app.weflayr.com/api";
2
+ export class WeflayrApiError extends Error {
3
+ status;
4
+ constructor(status, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = "WeflayrApiError";
8
+ }
9
+ }
10
+ const MAX_ROWS = 1000;
11
+ function chunk(items) {
12
+ const out = [];
13
+ for (let i = 0; i < items.length; i += MAX_ROWS)
14
+ out.push(items.slice(i, i + MAX_ROWS));
15
+ return out;
16
+ }
17
+ /** The first and last day of a "YYYY-MM" month. */
18
+ export function monthRange(month) {
19
+ const [year, monthIndex] = month.split("-").map(Number);
20
+ const last = new Date(Date.UTC(year, monthIndex, 0)).getUTCDate();
21
+ return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, "0")}` };
22
+ }
23
+ /**
24
+ * A small client for Weflayr's management API: cost, margin, revenue and customer tags.
25
+ * It needs a key with the Management scope.
26
+ */
27
+ export class WeflayrApi {
28
+ options;
29
+ constructor(options = {}) {
30
+ this.options = options;
31
+ }
32
+ key() {
33
+ const key = this.options.apiKey ??
34
+ process.env.WEFLAYR_MANAGEMENT_KEY ??
35
+ process.env.WEFLAYR_API_KEY;
36
+ if (!key) {
37
+ throw new Error("Set WEFLAYR_MANAGEMENT_KEY, or WEFLAYR_API_KEY with the Management scope.");
38
+ }
39
+ return key;
40
+ }
41
+ async post(path, body) {
42
+ let response;
43
+ try {
44
+ response = await fetch(`${this.options.baseURL ?? MANAGEMENT_URL}${path}`, {
45
+ method: "POST",
46
+ headers: {
47
+ authorization: `Bearer ${this.key()}`,
48
+ "content-type": "application/json",
49
+ },
50
+ body: JSON.stringify(body),
51
+ signal: AbortSignal.timeout(30_000),
52
+ });
53
+ }
54
+ catch (error) {
55
+ if (error instanceof Error && error.message.startsWith("Set WEFLAYR_"))
56
+ throw error;
57
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error;
58
+ const code = cause.code;
59
+ throw new Error(`Could not reach Weflayr's management API (${this.options.baseURL ?? MANAGEMENT_URL}${path}): ${cause instanceof Error ? cause.message : String(cause)}${code ? ` [${code}]` : ""}`);
60
+ }
61
+ const text = await response.text();
62
+ if (!response.ok) {
63
+ let message = text.slice(0, 300);
64
+ try {
65
+ const parsed = JSON.parse(text);
66
+ message = String(parsed.error ?? JSON.stringify(parsed.detail ?? parsed)).slice(0, 300);
67
+ }
68
+ catch {
69
+ // keep the raw text
70
+ }
71
+ throw new WeflayrApiError(response.status, `Weflayr ${path} answered ${response.status}: ${message}`);
72
+ }
73
+ return JSON.parse(text);
74
+ }
75
+ getCosts(input) {
76
+ return this.post("/costs/", input);
77
+ }
78
+ getMargin(input) {
79
+ return this.post("/margin/", input);
80
+ }
81
+ /** Books revenue per customer and month. Sends 1,000 rows per request. */
82
+ async setRevenue(rows, options = {}) {
83
+ let inserted = 0;
84
+ for (const part of chunk(rows)) {
85
+ const result = await this.post("/revenue/", {
86
+ rows: part,
87
+ upsert: options.upsert ?? true,
88
+ });
89
+ inserted += result.rows_inserted ?? part.length;
90
+ }
91
+ return { rowsInserted: inserted };
92
+ }
93
+ /** Sets tags such as the plan on customers. Sends 1,000 customers per request. */
94
+ async setCustomerTags(customers, options = {}) {
95
+ let updated = 0;
96
+ for (const part of chunk(customers)) {
97
+ const result = await this.post("/customer-tags/", {
98
+ customers: part,
99
+ upsert: options.upsert ?? true,
100
+ });
101
+ updated += result.customers_updated ?? part.length;
102
+ }
103
+ return { customersUpdated: updated };
104
+ }
105
+ /** What Weflayr has recorded for one customer in one month, in dollars, at list price. */
106
+ async customerCost(customer, month) {
107
+ const { from, to } = monthRange(month);
108
+ const page = await this.getCosts({
109
+ date_from: from,
110
+ date_to: to,
111
+ aggregate: "all",
112
+ group_by: "customer",
113
+ customers: [customer],
114
+ });
115
+ return page.rows.reduce((sum, row) => sum + (row.cost ?? 0), 0);
116
+ }
117
+ /** One customer's margin for one month, or null when Weflayr has no row for them. */
118
+ async customerMargin(customer, month) {
119
+ const { from, to } = monthRange(month);
120
+ const page = await this.getMargin({
121
+ date_from: from,
122
+ date_to: to,
123
+ aggregate: "all",
124
+ group_by: "customer",
125
+ customers: [customer],
126
+ });
127
+ return page.rows[0] ?? null;
128
+ }
129
+ }
130
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/client/api.ts"],"names":[],"mappings":"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"}