Skip to content

Commit ba5082c

Browse files
feat(catalog): live model discovery with static fallback (v0.2.0) (#3)
buildProvider fetches GET /models (60s TTL, muse-spark-* allowlist), re-resolving the key per call so no gateway restart is needed after login. Static baseline stays for pre-credential discovery and fallback. 16 catalog tests (shape/filter/TTL/fallback/key resolution). Verified live against api.meta.ai (8 models, sam-* correctly excluded).
1 parent 15decbc commit ba5082c

9 files changed

Lines changed: 371 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.0] - 2026-09-27
11+
12+
### Added
13+
14+
- Live model discovery: the catalog is fetched from `GET {baseUrl}/models`
15+
(60s in-memory TTL, only `muse-spark-*` admitted — the endpoint also
16+
serves non-chat families such as `sam-*`), so newly published models
17+
appear automatically. The static baseline remains as pre-credential
18+
discovery and as fallback whenever the live fetch fails.
19+
The subscription key is re-resolved on every discovery call, so no
20+
gateway restart is needed after the first login.
21+
1022
## [0.1.1] - 2026-09-27
1123

1224
### Security

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ plugins can be installed side by side.
3333
> contributor variants as **selectable** models but never selects them
3434
> implicitly: the setup default is the standard `muse-spark-1.3`.
3535
36+
Model discovery is **live**: the catalog is fetched from
37+
`GET {baseUrl}/models` (60s in-memory TTL, only `muse-spark-*` ids
38+
admitted — other endpoint families such as `sam-*`, `muse-image-*` or
39+
`muse-voice-*` are not chat models), so newly published models appear
40+
automatically with no plugin update. The static table above is the
41+
fallback used before login and whenever the live fetch fails.
42+
3643
## Install
3744

3845
From ClawHub:

