Skip to content
Merged
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
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
146 changes: 146 additions & 0 deletions src/app/setup-api/vnc/clipboard/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
export const dynamic = "force-dynamic";

import { NextRequest, NextResponse } from "next/server";
import { spawn } from "child_process";
import { promises as fs } from "fs";
import os from "os";
import path from "path";

// Bridges the host browser's clipboard with the guest X CLIPBOARD via
// `xclip`. Avoids the RFB ClientCutText Latin-1 limitation that mangles
// Cyrillic / CJK / emoji on the basic noVNC paste path, and works over
// plain HTTP (no `navigator.clipboard.readText()` permission required —
// the textarea in the paste modal captures the text natively).
const MAX_CLIPBOARD_BYTES = 1_048_576;
const XCLIP_TIMEOUT_MS = 5_000;

async function getVncDisplay(): Promise<string> {
if (process.env.CLAWBOX_VNC_DISPLAY) return process.env.CLAWBOX_VNC_DISPLAY;
try {
const envFile = path.join(os.homedir(), ".cache", "clawbox", "vnc-display.env");
const raw = await fs.readFile(envFile, "utf8");
const match = raw.match(/CLAWBOX_VNC_DISPLAY=(:\d+)/);
if (match) return match[1];
} catch {
// No marker yet — fall through to the default x11vnc display.
}
return ":99";
}

interface XclipResult {
stdout: string;
stderr: string;
code: number;
}

function runXclip(args: string[], display: string, input?: string): Promise<XclipResult> {
return new Promise((resolve, reject) => {
const isWrite = input !== undefined;
// For writes, xclip forks a daemon that keeps owning the X selection
// until another client claims it — if Node inherits its stdout/stderr,
// the `close` event never fires (the daemon holds those pipes open).
// Detach those streams so we observe only the parent's exit.
const proc = spawn("xclip", args, {
env: { ...process.env, DISPLAY: display },
stdio: isWrite ? ["pipe", "ignore", "ignore"] : ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const settle = (result: XclipResult) => {
if (settled) return;
settled = true;
resolve(result);
};
const timer = setTimeout(() => {
if (settled) return;
proc.kill("SIGTERM");
settle({ stdout, stderr: `${stderr}\n[xclip] timed out`, code: -1 });
}, XCLIP_TIMEOUT_MS);
if (proc.stdout) {
proc.stdout.on("data", (d: Buffer) => { stdout += d.toString("utf8"); });
}
if (proc.stderr) {
proc.stderr.on("data", (d: Buffer) => { stderr += d.toString("utf8"); });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
proc.on("error", (err) => {
clearTimeout(timer);
if (settled) return;
settled = true;
reject(err);
});
proc.on("close", (code) => {
clearTimeout(timer);
settle({ stdout, stderr, code: code ?? -1 });
});
if (isWrite && proc.stdin) {
proc.stdin.end(input, "utf8");
}
});
}

export async function GET() {
try {
const display = await getVncDisplay();
const { stdout, code, stderr } = await runXclip(
["-selection", "clipboard", "-out"],
display,
);
if (code !== 0) {
// xclip returns non-zero when the selection is empty; treat that as
// an empty string rather than an error so the UI can render "(empty)".
const isEmpty = stderr.includes("There is no owner");
if (isEmpty) {
return NextResponse.json({ text: "" }, { headers: { "Cache-Control": "no-store" } });
}
return NextResponse.json(
{ error: `xclip exit ${code}: ${stderr.slice(0, 200)}` },
{ status: 500, headers: { "Cache-Control": "no-store" } },
);
}
return NextResponse.json({ text: stdout }, { headers: { "Cache-Control": "no-store" } });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "xclip read failed" },
{ status: 500 },
);
}
}

export async function POST(request: NextRequest) {
let parsed: unknown;
try {
parsed = await request.json();
} catch {
return NextResponse.json({ error: "request body must be JSON" }, { status: 400 });
}
const body = (parsed ?? {}) as { text?: unknown };
const text = typeof body.text === "string" ? body.text : "";
if (text.length === 0) {
return NextResponse.json({ error: "text required" }, { status: 400 });
}
if (Buffer.byteLength(text, "utf8") > MAX_CLIPBOARD_BYTES) {
return NextResponse.json({ error: "text exceeds 1 MiB cap" }, { status: 413 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

try {
const display = await getVncDisplay();
const { code, stderr } = await runXclip(
["-selection", "clipboard", "-in"],
display,
text,
);
if (code !== 0) {
return NextResponse.json(
{ error: `xclip exit ${code}: ${stderr.slice(0, 200)}` },
{ status: 500 },
);
}
return NextResponse.json({ ok: true }, { headers: { "Cache-Control": "no-store" } });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "xclip write failed" },
{ status: 500 },
);
}
}
8 changes: 7 additions & 1 deletion src/components/ChatPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1356,7 +1356,13 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink
)
if (!activeOption?.provider) return null
const catalog = chatProviderCatalog
if (!catalog || catalog.models.length < 2) return null
if (!catalog) return null
// Show the dropdown when there are multiple models to pick OR
// the catalog permits a custom model id (then the picker
// surfaces a "type your own" affordance, which is the only way
// to switch to a different Claude variant when Anthropic's
// OAuth scope returned a single canonical model).
if (catalog.models.length < 2 && !catalog.allowCustom) return null
// ClawBox AI's wire-format provider is `deepseek` (Mike's
// gateway forwards to DeepSeek's API), while the UI normalizes
// to `clawai`. Try the canonical provider first, then fall
Expand Down
Loading
Loading