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
6 changes: 3 additions & 3 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": "clawbox-setup",
"version": "3.0.3",
"version": "3.0.4",
"private": true,
"description": "ClawBox setup wizard and dashboard",
"scripts": {
Expand Down
19 changes: 16 additions & 3 deletions scripts/gateway-pre-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -331,9 +331,22 @@ uses_codex = (
print("1" if uses_codex else "0")
PY
)"
if [ "$NEEDS_CODEX_PLUGIN" = "1" ] && [ ! -f "$CODEX_PLUGIN_DIR/package.json" ]; then
echo " Installing @openclaw/codex runtime plugin (codex model selected)…"
"$OPENCLAW_BIN" plugins install codex >/dev/null 2>&1 \
# Also check the nested peer-dep symlink. `openclaw plugins install
# codex` writes `<codex>/node_modules/openclaw -> <global openclaw>`
# alongside the package.json; if that symlink is missing or dangling
# (partial install, openclaw upgrade that cleared the nested
# node_modules, manual cleanup) the codex plugin loads but its
# top-level imports fail at runtime with:
# Error: Cannot find package 'openclaw' imported from
# .../@openclaw/codex/dist/shared-client-…js
# Checking only the package.json misses that broken state. `-e`
# follows symlinks, so it catches both "missing" and "dangling".
# `--force` on install rebuilds the symlink without reinstalling
# unnecessary content when the package directory is already there.
CODEX_PEER_DEP="$CODEX_PLUGIN_DIR/node_modules/openclaw/package.json"
if [ "$NEEDS_CODEX_PLUGIN" = "1" ] && { [ ! -f "$CODEX_PLUGIN_DIR/package.json" ] || [ ! -e "$CODEX_PEER_DEP" ]; }; then
echo " Installing/repairing @openclaw/codex runtime plugin (codex model selected)…"
"$OPENCLAW_BIN" plugins install codex --force >/dev/null 2>&1 \
|| echo " WARN: openclaw plugins install codex failed; Codex chats will fail until resolved"
fi

Expand Down
76 changes: 70 additions & 6 deletions src/app/setup-api/ai-models/catalog/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { promises as fsp } from "fs";
import path from "path";
import { findOpenclawBin } from "@/lib/openclaw-config";
import { DATA_DIR } from "@/lib/config-store";
import { CATALOG_PROVIDERS, isCatalogProvider } from "@/lib/provider-models";
import { CATALOG_PROVIDERS, isCatalogProvider, PROVIDER_CATALOGS } from "@/lib/provider-models";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -148,6 +148,21 @@ interface OpenRouterListResponse {
}>;
}

// Deprecated model ids we filter out of the catalog regardless of
// whether the upstream tagged them as such. Anthropic's
// `openclaw models list --provider anthropic` returns
// `claude-sonnet-4-20250514` without a `deprecated` tag on
// Claude.ai OAuth scopes, but Anthropic's own docs
// (https://platform.claude.com/docs/en/about-claude/models) list
// it (and the matching opus-4 snapshot) as retiring 2026-06-15.
// Surfacing them in the picker just sets users up to pick a model
// that will stop working. Add new ids here when Anthropic ships
// the next deprecation notice.
const DEPRECATED_MODEL_IDS: ReadonlySet<string> = new Set([
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
]);

