@wopr-network/platform-core 1.12.2 → 1.13.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/bootstrap.d.ts +2 -2
- package/dist/monetization/adapters/bootstrap.js +3 -2
- package/dist/monetization/adapters/bootstrap.test.js +11 -7
- package/dist/monetization/adapters/embeddings-factory.d.ts +10 -5
- package/dist/monetization/adapters/embeddings-factory.js +17 -4
- package/dist/monetization/adapters/embeddings-factory.test.js +85 -31
- package/dist/monetization/adapters/ollama-embeddings.d.ts +40 -0
- package/dist/monetization/adapters/ollama-embeddings.js +76 -0
- package/dist/monetization/adapters/ollama-embeddings.test.d.ts +1 -0
- package/dist/monetization/adapters/ollama-embeddings.test.js +178 -0
- package/dist/monetization/adapters/rate-table.js +9 -3
- package/dist/monetization/adapters/rate-table.test.js +22 -1
- package/package.json +1 -1
- package/src/monetization/adapters/bootstrap.test.ts +11 -7
- package/src/monetization/adapters/bootstrap.ts +3 -2
- package/src/monetization/adapters/embeddings-factory.test.ts +102 -33
- package/src/monetization/adapters/embeddings-factory.ts +24 -7
- package/src/monetization/adapters/ollama-embeddings.test.ts +235 -0
- package/src/monetization/adapters/ollama-embeddings.ts +120 -0
- package/src/monetization/adapters/rate-table.test.ts +32 -1
- package/src/monetization/adapters/rate-table.ts +9 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Credit } from "@wopr-network/platform-core/credits";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createOllamaEmbeddingsAdapter } from "./ollama-embeddings.js";
|
|
4
|
+
import { withMargin } from "./types.js";
|
|
5
|
+
/** Helper to create a mock Response */
|
|
6
|
+
function mockResponse(body, status = 200) {
|
|
7
|
+
return {
|
|
8
|
+
ok: status >= 200 && status < 300,
|
|
9
|
+
status,
|
|
10
|
+
json: () => Promise.resolve(body),
|
|
11
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
12
|
+
headers: new Headers(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** A successful OpenAI-compatible embeddings response */
|
|
16
|
+
function embeddingsResponse(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
model: "nomic-embed-text",
|
|
19
|
+
data: [{ embedding: [0.1, 0.2, 0.3, 0.4] }],
|
|
20
|
+
usage: { total_tokens: 5 },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function makeConfig(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
baseUrl: "http://ollama:11434",
|
|
27
|
+
costPerUnit: 0.000000005,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
describe("createOllamaEmbeddingsAdapter", () => {
|
|
32
|
+
it("returns adapter with correct name and capabilities", () => {
|
|
33
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig());
|
|
34
|
+
expect(adapter.name).toBe("ollama-embeddings");
|
|
35
|
+
expect(adapter.capabilities).toEqual(["embeddings"]);
|
|
36
|
+
expect(adapter.selfHosted).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it("calls /v1/embeddings endpoint", async () => {
|
|
39
|
+
const body = embeddingsResponse();
|
|
40
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
41
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
42
|
+
await adapter.embed({ input: "Hello world" });
|
|
43
|
+
const [url, init] = fetchFn.mock.calls[0];
|
|
44
|
+
expect(url).toBe("http://ollama:11434/v1/embeddings");
|
|
45
|
+
expect(init?.method).toBe("POST");
|
|
46
|
+
expect((init?.headers)["Content-Type"]).toBe("application/json");
|
|
47
|
+
});
|
|
48
|
+
it("calculates cost from token count and costPerToken", async () => {
|
|
49
|
+
const body = embeddingsResponse({ usage: { total_tokens: 100 } });
|
|
50
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
51
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig({ costPerToken: 0.000000005 }), fetchFn);
|
|
52
|
+
const result = await adapter.embed({ input: "test" });
|
|
53
|
+
// 100 tokens * $0.000000005 = $0.0000005
|
|
54
|
+
expect(result.cost.toDollars()).toBeCloseTo(0.0000005, 10);
|
|
55
|
+
});
|
|
56
|
+
it("applies margin to cost", async () => {
|
|
57
|
+
const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
|
|
58
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
59
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig({ marginMultiplier: 1.5 }), fetchFn);
|
|
60
|
+
const result = await adapter.embed({ input: "test" });
|
|
61
|
+
const expectedCost = Credit.fromDollars(1000 * 0.000000005);
|
|
62
|
+
expect(result.cost.toDollars()).toBeCloseTo(expectedCost.toDollars(), 10);
|
|
63
|
+
expect(result.charge?.toDollars()).toBeCloseTo(withMargin(expectedCost, 1.5).toDollars(), 10);
|
|
64
|
+
});
|
|
65
|
+
it("uses default 1.2 margin (lower than third-party)", async () => {
|
|
66
|
+
const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
|
|
67
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
68
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
69
|
+
const result = await adapter.embed({ input: "test" });
|
|
70
|
+
const expectedCost = Credit.fromDollars(1000 * 0.000000005);
|
|
71
|
+
expect(result.charge?.toDollars()).toBeCloseTo(withMargin(expectedCost, 1.2).toDollars(), 10);
|
|
72
|
+
});
|
|
73
|
+
it("uses default model (nomic-embed-text) when none specified", async () => {
|
|
74
|
+
const body = embeddingsResponse();
|
|
75
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
76
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
77
|
+
await adapter.embed({ input: "test" });
|
|
78
|
+
const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body);
|
|
79
|
+
expect(reqBody.model).toBe("nomic-embed-text");
|
|
80
|
+
});
|
|
81
|
+
it("passes requested model through to request", async () => {
|
|
82
|
+
const body = embeddingsResponse({ model: "mxbai-embed-large" });
|
|
83
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
84
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
85
|
+
const result = await adapter.embed({ input: "test", model: "mxbai-embed-large" });
|
|
86
|
+
const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body);
|
|
87
|
+
expect(reqBody.model).toBe("mxbai-embed-large");
|
|
88
|
+
expect(result.result.model).toBe("mxbai-embed-large");
|
|
89
|
+
});
|
|
90
|
+
it("passes dimensions through to request", async () => {
|
|
91
|
+
const body = embeddingsResponse();
|
|
92
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
93
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
94
|
+
await adapter.embed({ input: "test", dimensions: 256 });
|
|
95
|
+
const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body);
|
|
96
|
+
expect(reqBody.dimensions).toBe(256);
|
|
97
|
+
});
|
|
98
|
+
it("does not send dimensions when not specified", async () => {
|
|
99
|
+
const body = embeddingsResponse();
|
|
100
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
101
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
102
|
+
await adapter.embed({ input: "test" });
|
|
103
|
+
const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body);
|
|
104
|
+
expect(reqBody.dimensions).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
it("handles batch input (string[])", async () => {
|
|
107
|
+
const body = embeddingsResponse({
|
|
108
|
+
data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }],
|
|
109
|
+
usage: { total_tokens: 10 },
|
|
110
|
+
});
|
|
111
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
112
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
113
|
+
const result = await adapter.embed({ input: ["Hello", "World"] });
|
|
114
|
+
const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body);
|
|
115
|
+
expect(reqBody.input).toEqual(["Hello", "World"]);
|
|
116
|
+
expect(result.result.embeddings).toHaveLength(2);
|
|
117
|
+
expect(result.result.embeddings[0]).toEqual([0.1, 0.2, 0.3]);
|
|
118
|
+
expect(result.result.embeddings[1]).toEqual([0.4, 0.5, 0.6]);
|
|
119
|
+
expect(result.result.totalTokens).toBe(10);
|
|
120
|
+
});
|
|
121
|
+
it("throws on non-2xx response", async () => {
|
|
122
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse({ error: "model not found" }, 404));
|
|
123
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
124
|
+
await expect(adapter.embed({ input: "test" })).rejects.toThrow("Ollama embeddings error (404)");
|
|
125
|
+
});
|
|
126
|
+
it("throws on 500 server error", async () => {
|
|
127
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse({ error: "internal error" }, 500));
|
|
128
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
129
|
+
await expect(adapter.embed({ input: "test" })).rejects.toThrow("Ollama embeddings error (500)");
|
|
130
|
+
});
|
|
131
|
+
it("uses custom baseUrl", async () => {
|
|
132
|
+
const body = embeddingsResponse();
|
|
133
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
134
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig({ baseUrl: "http://gpu-node:11434" }), fetchFn);
|
|
135
|
+
await adapter.embed({ input: "test" });
|
|
136
|
+
const [url] = fetchFn.mock.calls[0];
|
|
137
|
+
expect(url).toBe("http://gpu-node:11434/v1/embeddings");
|
|
138
|
+
});
|
|
139
|
+
it("uses costPerUnit from SelfHostedAdapterConfig when costPerToken not set", async () => {
|
|
140
|
+
const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
|
|
141
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
142
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig({ costPerUnit: 0.00000001 }), fetchFn);
|
|
143
|
+
const result = await adapter.embed({ input: "test" });
|
|
144
|
+
// 1000 tokens * $0.00000001 = $0.00001
|
|
145
|
+
expect(result.cost.toDollars()).toBeCloseTo(0.00001, 8);
|
|
146
|
+
});
|
|
147
|
+
it("costPerToken takes precedence over costPerUnit", async () => {
|
|
148
|
+
const body = embeddingsResponse({ usage: { total_tokens: 1000 } });
|
|
149
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
150
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig({ costPerUnit: 0.00000001, costPerToken: 0.000000005 }), fetchFn);
|
|
151
|
+
const result = await adapter.embed({ input: "test" });
|
|
152
|
+
// costPerToken wins: 1000 * $0.000000005 = $0.000005
|
|
153
|
+
expect(result.cost.toDollars()).toBeCloseTo(0.000005, 10);
|
|
154
|
+
});
|
|
155
|
+
it("uses custom defaultModel from config", async () => {
|
|
156
|
+
const body = embeddingsResponse({ model: "mxbai-embed-large" });
|
|
157
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
158
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig({ defaultModel: "mxbai-embed-large" }), fetchFn);
|
|
159
|
+
await adapter.embed({ input: "test" });
|
|
160
|
+
const reqBody = JSON.parse(fetchFn.mock.calls[0][1]?.body);
|
|
161
|
+
expect(reqBody.model).toBe("mxbai-embed-large");
|
|
162
|
+
});
|
|
163
|
+
it("normalizes trailing slash in baseUrl", async () => {
|
|
164
|
+
const body = embeddingsResponse();
|
|
165
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
166
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig({ baseUrl: "http://ollama:11434/" }), fetchFn);
|
|
167
|
+
await adapter.embed({ input: "test" });
|
|
168
|
+
const [url] = fetchFn.mock.calls[0];
|
|
169
|
+
expect(url).toBe("http://ollama:11434/v1/embeddings");
|
|
170
|
+
});
|
|
171
|
+
it("returns correct totalTokens from response", async () => {
|
|
172
|
+
const body = embeddingsResponse({ usage: { total_tokens: 42 } });
|
|
173
|
+
const fetchFn = vi.fn().mockResolvedValueOnce(mockResponse(body));
|
|
174
|
+
const adapter = createOllamaEmbeddingsAdapter(makeConfig(), fetchFn);
|
|
175
|
+
const result = await adapter.embed({ input: "test" });
|
|
176
|
+
expect(result.result.totalTokens).toBe(42);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -95,8 +95,15 @@ export const RATE_TABLE = [
|
|
|
95
95
|
effectivePrice: 0.0247, // = costPerUnit * margin ($24.70 per 1K images)
|
|
96
96
|
},
|
|
97
97
|
// Embeddings
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
{
|
|
99
|
+
capability: "embeddings",
|
|
100
|
+
tier: "standard",
|
|
101
|
+
provider: "ollama-embeddings",
|
|
102
|
+
costPerUnit: 0.000000005, // Amortized GPU cost per token ($0.005 per 1M tokens)
|
|
103
|
+
billingUnit: "per-token",
|
|
104
|
+
margin: 1.2, // 20% — dashboard default; runtime uses getMargin()
|
|
105
|
+
effectivePrice: 0.000000006, // = costPerUnit * margin ($0.006 per 1M tokens)
|
|
106
|
+
},
|
|
100
107
|
{
|
|
101
108
|
capability: "embeddings",
|
|
102
109
|
tier: "premium",
|
|
@@ -131,7 +138,6 @@ export const RATE_TABLE = [
|
|
|
131
138
|
},
|
|
132
139
|
// Future self-hosted adapters will add more entries here:
|
|
133
140
|
// - transcription: self-hosted-whisper (standard) — when GPU adapter exists
|
|
134
|
-
// - embeddings: self-hosted-embeddings (standard) — when GPU adapter exists
|
|
135
141
|
// - image-generation: self-hosted-sdxl (standard) — when GPU adapter exists
|
|
136
142
|
];
|
|
137
143
|
/**
|
|
@@ -7,6 +7,12 @@ describe("RATE_TABLE", () => {
|
|
|
7
7
|
expect(standardTTS).toEqual(expect.objectContaining({ capability: "tts", tier: "standard" }));
|
|
8
8
|
expect(premiumTTS).toEqual(expect.objectContaining({ capability: "tts", tier: "premium" }));
|
|
9
9
|
});
|
|
10
|
+
it("contains both standard and premium tiers for embeddings", () => {
|
|
11
|
+
const standard = RATE_TABLE.find((e) => e.capability === "embeddings" && e.tier === "standard");
|
|
12
|
+
const premium = RATE_TABLE.find((e) => e.capability === "embeddings" && e.tier === "premium");
|
|
13
|
+
expect(standard).toEqual(expect.objectContaining({ capability: "embeddings", tier: "standard", provider: "ollama-embeddings" }));
|
|
14
|
+
expect(premium).toEqual(expect.objectContaining({ capability: "embeddings", tier: "premium", provider: "openrouter" }));
|
|
15
|
+
});
|
|
10
16
|
it("contains both standard and premium tiers for text-generation", () => {
|
|
11
17
|
const standard = RATE_TABLE.find((e) => e.capability === "text-generation" && e.tier === "standard");
|
|
12
18
|
const premium = RATE_TABLE.find((e) => e.capability === "text-generation" && e.tier === "premium");
|
|
@@ -36,7 +42,9 @@ describe("RATE_TABLE", () => {
|
|
|
36
42
|
const standardEntries = RATE_TABLE.filter((e) => e.tier === "standard");
|
|
37
43
|
for (const entry of standardEntries) {
|
|
38
44
|
// Self-hosted providers include "self-hosted-" prefix or are known self-hosted names
|
|
39
|
-
const isSelfHosted = entry.provider.startsWith("self-hosted-") ||
|
|
45
|
+
const isSelfHosted = entry.provider.startsWith("self-hosted-") ||
|
|
46
|
+
entry.provider === "chatterbox-tts" ||
|
|
47
|
+
entry.provider === "ollama-embeddings";
|
|
40
48
|
expect(isSelfHosted).toBe(true);
|
|
41
49
|
}
|
|
42
50
|
});
|
|
@@ -104,6 +112,12 @@ describe("getRatesForCapability", () => {
|
|
|
104
112
|
expect(rates.map((r) => r.tier)).toContain("standard");
|
|
105
113
|
expect(rates.map((r) => r.tier)).toContain("premium");
|
|
106
114
|
});
|
|
115
|
+
it("returns both standard and premium for embeddings", () => {
|
|
116
|
+
const rates = getRatesForCapability("embeddings");
|
|
117
|
+
expect(rates).toHaveLength(2);
|
|
118
|
+
expect(rates.map((r) => r.tier)).toContain("standard");
|
|
119
|
+
expect(rates.map((r) => r.tier)).toContain("premium");
|
|
120
|
+
});
|
|
107
121
|
it("returns premium-only for transcription (no standard tier yet)", () => {
|
|
108
122
|
const rates = getRatesForCapability("transcription");
|
|
109
123
|
expect(rates).toHaveLength(1);
|
|
@@ -141,6 +155,13 @@ describe("calculateSavings", () => {
|
|
|
141
155
|
// Savings: $2.01 per 100K chars
|
|
142
156
|
expect(savings).toBeCloseTo(2.01, 2);
|
|
143
157
|
});
|
|
158
|
+
it("calculates savings for embeddings at 1M tokens", () => {
|
|
159
|
+
const savings = calculateSavings("embeddings", 1_000_000);
|
|
160
|
+
// Standard (ollama-embeddings): $0.006 per 1M tokens
|
|
161
|
+
// Premium (openrouter): $0.026 per 1M tokens
|
|
162
|
+
// Savings: $0.020 per 1M tokens
|
|
163
|
+
expect(savings).toBeCloseTo(0.02, 3);
|
|
164
|
+
});
|
|
144
165
|
it("returns zero when capability has no standard tier", () => {
|
|
145
166
|
// Transcription only has premium (deepgram) — no self-hosted whisper yet
|
|
146
167
|
const savings = calculateSavings("transcription", 1000);
|
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ describe("bootstrapAdapters", () => {
|
|
|
19
19
|
deepgramApiKey: "sk-dg",
|
|
20
20
|
},
|
|
21
21
|
embeddings: {
|
|
22
|
+
ollamaBaseUrl: "http://ollama:11434",
|
|
22
23
|
openrouterApiKey: "sk-or",
|
|
23
24
|
},
|
|
24
25
|
imageGen: {
|
|
@@ -27,9 +28,9 @@ describe("bootstrapAdapters", () => {
|
|
|
27
28
|
},
|
|
28
29
|
});
|
|
29
30
|
|
|
30
|
-
// 5 text-gen + 2 TTS + 1 transcription +
|
|
31
|
-
expect(result.adapters).toHaveLength(
|
|
32
|
-
expect(result.summary.total).toBe(
|
|
31
|
+
// 5 text-gen + 2 TTS + 1 transcription + 2 embeddings + 2 image-gen = 12
|
|
32
|
+
expect(result.adapters).toHaveLength(12);
|
|
33
|
+
expect(result.summary.total).toBe(12);
|
|
33
34
|
expect(result.summary.skipped).toBe(0);
|
|
34
35
|
});
|
|
35
36
|
|
|
@@ -72,7 +73,7 @@ describe("bootstrapAdapters", () => {
|
|
|
72
73
|
|
|
73
74
|
expect(result.skipped.tts).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
74
75
|
expect(result.skipped.transcription).toEqual(["deepgram"]);
|
|
75
|
-
expect(result.skipped.embeddings).toEqual(["openrouter"]);
|
|
76
|
+
expect(result.skipped.embeddings).toEqual(["ollama-embeddings", "openrouter"]);
|
|
76
77
|
expect(result.skipped["text-generation"]).toEqual(["gemini", "minimax", "kimi", "openrouter"]);
|
|
77
78
|
expect(result.skipped["image-generation"]).toEqual(["replicate", "nano-banana"]);
|
|
78
79
|
});
|
|
@@ -136,14 +137,15 @@ describe("bootstrapAdaptersFromEnv", () => {
|
|
|
136
137
|
vi.stubEnv("CHATTERBOX_BASE_URL", "http://chatterbox:8000");
|
|
137
138
|
vi.stubEnv("ELEVENLABS_API_KEY", "env-el");
|
|
138
139
|
vi.stubEnv("DEEPGRAM_API_KEY", "env-dg");
|
|
140
|
+
vi.stubEnv("OLLAMA_BASE_URL", "http://ollama:11434");
|
|
139
141
|
vi.stubEnv("REPLICATE_API_TOKEN", "r8-rep");
|
|
140
142
|
vi.stubEnv("NANO_BANANA_API_KEY", "env-nb");
|
|
141
143
|
|
|
142
144
|
const result = bootstrapAdaptersFromEnv();
|
|
143
145
|
|
|
144
|
-
// 5 text-gen + 2 TTS + 1 transcription +
|
|
145
|
-
expect(result.adapters).toHaveLength(
|
|
146
|
-
expect(result.summary.total).toBe(
|
|
146
|
+
// 5 text-gen + 2 TTS + 1 transcription + 2 embeddings + 2 image-gen = 12
|
|
147
|
+
expect(result.adapters).toHaveLength(12);
|
|
148
|
+
expect(result.summary.total).toBe(12);
|
|
147
149
|
});
|
|
148
150
|
|
|
149
151
|
it("returns empty when no env vars set", () => {
|
|
@@ -155,6 +157,7 @@ describe("bootstrapAdaptersFromEnv", () => {
|
|
|
155
157
|
vi.stubEnv("CHATTERBOX_BASE_URL", "");
|
|
156
158
|
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
157
159
|
vi.stubEnv("DEEPGRAM_API_KEY", "");
|
|
160
|
+
vi.stubEnv("OLLAMA_BASE_URL", "");
|
|
158
161
|
vi.stubEnv("REPLICATE_API_TOKEN", "");
|
|
159
162
|
vi.stubEnv("NANO_BANANA_API_KEY", "");
|
|
160
163
|
|
|
@@ -173,6 +176,7 @@ describe("bootstrapAdaptersFromEnv", () => {
|
|
|
173
176
|
vi.stubEnv("CHATTERBOX_BASE_URL", "");
|
|
174
177
|
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
175
178
|
vi.stubEnv("DEEPGRAM_API_KEY", "");
|
|
179
|
+
vi.stubEnv("OLLAMA_BASE_URL", "");
|
|
176
180
|
vi.stubEnv("REPLICATE_API_TOKEN", "");
|
|
177
181
|
vi.stubEnv("NANO_BANANA_API_KEY", "");
|
|
178
182
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - text-generation (DeepSeek, Gemini, MiniMax, Kimi, OpenRouter)
|
|
12
12
|
* - tts (Chatterbox GPU, ElevenLabs)
|
|
13
13
|
* - transcription (Deepgram)
|
|
14
|
-
* - embeddings (OpenRouter)
|
|
14
|
+
* - embeddings (Ollama GPU, OpenRouter)
|
|
15
15
|
* - image-generation (Replicate, Nano Banana)
|
|
16
16
|
*/
|
|
17
17
|
|
|
@@ -121,7 +121,7 @@ export function bootstrapAdapters(config: BootstrapConfig): BootstrapResult {
|
|
|
121
121
|
* - DEEPSEEK_API_KEY, GEMINI_API_KEY, MINIMAX_API_KEY, KIMI_API_KEY, OPENROUTER_API_KEY (text-gen)
|
|
122
122
|
* - CHATTERBOX_BASE_URL, ELEVENLABS_API_KEY (TTS)
|
|
123
123
|
* - DEEPGRAM_API_KEY (transcription)
|
|
124
|
-
* - OPENROUTER_API_KEY (embeddings)
|
|
124
|
+
* - OLLAMA_BASE_URL, OPENROUTER_API_KEY (embeddings)
|
|
125
125
|
* - REPLICATE_API_TOKEN, NANO_BANANA_API_KEY (image-gen)
|
|
126
126
|
*
|
|
127
127
|
* Accepts optional per-capability config overrides.
|
|
@@ -146,6 +146,7 @@ export function bootstrapAdaptersFromEnv(overrides?: Partial<BootstrapConfig>):
|
|
|
146
146
|
...overrides?.transcription,
|
|
147
147
|
},
|
|
148
148
|
embeddings: {
|
|
149
|
+
ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
|
|
149
150
|
openrouterApiKey: process.env.OPENROUTER_API_KEY,
|
|
150
151
|
...overrides?.embeddings,
|
|
151
152
|
},
|
|
@@ -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
|
|
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(
|
|
12
|
-
expect(result.adapterMap.size).toBe(
|
|
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("
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
|
|
87
|
+
for (const adapter of result.adapters) {
|
|
88
|
+
expect(adapter.capabilities).toContain("embeddings");
|
|
89
|
+
}
|
|
46
90
|
});
|
|
47
91
|
|
|
48
|
-
it("
|
|
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
|
-
|
|
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
|
|
67
|
-
const spy = vi.spyOn(
|
|
114
|
+
it("passes per-adapter config overrides to ollama constructor", () => {
|
|
115
|
+
const spy = vi.spyOn(ollamaModule, "createOllamaEmbeddingsAdapter");
|
|
68
116
|
|
|
69
117
|
createEmbeddingsAdapters({
|
|
70
|
-
|
|
71
|
-
|
|
118
|
+
ollamaBaseUrl: "http://ollama:11434",
|
|
119
|
+
ollama: { marginMultiplier: 1.5 },
|
|
72
120
|
});
|
|
73
121
|
|
|
74
122
|
expect(spy).toHaveBeenCalledWith(
|
|
75
123
|
expect.objectContaining({
|
|
76
|
-
|
|
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("
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
92
|
-
|
|
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
|
|
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(
|
|
111
|
-
expect(result.adapters[0].name).toBe("
|
|
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
|
|
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("
|
|
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(
|
|
195
|
+
const spy = vi.spyOn(ollamaModule, "createOllamaEmbeddingsAdapter");
|
|
127
196
|
|
|
128
197
|
createEmbeddingsAdaptersFromEnv({
|
|
129
|
-
|
|
198
|
+
ollama: { marginMultiplier: 1.1 },
|
|
130
199
|
});
|
|
131
200
|
|
|
132
201
|
expect(spy).toHaveBeenCalledWith(
|
|
133
202
|
expect.objectContaining({
|
|
134
|
-
|
|
135
|
-
marginMultiplier: 1.
|
|
203
|
+
baseUrl: "http://ollama:11434",
|
|
204
|
+
marginMultiplier: 1.1,
|
|
136
205
|
}),
|
|
137
206
|
);
|
|
138
207
|
|