@mmmbuto/nexuscrew 0.9.74 → 0.9.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DRXfJssx.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-C_z6r985.css">
14
+ <script type="module" crossorigin src="/assets/index-BURtPwUi.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-B9GW2LHm.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.74"}
1
+ {"version":"0.9.76"}
@@ -41,8 +41,10 @@ const {
41
41
  CATALOG, CLIENT_LABELS, CLIENT_ORDER, engineClient, ordinaEngine, isStandardClient,
42
42
  credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames, profileFor,
43
43
  normalizeManagedSpec, codexSurfaceNames,
44
+ engineModelDiscoveryPlan, ollamaCloudCatalogModels, declaredModelsFor, ALIBABA_PI_MODELS,
44
45
  } = require('./managed.js');
45
46
  const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
47
+ const { createModelDiscovery, DISCOVERY_PROPOSAL_CAP } = require('./model-discovery.js');
46
48
  const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
47
49
  const { validEnvKey } = require('./env-key.js');
48
50
  const { isReservedLiveName } = require('../live-host/reserved.js');
@@ -963,7 +965,7 @@ async function createBuiltinFleet(cfg = {}) {
963
965
  // I campi non-identita' che `parseModel` ammette per una dichiarazione:
964
966
  // id ed engine SONO l'identita' (il chiaro per cui esiste edit-model e' non
965
967
  // doverla ritoccare), tutto il resto e' patchabile con la stessa validazione.
966
- const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'reasoning']);
968
+ const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning']);
967
969
 
968
970
  async function editModel(id, engine, patch) {
969
971
  if (readonly()) throw httpError(403, 'READONLY: edit-model bloccato');
@@ -982,12 +984,18 @@ async function createBuiltinFleet(cfg = {}) {
982
984
  }
983
985
  }
