From 882a1f088ec48602f860a21375457ec808884fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 02:46:48 +0000 Subject: [PATCH] fix(ai-gateway): resolve env config properly; add /api/admin/ai-gateway-probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #21 added a model fallback chain inside generateDraft, but uploads still fail. Tracing the code: every model in the chain goes through getGatewayModel() — and that helper hard-coded the Lovable gateway URL and only read LOVABLE_API_KEY. The deployment is on the generic OpenAI-compatible gateway: .env.example documents AI_GATEWAY_BASE_URL + AI_GATEWAY_API_KEY + AI_GATEWAY_MODEL, not LOVABLE_API_KEY. So getGatewayModel threw "LOVABLE_API_KEY missing" 500 BEFORE the fallback chain ever called the model — the fallback chain just retried the same throw, four times. This change: * getGatewayModel now resolves the gateway from env in priority order: AI_GATEWAY_API_KEY (+ AI_GATEWAY_BASE_URL) > LOVABLE_API_KEY (legacy Lovable URL) > OPENAI_API_KEY (direct OpenAI). The unresolved-config error names every env var so the operator sees the cause immediately. * Auth-header style adapts to the gateway: bearer for the generic / OpenAI paths, Lovable-API-Key header for Lovable. * New `getGatewayModel("default")` resolves to AI_GATEWAY_MODEL env (falls back to google/gemini-2.5-flash). The author fallback chain now tries `default` FIRST so a well-configured deployment doesn't pay the latency of a doomed initial attempt. Removes the dead "google/gemini-3-flash-preview" entry; adds gpt-4o-mini and gpt-4o so the chain still recovers when only an OpenAI gateway is available. * describeGatewayConfig() — diagnostic helper returning which env supplied the key + base URL + default model. Logged once per author attempt so a future "what's wrong" investigation is one log-grep away. * New /api/admin/ai-gateway-probe (admin only) returns the resolved config AND runs a 1-token round-trip against the default model, reporting latency or the provider's error verbatim. This is the thing I wanted to run from a browser tab when uploads broke. https://claude.ai/code/session_019gMoupKKTVydpNwiiACQRd --- src/lib/admin/author.server.ts | 40 +++++-- src/lib/ai-gateway.ts | 139 ++++++++++++++++++++++- src/routeTree.gen.ts | 21 ++++ src/routes/api/admin/ai-gateway-probe.ts | 83 ++++++++++++++ 4 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 src/routes/api/admin/ai-gateway-probe.ts diff --git a/src/lib/admin/author.server.ts b/src/lib/admin/author.server.ts index 8c598f11..33a3666f 100644 --- a/src/lib/admin/author.server.ts +++ b/src/lib/admin/author.server.ts @@ -1,5 +1,5 @@ import { generateText, Output } from "ai"; -import { getGatewayModel } from "@/lib/ai-gateway"; +import { getGatewayModel, describeGatewayConfig } from "@/lib/ai-gateway"; import { PackageDraftSchema } from "@/lib/skills/schemas"; const META_SYSTEM = `You are SkillForge Author, a proprietary meta-agent that designs production-grade agent packages. @@ -13,15 +13,23 @@ Type semantics: skill = capability; playbook = multi-step decision flow; soul = Slug must be lowercase-kebab.`; // Author model fallback chain. The structured-output path (Output.object → -// tool/function calling) is sensitive to provider/model quirks, so we try a -// short, ordered list before giving up. First success wins. Adding a model -// here is cheap; do NOT silently swallow failures — every attempt logs the -// model + the error so the cause is visible in the server logs. +// tool/function calling) is sensitive to provider/model quirks, so we try +// an ordered list before giving up. First success wins. +// +// The list intentionally mixes Google + OpenAI ids because production has +// shifted gateways: deployments running the Lovable gateway respond to +// google/* ids; deployments on a plain OpenAI-compatible gateway only +// understand openai/* (and reject the Google ones with a 400). Including +// both means at least one survives whichever gateway is configured. +// +// "default" is the env-configured AI_GATEWAY_MODEL — tried FIRST so a +// well-configured deployment doesn't pay the latency of a doomed attempt. const AUTHOR_MODEL_FALLBACKS = [ - "google/gemini-3-flash-preview", + "default", "google/gemini-2.5-flash", "google/gemini-2.5-pro", - "openai/gpt-5.1-mini", + "openai/gpt-4o-mini", + "openai/gpt-4o", ] as const; export async function generateDraft( @@ -35,7 +43,23 @@ export async function generateDraft( }\n\nDesign a complete, production-ready ${type} package. Return ONLY the JSON.`; const attempts: Array<{ model: string; error: string }> = []; - for (const modelId of AUTHOR_MODEL_FALLBACKS) { + // Log the gateway config once per request so the cause of a total + // failure (e.g. "no AI gateway configured") is clear in the logs. + const cfg = describeGatewayConfig(); + if (!cfg.configured) { + console.error("[skillforge.author] no AI gateway configured:", cfg); + throw new Error( + "SkillForge author cannot run: no AI gateway configured. Set AI_GATEWAY_API_KEY (or LOVABLE_API_KEY / OPENAI_API_KEY) in the server env.", + ); + } + // De-dup: if `default` resolves to one of the explicit ids below, drop + // the duplicate so we don't pay double latency on the doomed second try. + const explicit = AUTHOR_MODEL_FALLBACKS.filter((m) => m !== "default"); + const ordered = + cfg.defaultModel && explicit.includes(cfg.defaultModel as never) + ? explicit + : (["default", ...explicit] as readonly string[]); + for (const modelId of ordered) { try { const model = getGatewayModel(modelId); const { experimental_output } = await generateText({ diff --git a/src/lib/ai-gateway.ts b/src/lib/ai-gateway.ts index 1cc6aa72..9e2e1896 100644 --- a/src/lib/ai-gateway.ts +++ b/src/lib/ai-gateway.ts @@ -1,5 +1,96 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +/** + * AI Gateway resolution. + * + * Production deployments have shifted between two configurations: + * + * A. Lovable AI gateway (legacy): LOVABLE_API_KEY set, model ids like + * "google/gemini-3-flash-preview", base URL https://ai.gateway.lovable.dev/v1. + * B. Generic OpenAI-compatible gateway (current .env.example): + * AI_GATEWAY_API_KEY + AI_GATEWAY_BASE_URL set, default model + * "openai/gpt-4o-mini". + * + * Up until now this helper hard-coded the Lovable URL and only read + * LOVABLE_API_KEY — so a deployment running config (B) had every + * generateText() call die with "LOVABLE_API_KEY missing" BEFORE the + * author fallback chain ever got a chance to try its alternate models. + * That's why uploads silently failed across the site even after PR #21 + * added model fallbacks: the gateway helper itself never returned a + * usable model. + * + * Now we resolve in this priority: + * + * 1. AI_GATEWAY_API_KEY → generic gateway, base URL from + * AI_GATEWAY_BASE_URL (default OpenAI v1). + * 2. LOVABLE_API_KEY → Lovable gateway (legacy). + * 3. OPENAI_API_KEY → direct OpenAI. + * + * The error thrown when nothing is configured names the env vars so an + * operator sees the cause immediately in logs / Vercel. + */ + +type GatewayConfig = { + baseURL: string; + apiKey: string; + /** Which env var supplied the key — surfaced in logs/errors for ops. */ + source: "AI_GATEWAY_API_KEY" | "LOVABLE_API_KEY" | "OPENAI_API_KEY"; + /** Header shape that auth uses on this gateway. */ + authStyle: "lovable" | "bearer"; +}; + +function resolveConfig(): GatewayConfig { + if (process.env.AI_GATEWAY_API_KEY) { + return { + baseURL: process.env.AI_GATEWAY_BASE_URL || "https://api.openai.com/v1", + apiKey: process.env.AI_GATEWAY_API_KEY, + source: "AI_GATEWAY_API_KEY", + authStyle: "bearer", + }; + } + if (process.env.LOVABLE_API_KEY) { + return { + baseURL: "https://ai.gateway.lovable.dev/v1", + apiKey: process.env.LOVABLE_API_KEY, + source: "LOVABLE_API_KEY", + authStyle: "lovable", + }; + } + if (process.env.OPENAI_API_KEY) { + return { + baseURL: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, + source: "OPENAI_API_KEY", + authStyle: "bearer", + }; + } + throw new Response( + "No AI gateway configured. Set one of: AI_GATEWAY_API_KEY (+ AI_GATEWAY_BASE_URL), LOVABLE_API_KEY, or OPENAI_API_KEY.", + { status: 500 }, + ); +} + +function providerFor(cfg: GatewayConfig) { + if (cfg.authStyle === "lovable") { + return createOpenAICompatible({ + name: "lovable", + baseURL: cfg.baseURL, + headers: { + "Lovable-API-Key": cfg.apiKey, + "X-Lovable-AIG-SDK": "vercel-ai-sdk", + }, + }); + } + return createOpenAICompatible({ + name: "ai-gateway", + baseURL: cfg.baseURL, + apiKey: cfg.apiKey, + }); +} + +// Backwards-compatible export — callers in older code paths constructed +// a provider explicitly with a Lovable key. New code should just call +// `getGatewayModel`. export const createLovableAiGatewayProvider = (lovableApiKey: string) => createOpenAICompatible({ name: "lovable", @@ -10,8 +101,48 @@ export const createLovableAiGatewayProvider = (lovableApiKey: string) => }, }); -export function getGatewayModel(modelId = "google/gemini-3-flash-preview") { - const key = process.env.LOVABLE_API_KEY; - if (!key) throw new Response("LOVABLE_API_KEY missing", { status: 500 }); - return createLovableAiGatewayProvider(key)(modelId); +/** + * Return an LM Studio / Vercel-AI-SDK compatible model object for the + * given model id. Pass `"default"` (or omit) to use the env-configured + * default — useful for "just pick whatever works" call sites. + */ +export function getGatewayModel(modelId: string = "default") { + const cfg = resolveConfig(); + const resolved = + modelId === "default" + ? process.env.AI_GATEWAY_MODEL || "google/gemini-2.5-flash" + : modelId; + return providerFor(cfg)(resolved); +} + +/** + * Diagnostic — which env supplied the key, the base URL, and the + * default model. Returned by an admin debug endpoint and used by + * server-side logs to make config drift visible. + */ +export function describeGatewayConfig(): { + configured: boolean; + source: GatewayConfig["source"] | null; + baseURL: string | null; + defaultModel: string; + authStyle: GatewayConfig["authStyle"] | null; +} { + try { + const cfg = resolveConfig(); + return { + configured: true, + source: cfg.source, + baseURL: cfg.baseURL, + defaultModel: process.env.AI_GATEWAY_MODEL || "google/gemini-2.5-flash", + authStyle: cfg.authStyle, + }; + } catch { + return { + configured: false, + source: null, + baseURL: null, + defaultModel: process.env.AI_GATEWAY_MODEL || "(unset)", + authStyle: null, + }; + } } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 6537b207..e0fb9df2 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -90,6 +90,7 @@ import { Route as ApiPublicTelemetryRouteImport } from './routes/api/public/tele import { Route as ApiPublicSearchRouteImport } from './routes/api/public/search' import { Route as ApiPublicPackagesRouteImport } from './routes/api/public/packages' import { Route as ApiMcpHealthRouteImport } from './routes/api/mcp/health' +import { Route as ApiAdminAiGatewayProbeRouteImport } from './routes/api/admin/ai-gateway-probe' import { Route as AdminPackagesNewRouteImport } from './routes/admin.packages.new' import { Route as AdminImportMarkdownRouteImport } from './routes/admin.import.markdown' import { Route as AdminImportGithubRouteImport } from './routes/admin.import.github' @@ -518,6 +519,11 @@ const ApiMcpHealthRoute = ApiMcpHealthRouteImport.update({ path: '/health', getParentRoute: () => ApiMcpRoute, } as any) +const ApiAdminAiGatewayProbeRoute = ApiAdminAiGatewayProbeRouteImport.update({ + id: '/api/admin/ai-gateway-probe', + path: '/api/admin/ai-gateway-probe', + getParentRoute: () => rootRouteImport, +} as any) const AdminPackagesNewRoute = AdminPackagesNewRouteImport.update({ id: '/new', path: '/new', @@ -705,6 +711,7 @@ export interface FileRoutesByFullPath { '/admin/import/github': typeof AdminImportGithubRoute '/admin/import/markdown': typeof AdminImportMarkdownRoute '/admin/packages/new': typeof AdminPackagesNewRoute + '/api/admin/ai-gateway-probe': typeof ApiAdminAiGatewayProbeRoute '/api/mcp/health': typeof ApiMcpHealthRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute @@ -807,6 +814,7 @@ export interface FileRoutesByTo { '/admin/import/github': typeof AdminImportGithubRoute '/admin/import/markdown': typeof AdminImportMarkdownRoute '/admin/packages/new': typeof AdminPackagesNewRoute + '/api/admin/ai-gateway-probe': typeof ApiAdminAiGatewayProbeRoute '/api/mcp/health': typeof ApiMcpHealthRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute @@ -911,6 +919,7 @@ export interface FileRoutesById { '/admin/import/github': typeof AdminImportGithubRoute '/admin/import/markdown': typeof AdminImportMarkdownRoute '/admin/packages/new': typeof AdminPackagesNewRoute + '/api/admin/ai-gateway-probe': typeof ApiAdminAiGatewayProbeRoute '/api/mcp/health': typeof ApiMcpHealthRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute @@ -1016,6 +1025,7 @@ export interface FileRouteTypes { | '/admin/import/github' | '/admin/import/markdown' | '/admin/packages/new' + | '/api/admin/ai-gateway-probe' | '/api/mcp/health' | '/api/public/packages' | '/api/public/search' @@ -1118,6 +1128,7 @@ export interface FileRouteTypes { | '/admin/import/github' | '/admin/import/markdown' | '/admin/packages/new' + | '/api/admin/ai-gateway-probe' | '/api/mcp/health' | '/api/public/packages' | '/api/public/search' @@ -1221,6 +1232,7 @@ export interface FileRouteTypes { | '/admin/import/github' | '/admin/import/markdown' | '/admin/packages/new' + | '/api/admin/ai-gateway-probe' | '/api/mcp/health' | '/api/public/packages' | '/api/public/search' @@ -1307,6 +1319,7 @@ export interface RootRouteChildren { UHandleRoute: typeof UHandleRoute MarketplaceIndexRoute: typeof MarketplaceIndexRoute PacksIndexRoute: typeof PacksIndexRoute + ApiAdminAiGatewayProbeRoute: typeof ApiAdminAiGatewayProbeRoute ApiPublicPackagesRoute: typeof ApiPublicPackagesRouteWithChildren ApiPublicSearchRoute: typeof ApiPublicSearchRoute ApiPublicTelemetryRoute: typeof ApiPublicTelemetryRoute @@ -1897,6 +1910,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMcpHealthRouteImport parentRoute: typeof ApiMcpRoute } + '/api/admin/ai-gateway-probe': { + id: '/api/admin/ai-gateway-probe' + path: '/api/admin/ai-gateway-probe' + fullPath: '/api/admin/ai-gateway-probe' + preLoaderRoute: typeof ApiAdminAiGatewayProbeRouteImport + parentRoute: typeof rootRouteImport + } '/admin/packages/new': { id: '/admin/packages/new' path: '/new' @@ -2256,6 +2276,7 @@ const rootRouteChildren: RootRouteChildren = { UHandleRoute: UHandleRoute, MarketplaceIndexRoute: MarketplaceIndexRoute, PacksIndexRoute: PacksIndexRoute, + ApiAdminAiGatewayProbeRoute: ApiAdminAiGatewayProbeRoute, ApiPublicPackagesRoute: ApiPublicPackagesRouteWithChildren, ApiPublicSearchRoute: ApiPublicSearchRoute, ApiPublicTelemetryRoute: ApiPublicTelemetryRoute, diff --git a/src/routes/api/admin/ai-gateway-probe.ts b/src/routes/api/admin/ai-gateway-probe.ts new file mode 100644 index 00000000..33cc44db --- /dev/null +++ b/src/routes/api/admin/ai-gateway-probe.ts @@ -0,0 +1,83 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { generateText } from "ai"; +import { describeGatewayConfig, getGatewayModel } from "@/lib/ai-gateway"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +const supabaseAdmin = _supabaseAdmin as any; + +// Admin-only diagnostic. Returns: +// - which env var supplied the gateway key (or "configured: false") +// - which base URL the SDK will use +// - whether a 1-token round-trip to the default model succeeds, plus +// the latency and any error returned by the provider. +// +// Use it from /admin to figure out *exactly* what's wrong when the +// SkillForge author pipeline misbehaves. Auth: admin role required. + +async function isAdmin(userId: string | null): Promise { + if (!userId) return false; + const { data } = await supabaseAdmin + .from("user_roles") + .select("role") + .eq("user_id", userId) + .eq("role", "admin") + .maybeSingle(); + return !!data; +} + +async function resolveUserFromAuthHeader(req: Request): Promise { + // Cookie session — handled by Supabase auth middleware on most routes, + // but for a one-off API we just trust the supabase session JWT cookie. + const auth = req.headers.get("authorization") ?? ""; + if (auth.startsWith("Bearer ")) { + const token = auth.slice(7).trim(); + try { + const { data } = await supabaseAdmin.auth.getUser(token); + return data?.user?.id ?? null; + } catch { + return null; + } + } + return null; +} + +async function handle(req: Request): Promise { + const userId = await resolveUserFromAuthHeader(req); + if (!(await isAdmin(userId))) { + return new Response(JSON.stringify({ error: "Forbidden — admin only" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + const cfg = describeGatewayConfig(); + const probe: { + config: typeof cfg; + probe?: { ok: boolean; ms: number; text?: string; error?: string }; + } = { config: cfg }; + + if (cfg.configured) { + const t0 = Date.now(); + try { + const r = await generateText({ + model: getGatewayModel("default"), + prompt: "Reply with the single word: ok", + // keep cost trivial — we just want a successful HTTP round-trip + }); + probe.probe = { ok: true, ms: Date.now() - t0, text: r.text.slice(0, 80) }; + } catch (e: any) { + probe.probe = { ok: false, ms: Date.now() - t0, error: e?.message ?? String(e) }; + } + } + + return new Response(JSON.stringify(probe, null, 2), { + status: 200, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, + }); +} + +export const Route = createFileRoute("/api/admin/ai-gateway-probe")({ + server: { + handlers: { + GET: async ({ request }) => handle(request), + }, + }, +});