From 6ab0f02d0611cc9a5833f88c60825aaf636476b6 Mon Sep 17 00:00:00 2001 From: RogueWave Date: Sun, 31 May 2026 07:23:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(prometheus):=20real=20providers=20(Gemini/?= =?UTF-8?q?fal/Pollinations=E2=80=A6)=20+=20secure=20key=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .env.example | 17 +- apps/api/src/index.ts | 49 ++- ecosystem.config.cjs | 8 +- infra/status/index.html | 7 +- infra/status/status.json | 3 +- packages/config/src/index.ts | 23 ++ packages/prometheus/src/index.ts | 18 +- packages/prometheus/src/providers.test.ts | 203 ++++++++++ packages/prometheus/src/providers.ts | 431 ++++++++++++++++++++++ 9 files changed, 745 insertions(+), 14 deletions(-) create mode 100644 packages/prometheus/src/providers.test.ts diff --git a/.env.example b/.env.example index e41f39d..f5ab209 100644 --- a/.env.example +++ b/.env.example @@ -22,10 +22,21 @@ REDIS_URL=redis://localhost:6379 # ---- AI services (Prometheus) ---------------------------------------------- # 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= -OPENAI_API_KEY= -IMAGE_GEN_PROVIDER= # e.g. openai | stability | replicate -IMAGE_GEN_API_KEY= +OPENAI_API_KEY= # also enables the free omni-moderation pass +FAL_KEY= # fal.ai (FLUX schnell ~$0.003/img) +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 ------------------------------------------------------ WEB_PORT=3000 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4aa06c3..1b12c52 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -65,22 +65,54 @@ import type { // Prometheus owns generation (built in parallel). We code to its published // signature: `generateSpawn(input, opts?): Promise`. // 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"; +/** The provider bundle returned by `@pyre/prometheus`'s `createProviders`. */ +type AiProviders = ReturnType; + /** * 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 { /** Chaos factor 0..1 controlling mutation strength. */ chaos?: number; /** Optional manual operator theme seed. */ operatorSeed?: string; + /** Text provider override. */ + text?: AiProviders["text"]; + /** Image provider override. */ + image?: AiProviders["image"]; + /** Moderation provider override. */ + moderation?: AiProviders["moderation"]; } 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 // the configured `adminApiToken`. When the token is unset (empty), admin @@ -1223,7 +1255,12 @@ app.post<{ Body: GenerateBody }>( ...(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 (operatorSeed !== undefined) opts.operatorSeed = operatorSeed; @@ -1231,7 +1268,11 @@ app.post<{ Body: GenerateBody }>( try { result = await generateSpawn(input, opts); } 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" }); } diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 415f1fe..1ab1ecc 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -43,7 +43,9 @@ module.exports = { cwd: `${REPO}/apps/api`, script: "src/index.ts", 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, exec_mode: "fork", autorestart: true, @@ -67,7 +69,9 @@ module.exports = { cwd: `${REPO}/apps/worker`, script: "src/index.ts", 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, exec_mode: "fork", autorestart: true, diff --git a/infra/status/index.html b/infra/status/index.html index ebcf6fa..0d84595 100644 --- a/infra/status/index.html +++ b/infra/status/index.html @@ -150,7 +150,7 @@ 74%
-

39 of 53 phase deliverables complete

+

40 of 54 phase deliverables complete

Development Phases

@@ -224,14 +224,15 @@

Phase 4 Prometheus Generator

IN PROGRESS -

4 / 6 complete

+

5 / 7 complete

  • Meta mixer (deterministic influence model)
  • Spawn name/ticker/lore generation (provider-abstracted)
  • Image prompt generation
  • Safety checks (denylist + moderation)
  • +
  • Real AI providers wired (Gemini/Anthropic/OpenAI + Pollinations/fal/DeepInfra/Replicate) + secure key store
  • Generation input from receipt
  • -
  • Wire real providers (keys) + admin approval UI
  • +
  • Admin review & generate UI
diff --git a/infra/status/status.json b/infra/status/status.json index 86b210e..5be6c93 100644 --- a/infra/status/status.json +++ b/infra/status/status.json @@ -70,8 +70,9 @@ { "label": "Spawn name/ticker/lore generation (provider-abstracted)", "done": true }, { "label": "Image prompt generation", "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": "Wire real providers (keys) + admin approval UI", "done": false } + { "label": "Admin review & generate UI", "done": false } ] }, { diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e1a2428..a1ecbaf 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -95,6 +95,20 @@ export interface AppConfig { swapFeeBps: number; /** Upper bound on a user's optional extra "feed the PYRE" contribution (bps). */ 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. */ @@ -162,5 +176,14 @@ export function loadConfig(env: EnvSource = process.env): AppConfig { feeBps: parseIntSafe(env.PYRE_FEE_BPS, 500), swapFeeBps: parseIntSafe(env.PYRE_SWAP_FEE_BPS, 100), 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, ""), }; } diff --git a/packages/prometheus/src/index.ts b/packages/prometheus/src/index.ts index e636383..32872e6 100644 --- a/packages/prometheus/src/index.ts +++ b/packages/prometheus/src/index.ts @@ -34,11 +34,27 @@ export { StubImageProvider, StubModerationProvider, StubTextProvider, + GeminiTextProvider, + AnthropicTextProvider, + OpenAiTextProvider, + PollinationsImageProvider, + FalImageProvider, + DeepInfraImageProvider, + ReplicateImageProvider, + OpenAiModerationProvider, + createProviders, hashHex, hashString, deriveInputSeed, } from "./providers.js"; -export type { ImageProvider, ModerationProvider, TextProvider } from "./providers.js"; +export type { + ImageProvider, + ModerationProvider, + TextProvider, + ProviderKeys, + CreateProvidersOptions, + ProviderBundle, +} from "./providers.js"; // --------------------------------------------------------------------------- // Meta mixer diff --git a/packages/prometheus/src/providers.test.ts b/packages/prometheus/src/providers.test.ts new file mode 100644 index 0000000..ac5f55a --- /dev/null +++ b/packages/prometheus/src/providers.test.ts @@ -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; + 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; + 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); + }); +}); diff --git a/packages/prometheus/src/providers.ts b/packages/prometheus/src/providers.ts index 809690d..258442e 100644 --- a/packages/prometheus/src/providers.ts +++ b/packages/prometheus/src/providers.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }[]; + }; + 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. */ export function deriveInputSeed(input: PrometheusInput, operatorSeed?: string): number { const parts = [