Skip to content
Open
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
40 changes: 37 additions & 3 deletions desktop/src/electron/model-hub/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
// SPDX-License-Identifier: Apache-2.0

import type { EngineType } from '@/shared/types/engines'
import type { EngineHubModel, EngineHubSearchResponse } from '@/shared/types/engine-api'
import type {
EngineHubLookupResponse,
EngineHubModel,
EngineHubSearchResponse
} from '@/shared/types/engine-api'
import { loadOllamaModels, type OllamaTagsModel } from '@/electron/model-hub/ollama-library'
import { lookupOllamaModel } from '@/electron/model-hub/ollama-registry'
import {
lmStudioCatalogCache,
type LmStudioCatalogModel
Expand All @@ -15,11 +20,16 @@ function ollamaToHubModel(m: OllamaTagsModel): EngineHubModel {
id: m.name,
name: m.name,
author: '',
url: `https://ollama.com/library/${base}`,
// A namespaced name addresses that namespace's page; a bare one is a
// first-party model under `library`.
url: base.includes('/')
? `https://ollama.com/${base}`
: `https://ollama.com/library/${base}`,
size: m.size > 0 ? m.size : undefined,
downloads: 0,
likes: 0,
updatedAt: m.modified_at || new Date().toISOString(),
// Empty means the source reported no date; the row omits its age.
updatedAt: m.modified_at,
tags: [],
family: m.details.family || undefined,
parameterSize: m.details.parameter_size || undefined
Expand Down Expand Up @@ -57,6 +67,30 @@ export async function getEngineHubModels(engineType: EngineType): Promise<Engine
}
}

/**
* Resolve one exact model name {@link getEngineHubModels} did not return.
* Ollama's list is a committed snapshot, so a model published since it was taken
* is absent from the browse list even though the engine can pull it; this asks
* Ollama's registry for that one name.
*
* A null model means "not available" for every cause alike — no lookup source
* for the engine, no such model, or an unreachable registry. LM Studio has none
* because its catalog is fetched live and never falls behind a release.
*/
export async function lookupEngineHubModel(
engineType: EngineType,
name: string
): Promise<EngineHubLookupResponse> {
switch (engineType) {
case 'ollama': {
const found = await lookupOllamaModel(name)
return { model: found ? ollamaToHubModel(found) : null }
}
default:
return { model: null }
}
}

/**
* Kick a background refresh of the live engine hub caches so the first modal
* open is instant. Ollama needs no warming (it is a committed static list);
Expand Down
239 changes: 239 additions & 0 deletions desktop/src/electron/model-hub/ollama-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import axios, { isAxiosError } from 'axios'
import { createStructuredLogger } from '@/shared/utils/log'
import getErrorString from '@/shared/utils/get-error-string'
import type { OllamaTagsModel } from '@/electron/model-hub/ollama-library'

const log = createStructuredLogger('ollama-registry')

/**
* Resolve one Ollama model by exact name, for names the committed catalog
* snapshot (`ollama-models.json`) does not carry.
*
* `registry.ollama.ai` speaks the OCI distribution API that `ollama pull` uses,
* so this asks the same source the engine would. It only reads manifests: the
* registry answers 404 for `/tags/list`, so enumerating a catalog still needs
* the snapshot and a lookup is an exact-name check rather than a search.
*/

const REGISTRY_BASE = 'https://registry.ollama.ai/v2'
const USER_AGENT = 'PAIR/1.0'

/**
* Bounds the whole request rather than just the response. `axios`'s `timeout` is
* a socket timeout and does not start until a socket exists, so it misses a
* wedged DNS resolver; an `AbortSignal` covers every phase. `lmstudio-catalog.ts`
* carries the same reasoning at more length.
*/
const HTTP_TIMEOUT_MS = 8_000

/** What `ollama pull <name>` resolves a bare name to. */
const DEFAULT_TAG = 'latest'

/** Ollama's namespace for first-party models, implied by a bare name. */
const DEFAULT_NAMESPACE = 'library'

/**
* Characters a repository segment or tag may contain. This is the only guard on
* what reaches the request URL, so it has to hold on its own: the renderer runs
* its own check before asking, but a lookup arrives over IPC and main-process
* code does not treat the renderer as trusted. Without it, `foo/..` walks out of
* the namespace and `foo/bar#z` truncates the path.
*/
const SAFE_SEGMENT = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/

/**
* Positive and negative results both cached, because the hub looks names up as
* the user types and would otherwise re-request on every keystroke past a
* resolved name. Bounded so a long session cannot grow it without limit.
*/
const CACHE_LIMIT = 256
const cache = new Map<string, OllamaTagsModel | null>()

function remember(key: string, value: OllamaTagsModel | null): OllamaTagsModel | null {
if (cache.size >= CACHE_LIMIT) {
// Map iteration is insertion-ordered, so the first key is the oldest.
const oldest = cache.keys().next()
if (!oldest.done) cache.delete(oldest.value)
}
cache.set(key, value)
return value
}

