SysDeck/klanker-gate/apps/gateway/routes/openrouter_ingress.ts

198 lines
6.5 KiB
TypeScript
Executable File

import {
ChatCompletionRequestSchema,
EmbeddingRequestSchema,
} from "../../../packages/contracts/src/mod.ts";
import {
GatewayError,
gatewayErrorResponse,
type Router,
} from "../../../packages/core/src/mod.ts";
import type { AppContext } from "../context.ts";
import {
jsonResponse,
mapDispatchError,
parseJsonBody,
validationErrorResponse,
} from "./helpers.ts";
import { runChatCompletion } from "./inference.ts";
/** Client-supplied `fallbacks`, kept verbatim ahead of anything mapped. */
function normalizeFallbacks(
existing: unknown,
): Array<{ provider: string; model?: string }> {
if (!Array.isArray(existing)) {
return [];
}
const out: Array<{ provider: string; model?: string }> = [];
for (const item of existing) {
if (!item || typeof item !== "object") {
continue;
}
const { provider, model } = item as { provider?: unknown; model?: unknown };
if (typeof provider !== "string" || provider.length === 0) {
continue;
}
out.push(typeof model === "string" ? { provider, model } : { provider });
}
return out;
}
/**
* Failover depth a single request may request. Beyond this an OpenRouter-shaped
* `models[]` is an amplification vector, not a fallback preference.
*/
const MAX_MODELS_CHAIN = 12;
/**
* OpenRouter `models[]` -> the gateway's request-level fallback chain. Entries
* split on the FIRST `/` into provider + model; an entry without a `/` is
* skipped, because a fallback needs an account id. When the request names its
* own `model`, EVERY entry is a fallback; otherwise `models[0]` is the primary
* and the rest are fallbacks.
*/
export function modelsToFallbacks(
models: unknown,
existing: unknown,
requestModel?: unknown,
): { model?: string; fallbacks?: Array<{ provider: string; model?: string }> } {
if (!Array.isArray(models) || models.length === 0) {
return {};
}
// Non-string entries are dropped rather than voiding the whole array, and
// the chain is capped: every entry becomes a dispatch attempt, so an
// unbounded `models[]` would turn one client request into thousands of
// upstream calls (decision-log 79).
const list = (models.filter((m) => typeof m === "string") as string[])
.slice(0, MAX_MODELS_CHAIN);
if (list.length === 0) {
return {};
}
const hasModel = typeof requestModel === "string" && requestModel.length > 0;
const fallbacks = normalizeFallbacks(existing);
for (const entry of hasModel ? list : list.slice(1)) {
const slash = entry.indexOf("/");
if (slash <= 0) {
continue;
}
const provider = entry.slice(0, slash);
// A `:tag` suffix (`:free`, `:nitro`) rides along untouched.
const model = entry.slice(slash + 1);
fallbacks.push(model ? { provider, model } : { provider });
}
const capped = fallbacks.slice(0, MAX_MODELS_CHAIN);
return {
model: hasModel ? undefined : list[0],
fallbacks: capped.length > 0 ? capped : undefined,
};
}
function bodyObject(body: unknown): Record<string, unknown> {
return body !== null && typeof body === "object"
? body as Record<string, unknown>
: {};
}
/** Folds `models[]` into `model` + `fallbacks`; `models` stays on for egress. */
function applyModels(raw: Record<string, unknown>): Record<string, unknown> {
const patch = modelsToFallbacks(raw.models, raw.fallbacks, raw.model);
const model = typeof raw.model === "string" && raw.model.length > 0
? raw.model
: patch.model;
if (typeof model !== "string" || model.length === 0) {
return raw;
}
return patch.fallbacks
? { ...raw, model, fallbacks: patch.fallbacks }
: { ...raw, model };
}
/**
* OpenRouter-shaped ingress. Only the `/openrouter/v1/*` shapes are registered:
* an upstream rewrite folds `/openrouter/api/v1/*` onto them before routing.
* The canonical wire IS the OpenRouter wire, so chat flows through
* `runChatCompletion` unchanged in both streaming and non-streaming modes.
*/
export function registerOpenRouterIngressRoutes(
router: Router,
ctx: AppContext,
): void {
// `HTTP-Referer` / `X-Title` are accepted and dropped: egress attribution is
// the per-account `network.extraHeaders` setting, not a client-set header.
router.post("/openrouter/v1/chat/completions", async (req) => {
ctx.metrics.increment("requests.compat.openrouter");
try {
const parsed = ChatCompletionRequestSchema.safeParse(
applyModels(bodyObject(await parseJsonBody(req))),
);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
return await runChatCompletion(ctx, req, parsed.data);
} catch (error) {
return mapDispatchError(error);
}
});
router.post("/openrouter/v1/embeddings", async (req) => {
ctx.metrics.increment("requests.compat.openrouter");
try {
const parsed = EmbeddingRequestSchema.safeParse(await parseJsonBody(req));
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
const target = ctx.providers.resolve(parsed.data.model);
if (
!target.capabilities.supportsEmbeddings || !target.adapter.embeddings
) {
throw new GatewayError(
400,
`Provider "${target.providerId}" does not support embeddings.`,
"invalid_request_error",
);
}
return await target.adapter.embeddings(
{ ...parsed.data, model: target.model },
{ signal: req.signal },
);
} catch (error) {
return mapDispatchError(error);
}
});
router.get("/openrouter/v1/models", () => {
ctx.metrics.increment("requests.compat.openrouter");
return jsonResponse({ object: "list", data: ctx.providers.models() });
});
const notProxied = (surface: string, detail: string) => (): Response => {
ctx.metrics.increment("requests.compat.openrouter");
return gatewayErrorResponse(
new GatewayError(
501,
`OpenRouter ${surface} is not implemented. ${detail}`,
"not_implemented",
),
);
};
router.get(
"/openrouter/v1/generation",
notProxied(
"GET /generation",
"Frosty does not proxy OpenRouter's native per-generation cost " +
"accounting; it keeps its own. Use GET /api/analytics for cost and " +
"token rollups, or GET /api/logs for per-request records.",
),
);
router.get(
"/openrouter/v1/key",
notProxied(
"GET /key",
"Frosty does not proxy OpenRouter key introspection; the credential " +
"you present is a frosty one. Use GET /api/virtual-keys for virtual " +
"keys, or GET /api/providers for provider accounts.",
),
);
}