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

@@ -65,22 +65,54 @@ import type {
// Prometheus owns generation (built in parallel). We code to its published
// signature: `generateSpawn(input, opts?): Promise<PrometheusGenerateResponse>`.
// 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<typeof createProviders>;
/**
* 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" });
}