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/next/changed-issue-4154.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Ollama provider model refresh now probes each daemon once and applies the result to every provider sharing it, instead of re-running the full /api/tags + per-model capability sweep per provider
9 changes: 7 additions & 2 deletions server/lib/aiToolkit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@

import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { canRefreshModels, createProviderService, isOllamaBackedProvider } from './providers.js';
import {
canRefreshModels,
createProviderService,
isOllamaBackedProvider,
ollamaRefreshGroupKey,
} from './providers.js';
import { createRunnerService } from './runner.js';
import { createPromptsService } from './prompts.js';
import { createProviderStatusService } from './providerStatus.js';
Expand All @@ -25,7 +30,7 @@ export * from './validation.js';
export * from './errorDetection.js';
export * from './constants.js';
export { createProviderService, createRunnerService, createPromptsService, createProviderStatusService };
export { isOllamaBackedProvider, canRefreshModels };
export { isOllamaBackedProvider, canRefreshModels, ollamaRefreshGroupKey };
export { createProvidersRoutes, createRunsRoutes, createPromptsRoutes, createProviderStatusRoutes };

export function createAIToolkit(config = {}) {
Expand Down
57 changes: 56 additions & 1 deletion server/lib/aiToolkit/internal/modelFetchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
*/
import { ANTIGRAVITY_TUI_ID, isAntigravityCommand } from './antigravity.js';
import { CURSOR_TUI_ID, isCursorCommand } from './cursor.js';
import { isOllamaBackedProvider } from './ollamaBacked.js';
import { isOllamaBackedProvider, ollamaBaseFromProvider } from './ollamaBacked.js';

const displayName = (provider) => String(provider?.name || '').toLowerCase();

Expand Down Expand Up @@ -161,3 +161,58 @@ export function withRefreshCapabilityList(providers) {
if (!Array.isArray(providers)) return providers;
return providers.map(withRefreshCapability);
}

/**
* Stable key grouping providers whose model refresh issues the IDENTICAL probe
* against the same Ollama daemon — or `null` when the provider's refresh isn't
* an Ollama probe at all (refresh it on its own).
*
* Why a host wants this: several providers commonly resolve to ONE daemon (the
* built-in `ollama` provider plus any number of Claude/Codex/Gemini-over-Ollama
* CLI/TUI providers, all defaulting to `http://localhost:11434`). Refreshing
* them one by one re-fetches `/api/tags` and re-runs the whole per-model
* `/api/show` capability probe once per provider for a result that cannot
* differ. Grouping on this key lets the caller fetch once and apply the answer
* to every member (`fetchProviderModels` + `updateProvider`).
*
* `null` is a real sentinel, NOT "no group": a provider without a key must
* still be refreshed individually. Never treat a nullish key as a bucket.
*
* Two probe shapes are deliberately kept in SEPARATE key namespaces because
* they return different lists for the same daemon:
* - `api:` — an `api`-type provider short-circuits in `_refreshAPIProviderModels`
* to `${endpoint}/api/tags` and persists the UNFILTERED tag list.
* - `tools:` — a `cli`/`tui` provider routes to `_fetchOllamaToolCapableModels`,
* which filters down to tool-use-capable models.
* Collapsing the two would persist a tool-filtered list onto the plain `ollama`
* provider (or an unfiltered one onto a Claude harness that then silently fails
* to edit files).
*
* @param {object|null|undefined} provider
* @param {Array} [table]
* @returns {string|null}
*/
export function ollamaRefreshGroupKey(provider, table = MODEL_FETCHERS) {
if (!provider) return null;
if (provider.type === 'api') {
// Mirror of the short-circuit condition in `_refreshAPIProviderModels`.
// `apiKey` is part of the identity, not an extra: when the `/api/tags`
// short-circuit misses, that method falls THROUGH to a generic `/models`
// fetch carrying `provider.apiKey`, so two providers on one endpoint with
// different keys can legitimately see different catalogs. Only the keyless
// shape is safe to share, and a key makes the provider ungroupable.
if (provider.apiKey) return null;
const endpoint = String(provider.endpoint || '');
if (!endpoint.includes('ollama') && !endpoint.includes(':11434')) return null;
// Only a trailing slash is normalized away here — NOT an OpenAI-compat
// `/v1`, the way `ollamaBaseFromProvider` does for the tools namespace.
// The api arm probes `${endpoint}/api/tags` and then `${endpoint}/models`
// verbatim, so `…:11434` and `…:11434/v1` are two DIFFERENT requests (only
// the `/v1` spelling answers `/models`). Folding them together would share
// one provider's catalog onto another whose own probe would have 404'd.
return `api:${endpoint.replace(/\/+$/, '')}`;
}
return resolveModelFetcher(provider, table)?.key === 'ollama'
? `tools:${ollamaBaseFromProvider(provider)}`
: null;
}
77 changes: 76 additions & 1 deletion server/lib/aiToolkit/internal/modelFetchers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { MODEL_FETCHERS, canRefreshModels, resolveModelFetcher, withRefreshCapability, withRefreshCapabilityList } from './modelFetchers.js';
import { MODEL_FETCHERS, canRefreshModels, ollamaRefreshGroupKey, resolveModelFetcher, withRefreshCapability, withRefreshCapabilityList } from './modelFetchers.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SHIPPED = JSON.parse(readFileSync(resolve(__dirname, '../../../../data.reference/providers.json'), 'utf8'));
Expand Down Expand Up @@ -42,6 +42,81 @@ describe('MODEL_FETCHERS — shipped catalog visibility is unchanged', () => {
});
});

