diff --git a/src/app/api/session-pools/[provider]/route.ts b/src/app/api/session-pools/[provider]/route.ts new file mode 100644 index 000000000000..9929beb5ba2a --- /dev/null +++ b/src/app/api/session-pools/[provider]/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getWebSessionPoolHealth } from "@omniroute/open-sse/services/webSessionPoolHealth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ provider: string }> } +): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { provider } = await params; + + try { + const report = getWebSessionPoolHealth(provider); + const poolData = report.providers[0]; + + if (!poolData) { + return NextResponse.json( + { error: `No session pool found for provider '${provider}'` }, + { status: 404 } + ); + } + + return NextResponse.json({ + checkedAt: report.checkedAt, + ...poolData, + }); + } catch (err) { + return NextResponse.json( + { error: sanitizeErrorMessage(err) || "Failed to get session pool health" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/session-pools/route.ts b/src/app/api/session-pools/route.ts new file mode 100644 index 000000000000..f44b62f49de7 --- /dev/null +++ b/src/app/api/session-pools/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getWebSessionPoolHealth } from "@omniroute/open-sse/services/webSessionPoolHealth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const report = getWebSessionPoolHealth(); + return NextResponse.json(report); + } catch (err) { + return NextResponse.json( + { error: sanitizeErrorMessage(err) || "Failed to get session pool health" }, + { status: 500 } + ); + } +} diff --git a/tests/unit/session-pool-rest-api.test.ts b/tests/unit/session-pool-rest-api.test.ts new file mode 100644 index 000000000000..4b01e13656c8 --- /dev/null +++ b/tests/unit/session-pool-rest-api.test.ts @@ -0,0 +1,233 @@ +/** + * Session Pool REST API — Unit Tests (PR7 of issue #3368) + * + * Run: node --import tsx/esm --test tests/unit/session-pool-rest-api.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { WebSessionPoolHealthDeps } from "../../open-sse/services/webSessionPoolHealth.ts"; +import { getWebSessionPoolHealth } from "../../open-sse/services/webSessionPoolHealth.ts"; + +function createMockDeps(overrides: { + providers?: string[]; + stats?: Record; + sessionDetails?: Record; + breakerCooldown?: Record; + breakerRemaining?: Record; + breakerStates?: Record; +} = {}): WebSessionPoolHealthDeps { + const providers = overrides.providers ?? []; + const stats = overrides.stats ?? {}; + const sessionDetails = overrides.sessionDetails ?? {}; + const breakerCooldown = overrides.breakerCooldown ?? {}; + const breakerRemaining = overrides.breakerRemaining ?? {}; + const breakerStates = overrides.breakerStates ?? {}; + + return { + listProviders: () => [...providers], + getStats: (p: string) => stats[p] ?? null, + getSessionDetails: (p: string) => sessionDetails[p] ?? null, + isProviderInCooldown: (p: string) => breakerCooldown[p] ?? false, + getProviderCooldownRemainingMs: (p: string) => breakerRemaining[p] ?? null, + getProviderBreakerState: (p: string) => breakerStates[p] ?? null, + }; +} + +describe("GET /api/session-pools (list all)", () => { + it("returns empty report when no pools registered", () => { + const deps = createMockDeps(); + const report = getWebSessionPoolHealth(undefined, deps); + assert.equal(report.providers.length, 0); + assert.ok(report.checkedAt); + }); + + it("returns all registered pools", () => { + const now = Date.now(); + const deps = createMockDeps({ + providers: ["pollinations", "longcat"], + stats: { + pollinations: { + provider: "pollinations", + sessions: { total: 6, active: 5, cooldown: 1, dead: 0 }, + requests: { total: 100, success: 95, rate429: 3, otherErrors: 2 }, + throughput: "12.3 req/s", + successRate: "95.0%", + elapsed: "2h 15m", + createdAt: now - 2 * 60 * 60 * 1000, + }, + longcat: { + provider: "longcat", + sessions: { total: 4, active: 4, cooldown: 0, dead: 0 }, + requests: { total: 50, success: 50, rate429: 0, otherErrors: 0 }, + throughput: "8.0 req/s", + successRate: "100.0%", + elapsed: "1h", + createdAt: now - 60 * 60 * 1000, + }, + }, + sessionDetails: { pollinations: [], longcat: [] }, + breakerStates: { + pollinations: { state: "CLOSED", failureCount: 0, lastFailureTime: null, retryAfterMs: 0 }, + longcat: { state: "CLOSED", failureCount: 0, lastFailureTime: null, retryAfterMs: 0 }, + }, + }); + + const report = getWebSessionPoolHealth(undefined, deps); + assert.equal(report.providers.length, 2); + assert.equal(report.providers[0].provider, "pollinations"); + assert.equal(report.providers[1].provider, "longcat"); + }); + + it("includes health summary for each pool", () => { + const deps = createMockDeps({ + providers: ["pollinations"], + stats: { + pollinations: { + provider: "pollinations", + sessions: { total: 6, active: 5, cooldown: 1, dead: 0 }, + requests: { total: 100, success: 95, rate429: 3, otherErrors: 2 }, + throughput: "12.3 req/s", + successRate: "95.0%", + elapsed: "2h", + createdAt: Date.now() - 2 * 60 * 60 * 1000, + }, + }, + sessionDetails: { pollinations: [] }, + breakerStates: { + pollinations: { state: "CLOSED", failureCount: 0, lastFailureTime: null, retryAfterMs: 0 }, + }, + }); + + const report = getWebSessionPoolHealth(undefined, deps); + const pool = report.providers[0]; + assert.ok(pool.pool); + assert.equal(pool.pool.totalSessions, 6); + assert.equal(pool.pool.activeSessions, 5); + assert.equal(pool.health, "healthy"); + }); +}); + +describe("GET /api/session-pools/[provider] (single pool)", () => { + it("returns specific pool when provider arg given", () => { + const deps = createMockDeps({ + providers: ["pollinations", "longcat"], + stats: { + pollinations: { + provider: "pollinations", + sessions: { total: 6, active: 5, cooldown: 1, dead: 0 }, + requests: { total: 100, success: 95, rate429: 3, otherErrors: 2 }, + throughput: "12.3 req/s", + successRate: "95.0%", + elapsed: "2h", + createdAt: Date.now() - 2 * 60 * 60 * 1000, + }, + }, + sessionDetails: { pollinations: [] }, + breakerStates: { + pollinations: { state: "CLOSED", failureCount: 0, lastFailureTime: null, retryAfterMs: 0 }, + }, + }); + + const report = getWebSessionPoolHealth("pollinations", deps); + assert.equal(report.providers.length, 1); + assert.equal(report.providers[0].provider, "pollinations"); + }); + + it("returns pool with session details", () => { + const deps = createMockDeps({ + providers: ["pollinations"], + stats: { + pollinations: { + provider: "pollinations", + sessions: { total: 2, active: 2, cooldown: 0, dead: 0 }, + requests: { total: 10, success: 10, rate429: 0, otherErrors: 0 }, + throughput: "5.0 req/s", + successRate: "100.0%", + elapsed: "30m", + createdAt: Date.now() - 30 * 60 * 1000, + }, + }, + sessionDetails: { + pollinations: [ + { + id: "s1", + fingerprint: "fp-1", + status: "active", + totalRequests: 5, + successfulRequests: 5, + successRate: "100.0%", + inflight: 0, + cooldownRemaining: "none", + age: "30m", + }, + { + id: "s2", + fingerprint: "fp-2", + status: "active", + totalRequests: 5, + successfulRequests: 5, + successRate: "100.0%", + inflight: 1, + cooldownRemaining: "none", + age: "30m", + }, + ], + }, + breakerStates: { + pollinations: { state: "CLOSED", failureCount: 0, lastFailureTime: null, retryAfterMs: 0 }, + }, + }); + + const report = getWebSessionPoolHealth("pollinations", deps); + const pool = report.providers[0]; + assert.equal(pool.sessions.length, 2); + assert.equal(pool.sessions[0].id, "s1"); + assert.equal(pool.sessions[1].id, "s2"); + }); + + it("returns pool with breaker state when available", () => { + const now = Date.now(); + const deps = createMockDeps({ + providers: ["pollinations"], + stats: { + pollinations: { + provider: "pollinations", + sessions: { total: 6, active: 0, cooldown: 2, dead: 4 }, + requests: { total: 50, success: 10, rate429: 30, otherErrors: 10 }, + throughput: "0.5 req/s", + successRate: "20.0%", + elapsed: "1h", + createdAt: now - 60 * 60 * 1000, + }, + }, + sessionDetails: { pollinations: [] }, + breakerCooldown: { pollinations: true }, + breakerRemaining: { pollinations: 30000 }, + breakerStates: { + pollinations: { + state: "OPEN", + failureCount: 10, + lastFailureTime: now - 5000, + retryAfterMs: 30000, + }, + }, + }); + + const report = getWebSessionPoolHealth("pollinations", deps); + const pool = report.providers[0]; + assert.ok(pool.breaker); + assert.equal(pool.breaker.state, "OPEN"); + assert.equal(pool.breaker.inCooldown, true); + assert.equal(pool.breaker.cooldownRemainingMs, 30000); + assert.equal(pool.health, "down"); + }); + + it("returns provider entry even when no pool registered (for 404 detection)", () => { + const deps = createMockDeps(); + const report = getWebSessionPoolHealth("nonexistent", deps); + assert.equal(report.providers.length, 1); + assert.equal(report.providers[0].provider, "nonexistent"); + assert.equal(report.providers[0].pool, null); + }); +});