mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
2 Commits
fix/v3850-
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6afa6846c8 | ||
|
|
acb15fefba |
1
changelog.d/fixes/11367-catalog-eventloop-9147.md
Normal file
1
changelog.d/fixes/11367-catalog-eventloop-9147.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(catalog):** keep large `/v1/models` builds responsive by reusing the build-local capability snapshot throughout enrichment and Auto-Combo preparation, yielding cooperatively while constructing virtual candidate pools, and avoiding unrelated synchronous database diagnostics on the cache-TTL read path ([#11367](https://github.com/diegosouzapw/OmniRoute/pull/11367))
|
||||
@@ -1 +0,0 @@
|
||||
- **docs(openapi):** document the conditionally management-authenticated, same-origin `POST /api/openapi/try` proxy contract and restore the release branch's operation-coverage ratchet ([#11363](https://github.com/diegosouzapw/OmniRoute/pull/11363))
|
||||
@@ -6931,104 +6931,6 @@ paths:
|
||||
"500":
|
||||
description: Failed to parse OpenAPI spec
|
||||
|
||||
/api/openapi/try:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Proxy an API Explorer request to an OmniRoute endpoint
|
||||
description: >-
|
||||
Executes an API Explorer request through a server-side, same-origin proxy. The target
|
||||
must start with `/api/`, `/v1/`, `/v1beta/`, `/a2a`, or
|
||||
`/.well-known/agent.json`; protocol-relative and cross-origin targets are rejected.
|
||||
Hop-by-hop, proxy, host, cookie, and forwarding headers supplied in `headers` are
|
||||
stripped, while any dashboard cookie on the original request is forwarded separately.
|
||||
When `requireLogin` is disabled, the management-auth bypass mirrors the runtime setting;
|
||||
otherwise a management Bearer credential or dashboard session is required. Failures
|
||||
caught after authentication, including request JSON parsing, fetch, and response-body
|
||||
parsing failures, are returned in the normal HTTP 200 result envelope so the Explorer
|
||||
can display them; `status: 0` identifies that caught-failure path.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ManagementSessionAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [path]
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
|
||||
default: GET
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
pattern: "^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)"
|
||||
description: Same-origin OmniRoute API path, optionally including a query string.
|
||||
headers:
|
||||
type: object
|
||||
default: {}
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: >-
|
||||
Headers to forward after removing connection, content-length, cookie, host,
|
||||
keep-alive, proxy-authenticate, proxy-authorization, te, trailer,
|
||||
transfer-encoding, upgrade, x-forwarded-for, x-forwarded-host, and
|
||||
x-forwarded-proto headers.
|
||||
body:
|
||||
description: >-
|
||||
Optional JSON value. A truthy value is serialized unless it is already a
|
||||
string, and is not forwarded when `method` is `GET`.
|
||||
responses:
|
||||
"200":
|
||||
description: Upstream response or displayable caught-failure envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [status, statusText, headers, body, latencyMs, contentType]
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Upstream HTTP status, or 0 when request processing throws.
|
||||
statusText:
|
||||
type: string
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
body:
|
||||
description: >-
|
||||
Parsed JSON, response text truncated after 10,000 characters, or a sanitized
|
||||
caught-error object.
|
||||
latencyMs:
|
||||
type: integer
|
||||
minimum: 0
|
||||
contentType:
|
||||
type: string
|
||||
"400":
|
||||
description: Invalid request body or non-same-origin path
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ValidationErrorResponse"
|
||||
- type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: Path must be same-origin
|
||||
"401":
|
||||
$ref: "#/components/responses/ManagementAuthenticationRequired"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagementInvalidToken"
|
||||
"503":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
# ─── Agent Skills Catalog ────────────────────────────────────────────────────
|
||||
|
||||
/api/agent-skills:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilities";
|
||||
|
||||
import type { AutoVariant } from "./autoPrefix";
|
||||
import { VALID_VARIANTS } from "./autoPrefix";
|
||||
import type { PreparedVirtualAutoComboInputs } from "./virtualFactory";
|
||||
@@ -119,8 +121,7 @@ export function isPaidTierAutoId(autoId: string): boolean {
|
||||
* a candidate filter so the virtual combo only scores vision-capable models.
|
||||
*/
|
||||
export type BuiltinAutoSpec =
|
||||
| { variant: AutoVariant | undefined }
|
||||
| { category: AutoCategory; tier?: AutoTier };
|
||||
{ variant: AutoVariant | undefined } | { category: AutoCategory; tier?: AutoTier };
|
||||
|
||||
/**
|
||||
* Vision-flavored flat ids that MUST resolve to the `vision` category (candidate
|
||||
@@ -159,9 +160,14 @@ export function resolveBuiltinAutoSpec(modelStr: string, suffix: string): Builti
|
||||
return { variant: undefined };
|
||||
}
|
||||
|
||||
export async function prepareBuiltinAutoComboInputs(): Promise<PreparedVirtualAutoComboInputs> {
|
||||
export async function prepareBuiltinAutoComboInputs(
|
||||
resolutionSnapshot?: ModelCapabilityResolutionSnapshot
|
||||
): Promise<PreparedVirtualAutoComboInputs> {
|
||||
const { prepareVirtualAutoComboInputs } = await import("./virtualFactory.ts");
|
||||
return prepareVirtualAutoComboInputs({ includeResolvedCapabilities: true });
|
||||
return prepareVirtualAutoComboInputs({
|
||||
includeResolvedCapabilities: true,
|
||||
resolutionSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBuiltinAutoCombo(
|
||||
|
||||
@@ -404,7 +404,9 @@ export function computeAdvertisedLimits(candidates: AdvertisedLimitCandidate[]):
|
||||
return { contextLength, maxOutputTokens };
|
||||
}
|
||||
|
||||
const PREPARED_CAPABILITY_YIELD_INTERVAL = 16;
|
||||
// Catalog-scale pools can contain hundreds of models. Keep both candidate construction
|
||||
// and capability preparation cooperative instead of monopolising one event-loop turn.
|
||||
const VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL = 4;
|
||||
|
||||
type PreparedCapabilityValues = {
|
||||
resolvedContextLength: number | null;
|
||||
@@ -468,7 +470,7 @@ async function attachPreparedCapabilityValues(
|
||||
};
|
||||
byModel.set(candidate.model, values);
|
||||
state.resolvedSinceYield++;
|
||||
if (state.resolvedSinceYield >= PREPARED_CAPABILITY_YIELD_INTERVAL) {
|
||||
if (state.resolvedSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) {
|
||||
state.resolvedSinceYield = 0;
|
||||
await yieldVirtualAutoPreparationTurn();
|
||||
}
|
||||
@@ -479,7 +481,10 @@ async function attachPreparedCapabilityValues(
|
||||
}
|
||||
|
||||
export async function prepareVirtualAutoComboInputs(
|
||||
options: { includeResolvedCapabilities?: boolean } = {}
|
||||
options: {
|
||||
includeResolvedCapabilities?: boolean;
|
||||
resolutionSnapshot?: ModelCapabilityResolutionSnapshot;
|
||||
} = {}
|
||||
): Promise<PreparedVirtualAutoComboInputs> {
|
||||
const [connections, disabledNoAuthConnections, settings] = await Promise.all([
|
||||
getCachedProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
|
||||
@@ -524,6 +529,7 @@ export async function prepareVirtualAutoComboInputs(
|
||||
// Build one logical candidate per provider/model and keep account fallback as an
|
||||
// allowlist on that candidate. This avoids both the old "first registry model per
|
||||
// connection" blind spot and a connections × models Cartesian candidate pool.
|
||||
let candidateModelsSinceYield = 0;
|
||||
for (const [providerId, providerConnections] of connectionsByProvider) {
|
||||
const providerInfo = registry[providerId];
|
||||
const registryModelIds = Array.isArray(providerInfo?.models)
|
||||
@@ -557,6 +563,11 @@ export async function prepareVirtualAutoComboInputs(
|
||||
: Array.from(new Set([...registryModelIds, ...defaultModelIds]));
|
||||
|
||||
for (const modelId of modelIds) {
|
||||
candidateModelsSinceYield++;
|
||||
if (candidateModelsSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) {
|
||||
candidateModelsSinceYield = 0;
|
||||
await yieldVirtualAutoPreparationTurn();
|
||||
}
|
||||
if (hiddenModels?.has(modelId)) continue;
|
||||
|
||||
const allowedConnectionIds = providerConnections
|
||||
@@ -655,7 +666,7 @@ export async function prepareVirtualAutoComboInputs(
|
||||
const capabilityState: PreparedCapabilityState = {
|
||||
byTarget: new Map(),
|
||||
resolvedSinceYield: 0,
|
||||
resolutionSnapshot: createModelCapabilityResolutionSnapshot(),
|
||||
resolutionSnapshot: options.resolutionSnapshot ?? createModelCapabilityResolutionSnapshot(),
|
||||
};
|
||||
return {
|
||||
regularCandidates: await attachPreparedCapabilityValues(regularCandidates, capabilityState),
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
getSettings,
|
||||
getCachedProviderNodes,
|
||||
getModelAliases,
|
||||
getDatabaseSettings,
|
||||
getHiddenModelsByProvider,
|
||||
} from "@/lib/localDb";
|
||||
import { getUserDatabaseSettings } from "@/lib/db/databaseSettings";
|
||||
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
|
||||
import { extractAliasBackedModels } from "./aliasBackedModels";
|
||||
import {
|
||||
@@ -229,7 +229,10 @@ async function buildCatalogPayload(
|
||||
// Falls back to the hardcoded default if not set or on error.
|
||||
let cacheTTL = CATALOG_CACHE_TTL_MS_DEFAULT;
|
||||
try {
|
||||
const dbSettings = await getDatabaseSettings();
|
||||
// Only the persisted cache section is needed here. The full database-settings
|
||||
// view also calculates dbstat, WAL, schema and integrity diagnostics, which are
|
||||
// synchronous and can pin the event loop after an otherwise cooperative build.
|
||||
const dbSettings = getUserDatabaseSettings();
|
||||
cacheTTL = dbSettings.cache?.modelCatalogCacheTtlMs ?? CATALOG_CACHE_TTL_MS_DEFAULT;
|
||||
} catch {
|
||||
// Swallow — use default TTL on DB error
|
||||
@@ -249,7 +252,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
// event-loop yield, so a large deployment pins the single Node.js thread for the
|
||||
// whole build (reporter: 183 connections / 2000+ models → 10.1s stall that blocks the
|
||||
// dashboard WS heartbeat). Yield every `catYIELD_EVERY` items across the hot loops.
|
||||
const catYIELD_EVERY = 20;
|
||||
const catYIELD_EVERY = 5;
|
||||
let catYieldCount = 0;
|
||||
const maybeYieldCatalogBuild = async (): Promise<void> => {
|
||||
catYieldCount++;
|
||||
@@ -393,11 +396,10 @@ async function buildUnifiedModelsResponseCore(
|
||||
): boolean => {
|
||||
if (!providerKey || !modelId) return false;
|
||||
const canonical = canonicalProviderId || resolveCanonicalProviderId(providerKey);
|
||||
const alias =
|
||||
providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined;
|
||||
const alias = providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined;
|
||||
const nodePrefix = providerIdToPrefix[providerKey] || providerIdToPrefix[canonical];
|
||||
const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter(
|
||||
(k): k is string => Boolean(k)
|
||||
const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter((k): k is string =>
|
||||
Boolean(k)
|
||||
);
|
||||
for (const key of keysToCheck) {
|
||||
const hiddenSet = hiddenModelsByProvider.get(key);
|
||||
@@ -830,7 +832,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
try {
|
||||
const suffix = autoId.replace(/^auto\/?/, "");
|
||||
if (!preparedAutoInputs) {
|
||||
preparedAutoInputs = await prepareBuiltinAutoComboInputs();
|
||||
preparedAutoInputs = await prepareBuiltinAutoComboInputs(capabilityResolutionSnapshot);
|
||||
await yieldCatalogBuildTurn();
|
||||
}
|
||||
const virtualCombo = await createBuiltinAutoCombo(autoId, suffix, preparedAutoInputs);
|
||||
@@ -1053,11 +1055,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
// `openai` provider page (codex runs on the openai-compatible connection)
|
||||
// or via the `cx` alias — check all three so a hide from any of them
|
||||
// suppresses the bare model id here.
|
||||
if (
|
||||
isModelHiddenBulk("codex", modelId) ||
|
||||
isModelHiddenBulk("openai", modelId)
|
||||
)
|
||||
continue;
|
||||
if (isModelHiddenBulk("codex", modelId) || isModelHiddenBulk("openai", modelId)) continue;
|
||||
|
||||
const alias = providerIdToAlias.codex || "cx";
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
@@ -1892,7 +1890,9 @@ async function buildUnifiedModelsResponseCore(
|
||||
|
||||
const modelId =
|
||||
model.root || (typeof model.id === "string" ? model.id.split("/").pop() : undefined);
|
||||
return modelId ? getTokenLimit(canonicalId, modelId) : getTokenLimit(canonicalId);
|
||||
return modelId
|
||||
? getTokenLimit(canonicalId, modelId, capabilityResolutionSnapshot)
|
||||
: getTokenLimit(canonicalId, null, capabilityResolutionSnapshot);
|
||||
};
|
||||
|
||||
let enrichmentSnapshot: CatalogEnrichmentSnapshot | undefined;
|
||||
@@ -1905,7 +1905,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
}
|
||||
enrichmentSnapshot = {
|
||||
modelsDevPricing,
|
||||
capabilityResolution: capabilityResolutionSnapshot,
|
||||
capabilityResolutionSnapshot,
|
||||
providerNodeIdsByPrefix: providerNodeIdByPrefix,
|
||||
};
|
||||
// The production profile identified pricing snapshot construction as the last
|
||||
|
||||
@@ -227,7 +227,8 @@ export async function finalizeCatalogResponse(
|
||||
// per-entry work is interleaved with other callers / the dashboard WS.
|
||||
const yieldTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
||||
await yieldTurn();
|
||||
const capabilityResolutionSnapshot = createModelCapabilityResolutionSnapshot();
|
||||
const capabilityResolutionSnapshot =
|
||||
enrichmentSnapshot?.capabilityResolutionSnapshot ?? createModelCapabilityResolutionSnapshot();
|
||||
const enriched: Array<Record<string, unknown>> = [];
|
||||
const catYIELD_EVERY = 5;
|
||||
let catEnrichCount = 0;
|
||||
|
||||
@@ -40,7 +40,6 @@ type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export interface CatalogEnrichmentSnapshot {
|
||||
modelsDevPricing: PricingByProvider | null;
|
||||
capabilityResolution?: ModelCapabilityResolutionSnapshot;
|
||||
providerNodeIdsByPrefix?: Readonly<Record<string, string>>;
|
||||
/** #9147: build-local bulk load of synced capabilities + token/context overrides
|
||||
* so per-entry enrichment never hits SQLite again (see catalogResponse.ts). */
|
||||
|
||||
@@ -58,7 +58,7 @@ test.after(async () => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#9147 — catalog build at catalog-scale must not pin the event loop for a long stretch", async () => {
|
||||
test("#9147 — catalog build at catalog-scale must not pin the event loop for a long stretch", async (t) => {
|
||||
await seedCatalogScaleDataset();
|
||||
const req = new Request("http://localhost/v1/models");
|
||||
let settled = false;
|
||||
@@ -79,6 +79,9 @@ test("#9147 — catalog build at catalog-scale must not pin the event loop for a
|
||||
}
|
||||
const res = await buildPromise;
|
||||
assert.equal(res.status, 200);
|
||||
t.diagnostic(
|
||||
`maximum event-loop gap: ${maxGapMs.toFixed(1)}ms across ${ticks} interleaved ticks`
|
||||
);
|
||||
// 150ms is tight on GitHub-hosted unit shards (`--test-concurrency=4`):
|
||||
// sibling tests share the event loop, so a healthy yielding builder still
|
||||
// records 200–260ms gaps. 400ms still fails a true pin (seconds) while
|
||||
@@ -89,4 +92,9 @@ test("#9147 — catalog build at catalog-scale must not pin the event loop for a
|
||||
`catalog for ${CONNECTION_COUNT} connections / ${CONNECTION_COUNT * MODELS_PER_CONNECTION} models ` +
|
||||
`(${ticks} interleaved ticks observed) — the builder is not yielding to the event loop`
|
||||
);
|
||||
const body = (await res.json()) as { data?: Array<{ root?: string }> };
|
||||
assert.ok(
|
||||
body.data?.some((model) => model.root === "probe-model-59-11"),
|
||||
"the responsiveness probe must still traverse and return the last seeded catalog model"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,76 +49,6 @@ test("GET /api/openapi/spec documents its conditional management auth contract",
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/openapi/try documents its bounded management proxy contract", () => {
|
||||
const operation = paths["/api/openapi/try"]?.post;
|
||||
|
||||
assert.ok(operation, "POST /api/openapi/try must be present in docs/openapi.yaml");
|
||||
assert.deepEqual(operation.security, [{ BearerAuth: [] }, { ManagementSessionAuth: [] }]);
|
||||
assert.match(operation.description ?? "", /same-origin/);
|
||||
assert.match(operation.description ?? "", /When `requireLogin` is disabled/);
|
||||
|
||||
const requestBody = operation.requestBody;
|
||||
const requestSchema = requestBody?.content?.["application/json"]?.schema;
|
||||
assert.equal(requestBody?.required, true);
|
||||
assert.equal(requestSchema?.type, "object");
|
||||
assert.deepEqual(requestSchema?.required, ["path"]);
|
||||
assert.deepEqual(requestSchema?.properties?.method?.enum, [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
]);
|
||||
assert.equal(requestSchema?.properties?.method?.default, "GET");
|
||||
assert.equal(requestSchema?.properties?.path?.minLength, 1);
|
||||
assert.equal(
|
||||
requestSchema?.properties?.path?.pattern,
|
||||
"^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)"
|
||||
);
|
||||
assert.equal(requestSchema?.properties?.headers?.type, "object");
|
||||
assert.deepEqual(requestSchema?.properties?.headers?.additionalProperties, {
|
||||
type: "string",
|
||||
});
|
||||
assert.deepEqual(requestSchema?.properties?.headers?.default, {});
|
||||
assert.ok("body" in requestSchema.properties);
|
||||
|
||||
const successSchema = operation.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||
assert.equal(successSchema?.type, "object");
|
||||
assert.equal(successSchema?.additionalProperties, false);
|
||||
assert.deepEqual(successSchema?.required, [
|
||||
"status",
|
||||
"statusText",
|
||||
"headers",
|
||||
"body",
|
||||
"latencyMs",
|
||||
"contentType",
|
||||
]);
|
||||
assert.equal(successSchema?.properties?.status?.type, "integer");
|
||||
assert.equal(successSchema?.properties?.status?.minimum, 0);
|
||||
assert.equal(successSchema?.properties?.statusText?.type, "string");
|
||||
assert.equal(successSchema?.properties?.headers?.type, "object");
|
||||
assert.deepEqual(successSchema?.properties?.headers?.additionalProperties, {
|
||||
type: "string",
|
||||
});
|
||||
assert.match(successSchema?.properties?.body?.description ?? "", /10,000 characters/);
|
||||
assert.equal(successSchema?.properties?.latencyMs?.type, "integer");
|
||||
assert.equal(successSchema?.properties?.latencyMs?.minimum, 0);
|
||||
assert.equal(successSchema?.properties?.contentType?.type, "string");
|
||||
|
||||
const badRequestSchema = operation.responses?.["400"]?.content?.["application/json"]?.schema;
|
||||
assert.equal(badRequestSchema?.oneOf?.length, 2);
|
||||
assert.equal(badRequestSchema?.oneOf?.[0]?.$ref, "#/components/schemas/ValidationErrorResponse");
|
||||
assert.equal(badRequestSchema?.oneOf?.[1]?.properties?.error?.type, "string");
|
||||
assert.equal(
|
||||
operation.responses?.["401"]?.$ref,
|
||||
"#/components/responses/ManagementAuthenticationRequired"
|
||||
);
|
||||
assert.equal(operation.responses?.["403"]?.$ref, "#/components/responses/ManagementInvalidToken");
|
||||
assert.equal(operation.responses?.["503"]?.$ref, "#/components/responses/InternalError");
|
||||
});
|
||||
|
||||
test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => {
|
||||
for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
|
||||
Reference in New Issue
Block a user