‎dist/index.js‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
* intentionally distinct from the official `meta` id so both can coexist.
1616
*/
1717
import { defineSingleProviderPluginEntry, } from "openclaw/plugin-sdk/provider-entry";
18-
import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js";
19-
import { readCacheSync } from "./src/auth.js";
18+
import { ENV_VAR } from "./src/config.js";
19+
import { getLiveModelIdsCached, resolveKey } from "./src/catalog.js";
2020
import { museCodeBaselineModels } from "./src/baseline.models.js";
2121
// Resolution order: explicit env wins, else the login cache. Silent miss.
22-
if (!explicitToken()) {
23-
const cached = readCacheSync(defaultCachePath());
22+
if (!process.env[ENV_VAR]?.trim()) {
23+
const cached = resolveKey();
2424
if (cached)
2525
process.env[ENV_VAR] = cached;
2626
}
@@ -71,10 +71,9 @@ export function projectModel(row) {
7171
};
7272
}
7373
/**
74-
* Builds the provider config from the static Muse Spark baseline.
75-
* Used for both live and static discovery (the family has no public
76-
* unauthenticated catalog endpoint, so both surfaces share one builder —
77-
* same approach as the official Meta provider).
74+
* Builds a provider config from model rows (live ids or static baseline).
75+
* Live rows carry ids only, so names default to the id and the context
76+
* window to the family default; inference is unaffected.
7877
*/
7978
export function providerFromRows(rows) {
8079
const models = rows
@@ -87,6 +86,15 @@ export function providerFromRows(rows) {
8786
};
8887
}
8988
async function buildProvider() {
89+
// Live first (TTL-cached, key re-resolved per call so no gateway restart
90+
// is needed after the first login), static baseline as fallback.
91+
const key = resolveKey();
92+
if (key) {
93+
const live = await getLiveModelIdsCached(META_BASE_URL, key).catch(() => null);
94+
if (live && live.length > 0) {
95+
return providerFromRows(live.map((id) => ({ id })));
96+
}
97+
}
9098
return providerFromRows(museCodeBaselineModels);
9199
}
92100
async function buildStaticProvider() {

‎dist/src/catalog.js‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Live model catalog for the Meta Model API, with static fallback.
2+
//
3+
// buildProvider resolves the subscription key on every call (explicit env
4+
// wins, otherwise a fresh cache read, so no gateway restart is needed after
5+
// the first login), fetches GET {baseUrl}/models with a short TTL, filters
6+
// non-chat families, and falls back to the static baseline on any failure:
7+
// missing key, network error, or unexpected shape.
8+
import { defaultCachePath, explicitToken } from "./config.js";
9+
import { readCacheSync } from "./auth.js";
10+
export const MODELS_PATH = "/models";
11+
export const LIVE_CATALOG_TTL_MS = 60_000;
12+
export const FETCH_TIMEOUT_MS = 8_000;
13+
// Allowlist: this provider serves the Muse Spark chat family. The live
14+
// endpoint also returns other families (sam-*, muse-image-*, muse-voice-*)
15+
// that are not usable as chat/completions models — a denylist would leak
16+
// the next such family, so only muse-spark-* is admitted. Anything else
17+
// still resolves through the dynamic resolver if requested explicitly.
18+
const CHAT_FAMILY_PREFIX = "muse-spark-";
19+
let cache = null;
20+
/** Test-only cache reset. */
21+
export function __clearLiveCatalogCache() {
22+
cache = null;
23+
}
24+
/** Resolve the subscription key: explicit env wins, else a fresh cache read. */
25+
export function resolveKey() {
26+
return explicitToken() || readCacheSync(defaultCachePath());
27+
}
28+
function parseModelIds(body) {
29+
if (typeof body !== "object" || body === null)
30+
return null;
31+
const data = body.data;
32+
if (!Array.isArray(data))
33+
return null;
34+
const ids = data
35+
.filter((row) => {
36+
return (typeof row === "object" &&
37+
row !== null &&
38+
typeof row.id === "string" &&
39+
(row.id.length > 0));
40+
})
41+
.map((row) => row.id)
42+
.filter((id) => id.startsWith(CHAT_FAMILY_PREFIX));
43+
return ids.length > 0 ? ids : null;
44+
}
45+
/**
46+
* Fetch the live chat-model id list. Returns null on any failure
47+
* (network, auth, unexpected shape, empty list) — callers fall back
48+
* to the static baseline.
49+
*/
50+
export async function fetchLiveModelIds(baseUrl, apiKey, timeoutMs = FETCH_TIMEOUT_MS) {
51+
if (!apiKey)
52+
return null;
53+
try {
54+
const res = await fetch(`${baseUrl}${MODELS_PATH}`, {
55+
headers: {
56+
Accept: "application/json",
57+
Authorization: `Bearer ${apiKey}`,
58+
},
59+
signal: AbortSignal.timeout(timeoutMs),
60+
});
61+
if (!res.ok)
62+
return null;
63+
return parseModelIds((await res.json()));
64+
}
65+
catch {
66+
return null;
67+
}
68+
}
69+
/** TTL-cached live fetch. Returns null on miss/expiry+failure (use static). */
70+
export async function getLiveModelIdsCached(baseUrl, apiKey, ttlMs = LIVE_CATALOG_TTL_MS) {
71+
if (cache && Date.now() < cache.expiresAt)
72+
return cache.ids;
73+
const ids = await fetchLiveModelIds(baseUrl, apiKey);
74+
if (!ids) {
75+
cache = null;
76+
return null;
77+
}
78+
cache = { ids, expiresAt: Date.now() + Math.max(0, ttlMs) };
79+
return ids;
80+
}

‎index.ts‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@ import type {
2323
ModelProviderConfig,
2424
} from "openclaw/plugin-sdk/provider-model-types";
2525
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
26-
import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js";
27-
import { readCacheSync } from "./src/auth.js";
26+
import { ENV_VAR } from "./src/config.js";
27+
import { getLiveModelIdsCached, resolveKey } from "./src/catalog.js";
2828
import { museCodeBaselineModels } from "./src/baseline.models.js";
2929

3030
// Resolution order: explicit env wins, else the login cache. Silent miss.
31-
if (!explicitToken()) {
32-
const cached = readCacheSync(defaultCachePath());
31+
if (!process.env[ENV_VAR]?.trim()) {
32+
const cached = resolveKey();
3333
if (cached) process.env[ENV_VAR] = cached;
3434
}
3535

@@ -96,10 +96,9 @@ export function projectModel(row: MuseCodeModelRow): ModelDefinitionConfig | nul
9696
}
9797

9898
/**
99-
* Builds the provider config from the static Muse Spark baseline.
100-
* Used for both live and static discovery (the family has no public
101-
* unauthenticated catalog endpoint, so both surfaces share one builder —
102-
* same approach as the official Meta provider).
99+
* Builds a provider config from model rows (live ids or static baseline).
100+
* Live rows carry ids only, so names default to the id and the context
101+
* window to the family default; inference is unaffected.
103102
*/
104103
export function providerFromRows(rows: MuseCodeModelRow[]): ModelProviderConfig {
105104
const models = rows
@@ -114,6 +113,15 @@ export function providerFromRows(rows: MuseCodeModelRow[]): ModelProviderConfig
114113
}
115114

116115
async function buildProvider(): Promise<ModelProviderConfig> {
116+
// Live first (TTL-cached, key re-resolved per call so no gateway restart
117+
// is needed after the first login), static baseline as fallback.
118+
const key = resolveKey();
119+
if (key) {
120+
const live = await getLiveModelIdsCached(META_BASE_URL, key).catch(() => null);
121+
if (live && live.length > 0) {
122+
return providerFromRows(live.map((id) => ({ id })));
123+
}
124+
}
117125
return providerFromRows(museCodeBaselineModels);
118126
}
119127

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@thestreamcode/openclaw-muse-code",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"description": "Muse Spark in OpenClaw billed to the Muse Code monthly subscription (Meta device login, no API key)",
55
"type": "module",
66
"license": "MIT",

‎src/catalog.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Live model catalog for the Meta Model API, with static fallback.
2+
//
3+
// buildProvider resolves the subscription key on every call (explicit env
4+
// wins, otherwise a fresh cache read, so no gateway restart is needed after
5+
// the first login), fetches GET {baseUrl}/models with a short TTL, filters
6+
// non-chat families, and falls back to the static baseline on any failure:
7+
// missing key, network error, or unexpected shape.
8+
9+
import { defaultCachePath, explicitToken } from "./config.js";
10+
import { readCacheSync } from "./auth.js";
11+
12+
export const MODELS_PATH = "/models";
13+
export const LIVE_CATALOG_TTL_MS = 60_000;
14+
export const FETCH_TIMEOUT_MS = 8_000;
15+
16+
// Allowlist: this provider serves the Muse Spark chat family. The live
17+
// endpoint also returns other families (sam-*, muse-image-*, muse-voice-*)
18+
// that are not usable as chat/completions models — a denylist would leak
19+
// the next such family, so only muse-spark-* is admitted. Anything else
20+
// still resolves through the dynamic resolver if requested explicitly.
21+
const CHAT_FAMILY_PREFIX = "muse-spark-";
22+
23+
type ModelsResponse = {
24+
data?: unknown;
25+
};
26+
27+
type CachedEntry = {
28+
ids: string[];
29+
expiresAt: number;
30+
};
31+
32+
let cache: CachedEntry | null = null;
33+
34+
/** Test-only cache reset. */
35+
export function __clearLiveCatalogCache(): void {
36+
cache = null;
37+
}
38+
39+
/** Resolve the subscription key: explicit env wins, else a fresh cache read. */
40+
export function resolveKey(): string {
41+
return explicitToken() || readCacheSync(defaultCachePath());
42+
}
43+
44+
function parseModelIds(body: unknown): string[] | null {
45+
if (typeof body !== "object" || body === null) return null;
46+
const data = (body as ModelsResponse).data;
47+
if (!Array.isArray(data)) return null;
48+
const ids = data
49+
.filter((row): row is { id: string } => {
50+
return (
51+
typeof row === "object" &&
52+
row !== null &&
53+
typeof (row as { id?: unknown }).id === "string" &&
54+
((row as { id: string }).id.length > 0)
55+
);
56+
})
57+
.map((row) => row.id)
58+
.filter((id) => id.startsWith(CHAT_FAMILY_PREFIX));
59+
return ids.length > 0 ? ids : null;
60+
}
61+
62+
/**
63+
* Fetch the live chat-model id list. Returns null on any failure
64+
* (network, auth, unexpected shape, empty list) — callers fall back
65+
* to the static baseline.
66+
*/
67+
export async function fetchLiveModelIds(
68+
baseUrl: string,
69+
apiKey: string,
70+
timeoutMs: number = FETCH_TIMEOUT_MS,
71+
): Promise<string[] | null> {
72+
if (!apiKey) return null;
73+
try {
74+
const res = await fetch(`${baseUrl}${MODELS_PATH}`, {
75+
headers: {
76+
Accept: "application/json",
77+
Authorization: `Bearer ${apiKey}`,
78+
},
79+
signal: AbortSignal.timeout(timeoutMs),
80+
});
81+
if (!res.ok) return null;
82+
return parseModelIds((await res.json()) as unknown);
83+
} catch {
84+
return null;
85+
}
86+
}
87+
88+
/** TTL-cached live fetch. Returns null on miss/expiry+failure (use static). */
89+
export async function getLiveModelIdsCached(
90+
baseUrl: string,
91+
apiKey: string,
92+
ttlMs: number = LIVE_CATALOG_TTL_MS,
93+
): Promise<string[] | null> {
94+
if (cache && Date.now() < cache.expiresAt) return cache.ids;
95+
const ids = await fetchLiveModelIds(baseUrl, apiKey);
96+
if (!ids) {
97+
cache = null;
98+
return null;
99+
}
100+
cache = { ids, expiresAt: Date.now() + Math.max(0, ttlMs) };
101+
return ids;
102+
}

0 commit comments

Comments
 (0)