fix(providers): honor operator-set endpoint overrides for local models (#13078)

Merged. An operator-set endpoint override that is ignored for local models is the worst kind of setting — it looks applied and is not. Honouring it is the whole fix.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thank you.
This commit is contained in:
Markus Hartung
2026-09-16 18:36:52 +02:00
committed by GitHub
parent 5847c43922
commit 7dbe850daa
5 changed files with 251 additions and 24 deletions

View File

@@ -258,7 +258,7 @@ export async function PUT(request) {
}
}
const model = await updateCustomModel(provider, modelId, updates);
const model = await updateCustomModel(provider, modelId, updates, { createIfMissing: true });
if (!model) {
const rawKeys = Object.keys(raw);

View File

@@ -686,20 +686,37 @@ function applyTriStateBooleanOverride(
export async function updateCustomModel(
providerId: string,
modelId: string,
updates: Record<string, unknown> = {}
updates: Record<string, unknown> = {},
options: { createIfMissing?: boolean } = {}
) {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
.get(providerId);
if (!row) return null;
const value = getKeyValue(row).value;
if (!value) return null;
const value = row ? getKeyValue(row).value : null;
const models: JsonRecord[] = value ? JSON.parse(value) : [];
let index = models.findIndex((m: JsonRecord) => m.id === modelId);
const models = JSON.parse(value);
const index = models.findIndex((m: JsonRecord) => m.id === modelId);
if (index === -1) return null;
if (index === -1) {
if (!options.createIfMissing) return null;
// A model discovered via sync/passthrough (syncedAvailableModels) has no
// customModels row until an operator explicitly overrides one of its
// fields -- PUT /api/provider-models is exactly that "set an override"
// action, so upsert here (same default shape as addCustomModel()) instead
// of 404ing on the very save it exists to serve. Observed live: a
// llama.cpp connection's auto-discovered embedding model had no way to be
// marked "supports embeddings" because it had never been explicitly
// imported as a custom model first.
models.push({
id: modelId,
name: modelId,
source: "manual",
apiFormat: "chat-completions",
supportedEndpoints: ["chat"],
});
index = models.length - 1;
}
const current = models[index];
const currentCompat = (current as JsonRecord).compatByProtocol as CompatByProtocolMap | undefined;
@@ -770,10 +787,12 @@ export async function updateCustomModel(
models[index] = next;
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
JSON.stringify(models),
providerId
);
// INSERT OR REPLACE (not UPDATE): the createIfMissing path above may be
// writing this provider's customModels row for the first time, and an
// UPDATE...WHERE would silently match zero rows in that case.
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)"
).run(providerId, JSON.stringify(models));
finishModelCatalogWriteWithBackup();
return next;

View File

@@ -1,4 +1,4 @@
import { getSyncedAvailableModelsByConnection } from "@/lib/db/models";
import { getAllCustomModels, getSyncedAvailableModelsByConnection } from "@/lib/db/models";
import { isSelfHostedChatProvider, resolveProviderId } from "@/shared/constants/providers";
export type LocalSyncedEndpointRoute = {
@@ -14,18 +14,52 @@ export async function resolveLocalSyncedEndpointRoute(
const slashIndex = modelStr.indexOf("/");
if (slashIndex <= 0 || slashIndex === modelStr.length - 1) return null;
const provider = resolveProviderId(modelStr.slice(0, slashIndex));
const model = modelStr.slice(slashIndex + 1);
const providerPrefix = modelStr.slice(0, slashIndex);
const provider = resolveProviderId(providerPrefix);
if (!isSelfHostedChatProvider(provider)) return null;
const byConnection = await getSyncedAvailableModelsByConnection(provider);
const connectionIds = Object.entries(byConnection)
.filter(([, models]) =>
models.some(
(candidate) => candidate.id === model && candidate.supportedEndpoints?.includes(endpoint)
)
)
.map(([connectionId]) => connectionId);
const rawSuffix = modelStr.slice(slashIndex + 1);
// Some self-hosted servers (llama.cpp included) report models by absolute
// filesystem path, so the raw id itself already starts with "/" --
// "<prefix>/<rawId>" then reads as "<prefix>//models/foo.gguf" (a double
// slash). That IS the byte-for-byte round-trip-safe id the catalog
// displays, but an operator who naturally collapses it to a single slash
// ("<prefix>/models/foo.gguf") should still resolve -- try the raw model
// id both as given and with a leading "/" restored.
const modelCandidates = rawSuffix.startsWith("/") ? [rawSuffix] : [rawSuffix, `/${rawSuffix}`];
return connectionIds.length > 0 ? { provider, model, connectionIds } : null;
// Most local servers' own /v1/models response carries no capability data at
// all (llama.cpp included -- unlike Ollama's /api/show, there is nothing to
// probe), so a discovered model's synced cache entry below often has no
// supportedEndpoints of its own. An operator-set override (PUT
// /api/provider-models, keyed by the exact catalog id the client used) is
// the explicit "this model does support embeddings" declaration for
// exactly that case -- honor it here the same way the /v1/models catalog
// already merges customModels on top of synced entries, instead of only
// trusting the un-annotated raw sync cache.
const customModelsForProvider = (await getAllCustomModels())[provider];
const byConnection = await getSyncedAvailableModelsByConnection(provider);
for (const model of modelCandidates) {
const overrideEndpoints = Array.isArray(customModelsForProvider)
? (
customModelsForProvider as Array<{ id?: unknown; supportedEndpoints?: unknown }>
).find((entry) => entry.id === modelStr || entry.id === `${providerPrefix}/${model}`)
?.supportedEndpoints
: undefined;
const hasOverride = Array.isArray(overrideEndpoints) && overrideEndpoints.includes(endpoint);
const connectionIds = Object.entries(byConnection)
.filter(([, models]) =>
models.some(
(candidate) =>
candidate.id === model && (hasOverride || candidate.supportedEndpoints?.includes(endpoint))
)
)
.map(([connectionId]) => connectionId);
if (connectionIds.length > 0) return { provider, model, connectionIds };
}
return null;
}