fix openrouter available models sync
This commit is contained in:
@@ -1541,6 +1541,8 @@ Models:
|
||||
|
||||
**Models:** Access 100+ models from all major providers through a single API key.
|
||||
|
||||
**Dashboard behavior:** OpenRouter models are managed from **Available Models**. Manual add, import, and auto-sync all update the same list.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
+19
-19
@@ -686,25 +686,25 @@ Additional processing layers in the translation pipeline:
|
||||
|
||||
## Supported API Endpoints
|
||||
|
||||
| Endpoint | Format | Handler |
|
||||
| -------------------------------------------------- | ------------------ | ---------------------------------------------------- |
|
||||
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
|
||||
| `POST /v1/messages` | Claude Messages | Same handler (auto-detected) |
|
||||
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
|
||||
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
|
||||
| `GET /v1/embeddings` | Model listing | API route |
|
||||
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
|
||||
| `GET /v1/images/generations` | Model listing | API route |
|
||||
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation |
|
||||
| `POST /v1/messages/count_tokens` | Claude Token Count | API route |
|
||||
| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) |
|
||||
| `GET /api/models/catalog` | Catalog | All models grouped by provider + type |
|
||||
| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route |
|
||||
| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration |
|
||||
| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint |
|
||||
| `GET/POST/DELETE /api/provider-models` | Custom Models | Custom model management per provider |
|
||||
| Endpoint | Format | Handler |
|
||||
| -------------------------------------------------- | ------------------ | ------------------------------------------------------------------- |
|
||||
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
|
||||
| `POST /v1/messages` | Claude Messages | Same handler (auto-detected) |
|
||||
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
|
||||
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
|
||||
| `GET /v1/embeddings` | Model listing | API route |
|
||||
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
|
||||
| `GET /v1/images/generations` | Model listing | API route |
|
||||
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation |
|
||||
| `POST /v1/messages/count_tokens` | Claude Token Count | API route |
|
||||
| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) |
|
||||
| `GET /api/models/catalog` | Catalog | All models grouped by provider + type |
|
||||
| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route |
|
||||
| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration |
|
||||
| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint |
|
||||
| `GET/POST/DELETE /api/provider-models` | Provider Models | Provider model metadata backing custom and managed available models |
|
||||
|
||||
## Bypass Handler
|
||||
|
||||
|
||||
@@ -596,6 +596,11 @@ curl -X POST http://localhost:20128/api/provider-models \
|
||||
|
||||
Or use Dashboard: **Providers → [Provider] → Custom Models**.
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenRouter and OpenAI/Anthropic-compatible providers are managed from **Available Models** only. Manual add, import, and auto-sync all land in the same available-model list, so there is no separate Custom Models section for those providers.
|
||||
- The **Custom Models** section is intended for providers that do not expose managed available-model imports.
|
||||
|
||||
### Dedicated Provider Routes
|
||||
|
||||
Route requests directly to a specific provider with model validation:
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
MODEL_COMPAT_PROTOCOL_KEYS,
|
||||
type ModelCompatProtocolKey,
|
||||
} from "@/shared/constants/modelCompat";
|
||||
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
|
||||
|
||||
type CompatByProtocolMap = Partial<
|
||||
Record<
|
||||
@@ -331,6 +332,10 @@ interface CompatibleModelsSectionProps {
|
||||
providerStorageAlias: string;
|
||||
providerDisplayAlias: string;
|
||||
modelAliases: Record<string, string>;
|
||||
fallbackModels?: CompatModelRow[];
|
||||
description: string;
|
||||
inputLabel: string;
|
||||
inputPlaceholder: string;
|
||||
copied?: string;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise<void>;
|
||||
@@ -850,6 +855,7 @@ export default function ProviderDetailPage() {
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
|
||||
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
|
||||
const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter";
|
||||
const isSearchProvider = providerId.endsWith("-search");
|
||||
|
||||
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
||||
@@ -1469,7 +1475,10 @@ export default function ProviderDetailPage() {
|
||||
logs: [
|
||||
t("foundModelsStartingImport", { count: newModels.length }),
|
||||
...(newModels.length < fetchedModels.length
|
||||
? [t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) || `Skipping ${fetchedModels.length - newModels.length} existing models`]
|
||||
? [
|
||||
t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) ||
|
||||
`Skipping ${fetchedModels.length - newModels.length} existing models`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}));
|
||||
@@ -1664,6 +1673,14 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
const [clearingModels, setClearingModels] = useState(false);
|
||||
const providerAliasEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(modelAliases).filter(([, model]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
),
|
||||
[modelAliases, providerStorageAlias]
|
||||
);
|
||||
|
||||
const handleClearAllModels = async () => {
|
||||
if (clearingModels) return;
|
||||
if (!confirm(t("clearAllModelsConfirm"))) return;
|
||||
@@ -1675,11 +1692,8 @@ export default function ProviderDetailPage() {
|
||||
);
|
||||
if (res.ok) {
|
||||
// Also delete all aliases that belong to this provider
|
||||
const aliasEntries = Object.entries(modelAliases).filter(([, model]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
);
|
||||
await Promise.all(
|
||||
aliasEntries.map(([alias]) =>
|
||||
providerAliasEntries.map(([alias]) =>
|
||||
fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => {})
|
||||
@@ -1806,7 +1820,8 @@ export default function ProviderDetailPage() {
|
||||
</button>
|
||||
);
|
||||
|
||||
const clearAllButton = modelMeta.customModels.length > 0 && (
|
||||
const clearAllButton = (modelMeta.customModels.length > 0 ||
|
||||
providerAliasEntries.length > 0) && (
|
||||
<button
|
||||
onClick={handleClearAllModels}
|
||||
disabled={clearingModels}
|
||||
@@ -1818,7 +1833,21 @@ export default function ProviderDetailPage() {
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isCompatible) {
|
||||
if (isManagedAvailableModelsProvider) {
|
||||
const description =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterAnyModelHint")
|
||||
: t("compatibleModelsDescription", {
|
||||
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
|
||||
});
|
||||
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
|
||||
const inputPlaceholder =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterModelPlaceholder")
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
@@ -1829,6 +1858,10 @@ export default function ProviderDetailPage() {
|
||||
providerStorageAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
modelAliases={modelAliases}
|
||||
fallbackModels={providerId === "openrouter" ? modelMeta.customModels : undefined}
|
||||
description={description}
|
||||
inputLabel={inputLabel}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
@@ -2367,8 +2400,8 @@ export default function ProviderDetailPage() {
|
||||
<h2 className="text-lg font-semibold mb-4">{t("availableModels")}</h2>
|
||||
{renderModelsSection()}
|
||||
|
||||
{/* Custom Models — available for non-compatible, non-search providers */}
|
||||
{!isCompatible && (
|
||||
{/* Custom Models — available for providers without managed available-model metadata */}
|
||||
{!isManagedAvailableModelsProvider && (
|
||||
<CustomModelsSection
|
||||
providerId={providerId}
|
||||
providerAlias={providerDisplayAlias}
|
||||
@@ -3410,6 +3443,10 @@ function CompatibleModelsSection({
|
||||
providerStorageAlias,
|
||||
providerDisplayAlias,
|
||||
modelAliases,
|
||||
fallbackModels = [],
|
||||
description,
|
||||
inputLabel,
|
||||
inputPlaceholder,
|
||||
copied,
|
||||
onCopy,
|
||||
onSetAlias,
|
||||
@@ -3430,33 +3467,45 @@ function CompatibleModelsSection({
|
||||
const [importing, setImporting] = useState(false);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
const providerAliases = useMemo(
|
||||
() =>
|
||||
Object.entries(modelAliases).filter(([, model]: [string, any]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
),
|
||||
[modelAliases, providerStorageAlias]
|
||||
);
|
||||
|
||||
const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => ({
|
||||
modelId: (fullModel as string).replace(`${providerStorageAlias}/`, ""),
|
||||
fullModel,
|
||||
alias,
|
||||
}));
|
||||
const allModels = useMemo(() => {
|
||||
const rows = providerAliases.map(([alias, fullModel]: [string, any]) => ({
|
||||
modelId: (fullModel as string).replace(`${providerStorageAlias}/`, ""),
|
||||
alias,
|
||||
}));
|
||||
|
||||
const generateDefaultAlias = (modelId) => {
|
||||
const parts = modelId.split("/");
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
const seenModelIds = new Set(rows.map((row) => row.modelId));
|
||||
for (const model of fallbackModels) {
|
||||
if (!model?.id || seenModelIds.has(model.id)) continue;
|
||||
rows.push({ modelId: model.id, alias: null });
|
||||
seenModelIds.add(model.id);
|
||||
}
|
||||
|
||||
const resolveAlias = (modelId) => {
|
||||
const baseAlias = generateDefaultAlias(modelId);
|
||||
if (!modelAliases[baseAlias]) return baseAlias;
|
||||
const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`;
|
||||
if (!modelAliases[prefixedAlias]) return prefixedAlias;
|
||||
return null;
|
||||
};
|
||||
return rows;
|
||||
}, [fallbackModels, providerAliases, providerStorageAlias]);
|
||||
|
||||
const resolveAlias = useCallback(
|
||||
(modelId: string, workingAliases: Record<string, string>) =>
|
||||
resolveManagedModelAlias({
|
||||
modelId,
|
||||
fullModel: `${providerStorageAlias}/${modelId}`,
|
||||
providerDisplayAlias,
|
||||
existingAliases: workingAliases,
|
||||
}),
|
||||
[providerDisplayAlias, providerStorageAlias]
|
||||
);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newModel.trim() || adding) return;
|
||||
const modelId = newModel.trim();
|
||||
const resolvedAlias = resolveAlias(modelId);
|
||||
const resolvedAlias = resolveAlias(modelId, modelAliases);
|
||||
if (!resolvedAlias) {
|
||||
notify.error(t("allSuggestedAliasesExist"));
|
||||
return;
|
||||
@@ -3506,6 +3555,7 @@ function CompatibleModelsSection({
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const workingAliases = { ...modelAliases };
|
||||
await onImportWithProgress(
|
||||
// fetchModels callback
|
||||
async () => {
|
||||
@@ -3518,7 +3568,7 @@ function CompatibleModelsSection({
|
||||
async (model: any) => {
|
||||
const modelId = model.id || model.name || model.model;
|
||||
if (!modelId) return false;
|
||||
const resolvedAlias = resolveAlias(modelId);
|
||||
const resolvedAlias = resolveAlias(modelId, workingAliases);
|
||||
if (!resolvedAlias) return false;
|
||||
|
||||
// Save to customModels DB FIRST - only create alias if this succeeds
|
||||
@@ -3540,6 +3590,7 @@ function CompatibleModelsSection({
|
||||
|
||||
// Only create alias after customModel is saved successfully
|
||||
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
|
||||
workingAliases[resolvedAlias] = `${providerStorageAlias}/${modelId}`;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
@@ -3554,7 +3605,7 @@ function CompatibleModelsSection({
|
||||
const canImport = connections.some((conn) => conn.isActive !== false);
|
||||
|
||||
// Handle delete: remove from both alias and customModels DB
|
||||
const handleDeleteModel = async (modelId: string, alias: string) => {
|
||||
const handleDeleteModel = async (modelId: string, alias?: string | null) => {
|
||||
try {
|
||||
// Remove from customModels DB
|
||||
const res = await fetch(
|
||||
@@ -3565,7 +3616,9 @@ function CompatibleModelsSection({
|
||||
throw new Error(t("failedRemoveModelFromDatabase"));
|
||||
}
|
||||
// Also delete the alias
|
||||
await onDeleteAlias(alias);
|
||||
if (alias) {
|
||||
await onDeleteAlias(alias);
|
||||
}
|
||||
notify.success(t("modelRemovedSuccess"));
|
||||
onModelsChanged?.();
|
||||
} catch (error) {
|
||||
@@ -3576,11 +3629,7 @@ function CompatibleModelsSection({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("compatibleModelsDescription", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{description}</p>
|
||||
|
||||
<div className="flex items-end gap-2 flex-wrap">
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
@@ -3588,7 +3637,7 @@ function CompatibleModelsSection({
|
||||
htmlFor="new-compatible-model-input"
|
||||
className="text-xs text-text-muted mb-1 block"
|
||||
>
|
||||
{t("modelId")}
|
||||
{inputLabel}
|
||||
</label>
|
||||
<input
|
||||
id="new-compatible-model-input"
|
||||
@@ -3596,11 +3645,7 @@ function CompatibleModelsSection({
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder={
|
||||
isAnthropic
|
||||
? t("anthropicCompatibleModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder")
|
||||
}
|
||||
placeholder={inputPlaceholder}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -3622,9 +3667,9 @@ function CompatibleModelsSection({
|
||||
|
||||
{allModels.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{allModels.map(({ modelId, fullModel, alias }) => (
|
||||
{allModels.map(({ modelId, alias }) => (
|
||||
<PassthroughModelRow
|
||||
key={fullModel as string}
|
||||
key={`${providerStorageAlias}:${modelId}`}
|
||||
modelId={modelId}
|
||||
fullModel={`${providerDisplayAlias}/${modelId}`}
|
||||
copied={copied}
|
||||
@@ -3649,6 +3694,10 @@ CompatibleModelsSection.propTypes = {
|
||||
providerStorageAlias: PropTypes.string.isRequired,
|
||||
providerDisplayAlias: PropTypes.string.isRequired,
|
||||
modelAliases: PropTypes.object.isRequired,
|
||||
fallbackModels: PropTypes.array,
|
||||
description: PropTypes.string.isRequired,
|
||||
inputLabel: PropTypes.string.isRequired,
|
||||
inputPlaceholder: PropTypes.string.isRequired,
|
||||
copied: PropTypes.string,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onSetAlias: PropTypes.func.isRequired,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
import { replaceCustomModels } from "@/lib/db/models";
|
||||
import {
|
||||
syncManagedAvailableModelAliases,
|
||||
usesManagedAvailableModels,
|
||||
} from "@/lib/providerModels/managedAvailableModels";
|
||||
import { saveCallLog } from "@/lib/usage/callLogs";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
@@ -77,9 +81,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
const fetchedModels = modelsData.models || [];
|
||||
|
||||
// Filter out models already in the built-in registry
|
||||
const registryIds = new Set(
|
||||
getModelsByProviderId(connection.provider).map((m: any) => m.id)
|
||||
);
|
||||
const registryIds = new Set(getModelsByProviderId(connection.provider).map((m: any) => m.id));
|
||||
|
||||
// Replace the full model list
|
||||
const models = fetchedModels
|
||||
@@ -92,6 +94,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
const replaced = await replaceCustomModels(connection.provider, models);
|
||||
|
||||
let syncedAliases = 0;
|
||||
if (usesManagedAvailableModels(connection.provider)) {
|
||||
const aliasSync = await syncManagedAvailableModelAliases(
|
||||
connection.provider,
|
||||
models.map((model: any) => model.id)
|
||||
);
|
||||
syncedAliases = aliasSync.assignedAliases.length;
|
||||
}
|
||||
|
||||
// Log the successful sync
|
||||
await saveCallLog({
|
||||
method: "GET",
|
||||
@@ -105,6 +116,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
requestType: "model-sync",
|
||||
responseBody: {
|
||||
syncedModels: models.length,
|
||||
syncedAliases,
|
||||
provider: connection.provider,
|
||||
},
|
||||
});
|
||||
@@ -113,6 +125,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
ok: true,
|
||||
provider: connection.provider,
|
||||
syncedModels: replaced.length,
|
||||
syncedAliases,
|
||||
models: replaced,
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
deleteModelAlias,
|
||||
getModelAliases,
|
||||
getProviderNodeById,
|
||||
setModelAlias,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
getProviderAlias,
|
||||
isAnthropicCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
|
||||
|
||||
function isCompatibleProvider(providerId: string): boolean {
|
||||
return isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
|
||||
}
|
||||
|
||||
export function usesManagedAvailableModels(providerId: string): boolean {
|
||||
return providerId === "openrouter" || isCompatibleProvider(providerId);
|
||||
}
|
||||
|
||||
function getProviderStoragePrefix(providerId: string): string {
|
||||
if (isCompatibleProvider(providerId)) return providerId;
|
||||
return getProviderAlias(providerId) || providerId;
|
||||
}
|
||||
|
||||
async function getProviderDisplayPrefix(providerId: string): Promise<string> {
|
||||
if (!isCompatibleProvider(providerId)) {
|
||||
return getProviderAlias(providerId) || providerId;
|
||||
}
|
||||
|
||||
const providerNode = await getProviderNodeById(providerId);
|
||||
const prefix = providerNode?.prefix;
|
||||
return typeof prefix === "string" && prefix.trim().length > 0 ? prefix.trim() : providerId;
|
||||
}
|
||||
|
||||
export async function syncManagedAvailableModelAliases(providerId: string, modelIds: string[]) {
|
||||
const storagePrefix = getProviderStoragePrefix(providerId);
|
||||
const displayPrefix = await getProviderDisplayPrefix(providerId);
|
||||
const existingAliasesRaw = await getModelAliases();
|
||||
const workingAliases = Object.fromEntries(
|
||||
Object.entries(existingAliasesRaw).filter((entry): entry is [string, string] => {
|
||||
const [, value] = entry;
|
||||
return typeof value === "string";
|
||||
})
|
||||
);
|
||||
|
||||
const targetModelIds = Array.from(
|
||||
new Set(
|
||||
modelIds.map((modelId) => (typeof modelId === "string" ? modelId.trim() : "")).filter(Boolean)
|
||||
)
|
||||
);
|
||||
const targetFullModels = new Set(targetModelIds.map((modelId) => `${storagePrefix}/${modelId}`));
|
||||
const removedAliases: string[] = [];
|
||||
|
||||
for (const [alias, value] of Object.entries(workingAliases)) {
|
||||
if (!value.startsWith(`${storagePrefix}/`)) continue;
|
||||
if (targetFullModels.has(value)) continue;
|
||||
|
||||
await deleteModelAlias(alias);
|
||||
delete workingAliases[alias];
|
||||
removedAliases.push(alias);
|
||||
}
|
||||
|
||||
const assignedAliases: string[] = [];
|
||||
|
||||
for (const modelId of targetModelIds) {
|
||||
const fullModel = `${storagePrefix}/${modelId}`;
|
||||
const alias = resolveManagedModelAlias({
|
||||
modelId,
|
||||
fullModel,
|
||||
providerDisplayAlias: displayPrefix,
|
||||
existingAliases: workingAliases,
|
||||
});
|
||||
|
||||
if (!alias) continue;
|
||||
|
||||
if (workingAliases[alias] !== fullModel) {
|
||||
await setModelAlias(alias, fullModel);
|
||||
workingAliases[alias] = fullModel;
|
||||
}
|
||||
|
||||
assignedAliases.push(alias);
|
||||
}
|
||||
|
||||
return {
|
||||
assignedAliases,
|
||||
removedAliases,
|
||||
storagePrefix,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
type AliasMap = Record<string, string>;
|
||||
|
||||
export function getDefaultModelAliasBase(modelId: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
const segments = trimmed
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
return segments[segments.length - 1] || trimmed;
|
||||
}
|
||||
|
||||
export function resolveManagedModelAlias({
|
||||
modelId,
|
||||
fullModel,
|
||||
providerDisplayAlias,
|
||||
existingAliases,
|
||||
}: {
|
||||
modelId: string;
|
||||
fullModel: string;
|
||||
providerDisplayAlias: string;
|
||||
existingAliases: AliasMap;
|
||||
}): string | null {
|
||||
const baseAlias = getDefaultModelAliasBase(modelId);
|
||||
if (!baseAlias) return null;
|
||||
|
||||
for (const [alias, value] of Object.entries(existingAliases)) {
|
||||
if (value === fullModel) return alias;
|
||||
}
|
||||
|
||||
const displayAlias = providerDisplayAlias.trim();
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushCandidate = (candidate: string) => {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
candidates.push(trimmed);
|
||||
};
|
||||
|
||||
pushCandidate(baseAlias);
|
||||
if (displayAlias) {
|
||||
pushCandidate(`${displayAlias}-${baseAlias}`);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!(candidate in existingAliases) || existingAliases[candidate] === fullModel) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (let suffix = 2; suffix <= 5000; suffix += 1) {
|
||||
if (displayAlias) {
|
||||
const prefixed = `${displayAlias}-${baseAlias}-${suffix}`;
|
||||
if (!(prefixed in existingAliases) || existingAliases[prefixed] === fullModel) {
|
||||
return prefixed;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = `${baseAlias}-${suffix}`;
|
||||
if (!(fallback in existingAliases) || existingAliases[fallback] === fullModel) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-managed-models-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const managedModels = await import("../../src/lib/providerModels/managedAvailableModels.ts");
|
||||
const aliasUtils = await import("../../src/shared/utils/providerModelAliases.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("resolveManagedModelAlias preserves existing aliases and falls back to provider-prefixed suffixes", () => {
|
||||
const first = aliasUtils.resolveManagedModelAlias({
|
||||
modelId: "anthropic/claude-3.7-sonnet",
|
||||
fullModel: "openrouter/anthropic/claude-3.7-sonnet",
|
||||
providerDisplayAlias: "openrouter",
|
||||
existingAliases: {
|
||||
"claude-3.7-sonnet": "other-provider/claude-3.7-sonnet",
|
||||
"openrouter-claude-3.7-sonnet": "other-provider/claude-3.7-sonnet",
|
||||
},
|
||||
});
|
||||
assert.equal(first, "openrouter-claude-3.7-sonnet-2");
|
||||
|
||||
const preserved = aliasUtils.resolveManagedModelAlias({
|
||||
modelId: "openai/gpt-4.1",
|
||||
fullModel: "openrouter/openai/gpt-4.1",
|
||||
providerDisplayAlias: "openrouter",
|
||||
existingAliases: {
|
||||
kept: "openrouter/openai/gpt-4.1",
|
||||
"gpt-4.1": "other-provider/gpt-4.1",
|
||||
},
|
||||
});
|
||||
assert.equal(preserved, "kept");
|
||||
});
|
||||
|
||||
test("syncManagedAvailableModelAliases backfills openrouter aliases and removes stale entries", async () => {
|
||||
await modelsDb.setModelAlias("kept", "openrouter/openai/gpt-4.1");
|
||||
await modelsDb.setModelAlias("claude-3.7-sonnet", "other-provider/claude-3.7-sonnet");
|
||||
await modelsDb.setModelAlias("stale-model", "openrouter/legacy/stale-model");
|
||||
|
||||
const result = await managedModels.syncManagedAvailableModelAliases("openrouter", [
|
||||
"openai/gpt-4.1",
|
||||
"anthropic/claude-3.7-sonnet",
|
||||
]);
|
||||
const aliases = await modelsDb.getModelAliases();
|
||||
|
||||
assert.deepEqual(result.removedAliases, ["stale-model"]);
|
||||
assert.equal(aliases.kept, "openrouter/openai/gpt-4.1");
|
||||
assert.equal(aliases["openrouter-claude-3.7-sonnet"], "openrouter/anthropic/claude-3.7-sonnet");
|
||||
assert.equal(aliases["stale-model"], undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user