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
6 changes: 5 additions & 1 deletion desktop/src/electron/service-bridge/engine-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ function isReceiptPhase(value: JsonValue | undefined): value is EngineSettingsRe
}
function config(value: JsonValue | undefined): EngineSettingsConfig {
const row = object(value)
const modelSource = row.modelSource
if (modelSource !== undefined && modelSource !== 'huggingface' && modelSource !== 'modelscope')
throw new Error('Invalid model download source.')
return {
serverPort: number(row.serverPort),
proxyPort: number(row.proxyPort),
launchText: text(row.launchText)
launchText: text(row.launchText),
...(modelSource ? { modelSource } : {})
}
}
export function parseEngineSettings(value: JsonValue | undefined): EngineSettingsSnapshot {
Expand Down
1 change: 1 addition & 0 deletions desktop/src/shared/types/engine-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface EngineSettingsConfig {
proxyPort: number
/** Arguments and leading environment assignments, without an executable/subcommand. */
launchText: string
modelSource?: 'huggingface' | 'modelscope'
}

import type { EngineManagerName } from '@/shared/types/engines'
Expand Down
46 changes: 40 additions & 6 deletions desktop/src/ui/components/BackendRow/EngineSettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ModalDialog,
ModalHeading,
ModalRoot,
Select,
Stack,
Text,
TextArea,
Expand All @@ -36,14 +37,19 @@ interface Draft {
serverPort: string
proxyPort: string
launchText: string
modelSource: NonNullable<EngineSettingsConfig['modelSource']>
}
const asDraft = (config: EngineSettingsConfig): Draft => ({
...config,
serverPort: String(config.serverPort),
proxyPort: String(config.proxyPort)
proxyPort: String(config.proxyPort),
modelSource: config.modelSource ?? 'huggingface'
})
const sameDraft = (a: Draft, b: Draft): boolean =>
a.serverPort === b.serverPort && a.proxyPort === b.proxyPort && a.launchText === b.launchText
a.serverPort === b.serverPort &&
a.proxyPort === b.proxyPort &&
a.launchText === b.launchText &&
a.modelSource === b.modelSource

export function EngineSettingsSection({
nodeId,
Expand All @@ -61,7 +67,12 @@ export function EngineSettingsSection({
const connected = useConnectionStore(state => state.connected)
const isLocalNode = useConnectionStore(state => state.selfId === nodeId)
const [base, setBase] = useState<EngineSettingsSnapshot>()
const [draft, setDraft] = useState<Draft>({ serverPort: '', proxyPort: '', launchText: '' })
const [draft, setDraft] = useState<Draft>({
serverPort: '',
proxyPort: '',
launchText: '',
modelSource: 'huggingface'
})
const [errorDialog, setErrorDialog] = useState('')
const [confirmation, setConfirmation] = useState<EngineSettingsRequest>()
const [checking, setChecking] = useState(false)
Expand Down Expand Up @@ -109,7 +120,7 @@ export function EngineSettingsSection({
}
}, [snapshot, base, dirty])

function change(field: keyof Draft, value: string): void {
function change<K extends keyof Draft>(field: K, value: Draft[K]): void {
generation.current++
if (field === 'serverPort') serverPortEdited.current = true
setDraft(current => ({ ...current, [field]: value }))
Expand Down Expand Up @@ -142,7 +153,12 @@ export function EngineSettingsSection({
const request: EngineSettingsRequest = {
...target,
expectedRevision: base.revision,
settings: { serverPort, proxyPort, launchText: draft.launchText },
settings: {
serverPort,
proxyPort,
launchText: draft.launchText,
...(engineType === 'llama-cpp' ? { modelSource: draft.modelSource } : {})
},
resolution: useServerPort ? 'server' : 'launch'
}
try {
Expand Down Expand Up @@ -254,6 +270,24 @@ export function EngineSettingsSection({
/>
</FormField>
</Flex>
{engineType === 'llama-cpp' && (
<FormField slotLabel="Model download source">
<Select
size="small"
aria-label="Model download source"
value={draft.modelSource}
items={[
{ value: 'huggingface', children: 'Hugging Face' },
{ value: 'modelscope', children: 'ModelScope' }
]}
onValueChange={value => {
if (value === 'huggingface' || value === 'modelscope')
change('modelSource', value)
}}
disabled={locked || snapshot?.settings.modelSource === undefined}
/>
</FormField>
)}
<FormField slotLabel="Engine arguments">
<TextArea
size="small"
Expand Down Expand Up @@ -340,7 +374,7 @@ export function EngineSettingsSection({
}}
title="Restart engine?"
confirmLabel="Restart"
message="Applying these arguments will restart your engine, continue?"
message="Applying these settings will restart your engine, continue?"
onConfirm={() => {
if (confirmation) void apply(confirmation)
}}
Expand Down
74 changes: 74 additions & 0 deletions desktop/tests/modular/engine-settings-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock('@/electron/service-bridge/model-catalog', () => ({ getEngineHubModels:

import { handleServiceBridgeInvoke } from '@/electron/service-bridge/empty-handlers'
import { MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS } from '@/shared/constants/modular-runtime'
import type { EngineSettingsConfig } from '@/shared/types/engine-settings'

const baseline = {
nodeId: 'local-node',
Expand Down Expand Up @@ -113,4 +114,77 @@ describe('unified settings bridge', () => {
})
).rejects.toThrow()
})

it.each(['huggingface', 'modelscope'])(
'preserves the %s model source in snapshots',
async source => {
mocks.supervisor.callProcess.mockResolvedValue({
...baseline,
engine: 'llamacpp',
settings: { ...baseline.settings, modelSource: source }
})
const snapshot = await handleServiceBridgeInvoke('engines:get-settings', {
nodeId: 'local-node',
engine: 'llamacpp'
})
expect(snapshot.settings.modelSource).toBe(source)
}
)

it('preserves settings that omit a model source', async () => {
const snapshot = await handleServiceBridgeInvoke('engines:get-settings', {
nodeId: 'local-node',
engine: 'ollama'
})
expect(snapshot.settings).toEqual(baseline.settings)
})

it('relays a source-only change through preview and apply', async () => {
const settings: EngineSettingsConfig = {
serverPort: 8081,
proxyPort: 8080,
launchText: '--host 127.0.0.1 --port 8081',
modelSource: 'modelscope'
}
mocks.supervisor.callProcess.mockResolvedValueOnce({
settings,
revision: 4,
errors: {},
restart: true,
rebind: false
})
const request = {
nodeId: 'local-node',
engine: 'llamacpp',
expectedRevision: 4,
settings
} satisfies Parameters<typeof window.pairApi.engines.previewSettings>[0]
const preview = await handleServiceBridgeInvoke('engines:preview-settings', request)
expect(preview.settings).toEqual(settings)
await handleServiceBridgeInvoke('engines:apply-settings', {
...request,
settings: preview.settings,
requestId: 'source-change'
})
expect(mocks.supervisor.callProcess).toHaveBeenLastCalledWith(
'broker',
'engine:apply-settings',
{ ...request, requestId: 'source-change' },
MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS
)
})

it('rejects an unsupported model source from a device', async () => {
mocks.supervisor.callProcess.mockResolvedValue({
...baseline,
engine: 'llamacpp',
settings: { ...baseline.settings, modelSource: 'unrecognized' }
})
await expect(
handleServiceBridgeInvoke('engines:get-settings', {
nodeId: 'local-node',
engine: 'llamacpp'
})
).rejects.toThrow('Invalid model download source.')
})
})
15 changes: 15 additions & 0 deletions docs/engine-settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ vendor options can fail at startup.
For installing, starting and updating engines, refer to
[Managing engines](engine-lifecycle.mdx).

## Model Download Source

For llama.cpp, choose **Hugging Face** (the default) or **ModelScope** in
**Model download source**, then select **Apply**. A running engine asks to restart
before changing sources; a stopped engine stays stopped. The choice is saved
on the device running the engine and also selects the source used by the model
browser and search. Public GGUF repositories use the same `owner/repository:quant`
names, with `Q4_K_M` offered in the browser. Repository availability can differ
between sources. Downloaded files stay in llama.cpp's existing cache.

PAIR manages llama.cpp's `MODEL_ENDPOINT` from this selection. Set the source
with the dropdown rather than an environment assignment. ModelScope downloads
do not forward Hugging Face credentials; private or gated ModelScope models are
not supported.

## Argument Notation

The text contains engine arguments and optional leading environment assignments.
Expand Down
4 changes: 3 additions & 1 deletion services/nvpair-engine-manager/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ is validated again before execution, including settings saved by earlier version

The editor saves literal extra arguments in
host-platform `runtime.launch_args` and explicit environment assignments in
`runtime.launch_env`, after trusted template expansion. Empty arrays clear the
`runtime.launch_env`, after trusted template expansion. llama.cpp's selected
download provider is saved in host-platform `runtime.model_source` (`huggingface`
or `modelscope`). Empty arrays clear the
saved arguments or environment assignments. See [LAUNCH_TEXT.md](LAUNCH_TEXT.md) for validation
and [the broker protocol](../nvpair-ui-broker/ENGINE_SETTINGS.md) for application
and recovery. Editing `args`/`start` directly remains trusted manifest authoring.
Expand Down
7 changes: 7 additions & 0 deletions services/nvpair-engine-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ The `llamacpp` manifest runs `llama-server` in router mode on loopback port
uses the router's native `DELETE /models` cache operation and does not restart
the router. `LLAMA_CACHE` points at the manifest's `models_dir`, `~/.llamacpp`,
which no removal this service performs will delete.
The llama.cpp `modelSource` setting selects Hugging Face (default) or ModelScope
for both its catalogue and downloads. The manager translates ModelScope metadata
through a loopback-only adapter and sets the router's `MODEL_ENDPOINT`; llama.cpp
still owns GGUF selection, caching, progress and cancellation. ModelScope serves
public, ungated models without forwarding Hugging Face credentials.
Repositories that reach ModelScope's 3,000-entry file-list limit are excluded
rather than offered with incomplete GGUF shard metadata.
Readiness requires `/props` to report `role:"router"`, so model-selection
arguments that switch `llama-server` to single-model mode and incompatible
listeners already occupying the port are rejected rather than adopted.
Expand Down
49 changes: 34 additions & 15 deletions services/nvpair-engine-manager/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ import (
// holds, but not what its upstream offers.
//
// It lives here rather than in a client so both the desktop app and the terminal
// interface see the same catalogue. Each engine has exactly one curated source,
// and they are deliberately different in kind:
// interface see the same catalogue. Sources differ by engine:
//
// - Ollama has no public library API, only a client-rendered web page. A live
// scrape was fragile and broke whenever the markup changed, so the list is a
Expand All @@ -39,10 +38,10 @@ import (
// - LM Studio's catalogue *is* a Hugging Face org (`lmstudio-community`), whose
// repo ids are exactly the strings `lms get` accepts. That has a real API, so
// it is fetched live and cached.
// - llama.cpp downloads any GGUF repo on Hugging Face as `repo:quantization`.
// The browse list is the GGUF repos of a few approved publishers, fetched
// live and cached; a query searches all of Hugging Face, each query cached
// on its own.
// - llama.cpp downloads GGUF repos from its selected Hugging Face or ModelScope
// source as `repo:quantization`.
// Hugging Face browses approved publishers; ModelScope browses popular
// repositories. Both fetch live, with each source and search cached separately.

// catalogSourceKind distinguishes how an engine's catalogue is obtained.
type catalogSourceKind int
Expand Down Expand Up @@ -244,7 +243,9 @@ type catalogService struct {
// baseURL overrides the listing endpoint in tests. Empty means the real one;
// the caching and coalescing around the fetch is the part worth testing, and
// it cannot be exercised against the live API.
baseURL string
baseURL string
modelScopeEndpoint string
llamaSource func() (string, error)
}

// listingCache holds one fetched catalogue. It serves from memory while the
Expand Down Expand Up @@ -326,6 +327,21 @@ func (c *catalogService) Catalog(ctx context.Context, engine, platform, arch, qu
if err != nil {
return CatalogResult{}, err
}
if c.llamaSource != nil {
source, err := c.llamaSource()
if err != nil {
return CatalogResult{}, err
}
if source == "modelscope" {
models, fetchedAt, err := c.catalogSearch("modelscope:"+strings.ToLower(query), func(ctx context.Context) ([]CatalogModel, error) {
return c.fetchModelScope(ctx, query)
}).get(ctx)
if err != nil {
return CatalogResult{}, err
}
return CatalogResult{Models: models, Source: modelScopeURL + "/models", Platform: platform, Arch: arch, FetchedAt: fetchedAt.UTC().Format(time.RFC3339), Searchable: true, Query: query}, nil
}
}
models, fetchedAt, err := c.llamaCPPCatalog(ctx, query)
if err != nil {
return CatalogResult{}, err
Expand Down Expand Up @@ -579,21 +595,24 @@ func (c *catalogService) llamaCPPCatalog(ctx context.Context, query string) ([]C
// llamaCPPSearch is the cache for one query, created on first use. Queries
// differing only in case share one.
func (c *catalogService) llamaCPPSearch(query string) *listingCache {
key := strings.ToLower(query)
return c.catalogSearch("huggingface:"+strings.ToLower(query), func(ctx context.Context) ([]CatalogModel, error) {
rows, err := c.fetchHF(ctx, llamaCPPListing("search", query))
if err != nil {
return nil, err
}
return normalizeLlamaCPPRows(rows), nil
})
}

func (c *catalogService) catalogSearch(key string, fetch func(context.Context) ([]CatalogModel, error)) *listingCache {
c.searchMu.Lock()
defer c.searchMu.Unlock()
search, ok := c.llamaSearches[key]
if !ok {
search = &listingCache{
label: "llama.cpp search",
emptyOK: true,
fetch: func(ctx context.Context) ([]CatalogModel, error) {
rows, err := c.fetchHF(ctx, llamaCPPListing("search", query))
if err != nil {
return nil, err
}
return normalizeLlamaCPPRows(rows), nil
},
fetch: fetch,
}
c.llamaSearches[key] = search
}
Expand Down
Loading
Loading