Files
infiplot-web/lib/config.ts
T
Zonghao Yuan 0e4c2ebef4 feat(engine): merge cloudflare-migration — paradigm D engine, BYOK proxy, story persistence (#95)
Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into
staging with conflict resolution, feature integration, and bug fixes.

Engine:
- Paradigm D: single-stream Writer replacing dual-phase Plan/Beats
- Delete Architect agent; story bible generated via Writer <plan> tag
- Modular prompt architecture (segments/registry/builder)
- StreamRouter for tagged stream splitting (<plan>/<story>/<choices>)

Infrastructure:
- Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter)
- D1 database schema + Drizzle ORM (scaffolded, not yet active)
- R2 storage helpers (scaffolded, not yet active)
- Story persistence API routes + client-side persistence

BYOK (Bring Your Own Key):
- /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth)
- CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to
  server proxy transparently via OpenAI SDK custom fetch
- BYO config support added to classify-freeform and vision routes
- SettingsModal CORS privacy notice (keys never logged/stored)

SSE streaming:
- engineClient.ts: fetchSSE helper for progressive scene events
- startSession/requestScene accept optional emit callback
- Fix SSE error event field name (error → message) in scene/start routes

i18n integration:
- Wire buildLanguageDirective into paradigm D's prompt builder
- Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text
- Preserve Session.language + LanguageSwitcher from i18n commit

Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-18 18:05:38 +08:00

213 lines
7.3 KiB
TypeScript

