@wopr-network/platform-core 1.5.0 → 1.6.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.
@@ -28,13 +28,16 @@ export interface RateEntry {
28
28
  effectivePrice: number;
29
29
  }
30
30
  /**
31
- * The rate table.
31
+ * The rate table — admin-dashboard reference for pricing comparisons.
32
32
  *
33
33
  * Each capability has both standard and premium entries. Standard is always
34
34
  * cheaper than premium for the same capability (that's the whole point).
35
35
  *
36
- * Entries are added as self-hosted adapters are implemented. Currently only
37
- * TTS (Chatterbox vs ElevenLabs) is in the table.
36
+ * NOTE: Margin values here are reference defaults for dashboard display.
37
+ * Runtime margins are authoritative via `getMargin()` / `MARGIN_CONFIG_JSON`.
38
+ *
39
+ * NOTE: Text-generation rates are blended (approximate 50/50 input/output).
40
+ * Real costs vary by workload — output-heavy chat costs more than shown here.
38
41
  */
39
42
  export declare const RATE_TABLE: RateEntry[];
40
43
  /**
@@ -11,13 +11,16 @@
11
11
  * Premium tier = third-party brand-name APIs, higher cost
12
12
  */
13
13
  /**
14
- * The rate table.
14
+ * The rate table — admin-dashboard reference for pricing comparisons.
15
15
  *
16
16
  * Each capability has both standard and premium entries. Standard is always
17
17
  * cheaper than premium for the same capability (that's the whole point).
18
18
  *
19
- * Entries are added as self-hosted adapters are implemented. Currently only
20
- * TTS (Chatterbox vs ElevenLabs) is in the table.
19
+ * NOTE: Margin values here are reference defaults for dashboard display.
20
+ * Runtime margins are authoritative via `getMargin()` / `MARGIN_CONFIG_JSON`.
21
+ *
22
+ * NOTE: Text-generation rates are blended (approximate 50/50 input/output).
23
+ * Real costs vary by workload — output-heavy chat costs more than shown here.
21
24
  */
