Files
OmniRoute/src/lib/combos/modelNameCollision.ts
Diego Rodrigues de Sa e Souza a784c52d34 fix(api): warn (never reject) when a combo name shadows a real model id (#8530) (#8563)
POST /api/combos and PUT /api/combos/[id] had zero validation or
observability when a combo name collided with a real model id, and
sseModelService.getComboForModel() always resolves the combo first. That
combo-first precedence is not a bug: #6940 documents a combo named after a
bare model id (e.g. `gpt-5.5`) as the supported mechanism for per-model
provider fallback, reusing the #3227/#3233 machinery and covered by
tests/unit/responses-combo-resolution-3227.test.ts and
tests/unit/combo-name-codex-responses-rewrite.test.ts. Hard-rejecting a
colliding name (as #8530's literal acceptance criteria requested) would
regress that documented workflow.

Instead, both routes now attach a non-blocking `warning` field
(`COMBO_NAME_SHADOWS_MODEL`) to the create/rename response when the name
collides with a real model id, and a new boot-time scan
(scanComboModelNameCollisionsAtBoot in src/instrumentation-node.ts) logs a
startup warning enumerating existing collisions — so an operator who hits
this by accident has a signal, while the #6940-sanctioned pattern keeps
working exactly as before.

New tests/unit/combo-model-name-collision-8530.test.ts proves both: the
sanctioned shapes (create/rename to a colliding name) still return
201/200 with the warning attached, and non-colliding names get no warning
field at all.

Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
2026-07-26 03:52:58 -03:00

96 lines
3.5 KiB
TypeScript

/**
* Combo-name / model-id collision detection (#8530).
*
* #6940 (closed by the maintainer) documents a combo named identically to a
* bare model id — e.g. combo `gpt-5.5` fanning out to
* `acme-responses/gpt-5.5`, `backup-responses/gpt-5.5` — as THE supported
* mechanism for per-model provider fallback on bare Responses model ids
* (reusing the #3227/#3233 combo-before-rewrite precedence, which is
* regression-tested in `tests/unit/responses-combo-resolution-3227.test.ts`
* and `tests/unit/combo-name-codex-responses-rewrite.test.ts`).
*
* So a colliding name is NOT rejected — it is a supported, intentional
* pattern. This module only makes the collision *observable*: callers use it
* to attach a non-blocking warning to the create/rename response and to the
* boot-time scan, instead of silently shadowing the model with zero signal.
*/
import { PROVIDER_MODELS } from "@/shared/constants/models";
export interface ComboModelCollision {
providerId: string;
modelId: string;
}
let cachedIndex: Map<string, ComboModelCollision> | null = null;
/**
* Bare model id -> first provider that registers it. Built once from the
* (lazily-generated, then cached) provider registry and memoized for the
* process lifetime — the registry is static compiled-in config, not
* runtime/DB state, so it never needs invalidation.
*/
function getModelIdIndex(): Map<string, ComboModelCollision> {
if (cachedIndex) return cachedIndex;
const index = new Map<string, ComboModelCollision>();
for (const [providerId, models] of Object.entries(PROVIDER_MODELS)) {
for (const model of models) {
if (!index.has(model.id)) {
index.set(model.id, { providerId, modelId: model.id });
}
}
}
cachedIndex = index;
return index;
}
/** Test-only: force the memoized registry index to rebuild on next call. */
export function __resetModelNameCollisionCacheForTest(): void {
cachedIndex = null;
}
/**
* Returns the provider that registers `name` as a bare model id, or `null`
* when `name` does not collide with any known real model id.
*/
export function findCollidingModel(name: string): ComboModelCollision | null {
if (!name) return null;
return getModelIdIndex().get(name) ?? null;
}
/** Machine-readable warning payload attached to POST/PUT combo responses. */
export function buildComboNameCollisionWarning(
name: string
): { code: "COMBO_NAME_SHADOWS_MODEL"; modelId: string; providerId: string } | null {
const collision = findCollidingModel(name);
if (!collision) return null;
return {
code: "COMBO_NAME_SHADOWS_MODEL",
modelId: collision.modelId,
providerId: collision.providerId,
};
}
/** Shape of the minimal combo record the boot-time scan needs. */
export interface ComboLike {
name?: unknown;
}
/**
* Boot-time scan (see `src/instrumentation-node.ts`): enumerates existing
* combos whose name shadows a real model id, for a startup log — never a
* hard failure, since the shadowing pattern is intentional per #6940.
*/
export function scanCombosForModelCollisions(
combos: readonly ComboLike[]
): Array<{ comboName: string; providerId: string; modelId: string }> {
const results: Array<{ comboName: string; providerId: string; modelId: string }> = [];
for (const combo of combos) {
if (typeof combo.name !== "string" || combo.name.length === 0) continue;
const collision = findCollidingModel(combo.name);
if (collision) {
results.push({ comboName: combo.name, ...collision });
}
}
return results;
}