Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/11271-ollama-capability-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(ollama):** Ollama Local models are no longer flattened to `chat` at sync time — the synced store persists every advertised capability and chat filtering moves to read time, so `/v1/embeddings` and `/v1/images/generations` stop rejecting models the daemon reports as capable ([#11271](https://github.com/diegosouzapw/OmniRoute/pull/11271)) — thanks @yourspraveen
6 changes: 5 additions & 1 deletion open-sse/services/combo/autoStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
ResolvedComboTarget,
} from "./types.ts";
import { extractSessionAffinityKey } from "@/sse/services/auth";
import { filterChatSelectableModels } from "../modelEndpointPolicy.ts";
import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts";
import { getTaskFitness } from "../autoCombo/taskFitness.ts";
import {
Expand Down Expand Up @@ -470,10 +471,13 @@ export async function expandAutoComboCandidatePool(
// catalog only when the user has none. This keeps catalog-only models
// (e.g. openrouter/auto) out of pure-auto pools when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedModels, customModels] = await Promise.all([
// #11088 (option 1): the synced store now persists non-chat models too —
// chat combo pools must keep filtering them out at read time.
const [syncedModelsRaw, customModels] = await Promise.all([
getSyncedAvailableModels(providerId),
getCustomModels(providerId),
]);
const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw);
const hiddenModels = hiddenModelsMap.get(providerId);
const userVisibleIds = new Set<string>();
for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
Expand Down
38 changes: 38 additions & 0 deletions src/app/api/providers/[id]/models/discovery/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { isSelfHostedChatProvider } from "@/shared/constants/providers";
import { getStaticModelsForProvider, type LocalCatalogModel } from "@/lib/providers/staticModels";
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import {
buildOllamaShowUrl,
enrichOllamaModelsWithCapabilities,
} from "@/lib/providerModels/ollamaCapabilities";

export type JsonRecord = Record<string, unknown>;

Expand Down Expand Up @@ -102,3 +108,35 @@ export function buildNamedOpenAiStyleHeaders(

return headers;
}

// #11087 — Ollama's OpenAI-compatible /v1/models response carries no capability
// data, so every local model looked like a chat model and image/embedding
// requests were routed to text-only models. Probe /api/show per model (bounded
// concurrency, failures degrade to the unenriched entry) to recover the
// advertised capabilities. Lives here rather than inline in route.ts to keep the
// route file under its frozen file-size cap.
export async function enrichOllamaLocalModels(
models: unknown[],
baseUrl: string,
proxy: unknown,
token: string | null | undefined
): Promise<JsonRecord[]> {
const showUrl = buildOllamaShowUrl(baseUrl);
return enrichOllamaModelsWithCapabilities(models, async (modelId) => {
try {
const showResponse = await safeOutboundFetch(showUrl, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsProbe,
// Same guard tier as the discovery probe above: local-first, so LAN
// Ollama hosts are reachable while the outbound guard stays enforced.
guard: getProviderValidationGuard(),
proxyConfig: proxy,
method: "POST",
headers: buildOptionalBearerHeaders(token),
body: JSON.stringify({ model: modelId, verbose: false }),
});
return showResponse.ok ? await showResponse.json() : null;
} catch {
return null;
}
});
}
3 changes: 3 additions & 0 deletions src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
mergeSpecialtyCatalogIntoLiveModels,
buildOptionalBearerHeaders,
buildNamedOpenAiStyleHeaders,
enrichOllamaLocalModels,
} from "./discovery/helpers";
import {
fetchAntigravityDiscoveryModelsCached,
Expand Down Expand Up @@ -794,6 +795,8 @@ export async function GET(
models = isNamedOpenAIStyleProvider(provider)
? normalizeOpenAiLikeModelsResponse(data, provider)
: data.data || data.models || [];
if (provider === "ollama-local")
models = await enrichOllamaLocalModels(models, baseUrl, proxy, token);
break; // Success!
}

Expand Down
19 changes: 16 additions & 3 deletions src/app/api/v1/images/generations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import { getComboByName } from "@/lib/db/combos";
import { getAllCustomModels } from "@/lib/db/models";
import { resolveProxyForConnection } from "@/lib/db/settings";
import { resolveImageRouteModel } from "@/lib/images/imageRouteModel";
import {
resolveLocalSyncedEndpointRoute,
type LocalSyncedEndpointRoute,
} from "@/lib/providerModels/syncedEndpointRouting";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { calculateModalCost } from "@/lib/usage/costCalculator";
Expand Down Expand Up @@ -145,6 +149,16 @@ async function postHandler(request, context) {
// Parse model to get provider
let { provider, model: requestedModel } = parseImageModel(body.model);
let isCustomModel = false;
let syncedEndpointRoute: LocalSyncedEndpointRoute | null = null;

if (!provider) {
syncedEndpointRoute = await resolveLocalSyncedEndpointRoute(body.model, "images");
if (syncedEndpointRoute) {
provider = syncedEndpointRoute.provider;
body.model = `${syncedEndpointRoute.provider}/${syncedEndpointRoute.model}`;
isCustomModel = true;
}
}

// If not in built-in registry, check custom models tagged for images
if (!provider) {
Expand Down Expand Up @@ -231,9 +245,8 @@ async function postHandler(request, context) {
credentials = await getProviderCredentialsWithQuotaPreflight(
provider,
null,
null,
requestedModel
);
syncedEndpointRoute?.connectionIds ?? null,
requestedModel );
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
Expand Down
59 changes: 56 additions & 3 deletions src/lib/embeddings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { isPrivateHost, isCloudMetadataHost } from "@/shared/network/outboundUrl
import { calculateCost } from "@/lib/usage/costCalculator";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import { resolveLocalSyncedEndpointRoute } from "@/lib/providerModels/syncedEndpointRouting";

type ValidatedEmbeddingBody = Record<string, unknown> & { model: string };
type ProviderCredentialsResult = Awaited<ReturnType<typeof getProviderCredentials>>;
Expand Down Expand Up @@ -164,21 +165,74 @@ export async function createEmbeddingResponse(
model: options.resolvedModel ?? body.model,
}
: parseEmbeddingModel(body.model, dynamicProviders);
const { provider, model: resolvedModel } = parsedModel;
let { provider, model: resolvedModel } = parsedModel;
// #11088: a bare local-model request routes through the connection that
// advertises the requested endpoint — only when no explicit resolvedProvider
// already won above (explicit resolution takes precedence).
const syncedEndpointRoute = options.resolvedProvider
? null
: await resolveLocalSyncedEndpointRoute(body.model, "embeddings");
if (syncedEndpointRoute) {
provider = syncedEndpointRoute.provider;
resolvedModel = syncedEndpointRoute.model;
}
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid embedding model: ${body.model}. Use format: provider/model`
);
}

let credentials: ProviderCredentialsResult | null = null;
let providerConfig: EmbeddingProvider | null =
options.resolvedProvider ||
dynamicProviders.find((dp) => dp.id === provider) ||
getEmbeddingProvider(provider) ||
null;
let credentialsProviderId = provider;

if (syncedEndpointRoute) {
credentials = await getProviderCredentials(
provider,
null,
syncedEndpointRoute.connectionIds,
syncedEndpointRoute.model
);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for embedding provider: ${provider}`
);
}
if ("allRateLimited" in credentials && credentials.allRateLimited) {
return unavailableResponse(
HTTP_STATUS.RATE_LIMITED,
`[${provider}] All accounts rate limited`,
credentials.retryAfter,
credentials.retryAfterHuman
);
}

const providerSpecificData = (credentials as { providerSpecificData?: Record<string, unknown> })
.providerSpecificData;
const configuredBaseUrl = providerSpecificData?.baseUrl;
if (typeof configuredBaseUrl !== "string" || configuredBaseUrl.trim().length === 0) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No base URL configured for embedding provider: ${provider}`
);
}
let baseUrl = configuredBaseUrl.trim();
while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
providerConfig = {
id: provider,
baseUrl: baseUrl.endsWith("/embeddings") ? baseUrl : `${baseUrl}/embeddings`,
authType: "apikey",
authHeader: "bearer",
models: [],
};
}

if (!providerConfig) {
try {
const allNodes = (await getCachedProviderNodes()) as unknown as EmbeddingProviderNodeRow[];
Expand Down Expand Up @@ -226,8 +280,7 @@ export async function createEmbeddingResponse(
);
}

let credentials: ProviderCredentialsResult | null = null;
if (providerConfig.authType !== "none") {
if (!credentials && providerConfig.authType !== "none") {
credentials = await getProviderCredentials(credentialsProviderId);
if (!credentials) {
return errorResponse(
Expand Down
13 changes: 11 additions & 2 deletions src/lib/providerModels/managedModelImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts";
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
import { isSelfHostedChatProvider } from "@/shared/constants/providers";

type JsonRecord = Record<string, unknown>;

Expand Down Expand Up @@ -253,10 +254,18 @@ export async function importManagedModels({
const previousSyncedAvailableModels =
previousSyncedAvailableModelsInput ??
(await getSyncedAvailableModelsForConnection(providerId, connectionId));
const discoveredModels = filterChatSelectableModels(
// #11088 (option 1): self-hosted providers keep their non-chat models — chat
// filtering happens at read time (resolveLocalSyncedEndpointRoute). Every other
// provider keeps the import-time chat filter: the read-time path is gated on
// isSelfHostedChatProvider, so dropping it globally leaked image/video models
// into OpenAI chat selections (#11271).
const selectableModels = filterSelectableModels(
providerId,
filterSelectableModels(providerId, normalizeDiscoveredModels(fetchedModels, providerId))
normalizeDiscoveredModels(fetchedModels, providerId)
);
const discoveredModels = isSelfHostedChatProvider(providerId)
? selectableModels
: filterChatSelectableModels(providerId, selectableModels);
const candidateImportedModels = normalizeImportedModels(discoveredModels);
const importedIds = new Set(candidateImportedModels.map((model) => model.id));

Expand Down
9 changes: 6 additions & 3 deletions src/lib/providerModels/modelDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
} from "@/lib/db/models";
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization";
import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts";
import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts";
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";

type JsonRecord = Record<string, unknown>;
Expand Down Expand Up @@ -379,9 +378,13 @@ export async function persistDiscoveredModels(
connectionId: string,
models: unknown
): Promise<SyncedAvailableModel[]> {
const normalized = filterChatSelectableModels(
// #11088 (option 1): the synced store is endpoint-agnostic — images/embeddings
// models must persist so per-connection endpoint routing (#11088) and the
// /v1/models catalog can see them. Chat selectability is applied at read time
// (auto-pool expansion, chat projections), not at write time.
const normalized = filterSelectableModels(
providerId,
filterSelectableModels(providerId, normalizeDiscoveredModels(models, providerId))
normalizeDiscoveredModels(models, providerId)
);
await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized);
return normalized;
Expand Down
98 changes: 98 additions & 0 deletions src/lib/providerModels/ollamaCapabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { z } from "zod";

type JsonRecord = Record<string, unknown>;

const ollamaShowResponseSchema = z
.object({
capabilities: z.array(z.string().max(64)).max(32).optional(),
})
.passthrough();

const OLLAMA_CAPABILITY_TO_ENDPOINT: Readonly<Record<string, string>> = {
completion: "chat",
embedding: "embeddings",
image: "images",
};

const MAX_CONCURRENT_SHOW_REQUESTS = 4;

function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}

export function buildOllamaShowUrl(openAiBaseUrl: string): string {
let base = openAiBaseUrl.trim();
while (base.endsWith("/")) base = base.slice(0, -1);
base = base.replace(/\/(?:chat\/completions|completions|embeddings|images\/generations)$/i, "");
if (base.endsWith("/v1")) base = base.slice(0, -3);
return `${base}/api/show`;
}

export function applyOllamaShowCapabilities(model: unknown, showResponse: unknown): JsonRecord {
const record = asRecord(model);
const parsed = ollamaShowResponseSchema.safeParse(showResponse);
if (!parsed.success || !parsed.data.capabilities) return record;

const capabilities = Array.from(
new Set(parsed.data.capabilities.map((value) => value.trim().toLowerCase()).filter(Boolean))
);
const supportedEndpoints = Array.from(
new Set(
capabilities
.map((capability) => OLLAMA_CAPABILITY_TO_ENDPOINT[capability])
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
);
if (supportedEndpoints.length === 0) return record;

const apiFormat = supportedEndpoints.includes("chat")
? "chat-completions"
: supportedEndpoints.includes("embeddings")
? "embeddings"
: "images-generations";

return {
...record,
apiFormat,
supportedEndpoints,
...(capabilities.includes("vision") ? { supportsVision: true } : {}),
...(capabilities.includes("tools") ? { supportsTools: true } : {}),
...(capabilities.includes("thinking") ? { supportsThinking: true } : {}),
};
}

export async function enrichOllamaModelsWithCapabilities(
models: unknown[],
fetchShow: (modelId: string) => Promise<unknown | null>
): Promise<JsonRecord[]> {
const output: JsonRecord[] = new Array(models.length);
let nextIndex = 0;

const worker = async () => {
while (nextIndex < models.length) {
const index = nextIndex++;
const model = asRecord(models[index]);
const modelId =
typeof model.id === "string"
? model.id
: typeof model.name === "string"
? model.name
: typeof model.model === "string"
? model.model
: null;
if (!modelId) {
output[index] = model;
continue;
}
try {
output[index] = applyOllamaShowCapabilities(model, await fetchShow(modelId));
} catch {
output[index] = model;
}
}
};

const workerCount = Math.min(MAX_CONCURRENT_SHOW_REQUESTS, Math.max(1, models.length));
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return output;
}
Loading
Loading