ai_api / src /sse /services /model.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
10.2 kB
// Re-export from open-sse with localDb integration
import {
getModelAliases,
getComboByName,
getComboById,
getComboByNameInsensitive,
getProviderNodes,
getCustomModels,
} from "@/lib/localDb";
import { getCachedSettings } from "@/lib/localDb";
import { getComboStepTarget } from "@/lib/combos/steps";
import {
parseModel,
resolveModelAliasFromMap,
getModelInfoCore,
} from "@omniroute/open-sse/services/model.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
export { parseModel };
/**
* Reserved provider prefixes β€” built-in provider ids + aliases. User-defined
* compatible-node prefixes must not be allowed to shadow these, otherwise a
* node with prefix="cf" would hijack cloudflare-ai requests (and similar for
* every built-in provider). Ported from upstream 9router 047fdc89.
*
* Built lazily so the registry is only walked once per process.
*/
let _reservedProviderPrefixes: Set<string> | null = null;
function getReservedProviderPrefixes(): Set<string> {
if (_reservedProviderPrefixes) return _reservedProviderPrefixes;
const reserved = new Set<string>();
for (const entry of Object.values(REGISTRY)) {
if (entry?.id) reserved.add(entry.id);
if (entry?.alias) reserved.add(entry.alias);
}
_reservedProviderPrefixes = reserved;
return reserved;
}
/**
* Build a combined model alias map that merges both alias stores:
* 1. DB-namespace aliases (key_value WHERE namespace='modelAliases') β€” set via
* /api/models/alias/ and seeded at startup (e.g. gemini-cli default aliases).
* 2. Settings-based aliases (settings.modelAliases) β€” set via the Settings UI and
* /api/settings/model-aliases/ (stored as a JSON blob in namespace='settings').
*
* Settings-based aliases take priority so that UI configuration always wins.
* Without this merge, aliases configured via the Settings UI were never consulted
* during provider routing, causing provider inference (e.g. /^gpt-/ β†’ openai) to
* silently override them (issue #2618 / #2208).
*/
async function getCombinedModelAliases(): Promise<Record<string, unknown>> {
const [dbAliases, settings] = await Promise.all([
getModelAliases().catch(() => ({})),
getCachedSettings().catch(() => ({}) as Record<string, unknown>),
]);
const settingsAliases =
settings.modelAliases &&
typeof settings.modelAliases === "object" &&
!Array.isArray(settings.modelAliases)
? (settings.modelAliases as Record<string, unknown>)
: {};
// Settings-based aliases win over DB-namespace aliases on key collision
return { ...dbAliases, ...settingsAliases };
}
/**
* Resolve model alias from localDb
*/
export async function resolveModelAlias(alias) {
const aliases = await getModelAliases();
return resolveModelAliasFromMap(alias, aliases);
}
/**
* Look up custom-model metadata from the DB in a single read:
* - apiFormat: "responses" when the model is configured for the Responses API.
* - targetFormat: the optional per-model wire format override (#2905).
*/
async function lookupCustomModelMeta(
providerId: string,
modelId: string
): Promise<{ apiFormat?: string; targetFormat?: string }> {
try {
const models = await getCustomModels(providerId);
if (!Array.isArray(models)) return {};
const match = models.find((m: any) => m.id === modelId);
if (!match) return {};
return {
apiFormat: match.apiFormat === "responses" ? "responses" : undefined,
targetFormat: typeof match.targetFormat === "string" ? match.targetFormat : undefined,
};
} catch {
return {};
}
}
/**
* Get full model info (parse or resolve)
*/
export async function getModelInfo(modelStr) {
const parsed = parseModel(modelStr);
const { extendedContext } = parsed;
const attachCustomApiFormat = async (info: any) => {
if (!info?.provider || !info?.model) return info;
const { apiFormat, targetFormat } = await lookupCustomModelMeta(
String(info.provider),
String(info.model)
);
if (apiFormat || targetFormat) {
return {
...info,
...(apiFormat && { apiFormat }),
...(targetFormat && { targetFormat }),
};
}
return info;
};
// Check custom provider nodes first (for both alias and non-alias formats)
if (parsed.providerAlias || parsed.provider) {
// Ensure prefixToCheck is always a concise identifier, not a full model string
const prefixToCheck = parsed.providerAlias || parsed.provider;
// Compatible-node prefixes are user-defined. They must not be allowed to
// shadow built-in provider ids/aliases (e.g. `cf` β†’ cloudflare-ai). When
// prefixToCheck matches a built-in registry id/alias, skip the compatible-
// node prefix lookup so the request still routes to the built-in provider.
// Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...")
// are never in the reserved set, so the #2778 combo path still works.
// Ported from upstream 9router 047fdc89.
const reserved = getReservedProviderPrefixes();
const isReservedPrefix =
typeof prefixToCheck === "string" && reserved.has(prefixToCheck);
if (!isReservedPrefix) {
// Check OpenAI Compatible nodes
// Match by node.prefix (user-defined alias) OR node.id (internal UUID id stored by
// combo steps), so that combo targets using the internal node id still resolve
// correctly (#2778).
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
const matchedOpenAI = openaiNodes.find(
(node) => node.prefix === prefixToCheck || node.id === prefixToCheck
);
if (matchedOpenAI) {
const { apiFormat, targetFormat } = await lookupCustomModelMeta(
matchedOpenAI.id as string,
parsed.model as string
);
return {
provider: matchedOpenAI.id,
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
...(targetFormat && { targetFormat }),
};
}
// Check Anthropic Compatible nodes
const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" });
const matchedAnthropic = anthropicNodes.find(
(node) => node.prefix === prefixToCheck || node.id === prefixToCheck
);
if (matchedAnthropic) {
const { apiFormat, targetFormat } = await lookupCustomModelMeta(
matchedAnthropic.id as string,
parsed.model as string
);
return {
provider: matchedAnthropic.id,
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
...(targetFormat && { targetFormat }),
};
}
}
// stripModelPrefix: if enabled, strip provider prefix and re-resolve
// the bare model name using existing heuristics (claude-* β†’ anthropic, etc.)
try {
const settings = await getCachedSettings();
if (settings.stripModelPrefix === true) {
const strippedResult = await getModelInfoCore(parsed.model, getCombinedModelAliases);
return { ...strippedResult, extendedContext };
}
} catch {
// If settings read fails, fall through to normal resolution
}
}
if (!parsed.isAlias) {
return await attachCustomApiFormat(await getModelInfoCore(modelStr, null));
}
return await attachCustomApiFormat(await getModelInfoCore(modelStr, getCombinedModelAliases));
}
/**
* Check if model is a combo and return the full combo object
* @returns {Promise<Object|null>} Full combo object or null if not a combo
*/
export async function getCombo(modelStr) {
// Try exact match first (supports combos actually named "combo/ANY")
let combo = await getComboByName(modelStr);
if (combo && combo.models && combo.models.length > 0) {
return combo;
}
// Fallback: Strip combo/ prefix if present
if (modelStr.startsWith("combo/")) {
const nameToSearch = modelStr.substring(6);
combo = await getComboByName(nameToSearch);
if (combo && combo.models && combo.models.length > 0) {
return combo;
}
}
// #4446: the opencode-plugin publishes combos as ModelV2 `id: combo.id`, and
// the OpenCode `--model` dispatch path forwards a lowercased bare slug. The
// exact, case-sensitive name match above misses both a combo addressed by its
// stored id (UUID/slug) and a lowercased display name (e.g. "master-light" for
// a combo named "MASTER-LIGHT"). These two fallbacks only run after the exact
// match fails, so they never re-route a combo that already resolves today.
combo = await getComboById(modelStr);
if (combo && combo.models && combo.models.length > 0) {
return combo;
}
combo = await getComboByNameInsensitive(modelStr);
if (combo && combo.models && combo.models.length > 0) {
return combo;
}
return null;
}
/**
* Check if model matches a combo by name OR by model-combo mapping pattern.
* This augments getCombo() with glob-based model-to-combo resolution (#563).
*
* Resolution order:
* 1. Exact combo name match (existing behavior)
* 2. Model-combo mapping pattern match (new β€” glob patterns by priority)
* 3. null (no combo β€” single-model request)
*/
export async function getComboForModel(modelStr) {
// 1. Existing behavior β€” exact combo name match
const combo = await getCombo(modelStr);
if (combo) return combo;
// 2. NEW β€” check model-combo mappings table (pattern match)
try {
const { resolveComboForModel } = await import("@/lib/localDb");
const mapped = await resolveComboForModel(modelStr);
if (mapped && (mapped as any).models?.length > 0) {
return mapped;
}
} catch {
// If the mappings table doesn't exist yet (pre-migration), continue gracefully
}
return null;
}
/**
* Legacy: get combo models as string array
* @returns {Promise<string[]|null>}
*/
export async function getComboModels(modelStr) {
const combo = await getCombo(modelStr);
if (!combo) return null;
return (combo.models || [])
.map((entry) => getComboStepTarget(entry))
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
}