octaflow 0.30.0 → 0.31.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
@@ -215,7 +215,7 @@ next to what your steps actually do.
215
215
  | 🚑 | **Crash recovery** — a step whose worker died is re-queued while its attempt budget lasts |
216
216
  | 💓 | **Heartbeats** — a long step proves it is alive, so a dead one is caught in seconds, not minutes |
217
217
  | 🔭 | **Observability** — lifecycle events (run history), per-step spans, and `createMeterObserver` turning the events into OpenTelemetry-style metrics — all pluggable, all structural |
218
- | 🤖 | **AI add-on** — instrumented models, token/cost capture, quota, daily rollups, refusal-aware models, lenient JSON from local models (`withJsonRepair`), a vendor's refusal explained by key and hint (`explainProviderError`), a per-run circuit breaker, a connection test (`testProvider`) |
218
+ | 🤖 | **AI add-on** — instrumented models, token/cost capture, quota, daily rollups, a `usage` sink that books each step into `@octabits-io/metering` (the paid APIs around the model in the same table, priced at read time), refusal-aware models, lenient JSON from local models (`withJsonRepair`), a vendor's refusal explained by key and hint (`explainProviderError`), a per-run circuit breaker, a connection test (`testProvider`) |
219
219
  | 🧱 | **Pluggable everything** — `WorkflowStore`, `Dispatcher`, `StepGate`, `FlowObserver`, hooks |
220
220
 
221
221
  ---
@@ -12,6 +12,8 @@ export interface AccumulatedUsage {
12
12
  cacheReadTokens: number;
13
13
  cacheWriteTokens: number;
14
14
  modelId: string;
15
+ /** The provider the model belongs to (`model.provider`), when the call reported it. */
16
+ provider?: string;
15
17
  }
16
18
  export interface UsageAccumulator {
17
19
  /** Record usage from a single LLM call (additive — supports multiple calls per step). */
@@ -222,6 +224,32 @@ export interface AiUsageRecorder {
222
224
  date: string;
223
225
  }): Promise<void>;
224
226
  }
227
+ /**
228
+ * What one step's model usage looks like to a metering sink: the booking
229
+ * `@octabits-io/metering`'s `record` takes, by shape — tokens as the vendor
230
+ * reported them, the provider as the service, and the attribution octaflow
231
+ * knows (the run, the workflow type as the feature, the step). A sink that
232
+ * runs inside `withUsage` adds the paying account from the ambient context.
233
+ */
234
+ export interface AiModelUsageEvent {
235
+ kind: 'model';
236
+ /** The provider (`model.provider`), or `'model'` when the call did not report one. */
237
+ service: string;
238
+ model: string;
239
+ inputTokens: number;
240
+ outputTokens: number;
241
+ cacheReadTokens: number;
242
+ cacheWriteTokens: number;
243
+ context: {
244
+ runId: string;
245
+ feature: string;
246
+ correlationId?: string;
247
+ };
248
+ }
249
+ /** Where a step's model usage is booked — `@octabits-io/metering`'s `Metering` satisfies it. Optional: without one nothing is booked. */
250
+ export interface AiUsageSink {
251
+ record(event: AiModelUsageEvent): unknown;
252
+ }
225
253
  export interface AiQuotaPolicy {
226
254
  /** Return an error (e.g. `ai_quota_exceeded`) to reject a workflow start. */
227
255
  checkQuota(): Promise<Result<void, FlowErrorShape>>;
@@ -231,6 +259,12 @@ export interface CreateAiWorkflowHooksDeps<THost = unknown> {
231
259
  usageRecorder: AiUsageRecorder;
232
260
  /** Optional quota guard. BYOK exemption etc. is the policy's concern. */
233
261
  quotaPolicy?: AiQuotaPolicy;
262
+ /**
263
+ * Optional metering sink: every step's model usage is booked here as well,
264
+ * unpriced, attributed to the run and the workflow type — so a model call
265
+ * and the paid API calls around it land in one table with one context.
266
+ */
267
+ usage?: AiUsageSink;
234
268
  /** Defaults to `createCostEstimator()` (the built-in pricing table). */
235
269
  costEstimator?: CostEstimator;
236
270
  /** Clock injection for tests. */
package/dist/ai/index.js CHANGED
@@ -21,6 +21,7 @@ function createUsageAccumulator() {
21
21
  total.cacheReadTokens += usage.cacheReadTokens;
22
22
  total.cacheWriteTokens += usage.cacheWriteTokens;
23
23
  if (usage.modelId) total.modelId = usage.modelId;
24
+ if (usage.provider) total.provider = usage.provider;
24
25
  hasAny = true;
25
26
  },
26
27
  get() {
@@ -48,7 +49,8 @@ function createInstrumentedModel(model, accumulator) {
48
49
  outputTokens: usage.outputTokens?.total ?? 0,
49
50
  cacheReadTokens: usage.inputTokens?.cacheRead ?? 0,
50
51
  cacheWriteTokens: usage.inputTokens?.cacheWrite ?? 0,
51
- modelId: model.modelId
52
+ modelId: model.modelId,
53
+ provider: model.provider
52
54
  });
53
55
  return result;
54
56
  },
@@ -63,7 +65,8 @@ function createInstrumentedModel(model, accumulator) {
63
65
  outputTokens: u.outputTokens?.total ?? 0,
64
66
  cacheReadTokens: u.inputTokens?.cacheRead ?? 0,
65
67
  cacheWriteTokens: u.inputTokens?.cacheWrite ?? 0,
66
- modelId: model.modelId
68
+ modelId: model.modelId,
69
+ provider: model.provider
67
70
  });
68
71
  }
69
72
  } }));
@@ -440,7 +443,7 @@ function createAiWorkflowHooks(deps) {
440
443
  usage
441
444
  };
442
445
  },
443
- async onAfterStep({ workflowId, stepId, context }) {
446
+ async onAfterStep({ workflowId, stepId, workflow, step, context }) {
444
447
  const acc = context.usage;
445
448
  if (!acc.hasUsage()) return;
446
449
  const usage = acc.get();
@@ -456,6 +459,19 @@ function createAiWorkflowHooks(deps) {
456
459
  usage,
457
460
  costMicros
458
461
  });
462
+ deps.usage?.record({
463
+ kind: "model",
464
+ service: usage.provider ?? "model",
465
+ model: usage.modelId,
466
+ inputTokens: usage.inputTokens,
467
+ outputTokens: usage.outputTokens,
468
+ cacheReadTokens: usage.cacheReadTokens,
469
+ cacheWriteTokens: usage.cacheWriteTokens,
470
+ context: {
471
+ runId: String(workflowId),
472
+ feature: workflow?.type ?? step?.key ?? "workflow"
473
+ }
474
+ });
459
475
  },
460
476
  async onWorkflowCompleted({ workflowId, workflow }) {
461
477
  if (!deps.usageRecorder.recordWorkflowDaily) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octaflow",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Durable DAG workflow engine: Zod-typed steps over Postgres + pg-boss, with an optional AI add-on (token/cost/quota). Layered single package.",
5
5
  "license": "MIT",
6
6
  "type": "module",