/**
* Split `[namespace/]name[:tag]` into a registry path and tag, or null for a
* reference this registry cannot answer.
*
* OCI clients read a dot in the first of *several* segments as a registry
* hostname, which is how `hf.co/<user>/<repo>` is recognized as someone else's
* registry and declined rather than reported as missing. That rule must not
* reach a lone segment: bare model names legitimately carry dots — `qwen3.8`,
* `llama3.1`, `phi3.5` — and are always first-party names under `library`.
*/
function parseReference(name: string): { path: string; tag: string } | null {
const colon = name.lastIndexOf(':')
const slash = name.lastIndexOf('/')
// A colon ahead of the last slash belongs to a host:port, not a tag.
const hasTag = colon > slash
const repository = hasTag ? name.slice(0, colon) : name
const tag = hasTag ? name.slice(colon + 1) : DEFAULT_TAG
if (!SAFE_SEGMENT.test(tag)) return null

const segments = repository.split('/')
if (!segments.every(segment => SAFE_SEGMENT.test(segment))) return null
if (segments.length === 1) return { path: `${DEFAULT_NAMESPACE}/${segments[0]}`, tag }
if (segments.length > 2 || segments[0].includes('.')) return null
return { path: repository, tag }
}

function isRecord(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object'
}

function manifestLayers(raw: unknown): Record<string, unknown>[] {
if (!isRecord(raw) || !Array.isArray(raw.layers)) return []
return raw.layers.filter(isRecord)
}

/**
* Total bytes the engine downloads: every layer, not just the weights. The
* others (template, params, license) are a few kilobytes each, but the sum is
* what lands on disk.
*/
function totalLayerBytes(raw: unknown): number {
let total = 0
for (const layer of manifestLayers(raw)) {
if (typeof layer.size === 'number' && layer.size > 0) total += layer.size
}
return total
}

/**
* Capability chip for the row. `details.family` carries a capability rather than
* a model family here, because that is what the catalog scraper puts there and
* what the row renders.
*
* Vision is the only one a manifest proves: a multimodal model carries an extra
* `projector` layer for its vision encoder and nothing else does. Tools,
* thinking and embedding exist only on ollama.com's rendered page, so a model
* with those and no projector gets no chip instead of a wrong one.
*/
function capabilityFromLayers(raw: unknown): string {
const vision = manifestLayers(raw).some(
layer => layer.mediaType === 'application/vnd.ollama.image.projector'
)
return vision ? 'vision' : ''
}

function configDigest(raw: unknown): string {
if (!isRecord(raw) || !isRecord(raw.config)) return ''
return typeof raw.config.digest === 'string' ? raw.config.digest : ''
}

/** The 12-character form Ollama shows in its own model lists. */
function shortenDigest(digest: string): string {
return digest.replace(/^sha256:/, '').slice(0, 12)
}

/**
* What the model says about itself, read from the config blob the manifest
* points at. Worth the extra request because a tag like `27b-coding-mxfp8` is a
* label the publisher chose, while these are the real figures.
*/
interface OllamaModelConfig {
format: string
families: string[] | null
parameterSize: string
quantizationLevel: string
}

/** Resolves to null on any failure; the row still renders, with less on it. */
async function fetchConfig(path: string, digest: string): Promise<OllamaModelConfig | null> {
if (!digest) return null
try {
const { data } = await axios.get<unknown>(`${REGISTRY_BASE}/${path}/blobs/${digest}`, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
})
if (!isRecord(data)) return null
const families = Array.isArray(data.model_families)
? data.model_families.filter((f): f is string => typeof f === 'string')
: []
return {
format: typeof data.model_format === 'string' ? data.model_format : '',
families: families.length > 0 ? families : null,
parameterSize: typeof data.model_type === 'string' ? data.model_type : '',
quantizationLevel: typeof data.file_type === 'string' ? data.file_type : ''
}
} catch {
return null
}
}

