Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions src/lib/admin/author.server.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(
Expand All @@ -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({
Expand Down
139 changes: 135 additions & 4 deletions src/lib/ai-gateway.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
};
}
}
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -2256,6 +2276,7 @@ const rootRouteChildren: RootRouteChildren = {
UHandleRoute: UHandleRoute,
MarketplaceIndexRoute: MarketplaceIndexRoute,
PacksIndexRoute: PacksIndexRoute,
ApiAdminAiGatewayProbeRoute: ApiAdminAiGatewayProbeRoute,
ApiPublicPackagesRoute: ApiPublicPackagesRouteWithChildren,
ApiPublicSearchRoute: ApiPublicSearchRoute,
ApiPublicTelemetryRoute: ApiPublicTelemetryRoute,
Expand Down
83 changes: 83 additions & 0 deletions src/routes/api/admin/ai-gateway-probe.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string | null> {
// 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<Response> {
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),
},
},
});
Loading