@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.
- package/dist/monetization/adapters/rate-table.d.ts +6 -3
- package/dist/monetization/adapters/rate-table.js +16 -13
- package/dist/monetization/adapters/rate-table.test.js +12 -4
- package/dist/monetization/socket/socket.d.ts +14 -2
- package/dist/monetization/socket/socket.js +68 -21
- package/dist/monetization/socket/socket.test.js +178 -0
- package/package.json +3 -2
- package/src/monetization/adapters/rate-table.test.ts +14 -4
- package/src/monetization/adapters/rate-table.ts +16 -13
- package/src/monetization/socket/socket.test.ts +220 -0
- package/src/monetization/socket/socket.ts +82 -25
|
@@ -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
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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%
|
|
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%
|
|
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%
|
|
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, //
|
|
59
|
+
costPerUnit: 0.000001, // Blended per-token rate (variable across models)
|
|
57
60
|
billingUnit: "per-token",
|
|
58
|
-
margin: 1.3, // 30%
|
|
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
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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:
|
|
105
|
+
provider: providerName,
|
|
65
106
|
timestamp: Date.now(),
|
|
66
107
|
...(request.sessionId ? { sessionId: request.sessionId } : {}),
|
|
67
|
-
tier: isByok ? "byok" :
|
|
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
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
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.
|
|
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
|
|
27
|
-
const
|
|
28
|
-
|
|
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(
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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%
|
|
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%
|
|
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%
|
|
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, //
|
|
80
|
+
costPerUnit: 0.000001, // Blended per-token rate (variable across models)
|
|
78
81
|
billingUnit: "per-token",
|
|
79
|
-
margin: 1.3, // 30%
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 (
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
155
|
+
provider: providerName,
|
|
113
156
|
timestamp: Date.now(),
|
|
114
157
|
...(request.sessionId ? { sessionId: request.sessionId } : {}),
|
|
115
|
-
tier: isByok ? "byok" :
|
|
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
|
-
/**
|
|
133
|
-
private
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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";
|