diff --git a/CHANGELOG.md b/CHANGELOG.md index 93ca083..cf901e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-09-27 + +### Added + +- Live model discovery: the catalog is fetched from `GET {baseUrl}/models` + (60s in-memory TTL, only `muse-spark-*` admitted — the endpoint also + serves non-chat families such as `sam-*`), so newly published models + appear automatically. The static baseline remains as pre-credential + discovery and as fallback whenever the live fetch fails. + The subscription key is re-resolved on every discovery call, so no + gateway restart is needed after the first login. + ## [0.1.1] - 2026-09-27 ### Security diff --git a/README.md b/README.md index 47bdb78..8f7cd3e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ plugins can be installed side by side. > contributor variants as **selectable** models but never selects them > implicitly: the setup default is the standard `muse-spark-1.3`. +Model discovery is **live**: the catalog is fetched from +`GET {baseUrl}/models` (60s in-memory TTL, only `muse-spark-*` ids +admitted — other endpoint families such as `sam-*`, `muse-image-*` or +`muse-voice-*` are not chat models), so newly published models appear +automatically with no plugin update. The static table above is the +fallback used before login and whenever the live fetch fails. + ## Install From ClawHub: diff --git a/dist/index.js b/dist/index.js index e0a03be..ab79e8f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -15,12 +15,12 @@ * intentionally distinct from the official `meta` id so both can coexist. */ import { defineSingleProviderPluginEntry, } from "openclaw/plugin-sdk/provider-entry"; -import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js"; -import { readCacheSync } from "./src/auth.js"; +import { ENV_VAR } from "./src/config.js"; +import { getLiveModelIdsCached, resolveKey } from "./src/catalog.js"; import { museCodeBaselineModels } from "./src/baseline.models.js"; // Resolution order: explicit env wins, else the login cache. Silent miss. -if (!explicitToken()) { - const cached = readCacheSync(defaultCachePath()); +if (!process.env[ENV_VAR]?.trim()) { + const cached = resolveKey(); if (cached) process.env[ENV_VAR] = cached; } @@ -71,10 +71,9 @@ export function projectModel(row) { }; } /** - * Builds the provider config from the static Muse Spark baseline. - * Used for both live and static discovery (the family has no public - * unauthenticated catalog endpoint, so both surfaces share one builder — - * same approach as the official Meta provider). + * Builds a provider config from model rows (live ids or static baseline). + * Live rows carry ids only, so names default to the id and the context + * window to the family default; inference is unaffected. */ export function providerFromRows(rows) { const models = rows @@ -87,6 +86,15 @@ export function providerFromRows(rows) { }; } async function buildProvider() { + // Live first (TTL-cached, key re-resolved per call so no gateway restart + // is needed after the first login), static baseline as fallback. + const key = resolveKey(); + if (key) { + const live = await getLiveModelIdsCached(META_BASE_URL, key).catch(() => null); + if (live && live.length > 0) { + return providerFromRows(live.map((id) => ({ id }))); + } + } return providerFromRows(museCodeBaselineModels); } async function buildStaticProvider() { diff --git a/dist/src/catalog.js b/dist/src/catalog.js new file mode 100644 index 0000000..3369075 --- /dev/null +++ b/dist/src/catalog.js @@ -0,0 +1,80 @@ +// Live model catalog for the Meta Model API, with static fallback. +// +// buildProvider resolves the subscription key on every call (explicit env +// wins, otherwise a fresh cache read, so no gateway restart is needed after +// the first login), fetches GET {baseUrl}/models with a short TTL, filters +// non-chat families, and falls back to the static baseline on any failure: +// missing key, network error, or unexpected shape. +import { defaultCachePath, explicitToken } from "./config.js"; +import { readCacheSync } from "./auth.js"; +export const MODELS_PATH = "/models"; +export const LIVE_CATALOG_TTL_MS = 60_000; +export const FETCH_TIMEOUT_MS = 8_000; +// Allowlist: this provider serves the Muse Spark chat family. The live +// endpoint also returns other families (sam-*, muse-image-*, muse-voice-*) +// that are not usable as chat/completions models — a denylist would leak +// the next such family, so only muse-spark-* is admitted. Anything else +// still resolves through the dynamic resolver if requested explicitly. +const CHAT_FAMILY_PREFIX = "muse-spark-"; +let cache = null; +/** Test-only cache reset. */ +export function __clearLiveCatalogCache() { + cache = null; +} +/** Resolve the subscription key: explicit env wins, else a fresh cache read. */ +export function resolveKey() { + return explicitToken() || readCacheSync(defaultCachePath()); +} +function parseModelIds(body) { + if (typeof body !== "object" || body === null) + return null; + const data = body.data; + if (!Array.isArray(data)) + return null; + const ids = data + .filter((row) => { + return (typeof row === "object" && + row !== null && + typeof row.id === "string" && + (row.id.length > 0)); + }) + .map((row) => row.id) + .filter((id) => id.startsWith(CHAT_FAMILY_PREFIX)); + return ids.length > 0 ? ids : null; +} +/** + * Fetch the live chat-model id list. Returns null on any failure + * (network, auth, unexpected shape, empty list) — callers fall back + * to the static baseline. + */ +export async function fetchLiveModelIds(baseUrl, apiKey, timeoutMs = FETCH_TIMEOUT_MS) { + if (!apiKey) + return null; + try { + const res = await fetch(`${baseUrl}${MODELS_PATH}`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) + return null; + return parseModelIds((await res.json())); + } + catch { + return null; + } +} +/** TTL-cached live fetch. Returns null on miss/expiry+failure (use static). */ +export async function getLiveModelIdsCached(baseUrl, apiKey, ttlMs = LIVE_CATALOG_TTL_MS) { + if (cache && Date.now() < cache.expiresAt) + return cache.ids; + const ids = await fetchLiveModelIds(baseUrl, apiKey); + if (!ids) { + cache = null; + return null; + } + cache = { ids, expiresAt: Date.now() + Math.max(0, ttlMs) }; + return ids; +} diff --git a/index.ts b/index.ts index 1c6bc72..18302b3 100644 --- a/index.ts +++ b/index.ts @@ -23,13 +23,13 @@ import type { ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-types"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; -import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js"; -import { readCacheSync } from "./src/auth.js"; +import { ENV_VAR } from "./src/config.js"; +import { getLiveModelIdsCached, resolveKey } from "./src/catalog.js"; import { museCodeBaselineModels } from "./src/baseline.models.js"; // Resolution order: explicit env wins, else the login cache. Silent miss. -if (!explicitToken()) { - const cached = readCacheSync(defaultCachePath()); +if (!process.env[ENV_VAR]?.trim()) { + const cached = resolveKey(); if (cached) process.env[ENV_VAR] = cached; } @@ -96,10 +96,9 @@ export function projectModel(row: MuseCodeModelRow): ModelDefinitionConfig | nul } /** - * Builds the provider config from the static Muse Spark baseline. - * Used for both live and static discovery (the family has no public - * unauthenticated catalog endpoint, so both surfaces share one builder — - * same approach as the official Meta provider). + * Builds a provider config from model rows (live ids or static baseline). + * Live rows carry ids only, so names default to the id and the context + * window to the family default; inference is unaffected. */ export function providerFromRows(rows: MuseCodeModelRow[]): ModelProviderConfig { const models = rows @@ -114,6 +113,15 @@ export function providerFromRows(rows: MuseCodeModelRow[]): ModelProviderConfig } async function buildProvider(): Promise { + // Live first (TTL-cached, key re-resolved per call so no gateway restart + // is needed after the first login), static baseline as fallback. + const key = resolveKey(); + if (key) { + const live = await getLiveModelIdsCached(META_BASE_URL, key).catch(() => null); + if (live && live.length > 0) { + return providerFromRows(live.map((id) => ({ id }))); + } + } return providerFromRows(museCodeBaselineModels); } diff --git a/package-lock.json b/package-lock.json index 00331ab..4337767 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thestreamcode/openclaw-muse-code", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thestreamcode/openclaw-muse-code", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "devDependencies": { "openclaw": "2026.9.5", diff --git a/package.json b/package.json index adb0744..201fac6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thestreamcode/openclaw-muse-code", - "version": "0.1.1", + "version": "0.2.0", "description": "Muse Spark in OpenClaw billed to the Muse Code monthly subscription (Meta device login, no API key)", "type": "module", "license": "MIT", diff --git a/src/catalog.ts b/src/catalog.ts new file mode 100644 index 0000000..7b33332 --- /dev/null +++ b/src/catalog.ts @@ -0,0 +1,102 @@ +// Live model catalog for the Meta Model API, with static fallback. +// +// buildProvider resolves the subscription key on every call (explicit env +// wins, otherwise a fresh cache read, so no gateway restart is needed after +// the first login), fetches GET {baseUrl}/models with a short TTL, filters +// non-chat families, and falls back to the static baseline on any failure: +// missing key, network error, or unexpected shape. + +import { defaultCachePath, explicitToken } from "./config.js"; +import { readCacheSync } from "./auth.js"; + +export const MODELS_PATH = "/models"; +export const LIVE_CATALOG_TTL_MS = 60_000; +export const FETCH_TIMEOUT_MS = 8_000; + +// Allowlist: this provider serves the Muse Spark chat family. The live +// endpoint also returns other families (sam-*, muse-image-*, muse-voice-*) +// that are not usable as chat/completions models — a denylist would leak +// the next such family, so only muse-spark-* is admitted. Anything else +// still resolves through the dynamic resolver if requested explicitly. +const CHAT_FAMILY_PREFIX = "muse-spark-"; + +type ModelsResponse = { + data?: unknown; +}; + +type CachedEntry = { + ids: string[]; + expiresAt: number; +}; + +let cache: CachedEntry | null = null; + +/** Test-only cache reset. */ +export function __clearLiveCatalogCache(): void { + cache = null; +} + +/** Resolve the subscription key: explicit env wins, else a fresh cache read. */ +export function resolveKey(): string { + return explicitToken() || readCacheSync(defaultCachePath()); +} + +function parseModelIds(body: unknown): string[] | null { + if (typeof body !== "object" || body === null) return null; + const data = (body as ModelsResponse).data; + if (!Array.isArray(data)) return null; + const ids = data + .filter((row): row is { id: string } => { + return ( + typeof row === "object" && + row !== null && + typeof (row as { id?: unknown }).id === "string" && + ((row as { id: string }).id.length > 0) + ); + }) + .map((row) => row.id) + .filter((id) => id.startsWith(CHAT_FAMILY_PREFIX)); + return ids.length > 0 ? ids : null; +} + +/** + * Fetch the live chat-model id list. Returns null on any failure + * (network, auth, unexpected shape, empty list) — callers fall back + * to the static baseline. + */ +export async function fetchLiveModelIds( + baseUrl: string, + apiKey: string, + timeoutMs: number = FETCH_TIMEOUT_MS, +): Promise { + if (!apiKey) return null; + try { + const res = await fetch(`${baseUrl}${MODELS_PATH}`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) return null; + return parseModelIds((await res.json()) as unknown); + } catch { + return null; + } +} + +/** TTL-cached live fetch. Returns null on miss/expiry+failure (use static). */ +export async function getLiveModelIdsCached( + baseUrl: string, + apiKey: string, + ttlMs: number = LIVE_CATALOG_TTL_MS, +): Promise { + if (cache && Date.now() < cache.expiresAt) return cache.ids; + const ids = await fetchLiveModelIds(baseUrl, apiKey); + if (!ids) { + cache = null; + return null; + } + cache = { ids, expiresAt: Date.now() + Math.max(0, ttlMs) }; + return ids; +} diff --git a/test/catalog.test.ts b/test/catalog.test.ts new file mode 100644 index 0000000..c74895b --- /dev/null +++ b/test/catalog.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, afterEach, vi } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { + __clearLiveCatalogCache, + fetchLiveModelIds, + getLiveModelIdsCached, + resolveKey, +} from "../src/catalog.js"; +import { CREDENTIALS_ENV_VAR, ENV_VAR } from "../src/config.js"; + +type Handler = (url: string, options?: unknown) => { status: number; body: unknown }; + +function stubFetch(handler: Handler): { calls: () => number } { + let calls = 0; + globalThis.fetch = (async (input: unknown, init?: unknown) => { + calls += 1; + const { status, body } = handler(String(input), init); + return new Response(JSON.stringify(body), { status }); + }) as typeof fetch; + return { calls: () => calls }; +} + +const savedEnv = { ...process.env }; + +afterEach(() => { + vi.unstubAllGlobals(); + __clearLiveCatalogCache(); + delete process.env[ENV_VAR]; + delete process.env[CREDENTIALS_ENV_VAR]; + for (const [key, value] of Object.entries(savedEnv)) { + if (key === ENV_VAR || key === CREDENTIALS_ENV_VAR) process.env[key] = value as string; + } +}); + +const LIVE_OK = { + data: [ + { id: "muse-spark-1.3" }, + { id: "muse-spark-9.9" }, + { id: "sam-3.1" }, + { id: "muse-image-1.0" }, + { id: "muse-voice-2.0" }, + ], +}; + +describe("fetchLiveModelIds", () => { + it("admits only the muse-spark chat family", async () => { + stubFetch(() => ({ status: 200, body: LIVE_OK })); + expect(await fetchLiveModelIds("https://x.test/v1", "k")).toEqual([ + "muse-spark-1.3", + "muse-spark-9.9", + ]); + }); + + it("sends the bearer key to /models", async () => { + let seen: { url: string; auth: unknown } | null = null; + stubFetch((url, init) => { + const headers = (init as { headers?: Record })?.headers ?? {}; + seen = { url, auth: headers["Authorization"] }; + return { status: 200, body: { data: [{ id: "muse-spark-1.3" }] } }; + }); + await fetchLiveModelIds("https://x.test/v1", "LLM|k"); + expect(seen?.url).toBe("https://x.test/v1/models"); + expect(seen?.auth).toBe("Bearer LLM|k"); + }); + + it("returns null without a key", async () => { + const counter = stubFetch(() => ({ status: 200, body: LIVE_OK })); + expect(await fetchLiveModelIds("https://x.test/v1", "")).toBeNull(); + expect(counter.calls()).toBe(0); + }); + + it.each([ + ["http error", 401, { error: "nope" }], + ["non-object body", 200, []], + ["missing data array", 200, { data: "nope" }], + ["empty data", 200, { data: [] }], + ["no valid ids", 200, { data: [{ id: 42 }, {}] }], + ])("returns null on %s", async (_label, status, body) => { + stubFetch(() => ({ status: status as number, body })); + expect(await fetchLiveModelIds("https://x.test/v1", "k")).toBeNull(); + }); + + it("returns null on network failure", async () => { + globalThis.fetch = (async () => { + throw new Error("boom"); + }) as typeof fetch; + expect(await fetchLiveModelIds("https://x.test/v1", "k")).toBeNull(); + }); +}); + +describe("getLiveModelIdsCached", () => { + it("serves warm entries without refetching", async () => { + const counter = stubFetch(() => ({ status: 200, body: LIVE_OK })); + expect(await getLiveModelIdsCached("https://x.test/v1", "k", 60_000)).toHaveLength(2); + expect(await getLiveModelIdsCached("https://x.test/v1", "k", 60_000)).toHaveLength(2); + expect(counter.calls()).toBe(1); + }); + + it("refetches after expiry", async () => { + const counter = stubFetch(() => ({ status: 200, body: LIVE_OK })); + expect(await getLiveModelIdsCached("https://x.test/v1", "k", 0)).toHaveLength(2); + expect(await getLiveModelIdsCached("https://x.test/v1", "k", 60_000)).toHaveLength(2); + expect(counter.calls()).toBe(2); + }); + + it("clears stale entries on failure", async () => { + stubFetch(() => ({ status: 200, body: LIVE_OK })); + expect(await getLiveModelIdsCached("https://x.test/v1", "k", 60_000)).toHaveLength(2); + stubFetch(() => ({ status: 500, body: {} })); + __clearLiveCatalogCache(); + expect(await getLiveModelIdsCached("https://x.test/v1", "k", 60_000)).toBeNull(); + }); +}); + +describe("resolveKey", () => { + it("prefers explicit env over cache", () => { + process.env[ENV_VAR] = "pinned"; + expect(resolveKey()).toBe("pinned"); + }); + + it("falls back to the cache file", () => { + const dir = mkdtempSync(join(tmpdir(), "muse-catalog-")); + const path = join(dir, "creds.json"); + writeFileSync(path, JSON.stringify({ apiKey: "LLM|cache", accountId: "u" })); + process.env[CREDENTIALS_ENV_VAR] = path; + expect(resolveKey()).toBe("LLM|cache"); + }); + + it("resolves empty with no env and no cache", () => { + process.env[CREDENTIALS_ENV_VAR] = join(tmpdir(), "muse-catalog-absent.json"); + expect(resolveKey()).toBe(""); + }); +});