mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 19:32:20 +03:00
fix(api): page and stream-complete GET /v1/models for large catalogs (#12882)
Validado numa worktree combinada com a onda de dashboard/monitoring desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-api-typecheck OK (289), check-file-size OK após rebaseline, 130/131 nos testes focados — a falha restante é asserção de tempo de parede sob carga, verde 6/6 isolada. Paginar e completar o stream em `GET /v1/models` é a correção certa para catálogo grande: um payload único que cresce com o número de providers vira timeout silencioso no cliente, não erro.
This commit is contained in:
@@ -19,6 +19,7 @@ import { after } from "next/server";
|
||||
import { getModelCatalogCacheVersion } from "@/lib/db/readCache";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
|
||||
import { catalogPageCacheKey, catalogStringResponse, parseCatalogPage } from "./catalogPagination";
|
||||
import { isCodexModelCatalogClient } from "./catalogRequest";
|
||||
|
||||
/** Fingerprint an API key for the catalog memo Map. Never store the raw secret. */
|
||||
@@ -151,8 +152,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(label)), ms);
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(err) => { clearTimeout(timer); reject(err); }
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -180,7 +187,8 @@ function buildCatalogCacheKey(request: Request, catalogSettings?: CatalogCacheOp
|
||||
const configuredOnly = url.searchParams.get("configuredOnly") === "true" ? "1" : "0";
|
||||
const hideAuto = catalogSettings?.hideAutoCombos ? "1" : "0";
|
||||
const hideNoThink = catalogSettings?.hideNoThinkVariants ? "1" : "0";
|
||||
return `${prefix}|${isCodex}|${fingerprintCatalogAuthKey(apiKey)}|${configuredOnly}|${hideAuto}|${hideNoThink}`;
|
||||
const page = catalogPageCacheKey(parseCatalogPage(request));
|
||||
return `${prefix}|${isCodex}|${fingerprintCatalogAuthKey(apiKey)}|${configuredOnly}|${hideAuto}|${hideNoThink}|${page}`;
|
||||
}
|
||||
|
||||
// Tracks the model-catalog cache version (src/lib/db/readCache.ts) as of the last
|
||||
@@ -327,19 +335,21 @@ async function awaitCatalogInFlight(
|
||||
}
|
||||
const lastGood = catalogLastGood.get(cacheKey);
|
||||
if (msg === "catalog_build_timeout" && lastGood) {
|
||||
return new Response(lastGood.body, {
|
||||
status: lastGood.status,
|
||||
headers: mergeCatalogHeaders(corsHeaders, lastGood.headers, diagnosticHeaders, {
|
||||
return catalogStringResponse(
|
||||
lastGood.body,
|
||||
mergeCatalogHeaders(corsHeaders, lastGood.headers, diagnosticHeaders, {
|
||||
"x-omniroute-catalog": "last-good",
|
||||
}),
|
||||
});
|
||||
lastGood.status
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return new Response(payload.body, {
|
||||
status: payload.status,
|
||||
headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders),
|
||||
});
|
||||
return catalogStringResponse(
|
||||
payload.body,
|
||||
mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders),
|
||||
payload.status
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,10 +373,11 @@ export async function resolveCachedCatalogResponse(
|
||||
const cached = catalogCache.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return new Response(cached.body, {
|
||||
status: cached.status,
|
||||
headers: mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
|
||||
});
|
||||
return catalogStringResponse(
|
||||
cached.body,
|
||||
mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
|
||||
cached.status
|
||||
);
|
||||
}
|
||||
|
||||
// Stale-while-revalidate: an expired entry is still served immediately as long as
|
||||
@@ -383,10 +394,11 @@ export async function resolveCachedCatalogResponse(
|
||||
buildPayload,
|
||||
catalogSettings?.scheduleBackgroundRefresh ?? defaultBackgroundRefreshScheduler
|
||||
);
|
||||
return new Response(cached.body, {
|
||||
status: cached.status,
|
||||
headers: mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
|
||||
});
|
||||
return catalogStringResponse(
|
||||
cached.body,
|
||||
mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
|
||||
cached.status
|
||||
);
|
||||
}
|
||||
|
||||
const currentGeneration = getModelCatalogCacheVersion();
|
||||
|
||||
107
src/app/api/v1/models/catalogPagination.ts
Normal file
107
src/app/api/v1/models/catalogPagination.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* OpenAI-compatible `limit` / `after` paging plus a complete JSON body writer
|
||||
* for GET /v1/models. Large catalogs (~1MB+) were returned as one
|
||||
* `Response.json()` write without Content-Length, which cut mid-body for
|
||||
* Desktop and other buffered clients.
|
||||
*/
|
||||
|
||||
export const CATALOG_PAGE_LIMIT_MAX = 10_000;
|
||||
export const CATALOG_BODY_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
export type CatalogPage = {
|
||||
after: string | null;
|
||||
limit: number | null;
|
||||
};
|
||||
|
||||
export type CatalogPageResult<T> = {
|
||||
models: T[];
|
||||
hasMore: boolean;
|
||||
lastId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse OpenAI-style `limit` and `after` query params. Missing or invalid
|
||||
* values mean "return the full list" so existing clients stay byte-compatible
|
||||
* aside from the added Content-Length header.
|
||||
*/
|
||||
export function parseCatalogPage(request: Request): CatalogPage {
|
||||
const url = new URL(request.url);
|
||||
const afterRaw = url.searchParams.get("after");
|
||||
const after = afterRaw && afterRaw.trim().length > 0 ? afterRaw.trim() : null;
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
let limit: number | null = null;
|
||||
if (limitRaw != null && limitRaw.trim() !== "") {
|
||||
const parsed = Number.parseInt(limitRaw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
limit = Math.min(parsed, CATALOG_PAGE_LIMIT_MAX);
|
||||
}
|
||||
}
|
||||
return { after, limit };
|
||||
}
|
||||
|
||||
export function catalogPageCacheKey(page: CatalogPage): string {
|
||||
return `after=${page.after ?? ""};limit=${page.limit ?? ""}`;
|
||||
}
|
||||
|
||||
export function applyCatalogPage<T extends { id?: unknown }>(
|
||||
models: T[],
|
||||
page: CatalogPage
|
||||
): CatalogPageResult<T> {
|
||||
if (!page.after && page.limit == null) {
|
||||
return { models, hasMore: false, lastId: null };
|
||||
}
|
||||
|
||||
let start = 0;
|
||||
if (page.after) {
|
||||
const idx = models.findIndex((model) => model.id === page.after);
|
||||
start = idx >= 0 ? idx + 1 : 0;
|
||||
}
|
||||
|
||||
const end = page.limit != null ? start + page.limit : models.length;
|
||||
const sliced = models.slice(start, end);
|
||||
const last = sliced[sliced.length - 1];
|
||||
return {
|
||||
models: sliced,
|
||||
hasMore: end < models.length,
|
||||
lastId: typeof last?.id === "string" ? last.id : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a catalog JSON object with Content-Length and chunked enqueue so
|
||||
* the body cannot be cut mid-write on large payloads.
|
||||
*/
|
||||
export function catalogJsonResponse(
|
||||
body: Record<string, unknown>,
|
||||
headers: Record<string, string> = {},
|
||||
status = 200
|
||||
): Response {
|
||||
const payload = JSON.stringify(body);
|
||||
return catalogStringResponse(payload, headers, status);
|
||||
}
|
||||
|
||||
export function catalogStringResponse(
|
||||
payload: string,
|
||||
headers: Headers | Record<string, string> = {},
|
||||
status = 200
|
||||
): Response {
|
||||
const bytes = Buffer.byteLength(payload);
|
||||
const merged = headers instanceof Headers ? new Headers(headers) : new Headers(headers);
|
||||
merged.set("content-type", "application/json");
|
||||
merged.set("content-length", String(bytes));
|
||||
|
||||
if (bytes <= CATALOG_BODY_CHUNK_BYTES) {
|
||||
return new Response(payload, { status, headers: merged });
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const buf = Buffer.from(payload);
|
||||
for (let offset = 0; offset < buf.length; offset += CATALOG_BODY_CHUNK_BYTES) {
|
||||
controller.enqueue(buf.subarray(offset, offset + CATALOG_BODY_CHUNK_BYTES));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status, headers: merged });
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/shared/utils/featureFlags";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { maybeOmitCatalogModelName } from "./catalogHelpers";
|
||||
import { applyCatalogPage, catalogJsonResponse, parseCatalogPage } from "./catalogPagination";
|
||||
import { isCodexModelCatalogClient } from "./catalogRequest";
|
||||
|
||||
/**
|
||||
@@ -283,13 +284,19 @@ export async function finalizeCatalogResponse(
|
||||
// empty/foreign `base_instructions` would drop codex's agent prompt to nothing and
|
||||
// break its agent behavior (verified empirically against codex 0.137). An empty array
|
||||
// keeps codex on its built-in model info — same inference as today, minus the error.
|
||||
const page = parseCatalogPage(request);
|
||||
const paged = applyCatalogPage(orderedModels, page);
|
||||
const responseBody: Record<string, unknown> = {
|
||||
object: "list",
|
||||
data: orderedModels,
|
||||
data: paged.models,
|
||||
};
|
||||
if (page.limit != null || page.after) {
|
||||
responseBody.has_more = paged.hasMore;
|
||||
if (paged.lastId) responseBody.last_id = paged.lastId;
|
||||
}
|
||||
if (isCodexModelCatalogClient(request)) {
|
||||
responseBody.models = [];
|
||||
}
|
||||
|
||||
return Response.json(responseBody, { headers });
|
||||
return catalogJsonResponse(responseBody, headers);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,11 @@ function captureMapKeys(): { keys: string[]; restore: () => void } {
|
||||
};
|
||||
}
|
||||
|
||||
// buildCatalogCacheKey emits `prefix|isCodex|apiKeyFingerprint|configuredOnly|hideAuto|hideNoThink`
|
||||
// (6 pipe-delimited fields). Other in-flight keys (e.g. `x-request-id`) don't match.
|
||||
// buildCatalogCacheKey emits
|
||||
// `prefix|isCodex|apiKeyFingerprint|configuredOnly|hideAuto|hideNoThink|page`
|
||||
// (7 pipe-delimited fields). Other in-flight keys (e.g. `x-request-id`) don't match.
|
||||
function isCatalogCacheKey(k: string): boolean {
|
||||
return k.split("|").length === 6;
|
||||
return k.split("|").length === 7;
|
||||
}
|
||||
|
||||
test("catalog cache Map keys must not contain the raw bearer API key (#10313)", async () => {
|
||||
|
||||
79
tests/unit/models-catalog-pagination.test.ts
Normal file
79
tests/unit/models-catalog-pagination.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyCatalogPage,
|
||||
CATALOG_BODY_CHUNK_BYTES,
|
||||
catalogJsonResponse,
|
||||
catalogPageCacheKey,
|
||||
catalogStringResponse,
|
||||
parseCatalogPage,
|
||||
} from "../../src/app/api/v1/models/catalogPagination.ts";
|
||||
|
||||
function req(url: string): Request {
|
||||
return new Request(url);
|
||||
}
|
||||
|
||||
test("parseCatalogPage treats missing params as a full list", () => {
|
||||
assert.deepEqual(parseCatalogPage(req("http://local/v1/models")), { after: null, limit: null });
|
||||
});
|
||||
|
||||
test("parseCatalogPage accepts OpenAI-compatible limit and after", () => {
|
||||
assert.deepEqual(parseCatalogPage(req("http://local/v1/models?limit=20&after=gpt-4o")), {
|
||||
after: "gpt-4o",
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseCatalogPage ignores non-positive limits", () => {
|
||||
assert.deepEqual(parseCatalogPage(req("http://local/v1/models?limit=0&after=")), {
|
||||
after: null,
|
||||
limit: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyCatalogPage slices after a cursor and reports has_more", () => {
|
||||
const models = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
|
||||
const page = applyCatalogPage(models, { after: "b", limit: 1 });
|
||||
assert.deepEqual(page, { models: [{ id: "c" }], hasMore: true, lastId: "c" });
|
||||
});
|
||||
|
||||
test("applyCatalogPage returns the remainder when limit exceeds the tail", () => {
|
||||
const models = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
const page = applyCatalogPage(models, { after: "b", limit: 10 });
|
||||
assert.deepEqual(page, { models: [{ id: "c" }], hasMore: false, lastId: "c" });
|
||||
});
|
||||
|
||||
test("catalogPageCacheKey distinguishes pages so cached bodies do not mix", () => {
|
||||
assert.notEqual(
|
||||
catalogPageCacheKey({ after: null, limit: 10 }),
|
||||
catalogPageCacheKey({ after: "gpt-4o", limit: 10 })
|
||||
);
|
||||
});
|
||||
|
||||
test("catalogJsonResponse sets Content-Length to the full UTF-8 byte count", async () => {
|
||||
const body = { object: "list", data: [{ id: "x", name: "café" }] };
|
||||
const response = catalogJsonResponse(body, {});
|
||||
const payload = JSON.stringify(body);
|
||||
assert.equal(response.headers.get("content-length"), String(Buffer.byteLength(payload)));
|
||||
assert.equal(await response.text(), payload);
|
||||
});
|
||||
|
||||
test("catalogStringResponse streams large bodies without cutting mid-JSON", async () => {
|
||||
const payload = JSON.stringify({
|
||||
object: "list",
|
||||
data: Array.from({ length: 80 }, (_, i) => ({
|
||||
id: `model-${i}`,
|
||||
filler: "x".repeat(2_000),
|
||||
})),
|
||||
});
|
||||
assert.ok(Buffer.byteLength(payload) > CATALOG_BODY_CHUNK_BYTES);
|
||||
|
||||
const response = catalogStringResponse(payload, {});
|
||||
assert.equal(response.headers.get("content-length"), String(Buffer.byteLength(payload)));
|
||||
const text = await response.text();
|
||||
assert.equal(text.length, payload.length);
|
||||
assert.equal(text, payload);
|
||||
const parsed = JSON.parse(text) as { data: unknown[] };
|
||||
assert.equal(parsed.data.length, 80);
|
||||
});
|
||||
Reference in New Issue
Block a user