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

147 lines
4.5 KiB
TypeScript
Executable File

import type { CompletionRequest } from "../../../packages/contracts/src/mod.ts";
import {
ChatCompletionRequestSchema,
CompletionRequestSchema,
EmbeddingRequestSchema,
} from "../../../packages/contracts/src/mod.ts";
import { GatewayError, type Router } from "../../../packages/core/src/mod.ts";
import type { AppContext } from "../context.ts";
import {
mapDispatchError,
parseJsonBody,
validationErrorResponse,
} from "./helpers.ts";
import { runChatCompletion, runCompletion } from "./inference.ts";
/**
* Deployment id from a raw `/openai/deployments/{deployment}/...` pathname.
* Governance imports this so admission sees the EXACT string this file
* dispatches; an undecodable segment yields undefined (deny) there and a 400
* here.
*/
export function azureDeploymentFromPath(pathname: string): string | undefined {
const match = pathname.match(/^\/openai\/deployments\/([^/?]+)\//);
if (!match) {
return undefined;
}
try {
return decodeURIComponent(match[1]);
} catch {
return undefined;
}
}
/**
* Percent-decoded `:deployment` group. URLPattern hands groups back RAW, so
* `a%2Fb` arrives still encoded; a malformed escape is a client error.
*/
function deploymentFromMatch(match: URLPatternResult): string {
const raw = match.pathname.groups.deployment ?? "";
try {
return decodeURIComponent(raw);
} catch {
throw new GatewayError(
400,
"Azure deployment path segment is not valid percent-encoding.",
"invalid_request_error",
"deployment",
);
}
}
/**
* Body with `model` forced to the URL deployment. Azure routes on the URL, so a
* body `model` is ignored rather than merged.
*/
function withDeployment(
body: unknown,
deployment: string,
): Record<string, unknown> {
const raw = body !== null && typeof body === "object"
? body as Record<string, unknown>
: {};
return { ...raw, model: deployment };
}
/**
* Azure OpenAI deployment-scoped ingress: the inverse of the Azure egress
* adapter. Azure's newer undated `/openai/v1/*` family needs no routes here -
* the compat prefix rewrite already strips `/openai` before `/v1/*`.
*/
export function registerAzureIngressRoutes(
router: Router,
ctx: AppContext,
): void {
// `?api-version=` is accepted with ANY value and ignored; the resolved
// account's own configured version governs egress.
router.post(
"/openai/deployments/:deployment/chat/completions",
async (req, match) => {
ctx.metrics.increment("requests.compat.azure");
try {
const deployment = deploymentFromMatch(match);
const parsed = ChatCompletionRequestSchema.safeParse(
withDeployment(await parseJsonBody(req), deployment),
);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
return await runChatCompletion(ctx, req, parsed.data);
} catch (error) {
return mapDispatchError(error);
}
},
);
router.post(
"/openai/deployments/:deployment/completions",
async (req, match) => {
ctx.metrics.increment("requests.compat.azure");
try {
const deployment = deploymentFromMatch(match);
const parsed = CompletionRequestSchema.safeParse(
withDeployment(await parseJsonBody(req), deployment),
);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
return await runCompletion(ctx, req, parsed.data as CompletionRequest);
} catch (error) {
return mapDispatchError(error);
}
},
);
router.post(
"/openai/deployments/:deployment/embeddings",
async (req, match) => {
ctx.metrics.increment("requests.compat.azure");
try {
const deployment = deploymentFromMatch(match);
const parsed = EmbeddingRequestSchema.safeParse(
withDeployment(await parseJsonBody(req), deployment),
);
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);
}
},
);
}