984
986
  for (const key of ['contextWindow', 'maxTokens']) {
985
- if (patch[key] !== undefined && (!Number.isSafeInteger(patch[key]) || patch[key] < 1)) {
986
- throw httpError(400, `${key} non valido: intero >= 1`);
987
+ // Stessa scala del parser: un valore che il dialogo accetta e il file
988
+ // rifiuta farebbe fallire l'intero salvataggio, non il solo campo.
989
+ if (patch[key] !== undefined && (!Number.isSafeInteger(patch[key]) || patch[key] < 1024 || patch[key] > 100000000)) {
990
+ throw httpError(400, `${key} non valido: intero fra 1024 e 100000000`);
987
991
  }
988
992
  }
989
- if (patch.reasoning !== undefined && typeof patch.reasoning !== 'boolean') {
990
- throw httpError(400, 'reasoning non valido: booleano');
993
+ // `tools` e `vision` sono booleani tipizzati come `reasoning`: `false` e'
994
+ // una risposta, quindi non si rifiuta e non si scambia per assenza.
995
+ for (const key of ['tools', 'vision', 'reasoning']) {
996
+ if (patch[key] !== undefined && typeof patch[key] !== 'boolean') {
997
+ throw httpError(400, `${key} non valido: booleano`);
998
+ }
991
999
  }
992
1000
  const defs = reloadDefs();
993
1001
  if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
@@ -1603,6 +1611,8 @@ async function createBuiltinFleet(cfg = {}) {
1603
1611
  // arriverebbe al client come comportamento inspiegabile.
1604
1612
  contextWindow: { type: 'integer', required: false, min: 1024, max: 100000000 },
1605
1613
  maxTokens: { type: 'integer', required: false, min: 1024, max: 100000000 },
1614
+ tools: { type: 'boolean', required: false },
1615
+ vision: { type: 'boolean', required: false },
1606
1616
  reasoning: { type: 'boolean', required: false },
1607
1617
  },
1608
1618
  cell: {
@@ -1648,7 +1658,7 @@ async function createBuiltinFleet(cfg = {}) {
1648
1658
  }
1649
1659
 
1650
1660
  function capabilities() {
1651
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test', 'edit-model'];
1661
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials', 'model-test', 'model-discovery', 'edit-model'];
1652
1662
  }
1653
1663
 
1654
1664
  // Un engine del documento con un `baseUrl` dichiarato a mano: indirizzo e
@@ -1714,6 +1724,200 @@ async function createBuiltinFleet(cfg = {}) {
1714
1724
  return { engine: profile.id, model: wanted, ...out };
1715
1725
  }
1716
1726
 
1727
+ // Model discovery for the engine editor: the «you can use these» list with
1728
+ // per-field provenance (user > api > public catalog > backup). The engine's
1729
+ // endpoint and credential are resolved on the SAME road the launcher and
1730
+ // testModel use, live in memory only, and never enter the response: the
1731
+ // return shape is curated field by field below and the discovery module's
1732
+ // errors are fixed strings by construction (no key, no URL, ever).
1733
+ const engineModelDiscovery = createModelDiscovery();
1734
+ function backupModelsFor(profile) {
1735
+ if (!profile) return [];
1736
+ // ollama-cloud carries real measured descriptors: context window and
1737
+ // capabilities from the wired catalog, not bare ids.
1738
+ if (profile.provider === 'ollama-cloud') {
1739
+ return ollamaCloudCatalogModels().map((m) => ({
1740
+ id: m.id,
1741
+ ...(Number.isFinite(m.contextWindow) ? { contextWindow: m.contextWindow } : {}),
1742
+ ...(Array.isArray(m.input) && m.input.includes('image') ? { vision: true } : {}),
1743
+ ...(m.reasoning === true ? { reasoning: true } : {}),
1744
+ }));
1745
+ }
1746
+ return (Array.isArray(profile.models) ? profile.models : [])
1747
+ .filter((id) => typeof id === 'string' && id).map((id) => ({ id }));
1748
+ }
1749
+ // Internal-catalog PROPOSALS for the missing-data dialog (lot 3b): what the
1750
+ // wired database knows about a model id, for fields the discovery layers
1751
+ // could NOT fill. These are PROPOSALS only — they never join the merge, so
1752
+ // precedence and discovery stay untouched; the dialog shows them pre-filled
1753
+ // and whatever the operator confirms becomes a user value in fleet.json.
1754
+ // Lookup mirrors the wired ollama rule: exact key first, then the base name
1755
+ // without the ':tag' suffix ('deepseek-v4-flash:0731' -> 'deepseek-v4-flash').
1756
+ function internalModelIndex(profile) {
1757
+ const index = new Map();
1758
+ const add = (id, values) => {
1759
+ if (!id || !values) return;
1760
+ const known = index.get(id) || {};
1761
+ for (const field of ['contextWindow', 'maxTokens']) {
1762
+ if (values[field] !== undefined) known[field] = values[field];
1763
+ }
1764
+ if (Object.keys(known).length) index.set(id, known);
1765
+ };
1766
+ if (profile?.provider === 'ollama-cloud') {
1767
+ for (const m of ollamaCloudCatalogModels()) add(m.id, { contextWindow: m.contextWindow });
1768
+ }
1769
+ if (profile?.provider === 'alibaba-token-plan') {
1770
+ // ALIBABA_PI_MODELS carries the full descriptors (context, output) of
1771
+ // the wired database; the profile's own `models` list is bare ids.
1772
+ for (const m of ALIBABA_PI_MODELS) add(m.id, { contextWindow: m.contextWindow, maxTokens: m.maxTokens });
1773
+ }
1774
+ return index;
1775
+ }
1776
+ function internalValueFor(index, id) {
1777
+ if (!index.size || typeof id !== 'string') return null;
1778
+ if (index.has(id)) return index.get(id);
1779
+ const base = id.replace(/:[^:/]+$/, '');
1780
+ return base !== id && index.has(base) ? index.get(base) : null;
1781
+ }
1782
+ // Curated authentication diagnosis of the LISTING attempt. The route sees
1783
+ // only this block: the credential state, the env NAME (already public in
1784
+ // the editor), whether an attempt happened, whether it carried the
1785
+ // credential, and a fixed cause. Never the key value, its provenance, the
1786
+ // endpoint, or any transport exception text. Config (state) and transport
1787
+ // (attempt) stay separate: a present key is not a sent key, and a cached
1788
+ // answer is neither. A fresh cache makes no claim at all about the key
1789
+ // (attempted=false, no cause), and a SUCCESS never claims the endpoint does
1790
+ // not need a key — it only says the listing came back without one.
1791
+ function modelListingAuthDiagnosis(plan, result) {
1792
+ const credentialState = plan && ['none', 'configured', 'missing', 'unavailable'].includes(plan.credentialState)
1793
+ ? plan.credentialState : 'none';
1794
+ const envKey = typeof plan?.envKey === 'string' && plan.envKey ? plan.envKey : '';
1795
+ const attempt = result && result.attempt && typeof result.attempt === 'object' ? result.attempt : null;
1796
+ const attempted = !!attempt;
1797
+ const credentialSent = !!(attempt && attempt.credentialSent === true);
1798
+ let cause = '';
1799
+ if (attempt) {
1800
+ if (result.status === 'api') {
1801
+ // Success: the only outcome worth a line is the anonymous one, stated
1802
+ // as a fact about THIS listing, never about the endpoint in general.
1803
+ if (!credentialSent) cause = 'anonymous-success';
1804
+ } else if (attempt.category === 'network' || attempt.category === 'timeout') {
1805
+ cause = 'endpoint-unreachable';
1806
+ } else if (attempt.category === 'invalid-response') {
1807
+ cause = 'invalid-response';
1808
+ } else if (attempt.category === 'http') {
1809
+ const status = Number.isSafeInteger(attempt.status) ? attempt.status : 0;
1810
+ if (status === 403) cause = 'access-denied'; // with OR without the key: permissions or policy
1811
+ else if (status === 401) {
1812
+ if (credentialSent) cause = 'key-rejected';
1813
+ else if (credentialState === 'unavailable') cause = 'credential-unavailable';
1814
+ else cause = 'missing-key';
1815
+ } else cause = 'provider-error';
1816
+ }
1817
+ }
1818
+ return {
1819
+ credentialState,
1820
+ ...(envKey ? { envKey } : {}),
1821
+ attempted,
1822
+ credentialSent,
1823
+ ...(cause ? { cause } : {}),
1824
+ };
1825
+ }
1826
+ async function discoverEngineModels(engineId, opts = {}) {
1827
+ const id = String(engineId || '');
1828
+ if (!id || id.length > 64 || !/^[A-Za-z0-9._-]+$/.test(id)) {
1829
+ const e = new Error('engine non valido'); e.status = 400; throw e;
1830
+ }
1831
+ const defs = reloadDefs();
1832
+ const declared = findEngine(defs, id);
1833
+ let profile = null;
1834
+ let spec = null;
1835
+ if (declared && declared.managed) {
1836
+ spec = normalizeManagedSpec(declared.managed, { extraModels: extraModelsFrom(defs), engineId: declared.id });
1837
+ profile = spec ? profileFor(spec.client, spec.provider, spec.credentialProfile || '') : null;
1838
+ }
1839
+ if (!profile) {
1840
+ // Not declared (or not resolvable): the catalog profile alone still
1841
+ // knows client, provider, endpoint and credential key.
1842
+ profile = CATALOG.find((p) => p.id === id) || null;
1843
+ spec = profile ? { client: profile.client, provider: profile.provider, ...(profile.credentialProfile ? { credentialProfile: profile.credentialProfile } : {}) } : null;
1844
+ }
1845
+ if (!profile) { const e = new Error(`engine sconosciuto: ${id}`); e.status = 404; throw e; }
1846
+ const plan = engineModelDiscoveryPlan(profile, spec, cfg, home, id);
1847
+ if (!plan) {
1848
+ // No HTTP listing to interrogate (login/native flows, local providers
1849
+ // without a base URL): an honest empty answer, never an error, so the
1850
+ // editor stays usable. The UI hides the section on `supported: false`.
1851
+ return { engine: id, supported: false, source: 'unsupported', models: [] };
1852
+ }
1853
+ // User layer: models declared in fleet.json for this engine (or its
1854
+ // profile) — the values the operator chose, always on top and never
1855
+ // rewritten by discovery.
1856
+ const extraModels = extraModelsFrom(defs);
1857
+ const userModels = [];
1858
+ const seenUser = new Set();
1859
+ for (const key of (id !== profile.id ? [id, profile.id] : [id])) {
1860
+ for (const m of declaredModelsFor(extraModels, key)) {
1861
+ if (seenUser.has(m.id)) continue;
1862
+ seenUser.add(m.id);
1863
+ userModels.push({
1864
+ id: m.id,
1865
+ ...(Number.isFinite(m.contextWindow) ? { contextWindow: m.contextWindow } : {}),
1866
+ ...(Number.isFinite(m.maxTokens) ? { maxTokens: m.maxTokens } : {}),
1867
+ // definitions.js parses the three flags as typed booleans: `false`
1868
+ // is an explicit operator answer, not an absence. Keep it exactly
1869
+ // like that, or a provider's `true` would override the user layer
1870
+ // here (the merge itself already handles booleans correctly).
1871
+ ...(typeof m.tools === 'boolean' ? { tools: m.tools } : {}),
1872
+ ...(typeof m.vision === 'boolean' ? { vision: m.vision } : {}),
1873
+ ...(typeof m.reasoning === 'boolean' ? { reasoning: m.reasoning } : {}),
1874
+ });
1875
+ }
1876
+ }
1877
+ const backupModels = backupModelsFor(profile);
1878
+ const wanted = [...new Set([...userModels, ...backupModels].map((m) => m.id))].slice(0, 32);
1879
+ const result = await engineModelDiscovery.discoverEngine({
1880
+ ...plan,
1881
+ home,
1882
+ userModels, backupModels,
1883
+ ...(wanted.length ? { detailIds: wanted } : {}),
1884
+ publicCatalog: { provider: profile.provider, baseUrl: plan.baseUrl, ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) },
1885
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
1886
+ ...(opts.refresh === true ? { noCache: true } : {}),
1887
+ });
1888
+ // Curated response: `plan` (baseUrl, apiKey) stays out; apiModels is
1889
+ // already merged (with provenance) into `models`.
1890
+ // `backupValues` rides along only for fields NO layer could fill: the
1891
+ // internal catalog's proposal for the missing-data dialog. A field any
1892
+ // layer answered carries no proposal — the dialog asks for what nobody
1893
+ // knows, and the operator's confirmation is what turns a proposal into a
1894
+ // user value.
1895
+ const internalIndex = internalModelIndex(profile);
1896
+ const models = result.models.map((m) => {
1897
+ const backupValues = {};
1898
+ for (const field of ['contextWindow', 'maxTokens']) {
1899
+ if (m[field] === undefined) {
1900
+ const known = internalValueFor(internalIndex, m.id);
1901
+ if (known && known[field] !== undefined) backupValues[field] = known[field];
1902
+ }
1903
+ }
1904
+ return Object.keys(backupValues).length ? { ...m, backupValues } : m;
1905
+ });
1906
+ return {
1907
+ engine: id,
1908
+ supported: true,
1909
+ source: result.source,
1910
+ status: result.status,
1911
+ stale: result.stale === true,
1912
+ error: result.error || '',
1913
+ auth: modelListingAuthDiagnosis(plan, result),
1914
+ ...(result.fetchedAt ? { fetchedAt: result.fetchedAt } : {}),
1915
+ ...(result.publicCatalog ? { publicCatalog: result.publicCatalog } : {}),
1916
+ models,
1917
+ cap: DISCOVERY_PROPOSAL_CAP,
1918
+ };
1919
+ }
1920
+
1717
1921
  async function close() { await launchBroker.close(); if (leaseManager) leaseManager.close(); }
1718
1922
 
1719
1923
  return {
@@ -1723,7 +1927,7 @@ async function createBuiltinFleet(cfg = {}) {
1723
1927
  defineEngine, editEngine, removeEngine,
1724
1928
  defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
1725
1929
  schema, definitions, capabilities,
1726
- credentialStatus, setLocalCredential, removeLocalCredential, testModel,
1930
+ credentialStatus, setLocalCredential, removeLocalCredential, testModel, discoverEngineModels,
1727
1931
  defineModel, editModel, removeModel, close,
1728
1932
  // Fetta 2b: il lease Live esposto alle route /api/lease, che il bridge
1729
1933
  // MCP delle celle consuma per register/refresh/recovery. Assente se il
@@ -196,7 +196,7 @@ const MODEL_ID_MAX = 128;
196
196
 
197
197
  function parseModel(m) {
198
198
  if (!m || typeof m !== 'object' || Array.isArray(m)) return null;
199
- const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'reasoning']);
199
+ const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning']);
200
200
  if (Object.keys(m).some((k) => !allowed.has(k))) return null;
201
201
  const id = typeof m.id === 'string' ? m.id.trim() : '';
202
202
  // Stessa forma accettata dal gate dei modelli: nessun byte di controllo, un
@@ -221,6 +221,15 @@ function parseModel(m) {
221
221
  if (typeof m.reasoning !== 'boolean') return null;
222
222
  out.reasoning = m.reasoning;
223
223
  }
224
+ // `tools` e `vision`: stessi booleani tipizzati di `reasoning`. `false` e'
225
+ // una RISPOSTA dell'operatore, non un'assenza: si conserva tale, cosi' la
226
+ // scoperta (user > api > public) non lascia salire il `true` di un catalogo
227
+ // sopra la parola esplicita di chi dichiara.
228
+ for (const key of ['tools', 'vision']) {
229
+ if (m[key] === undefined) continue;
230
+ if (typeof m[key] !== 'boolean') return null;
231
+ out[key] = m[key];
232
+ }
224
233
  return out;
225
234
  }
226
235
 
@@ -10,6 +10,7 @@ const { execFile } = require('node:child_process');
10
10
  const { ENV_KEY_RE } = require('./env-key.js');
11
11
  const { termuxRuntimePaths } = require('../runtime/env.js');
12
12
  const { readCredentialStore, safePrivateDir } = require('./credentials.js');
13
+ const { createModelDiscovery } = require('./model-discovery.js');
13
14
  const { argomentiHookCodex, gateVersione, gatePiattaforma, EVENTI_CODEX } = require('./codex-hooks.js');
14
15
 
15
16
  const OLLAMA_CLOUD_MODELS = Object.freeze([
@@ -1295,6 +1296,70 @@ function applyStoreNeutralization(env, spec, profile) {
1295
1296
  }
1296
1297
 
1297
1298
  let ollamaCache = { at: 0, models: [] };
1299
+ // Shared model-discovery instance: per-engine HTTP collectors + disk cache.
1300
+ // The engine's key is resolved HERE (credentialSources/credential, the same
1301
+ // path the launcher uses) and handed to the module in memory only.
1302
+ const sharedModelDiscovery = createModelDiscovery();
1303
+ // Map a managed profile (+ normalized spec) to its model-discovery plan:
1304
+ // collector kind, endpoint base URL and the engine's own credential. This is
1305
+ // the per-engine wiring point for the discovery lots: same endpoint and same
1306
+ // key the launcher would use, never a parallel configuration. Returns null
1307
+ // when the profile has no HTTP listing to interrogate (login/native/local
1308
+ // runtimes without an HTTP endpoint, or a custom base URL that is not set).
1309
+ // `engineId` is the DECLARED engine id and becomes the cache key: two custom
1310
+ // engines of the same client resolve to one shared profile, and keying their
1311
+ // model cache by profile id would hand the second engine the first one's
1312
+ // listing without ever interrogating its endpoint. Without a declared id
1313
+ // (catalog engines) the profile id IS the engine id.
1314
+ function engineModelDiscoveryPlan(profile, spec, cfg, home, engineId) {
1315
+ if (!profile) return null;
1316
+ const specBase = spec && typeof spec.baseUrl === 'string' && /^https?:\/\//i.test(spec.baseUrl) ? spec.baseUrl : '';
1317
+ const profileBase = typeof profile.endpoint === 'string' && /^https?:\/\//i.test(profile.endpoint) ? profile.endpoint : '';
1318
+ let collector = null;
1319
+ let baseUrl = '';
1320
+ if (profile.provider === 'ollama' || profile.provider === 'ollama-cloud') {
1321
+ collector = 'ollama';
1322
+ baseUrl = specBase || profileBase || 'http://127.0.0.1:11434';
1323
+ } else if (profile.provider === 'lmstudio') {
1324
+ collector = 'lmstudio';
1325
+ baseUrl = specBase || 'http://127.0.0.1:1234';
1326
+ } else if (profile.protocol === 'anthropic_messages') {
1327
+ collector = 'anthropic';
1328
+ baseUrl = specBase || profileBase;
1329
+ } else if (profile.protocol === 'openai_responses' || profile.protocol === 'openai_completions') {
1330
+ collector = 'openai-compat';
1331
+ baseUrl = specBase || profileBase;
1332
+ } else {
1333
+ return null;
1334
+ }
1335
+ if (!baseUrl) return null;
1336
+ const plan = { engine: engineId || profile.id, collector, baseUrl };
1337
+ // The credential STATE travels with the plan, from the same resolution the
1338
+ // launcher uses (the caller never re-reads the credential sources):
1339
+ // none / configured / missing / unavailable, plus the env NAME when one
1340
+ // exists and the editor already shows it. A file that exists but cannot be
1341
+ // read is "unavailable", not "missing" — the same distinction describeManaged
1342
+ // makes for launching. The value, its provenance paths/hashes and any
1343
+ // exception text stay here: the plan hands the key to the collector in
1344
+ // memory and nothing else leaves.
1345
+ try {
1346
+ const cred = credential(profile, spec || {}, cfg, home, { blocked: [] });
1347
+ plan.credentialState = !cred ? 'unavailable'
1348
+ : cred.value ? 'configured'
1349
+ : cred.source === 'unreadable' ? 'unavailable'
1350
+ : cred.source === 'missing' ? 'missing'
1351
+ : 'none';
1352
+ if (cred && cred.value) plan.apiKey = cred.value;
1353
+ const envKey = cred && typeof cred.envKey === 'string' ? cred.envKey : '';
1354
+ // 'login'/'none' resolve to a pseudo key name: never invent one for them.
1355
+ if (plan.credentialState !== 'none' && ENV_KEY_RE.test(envKey || '')) plan.envKey = envKey;
1356
+ } catch (_) {
1357
+ // Resolution itself failed: the key is not known to be missing — it is
1358
+ // unavailable, and an anonymous rejection will say exactly that.
1359
+ plan.credentialState = 'unavailable';
1360
+ }
1361
+ return plan;
1362
+ }
1298
1363
  async function discoverOllamaModels(opts = {}) {
1299
1364
  const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
1300
1365
  if (!opts.noCache && ollamaCache.models.length && now - ollamaCache.at < ttl) return [...ollamaCache.models];
@@ -1307,13 +1372,22 @@ async function discoverOllamaModels(opts = {}) {
1307
1372
  || sources.local.OLLAMA_API_KEY || sources.shell.OLLAMA_API_KEY || sources.keys.OLLAMA_API_KEY
1308
1373
  || sources.legacy.OLLAMA_API_KEY;
1309
1374
  if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
1310
- const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
1311
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
1312
- const body = await response.json(); const available = new Set();
1313
- for (const item of Array.isArray(body.models) ? body.models : []) {
1314
- const name = typeof item?.name === 'string' ? item.name : '';
1315
- if (/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) && name.length <= 128) available.add(name);
1316
- }
1375
+ // Generalized on the model-discovery module (ollama collector): the HTTP
1376
+ // call, the sanitized errors and the last-good-on-disk cache live there.
1377
+ // The EXTERNAL behavior stays: the wired list filtered by what the API
1378
+ // declares live, and the full wired list when nothing is known.
1379
+ const layer = await sharedModelDiscovery.fetchEngineModels({
1380
+ engine: 'ollama-cloud', collector: 'ollama', baseUrl: 'https://ollama.com',
1381
+ apiKey, home, fetchImpl, timeoutMs: 2500, ttlMs: ttl, ...(opts.noCache ? { noCache: true } : {}),
1382
+ });
1383
+ // IL CONTRATTO ESTERNO NON CAMBIA (mandato L1: generalizzare «senza
1384
+ // cambiarne il comportamento esterno»). Un valore buono di ieri servito al
1385
+ // posto dell'API non e' una risposta di oggi: vale come "non si sa niente",
1386
+ // e allora si torna alla shortlist cablata intera. Solo `api` (o un valore
1387
+ // fresco, non stantio) dice cosa e' vivo adesso.
1388
+ const conosciuto = layer.status === 'api' || (layer.status === 'cache' && layer.stale === false);
1389
+ if (!conosciuto) throw new Error(layer.error || 'ollama-cloud discovery unavailable');
1390
+ const available = new Set(layer.apiModels.map((m) => m.id));
1317
1391
  const models = OLLAMA_CLOUD_MODELS.filter((name) => available.has(name));
1318
1392
  if (!models.length) throw new Error('no cloud models');
1319
1393
  ollamaCache = { at: now, models }; return [...models];
@@ -3091,7 +3165,7 @@ module.exports = {
3091
3165
  OPENCODE_GO_MESSAGES_MODELS, OPENCODE_GO_RESPONSES_MODELS, OPENCODE_GO_CHAT_MODELS, OPENCODE_GO_LIMITS,
3092
3166
  OPENCODE_GO_ANTHROPIC_ROOT, OPENCODE_GO_API_BASE,
3093
3167
  CLIENT_LABELS, CLIENT_ORDER, engineClient, ordinaEngine, isStandardClient, normalizeManagedSpec, profileFor,
3094
- defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
3168
+ defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, defaultGrokEngine, defaultVlEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, engineModelDiscoveryPlan, resolveManagedEngine, needsExplicitNode,
3095
3169
  discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension, customCatalogFor,
3096
3170
  codexSurfaceNames, writeCellCodexProfile,
3097
3171
  writeVlCellPrompt, vlSystemAppendGate,