describe('ollamaRefreshGroupKey — one probe per daemon, not one per provider', () => {
it('collapses every shipped CLI/TUI Ollama provider onto ONE key', () => {
// The shipped catalog ships four of them (claude-ollama, claude-ollama-tui,
// opencode-ollama, opencode-ollama-tui) all pointed at the same default
// daemon — the exact fan-out this key exists to dedup. If a future seed adds
// a fifth on the same daemon it must land in this same bucket.
const shared = Object.values(SHIPPED.providers)
.filter((p) => (p.type === 'cli' || p.type === 'tui') && p.ollamaBacked === true);
expect(shared.length).toBeGreaterThanOrEqual(4);
const keys = new Set(shared.map((p) => ollamaRefreshGroupKey(p)));
expect(keys.size).toBe(1);
expect([...keys][0]).toBe('tools:http://localhost:11434');
});

it('keeps the api-type ollama provider OUT of the tool-filtered bucket', () => {
// `_refreshAPIProviderModels` persists the unfiltered tag list; the CLI/TUI
// probe persists a tool-use-only subset. Same daemon, different answers.
const api = SHIPPED.providers.ollama;
expect(api.type).toBe('api');
expect(ollamaRefreshGroupKey(api)).toBe('api:http://localhost:11434/v1');
expect(ollamaRefreshGroupKey(api)).not.toBe(ollamaRefreshGroupKey(SHIPPED.providers['claude-ollama']));
});

it('separates providers on DIFFERENT daemons', () => {
const local = { id: 'a', type: 'cli', ollamaBacked: true, envVars: { ANTHROPIC_BASE_URL: 'http://localhost:11434' } };
const remote = { id: 'b', type: 'cli', ollamaBacked: true, envVars: { ANTHROPIC_BASE_URL: 'http://192.0.2.10:11434' } };
expect(ollamaRefreshGroupKey(local)).not.toBe(ollamaRefreshGroupKey(remote));
});

it('normalizes trailing slashes and an OpenAI-compat /v1 to the same daemon key', () => {
// Safe here because `_fetchOllamaToolCapableModels` itself resolves the base
// through `ollamaBaseFromProvider` — both spellings issue the SAME request.
const bare = { id: 'a', type: 'tui', ollamaBacked: true, envVars: { ANTHROPIC_BASE_URL: 'http://localhost:11434' } };
const suffixed = { id: 'b', type: 'tui', ollamaBacked: true, envVars: { ANTHROPIC_BASE_URL: 'http://localhost:11434/v1/' } };
expect(ollamaRefreshGroupKey(suffixed)).toBe(ollamaRefreshGroupKey(bare));
});

it('does NOT fold /v1 away for an api provider — that arm probes the endpoint verbatim', () => {
// `_refreshAPIProviderModels` requests `${endpoint}/api/tags` then
// `${endpoint}/models` with no normalization, so `…:11434` and
// `…:11434/v1` are genuinely different probes (only the `/v1` spelling
// answers `/models`). Sharing one answer between them would persist a
// catalog onto a provider whose own refresh would have failed.
const bare = { id: 'a', type: 'api', endpoint: 'http://localhost:11434' };
const v1 = { id: 'b', type: 'api', endpoint: 'http://localhost:11434/v1' };
expect(ollamaRefreshGroupKey(bare)).not.toBe(ollamaRefreshGroupKey(v1));
// A bare trailing slash IS just a spelling of the same URL, so it folds.
expect(ollamaRefreshGroupKey({ id: 'c', type: 'api', endpoint: 'http://localhost:11434/v1/' }))
.toBe(ollamaRefreshGroupKey(v1));
});

it('returns null — never a shared bucket — for anything that is not an Ollama probe', () => {
// A null key means "refresh me individually". Treating it as a group would
// persist one vendor's catalog onto every other provider.
expect(ollamaRefreshGroupKey(null)).toBeNull();
expect(ollamaRefreshGroupKey({ id: 'anthropic', type: 'api', endpoint: 'https://api.anthropic.com' })).toBeNull();
expect(ollamaRefreshGroupKey({ id: 'cursor-cli', type: 'cli', command: 'cursor-agent' })).toBeNull();
expect(ollamaRefreshGroupKey({ id: 'codex-tui', type: 'tui', command: 'codex' })).toBeNull();
// An api provider with no endpoint at all has nothing to key on.
expect(ollamaRefreshGroupKey({ id: 'x', type: 'api' })).toBeNull();
});

it('never groups two API providers that would attach DIFFERENT keys to the same probe', () => {
// `_refreshAPIProviderModels` falls through from its `/api/tags`
// short-circuit to a generic `/models` fetch carrying `provider.apiKey`, so
// a keyed provider is ungroupable even on an Ollama-shaped endpoint.
const paid = { id: 'x', type: 'api', endpoint: 'https://api.example.com/v1', apiKey: 'k1' };
expect(ollamaRefreshGroupKey(paid)).toBeNull();
const keyed = { id: 'y', type: 'api', endpoint: 'http://localhost:11434/v1', apiKey: 'k2' };
const alsoKeyed = { id: 'z', type: 'api', endpoint: 'http://localhost:11434/v1', apiKey: 'k3' };
expect(ollamaRefreshGroupKey(keyed)).toBeNull();
expect(ollamaRefreshGroupKey(alsoKeyed)).toBeNull();
});
});

