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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 16 additions & 8 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down
80 changes: 80 additions & 0 deletions dist/src/catalog.js
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 16 additions & 8 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand All @@ -114,6 +113,15 @@ export function providerFromRows(rows: MuseCodeModelRow[]): ModelProviderConfig
}

async function buildProvider(): Promise<ModelProviderConfig> {
// 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);
}

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
102 changes: 102 additions & 0 deletions src/catalog.ts
Original file line number Diff line number Diff line change
@@ -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<string[] | null> {
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<string[] | null> {
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;
}
Loading
Loading