From 34ad057ec4333a7eaf9d59a93b5c6e1a3d172cc8 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Fri, 18 Sep 2026 01:06:06 +0200 Subject: [PATCH] Discover served model IDs with provenance and explicit stale clearing --- packages/user-intent-kit/README.md | 27 ++++++++ .../user-intent-kit/src/adapters/desktop.js | 7 +- .../user-intent-kit/src/model-discovery.js | 69 +++++++++++++++++++ .../test/model-discovery.test.js | 50 ++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 packages/user-intent-kit/src/model-discovery.js create mode 100644 packages/user-intent-kit/test/model-discovery.test.js diff --git a/packages/user-intent-kit/README.md b/packages/user-intent-kit/README.md index d1ef142..4c837fc 100644 --- a/packages/user-intent-kit/README.md +++ b/packages/user-intent-kit/README.md @@ -365,3 +365,30 @@ a 50% reduction for that daemon, excluding startup/status changes and retries. These are schedule-derived counts, not measured Vercel billing or fleet totals. The test suite separately verifies 720 unchanged-agent writes over a simulated day. Standalone vitals scripts and secondary agents still contribute additional traffic. + +### Model identity discovery + +The device publisher checks local servers once per minute (cached and single +flight), piggybacking the result on its existing device PATCH. Defaults: +LM Studio at `127.0.0.1:1234/api/v1/models` and OpenAI-compatible serving +endpoints at ports 8080, 8000 and 8888 (`/v1/models`). LM Studio uses only loaded +LLM instances; downloaded models and embeddings are excluded. Generic OpenAI +IDs are labelled `advertised`, not proof of loaded weights or active generation. + +Optional configuration: + +- `INTENT_MODEL_SERVER_URL`: explicit server origin, replacing default probes. +- `INTENT_MODEL_SERVER_KIND`: `openai` (default) or `lmstudio`. +- `INTENT_MODEL_SERVER_KEY_FILE`: server-specific bearer token file, used only + for the explicit server. The intent API key is never reused. Redirects are + refused. HTTP is allowed only on loopback; remote endpoints require HTTPS. +- `INTENT_DEVICE_MODEL`: manual fallback when discovery is unavailable, visibly + suffixed `(manual)`. Successful empty results do not fall back to this label. + +Payloads include `model`, `models`, `model_status`, `model_source` and +`model_checked_at`. Unknown/unauthorized results explicitly send `model: null` +when there is no manual label, clearing any previous model through PATCH. +A refused/unreachable server means unknown, not verified absence. With automatic +multi-port discovery, absence cannot be verified while any endpoint is unknown. +Neither endpoint discovery nor these read-only probes load a model. +LM Studio schema: https://lmstudio.ai/docs/developer/rest/list . diff --git a/packages/user-intent-kit/src/adapters/desktop.js b/packages/user-intent-kit/src/adapters/desktop.js index 8f5efd2..968ad39 100644 --- a/packages/user-intent-kit/src/adapters/desktop.js +++ b/packages/user-intent-kit/src/adapters/desktop.js @@ -7,6 +7,7 @@ import { platform } from 'node:os'; const IDLE_AFTER_SEC = 300; import { collectHostTelemetry } from '../host-telemetry.js'; import { StatePublisher } from '../state-publisher.js'; +import { createModelDiscovery } from '../model-discovery.js'; /** * Desktop Adapter - detects active window and context on macOS. @@ -22,14 +23,16 @@ export class DesktopAdapter { #kind; #pollIntervalMs; #publisher; + #discoverModels; /** * @param {import('../client.js').IntentClient} client * @param {object} [opts] * @param {number} [opts.pollIntervalMs=30000] - How often to publish state */ - constructor(client, { pollIntervalMs = 30000, machine, kind } = {}) { + constructor(client, { pollIntervalMs = 30000, machine, kind, discoverModels = createModelDiscovery() } = {}) { this.#client = client; + this.#discoverModels = discoverModels; this.#machine = machine ?? client?.deviceId ?? undefined; this.#kind = kind; if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) throw new Error('Invalid pollIntervalMs'); @@ -46,7 +49,7 @@ export class DesktopAdapter { */ async publishState() { const state = this.#detectState(); - await this.#publisher.publish({ ...state, ttl_sec: 90 }); + await this.#publisher.publish({ ...state, ...await this.#discoverModels(), ttl_sec: 90 }); } /** diff --git a/packages/user-intent-kit/src/model-discovery.js b/packages/user-intent-kit/src/model-discovery.js new file mode 100644 index 0000000..b1f22a1 --- /dev/null +++ b/packages/user-intent-kit/src/model-discovery.js @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0 +import { readFile } from 'node:fs/promises'; + +/** Local, read-only model discovery. Never uses the intent API credential. */ +export function createModelDiscovery({ env = process.env, fetchImpl = fetch, + readKey = path => readFile(path, 'utf8'), now = () => Date.now(), cacheMs = 60000 } = {}) { + let cached, last = -Infinity, pending; + const explicit = env.INTENT_MODEL_SERVER_URL; + const kind = env.INTENT_MODEL_SERVER_KIND || 'openai'; + const endpoints = explicit ? [{ url: explicit, kind }] : [ + { url: 'http://127.0.0.1:1234', kind: 'lmstudio' }, + ...[8080, 8000, 8888].map(port => ({ url: `http://127.0.0.1:${port}`, kind: 'openai' })), + ]; + async function probe(endpoint) { + try { + const url = new URL(endpoint.url); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if (url.username || url.password || url.search || url.hash || + !(url.protocol === 'https:' || (url.protocol === 'http:' && loopback))) { + return { status: 'invalid_config' }; + } + if (!['openai', 'lmstudio'].includes(endpoint.kind)) return { status: 'invalid_config' }; + const headers = {}; + if (explicit && env.INTENT_MODEL_SERVER_KEY_FILE) { + headers.Authorization = `Bearer ${(await readKey(env.INTENT_MODEL_SERVER_KEY_FILE)).trim()}`; + } + const suffix = endpoint.kind === 'lmstudio' ? '/api/v1/models' : '/v1/models'; + const response = await fetchImpl(url.href.replace(/\/$/, '') + suffix, { + headers, redirect: 'error', signal: AbortSignal.timeout(1500), + }); + if (!response.ok) return { status: response.status === 401 || response.status === 403 ? 'unauthorized' : 'unknown' }; + const data = await response.json(); + const rows = endpoint.kind === 'lmstudio' ? data.models : data.data; + if (!Array.isArray(rows)) return { status: 'unknown' }; + // LM Studio's OpenAI list includes downloaded/JIT models: use loaded instances. + if (endpoint.kind === 'lmstudio' && rows.some(m => !m || !Array.isArray(m.loaded_instances))) return { status: 'unknown' }; + const ids = endpoint.kind === 'lmstudio' + ? rows.filter(m => m.type === 'llm').flatMap(m => m.loaded_instances.map(i => i.id)) + : rows.map(m => m?.id); + if (ids.some(id => typeof id !== 'string' || !id.trim())) return { status: 'unknown' }; + return { status: ids.length ? (endpoint.kind === 'lmstudio' ? 'loaded' : 'advertised') : 'none', + ids, source: endpoint.kind }; + } catch { return { status: 'unknown' }; } + } + async function discover() { + const results = await Promise.all(endpoints.map(probe)); + const good = results.filter(r => r.ids?.length); + const models = [...new Set(good.flatMap(r => r.ids))].sort(); + const allKnown = results.every(r => Array.isArray(r.ids)); + let status = models.length ? (good.every(r => r.status === 'loaded') ? 'loaded' : 'advertised') + : allKnown ? 'none' : results.some(r => r.status === 'unauthorized') ? 'unauthorized' : 'unknown'; + let source = good.length ? [...new Set(good.map(r => r.source))].join(',') : null; + let model = models.length ? models.join(', ') : null; + if (!model && status !== 'none' && env.INTENT_DEVICE_MODEL?.trim()) { + model = `${env.INTENT_DEVICE_MODEL.trim()} (manual)`; + source = 'manual'; + // Keep the failure cause separately; never turn a configured label into live proof. + status = `manual:${status}`; + } + return { model, models, model_status: status, model_source: source, + model_checked_at: new Date(now()).toISOString() }; + } + return async () => { + if (cached && now() >= last && now() - last < cacheMs) return structuredClone(cached); + if (!pending) pending = discover().then(value => { cached = value; last = now(); return value; }) + .finally(() => { pending = undefined; }); + return structuredClone(await pending); + }; +} diff --git a/packages/user-intent-kit/test/model-discovery.test.js b/packages/user-intent-kit/test/model-discovery.test.js new file mode 100644 index 0000000..7677c50 --- /dev/null +++ b/packages/user-intent-kit/test/model-discovery.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createModelDiscovery } from '../src/model-discovery.js'; +const env = { INTENT_MODEL_SERVER_URL: 'http://127.0.0.1:8888' }; +const ok = data => ({ ok: true, json: async () => data }); +test('OpenAI compatible server advertises all IDs without claiming loaded state', async () => { + const probe = createModelDiscovery({ env, fetchImpl: async () => ok({ data: [{ id: 'glm' }, { id: 'qwen' }] }) }); + const r = await probe(); assert.equal(r.model, 'glm, qwen'); assert.equal(r.model_status, 'advertised'); +}); +test('LM Studio only lists loaded LLM instances, not downloaded models/embeddings', async () => { + const probe = createModelDiscovery({ env: { ...env, INTENT_MODEL_SERVER_KIND: 'lmstudio' }, + fetchImpl: async url => { assert.ok(url.endsWith('/api/v1/models')); return ok({ models: [ + { type: 'llm', loaded_instances: [{ id: 'loaded' }] }, + { type: 'llm', loaded_instances: [] }, { type: 'embedding', loaded_instances: [{ id: 'embed' }] }, + ] }); } }); + assert.equal((await probe()).model, 'loaded'); +}); +test('authorized empty list clears previous model and does not revive manual label', async () => { + const p = createModelDiscovery({ env: { ...env, INTENT_DEVICE_MODEL: 'old' }, fetchImpl: async () => ok({ data: [] }) }); + const r = await p(); assert.equal(r.model, null); assert.equal(r.model_status, 'none'); +}); +test('auth failure clears prior live model; manual fallback is explicitly labelled', async () => { + let now = 0, calls = 0; + const p = createModelDiscovery({ env, now: () => now, fetchImpl: async () => ++calls === 1 + ? ok({ data: [{ id: 'live' }] }) : { ok: false, status: 401 } }); + assert.equal((await p()).model, 'live'); now = 61000; + const r = await p(); assert.equal(r.model, null); assert.equal(r.model_status, 'unauthorized'); + const manual = createModelDiscovery({ env: { ...env, INTENT_DEVICE_MODEL: 'typed' }, fetchImpl: async () => { throw Error('offline'); } }); + assert.equal((await manual()).model, 'typed (manual)'); +}); +test('server key is bound to explicit endpoint, redirects disabled, never uses intent key', async () => { + let requests = 0; + const p = createModelDiscovery({ env: { ...env, INTENT_API_KEY: 'never-send', INTENT_MODEL_SERVER_KEY_FILE: '/server-key' }, + readKey: async path => { assert.equal(path, '/server-key'); return 'server-only'; }, + fetchImpl: async (url, opts) => { requests++; assert.equal(opts.headers.Authorization, 'Bearer server-only'); + assert.equal(opts.redirect, 'error'); return ok({ data: [] }); } }); + await Promise.all([p(), p()]); await p(); assert.equal(requests, 1); + const auto = createModelDiscovery({ env: { INTENT_API_KEY: 'never-send', INTENT_MODEL_SERVER_KEY_FILE: '/server-key' }, + readKey: async () => { throw Error('must not read'); }, fetchImpl: async (_, opts) => { + assert.deepEqual(opts.headers, {}); throw Error('offline'); } }); + await auto(); +}); +test('unsafe URLs and malformed payloads fail unknown, not no-model', async () => { + for (const url of ['http://remote.example', 'https://user:pass@example.com', 'file:///tmp/x']) { + const p = createModelDiscovery({ env: { INTENT_MODEL_SERVER_URL: url }, fetchImpl: async () => { assert.fail('unsafe fetch'); } }); + assert.equal((await p()).model, null); + } + const p = createModelDiscovery({ env, fetchImpl: async () => ok({ data: [{}] }) }); + assert.equal((await p()).model_status, 'unknown'); +});