695 lines
24 KiB
TypeScript
Executable File
695 lines
24 KiB
TypeScript
Executable File
import {
|
|
EmbeddingRequestSchema,
|
|
type ImageGenerationRequest,
|
|
ImageGenerationRequestSchema,
|
|
MAX_IMAGE_N,
|
|
MAX_MEDIA_INFLIGHT_BYTES,
|
|
MAX_TRANSCRIPTION_JSON_BYTES,
|
|
MEDIA_BYTES_PER_IMAGE,
|
|
type ProviderCapability,
|
|
SpeechRequestSchema,
|
|
} from "../../../packages/contracts/src/mod.ts";
|
|
import {
|
|
errorResponse,
|
|
GatewayError,
|
|
type Router,
|
|
} from "../../../packages/core/src/mod.ts";
|
|
import {
|
|
ProviderError,
|
|
type ResolvedTarget,
|
|
targetScope,
|
|
} from "../../../packages/providers/src/mod.ts";
|
|
import {
|
|
clampCount,
|
|
clampSeconds,
|
|
type CounterSink,
|
|
type MediaUnits,
|
|
mergeRequestStatus,
|
|
mergeRequestTokens,
|
|
mergeRequestUnits,
|
|
normalizeUsage,
|
|
type RequestDispatch,
|
|
setRequestDispatch,
|
|
type UsageShape,
|
|
} from "../../../packages/telemetry/src/usage.ts";
|
|
import type { AppContext } from "../context.ts";
|
|
import {
|
|
dispatchDetached,
|
|
dispatchThenDetach,
|
|
jsonResponse,
|
|
mapDispatchError,
|
|
parseJsonBody,
|
|
readCappedBytes,
|
|
rebuild,
|
|
validationErrorResponse,
|
|
} from "./helpers.ts";
|
|
|
|
/** The three media surfaces. Used both to register the routes and to gate the
|
|
* unit readers, so a path change cannot desync the gate from the route. */
|
|
const IMAGES_PATH = "/v1/images/generations";
|
|
const SPEECH_PATH = "/v1/audio/speech";
|
|
const TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions";
|
|
|
|
function requireCapability(
|
|
target: ResolvedTarget,
|
|
capability: keyof ProviderCapability,
|
|
what: string,
|
|
): void {
|
|
if (!target.capabilities[capability]) {
|
|
throw new GatewayError(
|
|
400,
|
|
`Provider "${target.providerId}" does not support ${what}.`,
|
|
"invalid_request_error",
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Routes by provider prefix in the model, else default-provider rules. */
|
|
function targetFromModel(ctx: AppContext, model: string): ResolvedTarget {
|
|
return ctx.providers.resolve(model);
|
|
}
|
|
|
|
/** Routes multipart/list endpoints by ?provider= or the default provider. */
|
|
function targetFromQuery(ctx: AppContext, req: Request): ResolvedTarget {
|
|
const provider = new URL(req.url).searchParams.get("provider");
|
|
const target = provider
|
|
? ctx.providers.accountTarget(provider)
|
|
: ctx.providers.resolve("");
|
|
// A provider-scoped virtual key must not read files/batches from an
|
|
// out-of-scope account via ?provider= (these data-plane paths carry no model,
|
|
// so only the provider allowlist applies).
|
|
const scope = targetScope.getStore();
|
|
if (scope?.providers && !scope.providers.has(target.providerId)) {
|
|
throw new GatewayError(
|
|
403,
|
|
`Virtual key is not permitted to use provider "${target.providerId}".`,
|
|
"governance_error",
|
|
undefined,
|
|
"provider_not_permitted",
|
|
);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function jsonForward(path: string, body: unknown): Request {
|
|
return new Request(`http://internal${path}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function requireRawProxy(target: ResolvedTarget) {
|
|
if (!target.adapter.rawProxy) {
|
|
throw new GatewayError(
|
|
400,
|
|
`Provider "${target.providerId}" has no passthrough surface for this endpoint.`,
|
|
"invalid_request_error",
|
|
);
|
|
}
|
|
return target.adapter.rawProxy.bind(target.adapter);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Media accounting: the in-flight byte budget, the quantity readers, and the
|
|
// one dispatcher that writes the channel.
|
|
|
|
/**
|
|
* Bytes reserved across every media body read in flight in THIS process.
|
|
* Reserved pessimistically at the per-response cap before the provider is
|
|
* dispatched to, and held until the read finishes, so a mid-read crossing is
|
|
* structurally impossible. Because the hold spans the provider's generation
|
|
* latency it is also the media path's only concurrency bound.
|
|
*/
|
|
let mediaInflightBytes = 0;
|
|
|
|
/**
|
|
* Bytes currently reserved. Nothing in production reads this; it exists so the
|
|
* `finally` release is observable, since a reservation that leaks on one exit
|
|
* path degrades into a permanent 429 and nothing else would show it.
|
|
*/
|
|
export function mediaInflightReserved(): number {
|
|
return mediaInflightBytes;
|
|
}
|
|
|
|
type MediaReservation =
|
|
| { ok: true; bytes: number }
|
|
| { ok: false; response: Response };
|
|
|
|
/**
|
|
* Bytes ONE response on this surface may be buffered into, which is also the
|
|
* reservation it takes. Zero means the surface is not buffered at all: a TTS
|
|
* reply streams straight through, so it needs no cap and takes no reservation.
|
|
*
|
|
* `sampleCount` before `n`, matching `imagen.ts`. `Math.min` is defence in
|
|
* depth, not the bound - the bound is the schema's `.max(MAX_IMAGE_N)`, which
|
|
* every caller of this function has already applied.
|
|
*/
|
|
function mediaReservationBytes(
|
|
pathname: string,
|
|
body?: ImageGenerationRequest,
|
|
): number {
|
|
if (pathname === IMAGES_PATH) {
|
|
const requested = clampCount(body?.sampleCount ?? body?.n ?? 1) || 1;
|
|
return Math.min(requested, MAX_IMAGE_N) * MEDIA_BYTES_PER_IMAGE;
|
|
}
|
|
if (pathname === TRANSCRIPTIONS_PATH) {
|
|
return MAX_TRANSCRIPTION_JSON_BYTES;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Takes `bytes` from the process budget, or refuses with the house 429. The
|
|
* refusal happens before the provider is reached, so nothing was spent and
|
|
* there is nothing to bill. Fail-closed: over budget denies.
|
|
*/
|
|
function reserveMediaInflight(
|
|
bytes: number,
|
|
counters: CounterSink,
|
|
): MediaReservation {
|
|
if (bytes <= 0) {
|
|
return { ok: true, bytes: 0 };
|
|
}
|
|
if (mediaInflightBytes + bytes > MAX_MEDIA_INFLIGHT_BYTES) {
|
|
counters.increment("media.inflight_rejected");
|
|
const response = errorResponse(
|
|
429,
|
|
"Too many concurrent media responses in flight; retry.",
|
|
"governance_error",
|
|
undefined,
|
|
"media_inflight",
|
|
);
|
|
// Set in place, as the governance rate-limit denial does. A rebuild would
|
|
// mint a second Response for a header the first one can already carry.
|
|
response.headers.set("Retry-After", "1");
|
|
return { ok: false, response };
|
|
}
|
|
mediaInflightBytes += bytes;
|
|
return { ok: true, bytes };
|
|
}
|
|
|
|
/** Releases a reservation. Runs in a `finally`, so every exit path - return,
|
|
* abort, throw, and the over-cap read - gives the bytes back. */
|
|
function releaseMediaInflight(reservation: MediaReservation): void {
|
|
if (reservation.ok) {
|
|
mediaInflightBytes -= reservation.bytes;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Units the provider's parsed body proves it delivered, gated on the PATHNAME
|
|
* and never on the body's shape: `/v1/embeddings` answers with `data: []` too,
|
|
* so a shape-driven count would bill embeddings as images.
|
|
*/
|
|
export function countUnits(pathname: string, body: unknown): MediaUnits {
|
|
if (pathname === IMAGES_PATH) {
|
|
const data = (body as { data?: unknown } | undefined)?.data;
|
|
return Array.isArray(data) ? { imageCount: clampCount(data.length) } : {};
|
|
}
|
|
if (pathname === TRANSCRIPTIONS_PATH) {
|
|
const parsed = body as
|
|
| { usage?: { seconds?: unknown }; duration?: unknown }
|
|
| undefined;
|
|
// `usage.seconds` first: a body can carry it without carrying `duration`,
|
|
// so taking it first strictly increases the priced population.
|
|
const seconds = clampSeconds(parsed?.usage?.seconds) ??
|
|
clampSeconds(parsed?.duration);
|
|
return seconds === undefined ? {} : { audioSeconds: seconds };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* The response body's own token usage, through the one shared normalizer, so a
|
|
* media surface that bills on tokens as well as units is counted from the same
|
|
* parse as its units.
|
|
*
|
|
* An all-zero result is reported as ABSENT rather than as zeros: a body that
|
|
* carries no token fields must not become a billed zero (decision-log 56), and
|
|
* a genuinely zero-token response prices identically either way.
|
|
*/
|
|
export function tokensFrom(
|
|
body: unknown,
|
|
): RequestDispatch["tokens"] | undefined {
|
|
const carrier = body as
|
|
| { usage?: UsageShape; usageMetadata?: UsageShape }
|
|
| undefined;
|
|
const usage = carrier?.usage ?? carrier?.usageMetadata;
|
|
if (!usage || typeof usage !== "object") {
|
|
return undefined;
|
|
}
|
|
const n = normalizeUsage(usage);
|
|
if (n.prompt + n.completion + n.cached + n.cacheCreation === 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
prompt: n.prompt,
|
|
completion: n.completion,
|
|
cached: n.cached,
|
|
cacheCreation: n.cacheCreation,
|
|
};
|
|
}
|
|
|
|
/** Parses buffered provider bytes, or undefined when they are not JSON - a
|
|
* transcription can legitimately answer `text`, `srt` or `vtt`. The bytes reach
|
|
* the client either way, so this must never throw. */
|
|
function safeJson(bytes: Uint8Array): unknown {
|
|
try {
|
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Records the provider's own status, from whichever shape it arrived in, and
|
|
* reports whether a provider was reached at all.
|
|
*
|
|
* `rawProxy` is the single `!ok` authority: it THROWS a ProviderError rather
|
|
* than returning a `!ok` Response, and every `generateImage` adapter does the
|
|
* same. So on a provider error the status arrives as the error, never as
|
|
* `upstream.status`, and a route that read only `upstream.status` would leave a
|
|
* provider 400 indistinguishable from a refused connection - which is exactly
|
|
* the discrimination the write gate depends on.
|
|
*
|
|
* Residual: an adapter that raises ProviderError from its OWN request validation
|
|
* (azure with no deployment, gemini with an unsupported response_format) records
|
|
* a status no provider produced. It cannot cause an over-bill, because no
|
|
* quantity is ever written on this path.
|
|
*/
|
|
function recordProviderStatus(
|
|
ctx: AppContext,
|
|
req: Request,
|
|
error: unknown,
|
|
): boolean {
|
|
if (!(error instanceof ProviderError)) {
|
|
return false;
|
|
}
|
|
if (!mergeRequestStatus(req, error.status)) {
|
|
ctx.metrics.increment("accounting.status_dropped");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Dispatches a buffered media surface and writes the accounting channel.
|
|
*
|
|
* The write gate is the whole point: `units` and `tokens` reach the channel only
|
|
* once the provider's response headers are in hand AND `upstream.ok`. A gate at
|
|
* the read site could not do this - `mapDispatchError` returns a Response
|
|
* carrying the PROVIDER's status, so a provider 400 and a gateway 400 are
|
|
* indistinguishable there, and a refused connection produces no Response at
|
|
* all. This site knows the provider's status exactly, from `upstream.status` on
|
|
* the success path and from the thrown ProviderError otherwise.
|
|
*/
|
|
async function dispatchBufferedMedia(
|
|
ctx: AppContext,
|
|
req: Request,
|
|
pathname: string,
|
|
target: ResolvedTarget,
|
|
reservationBytes: number,
|
|
dispatch: (signal: AbortSignal) => Promise<Response>,
|
|
): Promise<Response> {
|
|
const reservation = reserveMediaInflight(reservationBytes, ctx.metrics);
|
|
if (!reservation.ok) {
|
|
return reservation.response;
|
|
}
|
|
let headersSeen = false;
|
|
try {
|
|
setRequestDispatch(
|
|
req,
|
|
{ providerId: target.providerId, model: target.model },
|
|
ctx.metrics,
|
|
);
|
|
const upstream = await dispatchThenDetach(req, dispatch);
|
|
headersSeen = true;
|
|
if (!mergeRequestStatus(req, upstream.status)) {
|
|
// A dropped status is a silently unbilled row; keep it observable.
|
|
ctx.metrics.increment("accounting.status_dropped");
|
|
}
|
|
if (!upstream.ok) {
|
|
return upstream; // no units, no tokens: no provider work was delivered
|
|
}
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = await readCappedBytes(upstream.body, reservation.bytes);
|
|
} catch (error) {
|
|
if (error instanceof GatewayError && error.status === 413) {
|
|
// readCappedBytes' 413 describes a REQUEST body. An over-cap PROVIDER
|
|
// response is a provider-contract violation, so it must not reach the
|
|
// client as a client error - and the row stays unbilled (status
|
|
// recorded, no units, no tokens) and counted.
|
|
ctx.metrics.increment("media.body_cap_exceeded");
|
|
return errorResponse(
|
|
502,
|
|
`Provider response exceeded the ${reservation.bytes}-byte buffer ` +
|
|
`limit for this surface.`,
|
|
"provider_error",
|
|
undefined,
|
|
"media_body_cap_exceeded",
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
const parsed = safeJson(bytes);
|
|
mergeRequestUnits(req, countUnits(pathname, parsed));
|
|
mergeRequestTokens(req, tokensFrom(parsed));
|
|
// readCappedBytes always merges into a fresh `new Uint8Array(total)`, which
|
|
// is ArrayBuffer-backed; the assertion only narrows it to what BodyInit
|
|
// requires (the same narrowing gemini.ts does for pcmToWav).
|
|
return rebuild(upstream, bytes as Uint8Array<ArrayBuffer>);
|
|
} catch (error) {
|
|
const reached = recordProviderStatus(ctx, req, error);
|
|
if (!headersSeen && !reached && req.signal.aborted) {
|
|
ctx.metrics.increment("media.abort_pre_headers");
|
|
}
|
|
return mapDispatchError(error);
|
|
} finally {
|
|
releaseMediaInflight(reservation);
|
|
}
|
|
}
|
|
|
|
export function registerAdvancedRoutes(router: Router, ctx: AppContext): void {
|
|
router.post("/v1/embeddings", async (req) => {
|
|
const parsed = EmbeddingRequestSchema.safeParse(await parseJsonBody(req));
|
|
if (!parsed.success) {
|
|
return validationErrorResponse(parsed.error);
|
|
}
|
|
ctx.metrics.increment("requests.embeddings");
|
|
try {
|
|
const target = targetFromModel(ctx, parsed.data.model);
|
|
requireCapability(target, "supportsEmbeddings", "embeddings");
|
|
if (!target.adapter.embeddings) {
|
|
throw new GatewayError(
|
|
400,
|
|
`Provider "${target.providerId}" has no embeddings adapter.`,
|
|
);
|
|
}
|
|
return await target.adapter.embeddings(
|
|
{ ...parsed.data, model: target.model },
|
|
{ signal: req.signal },
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.post(IMAGES_PATH, async (req) => {
|
|
const parsed = ImageGenerationRequestSchema.safeParse(
|
|
await parseJsonBody(req),
|
|
);
|
|
if (!parsed.success) {
|
|
return validationErrorResponse(parsed.error);
|
|
}
|
|
ctx.metrics.increment("requests.images");
|
|
// True once the native adapter has been called. Scopes the pre-dispatch
|
|
// abort counter below to that branch: the rawProxy branch counts its own
|
|
// inside dispatchBufferedMedia, and mapDispatchError rethrows an AbortError
|
|
// through here, so an unscoped increment would count it twice.
|
|
let nativeDispatched = false;
|
|
try {
|
|
const target = parsed.data.model
|
|
? targetFromModel(ctx, parsed.data.model)
|
|
: targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsImages", "image generation");
|
|
const body = parsed.data.model
|
|
? { ...parsed.data, model: target.model }
|
|
: parsed.data;
|
|
// Native image adapters (e.g. Google Imagen `:predict`) translate the
|
|
// request themselves; OpenAI-wire providers (OpenAI, Nebius) keep the
|
|
// byte-identical rawProxy passthrough.
|
|
if (target.adapter.generateImage) {
|
|
const generate = target.adapter.generateImage.bind(target.adapter);
|
|
setRequestDispatch(
|
|
req,
|
|
{ providerId: target.providerId, model: target.model },
|
|
ctx.metrics,
|
|
);
|
|
nativeDispatched = true;
|
|
// dispatchDetached, not dispatchThenDetach: this adapter resolves only
|
|
// after it has read and parsed the provider's whole body, so there is
|
|
// no header boundary here for the route to detach at. Keeping the
|
|
// client's abort live would cancel a render the provider has already
|
|
// performed - the images would be charged and none of them counted.
|
|
const result = await dispatchDetached(
|
|
req,
|
|
(signal) => generate(body, { signal }),
|
|
);
|
|
// Every generateImage adapter throws ProviderError on a non-2xx, so a
|
|
// resolved call IS the evidence a provider was reached and delivered.
|
|
// The typed return cannot carry the exact code, so 200 stands for it.
|
|
// No reservation: the adapter buffers its own JSON, so there is no
|
|
// gateway-side capped read for a reservation to bound.
|
|
mergeRequestStatus(req, 200);
|
|
mergeRequestUnits(req, countUnits(IMAGES_PATH, result));
|
|
mergeRequestTokens(req, tokensFrom(result));
|
|
return jsonResponse(result);
|
|
}
|
|
const proxy = requireRawProxy(target);
|
|
return await dispatchBufferedMedia(
|
|
ctx,
|
|
req,
|
|
IMAGES_PATH,
|
|
target,
|
|
mediaReservationBytes(IMAGES_PATH, parsed.data),
|
|
(signal) =>
|
|
proxy(
|
|
"/images/generations",
|
|
jsonForward("/images/generations", body),
|
|
{ signal },
|
|
),
|
|
);
|
|
} catch (error) {
|
|
// The native generateImage branch dispatches inside this try, and its
|
|
// adapters throw ProviderError on a non-2xx, so this is where that
|
|
// branch's provider status is recovered. dispatchBufferedMedia records
|
|
// its own and never lets a ProviderError escape, so there is no overlap.
|
|
const reached = recordProviderStatus(ctx, req, error);
|
|
if (nativeDispatched && !reached && req.signal.aborted) {
|
|
// The only abort this branch can still throw on is one that had already
|
|
// landed at entry, so no provider was reached and no quantity exists.
|
|
ctx.metrics.increment("media.abort_pre_headers");
|
|
}
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.post(SPEECH_PATH, async (req) => {
|
|
const parsed = SpeechRequestSchema.safeParse(await parseJsonBody(req));
|
|
if (!parsed.success) {
|
|
return validationErrorResponse(parsed.error);
|
|
}
|
|
ctx.metrics.increment("requests.speech");
|
|
// Request-side quantity, so it is free and complete before dispatch - but it
|
|
// stays in a LOCAL until the provider's headers prove a provider was
|
|
// reached. Code points, not UTF-16 units: `[...s].length` counts an
|
|
// astral-plane character once.
|
|
const characterCount = clampCount([...parsed.data.input].length);
|
|
// Gemini TTS is token-priced and answers with audio bytes, so its usage
|
|
// block arrives through this callback rather than through a JSON body. The
|
|
// numbers are provider-derived; the route bounds them here.
|
|
let reported: RequestDispatch["tokens"] | undefined;
|
|
const onUsage = (usage: { prompt?: number; completion?: number }): void => {
|
|
reported = {
|
|
prompt: clampCount(usage.prompt),
|
|
completion: clampCount(usage.completion),
|
|
cached: 0,
|
|
cacheCreation: 0,
|
|
};
|
|
};
|
|
let headersSeen = false;
|
|
try {
|
|
const target = targetFromModel(ctx, parsed.data.model);
|
|
requireCapability(target, "supportsAudio", "text-to-speech");
|
|
const proxy = requireRawProxy(target);
|
|
setRequestDispatch(
|
|
req,
|
|
{ providerId: target.providerId, model: target.model },
|
|
ctx.metrics,
|
|
);
|
|
const upstream = await dispatchThenDetach(req, (signal) =>
|
|
proxy(
|
|
"/audio/speech",
|
|
jsonForward("/audio/speech", { ...parsed.data, model: target.model }),
|
|
{ signal, onUsage },
|
|
));
|
|
headersSeen = true;
|
|
if (!mergeRequestStatus(req, upstream.status)) {
|
|
ctx.metrics.increment("accounting.status_dropped");
|
|
}
|
|
if (upstream.ok) {
|
|
mergeRequestUnits(req, { characterCount });
|
|
mergeRequestTokens(req, reported);
|
|
}
|
|
// No buffering, no decode, no rebuild, no reservation: the provider's
|
|
// audio stream is returned untouched.
|
|
return upstream;
|
|
} catch (error) {
|
|
const reached = recordProviderStatus(ctx, req, error);
|
|
if (!headersSeen && !reached && req.signal.aborted) {
|
|
ctx.metrics.increment("media.abort_pre_headers");
|
|
}
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
// Multipart passthrough: routed by ?provider= or the default provider.
|
|
router.post(TRANSCRIPTIONS_PATH, async (req) => {
|
|
ctx.metrics.increment("requests.transcriptions");
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsAudio", "transcription");
|
|
const proxy = requireRawProxy(target);
|
|
return await dispatchBufferedMedia(
|
|
ctx,
|
|
req,
|
|
TRANSCRIPTIONS_PATH,
|
|
target,
|
|
mediaReservationBytes(TRANSCRIPTIONS_PATH),
|
|
(signal) => proxy("/audio/transcriptions", req, { signal }),
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.post("/v1/files", async (req) => {
|
|
ctx.metrics.increment("requests.files");
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "file APIs");
|
|
return await requireRawProxy(target)("/files", req, {
|
|
signal: req.signal,
|
|
});
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.get("/v1/files", async (req) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "file APIs");
|
|
return await requireRawProxy(target)("/files", req, {
|
|
signal: req.signal,
|
|
});
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
// Retrieve the raw bytes/results of a file (e.g. a batch output_file_id).
|
|
// Registered before /v1/files/:id so the 4-segment path wins its own match.
|
|
router.get("/v1/files/:id/content", async (req, match) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "file APIs");
|
|
return await requireRawProxy(target)(
|
|
`/files/${match.pathname.groups.id}/content`,
|
|
req,
|
|
{ signal: req.signal },
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.get("/v1/files/:id", async (req, match) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "file APIs");
|
|
return await requireRawProxy(target)(
|
|
`/files/${match.pathname.groups.id}`,
|
|
req,
|
|
{ signal: req.signal },
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.delete("/v1/files/:id", async (req, match) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "file APIs");
|
|
return await requireRawProxy(target)(
|
|
`/files/${match.pathname.groups.id}`,
|
|
req,
|
|
{ signal: req.signal },
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.post("/v1/batches", async (req) => {
|
|
ctx.metrics.increment("requests.batches");
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "batch APIs");
|
|
return await requireRawProxy(target)("/batches", req, {
|
|
signal: req.signal,
|
|
});
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.get("/v1/batches/:id", async (req, match) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "batch APIs");
|
|
return await requireRawProxy(target)(
|
|
`/batches/${match.pathname.groups.id}`,
|
|
req,
|
|
{ signal: req.signal },
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
// Provider-specific batch result stream. Keep this before the generic batch
|
|
// retrieval route so `/results` is not mistaken for a batch id.
|
|
router.get("/v1/batches/:id/results", async (req, match) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "batch APIs");
|
|
return await requireRawProxy(target)(
|
|
`/batches/${match.pathname.groups.id}/results`,
|
|
req,
|
|
{ signal: req.signal },
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.get("/v1/batches", async (req) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "batch APIs");
|
|
return await requireRawProxy(target)("/batches", req, {
|
|
signal: req.signal,
|
|
});
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
|
|
router.post("/v1/batches/:id/cancel", async (req, match) => {
|
|
try {
|
|
const target = targetFromQuery(ctx, req);
|
|
requireCapability(target, "supportsFiles", "batch APIs");
|
|
return await requireRawProxy(target)(
|
|
`/batches/${match.pathname.groups.id}/cancel`,
|
|
req,
|
|
{ signal: req.signal },
|
|
);
|
|
} catch (error) {
|
|
return mapDispatchError(error);
|
|
}
|
|
});
|
|
}
|