// Per-provider allowlist regex. When set, only model ids matching the
// pattern survive the catalog filter. Used to curate noisy upstream
// catalogs down to a useful set without the picker exploding to 40+
Expand Down Expand Up @@ -199,6 +214,7 @@ function transformOpenclawEntries(
: entry.key;
if (!id) continue;
if (entry.tags?.includes("deprecated")) continue;
if (DEPRECATED_MODEL_IDS.has(id)) continue;
if (allowed && !allowed.test(id)) continue;
out.push({
id,
Expand Down Expand Up @@ -240,15 +256,60 @@ const ALLOW_CUSTOM_BY_PROVIDER: Record<string, boolean> = {
clawai: false,
};

// Context-window lookup for static-catalog entries we know about but the
// live upstream enumeration didn't return. Values from each provider's
// official model docs. Used only as a fallback for `augmentWithStaticCatalog`;
// when the live catalog includes the same id its real contextWindow wins.
const STATIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
// Anthropic — https://platform.claude.com/docs/en/about-claude/models
"claude-opus-4-7": 1_000_000,
"claude-opus-4-6": 1_000_000,
"claude-sonnet-4-6": 1_000_000,
"claude-sonnet-4-5": 200_000,
"claude-opus-4-5": 200_000,
"claude-haiku-4-5": 200_000,
};

// Merge the curated static list from PROVIDER_CATALOGS into the live
// upstream models. Live entries take precedence (their contextWindow /
// input / label reflect what the gateway actually negotiated). Static
// entries with ids the live list doesn't include get appended — this
// covers the case where a provider's OAuth scope returns a single
// deprecated model (Anthropic Claude.ai consumer OAuth is the live
// example: 2025-11 docs list claude-opus-4-7 / claude-sonnet-4-6 /
// claude-haiku-4-5 as current, but `openclaw models list --provider
// anthropic` on a Claude.ai-OAuth device only returns the deprecated
// claude-sonnet-4-20250514). The picker would otherwise force the
// user to type custom model ids by hand.
function augmentWithStaticCatalog(provider: string, live: CatalogModel[]): CatalogModel[] {
if (!isCatalogProvider(provider)) return live;
const staticEntry = PROVIDER_CATALOGS[provider];
if (!staticEntry) return live;
const liveIds = new Set(live.map((m) => m.id));
const augmented: CatalogModel[] = [...live];
for (const sm of staticEntry.models) {
if (liveIds.has(sm.id)) continue;
augmented.push({
id: sm.id,
label: sm.label,
contextWindow: STATIC_MODEL_CONTEXT_WINDOWS[sm.id] ?? 200_000,
hint: sm.hint,
});
}
augmented.sort(compareCatalogModels);
return augmented;
}

function buildPayload(provider: string, models: CatalogModel[]): CatalogResponse {
const merged = augmentWithStaticCatalog(provider, models);
const fallbackDefault = DEFAULT_MODEL_BY_PROVIDER[provider];
const defaultModelId = models.find((m) => m.id === fallbackDefault)?.id
?? models[0]?.id
const defaultModelId = merged.find((m) => m.id === fallbackDefault)?.id
?? merged[0]?.id
?? fallbackDefault
?? "";
return {
provider,
models,
models: merged,
defaultModelId,
allowCustom: ALLOW_CUSTOM_BY_PROVIDER[provider] ?? true,
fetchedAt: Date.now(),
Expand Down Expand Up @@ -330,8 +391,11 @@ async function fetchOpenRouterCatalog(): Promise<CatalogModel[]> {
// Refresh the catalog for `provider` in the background. Returns
// immediately; the actual openclaw spawn / openrouter fetch runs out
// of band. Single-flight via `refreshing` so concurrent requests
// collapse to one fork.
function refreshInBackground(provider: string): void {
// collapse to one fork. Exported so configure/route.ts can trigger a
// refresh right after the user adds an API key — otherwise the
// catalog stays on the pre-auth snapshot from boot warmup until the
// next service restart.
export function refreshInBackground(provider: string): void {
if (refreshing.has(provider)) return;
refreshing.add(provider);

Expand Down
22 changes: 21 additions & 1 deletion src/app/setup-api/ai-models/configure/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
type ClawboxAiTier,
} from "@/lib/clawbox-ai-models";
import { OPENROUTER_CURATED_MODELS, OPENROUTER_DEFAULT_MODEL_ID } from "@/lib/openrouter-models";
import { isValidModelId } from "@/lib/provider-models";
import { isValidModelId, isCatalogProvider } from "@/lib/provider-models";
import { refreshInBackground as refreshCatalogInBackground } from "@/app/setup-api/ai-models/catalog/route";

const OPENCLAW_BIN = findOpenclawBin();
const OPENCLAW_HOME_DIR =
Expand Down Expand Up @@ -839,6 +840,25 @@ export async function POST(request: Request) {
await setProviderPlugins(primaryProvider);
}

// 8c. Kick off a catalog refresh for the just-configured provider so
// the picker shows the full live model list instead of whatever
// the boot-time warmup found before the user added their API key.
// Without this, a device that adds Anthropic / OpenAI / etc. credentials
// after first boot stays stuck on the pre-auth snapshot — which is
// often a single fallback entry or empty — until the next service
// restart. The refresh runs out-of-band; we don't await it. Single-
// flight guarded inside refreshInBackground, so concurrent configure
// calls collapse to one openclaw fork.
//
// `ocProvider` is the openclaw-side provider id (e.g. "anthropic",
// "openai", "openai-codex", "google", "deepseek"). The catalog uses
// "clawai" for ClawBox AI rather than "deepseek", so map that case.
// Skip providers that aren't part of the catalog (local-only, llamacpp).
const catalogProvider = ocProvider === "deepseek" ? "clawai" : ocProvider;
if (isCatalogProvider(catalogProvider)) {
refreshCatalogInBackground(catalogProvider);
}

// 9. Restart OpenClaw gateway so it picks up the new auth profile and model
try {
await restartGateway();
Expand Down
57 changes: 42 additions & 15 deletions src/app/setup-api/ai-models/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ const PORTAL_FETCH_TIMEOUT_MS = 4_000;
// reset / multi-account dev churn — but a long-running process would
// otherwise leak entries forever.
const PORTAL_TIER_CACHE_MAX_ENTRIES = 64;
// Short negative-cache window for tokens whose last portal lookup
// resolved to `unreachable` (4xx auth failure, 5xx, or network
// error). With useClawboxLogin polling every 30s, this caps the
// per-device portal load during a sustained auth-failure or
// outage at ~1 request per 30s (down from 1-per-poll). Smaller
// than PORTAL_TIER_CACHE_TTL_MS because the positive cache is
// safe to hold longer; an unreachable verdict needs to clear
// quickly enough that recovery (token re-pair, portal recovers)
// shows up on the next poll, not minutes later.
const PORTAL_UNREACHABLE_TTL_MS = 30_000;

interface DeviceInfoResponse {
tier?: string;
Expand All @@ -57,6 +67,10 @@ interface PortalCacheEntry {
}

const portalTierCache = new Map<string, PortalCacheEntry>();
// token → epoch-ms timestamp when its unreachable verdict expires.
// Separate from portalTierCache because the value is "we tried and
// it failed, don't try again yet" rather than "the answer is null".
const portalUnreachableCache = new Map<string, number>();
const inFlightPortalLookups = new Map<string, Promise<PortalLookup>>();

/**
Expand Down Expand Up @@ -111,11 +125,15 @@ function mapPortalTier(body: DeviceInfoResponse): ClawboxAiTier | null {
*
* Cache semantics:
* - 200 OK: parsed tier is cached for `PORTAL_TIER_CACHE_TTL_MS`.
* - 401 / 403: a definitive "no entitlement" verdict is also cached
* so we don't re-hammer the portal for invalid tokens.
* - 5xx / network error: cache untouched; caller falls back to the
* locally-stored picker selection so the badge doesn't flicker
* during transient portal outages.
* - Non-200 / network error: token is marked unreachable for
* `PORTAL_UNREACHABLE_TTL_MS` so we don't hit the portal every
* 30 s status poll during a sustained auth failure or outage.
* A successful 200 clears the unreachable mark so recovery is
* responsive.
*
* 401/403 are deliberately treated the same as 5xx/network errors
* (unreachable) rather than as a definitive "Free" verdict — see
* the non-200 branch in the body for the rationale.
*
* @param token The bearer token to look up.
* @returns Either a definitive `{ source: "portal", tier }` answer or
Expand All @@ -126,10 +144,19 @@ async function fetchPortalTier(token: string): Promise<PortalLookup> {
const cached = portalTierCache.get(token);
if (cached && cached.expiresAt > now) return { source: "portal", tier: cached.tier };

const unreachableUntil = portalUnreachableCache.get(token);
if (unreachableUntil !== undefined && unreachableUntil > now) {
return { source: "unreachable" };
}

const existing = inFlightPortalLookups.get(token);
if (existing) return existing;

const promise = (async (): Promise<PortalLookup> => {
const markUnreachable = (): PortalLookup => {
portalUnreachableCache.set(token, now + PORTAL_UNREACHABLE_TTL_MS);
return { source: "unreachable" };
};
try {
const res = await fetch(PORTAL_DEVICE_INFO_URL, {
headers: { Authorization: `Bearer ${token}` },
Expand All @@ -139,19 +166,18 @@ async function fetchPortalTier(token: string): Promise<PortalLookup> {
const body = await res.json() as DeviceInfoResponse;
const tier = mapPortalTier(body);
rememberTier(token, tier, now);
portalUnreachableCache.delete(token);
return { source: "portal", tier };
}
// 401/403 are definitive — the portal *did* answer, the token just
// doesn't entitle anything. Cache so we don't re-hammer for the
// TTL. 5xx and network errors leave the cache untouched and let
// callers fall back to the locally-stored picker selection.
if (res.status === 401 || res.status === 403) {
rememberTier(token, null, now);
return { source: "portal", tier: null };
}
return { source: "unreachable" };
// 401/403 is ambiguous: it can mean genuinely Free OR token
// revoked / migrated / corrupted on a still-paid account. We
// can't tell from the response alone, and treating it as
// "Free" silently downgrades paid users with broken auth (and
// fires the downgrade-celebration popup). Mark unreachable
// instead so callers preserve localTier.
return markUnreachable();
} catch {
return { source: "unreachable" };
return markUnreachable();
}
})();

Expand All @@ -169,6 +195,7 @@ async function fetchPortalTier(token: string): Promise<PortalLookup> {
* a clean module-state. Not for production use.
*/
export function _resetPortalTierCache() {
portalUnreachableCache.clear();
portalTierCache.clear();
inFlightPortalLookups.clear();
}
Expand Down
Loading
Loading