@velum-labs/routekit-eval-service 1.3.2 → 1.4.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.
@@ -4,11 +4,13 @@ export type RouteKitEvalGatewayCallEvent = {
4
4
  readonly observationId: string;
5
5
  readonly phase: "issued";
6
6
  readonly role: "candidate" | "judge";
7
+ readonly model: string;
7
8
  } | {
8
9
  readonly callId?: string;
9
10
  readonly observationId: string;
10
11
  readonly phase: "completed";
11
12
  readonly role: "candidate" | "judge";
13
+ readonly model: string;
12
14
  };
13
15
  export type RouteKitEvalServiceOptions = Omit<NodeTestExecutionOptions, "bridgeOrigin"> & {
14
16
  /**
@@ -1,6 +1,7 @@
1
1
  import { layer as NodeServicesLayer } from "@effect/platform-node/NodeServices";
2
2
  import { EVAL_ATTRIBUTION_HEADER } from "@velum-labs/routekit-eval-contracts";
3
3
  import { EvalEngineExecutionError, EvalExecutionPort, makeEvalEngineLayer, makeRouteKitEvalExecutionPortService } from "@velum-labs/routekit-eval-engine";
4
+ import { CostServiceLive } from "@velum-labs/routekit-registry";
4
5
  import { Data, Effect, FileSystem, Layer, Path, Stream } from "effect";
5
6
  import { HttpClient } from "effect/unstable/http";
6
7
  import { makeEvalServiceLayer } from "./service.js";
@@ -21,22 +22,42 @@ const gatewayCallRole = (raw) => {
21
22
  return undefined;
22
23
  }
23
24
  };
25
+ const gatewayCallModel = (body) => {
26
+ const raw = body._tag === "Uint8Array"
27
+ ? new TextDecoder().decode(body.body)
28
+ : body._tag === "Raw" && typeof body.body === "string"
29
+ ? body.body
30
+ : undefined;
31
+ if (raw === undefined)
32
+ return undefined;
33
+ try {
34
+ const decoded = JSON.parse(raw);
35
+ return typeof decoded.model === "string" && decoded.model.length > 0
36
+ ? decoded.model
37
+ : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ };
24
43
  const observeGatewayCalls = (client, observe) => {
25
44
  let nextObservationId = 0;
26
45
  return HttpClient.transform(client, (responseEffect, request) => {
27
46
  const role = gatewayCallRole(request.headers[EVAL_ATTRIBUTION_HEADER]);
28
- if (role === undefined)
47
+ const model = gatewayCallModel(request.body);
48
+ if (role === undefined || model === undefined)
29
49
  return responseEffect;
30
50
  return Effect.gen(function* () {
31
51
  nextObservationId += 1;
32
52
  const observationId = `gateway-call-${String(nextObservationId)}`;
33
- yield* observe({ observationId, phase: "issued", role });
53
+ yield* observe({ observationId, phase: "issued", role, model });
34
54
  const response = yield* responseEffect;
35
55
  const rawCallId = response.headers[MODEL_CALL_ID_HEADER]?.trim();
36
56
  yield* observe({
37
57
  observationId,
38
58
  phase: "completed",
39
59
  role,
60
+ model,
40
61
  ...(rawCallId === undefined || rawCallId.length === 0 ? {} : { callId: rawCallId })
41
62
  });
42
63
  return response;
@@ -112,4 +133,4 @@ const makeProductionEvalEngineLayer = (options) => makeEvalEngineLayer().pipe(La
112
133
  * execution port is the only adapter; no parallel comparison-runner service
113
134
  * mirrors or wraps the engine API.
114
135
  */
115
- export const makeRouteKitEvalServiceLayer = (configuration, options) => makeEvalServiceLayer(configuration).pipe(Layer.provide(makeProductionEvalEngineLayer(options)), Layer.provide(NodeServicesLayer));
136
+ export const makeRouteKitEvalServiceLayer = (configuration, options) => makeEvalServiceLayer(configuration).pipe(Layer.provide(makeProductionEvalEngineLayer(options)), Layer.provide(CostServiceLive), Layer.provide(NodeServicesLayer));
package/dist/service.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { EvalComparisonRequest, EvalComparisonResult, EvalReasoningEffort, PublishedRoutingActivation, RoutingActivationConstraints, RoutingBasis, RoutingObjectivePolicy } from "@velum-labs/routekit-eval-contracts";
2
2
  import { EvalRunManifest } from "@velum-labs/routekit-eval-contracts";
3
3
  import { EvalEngine } from "@velum-labs/routekit-eval-engine";
4
+ import { CostService } from "@velum-labs/routekit-registry";
4
5
  import { Context, Effect, FileSystem, Layer, Path } from "effect";
5
6
  import { EvalServiceComparisonError, EvalServiceConfigurationError, EvalServiceEstimateError, EvalServicePolicyError, EvalServicePublicationError, EvalServiceSpendLimitError, EvalServiceValidationError } from "./errors.js";
6
7
  export type EvalComparisonMode = "pilot" | "full";
@@ -10,8 +11,13 @@ export type EvalSuiteInspection = {
10
11
  };
11
12
  export type EvalComparisonEstimate = {
12
13
  readonly callCount: number;
14
+ readonly maximumInputTokens: number;
15
+ readonly maximumOutputTokens: number;
13
16
  readonly maximumCostUsd?: number;
14
- readonly pricingKnown: boolean;
17
+ readonly knownPricedMaximumUsd: number;
18
+ readonly unpricedCalls: number;
19
+ readonly subscriptionUnitCalls: number;
20
+ readonly unknowns: readonly string[];
15
21
  };
16
22
  export type EvalRunConfiguration = {
17
23
  readonly concurrency?: number;
@@ -52,6 +58,6 @@ export type EvalServiceShape = {
52
58
  declare const EvalService_base: Context.ServiceClass<EvalService, "@velum-labs/routekit-eval-service/EvalService", EvalServiceShape>;
53
59
  export declare class EvalService extends EvalService_base {
54
60
  }
55
- export declare const makeEvalService: (configuration?: EvalServiceConfiguration) => Effect.Effect<EvalService["Service"], never, EvalEngine | FileSystem.FileSystem | Path.Path>;
56
- export declare const makeEvalServiceLayer: (configuration?: EvalServiceConfiguration) => Layer.Layer<EvalService, never, EvalEngine | FileSystem.FileSystem | Path.Path>;
61
+ export declare const makeEvalService: (configuration?: EvalServiceConfiguration) => Effect.Effect<EvalService["Service"], never, CostService | EvalEngine | FileSystem.FileSystem | Path.Path>;
62
+ export declare const makeEvalServiceLayer: (configuration?: EvalServiceConfiguration) => Layer.Layer<EvalService, never, CostService | EvalEngine | FileSystem.FileSystem | Path.Path>;
57
63
  export {};
package/dist/service.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { assertExplicitEvalModel, assertRoutingBasis, EvalRunManifest } from "@velum-labs/routekit-eval-contracts";
2
2
  import { EvalEngine } from "@velum-labs/routekit-eval-engine";
3
3
  import { makeRoutingActivationStore } from "@velum-labs/routekit-eval-store";
4
- import { DEFAULT_MODEL_PRICING, PRICING_ALIASES } from "@velum-labs/routekit-registry";
4
+ import { CostService } from "@velum-labs/routekit-registry";
5
5
  import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
6
6
  import { compileDimensionEvidenceMatrix } from "./dimension-evidence.js";
7
7
  import { EvalServiceComparisonError, EvalServiceConfigurationError, EvalServiceEstimateError, EvalServicePolicyError, EvalServicePublicationError, EvalServiceSpendLimitError, EvalServiceValidationError } from "./errors.js";
@@ -101,61 +101,57 @@ const comparisonRequest = (configuration, gatewayUrl, input, suite) => ({
101
101
  : { timeoutMs: configuration.full.timeoutMs })
102
102
  });
103
103
  const MAXIMUM_INPUT_TOKENS_PER_CALL = 256 * 1024;
104
- const modelPricing = (model) => {
105
- const names = [model, model.includes("/") ? model.slice(model.indexOf("/") + 1) : model];
106
- for (const name of names) {
107
- const direct = Object.entries(DEFAULT_MODEL_PRICING).find(([candidate]) => candidate.toLowerCase() === name.toLowerCase())?.[1];
108
- if (direct !== undefined)
109
- return direct;
110
- const alias = Object.entries(PRICING_ALIASES).find(([candidate]) => candidate.toLowerCase() === name.toLowerCase())?.[1];
111
- if (alias === undefined)
112
- continue;
113
- const resolved = Object.entries(DEFAULT_MODEL_PRICING).find(([candidate]) => candidate.toLowerCase() === alias.toLowerCase())?.[1];
114
- if (resolved !== undefined)
115
- return resolved;
116
- }
117
- return undefined;
118
- };
119
- const maximumCallCost = (pricing, maximumOutputTokens) => (MAXIMUM_INPUT_TOKENS_PER_CALL * pricing.inputPer1mTokens +
120
- maximumOutputTokens * pricing.outputPer1mTokens) /
121
- 1_000_000;
122
- const estimateManifest = (manifest) => {
123
- const candidatePrices = manifest.candidateModels.map(modelPricing);
124
- const judgePrice = modelPricing(manifest.judgeModel);
125
- if (candidatePrices.some((pricing) => pricing === undefined) || judgePrice === undefined) {
126
- return {
127
- callCount: manifest.expectedCallCount,
128
- pricingKnown: false
129
- };
130
- }
131
- const candidateCost = candidatePrices.reduce((total, pricing) => total + manifest.caseCount * maximumCallCost(pricing, manifest.maxOutputTokens), 0);
132
- const judgeCost = manifest.caseCount *
133
- manifest.candidateModels.length *
134
- maximumCallCost(judgePrice, manifest.maxOutputTokens);
135
- return {
104
+ const estimateManifest = (manifest, costService) => {
105
+ const calls = [
106
+ ...manifest.candidateModels.map((model) => ({
107
+ model,
108
+ callCount: manifest.caseCount,
109
+ maximumInputTokens: MAXIMUM_INPUT_TOKENS_PER_CALL,
110
+ maximumOutputTokens: manifest.maxOutputTokens,
111
+ ...(manifest.candidateReasoningEfforts?.[model] === undefined
112
+ ? {}
113
+ : { reasoningEffort: manifest.candidateReasoningEfforts[model] })
114
+ })),
115
+ {
116
+ model: manifest.judgeModel,
117
+ callCount: manifest.caseCount * manifest.candidateModels.length,
118
+ maximumInputTokens: MAXIMUM_INPUT_TOKENS_PER_CALL,
119
+ maximumOutputTokens: manifest.maxOutputTokens
120
+ }
121
+ ];
122
+ return costService.estimate(calls).pipe(Effect.map((estimate) => ({
136
123
  callCount: manifest.expectedCallCount,
137
- maximumCostUsd: candidateCost + judgeCost,
138
- pricingKnown: true
139
- };
124
+ maximumInputTokens: estimate.knownInputTokens,
125
+ maximumOutputTokens: estimate.knownOutputTokens,
126
+ ...(estimate.maximumCostUsd === undefined ? {} : { maximumCostUsd: estimate.maximumCostUsd }),
127
+ knownPricedMaximumUsd: estimate.knownPricedMaximumUsd,
128
+ unpricedCalls: estimate.unpricedCalls + estimate.upstreamManagedCalls + estimate.unknownBillingCalls,
129
+ subscriptionUnitCalls: estimate.subscriptionUnitCalls,
130
+ unknowns: estimate.unknowns
131
+ })));
140
132
  };
141
- const enforceSpendLimit = (request, manifest) => {
142
- if (request.spendLimitUsd === undefined)
133
+ const enforceSpendLimit = (request, manifest, costService) => {
134
+ const spendLimitUsd = request.spendLimitUsd;
135
+ if (spendLimitUsd === undefined)
143
136
  return Effect.void;
144
- const estimate = estimateManifest(manifest);
145
- if (!estimate.pricingKnown || estimate.maximumCostUsd === undefined) {
146
- return Effect.fail(new EvalServiceSpendLimitError({
147
- operation: "enforce the comparison spend limit",
148
- detail: "RouteKit Eval cannot enforce spendLimitUsd because pricing is unknown for one or more manifest models."
149
- }));
150
- }
151
- if (estimate.maximumCostUsd > request.spendLimitUsd) {
152
- return Effect.fail(new EvalServiceSpendLimitError({
153
- operation: "enforce the comparison spend limit",
154
- detail: `RouteKit Eval maximum estimated cost $${estimate.maximumCostUsd.toFixed(6)} ` +
155
- `exceeds spendLimitUsd $${request.spendLimitUsd.toFixed(6)}.`
156
- }));
157
- }
158
- return Effect.void;
137
+ return estimateManifest(manifest, costService).pipe(Effect.flatMap((estimate) => {
138
+ if (estimate.unpricedCalls > 0 ||
139
+ estimate.maximumCostUsd === undefined ||
140
+ estimate.unknowns.length > 0) {
141
+ return Effect.fail(new EvalServiceSpendLimitError({
142
+ operation: "enforce the comparison spend limit",
143
+ detail: "RouteKit Eval cannot enforce spendLimitUsd because pricing is unknown for one or more manifest models."
144
+ }));
145
+ }
146
+ if (estimate.maximumCostUsd > spendLimitUsd) {
147
+ return Effect.fail(new EvalServiceSpendLimitError({
148
+ operation: "enforce the comparison spend limit",
149
+ detail: `RouteKit Eval maximum estimated cost $${estimate.maximumCostUsd.toFixed(6)} ` +
150
+ `exceeds spendLimitUsd $${spendLimitUsd.toFixed(6)}.`
151
+ }));
152
+ }
153
+ return Effect.void;
154
+ }));
159
155
  };
160
156
  const EXECUTION_MANIFEST_NAME = "routekit.eval-manifest.json";
161
157
  const discoverExecutionManifests = Effect.fnUntraced(function* (root) {
@@ -253,6 +249,7 @@ const loadExecutionManifest = Effect.fn("EvalService.loadExecutionManifest")(fun
253
249
  });
254
250
  export const makeEvalService = (configuration = {}) => Effect.gen(function* () {
255
251
  const engine = yield* EvalEngine;
252
+ const costService = yield* CostService;
256
253
  const fs = yield* FileSystem.FileSystem;
257
254
  const paths = yield* Path.Path;
258
255
  const inspectRaw = Effect.fn("EvalService.inspectRaw")(function* (request) {
@@ -273,12 +270,12 @@ export const makeEvalService = (configuration = {}) => Effect.gen(function* () {
273
270
  detail: detailOf(cause),
274
271
  cause
275
272
  })));
276
- const estimate = (request) => inspectRaw(request).pipe(Effect.map((inspection) => estimateManifest(inspection.manifest)), Effect.mapError((cause) => new EvalServiceEstimateError({
273
+ const estimate = (request) => inspectRaw(request).pipe(Effect.flatMap((inspection) => estimateManifest(inspection.manifest, costService)), Effect.mapError((cause) => new EvalServiceEstimateError({
277
274
  operation: "estimate the comparison",
278
275
  detail: detailOf(cause),
279
276
  cause
280
277
  })));
281
- const runInspectedComparison = (request, inspection) => enforceSpendLimit(request, inspection.manifest).pipe(Effect.flatMap(() => engine
278
+ const runInspectedComparison = (request, inspection) => enforceSpendLimit(request, inspection.manifest, costService).pipe(Effect.flatMap(() => engine
282
279
  .runComparison({
283
280
  ...request,
284
281
  expectedCaseIds: [...inspection.manifest.caseIds],
@@ -99,7 +99,10 @@ test("production runner estimates the exact imported nested-loop testdrive suite
99
99
  }, "pilot");
100
100
  })).pipe(Effect.provide(NodeHttpClient.layerUndici)));
101
101
  assert.equal(result.callCount, 30);
102
- assert.equal(result.pricingKnown, true);
102
+ assert.equal(result.unpricedCalls, 0);
103
+ assert.equal(result.subscriptionUnitCalls, 0);
104
+ assert.equal(result.maximumInputTokens, 30 * 256 * 1024);
105
+ assert.equal(result.maximumOutputTokens, 30 * 1_024);
103
106
  assert.equal((result.maximumCostUsd ?? 0) > 0, true);
104
107
  });
105
108
  test("production runner rejects a manifest for a different profile", async () => {
@@ -234,6 +237,7 @@ test("production runner executes candidate and judge traffic through the live en
234
237
  observed.push({
235
238
  phase: event.phase,
236
239
  role: event.role,
240
+ model: event.model,
237
241
  ...(event.phase === "completed" && event.callId !== undefined
238
242
  ? { callId: event.callId }
239
243
  : {})
@@ -251,17 +255,19 @@ test("production runner executes candidate and judge traffic through the live en
251
255
  { model: "openai/judge", reasoningEffort: undefined }
252
256
  ]);
253
257
  assert.deepEqual(observed, [
254
- { phase: "issued", role: "candidate" },
258
+ { phase: "issued", role: "candidate", model: "openai/cheap" },
255
259
  {
256
260
  callId: "model-call-1",
257
261
  phase: "completed",
258
- role: "candidate"
262
+ role: "candidate",
263
+ model: "openai/cheap"
259
264
  },
260
- { phase: "issued", role: "judge" },
265
+ { phase: "issued", role: "judge", model: "openai/judge" },
261
266
  {
262
267
  callId: "model-call-2",
263
268
  phase: "completed",
264
- role: "judge"
269
+ role: "judge",
270
+ model: "openai/judge"
265
271
  }
266
272
  ]);
267
273
  assert.deepEqual(result.models.map(({ model }) => model), ["openai/cheap"]);
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { after, test } from "node:test";
6
6
  import { layer as NodeServicesLayer } from "@effect/platform-node/NodeServices";
7
7
  import { EvalEngine } from "@velum-labs/routekit-eval-engine";
8
+ import { CostServiceLive } from "@velum-labs/routekit-registry";
8
9
  import { Effect, Fiber, FileSystem, Layer, Path } from "effect";
9
10
  import { EvalService, makeEvalService, makeEvalServiceLayer } from "../service.js";
10
11
  const roots = [];
@@ -135,7 +136,7 @@ test("manifest discovery does not call FileSystem.glob when Node glob is unavail
135
136
  suiteDigest: "suite-nested"
136
137
  }),
137
138
  runComparison: () => Effect.die("unexpected comparison")
138
- })), Effect.provideService(FileSystem.FileSystem, fileSystemWithoutNodeGlob), Effect.provideService(Path.Path, paths));
139
+ })), Effect.provideService(FileSystem.FileSystem, fileSystemWithoutNodeGlob), Effect.provideService(Path.Path, paths), Effect.provide(CostServiceLive));
139
140
  return yield* service.inspect({
140
141
  version: 1,
141
142
  profileId: "nested",
@@ -162,7 +163,7 @@ test("dimension matrix qualification inspects every manifest before publishing o
162
163
  const layer = makeEvalServiceLayer({
163
164
  gatewayUrl: "http://127.0.0.1:8080/v1",
164
165
  snapshotRoot
165
- }).pipe(Layer.provide(engine), Layer.provide(NodeServicesLayer));
166
+ }).pipe(Layer.provide(engine), Layer.provide(CostServiceLive), Layer.provide(NodeServicesLayer));
166
167
  const result = await Effect.runPromise(Effect.gen(function* () {
167
168
  return yield* (yield* EvalService).qualifyDimensionMatrix(qualification(root));
168
169
  }).pipe(Effect.provide(layer)));
@@ -184,7 +185,7 @@ test("dimension matrix qualification never publishes incomplete comparison evide
184
185
  const layer = makeEvalServiceLayer({
185
186
  gatewayUrl: "http://127.0.0.1:8080/v1",
186
187
  snapshotRoot
187
- }).pipe(Layer.provide(engine), Layer.provide(NodeServicesLayer));
188
+ }).pipe(Layer.provide(engine), Layer.provide(CostServiceLive), Layer.provide(NodeServicesLayer));
188
189
  const exit = await Effect.runPromiseExit(Effect.gen(function* () {
189
190
  return yield* (yield* EvalService).qualifyDimensionMatrix(qualification(root));
190
191
  }).pipe(Effect.provide(layer)));
@@ -204,7 +205,7 @@ test("interrupted qualification leaves no half-published activation for a daemon
204
205
  const layer = makeEvalServiceLayer({
205
206
  gatewayUrl: "http://127.0.0.1:8080/v1",
206
207
  snapshotRoot
207
- }).pipe(Layer.provide(engine), Layer.provide(NodeServicesLayer));
208
+ }).pipe(Layer.provide(engine), Layer.provide(CostServiceLive), Layer.provide(NodeServicesLayer));
208
209
  await Effect.runPromise(Effect.gen(function* () {
209
210
  const fiber = yield* Effect.forkChild((yield* EvalService).qualifyDimensionMatrix(qualification(root)));
210
211
  yield* Effect.yieldNow;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-eval-service",
3
3
  "private": false,
4
- "version": "1.3.2",
4
+ "version": "1.4.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -32,10 +32,10 @@
32
32
  "dependencies": {
33
33
  "@effect/platform-node": "4.0.0-rc.108",
34
34
  "effect": "4.0.0-rc.108",
35
- "@velum-labs/routekit-eval-contracts": "1.3.2",
36
- "@velum-labs/routekit-eval-engine": "1.3.2",
37
- "@velum-labs/routekit-eval-store": "1.3.2",
38
- "@velum-labs/routekit-registry": "1.3.2"
35
+ "@velum-labs/routekit-eval-contracts": "1.4.0",
36
+ "@velum-labs/routekit-eval-engine": "1.4.0",
37
+ "@velum-labs/routekit-eval-store": "1.4.0",
38
+ "@velum-labs/routekit-registry": "1.4.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "7.0.2"