describe('resolveModelFetcher — the ordering the old chains encoded in prose', () => {
it('routes an Ollama-backed claude CLI to Ollama, not the static Anthropic list', () => {
// The one load-bearing row order: ollama first.
Expand Down
17 changes: 17 additions & 0 deletions server/lib/aiToolkit/internal/ollamaBacked.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,20 @@ export function isOllamaBackedProvider(provider) {
const base = String(provider?.envVars?.ANTHROPIC_BASE_URL || provider?.endpoint || '');
return /:11434\b/.test(base) || /ollama/i.test(base);
}

/**
* Normalize an Ollama base URL (strip trailing slash + an OpenAI-compat `/v1`)
* so two providers pointed at the same daemon through differently-spelled URLs
* resolve to the same string.
*
* Lives here beside {@link isOllamaBackedProvider} rather than in `providers.js`
* so `internal/modelFetchers.js` can build a refresh group key on it without
* importing back into `providers.js` and forming a module cycle. Deliberately
* NOT re-exported from `providers.js`: `ollamaRefreshGroupKey` is the contract
* hosts group on, and exporting the normalizer alongside it only invites a
* caller to re-derive the grouping rule and drift from the real dispatch.
*/
export function ollamaBaseFromProvider(provider) {
const base = String(provider?.envVars?.ANTHROPIC_BASE_URL || provider?.endpoint || 'http://localhost:11434');
return base.replace(/\/+$/, '').replace(/\/v1$/, '');
}
51 changes: 35 additions & 16 deletions server/lib/aiToolkit/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ import {
CURSOR_COMMAND,
parseCursorModelList,
} from './internal/cursor.js';
import { isOllamaBackedProvider } from './internal/ollamaBacked.js';
import { canRefreshModels, resolveModelFetcher } from './internal/modelFetchers.js';
import { isOllamaBackedProvider, ollamaBaseFromProvider } from './internal/ollamaBacked.js';
import { canRefreshModels, ollamaRefreshGroupKey, resolveModelFetcher } from './internal/modelFetchers.js';

// Re-exported (rather than defined here) so the model-fetcher table can key its
// ollama row on the same predicate without importing back into this module.
export { isOllamaBackedProvider };
// Groups providers that share one daemon + one probe shape, so a host fanning a
// refresh across them can fetch once instead of once per provider. The base-URL
// normalizer it keys on stays internal — the group key IS the contract, and an
// exported normalizer only invites callers to re-derive the grouping rule.
export { ollamaRefreshGroupKey };
// The pure capability predicate that both refresh arms below dispatch through —
// exported so the providers route can decorate its payload with it and the
// client stops re-deriving refreshability from command/name string sniffing.
Expand Down Expand Up @@ -125,12 +130,6 @@ const TOOL_USE_RE = new RegExp([
'deepseek-v3', 'deepseek-r1', 'deepseek-v4',
].join('|'), 'i');

/** Normalize an Ollama base URL (strip trailing slash + an OpenAI-compat `/v1`). */
function ollamaBaseFromProvider(provider) {
const base = String(provider?.envVars?.ANTHROPIC_BASE_URL || provider?.endpoint || 'http://localhost:11434');
return base.replace(/\/+$/, '').replace(/\/v1$/, '');
}

/**
* Whether an Ollama model supports tool use. Prefers the authoritative `tools`
* capability from /api/show (a non-empty capabilities array without `tools` is
Expand Down Expand Up @@ -725,7 +724,23 @@ export function createProviderService(config = {}) {
return { success: false, error: 'Unknown provider type' };
},

async refreshProviderModels(id) {
/**
* Probe a provider's model list WITHOUT persisting it — the compute half of
* {@link refreshProviderModels}.
*
* Split out so a host fanning a refresh across several providers backed by
* the SAME upstream (see {@link ollamaRefreshGroupKey}) can run the probe
* once and apply the answer to each of them via `updateProvider`, instead of
* re-issuing an identical `/api/tags` + per-model `/api/show` sweep per
* provider.
*
* Same contract as `refreshProviderModels` minus the write: `null` means
* ONLY "no such provider"; every other failure throws (with `.status`).
*
* @param {string} id
* @returns {Promise<string[]|null>}
*/
async fetchProviderModels(id) {
const data = await loadProviders();
const provider = data.providers[id];

Expand Down Expand Up @@ -799,14 +814,18 @@ export function createProviderService(config = {}) {
throw unsupported;
}

const updatedProvider = {
...data.providers[id],
models
};
return models;
},

data.providers[id] = updatedProvider;
await saveProviders(data);
return updatedProvider;
/**
* Probe AND persist a provider's model list. Thin composition of
* {@link fetchProviderModels} + `updateProvider` — keep it that way so the
* two halves can't drift.
*/
async refreshProviderModels(id) {
const models = await this.fetchProviderModels(id);
if (models === null) return null;
return this.updateProvider(id, { models });
},

async _refreshAPIProviderModels(provider) {
Expand Down
73 changes: 73 additions & 0 deletions server/lib/aiToolkit/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,79 @@ describe('Provider Service', () => {
expect(await providerService.refreshProviderModels('no-such-provider')).toBeNull();
});

describe('fetchProviderModels — the compute half, without the write', () => {
const ollamaCli = {
name: 'Claude Ollama (local model)',
type: 'cli',
command: 'claude',
ollamaBacked: true,
models: ['stale-model'],
envVars: { ANTHROPIC_BASE_URL: 'http://localhost:11434' },
};

it('returns the probed list and leaves the stored one untouched', async () => {
stubOllama(['qwen2.5:7b', 'gemma2:9b'], {
'qwen2.5:7b': ['completion', 'tools'],
'gemma2:9b': ['completion', 'vision'],
});
const p = await providerService.createProvider(ollamaCli);

expect(await providerService.fetchProviderModels(p.id)).toEqual(['qwen2.5:7b']);
// The whole point of the split: a caller can fan ONE probe out to
// several providers itself, so this call must not have persisted.
expect((await providerService.getProviderById(p.id)).models).toEqual(['stale-model']);
});

it('is what refreshProviderModels persists — the two halves cannot drift', async () => {
stubOllama(['qwen2.5:7b', 'gemma2:9b'], {
'qwen2.5:7b': ['completion', 'tools'],
'gemma2:9b': ['completion', 'vision'],
});
const p = await providerService.createProvider(ollamaCli);

const fetched = await providerService.fetchProviderModels(p.id);
const persisted = await providerService.refreshProviderModels(p.id);
expect(persisted.models).toEqual(fetched);
expect((await providerService.getProviderById(p.id)).models).toEqual(fetched);
});

it('applying a fetched list to a SIBLING provider matches refreshing it directly', async () => {
// The dedup path in localLlm.js: probe once through one member of a
// group, then `updateProvider(id, { models })` every member.
stubOllama(['qwen2.5:7b', 'gemma2:9b'], {
'qwen2.5:7b': ['completion', 'tools'],
'gemma2:9b': ['completion', 'vision'],
});
const lead = await providerService.createProvider({ ...ollamaCli, name: 'Lead Ollama' });
const sibling = await providerService.createProvider({ ...ollamaCli, name: 'Sibling Ollama', type: 'tui' });

const models = await providerService.fetchProviderModels(lead.id);
const applied = await providerService.updateProvider(sibling.id, { models });
const directly = await providerService.refreshProviderModels(sibling.id);

expect(applied.models).toEqual(directly.models);
// Every other field survives the apply — `updateProvider` spreads.
expect(applied.ollamaBacked).toBe(true);
expect(applied.id).toBe(sibling.id);
});

it('returns null only for a provider that does not exist', async () => {
expect(await providerService.fetchProviderModels('no-such-provider')).toBeNull();
});

it('throws (does not return null) when the probe fails', async () => {
const p = await providerService.createProvider(ollamaCli);
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503 })));
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const err = await providerService.fetchProviderModels(p.id).catch(e => e);
errSpy.mockRestore();

expect(err).toBeInstanceOf(Error);
expect(err.status).toBe(502);
expect((await providerService.getProviderById(p.id)).models).toEqual(['stale-model']);
});
});

// Pins the `provider.id === ANTIGRAVITY_TUI_ID` half of that arm's OR — the
// same gap that was open on CURSOR_TUI_ID. Every other antigravity-TUI test
// matches on the `agy` command, so deleting the id clause left the suite
Expand Down
Loading