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:
@@ -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, ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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. */
|
||||
export function deriveInputSeed(input: PrometheusInput, operatorSeed?: string): number {
|
||||
const parts = [
|
||||
|
||||
Reference in New Issue
Block a user