Files
OmniRoute/open-sse/services/modelEndpointPolicy.ts

115 lines
4.2 KiB
TypeScript

/**
* Provider model endpoint policy.
*
* Upstream `/models` responses often omit endpoint/modality metadata. In that
* case, specialty models can otherwise be imported as chat models simply
* because "chat" is OmniRoute's historical default. Keep the exceptional
* provider knowledge here so discovery, import, and catalog projection agree.
*/
export type ModelEndpointKind = "chat" | "image" | "video" | "non-chat" | "unknown";
export type ModelEndpointDecision = {
kind: ModelEndpointKind;
chatSelectable: boolean;
reason: "explicit-endpoints" | "provider-policy" | "unclassified";
};
type EndpointAwareModel = {
id: string;
supportedEndpoints?: readonly string[];
};
const CHAT_ENDPOINTS = new Set([
"chat",
"chat-completions",
"chat/completions",
"messages",
"responses",
]);
const IMAGE_ENDPOINTS = new Set(["image", "images", "images/generations"]);
const VIDEO_ENDPOINTS = new Set(["video", "videos", "videos/generations"]);
function normalizeEndpoint(endpoint: string): string {
return endpoint.trim().toLowerCase().replace(/^\/+/, "").replace(/^v1\//, "");
}
function classifyExplicitEndpoints(
supportedEndpoints: readonly string[] | undefined
): ModelEndpointDecision | null {
if (!supportedEndpoints?.length) return null;
const endpoints = supportedEndpoints.map(normalizeEndpoint).filter(Boolean);
if (endpoints.some((endpoint) => CHAT_ENDPOINTS.has(endpoint))) {
return { kind: "chat", chatSelectable: true, reason: "explicit-endpoints" };
}
if (endpoints.some((endpoint) => IMAGE_ENDPOINTS.has(endpoint))) {
return { kind: "image", chatSelectable: false, reason: "explicit-endpoints" };
}
if (endpoints.some((endpoint) => VIDEO_ENDPOINTS.has(endpoint))) {
return { kind: "video", chatSelectable: false, reason: "explicit-endpoints" };
}
return { kind: "non-chat", chatSelectable: false, reason: "explicit-endpoints" };
}
function normalizeOpenAiModelId(modelId: string): string {
return modelId.startsWith("openai/") ? modelId.slice("openai/".length) : modelId;
}
function classifyOpenAiModel(modelId: string): ModelEndpointDecision | null {
const normalized = normalizeOpenAiModelId(modelId).toLowerCase();
if (
normalized.startsWith("gpt-image-") ||
normalized.startsWith("dall-e-") ||
normalized === "chatgpt-image-latest"
) {
return { kind: "image", chatSelectable: false, reason: "provider-policy" };
}
if (normalized.startsWith("sora-")) {
return { kind: "video", chatSelectable: false, reason: "provider-policy" };
}
return null;
}
export function getModelEndpointDecision(
provider: string | null | undefined,
modelId: string,
supportedEndpoints?: readonly string[]
): ModelEndpointDecision {
const explicit = classifyExplicitEndpoints(supportedEndpoints);
if (provider?.trim().toLowerCase() === "openai") {
const openAiDecision = classifyOpenAiModel(modelId);
if (openAiDecision) {
// Old imported rows were persisted with `["chat"]` as a synthetic default
// even when upstream `/models` supplied no endpoint metadata. Do not let
// that default reclassify a known specialty model. A genuinely
// multi-endpoint model can opt in by explicitly naming both its specialty
// endpoint and a chat/Responses endpoint.
const normalizedEndpoints = supportedEndpoints?.map(normalizeEndpoint) ?? [];
const hasSpecialtyEndpoint =
openAiDecision.kind === "image"
? normalizedEndpoints.some((endpoint) => IMAGE_ENDPOINTS.has(endpoint))
: normalizedEndpoints.some((endpoint) => VIDEO_ENDPOINTS.has(endpoint));
if (explicit?.chatSelectable && hasSpecialtyEndpoint) return explicit;
return openAiDecision;
}
}
if (explicit) return explicit;
return { kind: "unknown", chatSelectable: true, reason: "unclassified" };
}
export function isChatSelectableModel(
provider: string | null | undefined,
model: EndpointAwareModel
): boolean {
return getModelEndpointDecision(provider, model.id, model.supportedEndpoints).chatSelectable;
}
export function filterChatSelectableModels<T extends EndpointAwareModel>(
provider: string | null | undefined,
models: readonly T[]
): T[] {
return models.filter((model) => isChatSelectableModel(provider, model));
}