0e4c2ebef4
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>
213 lines
7.3 KiB
TypeScript
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,
|
|
};
|
|
}
|