/**
* Look one exact model name up in Ollama's registry.
*
* Null covers every "not available" case alike — a reference this registry does
* not serve, a model or tag that does not exist, a timeout, an offline machine.
* None of them surface as an error: the lookup is speculative, and the user
* asked to search rather than to make this request.
*/
export async function lookupOllamaModel(name: string): Promise<OllamaTagsModel | null> {
const cached = cache.get(name)
if (cached !== undefined) return cached

const reference = parseReference(name)
if (!reference) return remember(name, null)

try {
const { data } = await axios.get<unknown>(
`${REGISTRY_BASE}/${reference.path}/manifests/${reference.tag}`,
{
headers: {
'User-Agent': USER_AGENT,
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
},
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
}
)
const size = totalLayerBytes(data)
if (size === 0) return remember(name, null)

const digest = configDigest(data)
const config = await fetchConfig(reference.path, digest)

log.info({ sublevel: 'lookup', message: `resolved ${name} outside the catalog snapshot` })
return remember(name, {
name,
model: name,
// Neither the manifest nor its headers carry a publication date.
// Empty means unknown, and the row omits its age rather than
// claiming the model was updated just now.
modified_at: '',
size,
digest: shortenDigest(digest),
details: {
parent_model: '',
format: config?.format ?? '',
family: capabilityFromLayers(data),
families: config?.families ?? null,
// Falls back to the tag, which is what the catalog shows here.
parameter_size:
config?.parameterSize || (reference.tag === DEFAULT_TAG ? '' : reference.tag),
quantization_level: config?.quantizationLevel ?? ''
}
})
} catch (err) {
const status = isAxiosError(err) ? err.response?.status : undefined
// 404 is the ordinary answer for a name that does not exist.
if (status !== 404) {
log.warn({
sublevel: 'http',
message: `lookup for ${name} failed: ${status ?? ''} ${getErrorString(err)}`.trim()
})
}
return remember(name, null)
}
}
4 changes: 3 additions & 1 deletion desktop/src/electron/service-bridge/empty-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS
} from '@/shared/constants/modular-runtime'
import getErrorString from '@/shared/utils/get-error-string'
import { getEngineHubModels } from '@/electron/model-hub'
import { getEngineHubModels, lookupEngineHubModel } from '@/electron/model-hub'
import { getModularSupervisor } from './modular-supervisor'
import {
getModularBridgeState,
Expand Down Expand Up @@ -966,6 +966,8 @@ const EMPTY_SERVICE_BRIDGE_HANDLERS: BridgeHandlerMap = {
'engine:command': payload => handleEngineCommand(payload),
'engine:search-hub': payload =>
payload ? getEngineHubModels(payload.engineType) : { models: [] },
'engine:lookup-hub-model': payload =>
payload ? lookupEngineHubModel(payload.engineType, payload.name) : { model: null },

'errors:get-initial': () => handleErrorsGetInitial(),
'errors:clear': payload => (payload ? handleErrorsClear(payload) : null),
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/shared/types/engine-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export interface EngineHubSearchResponse {
models: EngineHubModel[]
}

/**
* Response for the `engine:lookup-hub-model` channel. `null` means the name is
* not available, whether because it does not exist, the engine has no registry
* to ask, or that registry could not be reached.
*/
export interface EngineHubLookupResponse {
model: EngineHubModel | null
}

/** Engine command discriminator. */
export type EngineCommandType =
| 'toggle'
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/shared/types/ws-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/
import type {
EngineCommandPayload,
EngineHubLookupResponse,
EngineHubSearchResponse,
EngineInitialState,
EngineStatePatch
Expand Down Expand Up @@ -93,6 +94,12 @@ export interface WsInvokeChannelMap {
'engines:get-initial': { request: void; response: EngineInitialState }
'engine:command': { request: EngineCommandPayload; response: null }
'engine:search-hub': { request: { engineType: EngineType }; response: EngineHubSearchResponse }
// Exact-name lookup, used only when a search matches nothing, so the
// catalog the app ships with cannot hide a model the engine could pull.
'engine:lookup-hub-model': {
request: { engineType: EngineType; name: string }
response: EngineHubLookupResponse
}

// Errors
'errors:get-initial': { request: void; response: ServiceError[] }
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/ui/api/engine-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
import type { PreloadServiceTransport as ServiceTransport } from '@/shared/types/service-bridge'
import type {
EngineHubLookupResponse,
EngineHubSearchResponse,
EngineInitialState,
EngineStatePatch,
Expand Down Expand Up @@ -49,6 +50,8 @@ export interface IEngineApi {
setModelExpiry(engineType: EngineType, nodeId: string, model: string, expiry: string): void
/** Search the model registry/hub for available models. */
searchHub(engineType: EngineType): Promise<EngineHubSearchResponse>
/** Resolve one exact model name the hub catalog does not list. */
lookupHubModel(engineType: EngineType, name: string): Promise<EngineHubLookupResponse>

/** Durable engine state changed. Prefer this for new renderer state. */
onStateChanged(callback: (patch: EngineStatePatch) => void): () => void
Expand Down Expand Up @@ -103,6 +106,8 @@ export function createEngineApi(transport: ServiceTransport): IEngineApi {
expiry
}),
searchHub: engineType => transport.invoke('engine:search-hub', { engineType }),
lookupHubModel: (engineType, name) =>
transport.invoke('engine:lookup-hub-model', { engineType, name }),

onStateChanged: cb => transport.subscribePush('engines:state-changed', cb),
onProgress: cb => transport.subscribePush('engines:progress-changed', cb),
Expand Down
Loading