Skip to content

Commit 3ab1edf

Browse files
committed
feat(api): add provider runtime status and readiness gating
1 parent 802de7f commit 3ab1edf

6 files changed

Lines changed: 92 additions & 7 deletions

File tree

apps/api/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
PORT=3001
22
NODE_ENV=development
3+
STACKFORGE_PROVIDER=auto
34
OPENROUTER_API_KEY=
45
OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions
56
OPENROUTER_APP_NAME=stackforge-api

apps/api/src/controllers/generate.controller.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Request, Response, NextFunction } from "express";
22
import { GenerateRequestSchema } from "@stackforge/shared";
3-
import { generateProject } from "../services/generate.service.js";
3+
import { generateProject, getRuntimeStatus } from "../services/generate.service.js";
44

55
export function generateController(req: Request, res: Response, next: NextFunction): void {
66
const parsed = GenerateRequestSchema.safeParse(req.body);
@@ -12,6 +12,15 @@ export function generateController(req: Request, res: Response, next: NextFuncti
1212

1313
const { prompt, projectName } = parsed.data;
1414
const resolvedName = projectName ?? prompt.slice(0, 40).replace(/\s+/g, "-").toLowerCase();
15+
const runtime = getRuntimeStatus();
16+
17+
if (!runtime.ready) {
18+
res.status(503).json({
19+
error: runtime.reason ?? "LLM provider is not configured",
20+
provider: runtime.provider,
21+
});
22+
return;
23+
}
1524

1625
try {
1726
const job = generateProject(prompt, resolvedName);

apps/api/src/controllers/jobs.controller.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import type { Request, Response, NextFunction } from "express";
22
import { JobIdParamSchema, JOB_STATUS } from "@stackforge/shared";
33
import { getJob, listJobs, summarizeJobTokenUsage } from "../store/job.store.js";
4+
import { getRuntimeStatus } from "../services/generate.service.js";
45
import { subscribe, unsubscribe } from "../services/sse.service.js";
56

7+
export function runtimeController(_req: Request, res: Response): void {
8+
const runtime = getRuntimeStatus();
9+
res.status(runtime.ready ? 200 : 503).json(runtime);
10+
}
11+
612
export function listJobsController(_req: Request, res: Response): void {
713
const jobs = listJobs().map((job) => ({
814
id: job.id,

apps/api/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ const PORT = process.env["PORT"] ?? "3001";
88

99
app.use(express.json());
1010

11+
// CORS for Vite dev server
12+
app.use((_req, res, next) => {
13+
res.setHeader("Access-Control-Allow-Origin", "*");
14+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
15+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
16+
if (_req.method === "OPTIONS") {
17+
res.sendStatus(204);
18+
return;
19+
}
20+
next();
21+
});
22+
1123
app.get("/healthz", (_req, res) => {
1224
res.json({ status: "ok", service: "stackforge-api", ts: new Date().toISOString() });
1325
});

apps/api/src/routes/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { Router, type IRouter } from "express";
22
import { generateController } from "../controllers/generate.controller.js";
3-
import { listJobsController, getJobController, streamController } from "../controllers/jobs.controller.js";
3+
import {
4+
runtimeController,
5+
listJobsController,
6+
getJobController,
7+
streamController,
8+
} from "../controllers/jobs.controller.js";
49

510
const router: IRouter = Router();
611

712
router.post("/generate", generateController);
13+
router.get("/runtime", runtimeController);
814
router.get("/jobs", listJobsController);
915
router.get("/jobs/:jobId", getJobController);
1016
router.get("/stream/:jobId", streamController);

apps/api/src/services/generate.service.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import type { SSEEvent, AgentName } from "@stackforge/shared";
22

33
import { JOB_STATUS } from "@stackforge/shared";
4-
import { OpenRouterProvider, AgentCache, runOrchestrator } from "@stackforge/agents";
4+
import {
5+
OpenRouterProvider,
6+
MockProvider,
7+
AgentCache,
8+
runOrchestrator,
9+
type LLMProvider,
10+
} from "@stackforge/agents";
511
import {
612
createJob,
713
getJob,
@@ -11,6 +17,29 @@ import {
1117
} from "../store/job.store.js";
1218
import { broadcast, closeJobClients } from "./sse.service.js";
1319

20+
type ProviderMode = "openrouter" | "mock";
21+
22+
export type RuntimeStatus = {
23+
provider: ProviderMode;
24+
ready: boolean;
25+
reason?: string;
26+
};
27+
28+
function resolveProviderMode(): ProviderMode {
29+
const configured = (process.env["STACKFORGE_PROVIDER"] ?? "auto").trim().toLowerCase();
30+
31+
if (configured === "openrouter") {
32+
return "openrouter";
33+
}
34+
35+
if (configured === "mock") {
36+
return "mock";
37+
}
38+
39+
const hasOpenRouterKey = (process.env["OPENROUTER_API_KEY"] ?? "").trim().length > 0;
40+
return hasOpenRouterKey ? "openrouter" : "mock";
41+
}
42+
1443
function readEnv(name: string): string {
1544
const value = process.env[name];
1645
if (value === undefined || value.trim().length === 0) {
@@ -19,7 +48,7 @@ function readEnv(name: string): string {
1948
return value;
2049
}
2150

22-
function buildProvider(): OpenRouterProvider {
51+
function buildOpenRouterProvider(): OpenRouterProvider {
2352
const endpoint = process.env["OPENROUTER_ENDPOINT"];
2453
const options = {
2554
apiKey: readEnv("OPENROUTER_API_KEY"),
@@ -31,16 +60,38 @@ function buildProvider(): OpenRouterProvider {
3160
return new OpenRouterProvider(options);
3261
}
3362

34-
let provider: OpenRouterProvider | undefined;
63+
function buildProvider(mode: ProviderMode): LLMProvider {
64+
if (mode === "mock") {
65+
return new MockProvider();
66+
}
67+
68+
return buildOpenRouterProvider();
69+
}
3570

36-
function getProvider(): OpenRouterProvider {
71+
let provider: LLMProvider | undefined;
72+
let providerMode: ProviderMode | undefined;
73+
74+
function getProvider(): LLMProvider {
3775
if (provider === undefined) {
38-
provider = buildProvider();
76+
providerMode = resolveProviderMode();
77+
provider = buildProvider(providerMode);
3978
}
4079

4180
return provider;
4281
}
4382

83+
export function getRuntimeStatus(): RuntimeStatus {
84+
const mode = providerMode ?? resolveProviderMode();
85+
86+
try {
87+
void getProvider();
88+
return { provider: mode, ready: true };
89+
} catch (error) {
90+
const reason = error instanceof Error ? error.message : String(error);
91+
return { provider: mode, ready: false, reason };
92+
}
93+
}
94+
4495
const cache = new AgentCache();
4596

4697
function buildEmitter(jobId: string): (event: SSEEvent) => void {

0 commit comments

Comments
 (0)