import "server-only";
import type {
ByoLlmKeys,
EngineConfig,
ProviderConfig,
ProviderProtocol,
TtsConfig,
} from "@infiplot/types";
import { validateUpstreamUrl, normalizeBaseUrl } from "./byoProxy";
const VALID_PROTOCOLS = [
"openai_compatible",
"openai",
"runware",
] as const;
function readVar(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required environment variable: ${name}`);
return v;
}
function readOptionalVar(name: string): string | undefined {
const v = process.env[name];
return v && v.length > 0 ? v : undefined;
}
// Invalid/non-positive values are treated as unset (feature stays off) rather
// than failing boot — these knobs are tuning aids, not required config.
function readOptionalPositiveInt(name: string): number | undefined {
const v = readOptionalVar(name);
if (!v) return undefined;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
}
// Optional *_PROVIDER selector. Unset → undefined, and each ai-client adapter
// applies its own default (text/vision → openai_compatible; image → inferred
// from the base URL). Validated eagerly so a typo fails fast at boot rather
// than mid-request.
function readProvider(name: string): ProviderProtocol | undefined {
const v = readOptionalVar(name)?.trim().toLowerCase();
if (!v) return undefined;
if ((VALID_PROTOCOLS as readonly string[]).includes(v)) {
return v as ProviderProtocol;
}
// anthropic/google were removed with the Vercel AI SDK — nudge users who
// still set them toward the OpenAI-compatible endpoints (see .env.example).
const hint =
v === "anthropic" || v === "google"
? ` — use openai_compatible with their OpenAI-compatible endpoint instead`
: "";
throw new Error(
`Invalid ${name}: "${v}". Must be one of: ${VALID_PROTOCOLS.join(", ")}${hint}`,
);
}
function loadTtsConfig(): TtsConfig | undefined {
const baseUrl = readOptionalVar("TTS_BASE_URL");
const apiKey = readOptionalVar("TTS_API_KEY");
const speechModel = readOptionalVar("TTS_SPEECH_MODEL");
// Missing any → TTS disabled (game runs silently).
if (!baseUrl || !apiKey || !speechModel) return undefined;
return { baseUrl, apiKey, speechModel };
}
export function loadEngineConfig(): EngineConfig {
return {
text: {
baseUrl: readVar("TEXT_BASE_URL"),
apiKey: readVar("TEXT_API_KEY"),
model: readVar("TEXT_MODEL"),
provider: readProvider("TEXT_PROVIDER"),
},
image: {
baseUrl: readVar("IMAGE_BASE_URL"),
apiKey: readVar("IMAGE_API_KEY"),
model: readVar("IMAGE_MODEL"),
provider: readProvider("IMAGE_PROVIDER"),
},
vision: {
baseUrl: readVar("VISION_BASE_URL"),
apiKey: readVar("VISION_API_KEY"),
model: readVar("VISION_MODEL"),
provider: readProvider("VISION_PROVIDER"),
},
tts: loadTtsConfig(),
mockImage: readOptionalVar("MOCK_IMAGE") === "true",
imageTimeoutMs: readOptionalPositiveInt("IMAGE_TIMEOUT_MS"),
imageHedgeMs: readOptionalPositiveInt("IMAGE_HEDGE_MS"),
};
}
// ── BYOK (Bring Your Own Key) ────────────────────────────────────────────
/** Provider default base URLs when user doesn't specify one. */
const PROVIDER_DEFAULTS: Record<string, string> = {
openai: "https://api.openai.com",
claude: "https://api.anthropic.com",
gemini: "https://generativelanguage.googleapis.com",
};
/** Provider default models when user doesn't specify one. */
const MODEL_DEFAULTS: Record<string, { text: string; image: string; vision: string }> = {
openai: {
text: "gpt-4o",
image: "gpt-image-1", // CR-4: 支持任意尺寸,dall-e-3 不支持 1536x1024
vision: "gpt-4o",
},
claude: {
text: "claude-3-5-sonnet-20241022",
image: "claude-3-5-sonnet-20241022", // Claude doesn't have native image gen
vision: "claude-3-5-sonnet-20241022",
},
gemini: {
text: "gemini-2.0-flash-exp",
image: "imagen-3.0-generate-001",
vision: "gemini-2.0-flash-exp",
},
};
type ByoRole = "text" | "image" | "vision";
type ByoProviderConfig = { provider: string; apiKey: string; baseUrl?: string; model?: string };
/**
* Build ProviderConfig from user-supplied BYOK credentials.
* Validates upstream URL (SSRF protection), normalizes baseUrl, applies defaults.
* Throws on validation failure so API route can return 400.
*/
function buildByoProviderConfig(
role: ByoRole,
byo: ByoProviderConfig,
fallback: ProviderConfig,
): ProviderConfig {
const { provider, apiKey, baseUrl } = byo;
// Validate provider
if (!["openai", "claude", "gemini"].includes(provider)) {
throw new Error(`Invalid BYO provider for ${role}: ${provider}`);
}
// Claude/Gemini cannot generate images — only OpenAI supports image generation
if (role === "image" && provider !== "openai") {
throw new Error(
`BYO provider "${provider}" does not support image generation. Use "openai" for the image role.`,
);
}
// Validate apiKey
if (!apiKey?.trim()) {
throw new Error(`Missing BYO apiKey for ${role}`);
}
// Resolve baseUrl (user-provided or provider default)
let resolvedBaseUrl = baseUrl?.trim() || PROVIDER_DEFAULTS[provider];
if (!resolvedBaseUrl) {
throw new Error(`No baseUrl for BYO ${role} provider: ${provider}`);
}
resolvedBaseUrl = normalizeBaseUrl(resolvedBaseUrl);
// SSRF protection — validates the HOST portion of the URL.
// SAFETY INVARIANT: ai-client/normalizeUrl.ts only appends PATH segments
// (e.g. /v1) but never changes the host/authority. If that invariant ever
// breaks, this check must be moved downstream or duplicated. (CR-9)
const validation = validateUpstreamUrl(resolvedBaseUrl);
if (!validation.valid) {
throw new Error(`Invalid BYO baseUrl for ${role}: ${validation.error}`);
}
// Resolve model (user-provided > provider default > official model)
const modelDefaults = MODEL_DEFAULTS[provider];
const model = byo.model?.trim() || modelDefaults?.[role] || fallback.model;
// All providers are reached via their OpenAI-compatible endpoints.
const providerProtocol: ProviderProtocol =
provider === "openai" ? "openai" : "openai_compatible";
return {
baseUrl: resolvedBaseUrl,
apiKey: apiKey.trim(),
model,
provider: providerProtocol,
};
}
/**
* Build EngineConfig with BYOK (Bring Your Own Key) overrides.
* - `byo` param contains user-provided keys from request body (StartRequest.byo / SceneRequest.byo)
* - For each role (text/image/vision), if user provided BYO config, use it; otherwise fallback to official keys
* - Validates all BYO baseUrls (SSRF protection) and throws on failure
*/
export function buildByoEngineConfig(
byo: ByoLlmKeys,
officialConfig: EngineConfig,
): EngineConfig {
return {
text: byo.text
? buildByoProviderConfig("text", byo.text, officialConfig.text)
: officialConfig.text,
image: byo.image
? buildByoProviderConfig("image", byo.image, officialConfig.image)
: officialConfig.image,
vision: byo.vision
? buildByoProviderConfig("vision", byo.vision, officialConfig.vision)
: officialConfig.vision,
tts: officialConfig.tts, // TTS BYOK stays client-side only (existing flow)
mockImage: officialConfig.mockImage,
};
}