feat(prometheus): real providers (Gemini/fal/Pollinations…) + secure key store
- Secure secrets: gitignored ~/pyre/.env (chmod 600) loaded into the API via
`node --env-file-if-exists`; keys never committed/logged/returned. .env.example
documents the vars. Free-first default (text=gemini, image=pollinations).
- @pyre/config: provider selection + key fields.
- @pyre/prometheus: real providers via fetch (no SDK deps) — Gemini/Anthropic/
OpenAI text, Pollinations(free)/fal/DeepInfra/Replicate image, OpenAI moderation;
`createProviders()` factory selects by config + key presence, falls back to stub.
29 tests.
- @pyre/api: /api/prometheus/generate builds providers from config; keys never logged.
Live-verified end-to-end: admin-gated generate returned a real Spawn ("Ashen
Golem"/$AGOL) with a Pollinations image on the $0 stub-text+free-image stack;
.env-loaded admin token enforced. typecheck 8/8, 150 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
.env.example
17
.env.example
@@ -22,10 +22,21 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
# ---- AI services (Prometheus) ----------------------------------------------
|
# ---- AI services (Prometheus) ----------------------------------------------
|
||||||
# API-based only for MVP. Do NOT run local LLMs/image models on the server.
|
# API-based only for MVP. Do NOT run local LLMs/image models on the server.
|
||||||
|
# Keys live ONLY in the gitignored ~/pyre/.env (chmod 600), loaded by the API at
|
||||||
|
# runtime — never in this committed template, never in git.
|
||||||
|
#
|
||||||
|
# Provider selection (free-first default): text=gemini (free tier), image=
|
||||||
|
# pollinations (free, keyless). Falls back to a deterministic stub when a key is
|
||||||
|
# missing, so generation always runs.
|
||||||
|
PROMETHEUS_TEXT_PROVIDER=stub # gemini | anthropic | openai | stub
|
||||||
|
PROMETHEUS_IMAGE_PROVIDER=stub # pollinations | fal | deepinfra | replicate | stub
|
||||||
|
GEMINI_API_KEY= # free tier — aistudio.google.com/apikey
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY= # also enables the free omni-moderation pass
|
||||||
IMAGE_GEN_PROVIDER= # e.g. openai | stability | replicate
|
FAL_KEY= # fal.ai (FLUX schnell ~$0.003/img)
|
||||||
IMAGE_GEN_API_KEY=
|
DEEPINFRA_API_KEY= # cheapest image (~$0.0005/img)
|
||||||
|
REPLICATE_API_TOKEN= # FLUX schnell ~$0.003/img
|
||||||
|
PINATA_JWT= # IPFS upload of Spawn image + metadata
|
||||||
|
|
||||||
# ---- App URLs / ports ------------------------------------------------------
|
# ---- App URLs / ports ------------------------------------------------------
|
||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
|
|||||||
@@ -65,22 +65,54 @@ import type {
|
|||||||
// Prometheus owns generation (built in parallel). We code to its published
|
// Prometheus owns generation (built in parallel). We code to its published
|
||||||
// signature: `generateSpawn(input, opts?): Promise<PrometheusGenerateResponse>`.
|
// signature: `generateSpawn(input, opts?): Promise<PrometheusGenerateResponse>`.
|
||||||
// If the only typecheck error is this not-yet-exported symbol, that is expected.
|
// If the only typecheck error is this not-yet-exported symbol, that is expected.
|
||||||
import { generateSpawn } from "@pyre/prometheus";
|
import { generateSpawn, createProviders } from "@pyre/prometheus";
|
||||||
import { getSellQuote, getShield } from "./jupiter.js";
|
import { getSellQuote, getShield } from "./jupiter.js";
|
||||||
|
|
||||||
|
/** The provider bundle returned by `@pyre/prometheus`'s `createProviders`. */
|
||||||
|
type AiProviders = ReturnType<typeof createProviders>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options accepted by `@pyre/prometheus`'s `generateSpawn`. Declared locally to
|
* Options accepted by `@pyre/prometheus`'s `generateSpawn`. Declared locally to
|
||||||
* match the published signature without depending on its internal types.
|
* match the published signature without depending on its internal types. The
|
||||||
|
* provider overrides are typed off `createProviders`'s return so we never have
|
||||||
|
* to spell out the providers' internal shapes.
|
||||||
*/
|
*/
|
||||||
interface GenerateSpawnOpts {
|
interface GenerateSpawnOpts {
|
||||||
/** Chaos factor 0..1 controlling mutation strength. */
|
/** Chaos factor 0..1 controlling mutation strength. */
|
||||||
chaos?: number;
|
chaos?: number;
|
||||||
/** Optional manual operator theme seed. */
|
/** Optional manual operator theme seed. */
|
||||||
operatorSeed?: string;
|
operatorSeed?: string;
|
||||||
|
/** Text provider override. */
|
||||||
|
text?: AiProviders["text"];
|
||||||
|
/** Image provider override. */
|
||||||
|
image?: AiProviders["image"];
|
||||||
|
/** Moderation provider override. */
|
||||||
|
moderation?: AiProviders["moderation"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prometheus AI providers — built ONCE at module scope from operator config.
|
||||||
|
//
|
||||||
|
// SECURITY: the provider keys live only inside this bundle; they are NEVER
|
||||||
|
// logged, never echoed in a response, and never included in an error body.
|
||||||
|
// `createProviders` selects the configured text/image backends and wires the
|
||||||
|
// API keys; an empty key simply means that backend is unconfigured.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const aiProviders = createProviders({
|
||||||
|
textProvider: config.prometheusTextProvider,
|
||||||
|
imageProvider: config.prometheusImageProvider,
|
||||||
|
keys: {
|
||||||
|
gemini: config.geminiApiKey,
|
||||||
|
anthropic: config.anthropicApiKey,
|
||||||
|
openai: config.openaiApiKey,
|
||||||
|
fal: config.falKey,
|
||||||
|
deepinfra: config.deepinfraApiKey,
|
||||||
|
replicate: config.replicateApiToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Admin gate (§16): admin endpoints require an `x-admin-token` header equal to
|
// Admin gate (§16): admin endpoints require an `x-admin-token` header equal to
|
||||||
// the configured `adminApiToken`. When the token is unset (empty), admin
|
// the configured `adminApiToken`. When the token is unset (empty), admin
|
||||||
@@ -1223,7 +1255,12 @@ app.post<{ Body: GenerateBody }>(
|
|||||||
...(operatorSeed !== undefined ? { manualThemeSeed: operatorSeed } : {}),
|
...(operatorSeed !== undefined ? { manualThemeSeed: operatorSeed } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const opts: GenerateSpawnOpts = {};
|
// Wire the real providers (built once at module scope) into every call.
|
||||||
|
const opts: GenerateSpawnOpts = {
|
||||||
|
text: aiProviders.text,
|
||||||
|
image: aiProviders.image,
|
||||||
|
moderation: aiProviders.moderation,
|
||||||
|
};
|
||||||
if (typeof chaos === "number") opts.chaos = chaos;
|
if (typeof chaos === "number") opts.chaos = chaos;
|
||||||
if (operatorSeed !== undefined) opts.operatorSeed = operatorSeed;
|
if (operatorSeed !== undefined) opts.operatorSeed = operatorSeed;
|
||||||
|
|
||||||
@@ -1231,7 +1268,11 @@ app.post<{ Body: GenerateBody }>(
|
|||||||
try {
|
try {
|
||||||
result = await generateSpawn(input, opts);
|
result = await generateSpawn(input, opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
request.log.error({ err }, "Prometheus generation failed");
|
// SECURITY: log only the error MESSAGE server-side — never the opts (they
|
||||||
|
// carry provider keys), never the request body, and never echo any detail
|
||||||
|
// to the client (the response is a fixed 502 with no key material).
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
request.log.error({ detail }, "Prometheus generation failed");
|
||||||
return reply.code(502).send({ error: "generation failed" });
|
return reply.code(502).send({ error: "generation failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ module.exports = {
|
|||||||
cwd: `${REPO}/apps/api`,
|
cwd: `${REPO}/apps/api`,
|
||||||
script: "src/index.ts",
|
script: "src/index.ts",
|
||||||
interpreter: "node",
|
interpreter: "node",
|
||||||
interpreter_args: "--import tsx",
|
// Load secrets from the gitignored ~/pyre/.env (chmod 600) if present,
|
||||||
|
// then register tsx. Keys never live in this committed file.
|
||||||
|
interpreter_args: `--env-file-if-exists=${REPO}/.env --import tsx`,
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: "fork",
|
exec_mode: "fork",
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
@@ -67,7 +69,9 @@ module.exports = {
|
|||||||
cwd: `${REPO}/apps/worker`,
|
cwd: `${REPO}/apps/worker`,
|
||||||
script: "src/index.ts",
|
script: "src/index.ts",
|
||||||
interpreter: "node",
|
interpreter: "node",
|
||||||
interpreter_args: "--import tsx",
|
// Load secrets from the gitignored ~/pyre/.env (chmod 600) if present,
|
||||||
|
// then register tsx. Keys never live in this committed file.
|
||||||
|
interpreter_args: `--env-file-if-exists=${REPO}/.env --import tsx`,
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: "fork",
|
exec_mode: "fork",
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
<span class="overall-pct">74%</span>
|
<span class="overall-pct">74%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar"><span style="width: 74%"></span></div>
|
<div class="bar"><span style="width: 74%"></span></div>
|
||||||
<p class="count">39 of 53 phase deliverables complete</p>
|
<p class="count">40 of 54 phase deliverables complete</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 class="section">Development Phases</h2>
|
<h2 class="section">Development Phases</h2>
|
||||||
@@ -224,14 +224,15 @@
|
|||||||
<h3><span class="phase-id">Phase 4</span> Prometheus Generator</h3>
|
<h3><span class="phase-id">Phase 4</span> Prometheus Generator</h3>
|
||||||
<span class="badge in_progress">IN PROGRESS</span>
|
<span class="badge in_progress">IN PROGRESS</span>
|
||||||
</header>
|
</header>
|
||||||
<p class="count">4 / 6 complete</p>
|
<p class="count">5 / 7 complete</p>
|
||||||
<ul class="checklist">
|
<ul class="checklist">
|
||||||
<li class="item done"><span class="mark">✓</span><span>Meta mixer (deterministic influence model)</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Meta mixer (deterministic influence model)</span></li>
|
||||||
<li class="item done"><span class="mark">✓</span><span>Spawn name/ticker/lore generation (provider-abstracted)</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Spawn name/ticker/lore generation (provider-abstracted)</span></li>
|
||||||
<li class="item done"><span class="mark">✓</span><span>Image prompt generation</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Image prompt generation</span></li>
|
||||||
<li class="item done"><span class="mark">✓</span><span>Safety checks (denylist + moderation)</span></li>
|
<li class="item done"><span class="mark">✓</span><span>Safety checks (denylist + moderation)</span></li>
|
||||||
|
<li class="item done"><span class="mark">✓</span><span>Real AI providers wired (Gemini/Anthropic/OpenAI + Pollinations/fal/DeepInfra/Replicate) + secure key store</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Generation input from receipt</span></li>
|
<li class="item"><span class="mark">○</span><span>Generation input from receipt</span></li>
|
||||||
<li class="item"><span class="mark">○</span><span>Wire real providers (keys) + admin approval UI</span></li>
|
<li class="item"><span class="mark">○</span><span>Admin review & generate UI</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article class="card in_progress">
|
<article class="card in_progress">
|
||||||
|
|||||||
@@ -70,8 +70,9 @@
|
|||||||
{ "label": "Spawn name/ticker/lore generation (provider-abstracted)", "done": true },
|
{ "label": "Spawn name/ticker/lore generation (provider-abstracted)", "done": true },
|
||||||
{ "label": "Image prompt generation", "done": true },
|
{ "label": "Image prompt generation", "done": true },
|
||||||
{ "label": "Safety checks (denylist + moderation)", "done": true },
|
{ "label": "Safety checks (denylist + moderation)", "done": true },
|
||||||
|
{ "label": "Real AI providers wired (Gemini/Anthropic/OpenAI + Pollinations/fal/DeepInfra/Replicate) + secure key store", "done": true },
|
||||||
{ "label": "Generation input from receipt", "done": false },
|
{ "label": "Generation input from receipt", "done": false },
|
||||||
{ "label": "Wire real providers (keys) + admin approval UI", "done": false }
|
{ "label": "Admin review & generate UI", "done": false }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,6 +95,20 @@ export interface AppConfig {
|
|||||||
swapFeeBps: number;
|
swapFeeBps: number;
|
||||||
/** Upper bound on a user's optional extra "feed the PYRE" contribution (bps). */
|
/** Upper bound on a user's optional extra "feed the PYRE" contribution (bps). */
|
||||||
maxContributionBps: number;
|
maxContributionBps: number;
|
||||||
|
|
||||||
|
// ---- Prometheus AI providers (keys live only in the gitignored .env) ----
|
||||||
|
/** Text provider: "gemini" | "anthropic" | "openai" | "stub". */
|
||||||
|
prometheusTextProvider: string;
|
||||||
|
/** Image provider: "pollinations" | "fal" | "deepinfra" | "replicate" | "stub". */
|
||||||
|
prometheusImageProvider: string;
|
||||||
|
geminiApiKey: string;
|
||||||
|
anthropicApiKey: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
falKey: string;
|
||||||
|
deepinfraApiKey: string;
|
||||||
|
replicateApiToken: string;
|
||||||
|
/** Pinata JWT for IPFS upload of Spawn image + metadata. */
|
||||||
|
pinataJwt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A minimal env-shaped record. `process.env` satisfies this. */
|
/** A minimal env-shaped record. `process.env` satisfies this. */
|
||||||
@@ -162,5 +176,14 @@ export function loadConfig(env: EnvSource = process.env): AppConfig {
|
|||||||
feeBps: parseIntSafe(env.PYRE_FEE_BPS, 500),
|
feeBps: parseIntSafe(env.PYRE_FEE_BPS, 500),
|
||||||
swapFeeBps: parseIntSafe(env.PYRE_SWAP_FEE_BPS, 100),
|
swapFeeBps: parseIntSafe(env.PYRE_SWAP_FEE_BPS, 100),
|
||||||
maxContributionBps: parseIntSafe(env.PYRE_MAX_CONTRIBUTION_BPS, 5000),
|
maxContributionBps: parseIntSafe(env.PYRE_MAX_CONTRIBUTION_BPS, 5000),
|
||||||
|
prometheusTextProvider: str(env.PROMETHEUS_TEXT_PROVIDER, "stub"),
|
||||||
|
prometheusImageProvider: str(env.PROMETHEUS_IMAGE_PROVIDER, "stub"),
|
||||||
|
geminiApiKey: str(env.GEMINI_API_KEY, ""),
|
||||||
|
anthropicApiKey: str(env.ANTHROPIC_API_KEY, ""),
|
||||||
|
openaiApiKey: str(env.OPENAI_API_KEY, ""),
|
||||||
|
falKey: str(env.FAL_KEY, ""),
|
||||||
|
deepinfraApiKey: str(env.DEEPINFRA_API_KEY, ""),
|
||||||
|
replicateApiToken: str(env.REPLICATE_API_TOKEN, ""),
|
||||||
|
pinataJwt: str(env.PINATA_JWT, ""),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,27 @@ export {
|
|||||||
StubImageProvider,
|
StubImageProvider,
|
||||||
StubModerationProvider,
|
StubModerationProvider,
|
||||||
StubTextProvider,
|
StubTextProvider,
|
||||||
|
GeminiTextProvider,
|
||||||
|
AnthropicTextProvider,
|
||||||
|
OpenAiTextProvider,
|
||||||
|
PollinationsImageProvider,
|
||||||
|
FalImageProvider,
|
||||||
|
DeepInfraImageProvider,
|
||||||
|
ReplicateImageProvider,
|
||||||
|
OpenAiModerationProvider,
|
||||||
|
createProviders,
|
||||||
hashHex,
|
hashHex,
|
||||||
hashString,
|
hashString,
|
||||||
deriveInputSeed,
|
deriveInputSeed,
|
||||||
} from "./providers.js";
|
} from "./providers.js";
|
||||||
export type { ImageProvider, ModerationProvider, TextProvider } from "./providers.js";
|
export type {
|
||||||
|
ImageProvider,
|
||||||
|
ModerationProvider,
|
||||||
|
TextProvider,
|
||||||
|
ProviderKeys,
|
||||||
|
CreateProvidersOptions,
|
||||||
|
ProviderBundle,
|
||||||
|
} from "./providers.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Meta mixer
|
// Meta mixer
|
||||||
|
|||||||
203
packages/prometheus/src/providers.test.ts
Normal file
203
packages/prometheus/src/providers.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
GeminiTextProvider,
|
||||||
|
AnthropicTextProvider,
|
||||||
|
OpenAiTextProvider,
|
||||||
|
PollinationsImageProvider,
|
||||||
|
FalImageProvider,
|
||||||
|
DeepInfraImageProvider,
|
||||||
|
ReplicateImageProvider,
|
||||||
|
OpenAiModerationProvider,
|
||||||
|
StubTextProvider,
|
||||||
|
StubImageProvider,
|
||||||
|
StubModerationProvider,
|
||||||
|
createProviders,
|
||||||
|
} from "./index.js";
|
||||||
|
|
||||||
|
/** Build a minimal Response-like object for the mocked global fetch. */
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText: status === 200 ? "OK" : "ERR",
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GeminiTextProvider", () => {
|
||||||
|
it("returns the candidate text part (the JSON string)", async () => {
|
||||||
|
const payload = JSON.stringify({ name: "Ashen Ember", ticker: "AE" });
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
jsonResponse({ candidates: [{ content: { parts: [{ text: payload }] } }] }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const out = await new GeminiTextProvider("secret-key").generate("prompt");
|
||||||
|
expect(out).toBe(payload);
|
||||||
|
|
||||||
|
// Requests JSON mime-type and never logs/exposes the key beyond the URL query.
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const body = JSON.parse(String(init.body));
|
||||||
|
expect(body.generationConfig.responseMimeType).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a non-2xx response", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ error: "x" }, 429)));
|
||||||
|
await expect(new GeminiTextProvider("k").generate("p")).rejects.toThrow(/HTTP 429/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when there is no candidate text", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ candidates: [] })));
|
||||||
|
await expect(new GeminiTextProvider("k").generate("p")).rejects.toThrow(/no text candidate/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AnthropicTextProvider", () => {
|
||||||
|
it("concatenates text blocks and sets the version header", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: '{"name":"X"' },
|
||||||
|
{ type: "text", text: ',"ticker":"XX"}' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const out = await new AnthropicTextProvider("k").generate("p");
|
||||||
|
expect(out).toBe('{"name":"X","ticker":"XX"}');
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers["anthropic-version"]).toBe("2023-06-01");
|
||||||
|
expect(headers["x-api-key"]).toBe("k");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenAiTextProvider", () => {
|
||||||
|
it("returns the message content and requests a json_object", async () => {
|
||||||
|
const payload = JSON.stringify({ name: "Y", ticker: "YY" });
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(jsonResponse({ choices: [{ message: { content: payload } }] }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const out = await new OpenAiTextProvider("k").generate("p");
|
||||||
|
expect(out).toBe(payload);
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const body = JSON.parse(String(init.body));
|
||||||
|
expect(body.response_format).toEqual({ type: "json_object" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PollinationsImageProvider", () => {
|
||||||
|
it("returns a deterministic URL WITHOUT hitting the network by default", async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const res = await new PollinationsImageProvider().generate("a frog");
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(res.url).toBe(
|
||||||
|
"https://image.pollinations.ai/prompt/a%20frog?width=1024&height=1024&nologo=true&model=flux",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FalImageProvider", () => {
|
||||||
|
it("reads images[0].url", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(jsonResponse({ images: [{ url: "https://fal/img.png" }] })),
|
||||||
|
);
|
||||||
|
const res = await new FalImageProvider("k").generate("p");
|
||||||
|
expect(res.url).toBe("https://fal/img.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-2xx", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 500)));
|
||||||
|
await expect(new FalImageProvider("k").generate("p")).rejects.toThrow(/HTTP 500/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeepInfraImageProvider", () => {
|
||||||
|
it("prefers url, falls back to b64_json", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(jsonResponse({ data: [{ b64_json: "AAAA" }] })),
|
||||||
|
);
|
||||||
|
const res = await new DeepInfraImageProvider("k").generate("p");
|
||||||
|
expect(res.b64).toBe("AAAA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReplicateImageProvider", () => {
|
||||||
|
it("reads output[0] and sets the Prefer: wait header", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(jsonResponse({ output: ["https://replicate/img.png"] }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const res = await new ReplicateImageProvider("tok").generate("p");
|
||||||
|
expect(res.url).toBe("https://replicate/img.png");
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers["prefer"]).toBe("wait");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenAiModerationProvider", () => {
|
||||||
|
it("maps results[0] to flagged + truthy categories", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
results: [{ flagged: true, categories: { violence: true, hate: false } }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = await new OpenAiModerationProvider("k").check("text");
|
||||||
|
expect(res.flagged).toBe(true);
|
||||||
|
expect(res.categories).toEqual(["violence"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createProviders", () => {
|
||||||
|
it("falls back to Stubs when no keys/selection are present", () => {
|
||||||
|
const b = createProviders();
|
||||||
|
expect(b.text).toBeInstanceOf(StubTextProvider);
|
||||||
|
expect(b.image).toBeInstanceOf(StubImageProvider);
|
||||||
|
expect(b.moderation).toBeInstanceOf(StubModerationProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Stub text when the selected provider has no key", () => {
|
||||||
|
const b = createProviders({ textProvider: "gemini", keys: {} });
|
||||||
|
expect(b.text).toBeInstanceOf(StubTextProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the real provider when its key is present", () => {
|
||||||
|
const b = createProviders({
|
||||||
|
textProvider: "anthropic",
|
||||||
|
imageProvider: "fal",
|
||||||
|
keys: { anthropic: "a", fal: "f", openai: "o" },
|
||||||
|
});
|
||||||
|
expect(b.text).toBeInstanceOf(AnthropicTextProvider);
|
||||||
|
expect(b.image).toBeInstanceOf(FalImageProvider);
|
||||||
|
expect(b.moderation).toBeInstanceOf(OpenAiModerationProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pollinations image needs no key and is always available", () => {
|
||||||
|
const b = createProviders({ imageProvider: "pollinations" });
|
||||||
|
expect(b.image).toBeInstanceOf(PollinationsImageProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moderation falls back to Stub without an openai key", () => {
|
||||||
|
const b = createProviders({ keys: { gemini: "g" } });
|
||||||
|
expect(b.moderation).toBeInstanceOf(StubModerationProvider);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -153,6 +153,437 @@ export class StubModerationProvider implements ModerationProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real network providers
|
||||||
|
//
|
||||||
|
// These call provider REST APIs directly via the global `fetch` (NO SDK deps).
|
||||||
|
// They are DEFENSIVE: every request has a 15s timeout (AbortController) and
|
||||||
|
// throws a clear Error on timeout or non-2xx so the caller can fall back to a
|
||||||
|
// Stub. Keys are passed in by construction — never read from `process.env`
|
||||||
|
// here, and never logged (including inside error messages or response dumps).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Per-request network timeout for all real providers. */
|
||||||
|
const PROVIDER_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
/** Default max tokens for text completions that require an explicit cap. */
|
||||||
|
const DEFAULT_MAX_TOKENS = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fetch` wrapper with an AbortController-backed timeout. Throws a clear Error
|
||||||
|
* on timeout. Never includes secrets — callers must not pass keys in `label`.
|
||||||
|
*/
|
||||||
|
async function fetchWithTimeout(
|
||||||
|
label: string,
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
timeoutMs: number = PROVIDER_TIMEOUT_MS,
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
throw new Error(`${label}: request timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
// Surface a generic network failure without leaking request internals.
|
||||||
|
const reason = err instanceof Error ? err.message : "unknown error";
|
||||||
|
throw new Error(`${label}: network request failed (${reason})`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a short, safe snippet of an error response body for diagnostics. */
|
||||||
|
async function safeErrorSnippet(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = await res.text();
|
||||||
|
return body.slice(0, 200);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throw a clear Error for a non-2xx response; never logs/echoes secrets. */
|
||||||
|
async function ensureOk(label: string, res: Response): Promise<void> {
|
||||||
|
if (res.ok) return;
|
||||||
|
const snippet = await safeErrorSnippet(res);
|
||||||
|
throw new Error(
|
||||||
|
`${label}: HTTP ${res.status} ${res.statusText}${snippet ? ` — ${snippet}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a JSON response body, throwing a clear Error on malformed JSON. */
|
||||||
|
async function parseJson(label: string, res: Response): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label}: response was not valid JSON`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Text providers --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Gemini text provider (FREE tier). Requests JSON mime-type output and
|
||||||
|
* returns the first candidate's text part (a JSON string for the parser).
|
||||||
|
*/
|
||||||
|
export class GeminiTextProvider implements TextProvider {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string,
|
||||||
|
private readonly model: string = "gemini-2.0-flash",
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generate(prompt: string): Promise<string> {
|
||||||
|
const label = "GeminiTextProvider";
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${encodeURIComponent(
|
||||||
|
this.apiKey,
|
||||||
|
)}`;
|
||||||
|
const res = await fetchWithTimeout(label, url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { responseMimeType: "application/json" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await ensureOk(label, res);
|
||||||
|
const data = (await parseJson(label, res)) as {
|
||||||
|
candidates?: { content?: { parts?: { text?: unknown }[] } }[];
|
||||||
|
};
|
||||||
|
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
|
if (typeof text !== "string" || text.length === 0) {
|
||||||
|
throw new Error(`${label}: no text candidate in response`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anthropic Claude text provider (Messages API). Returns the concatenated text
|
||||||
|
* of all text blocks in the response content.
|
||||||
|
*/
|
||||||
|
export class AnthropicTextProvider implements TextProvider {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string,
|
||||||
|
private readonly model: string = "claude-haiku-4-5",
|
||||||
|
private readonly maxTokens: number = DEFAULT_MAX_TOKENS,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generate(prompt: string): Promise<string> {
|
||||||
|
const label = "AnthropicTextProvider";
|
||||||
|
const res = await fetchWithTimeout(label, "https://api.anthropic.com/v1/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-api-key": this.apiKey,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: this.maxTokens,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await ensureOk(label, res);
|
||||||
|
const data = (await parseJson(label, res)) as {
|
||||||
|
content?: { type?: string; text?: unknown }[];
|
||||||
|
};
|
||||||
|
const text = (data.content ?? [])
|
||||||
|
.filter((b) => b.type === "text" && typeof b.text === "string")
|
||||||
|
.map((b) => b.text as string)
|
||||||
|
.join("");
|
||||||
|
if (text.length === 0) {
|
||||||
|
throw new Error(`${label}: no text block in response`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI chat-completions text provider. Requests a JSON object response and
|
||||||
|
* returns the first choice's message content (a JSON string for the parser).
|
||||||
|
*/
|
||||||
|
export class OpenAiTextProvider implements TextProvider {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string,
|
||||||
|
private readonly model: string = "gpt-4o-mini",
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generate(prompt: string): Promise<string> {
|
||||||
|
const label = "OpenAiTextProvider";
|
||||||
|
const res = await fetchWithTimeout(label, "https://api.openai.com/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await ensureOk(label, res);
|
||||||
|
const data = (await parseJson(label, res)) as {
|
||||||
|
choices?: { message?: { content?: unknown } }[];
|
||||||
|
};
|
||||||
|
const text = data.choices?.[0]?.message?.content;
|
||||||
|
if (typeof text !== "string" || text.length === 0) {
|
||||||
|
throw new Error(`${label}: no message content in response`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image providers -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pollinations image provider (FREE, KEYLESS). Pollinations is a GET-a-URL
|
||||||
|
* service: the returned URL renders the image on access. Optionally HEAD-checks
|
||||||
|
* reachability; on failure it still throws so the caller can fall back.
|
||||||
|
*/
|
||||||
|
export class PollinationsImageProvider implements ImageProvider {
|
||||||
|
constructor(private readonly headCheck: boolean = false) {}
|
||||||
|
|
||||||
|
async generate(prompt: string): Promise<{ url?: string; b64?: string }> {
|
||||||
|
const label = "PollinationsImageProvider";
|
||||||
|
const url = `https://image.pollinations.ai/prompt/${encodeURIComponent(
|
||||||
|
prompt,
|
||||||
|
)}?width=1024&height=1024&nologo=true&model=flux`;
|
||||||
|
if (this.headCheck) {
|
||||||
|
const res = await fetchWithTimeout(label, url, { method: "HEAD" });
|
||||||
|
await ensureOk(label, res);
|
||||||
|
}
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fal.ai FLUX.1 [schnell] image provider. Returns the first generated image's
|
||||||
|
* URL.
|
||||||
|
*/
|
||||||
|
export class FalImageProvider implements ImageProvider {
|
||||||
|
constructor(private readonly key: string) {}
|
||||||
|
|
||||||
|
async generate(prompt: string): Promise<{ url?: string; b64?: string }> {
|
||||||
|
const label = "FalImageProvider";
|
||||||
|
const res = await fetchWithTimeout(label, "https://fal.run/fal-ai/flux/schnell", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Key ${this.key}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ prompt, image_size: "square_hd", num_images: 1 }),
|
||||||
|
});
|
||||||
|
await ensureOk(label, res);
|
||||||
|
const data = (await parseJson(label, res)) as { images?: { url?: unknown }[] };
|
||||||
|
const url = data.images?.[0]?.url;
|
||||||
|
if (typeof url !== "string" || url.length === 0) {
|
||||||
|
throw new Error(`${label}: no image url in response`);
|
||||||
|
}
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeepInfra image provider (OpenAI-compatible images API) running FLUX.1
|
||||||
|
* [schnell]. Returns a URL or base64 payload depending on what the API yields.
|
||||||
|
*/
|
||||||
|
export class DeepInfraImageProvider implements ImageProvider {
|
||||||
|
constructor(
|
||||||
|
private readonly key: string,
|
||||||
|
private readonly model: string = "black-forest-labs/FLUX-1-schnell",
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generate(prompt: string): Promise<{ url?: string; b64?: string }> {
|
||||||
|
const label = "DeepInfraImageProvider";
|
||||||
|
const res = await fetchWithTimeout(
|
||||||
|
label,
|
||||||
|
"https://api.deepinfra.com/v1/openai/images/generations",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${this.key}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model: this.model, prompt, size: "1024x1024" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ensureOk(label, res);
|
||||||
|
const data = (await parseJson(label, res)) as {
|
||||||
|
data?: { url?: unknown; b64_json?: unknown }[];
|
||||||
|
};
|
||||||
|
const first = data.data?.[0];
|
||||||
|
if (first && typeof first.url === "string" && first.url.length > 0) {
|
||||||
|
return { url: first.url };
|
||||||
|
}
|
||||||
|
if (first && typeof first.b64_json === "string" && first.b64_json.length > 0) {
|
||||||
|
return { b64: first.b64_json };
|
||||||
|
}
|
||||||
|
throw new Error(`${label}: no image url or b64 in response`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicate image provider running `black-forest-labs/flux-schnell`. Uses the
|
||||||
|
* `Prefer: wait` header so the prediction resolves synchronously; returns the
|
||||||
|
* first output URL.
|
||||||
|
*/
|
||||||
|
export class ReplicateImageProvider implements ImageProvider {
|
||||||
|
constructor(private readonly token: string) {}
|
||||||
|
|
||||||
|
async generate(prompt: string): Promise<{ url?: string; b64?: string }> {
|
||||||
|
const label = "ReplicateImageProvider";
|
||||||
|
const res = await fetchWithTimeout(
|
||||||
|
label,
|
||||||
|
"https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${this.token}`,
|
||||||
|
prefer: "wait",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: {
|
||||||
|
prompt,
|
||||||
|
aspect_ratio: "1:1",
|
||||||
|
num_inference_steps: 4,
|
||||||
|
output_format: "png",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ensureOk(label, res);
|
||||||
|
const data = (await parseJson(label, res)) as { output?: unknown };
|
||||||
|
const output = data.output;
|
||||||
|
const url = Array.isArray(output) ? output[0] : output;
|
||||||
|
if (typeof url !== "string" || url.length === 0) {
|
||||||
|
throw new Error(`${label}: no output url in response`);
|
||||||
|
}
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Moderation provider ---------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI moderation provider (FREE) using `omni-moderation-latest`. Maps the
|
||||||
|
* first result to `{ flagged, categories }` (the truthy category keys).
|
||||||
|
*/
|
||||||
|
export class OpenAiModerationProvider implements ModerationProvider {
|
||||||
|
constructor(private readonly apiKey: string) {}
|
||||||
|
|
||||||
|
async check(text: string): Promise<{ flagged: boolean; categories: string[] }> {
|
||||||
|
const label = "OpenAiModerationProvider";
|
||||||
|
const res = await fetchWithTimeout(label, "https://api.openai.com/v1/moderations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model: "omni-moderation-latest", input: text }),
|
||||||
|
});
|
||||||
|
await ensureOk(label, res);
|
||||||
|
const data = (await parseJson(label, res)) as {
|
||||||
|
results?: { flagged?: unknown; categories?: Record<string, unknown> }[];
|
||||||
|
};
|
||||||
|
const result = data.results?.[0];
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`${label}: no result in response`);
|
||||||
|
}
|
||||||
|
const categories = Object.entries(result.categories ?? {})
|
||||||
|
.filter(([, v]) => v === true)
|
||||||
|
.map(([k]) => k);
|
||||||
|
return { flagged: result.flagged === true, categories };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** API keys for the real providers. All optional; absent → Stub fallback. */
|
||||||
|
export interface ProviderKeys {
|
||||||
|
gemini?: string;
|
||||||
|
anthropic?: string;
|
||||||
|
openai?: string;
|
||||||
|
fal?: string;
|
||||||
|
deepinfra?: string;
|
||||||
|
replicate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for {@link createProviders}. Decoupled from `@pyre/config`. */
|
||||||
|
export interface CreateProvidersOptions {
|
||||||
|
/** Preferred text provider: "gemini" | "anthropic" | "openai". */
|
||||||
|
textProvider?: string;
|
||||||
|
/** Preferred image provider: "pollinations" | "fal" | "deepinfra" | "replicate". */
|
||||||
|
imageProvider?: string;
|
||||||
|
keys?: ProviderKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The provider bundle the engine consumes. */
|
||||||
|
export interface ProviderBundle {
|
||||||
|
text: TextProvider;
|
||||||
|
image: ImageProvider;
|
||||||
|
moderation: ModerationProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a provider bundle from a plain options object. Selects the requested
|
||||||
|
* real provider IF its key is present (Pollinations needs none), otherwise
|
||||||
|
* falls back to the deterministic Stub so the engine always runs offline.
|
||||||
|
*
|
||||||
|
* - text: `textProvider` ("gemini"/"anthropic"/"openai") with matching key, else Stub.
|
||||||
|
* - image: `imageProvider`; "pollinations" is always available; "fal"/"deepinfra"/
|
||||||
|
* "replicate" require their key, else Stub.
|
||||||
|
* - moderation: OpenAI if `keys.openai` is present, else Stub.
|
||||||
|
*/
|
||||||
|
export function createProviders(opts: CreateProvidersOptions = {}): ProviderBundle {
|
||||||
|
const keys = opts.keys ?? {};
|
||||||
|
|
||||||
|
let text: TextProvider = new StubTextProvider();
|
||||||
|
switch (opts.textProvider) {
|
||||||
|
case "gemini":
|
||||||
|
if (keys.gemini) text = new GeminiTextProvider(keys.gemini);
|
||||||
|
break;
|
||||||
|
case "anthropic":
|
||||||
|
if (keys.anthropic) text = new AnthropicTextProvider(keys.anthropic);
|
||||||
|
break;
|
||||||
|
case "openai":
|
||||||
|
if (keys.openai) text = new OpenAiTextProvider(keys.openai);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let image: ImageProvider = new StubImageProvider();
|
||||||
|
switch (opts.imageProvider) {
|
||||||
|
case "pollinations":
|
||||||
|
image = new PollinationsImageProvider();
|
||||||
|
break;
|
||||||
|
case "fal":
|
||||||
|
if (keys.fal) image = new FalImageProvider(keys.fal);
|
||||||
|
break;
|
||||||
|
case "deepinfra":
|
||||||
|
if (keys.deepinfra) image = new DeepInfraImageProvider(keys.deepinfra);
|
||||||
|
break;
|
||||||
|
case "replicate":
|
||||||
|
if (keys.replicate) image = new ReplicateImageProvider(keys.replicate);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderation: ModerationProvider = keys.openai
|
||||||
|
? new OpenAiModerationProvider(keys.openai)
|
||||||
|
: new StubModerationProvider();
|
||||||
|
|
||||||
|
return { text, image, moderation };
|
||||||
|
}
|
||||||
|
|
||||||
/** Convenience: derive a deterministic numeric seed from a Prometheus input. */
|
/** Convenience: derive a deterministic numeric seed from a Prometheus input. */
|
||||||
export function deriveInputSeed(input: PrometheusInput, operatorSeed?: string): number {
|
export function deriveInputSeed(input: PrometheusInput, operatorSeed?: string): number {
|
||||||
const parts = [
|
const parts = [
|
||||||
|
|||||||
Reference in New Issue
Block a user