mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 03:02:14 +03:00
300 lines
13 KiB
TypeScript
300 lines
13 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
getSyncedAvailableModelsForConnection,
|
|
replaceSyncedAvailableModelsForConnection,
|
|
type SyncedAvailableModel,
|
|
} from "@/lib/db/models";
|
|
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization";
|
|
import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function asRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
function toNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve a positive integer token limit from a list of candidate values.
|
|
* Used to fall back across the differently-named context/output fields that
|
|
* upstream catalogs expose (e.g. OpenRouter uses `context_length` /
|
|
* `top_provider.context_length` instead of `inputTokenLimit`). See #3202.
|
|
*/
|
|
function firstPositiveNumber(...candidates: unknown[]): number | undefined {
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function modalitiesIncludeImage(value: unknown): boolean {
|
|
return (
|
|
Array.isArray(value) &&
|
|
value.some((entry) => toNonEmptyString(entry)?.toLowerCase() === "image")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* #4264: detect image-input (vision) capability from a discovered model record.
|
|
* Handles the common upstream shapes: an explicit `supportsVision` flag, the
|
|
* OpenRouter `architecture.input_modalities` array and string `architecture.modality`
|
|
* ("text+image->text" — the input side is everything before "->"), and a top-level
|
|
* `input_modalities` array. Returns false when the upstream exposes no modality info.
|
|
*/
|
|
export function detectVisionInput(record: JsonRecord): boolean {
|
|
if (record.supportsVision === true) return true;
|
|
|
|
const architecture = asRecord(record.architecture);
|
|
if (modalitiesIncludeImage(architecture.input_modalities)) return true;
|
|
if (modalitiesIncludeImage(record.input_modalities)) return true;
|
|
|
|
const modality = toNonEmptyString(architecture.modality) || toNonEmptyString(record.modality);
|
|
if (modality) {
|
|
const [inputPart] = modality.toLowerCase().split("->");
|
|
if ((inputPart || "").includes("image")) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// #7694: nested `reasoning.supported_efforts` shape some OpenAI-compatible upstreams
|
|
// expose (as opposed to the flat `supportedThinkingEfforts` field OmniRoute's own
|
|
// import format already emits). Hard Rule #7 — validate the untrusted upstream
|
|
// payload with Zod before it is trusted/stored; a malformed shape degrades to
|
|
// `undefined` instead of throwing, so one bad record never fails the whole sync.
|
|
const reasoningSupportedEffortsSchema = z
|
|
.object({ supported_efforts: z.array(z.string()).optional() })
|
|
.partial()
|
|
.nullable()
|
|
.optional();
|
|
|
|
// #8347: CLIProxyAPI-style upstreams expose reasoning tiers as a top-level
|
|
// `supported_reasoning_levels` array, or nested under `thinking.levels`. Both accept
|
|
// entries that are either plain strings or `{ effort: string }` objects (the report shows
|
|
// the object form; `discovery/codex.ts:140` reads the same `supported_reasoning_levels`
|
|
// key as a bare existence check). Validate with Zod (Hard Rule #7): a malformed ENTRY is
|
|
// dropped individually rather than failing the whole array/record.
|
|
const effortEntrySchema = z.union([z.string(), z.object({ effort: z.string() })]);
|
|
const effortListSchema = z.array(z.unknown());
|
|
|
|
const supportedReasoningLevelsSchema = z.object({ supported_reasoning_levels: z.unknown() });
|
|
const thinkingLevelsSchema = z.object({ thinking: z.object({ levels: z.unknown() }).partial() });
|
|
|
|
// Maps common upstream synonyms onto OmniRoute's canonical effort vocabulary
|
|
// (`src/shared/reasoning/effortStandardization.ts`). Values already in
|
|
// `CANONICAL_EFFORT_VALUES`, and any unrecognized provider-native tier (e.g.
|
|
// Codex's own "ultra"), pass through unchanged — only known synonyms are mapped.
|
|
const EFFORT_SYNONYMS: Record<string, string> = { max: "xhigh" };
|
|
|
|
function normalizeSupportedEffort(effort: string): string {
|
|
if ((CANONICAL_EFFORT_VALUES as readonly string[]).includes(effort)) return effort;
|
|
return EFFORT_SYNONYMS[effort.toLowerCase()] || effort;
|
|
}
|
|
|
|
/**
|
|
* #8347: shared parser for the two new upstream shapes (`supported_reasoning_levels`,
|
|
* `thinking.levels`). Accepts a list whose entries are either plain strings or
|
|
* `{ effort: string }` objects, drops malformed entries individually (never throws), and
|
|
* normalizes survivors onto the canonical vocabulary. Returns `undefined` when nothing
|
|
* usable remains, mirroring `detectSupportedThinkingEfforts`'s existing contract.
|
|
*/
|
|
function parseEffortList(rawList: unknown): string[] | undefined {
|
|
const listParsed = effortListSchema.safeParse(rawList);
|
|
if (!listParsed.success) return undefined;
|
|
|
|
const efforts = Array.from(
|
|
new Set(
|
|
listParsed.data
|
|
.map((entry) => {
|
|
const entryParsed = effortEntrySchema.safeParse(entry);
|
|
if (!entryParsed.success) return null;
|
|
const raw =
|
|
typeof entryParsed.data === "string" ? entryParsed.data : entryParsed.data.effort;
|
|
return raw.length > 0 ? normalizeSupportedEffort(raw) : null;
|
|
})
|
|
.filter((effort): effort is string => effort !== null)
|
|
)
|
|
);
|
|
return efforts.length > 0 ? efforts : undefined;
|
|
}
|
|
|
|
/**
|
|
* #7694: read the nested `record.reasoning.supported_efforts` shape and normalize each
|
|
* tier onto the canonical vocabulary. Returns `undefined` (never throws) when the field
|
|
* is absent or malformed, so it can be used as a fallback alongside the pre-existing flat
|
|
* `record.supportedThinkingEfforts` field without disturbing that field's current
|
|
* pass-through behavior.
|
|
*/
|
|
export function detectSupportedThinkingEfforts(record: JsonRecord): string[] | undefined {
|
|
const parsed = reasoningSupportedEffortsSchema.safeParse(record.reasoning);
|
|
if (parsed.success && parsed.data) {
|
|
const rawEfforts = parsed.data.supported_efforts;
|
|
if (Array.isArray(rawEfforts)) {
|
|
const efforts = Array.from(
|
|
new Set(
|
|
rawEfforts
|
|
.filter((effort): effort is string => typeof effort === "string" && effort.length > 0)
|
|
.map(normalizeSupportedEffort)
|
|
)
|
|
);
|
|
if (efforts.length > 0) return efforts;
|
|
}
|
|
}
|
|
|
|
// #9160: fall back to `capabilities.effort_tiers` before the legacy fields.
|
|
// OmniRoute's own catalog surfaces effort tiers inside `capabilities.effort_tiers`,
|
|
// which the existing `parseEffortList` already handles (string arrays).
|
|
const capabilitiesRecord = asRecord(record.capabilities);
|
|
const capabilitiesParsed = effortListSchema.safeParse(capabilitiesRecord.effort_tiers);
|
|
if (capabilitiesParsed.success) {
|
|
const fromCapabilities = parseEffortList(capabilitiesRecord.effort_tiers);
|
|
if (fromCapabilities) return fromCapabilities;
|
|
}
|
|
|
|
// #8347: fall back to `supported_reasoning_levels`, then `thinking.levels` — in that
|
|
// order, per the regression guard for #7694 (the flat field and `reasoning.supported_efforts`
|
|
// both take precedence over these two and are handled above / by the caller).
|
|
const levelsParsed = supportedReasoningLevelsSchema.safeParse(record);
|
|
if (levelsParsed.success) {
|
|
const fromLevels = parseEffortList(levelsParsed.data.supported_reasoning_levels);
|
|
if (fromLevels) return fromLevels;
|
|
}
|
|
|
|
const thinkingParsed = thinkingLevelsSchema.safeParse(record);
|
|
if (thinkingParsed.success) {
|
|
const fromThinking = parseEffortList(thinkingParsed.data.thinking?.levels);
|
|
if (fromThinking) return fromThinking;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean {
|
|
return asRecord(providerSpecificData).autoFetchModels !== false;
|
|
}
|
|
|
|
export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel[] {
|
|
const items = Array.isArray(models) ? models : [];
|
|
const deduped = new Map<string, SyncedAvailableModel>();
|
|
|
|
for (const item of items) {
|
|
const record = asRecord(item);
|
|
const id =
|
|
toNonEmptyString(record.id) ||
|
|
toNonEmptyString(record.name) ||
|
|
toNonEmptyString(record.model);
|
|
if (!id) continue;
|
|
|
|
const name =
|
|
toNonEmptyString(record.name) ||
|
|
toNonEmptyString(record.displayName) ||
|
|
toNonEmptyString(record.model) ||
|
|
id;
|
|
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
|
|
? Array.from(
|
|
new Set(
|
|
record.supportedEndpoints
|
|
.map((endpoint) => toNonEmptyString(endpoint))
|
|
.filter((endpoint): endpoint is string => Boolean(endpoint))
|
|
)
|
|
).sort()
|
|
: undefined;
|
|
|
|
const topProvider = asRecord(record.top_provider);
|
|
|
|
// OpenRouter (and similar passthrough catalogs) report the context window as
|
|
// `context_length` / `top_provider.context_length`, not `inputTokenLimit`.
|
|
// Fall back across those names so synced models carry a real window instead
|
|
// of the provider default (128K). Explicit `inputTokenLimit` still wins. #3202
|
|
const inputTokenLimit = firstPositiveNumber(
|
|
record.inputTokenLimit,
|
|
record.context_length,
|
|
record.contextLength,
|
|
topProvider.context_length
|
|
);
|
|
const outputTokenLimit = firstPositiveNumber(
|
|
record.outputTokenLimit,
|
|
topProvider.max_completion_tokens
|
|
);
|
|
|
|
// #4264: capture image-input (vision) capability at sync time. OpenRouter (and
|
|
// similar passthrough catalogs) declare it via `architecture.input_modalities`
|
|
// (e.g. ["text","image"]) or the string `architecture.modality` ("text+image->text");
|
|
// some providers expose a top-level `input_modalities`. Without this, synced
|
|
// models reached the catalog with no vision flag and vision-capable models
|
|
// (which work at request time) showed up as non-vision after import.
|
|
const supportsVision = detectVisionInput(record);
|
|
|
|
deduped.set(id, {
|
|
id,
|
|
name,
|
|
source: "imported",
|
|
...(toNonEmptyString(record.apiFormat)
|
|
? { apiFormat: toNonEmptyString(record.apiFormat)! }
|
|
: {}),
|
|
...(toNonEmptyString(record.targetFormat)
|
|
? { targetFormat: toNonEmptyString(record.targetFormat)! }
|
|
: {}),
|
|
...(toNonEmptyString(record.upstreamProtocol)
|
|
? { upstreamProtocol: toNonEmptyString(record.upstreamProtocol)! }
|
|
: {}),
|
|
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
|
|
...(() => {
|
|
// #7694/#8347: the flat field (OmniRoute's own import format) wins verbatim when
|
|
// present, unchanged from its current pass-through behavior. Otherwise
|
|
// `detectSupportedThinkingEfforts` falls back in order: `reasoning.supported_efforts`
|
|
// → `supported_reasoning_levels` → `thinking.levels` (all normalized onto the
|
|
// canonical vocabulary) — never disturbing the flat field's precedence.
|
|
if (Array.isArray(record.supportedThinkingEfforts)) {
|
|
return {
|
|
supportedThinkingEfforts: record.supportedThinkingEfforts.filter(
|
|
(effort): effort is string => typeof effort === "string" && effort.length > 0
|
|
),
|
|
};
|
|
}
|
|
const nested = detectSupportedThinkingEfforts(record);
|
|
return nested ? { supportedThinkingEfforts: nested } : {};
|
|
})(),
|
|
...(toNonEmptyString(record.defaultThinkingEffort)
|
|
? { defaultThinkingEffort: toNonEmptyString(record.defaultThinkingEffort)! }
|
|
: {}),
|
|
...(typeof inputTokenLimit === "number" ? { inputTokenLimit } : {}),
|
|
...(typeof outputTokenLimit === "number" ? { outputTokenLimit } : {}),
|
|
...(typeof record.description === "string" ? { description: record.description } : {}),
|
|
...(typeof record.supportsThinking === "boolean"
|
|
? { supportsThinking: record.supportsThinking }
|
|
: {}),
|
|
...(record.alwaysThinking === true ? { alwaysThinking: true } : {}),
|
|
...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}),
|
|
...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}),
|
|
...(supportsVision ? { supportsVision: true } : {}),
|
|
});
|
|
}
|
|
|
|
return Array.from(deduped.values());
|
|
}
|
|
|
|
export async function getCachedDiscoveredModels(
|
|
providerId: string,
|
|
connectionId: string
|
|
): Promise<SyncedAvailableModel[]> {
|
|
const models = await getSyncedAvailableModelsForConnection(providerId, connectionId);
|
|
return providerId === "kiro"
|
|
? models.filter((model) => !isObsoleteKiroModelAlias(model.id))
|
|
: models;
|
|
}
|
|
|
|
export async function persistDiscoveredModels(
|
|
providerId: string,
|
|
connectionId: string,
|
|
models: unknown
|
|
): Promise<SyncedAvailableModel[]> {
|
|
const normalized = normalizeDiscoveredModels(models);
|
|
await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized);
|
|
return normalized;
|
|
}
|