mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
chore(duplication): share combo builder model options (#5477)
Share combo builder model options; add regression test locking custom-model source classification (manual->custom, api-sync->imported). Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
@@ -315,6 +315,98 @@ function addModelOption(
|
||||
);
|
||||
}
|
||||
|
||||
function buildModelOptions(
|
||||
providerId: string,
|
||||
builtInModels: RegistryModel[],
|
||||
syncedModels: SyncedModelLike[],
|
||||
customModels: CustomModelLike[]
|
||||
): Map<string, ComboBuilderModelOption> {
|
||||
const modelMap = new Map<string, ComboBuilderModelOption>();
|
||||
const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels);
|
||||
|
||||
for (const model of syncedModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "imported",
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of builtInModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "system",
|
||||
contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of customModels) {
|
||||
if (model.isHidden === true) continue;
|
||||
const source = ["api-sync", "auto-sync", "imported"].includes(
|
||||
toStringOrNull(model.source)?.toLowerCase() || ""
|
||||
)
|
||||
? "imported"
|
||||
: ("custom" as BuilderModelSource);
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source,
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
apiFormat: toStringOrNull(model.apiFormat),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(fallbackModels)) {
|
||||
for (const model of fallbackModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "fallback",
|
||||
contextLength:
|
||||
typeof (model as { contextLength?: number }).contextLength === "number"
|
||||
? (model as { contextLength?: number }).contextLength || null
|
||||
: resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return modelMap;
|
||||
}
|
||||
|
||||
function compareConnections(
|
||||
left: ComboBuilderConnectionOption,
|
||||
right: ComboBuilderConnectionOption
|
||||
@@ -398,7 +490,6 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
|
||||
for (const [providerId, providerConnections] of connectionsByProvider) {
|
||||
const providerNode = providerNodeMap.get(providerId) || null;
|
||||
const providerVisual = getProviderVisual(providerId, providerNode);
|
||||
const modelMap = new Map<string, ComboBuilderModelOption>();
|
||||
const builtInModels = getModelsByProviderId(providerId);
|
||||
const syncedModels = normalizeSyncedModels(
|
||||
(syncedModelsMap as Record<string, unknown>)[providerId]
|
||||
@@ -406,92 +497,17 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
|
||||
const customModels = normalizeCustomModels(
|
||||
(customModelsMap as Record<string, unknown>)[providerId]
|
||||
);
|
||||
const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels);
|
||||
const acceptsArbitraryModel =
|
||||
Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) ||
|
||||
isOpenAICompatibleProvider(providerId) ||
|
||||
isAnthropicCompatibleProvider(providerId) ||
|
||||
isClaudeCodeCompatibleProvider(providerId);
|
||||
|
||||
for (const model of syncedModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "imported",
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of builtInModels as RegistryModel[]) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "system",
|
||||
contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of customModels) {
|
||||
if (model.isHidden === true) continue;
|
||||
const source = ["api-sync", "auto-sync", "imported"].includes(
|
||||
toStringOrNull(model.source)?.toLowerCase() || ""
|
||||
)
|
||||
? "imported"
|
||||
: ("custom" as BuilderModelSource);
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source,
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
apiFormat: toStringOrNull(model.apiFormat),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(fallbackModels)) {
|
||||
for (const model of fallbackModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "fallback",
|
||||
contextLength:
|
||||
typeof (model as { contextLength?: number }).contextLength === "number"
|
||||
? (model as { contextLength?: number }).contextLength || null
|
||||
: resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
const modelMap = buildModelOptions(
|
||||
providerId,
|
||||
builtInModels as RegistryModel[],
|
||||
syncedModels,
|
||||
customModels
|
||||
);
|
||||
|
||||
const normalizedConnections = providerConnections
|
||||
.map((connection) => buildConnectionOption(connection))
|
||||
@@ -536,7 +552,6 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
|
||||
if (connectionsByProvider.has(providerId)) continue;
|
||||
|
||||
const providerVisual = getProviderVisual(providerId, null);
|
||||
const modelMap = new Map<string, ComboBuilderModelOption>();
|
||||
const builtInModels = getModelsByProviderId(providerId);
|
||||
const syncedModels = normalizeSyncedModels(
|
||||
(syncedModelsMap as Record<string, unknown>)[providerId]
|
||||
@@ -544,92 +559,17 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
|
||||
const customModels = normalizeCustomModels(
|
||||
(customModelsMap as Record<string, unknown>)[providerId]
|
||||
);
|
||||
const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels);
|
||||
const acceptsArbitraryModel =
|
||||
Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) ||
|
||||
isOpenAICompatibleProvider(providerId) ||
|
||||
isAnthropicCompatibleProvider(providerId) ||
|
||||
isClaudeCodeCompatibleProvider(providerId);
|
||||
|
||||
for (const model of syncedModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "imported",
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of builtInModels as RegistryModel[]) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "system",
|
||||
contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of customModels) {
|
||||
if (model.isHidden === true) continue;
|
||||
const source = ["api-sync", "auto-sync", "imported"].includes(
|
||||
toStringOrNull(model.source)?.toLowerCase() || ""
|
||||
)
|
||||
? "imported"
|
||||
: ("custom" as BuilderModelSource);
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source,
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
apiFormat: toStringOrNull(model.apiFormat),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(fallbackModels)) {
|
||||
for (const model of fallbackModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "fallback",
|
||||
contextLength:
|
||||
typeof (model as { contextLength?: number }).contextLength === "number"
|
||||
? (model as { contextLength?: number }).contextLength || null
|
||||
: resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
const modelMap = buildModelOptions(
|
||||
providerId,
|
||||
builtInModels as RegistryModel[],
|
||||
syncedModels,
|
||||
customModels
|
||||
);
|
||||
|
||||
// #2901: no-auth providers must route under their alias (e.g. "oc"), not
|
||||
// their id — "opencode/<model>" misroutes to the opencode-zen api-key tier
|
||||
|
||||
45
tests/unit/combo-builder-model-source-5477.test.ts
Normal file
45
tests/unit/combo-builder-model-source-5477.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* #5477 — the combo builder's per-model option construction was extracted into a
|
||||
* shared `buildModelOptions` helper (one source of truth for the synced /
|
||||
* built-in / custom / fallback branches). This test drives the real builder and
|
||||
* locks the custom-model **source classification** branch: a custom model whose
|
||||
* stored `source` is one of api-sync/auto-sync/imported must surface as
|
||||
* `imported`, everything else as `custom`. A silent divergence in that mapping is
|
||||
* exactly what the extraction could introduce.
|
||||
*/
|
||||
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-combo-source-"));
|
||||
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 { getComboBuilderOptions } = await import("../../src/lib/combos/builderOptions.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#5477 buildModelOptions classifies custom-model source (manual -> custom, api-sync -> imported)", async () => {
|
||||
// Attach to a no-auth provider ("opencode") — it surfaces in the builder
|
||||
// without a configured connection, so the custom-model branch is exercised.
|
||||
await modelsDb.addCustomModel("opencode", "zzz-manual-5477", "Manual 5477", "manual");
|
||||
await modelsDb.addCustomModel("opencode", "zzz-apisync-5477", "ApiSync 5477", "api-sync");
|
||||
|
||||
const payload = await getComboBuilderOptions();
|
||||
|
||||
const allModels = payload.providers.flatMap((p) => p.models);
|
||||
const manual = allModels.find((m) => m.id === "zzz-manual-5477");
|
||||
const apiSync = allModels.find((m) => m.id === "zzz-apisync-5477");
|
||||
|
||||
assert.ok(manual, "manual custom model must appear in the combo builder output");
|
||||
assert.ok(apiSync, "api-sync custom model must appear in the combo builder output");
|
||||
|
||||
assert.equal(manual.source, "custom", "source=manual must classify as 'custom'");
|
||||
assert.equal(apiSync.source, "imported", "source=api-sync must classify as 'imported'");
|
||||
});
|
||||
Reference in New Issue
Block a user