@wopr-network/platform-core 1.12.2 → 1.13.1

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.
Files changed (123) hide show
  1. package/dist/api/routes/activity.d.ts +9 -0
  2. package/dist/api/routes/activity.js +68 -0
  3. package/dist/api/routes/admin-audit-helper.d.ts +7 -0
  4. package/dist/api/routes/admin-audit-helper.js +13 -0
  5. package/dist/api/routes/admin-audit.d.ts +13 -0
  6. package/dist/api/routes/admin-audit.js +61 -0
  7. package/dist/api/routes/admin-backups.d.ts +19 -0
  8. package/dist/api/routes/admin-backups.js +116 -0
  9. package/dist/api/routes/admin-compliance.d.ts +9 -0
  10. package/dist/api/routes/admin-compliance.js +27 -0
  11. package/dist/api/routes/admin-credits.d.ts +9 -0
  12. package/dist/api/routes/admin-credits.js +255 -0
  13. package/dist/api/routes/admin-gpu.d.ts +46 -0
  14. package/dist/api/routes/admin-gpu.js +140 -0
  15. package/dist/api/routes/admin-inference.d.ts +16 -0
  16. package/dist/api/routes/admin-inference.js +98 -0
  17. package/dist/api/routes/admin-marketplace.d.ts +36 -0
  18. package/dist/api/routes/admin-marketplace.js +181 -0
  19. package/dist/api/routes/admin-migration.d.ts +10 -0
  20. package/dist/api/routes/admin-migration.js +46 -0
  21. package/dist/api/routes/admin-notes.d.ts +34 -0
  22. package/dist/api/routes/admin-notes.js +131 -0
  23. package/dist/api/routes/admin-onboarding.d.ts +7 -0
  24. package/dist/api/routes/admin-onboarding.js +49 -0
  25. package/dist/api/routes/admin-rates.d.ts +9 -0
  26. package/dist/api/routes/admin-rates.js +427 -0
  27. package/dist/api/routes/admin-recovery.d.ts +91 -0
  28. package/dist/api/routes/admin-recovery.js +246 -0
  29. package/dist/api/routes/admin-roles.d.ts +27 -0
  30. package/dist/api/routes/admin-roles.js +157 -0
  31. package/dist/api/routes/audit.d.ts +19 -0
  32. package/dist/api/routes/audit.js +95 -0
  33. package/dist/api/routes/auth.d.ts +19 -0
  34. package/dist/api/routes/auth.js +25 -0
  35. package/dist/api/routes/channel-validate.d.ts +11 -0
  36. package/dist/api/routes/channel-validate.js +148 -0
  37. package/dist/api/routes/fleet-events.d.ts +4 -0
  38. package/dist/api/routes/fleet-events.js +53 -0
  39. package/dist/api/routes/friends-proxy.d.ts +28 -0
  40. package/dist/api/routes/friends-proxy.js +63 -0
  41. package/dist/api/routes/friends-types.d.ts +34 -0
  42. package/dist/api/routes/friends-types.js +28 -0
  43. package/dist/api/routes/health.d.ts +14 -0
  44. package/dist/api/routes/health.js +32 -0
  45. package/dist/api/routes/health.test.d.ts +1 -0
  46. package/dist/api/routes/health.test.js +70 -0
  47. package/dist/api/routes/incident-response.d.ts +9 -0
  48. package/dist/api/routes/incident-response.js +148 -0
  49. package/dist/api/routes/internal-gpu.d.ts +12 -0
  50. package/dist/api/routes/internal-gpu.js +70 -0
  51. package/dist/api/routes/internal-nodes.d.ts +41 -0
  52. package/dist/api/routes/internal-nodes.js +105 -0
  53. package/dist/api/routes/login-history.d.ts +11 -0
  54. package/dist/api/routes/login-history.js +22 -0
  55. package/dist/api/routes/public-pricing.d.ts +9 -0
  56. package/dist/api/routes/public-pricing.js +32 -0
  57. package/dist/api/routes/quota.d.ts +8 -0
  58. package/dist/api/routes/quota.js +113 -0
  59. package/dist/api/routes/secret-audit.d.ts +12 -0
  60. package/dist/api/routes/secret-audit.js +41 -0
  61. package/dist/api/routes/secrets.d.ts +31 -0
  62. package/dist/api/routes/secrets.js +135 -0
  63. package/dist/api/routes/tenant-keys.d.ts +16 -0
  64. package/dist/api/routes/tenant-keys.js +142 -0
  65. package/dist/api/routes/verify-email.d.ts +19 -0
  66. package/dist/api/routes/verify-email.js +70 -0
  67. package/dist/api/routes/ws-auth.d.ts +21 -0
  68. package/dist/api/routes/ws-auth.js +24 -0
  69. package/dist/monetization/adapters/bootstrap.d.ts +2 -2
  70. package/dist/monetization/adapters/bootstrap.js +3 -2
  71. package/dist/monetization/adapters/bootstrap.test.js +11 -7
  72. package/dist/monetization/adapters/embeddings-factory.d.ts +10 -5
  73. package/dist/monetization/adapters/embeddings-factory.js +17 -4
  74. package/dist/monetization/adapters/embeddings-factory.test.js +85 -31
  75. package/dist/monetization/adapters/ollama-embeddings.d.ts +40 -0
  76. package/dist/monetization/adapters/ollama-embeddings.js +76 -0
  77. package/dist/monetization/adapters/ollama-embeddings.test.d.ts +1 -0
  78. package/dist/monetization/adapters/ollama-embeddings.test.js +178 -0
  79. package/dist/monetization/adapters/rate-table.js +9 -3
  80. package/dist/monetization/adapters/rate-table.test.js +22 -1
  81. package/package.json +35 -1
  82. package/src/api/routes/activity.ts +77 -0
  83. package/src/api/routes/admin-audit-helper.ts +18 -0
  84. package/src/api/routes/admin-audit.ts +67 -0
  85. package/src/api/routes/admin-backups.ts +134 -0
  86. package/src/api/routes/admin-compliance.ts +35 -0
  87. package/src/api/routes/admin-credits.ts +280 -0
  88. package/src/api/routes/admin-gpu.ts +202 -0
  89. package/src/api/routes/admin-inference.ts +109 -0
  90. package/src/api/routes/admin-marketplace.ts +233 -0
  91. package/src/api/routes/admin-migration.ts +61 -0
  92. package/src/api/routes/admin-notes.ts +145 -0
  93. package/src/api/routes/admin-onboarding.ts +62 -0
  94. package/src/api/routes/admin-rates.ts +462 -0
  95. package/src/api/routes/admin-recovery.ts +376 -0
  96. package/src/api/routes/admin-roles.ts +205 -0
  97. package/src/api/routes/audit.ts +106 -0
  98. package/src/api/routes/auth.ts +30 -0
  99. package/src/api/routes/channel-validate.ts +182 -0
  100. package/src/api/routes/fleet-events.ts +66 -0
  101. package/src/api/routes/friends-proxy.ts +94 -0
  102. package/src/api/routes/friends-types.ts +37 -0
  103. package/src/api/routes/health.test.ts +80 -0
  104. package/src/api/routes/health.ts +48 -0
  105. package/src/api/routes/incident-response.ts +159 -0
  106. package/src/api/routes/internal-gpu.ts +92 -0
  107. package/src/api/routes/internal-nodes.ts +157 -0
  108. package/src/api/routes/login-history.ts +28 -0
  109. package/src/api/routes/public-pricing.ts +36 -0
  110. package/src/api/routes/quota.ts +136 -0
  111. package/src/api/routes/secret-audit.ts +55 -0
  112. package/src/api/routes/secrets.ts +178 -0
  113. package/src/api/routes/tenant-keys.ts +178 -0
  114. package/src/api/routes/verify-email.ts +102 -0
  115. package/src/api/routes/ws-auth.ts +44 -0
  116. package/src/monetization/adapters/bootstrap.test.ts +11 -7
  117. package/src/monetization/adapters/bootstrap.ts +3 -2
  118. package/src/monetization/adapters/embeddings-factory.test.ts +102 -33
  119. package/src/monetization/adapters/embeddings-factory.ts +24 -7
  120. package/src/monetization/adapters/ollama-embeddings.test.ts +235 -0
  121. package/src/monetization/adapters/ollama-embeddings.ts +120 -0
  122. package/src/monetization/adapters/rate-table.test.ts +32 -1
  123. package/src/monetization/adapters/rate-table.ts +9 -3
