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:
2026-05-31 07:23:18 +00:00
parent 8b58faf7c1
commit 6ab0f02d06
9 changed files with 745 additions and 14 deletions

View File

@@ -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, ""),
};
}

View File

@@ -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

View 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);
});
});

View File

@@ -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. */
export function deriveInputSeed(input: PrometheusInput, operatorSeed?: string): number {
const parts = [