22
25
  export const RATE_TABLE = [
23
26
  // TTS - Text-to-Speech
@@ -27,8 +30,8 @@ export const RATE_TABLE = [
27
30
  provider: "chatterbox-tts",
28
31
  costPerUnit: 0.000002, // Amortized GPU cost
29
32
  billingUnit: "per-character",
30
- margin: 1.2, // 20% margin
31
- effectivePrice: 0.0000024, // $2.40 per 1M chars
33
+ margin: 1.2, // 20% — dashboard default; runtime uses getMargin()
34
+ effectivePrice: 0.0000024, // = costPerUnit * margin ($2.40 per 1M chars)
32
35
  },
33
36
  {
34
37
  capability: "tts",
@@ -36,27 +39,27 @@ export const RATE_TABLE = [
36
39
  provider: "elevenlabs",
37
40
  costPerUnit: 0.000015, // Third-party wholesale
38
41
  billingUnit: "per-character",
39
- margin: 1.5, // 50% margin
40
- effectivePrice: 0.0000225, // $22.50 per 1M chars
42
+ margin: 1.5, // 50% — dashboard default; runtime uses getMargin()
43
+ effectivePrice: 0.0000225, // = costPerUnit * margin ($22.50 per 1M chars)
41
44
  },
42
45
  // Text Generation
43
46
  {
44
47
  capability: "text-generation",
45
48
  tier: "standard",
46
49
  provider: "self-hosted-llm",
47
- costPerUnit: 0.00000005, // Amortized GPU cost per token (H100)
50
+ costPerUnit: 0.00000005, // Amortized GPU cost per token (H100), blended in/out
48
51
  billingUnit: "per-token",
49
- margin: 1.2, // 20% margin
50
- effectivePrice: 0.00000006, // $0.06 per 1M tokens
52
+ margin: 1.2, // 20% — dashboard default; runtime uses getMargin()
53
+ effectivePrice: 0.00000006, // = costPerUnit * margin ($0.06 per 1M tokens)
51
54
  },
52
55
  {
53
56
  capability: "text-generation",
54
57
  tier: "premium",
55
58
  provider: "openrouter",
56
- costPerUnit: 0.000001, // Fallback per-token rate
59
+ costPerUnit: 0.000001, // Blended per-token rate (variable across models)
57
60
  billingUnit: "per-token",
58
- margin: 1.3, // 30% margin
59
- effectivePrice: 0.0000013, // $1.30 per 1M tokens
61
+ margin: 1.3, // 30% — dashboard default; runtime uses getMargin()
62
+ effectivePrice: 0.0000013, // = costPerUnit * margin ($1.30 per 1M tokens)
60
63
  },
61
64
  // Future self-hosted adapters will add more entries here:
62
65
  // - transcription: self-hosted-whisper (standard) vs deepgram (premium)
@@ -13,10 +13,18 @@ describe("RATE_TABLE", () => {
13
13
  expect(standard).toEqual(expect.objectContaining({ capability: "text-generation", tier: "standard", provider: "self-hosted-llm" }));
14
14
  expect(premium).toEqual(expect.objectContaining({ capability: "text-generation", tier: "premium", provider: "openrouter" }));
15
15
  });
16
- it("standard tier is cheaper than premium tier for same capability", () => {
17
- const standardTTS = RATE_TABLE.find((e) => e.capability === "tts" && e.tier === "standard");
18
- const premiumTTS = RATE_TABLE.find((e) => e.capability === "tts" && e.tier === "premium");
19
- expect(standardTTS?.effectivePrice).toBeLessThan(premiumTTS?.effectivePrice ?? Infinity);
16
+ it("standard tier is cheaper than premium tier for every capability", () => {
17
+ const capabilities = new Set(RATE_TABLE.map((e) => e.capability));
18
+ let compared = 0;
19
+ for (const capability of capabilities) {
20
+ const standard = RATE_TABLE.find((e) => e.capability === capability && e.tier === "standard");
21
+ const premium = RATE_TABLE.find((e) => e.capability === capability && e.tier === "premium");
22
+ if (standard && premium) {
23
+ expect(standard.effectivePrice).toBeLessThan(premium.effectivePrice);
24
+ compared++;
25
+ }
26
+ }
27
+ expect(compared).toBeGreaterThan(0);
20
28
  });
21
29
  it("effective price equals cost * margin", () => {
22
30
  for (const entry of RATE_TABLE) {
@@ -4,9 +4,14 @@
4
4
  * The socket layer is the glue: it receives a capability request with a tenant ID,
5
5
  * selects the right adapter, calls it, emits a MeterEvent, and returns the result.
6
6
  * Adapters never touch metering or billing — that's the socket's job.
7
+ *
8
+ * When an ArbitrageRouter is configured, the socket delegates provider selection
9
+ * to the router (GPU-first, cost-sorted, 5xx failover) while keeping ownership
10
+ * of budget checks, metering, BYOK, and margin calculation.
7
11
  */
8
12
  import type { MeterEmitter } from "@wopr-network/platform-core/metering";
9
13
  import type { AdapterCapability, ProviderAdapter } from "../adapters/types.js";
14
+ import type { ArbitrageRouter } from "../arbitrage/router.js";
10
15
  import type { BudgetChecker, SpendLimits } from "../budget/budget-checker.js";
11
16
  export interface SocketConfig {
12
17
  /** MeterEmitter instance for usage tracking */
@@ -15,6 +20,8 @@ export interface SocketConfig {
15
20
  budgetChecker?: BudgetChecker;
16
21
  /** Default margin multiplier (default: 1.3) */
17
22
  defaultMargin?: number;
23
+ /** ArbitrageRouter for cost-optimized routing (GPU-first, cheapest, 5xx failover) */
24
+ router?: ArbitrageRouter;
18
25
  }
19
26
  export interface SocketRequest {
20
27
  /** Who is making the request */
@@ -23,8 +30,10 @@ export interface SocketRequest {
23
30
  capability: AdapterCapability;
24
31
  /** The request payload (matches the capability's input type) */
25
32
  input: unknown;
26
- /** Optional: force a specific adapter by name */
33
+ /** Optional: force a specific adapter by name (highest priority, bypasses router) */
27
34
  adapter?: string;
35
+ /** Optional: model specifier for model-level routing (e.g., "gemini-2.5-pro") */
36
+ model?: string;
28
37
  /** Optional: override margin for this request */
29
38
  margin?: number;
30
39
  /** Optional: session ID for grouping events */
@@ -43,6 +52,7 @@ export declare class AdapterSocket {
43
52
  private readonly meter;
44
53
  private readonly budgetChecker?;
45
54
  private readonly defaultMargin;
55
+ private readonly router?;
46
56
  constructor(config: SocketConfig);
47
57
  /** Register an adapter. Overwrites any existing adapter with the same name. */
48
58
  register(adapter: ProviderAdapter): void;
@@ -50,6 +60,8 @@ export declare class AdapterSocket {
50
60
  execute<T>(request: SocketRequest): Promise<T>;
51
61
  /** List all capabilities across all registered adapters (deduplicated). */
52
62
  capabilities(): AdapterCapability[];
53
- /** Resolve which adapter to use for a request. */
63
+ /** Call a specific adapter by name. Used by explicit override and legacy routing. */
64
+ private executeWithAdapter;
65
+ /** Resolve which adapter to use for a request (legacy routing — pricingTier or first-match). */
54
66
  private resolveAdapter;
55
67
  }
@@ -4,6 +4,10 @@
4
4
  * The socket layer is the glue: it receives a capability request with a tenant ID,
5
5
  * selects the right adapter, calls it, emits a MeterEvent, and returns the result.
6
6
  * Adapters never touch metering or billing — that's the socket's job.
7
+ *
8
+ * When an ArbitrageRouter is configured, the socket delegates provider selection
9
+ * to the router (GPU-first, cost-sorted, 5xx failover) while keeping ownership
10
+ * of budget checks, metering, BYOK, and margin calculation.
7
11
  */
8
12
  import { Credit } from "@wopr-network/platform-core/credits";
9
13
  import { withMargin } from "../adapters/types.js";
@@ -20,14 +24,20 @@ export class AdapterSocket {
20
24
  meter;
21
25
  budgetChecker;
22
26
  defaultMargin;
27
+ router;
23
28
  constructor(config) {
24
29
  this.meter = config.meter;
25
30
  this.budgetChecker = config.budgetChecker;
26
31
  this.defaultMargin = config.defaultMargin ?? 1.3;
32
+ this.router = config.router;
27
33
  }
28
34
  /** Register an adapter. Overwrites any existing adapter with the same name. */
29
35
  register(adapter) {
30
36
  this.adapters.set(adapter.name, adapter);
37
+ // Also register with router so it can call the adapter during failover
38
+ if (this.router) {
39
+ this.router.registerAdapter(adapter);
40
+ }
31
41
  }
32
42
  /** Execute a capability request against the best adapter. */
33
43
  async execute(request) {
@@ -43,14 +53,45 @@ export class AdapterSocket {
43
53
  throw error;
44
54
  }
45
55
  }
46
- const adapter = this.resolveAdapter(request);
47
- const method = CAPABILITY_METHOD[request.capability];
48
- const fn = adapter[method];
49
- if (!fn) {
50
- throw new Error(`Adapter "${adapter.name}" is registered for "${request.capability}" but does not implement "${String(method)}"`);
56
+ // Determine execution path: explicit adapter > arbitrage router > legacy routing
57
+ let adapterResult;
58
+ let providerName;
59
+ let providerSelfHosted;
60
+ if (request.adapter) {
61
+ // Explicit adapter override — highest priority, bypasses router
62
+ const result = await this.executeWithAdapter(request.adapter, request.capability, request.input);
63
+ adapterResult = result.adapterResult;
64
+ providerName = result.providerName;
65
+ providerSelfHosted = result.selfHosted;
66
+ }
67
+ else if (this.router) {
68
+ // Arbitrage router — cost-optimized routing with GPU-first and 5xx failover.
69
+ // Margin tracking is handled by the meter event below, not the router's callback.
70
+ // The router's onMarginRecord callback is intentionally unused here — it fires
71
+ // only when sellPrice is passed to route(), which the socket never does.
72
+ const routerResult = await this.router.route({
73
+ capability: request.capability,
74
+ tenantId: request.tenantId,
75
+ input: request.input,
76
+ model: request.model,
77
+ });
78
+ adapterResult = routerResult;
79
+ providerName = routerResult.provider;
80
+ const routedAdapter = this.adapters.get(providerName);
81
+ if (!routedAdapter) {
82
+ throw new Error(`Router selected provider "${providerName}" but it is not registered in the socket. ` +
83
+ `Always register adapters via socket.register() — never directly on the router.`);
84
+ }
85
+ providerSelfHosted = routedAdapter.selfHosted === true;
86
+ }
87
+ else {
88
+ // Legacy routing — first-match or tier-based
89
+ const adapter = this.resolveAdapter(request);
90
+ const result = await this.executeWithAdapter(adapter.name, request.capability, request.input);
91
+ adapterResult = result.adapterResult;
92
+ providerName = result.providerName;
93
+ providerSelfHosted = result.selfHosted;
51
94
  }
52
- // Call the adapter — if it throws, no meter event is emitted.
53
- const adapterResult = await fn.call(adapter, request.input);
54
95
  // Compute charge if the adapter didn't supply one
55
96
  const margin = request.margin ?? this.defaultMargin;
56
97
  const charge = adapterResult.charge ?? withMargin(adapterResult.cost, margin);
@@ -61,10 +102,10 @@ export class AdapterSocket {
61
102
  cost: isByok ? Credit.ZERO : adapterResult.cost,
62
103
  charge: isByok ? Credit.ZERO : charge,
63
104
  capability: request.capability,
64
- provider: adapter.name,
105
+ provider: providerName,
65
106
  timestamp: Date.now(),
66
107
  ...(request.sessionId ? { sessionId: request.sessionId } : {}),
67
- tier: isByok ? "byok" : adapter.selfHosted ? "wopr" : "branded",
108
+ tier: isByok ? "byok" : providerSelfHosted ? "wopr" : "branded",
68
109
  });
69
110
  return adapterResult.result;
70
111
  }
@@ -78,19 +119,25 @@ export class AdapterSocket {
78
119
  }
79
120
  return [...seen];
80
121
  }
81
- /** Resolve which adapter to use for a request. */
82
- resolveAdapter(request) {
83
- // If a specific adapter is requested, use it (highest priority)
84
- if (request.adapter) {
85
- const adapter = this.adapters.get(request.adapter);
86
- if (!adapter) {
87
- throw new Error(`Adapter "${request.adapter}" is not registered`);
88
- }
89
- if (!adapter.capabilities.includes(request.capability)) {
90
- throw new Error(`Adapter "${request.adapter}" does not support capability "${request.capability}"`);
91
- }
92
- return adapter;
122
+ /** Call a specific adapter by name. Used by explicit override and legacy routing. */
123
+ async executeWithAdapter(adapterName, capability, input) {
124
+ const adapter = this.adapters.get(adapterName);
125
+ if (!adapter) {
126
+ throw new Error(`Adapter "${adapterName}" is not registered`);
93
127
  }
128
+ if (!adapter.capabilities.includes(capability)) {
129
+ throw new Error(`Adapter "${adapterName}" does not support capability "${capability}"`);
130
+ }
131
+ const method = CAPABILITY_METHOD[capability];
132
+ const fn = adapter[method];
133
+ if (!fn) {
134
+ throw new Error(`Adapter "${adapter.name}" is registered for "${capability}" but does not implement "${String(method)}"`);
135
+ }
136
+ const adapterResult = await fn.call(adapter, input);
137
+ return { adapterResult, providerName: adapter.name, selfHosted: adapter.selfHosted === true };
138
+ }
139
+ /** Resolve which adapter to use for a request (legacy routing — pricingTier or first-match). */
140
+ resolveAdapter(request) {
94
141
  // If a pricing tier is specified, prefer adapters matching that tier
95
142
  if (request.pricingTier) {
96
143
  const preferSelfHosted = request.pricingTier === "standard";
@@ -756,4 +756,182 @@ describe("AdapterSocket", () => {
756
756
  }
757
757
  });
758
758
  });
759
+ // ---------------------------------------------------------------------------
760
+ // execute -- ArbitrageRouter integration
761
+ // ---------------------------------------------------------------------------
762
+ describe("execute -- ArbitrageRouter integration", () => {
763
+ function stubRouter(overrides = {}) {
764
+ return {
765
+ route: vi.fn().mockResolvedValue({
766
+ result: { text: "routed-response", model: "deepseek-chat", usage: { inputTokens: 50, outputTokens: 50 } },
767
+ cost: Credit.fromDollars(0.001),
768
+ provider: "deepseek",
769
+ }),
770
+ selectProvider: vi.fn(),
771
+ registerAdapter: vi.fn(),
772
+ ...overrides,
773
+ };
774
+ }
775
+ it("delegates to router when configured and no explicit adapter", async () => {
776
+ const meter = stubMeter();
777
+ const router = stubRouter();
778
+ const socket = new AdapterSocket({ meter, router });
779
+ // Register adapter so selfHosted lookup works
780
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
781
+ const result = await socket.execute({
782
+ tenantId: "t-1",
783
+ capability: "text-generation",
784
+ input: { prompt: "hello" },
785
+ });
786
+ expect(result.text).toBe("routed-response");
787
+ expect(router.route).toHaveBeenCalledOnce();
788
+ expect(meter.events).toHaveLength(1);
789
+ expect(meter.events[0].provider).toBe("deepseek");
790
+ });
791
+ it("passes model to router for model-level routing", async () => {
792
+ const meter = stubMeter();
793
+ const router = stubRouter();
794
+ const socket = new AdapterSocket({ meter, router });
795
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
796
+ await socket.execute({
797
+ tenantId: "t-1",
798
+ capability: "text-generation",
799
+ input: { prompt: "hello" },
800
+ model: "gemini-2.5-pro",
801
+ });
802
+ expect(router.route).toHaveBeenCalledWith(expect.objectContaining({ model: "gemini-2.5-pro" }));
803
+ });
804
+ it("explicit adapter override bypasses router", async () => {
805
+ const meter = stubMeter();
806
+ const router = stubRouter();
807
+ const socket = new AdapterSocket({ meter, router });
808
+ socket.register(stubAdapter({
809
+ name: "explicit-provider",
810
+ capabilities: ["transcription"],
811
+ async transcribe() {
812
+ return {
813
+ result: { text: "explicit-result", detectedLanguage: "en", durationSeconds: 5 },
814
+ cost: Credit.fromDollars(0.05),
815
+ };
816
+ },
817
+ }));
818
+ const result = await socket.execute({
819
+ tenantId: "t-1",
820
+ capability: "transcription",
821
+ input: { audioUrl: "https://example.com/audio.mp3" },
822
+ adapter: "explicit-provider",
823
+ });
824
+ expect(result.text).toBe("explicit-result");
825
+ expect(router.route).not.toHaveBeenCalled();
826
+ expect(meter.events[0].provider).toBe("explicit-provider");
827
+ });
828
+ it("emits BYOK zero cost/charge even with router", async () => {
829
+ const meter = stubMeter();
830
+ const router = stubRouter();
831
+ const socket = new AdapterSocket({ meter, router });
832
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
833
+ await socket.execute({
834
+ tenantId: "t-1",
835
+ capability: "text-generation",
836
+ input: { prompt: "hello" },
837
+ byok: true,
838
+ });
839
+ expect(meter.events).toHaveLength(1);
840
+ expect(meter.events[0].cost.isZero()).toBe(true);
841
+ expect(meter.events[0].charge.isZero()).toBe(true);
842
+ expect(meter.events[0].tier).toBe("byok");
843
+ });
844
+ it("applies margin to router result cost", async () => {
845
+ const meter = stubMeter();
846
+ const router = stubRouter();
847
+ const socket = new AdapterSocket({ meter, router, defaultMargin: 1.5 });
848
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
849
+ await socket.execute({
850
+ tenantId: "t-1",
851
+ capability: "text-generation",
852
+ input: { prompt: "hello" },
853
+ });
854
+ // Router returns cost $0.001, margin 1.5 → charge $0.0015
855
+ expect(meter.events[0].cost.toDollars()).toBe(0.001);
856
+ expect(meter.events[0].charge.toDollars()).toBe(0.0015);
857
+ });
858
+ it("sets tier to wopr when router picks self-hosted adapter", async () => {
859
+ const meter = stubMeter();
860
+ const router = stubRouter({
861
+ route: vi.fn().mockResolvedValue({
862
+ result: { text: "gpu-response", model: "llama-3", usage: { inputTokens: 25, outputTokens: 25 } },
863
+ cost: Credit.fromDollars(0.0001),
864
+ provider: "self-hosted-llm",
865
+ }),
866
+ registerAdapter: vi.fn(),
867
+ });
868
+ const socket = new AdapterSocket({ meter, router });
869
+ socket.register(stubAdapter({ name: "self-hosted-llm", capabilities: ["text-generation"], selfHosted: true }));
870
+ await socket.execute({
871
+ tenantId: "t-1",
872
+ capability: "text-generation",
873
+ input: { prompt: "hello" },
874
+ });
875
+ expect(meter.events[0].tier).toBe("wopr");
876
+ });
877
+ it("sets tier to branded when router picks third-party adapter", async () => {
878
+ const meter = stubMeter();
879
+ const router = stubRouter();
880
+ const socket = new AdapterSocket({ meter, router });
881
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"], selfHosted: false }));
882
+ await socket.execute({
883
+ tenantId: "t-1",
884
+ capability: "text-generation",
885
+ input: { prompt: "hello" },
886
+ });
887
+ expect(meter.events[0].tier).toBe("branded");
888
+ });
889
+ it("register() forwards adapter to router.registerAdapter()", () => {
890
+ const router = stubRouter();
891
+ const socket = new AdapterSocket({ meter: stubMeter(), router });
892
+ const adapter = stubAdapter({ name: "test-adapter" });
893
+ socket.register(adapter);
894
+ expect(router.registerAdapter).toHaveBeenCalledWith(adapter);
895
+ });
896
+ it("includes sessionId in meter event when routing via router", async () => {
897
+ const meter = stubMeter();
898
+ const router = stubRouter();
899
+ const socket = new AdapterSocket({ meter, router });
900
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
901
+ await socket.execute({
902
+ tenantId: "t-1",
903
+ capability: "text-generation",
904
+ input: { prompt: "hello" },
905
+ sessionId: "sess-99",
906
+ });
907
+ expect(meter.events[0].sessionId).toBe("sess-99");
908
+ });
909
+ it("per-request margin override works with router", async () => {
910
+ const meter = stubMeter();
911
+ const router = stubRouter();
912
+ const socket = new AdapterSocket({ meter, router, defaultMargin: 1.3 });
913
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
914
+ await socket.execute({
915
+ tenantId: "t-1",
916
+ capability: "text-generation",
917
+ input: { prompt: "hello" },
918
+ margin: 2.0,
919
+ });
920
+ // Router returns cost $0.001, margin override 2.0 → charge $0.002
921
+ expect(meter.events[0].charge.toDollars()).toBe(0.002);
922
+ });
923
+ it("falls back to legacy routing when no router configured", async () => {
924
+ const meter = stubMeter();
925
+ // No router — should use legacy resolveAdapter
926
+ const socket = new AdapterSocket({ meter });
927
+ socket.register(stubAdapter());
928
+ const result = await socket.execute({
929
+ tenantId: "t-1",
930
+ capability: "transcription",
931
+ input: { audioUrl: "https://example.com/audio.mp3" },
932
+ });
933
+ expect(result.text).toBe("hello");
934
+ expect(meter.events).toHaveLength(1);
935
+ });
936
+ });
759
937
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -36,7 +36,8 @@
36
36
  "./security": "./dist/security/index.js",
37
37
  "./setup": "./dist/setup/index.js",
38
38
  "./tenancy": "./dist/tenancy/index.js",
39
- "./trpc": "./dist/trpc/index.js"
39
+ "./trpc": "./dist/trpc/index.js",
40
+ "./*": "./dist/*.js"
40
41
  },
41
42
  "scripts": {
42
43
  "build": "tsc",
@@ -23,11 +23,21 @@ describe("RATE_TABLE", () => {
23
23
  );
24
24
  });
25
25
 
26
- it("standard tier is cheaper than premium tier for same capability", () => {
27
- const standardTTS = RATE_TABLE.find((e) => e.capability === "tts" && e.tier === "standard");
28
- const premiumTTS = RATE_TABLE.find((e) => e.capability === "tts" && e.tier === "premium");
26
+ it("standard tier is cheaper than premium tier for every capability", () => {
27
+ const capabilities = new Set(RATE_TABLE.map((e) => e.capability));
28
+ let compared = 0;
29
+
30
+ for (const capability of capabilities) {
31
+ const standard = RATE_TABLE.find((e) => e.capability === capability && e.tier === "standard");
32
+ const premium = RATE_TABLE.find((e) => e.capability === capability && e.tier === "premium");
33
+
34
+ if (standard && premium) {
35
+ expect(standard.effectivePrice).toBeLessThan(premium.effectivePrice);
36
+ compared++;
37
+ }
38
+ }
29
39
 
30
- expect(standardTTS?.effectivePrice).toBeLessThan(premiumTTS?.effectivePrice ?? Infinity);
40
+ expect(compared).toBeGreaterThan(0);
31
41
  });
32
42
 
33
43
  it("effective price equals cost * margin", () => {
@@ -31,13 +31,16 @@ export interface RateEntry {
31
31
  }
32
32
 
33
33
  /**
34
- * The rate table.
34
+ * The rate table — admin-dashboard reference for pricing comparisons.
35
35
  *
36
36
  * Each capability has both standard and premium entries. Standard is always
37
37
  * cheaper than premium for the same capability (that's the whole point).
38
38
  *
39
- * Entries are added as self-hosted adapters are implemented. Currently only
40
- * TTS (Chatterbox vs ElevenLabs) is in the table.
39
+ * NOTE: Margin values here are reference defaults for dashboard display.
40
+ * Runtime margins are authoritative via `getMargin()` / `MARGIN_CONFIG_JSON`.
41
+ *
42
+ * NOTE: Text-generation rates are blended (approximate 50/50 input/output).
43
+ * Real costs vary by workload — output-heavy chat costs more than shown here.
41
44
  */
42
45
  export const RATE_TABLE: RateEntry[] = [
43
46
  // TTS - Text-to-Speech
@@ -47,8 +50,8 @@ export const RATE_TABLE: RateEntry[] = [
47
50
  provider: "chatterbox-tts",
48
51
  costPerUnit: 0.000002, // Amortized GPU cost
49
52
  billingUnit: "per-character",
50
- margin: 1.2, // 20% margin
51
- effectivePrice: 0.0000024, // $2.40 per 1M chars
53
+ margin: 1.2, // 20% — dashboard default; runtime uses getMargin()
54
+ effectivePrice: 0.0000024, // = costPerUnit * margin ($2.40 per 1M chars)
52
55
  },
53
56
  {
54
57
  capability: "tts",
@@ -56,8 +59,8 @@ export const RATE_TABLE: RateEntry[] = [
56
59
  provider: "elevenlabs",
57
60
  costPerUnit: 0.000015, // Third-party wholesale
58
61
  billingUnit: "per-character",
59
- margin: 1.5, // 50% margin
60
- effectivePrice: 0.0000225, // $22.50 per 1M chars
62
+ margin: 1.5, // 50% — dashboard default; runtime uses getMargin()
63
+ effectivePrice: 0.0000225, // = costPerUnit * margin ($22.50 per 1M chars)
61
64
  },
62
65
 
63
66
  // Text Generation
@@ -65,19 +68,19 @@ export const RATE_TABLE: RateEntry[] = [
65
68
  capability: "text-generation",
66
69
  tier: "standard",
67
70
  provider: "self-hosted-llm",
68
- costPerUnit: 0.00000005, // Amortized GPU cost per token (H100)
71
+ costPerUnit: 0.00000005, // Amortized GPU cost per token (H100), blended in/out
69
72
  billingUnit: "per-token",
70
- margin: 1.2, // 20% margin
71
- effectivePrice: 0.00000006, // $0.06 per 1M tokens
73
+ margin: 1.2, // 20% — dashboard default; runtime uses getMargin()
74
+ effectivePrice: 0.00000006, // = costPerUnit * margin ($0.06 per 1M tokens)
72
75
  },
73
76
  {
74
77
  capability: "text-generation",
75
78
  tier: "premium",
76
79
  provider: "openrouter",
77
- costPerUnit: 0.000001, // Fallback per-token rate
80
+ costPerUnit: 0.000001, // Blended per-token rate (variable across models)
78
81
  billingUnit: "per-token",
79
- margin: 1.3, // 30% margin
80
- effectivePrice: 0.0000013, // $1.30 per 1M tokens
82
+ margin: 1.3, // 30% — dashboard default; runtime uses getMargin()
83
+ effectivePrice: 0.0000013, // = costPerUnit * margin ($1.30 per 1M tokens)
81
84
  },
82
85
 
83
86
  // Future self-hosted adapters will add more entries here:
@@ -10,9 +10,11 @@ import type {
10
10
  AdapterResult,
11
11
  EmbeddingsOutput,
12
12
  ProviderAdapter,
13
+ TextGenerationOutput,
13
14
  TranscriptionOutput,
14
15
  TTSOutput,
15
16
  } from "../adapters/types.js";
17
+ import type { ArbitrageRouter } from "../arbitrage/router.js";
16
18
  import { BudgetChecker, type SpendLimits } from "../budget/budget-checker.js";
17
19
  import { AdapterSocket, type SocketConfig } from "./socket.js";
18
20
 
@@ -934,4 +936,222 @@ describe("AdapterSocket", () => {
934
936
  }
935
937
  });
936
938
  });
939
+
940
+ // ---------------------------------------------------------------------------
941
+ // execute -- ArbitrageRouter integration
942
+ // ---------------------------------------------------------------------------
943
+
944
+ describe("execute -- ArbitrageRouter integration", () => {
945
+ function stubRouter(overrides: Partial<ArbitrageRouter> = {}): ArbitrageRouter {
946
+ return {
947
+ route: vi.fn().mockResolvedValue({
948
+ result: { text: "routed-response", model: "deepseek-chat", usage: { inputTokens: 50, outputTokens: 50 } },
949
+ cost: Credit.fromDollars(0.001),
950
+ provider: "deepseek",
951
+ }),
952
+ selectProvider: vi.fn(),
953
+ registerAdapter: vi.fn(),
954
+ ...overrides,
955
+ } as unknown as ArbitrageRouter;
956
+ }
957
+
958
+ it("delegates to router when configured and no explicit adapter", async () => {
959
+ const meter = stubMeter();
960
+ const router = stubRouter();
961
+ const socket = new AdapterSocket({ meter, router });
962
+
963
+ // Register adapter so selfHosted lookup works
964
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
965
+
966
+ const result = await socket.execute<TextGenerationOutput>({
967
+ tenantId: "t-1",
968
+ capability: "text-generation",
969
+ input: { prompt: "hello" },
970
+ });
971
+
972
+ expect(result.text).toBe("routed-response");
973
+ expect(router.route).toHaveBeenCalledOnce();
974
+ expect(meter.events).toHaveLength(1);
975
+ expect(meter.events[0].provider).toBe("deepseek");
976
+ });
977
+
978
+ it("passes model to router for model-level routing", async () => {
979
+ const meter = stubMeter();
980
+ const router = stubRouter();
981
+ const socket = new AdapterSocket({ meter, router });
982
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
983
+
984
+ await socket.execute<TextGenerationOutput>({
985
+ tenantId: "t-1",
986
+ capability: "text-generation",
987
+ input: { prompt: "hello" },
988
+ model: "gemini-2.5-pro",
989
+ });
990
+
991
+ expect(router.route).toHaveBeenCalledWith(expect.objectContaining({ model: "gemini-2.5-pro" }));
992
+ });
993
+
994
+ it("explicit adapter override bypasses router", async () => {
995
+ const meter = stubMeter();
996
+ const router = stubRouter();
997
+ const socket = new AdapterSocket({ meter, router });
998
+
999
+ socket.register(
1000
+ stubAdapter({
1001
+ name: "explicit-provider",
1002
+ capabilities: ["transcription"],
1003
+ async transcribe() {
1004
+ return {
1005
+ result: { text: "explicit-result", detectedLanguage: "en", durationSeconds: 5 },
1006
+ cost: Credit.fromDollars(0.05),
1007
+ };
1008
+ },
1009
+ }),
1010
+ );
1011
+
1012
+ const result = await socket.execute<TranscriptionOutput>({
1013
+ tenantId: "t-1",
1014
+ capability: "transcription",
1015
+ input: { audioUrl: "https://example.com/audio.mp3" },
1016
+ adapter: "explicit-provider",
1017
+ });
1018
+
1019
+ expect(result.text).toBe("explicit-result");
1020
+ expect(router.route).not.toHaveBeenCalled();
1021
+ expect(meter.events[0].provider).toBe("explicit-provider");
1022
+ });
1023
+
1024
+ it("emits BYOK zero cost/charge even with router", async () => {
1025
+ const meter = stubMeter();
1026
+ const router = stubRouter();
1027
+ const socket = new AdapterSocket({ meter, router });
1028
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
1029
+
1030
+ await socket.execute<TextGenerationOutput>({
1031
+ tenantId: "t-1",
1032
+ capability: "text-generation",
1033
+ input: { prompt: "hello" },
1034
+ byok: true,
1035
+ });
1036
+
1037
+ expect(meter.events).toHaveLength(1);
1038
+ expect(meter.events[0].cost.isZero()).toBe(true);
1039
+ expect(meter.events[0].charge.isZero()).toBe(true);
1040
+ expect(meter.events[0].tier).toBe("byok");
1041
+ });
1042
+
1043
+ it("applies margin to router result cost", async () => {
1044
+ const meter = stubMeter();
1045
+ const router = stubRouter();
1046
+ const socket = new AdapterSocket({ meter, router, defaultMargin: 1.5 });
1047
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
1048
+
1049
+ await socket.execute<TextGenerationOutput>({
1050
+ tenantId: "t-1",
1051
+ capability: "text-generation",
1052
+ input: { prompt: "hello" },
1053
+ });
1054
+
1055
+ // Router returns cost $0.001, margin 1.5 → charge $0.0015
1056
+ expect(meter.events[0].cost.toDollars()).toBe(0.001);
1057
+ expect(meter.events[0].charge.toDollars()).toBe(0.0015);
1058
+ });
1059
+
1060
+ it("sets tier to wopr when router picks self-hosted adapter", async () => {
1061
+ const meter = stubMeter();
1062
+ const router = stubRouter({
1063
+ route: vi.fn().mockResolvedValue({
1064
+ result: { text: "gpu-response", model: "llama-3", usage: { inputTokens: 25, outputTokens: 25 } },
1065
+ cost: Credit.fromDollars(0.0001),
1066
+ provider: "self-hosted-llm",
1067
+ }),
1068
+ registerAdapter: vi.fn(),
1069
+ } as unknown as Partial<ArbitrageRouter>);
1070
+
1071
+ const socket = new AdapterSocket({ meter, router });
1072
+ socket.register(stubAdapter({ name: "self-hosted-llm", capabilities: ["text-generation"], selfHosted: true }));
1073
+
1074
+ await socket.execute<TextGenerationOutput>({
1075
+ tenantId: "t-1",
1076
+ capability: "text-generation",
1077
+ input: { prompt: "hello" },
1078
+ });
1079
+
1080
+ expect(meter.events[0].tier).toBe("wopr");
1081
+ });
1082
+
1083
+ it("sets tier to branded when router picks third-party adapter", async () => {
1084
+ const meter = stubMeter();
1085
+ const router = stubRouter();
1086
+ const socket = new AdapterSocket({ meter, router });
1087
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"], selfHosted: false }));
1088
+
1089
+ await socket.execute<TextGenerationOutput>({
1090
+ tenantId: "t-1",
1091
+ capability: "text-generation",
1092
+ input: { prompt: "hello" },
1093
+ });
1094
+
1095
+ expect(meter.events[0].tier).toBe("branded");
1096
+ });
1097
+
1098
+ it("register() forwards adapter to router.registerAdapter()", () => {
1099
+ const router = stubRouter();
1100
+ const socket = new AdapterSocket({ meter: stubMeter(), router });
1101
+
1102
+ const adapter = stubAdapter({ name: "test-adapter" });
1103
+ socket.register(adapter);
1104
+
1105
+ expect(router.registerAdapter).toHaveBeenCalledWith(adapter);
1106
+ });
1107
+
1108
+ it("includes sessionId in meter event when routing via router", async () => {
1109
+ const meter = stubMeter();
1110
+ const router = stubRouter();
1111
+ const socket = new AdapterSocket({ meter, router });
1112
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
1113
+
1114
+ await socket.execute<TextGenerationOutput>({
1115
+ tenantId: "t-1",
1116
+ capability: "text-generation",
1117
+ input: { prompt: "hello" },
1118
+ sessionId: "sess-99",
1119
+ });
1120
+
1121
+ expect(meter.events[0].sessionId).toBe("sess-99");
1122
+ });
1123
+
1124
+ it("per-request margin override works with router", async () => {
1125
+ const meter = stubMeter();
1126
+ const router = stubRouter();
1127
+ const socket = new AdapterSocket({ meter, router, defaultMargin: 1.3 });
1128
+ socket.register(stubAdapter({ name: "deepseek", capabilities: ["text-generation"] }));
1129
+
1130
+ await socket.execute<TextGenerationOutput>({
1131
+ tenantId: "t-1",
1132
+ capability: "text-generation",
1133
+ input: { prompt: "hello" },
1134
+ margin: 2.0,
1135
+ });
1136
+
1137
+ // Router returns cost $0.001, margin override 2.0 → charge $0.002
1138
+ expect(meter.events[0].charge.toDollars()).toBe(0.002);
1139
+ });
1140
+
1141
+ it("falls back to legacy routing when no router configured", async () => {
1142
+ const meter = stubMeter();
1143
+ // No router — should use legacy resolveAdapter
1144
+ const socket = new AdapterSocket({ meter });
1145
+ socket.register(stubAdapter());
1146
+
1147
+ const result = await socket.execute<TranscriptionOutput>({
1148
+ tenantId: "t-1",
1149
+ capability: "transcription",
1150
+ input: { audioUrl: "https://example.com/audio.mp3" },
1151
+ });
1152
+
1153
+ expect(result.text).toBe("hello");
1154
+ expect(meter.events).toHaveLength(1);
1155
+ });
1156
+ });
937
1157
  });
@@ -4,12 +4,17 @@
4
4
  * The socket layer is the glue: it receives a capability request with a tenant ID,
5
5
  * selects the right adapter, calls it, emits a MeterEvent, and returns the result.
6
6
  * Adapters never touch metering or billing — that's the socket's job.
7
+ *
8
+ * When an ArbitrageRouter is configured, the socket delegates provider selection
9
+ * to the router (GPU-first, cost-sorted, 5xx failover) while keeping ownership
10
+ * of budget checks, metering, BYOK, and margin calculation.
7
11
  */
8
12
 
9
13
  import { Credit } from "@wopr-network/platform-core/credits";
10
14
  import type { MeterEmitter } from "@wopr-network/platform-core/metering";
11
15
  import type { AdapterCapability, AdapterResult, ProviderAdapter } from "../adapters/types.js";
12
16
  import { withMargin } from "../adapters/types.js";
17
+ import type { ArbitrageRouter } from "../arbitrage/router.js";
13
18
  import type { BudgetChecker, SpendLimits } from "../budget/budget-checker.js";
14
19
 
15
20
  export interface SocketConfig {
@@ -19,6 +24,8 @@ export interface SocketConfig {
19
24
  budgetChecker?: BudgetChecker;
20
25
  /** Default margin multiplier (default: 1.3) */
21
26
  defaultMargin?: number;
27
+ /** ArbitrageRouter for cost-optimized routing (GPU-first, cheapest, 5xx failover) */
28
+ router?: ArbitrageRouter;
22
29
  }
23
30
 
24
31
  export interface SocketRequest {
@@ -28,8 +35,10 @@ export interface SocketRequest {
28
35
  capability: AdapterCapability;
29
36
  /** The request payload (matches the capability's input type) */
30
37
  input: unknown;
31
- /** Optional: force a specific adapter by name */
38
+ /** Optional: force a specific adapter by name (highest priority, bypasses router) */
32
39
  adapter?: string;
40
+ /** Optional: model specifier for model-level routing (e.g., "gemini-2.5-pro") */
41
+ model?: string;
33
42
  /** Optional: override margin for this request */
34
43
  margin?: number;
35
44
  /** Optional: session ID for grouping events */
@@ -58,16 +67,22 @@ export class AdapterSocket {
58
67
  private readonly meter: MeterEmitter;
59
68
  private readonly budgetChecker?: BudgetChecker;
60
69
  private readonly defaultMargin: number;
70
+ private readonly router?: ArbitrageRouter;
61
71
 
62
72
  constructor(config: SocketConfig) {
63
73
  this.meter = config.meter;
64
74
  this.budgetChecker = config.budgetChecker;
65
75
  this.defaultMargin = config.defaultMargin ?? 1.3;
76
+ this.router = config.router;
66
77
  }
67
78
 
68
79
  /** Register an adapter. Overwrites any existing adapter with the same name. */
69
80
  register(adapter: ProviderAdapter): void {
70
81
  this.adapters.set(adapter.name, adapter);
82
+ // Also register with router so it can call the adapter during failover
83
+ if (this.router) {
84
+ this.router.registerAdapter(adapter);
85
+ }
71
86
  }
72
87
 
73
88
  /** Execute a capability request against the best adapter. */
@@ -85,19 +100,47 @@ export class AdapterSocket {
85
100
  }
86
101
  }
87
102
 
88
- const adapter = this.resolveAdapter(request);
89
- const method = CAPABILITY_METHOD[request.capability];
90
- const fn = adapter[method] as ((input: unknown) => Promise<AdapterResult<T>>) | undefined;
103
+ // Determine execution path: explicit adapter > arbitrage router > legacy routing
104
+ let adapterResult: AdapterResult<T>;
105
+ let providerName: string;
106
+ let providerSelfHosted: boolean;
91
107
 
92
- if (!fn) {
93
- throw new Error(
94
- `Adapter "${adapter.name}" is registered for "${request.capability}" but does not implement "${String(method)}"`,
95
- );
108
+ if (request.adapter) {
109
+ // Explicit adapter override — highest priority, bypasses router
110
+ const result = await this.executeWithAdapter<T>(request.adapter, request.capability, request.input);
111
+ adapterResult = result.adapterResult;
112
+ providerName = result.providerName;
113
+ providerSelfHosted = result.selfHosted;
114
+ } else if (this.router) {
115
+ // Arbitrage router — cost-optimized routing with GPU-first and 5xx failover.
116
+ // Margin tracking is handled by the meter event below, not the router's callback.
117
+ // The router's onMarginRecord callback is intentionally unused here — it fires
118
+ // only when sellPrice is passed to route(), which the socket never does.
119
+ const routerResult = await this.router.route<T>({
120
+ capability: request.capability,
121
+ tenantId: request.tenantId,
122
+ input: request.input,
123
+ model: request.model,
124
+ });
125
+ adapterResult = routerResult;
126
+ providerName = routerResult.provider;
127
+ const routedAdapter = this.adapters.get(providerName);
128
+ if (!routedAdapter) {
129
+ throw new Error(
130
+ `Router selected provider "${providerName}" but it is not registered in the socket. ` +
131
+ `Always register adapters via socket.register() — never directly on the router.`,
132
+ );
133
+ }
134
+ providerSelfHosted = routedAdapter.selfHosted === true;
135
+ } else {
136
+ // Legacy routing — first-match or tier-based
137
+ const adapter = this.resolveAdapter(request);
138
+ const result = await this.executeWithAdapter<T>(adapter.name, request.capability, request.input);
139
+ adapterResult = result.adapterResult;
140
+ providerName = result.providerName;
141
+ providerSelfHosted = result.selfHosted;
96
142
  }
97
143
 
98
- // Call the adapter — if it throws, no meter event is emitted.
99
- const adapterResult = await fn.call(adapter, request.input);
100
-
101
144
  // Compute charge if the adapter didn't supply one
102
145
  const margin = request.margin ?? this.defaultMargin;
103
146
  const charge = adapterResult.charge ?? withMargin(adapterResult.cost, margin);
@@ -109,10 +152,10 @@ export class AdapterSocket {
109
152
  cost: isByok ? Credit.ZERO : adapterResult.cost,
110
153
  charge: isByok ? Credit.ZERO : charge,
111
154
  capability: request.capability,
112
- provider: adapter.name,
155
+ provider: providerName,
113
156
  timestamp: Date.now(),
114
157
  ...(request.sessionId ? { sessionId: request.sessionId } : {}),
115
- tier: isByok ? "byok" : adapter.selfHosted ? "wopr" : "branded",
158
+ tier: isByok ? "byok" : providerSelfHosted ? "wopr" : "branded",
116
159
  });
117
160
 
118
161
  return adapterResult.result;
@@ -129,20 +172,34 @@ export class AdapterSocket {
129
172
  return [...seen];
130
173
  }
131
174
 
132
- /** Resolve which adapter to use for a request. */
133
- private resolveAdapter(request: SocketRequest): ProviderAdapter {
134
- // If a specific adapter is requested, use it (highest priority)
135
- if (request.adapter) {
136
- const adapter = this.adapters.get(request.adapter);
137
- if (!adapter) {
138
- throw new Error(`Adapter "${request.adapter}" is not registered`);
139
- }
140
- if (!adapter.capabilities.includes(request.capability)) {
141
- throw new Error(`Adapter "${request.adapter}" does not support capability "${request.capability}"`);
142
- }
143
- return adapter;
175
+ /** Call a specific adapter by name. Used by explicit override and legacy routing. */
176
+ private async executeWithAdapter<T>(
177
+ adapterName: string,
178
+ capability: AdapterCapability,
179
+ input: unknown,
180
+ ): Promise<{ adapterResult: AdapterResult<T>; providerName: string; selfHosted: boolean }> {
181
+ const adapter = this.adapters.get(adapterName);
182
+ if (!adapter) {
183
+ throw new Error(`Adapter "${adapterName}" is not registered`);
184
+ }
185
+ if (!adapter.capabilities.includes(capability)) {
186
+ throw new Error(`Adapter "${adapterName}" does not support capability "${capability}"`);
144
187
  }
145
188
 
189
+ const method = CAPABILITY_METHOD[capability];
190
+ const fn = adapter[method] as ((input: unknown) => Promise<AdapterResult<T>>) | undefined;
191
+ if (!fn) {
192
+ throw new Error(
193
+ `Adapter "${adapter.name}" is registered for "${capability}" but does not implement "${String(method)}"`,
194
+ );
195
+ }
196
+
197
+ const adapterResult = await fn.call(adapter, input);
198
+ return { adapterResult, providerName: adapter.name, selfHosted: adapter.selfHosted === true };
199
+ }
200
+
201
+ /** Resolve which adapter to use for a request (legacy routing — pricingTier or first-match). */
202
+ private resolveAdapter(request: SocketRequest): ProviderAdapter {
146
203
  // If a pricing tier is specified, prefer adapters matching that tier
147
204
  if (request.pricingTier) {
148
205
  const preferSelfHosted = request.pricingTier === "standard";