@@ -1,34 +1,75 @@
1
1
  import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { createEmbeddingsAdapters, createEmbeddingsAdaptersFromEnv } from "./embeddings-factory.js";
3
+ import * as ollamaModule from "./ollama-embeddings.js";
3
4
  import * as openrouterModule from "./openrouter.js";
4
5
 
5
6
  describe("createEmbeddingsAdapters", () => {
6
- it("creates adapter when API key provided", () => {
7
+ it("creates all adapters when all config provided", () => {
7
8
  const result = createEmbeddingsAdapters({
9
+ ollamaBaseUrl: "http://ollama:11434",
8
10
  openrouterApiKey: "sk-or",
9
11
  });
10
12
 
11
- expect(result.adapters).toHaveLength(1);
12
- expect(result.adapterMap.size).toBe(1);
13
+ expect(result.adapters).toHaveLength(2);
14
+ expect(result.adapterMap.size).toBe(2);
13
15
  expect(result.skipped).toHaveLength(0);
14
16
  });
15
17
 
16
- it("adapter is openrouter", () => {
18
+ it("orders adapters cheapest first (ollama before openrouter)", () => {
17
19
  const result = createEmbeddingsAdapters({
20
+ ollamaBaseUrl: "http://ollama:11434",
18
21
  openrouterApiKey: "sk-or",
19
22
  });
20
23
 
24
+ expect(result.adapters[0].name).toBe("ollama-embeddings");
25
+ expect(result.adapters[1].name).toBe("openrouter");
26
+ });
27
+
28
+ it("ollama adapter is self-hosted", () => {
29
+ const result = createEmbeddingsAdapters({
30
+ ollamaBaseUrl: "http://ollama:11434",
31
+ });
32
+
33
+ expect(result.adapters[0].selfHosted).toBe(true);
34
+ });
35
+
36
+ it("creates only openrouter when no ollama URL", () => {
37
+ const result = createEmbeddingsAdapters({
38
+ openrouterApiKey: "sk-or",
39
+ });
40
+
41
+ expect(result.adapters).toHaveLength(1);
21
42
  expect(result.adapters[0].name).toBe("openrouter");
43
+ expect(result.skipped).toEqual(["ollama-embeddings"]);
44
+ });
45
+
46
+ it("creates only ollama when no openrouter key", () => {
47
+ const result = createEmbeddingsAdapters({
48
+ ollamaBaseUrl: "http://ollama:11434",
49
+ });
50
+
51
+ expect(result.adapters).toHaveLength(1);
52
+ expect(result.adapters[0].name).toBe("ollama-embeddings");
53
+ expect(result.skipped).toEqual(["openrouter"]);
22
54
  });
23
55
 
24
- it("skips openrouter when no API key", () => {
56
+ it("skips both when no config", () => {
25
57
  const result = createEmbeddingsAdapters({});
26
58
 
27
59
  expect(result.adapters).toHaveLength(0);
28
- expect(result.skipped).toEqual(["openrouter"]);
60
+ expect(result.skipped).toEqual(["ollama-embeddings", "openrouter"]);
29
61
  });
30
62
 
31
- it("skips adapter with empty string key", () => {
63
+ it("skips ollama with empty string URL", () => {
64
+ const result = createEmbeddingsAdapters({
65
+ ollamaBaseUrl: "",
66
+ });
67
+
68
+ expect(result.adapters).toHaveLength(0);
69
+ expect(result.skipped).toContain("ollama-embeddings");
70
+ });
71
+
72
+ it("skips openrouter with empty string key", () => {
32
73
  const result = createEmbeddingsAdapters({
33
74
  openrouterApiKey: "",
34
75
  });
@@ -37,24 +78,31 @@ describe("createEmbeddingsAdapters", () => {
37
78
  expect(result.skipped).toContain("openrouter");
38
79
  });
39
80
 
40
- it("adapter supports embeddings capability", () => {
81
+ it("both adapters support embeddings capability", () => {
41
82
  const result = createEmbeddingsAdapters({
83
+ ollamaBaseUrl: "http://ollama:11434",
42
84
  openrouterApiKey: "sk-or",
43
85
  });
44
86
 
45
- expect(result.adapters[0].capabilities).toContain("embeddings");
87
+ for (const adapter of result.adapters) {
88
+ expect(adapter.capabilities).toContain("embeddings");
89
+ }
46
90
  });
47
91
 
48
- it("adapter implements embed", () => {
92
+ it("both adapters implement embed", () => {
49
93
  const result = createEmbeddingsAdapters({
94
+ ollamaBaseUrl: "http://ollama:11434",
50
95
  openrouterApiKey: "sk-or",
51
96
  });
52
97
 
53
- expect(typeof result.adapters[0].embed).toBe("function");
98
+ for (const adapter of result.adapters) {
99
+ expect(typeof adapter.embed).toBe("function");
100
+ }
54
101
  });
55
102
 
56
103
  it("adapterMap keys match adapter names", () => {
57
104
  const result = createEmbeddingsAdapters({
105
+ ollamaBaseUrl: "http://ollama:11434",
58
106
  openrouterApiKey: "sk-or",
59
107
  });
60
108
 
@@ -63,17 +111,17 @@ describe("createEmbeddingsAdapters", () => {
63
111
  }
64
112
  });
65
113
 
66
- it("passes per-adapter config overrides to adapter constructor", () => {
67
- const spy = vi.spyOn(openrouterModule, "createOpenRouterAdapter");
114
+ it("passes per-adapter config overrides to ollama constructor", () => {
115
+ const spy = vi.spyOn(ollamaModule, "createOllamaEmbeddingsAdapter");
68
116
 
69
117
  createEmbeddingsAdapters({
70
- openrouterApiKey: "sk-or",
71
- openrouter: { marginMultiplier: 1.5 },
118
+ ollamaBaseUrl: "http://ollama:11434",
119
+ ollama: { marginMultiplier: 1.5 },
72
120
  });
73
121
 
74
122
  expect(spy).toHaveBeenCalledWith(
75
123
  expect.objectContaining({
76
- apiKey: "sk-or",
124
+ baseUrl: "http://ollama:11434",
77
125
  marginMultiplier: 1.5,
78
126
  }),
79
127
  );
@@ -81,15 +129,22 @@ describe("createEmbeddingsAdapters", () => {
81
129
  spy.mockRestore();
82
130
  });
83
131
 
84
- it("apiKey cannot be overridden via openrouter config", () => {
85
- // Ensure apiKey always comes from openrouterApiKey, not from spread
86
- const result = createEmbeddingsAdapters({
87
- openrouterApiKey: "sk-real",
88
- openrouter: { apiKey: "sk-evil" } as never,
132
+ it("passes per-adapter config overrides to openrouter constructor", () => {
133
+ const spy = vi.spyOn(openrouterModule, "createOpenRouterAdapter");
134
+
135
+ createEmbeddingsAdapters({
136
+ openrouterApiKey: "sk-or",
137
+ openrouter: { marginMultiplier: 1.5 },
89
138
  });
90
139
 
91
- expect(result.adapters).toHaveLength(1);
92
- expect(result.adapters[0].name).toBe("openrouter");
140
+ expect(spy).toHaveBeenCalledWith(
141
+ expect.objectContaining({
142
+ apiKey: "sk-or",
143
+ marginMultiplier: 1.5,
144
+ }),
145
+ );
146
+
147
+ spy.mockRestore();
93
148
  });
94
149
  });
95
150
 
@@ -102,37 +157,51 @@ describe("createEmbeddingsAdaptersFromEnv", () => {
102
157
  vi.unstubAllEnvs();
103
158
  });
104
159
 
105
- it("reads key from environment variable", () => {
160
+ it("reads keys from environment variables", () => {
161
+ vi.stubEnv("OLLAMA_BASE_URL", "http://ollama:11434");
106
162
  vi.stubEnv("OPENROUTER_API_KEY", "env-or");
107
163
 
108
164
  const result = createEmbeddingsAdaptersFromEnv();
109
165
 
110
- expect(result.adapters).toHaveLength(1);
111
- expect(result.adapters[0].name).toBe("openrouter");
166
+ expect(result.adapters).toHaveLength(2);
167
+ expect(result.adapters[0].name).toBe("ollama-embeddings");
168
+ expect(result.adapters[1].name).toBe("openrouter");
112
169
  expect(result.skipped).toHaveLength(0);
113
170
  });
114
171
 
115
- it("returns empty when no env var set", () => {
172
+ it("returns empty when no env vars set", () => {
173
+ vi.stubEnv("OLLAMA_BASE_URL", "");
116
174
  vi.stubEnv("OPENROUTER_API_KEY", "");
117
175
 
118
176
  const result = createEmbeddingsAdaptersFromEnv();
119
177
 
120
178
  expect(result.adapters).toHaveLength(0);
121
- expect(result.skipped).toEqual(["openrouter"]);
179
+ expect(result.skipped).toEqual(["ollama-embeddings", "openrouter"]);
122
180
  });
123
181
 
124
- it("passes per-adapter overrides alongside env key to adapter constructor", () => {
182
+ it("creates only ollama when only OLLAMA_BASE_URL set", () => {
183
+ vi.stubEnv("OLLAMA_BASE_URL", "http://ollama:11434");
184
+ vi.stubEnv("OPENROUTER_API_KEY", "");
185
+
186
+ const result = createEmbeddingsAdaptersFromEnv();
187
+
188
+ expect(result.adapters).toHaveLength(1);
189
+ expect(result.adapters[0].name).toBe("ollama-embeddings");
190
+ });
191
+
192
+ it("passes per-adapter overrides alongside env vars", () => {
193
+ vi.stubEnv("OLLAMA_BASE_URL", "http://ollama:11434");
125
194
  vi.stubEnv("OPENROUTER_API_KEY", "env-or");
126
- const spy = vi.spyOn(openrouterModule, "createOpenRouterAdapter");
195
+ const spy = vi.spyOn(ollamaModule, "createOllamaEmbeddingsAdapter");
127
196
 
128
197
  createEmbeddingsAdaptersFromEnv({
129
- openrouter: { marginMultiplier: 1.2 },
198
+ ollama: { marginMultiplier: 1.1 },
130
199
  });
131
200
 
132
201
  expect(spy).toHaveBeenCalledWith(
133
202
  expect.objectContaining({
134
- apiKey: "env-or",
135
- marginMultiplier: 1.2,
203
+ baseUrl: "http://ollama:11434",
204
+ marginMultiplier: 1.1,
136
205
  }),
137
206
  );
138
207
 
@@ -7,18 +7,22 @@
7
7
  * registers with an ArbitrageRouter or AdapterSocket.
8
8
  *
9
9
  * Priority order (cheapest first, when all adapters available):
10
- * self-hosted-embeddings (GPU, cheapest — not yet implemented)
10
+ * Ollama (GPU, cheapest — $0.005/1M tokens amortized)
11
11
  * → OpenRouter ($0.02/1M tokens via text-embedding-3-small)
12
12
  */
13
13
 
14
+ import { createOllamaEmbeddingsAdapter, type OllamaEmbeddingsAdapterConfig } from "./ollama-embeddings.js";
14
15
  import { createOpenRouterAdapter, type OpenRouterAdapterConfig } from "./openrouter.js";
15
16
  import type { ProviderAdapter } from "./types.js";
16
17
 
17
- /** Top-level factory config. Only providers with an API key are instantiated. */
18
+ /** Top-level factory config. Only providers with a key/URL are instantiated. */
18
19
  export interface EmbeddingsFactoryConfig {
20
+ /** Ollama base URL (e.g., "http://ollama:11434"). Omit or empty string to skip. */
21
+ ollamaBaseUrl?: string;
19
22
  /** OpenRouter API key. Omit or empty string to skip. */
20
23
  openrouterApiKey?: string;
21
24
  /** Per-adapter config overrides */
25
+ ollama?: Omit<Partial<OllamaEmbeddingsAdapterConfig>, "baseUrl">;
22
26
  openrouter?: Omit<Partial<OpenRouterAdapterConfig>, "apiKey">;
23
27
  }
24
28
 
@@ -35,13 +39,26 @@ export interface EmbeddingsFactoryResult {
35
39
  /**
36
40
  * Create embeddings adapters from the provided config.
37
41
  *
38
- * Returns only adapters whose API key is present and non-empty.
42
+ * Returns only adapters whose key/URL is present and non-empty.
39
43
  * Order matches arbitrage priority: cheapest first.
40
44
  */
41
45
  export function createEmbeddingsAdapters(config: EmbeddingsFactoryConfig): EmbeddingsFactoryResult {
42
46
  const adapters: ProviderAdapter[] = [];
43
47
  const skipped: string[] = [];
44
48
 
49
+ // Ollama — $0.005/1M tokens (self-hosted GPU, cheapest)
50
+ if (config.ollamaBaseUrl) {
51
+ adapters.push(
52
+ createOllamaEmbeddingsAdapter({
53
+ baseUrl: config.ollamaBaseUrl,
54
+ costPerUnit: 0.000000005,
55
+ ...config.ollama,
56
+ }),
57
+ );
58
+ } else {
59
+ skipped.push("ollama-embeddings");
60
+ }
61
+
45
62
  // OpenRouter — $0.02/1M tokens (text-embedding-3-small via OpenAI)
46
63
  if (config.openrouterApiKey) {
47
64
  adapters.push(createOpenRouterAdapter({ ...config.openrouter, apiKey: config.openrouterApiKey }));
@@ -49,8 +66,6 @@ export function createEmbeddingsAdapters(config: EmbeddingsFactoryConfig): Embed
49
66
  skipped.push("openrouter");
50
67
  }
51
68
 
52
- // Future: self-hosted-embeddings will go BEFORE openrouter (GPU tier, cheapest)
53
-
54
69
  const adapterMap = new Map<string, ProviderAdapter>();
55
70
  for (const adapter of adapters) {
56
71
  adapterMap.set(adapter.name, adapter);
@@ -62,15 +77,17 @@ export function createEmbeddingsAdapters(config: EmbeddingsFactoryConfig): Embed
62
77
  /**
63
78
  * Create embeddings adapters from environment variables.
64
79
  *
65
- * Reads API keys from:
80
+ * Reads config from:
81
+ * - OLLAMA_BASE_URL (for self-hosted Ollama embeddings)
66
82
  * - OPENROUTER_API_KEY
67
83
  *
68
84
  * Accepts optional per-adapter overrides.
69
85
  */
70
86
  export function createEmbeddingsAdaptersFromEnv(
71
- overrides?: Omit<EmbeddingsFactoryConfig, "openrouterApiKey">,
87
+ overrides?: Omit<EmbeddingsFactoryConfig, "ollamaBaseUrl" | "openrouterApiKey">,
72
88
  ): EmbeddingsFactoryResult {
73
89
  return createEmbeddingsAdapters({
90
+ ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
74
91
  openrouterApiKey: process.env.OPENROUTER_API_KEY,
75
92
  ...overrides,
76
93
  });
@@ -0,0 +1,235 @@
1
+ import { Credit } from "@wopr-network/platform-core/credits";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import type { FetchFn, OllamaEmbeddingsAdapterConfig } from "./ollama-embeddings.js";
4
+ import { createOllamaEmbeddingsAdapter } from "./ollama-embeddings.js";
5
+ import { withMargin } from "./types.js";
6
+
7
+ /** Helper to create a mock Response */
8
+ function mockResponse(body: unknown, status = 200): Response {
9
+ return {
10
+ ok: status >= 200 && status < 300,
11
+ status,
12
+ json: () => Promise.resolve(body),
13
+ text: () => Promise.resolve(JSON.stringify(body)),
14
+ headers: new Headers(),
15
+ } as Response;
16
+ }
17
+
18
+ /** A successful OpenAI-compatible embeddings response */
19
+ function embeddingsResponse(overrides: Record<string, unknown> = {}) {
20
+ return {
21
+ model: "nomic-embed-text",
22
+ data: [{ embedding: [0.1, 0.2, 0.3, 0.4] }],
23
+ usage: { total_tokens: 5 },
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function makeConfig(overrides: Partial<OllamaEmbeddingsAdapterConfig> = {}): OllamaEmbeddingsAdapterConfig {
29
+ return {
30
+ baseUrl: "http://ollama:11434",
31
+ costPerUnit: 0.000000005,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("createOllamaEmbeddingsAdapter", () => {
37
+ it("returns adapter with correct name and capabilities", () => {
38
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig());
39
+ expect(adapter.name).toBe("ollama-embeddings");
40
+ expect(adapter.capabilities).toEqual(["embeddings"]);
41
+ expect(adapter.selfHosted).toBe(true);
42
+ });
43
+
44
+ it("calls /v1/embeddings endpoint", async () => {
45
+ const body = embeddingsResponse();
46
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
47
+
48
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
49
+ await adapter.embed({ input: "Hello world" });
50
+
51
+ const [url, init] = fetchFn.mock.calls[0];
52
+ expect(url).toBe("http://ollama:11434/v1/embeddings");
53
+ expect(init?.method).toBe("POST");
54
+ expect((init?.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
55
+ });
56
+
57
+ it("calculates cost from token count and costPerToken", async () => {
58
+ const body = embeddingsResponse({ usage: { total_tokens: 100 } });
59
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
60
+
61
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig({ costPerToken: 0.000000005 }), fetchFn);
62
+ const result = await adapter.embed({ input: "test" });
63
+
64
+ // 100 tokens * $0.000000005 = $0.0000005
65
+ expect(result.cost.toDollars()).toBeCloseTo(0.0000005, 10);
66
+ });
67
+
68
+ it("applies margin to cost", async () => {
69
+ const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
70
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
71
+
72
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig({ marginMultiplier: 1.5 }), fetchFn);
73
+ const result = await adapter.embed({ input: "test" });
74
+
75
+ const expectedCost = Credit.fromDollars(1000 * 0.000000005);
76
+ expect(result.cost.toDollars()).toBeCloseTo(expectedCost.toDollars(), 10);
77
+ expect(result.charge?.toDollars()).toBeCloseTo(withMargin(expectedCost, 1.5).toDollars(), 10);
78
+ });
79
+
80
+ it("uses default 1.2 margin (lower than third-party)", async () => {
81
+ const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
82
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
83
+
84
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
85
+ const result = await adapter.embed({ input: "test" });
86
+
87
+ const expectedCost = Credit.fromDollars(1000 * 0.000000005);
88
+ expect(result.charge?.toDollars()).toBeCloseTo(withMargin(expectedCost, 1.2).toDollars(), 10);
89
+ });
90
+
91
+ it("uses default model (nomic-embed-text) when none specified", async () => {
92
+ const body = embeddingsResponse();
93
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
94
+
95
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
96
+ await adapter.embed({ input: "test" });
97
+
98
+ const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body as string);
99
+ expect(reqBody.model).toBe("nomic-embed-text");
100
+ });
101
+
102
+ it("passes requested model through to request", async () => {
103
+ const body = embeddingsResponse({ model: "mxbai-embed-large" });
104
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
105
+
106
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
107
+ const result = await adapter.embed({ input: "test", model: "mxbai-embed-large" });
108
+
109
+ const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body as string);
110
+ expect(reqBody.model).toBe("mxbai-embed-large");
111
+ expect(result.result.model).toBe("mxbai-embed-large");
112
+ });
113
+
114
+ it("passes dimensions through to request", async () => {
115
+ const body = embeddingsResponse();
116
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
117
+
118
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
119
+ await adapter.embed({ input: "test", dimensions: 256 });
120
+
121
+ const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body as string);
122
+ expect(reqBody.dimensions).toBe(256);
123
+ });
124
+
125
+ it("does not send dimensions when not specified", async () => {
126
+ const body = embeddingsResponse();
127
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
128
+
129
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
130
+ await adapter.embed({ input: "test" });
131
+
132
+ const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body as string);
133
+ expect(reqBody.dimensions).toBeUndefined();
134
+ });
135
+
136
+ it("handles batch input (string[])", async () => {
137
+ const body = embeddingsResponse({
138
+ data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }],
139
+ usage: { total_tokens: 10 },
140
+ });
141
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
142
+
143
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
144
+ const result = await adapter.embed({ input: ["Hello", "World"] });
145
+
146
+ const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body as string);
147
+ expect(reqBody.input).toEqual(["Hello", "World"]);
148
+ expect(result.result.embeddings).toHaveLength(2);
149
+ expect(result.result.embeddings[0]).toEqual([0.1, 0.2, 0.3]);
150
+ expect(result.result.embeddings[1]).toEqual([0.4, 0.5, 0.6]);
151
+ expect(result.result.totalTokens).toBe(10);
152
+ });
153
+
154
+ it("throws on non-2xx response", async () => {
155
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse({ error: "model not found" }, 404));
156
+
157
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
158
+ await expect(adapter.embed({ input: "test" })).rejects.toThrow("Ollama embeddings error (404)");
159
+ });
160
+
161
+ it("throws on 500 server error", async () => {
162
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse({ error: "internal error" }, 500));
163
+
164
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
165
+ await expect(adapter.embed({ input: "test" })).rejects.toThrow("Ollama embeddings error (500)");
166
+ });
167
+
168
+ it("uses custom baseUrl", async () => {
169
+ const body = embeddingsResponse();
170
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
171
+
172
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig({ baseUrl: "http://gpu-node:11434" }), fetchFn);
173
+ await adapter.embed({ input: "test" });
174
+
175
+ const [url] = fetchFn.mock.calls[0];
176
+ expect(url).toBe("http://gpu-node:11434/v1/embeddings");
177
+ });
178
+
179
+ it("uses costPerUnit from SelfHostedAdapterConfig when costPerToken not set", async () => {
180
+ const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
181
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
182
+
183
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig({ costPerUnit: 0.00000001 }), fetchFn);
184
+ const result = await adapter.embed({ input: "test" });
185
+
186
+ // 1000 tokens * $0.00000001 = $0.00001
187
+ expect(result.cost.toDollars()).toBeCloseTo(0.00001, 8);
188
+ });
189
+
190
+ it("costPerToken takes precedence over costPerUnit", async () => {
191
+ const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
192
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
193
+
194
+ const adapter = createOllamaEmbeddingsAdapter(
195
+ makeConfig({ costPerUnit: 0.00000001, costPerToken: 0.000000005 }),
196
+ fetchFn,
197
+ );
198
+ const result = await adapter.embed({ input: "test" });
199
+
200
+ // costPerToken wins: 1000 * $0.000000005 = $0.000005
201
+ expect(result.cost.toDollars()).toBeCloseTo(0.000005, 10);
202
+ });
203
+
204
+ it("uses custom defaultModel from config", async () => {
205
+ const body = embeddingsResponse({ model: "mxbai-embed-large" });
206
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
207
+
208
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig({ defaultModel: "mxbai-embed-large" }), fetchFn);
209
+ await adapter.embed({ input: "test" });
210
+
211
+ const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body as string);
212
+ expect(reqBody.model).toBe("mxbai-embed-large");
213
+ });
214
+
215
+ it("normalizes trailing slash in baseUrl", async () => {
216
+ const body = embeddingsResponse();
217
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
218
+
219
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig({ baseUrl: "http://ollama:11434/" }), fetchFn);
220
+ await adapter.embed({ input: "test" });
221
+
222
+ const [url] = fetchFn.mock.calls[0];
223
+ expect(url).toBe("http://ollama:11434/v1/embeddings");
224
+ });
225
+
226
+ it("returns correct totalTokens from response", async () => {
227
+ const body = embeddingsResponse({ usage: { total_tokens: 42 } });
228
+ const fetchFn = vi.fn<FetchFn>().mockResolvedValueOnce(mockResponse(body));
229
+
230
+ const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
231
+ const result = await adapter.embed({ input: "test" });
232
+
233
+ expect(result.result.totalTokens).toBe(42);
234
+ });
235
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Ollama self-hosted embeddings adapter — embeddings on our own GPU infrastructure.
3
+ *
4
+ * Points at a self-hosted Ollama container running on our internal network.
5
+ * Same ProviderAdapter interface as OpenRouter embeddings, but with:
6
+ * - No API key required (internal container-to-container)
7
+ * - Amortized GPU cost instead of third-party API invoicing
8
+ * - Lower margin (cheaper for users = the standard pricing tier)
9
+ *
10
+ * Uses Ollama's OpenAI-compatible /v1/embeddings endpoint, so it works with
11
+ * any Ollama-hosted embedding model (nomic-embed-text, mxbai-embed-large, etc.).
12
+ *
13
+ * Cost model:
14
+ * Base cost = total_tokens * costPerToken
15
+ * Default costPerToken = $0.000000005 (GPU depreciation + electricity)
16
+ * Charge = base_cost * marginMultiplier (e.g., 1.2 = 20% margin vs 30% for third-party)
17
+ */
18
+
19
+ import { Credit } from "@wopr-network/platform-core/credits";
20
+ import type { FetchFn, SelfHostedAdapterConfig } from "./self-hosted-base.js";
21
+ import type { AdapterResult, EmbeddingsInput, EmbeddingsOutput, ProviderAdapter } from "./types.js";
22
+ import { withMargin } from "./types.js";
23
+
24
+ // Re-export FetchFn for tests
25
+ export type { FetchFn };
26
+
27
+ /**
28
+ * Configuration for the Ollama embeddings adapter.
29
+ *
30
+ * Cost precedence: `costPerToken` (if set) > `costPerUnit` (from SelfHostedAdapterConfig).
31
+ * Use `costPerToken` for adapter-specific overrides; `costPerUnit` is the base config
32
+ * shared across all self-hosted adapters.
33
+ */
34
+ export interface OllamaEmbeddingsAdapterConfig extends SelfHostedAdapterConfig {
35
+ /** Cost per token in USD (amortized GPU time, default: $0.000000005). Takes precedence over costPerUnit. */
36
+ costPerToken?: number;
37
+ /** Default embedding model (default: "nomic-embed-text") */
38
+ defaultModel?: string;
39
+ }
40
+
41
+ // ~4x cheaper than OpenRouter's text-embedding-3-small ($0.02/1M tokens)
42
+ const DEFAULT_COST_PER_TOKEN = 0.000000005; // $0.005 per 1M tokens
43
+ const DEFAULT_MARGIN = 1.2; // 20% vs 30% for third-party
44
+ const DEFAULT_MODEL = "nomic-embed-text";
45
+
46
+ /** OpenAI-compatible embeddings response (subset we care about) */
47
+ interface OllamaEmbeddingsResponse {
48
+ model: string;
49
+ data: Array<{
50
+ embedding: number[];
51
+ }>;
52
+ usage: {
53
+ total_tokens: number;
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Create an Ollama self-hosted embeddings adapter.
59
+ *
60
+ * Uses factory function pattern (not class) for minimal API surface and easy
61
+ * dependency injection of fetch for testing.
62
+ */
63
+ export function createOllamaEmbeddingsAdapter(
64
+ config: OllamaEmbeddingsAdapterConfig,
65
+ fetchFn: FetchFn = fetch,
66
+ ): ProviderAdapter & Required<Pick<ProviderAdapter, "embed">> {
67
+ const costPerToken = config.costPerToken ?? config.costPerUnit ?? DEFAULT_COST_PER_TOKEN;
68
+ const marginMultiplier = config.marginMultiplier ?? DEFAULT_MARGIN;
69
+ const defaultModel = config.defaultModel ?? DEFAULT_MODEL;
70
+ const timeoutMs = config.timeoutMs ?? 30000;
71
+
72
+ return {
73
+ name: "ollama-embeddings",
74
+ capabilities: ["embeddings"] as const,
75
+ selfHosted: true,
76
+
77
+ async embed(input: EmbeddingsInput): Promise<AdapterResult<EmbeddingsOutput>> {
78
+ const model = input.model ?? defaultModel;
79
+
80
+ const body: Record<string, unknown> = {
81
+ input: input.input,
82
+ model,
83
+ };
84
+ if (input.dimensions !== undefined) {
85
+ body.dimensions = input.dimensions;
86
+ }
87
+
88
+ const base = config.baseUrl.replace(/\/+$/, "");
89
+ const res = await fetchFn(`${base}/v1/embeddings`, {
90
+ method: "POST",
91
+ headers: {
92
+ "Content-Type": "application/json",
93
+ },
94
+ body: JSON.stringify(body),
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ });
97
+
98
+ if (!res.ok) {
99
+ const text = await res.text();
100
+ throw new Error(`Ollama embeddings error (${res.status}): ${text}`);
101
+ }
102
+
103
+ const data = (await res.json()) as OllamaEmbeddingsResponse;
104
+
105
+ const totalTokens = data.usage?.total_tokens ?? 0;
106
+ const cost = Credit.fromDollars(totalTokens * costPerToken);
107
+ const charge = withMargin(cost, marginMultiplier);
108
+
109
+ return {
110
+ result: {
111
+ embeddings: data.data.map((d) => d.embedding),
112
+ model: data.model,
113
+ totalTokens,
114
+ },
115
+ cost,
116
+ charge,
117
+ };
118
+ },
119
+ };
120
+ }