diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 8ea681f..56cb944 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -79,7 +79,7 @@ openclaw onboard \ --skip-health \ --auth-choice custom-api-key \ --custom-base-url "https://inference.local/v1" \ - --custom-model-id "aws/anthropic/bedrock-claude-opus-4-6" \ + --custom-model-id "-" \ --custom-api-key "$_ONBOARD_KEY" \ --secret-input-mode plaintext \ --custom-compatibility openai \ diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/api-keys-page.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/api-keys-page.ts index bcbaecd..29fc9ac 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/api-keys-page.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/api-keys-page.ts @@ -6,7 +6,7 @@ * model-registry.ts getter functions. */ -import { ICON_KEY, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from "./icons.ts"; +import { ICON_KEY, ICON_EYE, ICON_EYE_OFF, ICON_CHECK, ICON_LOADER, ICON_CLOSE } from "./icons.ts"; import { getInferenceApiKey, getIntegrateApiKey, @@ -24,6 +24,7 @@ interface KeyFieldDef { label: string; description: string; placeholder: string; + serverCredentialKey: string; get: () => string; set: (v: string) => void; } @@ -34,6 +35,7 @@ const KEY_FIELDS: KeyFieldDef[] = [ label: "Inference API Key", description: "For inference-api.nvidia.com — powers NVIDIA Claude Opus 4.6", placeholder: "nvapi-...", + serverCredentialKey: "OPENAI_API_KEY", get: getInferenceApiKey, set: setInferenceApiKey, }, @@ -42,11 +44,67 @@ const KEY_FIELDS: KeyFieldDef[] = [ label: "Integrate API Key", description: "For integrate.api.nvidia.com — powers Kimi K2.5, Nemotron Ultra, DeepSeek V3.2", placeholder: "nvapi-...", + serverCredentialKey: "NVIDIA_API_KEY", get: getIntegrateApiKey, set: setIntegrateApiKey, }, ]; +// --------------------------------------------------------------------------- +// Sync localStorage keys to server-side provider credentials +// --------------------------------------------------------------------------- + +interface ProviderSummary { + name: string; + type: string; + credentialKeys: string[]; +} + +/** + * Push localStorage API keys to every server-side provider whose credential + * key matches. This bridges the gap between the browser-only API Keys tab + * and the NemoClaw proxy which reads credentials from the server-side store. + */ +export async function syncKeysToProviders(): Promise { + const res = await fetch("/api/providers"); + if (!res.ok) throw new Error(`Failed to fetch providers: ${res.status}`); + const body = await res.json(); + if (!body.ok) throw new Error(body.error || "Failed to fetch providers"); + + const providers: ProviderSummary[] = body.providers || []; + const errors: string[] = []; + + for (const provider of providers) { + for (const field of KEY_FIELDS) { + const key = field.get(); + if (!isKeyConfigured(key)) continue; + if (!provider.credentialKeys?.includes(field.serverCredentialKey)) continue; + + try { + const updateRes = await fetch(`/api/providers/${encodeURIComponent(provider.name)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: provider.type, + credentials: { [field.serverCredentialKey]: key }, + config: {}, + }), + }); + const updateBody = await updateRes.json(); + if (!updateBody.ok) { + errors.push(`${provider.name}: ${updateBody.error || "update failed"}`); + } + } catch (err) { + errors.push(`${provider.name}: ${err}`); + } + } + } + + if (errors.length > 0) { + throw new Error(errors.join("; ")); + } +} + // --------------------------------------------------------------------------- // Render the API Keys page into a container element // --------------------------------------------------------------------------- @@ -71,7 +129,7 @@ export function renderApiKeysPage(container: HTMLElement): void { Enter your NVIDIA API keys to enable model switching and DGX deployment. Keys are stored locally in your browser and never sent to third parties.

- + Get your keys at build.nvidia.com → `; page.appendChild(intro); @@ -100,7 +158,7 @@ export function renderApiKeysPage(container: HTMLElement): void { form.appendChild(actions); page.appendChild(form); - saveBtn.addEventListener("click", () => { + saveBtn.addEventListener("click", async () => { for (const field of KEY_FIELDS) { const input = form.querySelector(`[data-key-id="${field.id}"]`); if (input) field.set(input.value.trim()); @@ -108,12 +166,25 @@ export function renderApiKeysPage(container: HTMLElement): void { updateStatusDots(); - feedback.className = "nemoclaw-key-feedback nemoclaw-key-feedback--success"; - feedback.innerHTML = `${ICON_CHECK}Keys saved`; - setTimeout(() => { - feedback.className = "nemoclaw-key-feedback"; - feedback.textContent = ""; - }, 3000); + feedback.className = "nemoclaw-key-feedback nemoclaw-key-feedback--saving"; + feedback.innerHTML = `${ICON_LOADER}Syncing keys to providers\u2026`; + saveBtn.disabled = true; + + try { + await syncKeysToProviders(); + feedback.className = "nemoclaw-key-feedback nemoclaw-key-feedback--success"; + feedback.innerHTML = `${ICON_CHECK}Keys saved & synced to providers`; + } catch (err) { + console.warn("[NeMoClaw] Provider key sync failed:", err); + feedback.className = "nemoclaw-key-feedback nemoclaw-key-feedback--error"; + feedback.innerHTML = `${ICON_CLOSE}Keys saved locally but sync failed`; + } finally { + saveBtn.disabled = false; + setTimeout(() => { + feedback.className = "nemoclaw-key-feedback"; + feedback.textContent = ""; + }, 4000); + } }); } diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index d5e6f1c..5ff25a2 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -5,7 +5,7 @@ * 1. A green "Deploy DGX Spark/Station" CTA button in the topbar * 2. A "NeMoClaw" collapsible nav group with Policy, Inference Routes, * and API Keys pages - * 3. A model selector wired to NVIDIA endpoints via config.patch + * 3. A model selector wired to NVIDIA endpoints * * Operates purely as an overlay — no original OpenClaw source files are modified. */ @@ -15,7 +15,8 @@ import { injectButton } from "./deploy-modal.ts"; import { injectNavGroup, activateNemoPage, watchOpenClawNavClicks } from "./nav-group.ts"; import { injectModelSelector, watchChatCompose } from "./model-selector.ts"; import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from "./model-registry.ts"; -import { waitForClient, patchConfig, waitForReconnect } from "./gateway-bridge.ts"; +import { waitForClient, waitForReconnect, patchConfig } from "./gateway-bridge.ts"; +import { syncKeysToProviders } from "./api-keys-page.ts"; function inject(): boolean { const hasButton = injectButton(); @@ -38,50 +39,6 @@ function watchGotoLinks() { }); } -/** - * Update the NemoClaw provider credential on the host so the sandbox - * proxy / inference router uses the real key for inference.local requests. - * Mirrors the policy-sync pattern in policy-page.ts. - */ -function injectKeyViaHost(key: string): void { - fetch("/api/inject-key", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }) - .then((r) => r.json()) - .then((b) => console.log("[NeMoClaw] inject-key:", b)) - .catch((e) => console.warn("[NeMoClaw] inject-key failed:", e)); -} - -/** - * When API keys arrive via URL parameters (from the welcome UI), apply - * the default model's provider config so the gateway has a valid key - * immediately rather than the placeholder set during onboarding. - */ -function applyIngestedKeys(): void { - waitForClient().then(async () => { - const apiKey = resolveApiKey(DEFAULT_MODEL.keyType); - await patchConfig({ - models: { - providers: { - [DEFAULT_MODEL.providerKey]: { - baseUrl: DEFAULT_MODEL.providerConfig.baseUrl, - api: DEFAULT_MODEL.providerConfig.api, - models: DEFAULT_MODEL.providerConfig.models, - apiKey, - }, - }, - }, - agents: { - defaults: { model: { primary: DEFAULT_MODEL.modelRef } }, - }, - }); - }).catch((err) => { - console.error("[NeMoClaw] Failed to apply ingested API key:", err); - }); -} - /** * Insert a full-screen loading overlay that covers the OpenClaw UI while the * gateway connects and auto-pairs the device. The overlay is styled via @@ -108,10 +65,51 @@ function revealApp(): void { } } +/** + * Read the live OpenClaw config, find the active model.primary ref, and + * patch streaming: true for it. For proxy-managed models the model.primary + * never changes after onboard, so enabling it once covers every proxy model + * switch. + */ +async function enableStreamingForActiveModel(): Promise { + const client = await waitForClient(); + const snapshot = await client.request>("config.get", {}); + + const agents = snapshot?.agents as Record | undefined; + const defaults = agents?.defaults as Record | undefined; + const model = defaults?.model as Record | undefined; + const primary = model?.primary as string | undefined; + + if (!primary) { + console.warn("[NeMoClaw] Could not determine active model primary from config"); + return; + } + + const models = defaults?.models as Record> | undefined; + if (models?.[primary]?.streaming === true) return; + + await patchConfig({ + agents: { + defaults: { + models: { + [primary]: { streaming: true }, + }, + }, + }, + }); +} + function bootstrap() { showConnectOverlay(); - waitForReconnect(30_000).then(revealApp).catch(revealApp); + waitForReconnect(30_000) + .then(() => { + revealApp(); + enableStreamingForActiveModel().catch((err) => + console.warn("[NeMoClaw] Failed to enable streaming:", err), + ); + }) + .catch(revealApp); const keysIngested = ingestKeysFromUrl(); @@ -121,8 +119,9 @@ function bootstrap() { const defaultKey = resolveApiKey(DEFAULT_MODEL.keyType); if (keysIngested || isKeyConfigured(defaultKey)) { - applyIngestedKeys(); - injectKeyViaHost(defaultKey); + syncKeysToProviders().catch((e) => + console.warn("[NeMoClaw] bootstrap provider key sync failed:", e), + ); } if (inject()) { diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts index d1fd47e..2c30b13 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts @@ -379,6 +379,7 @@ function buildQuickChip(modelId: string, name: string, providerName: string, cur const items = getCustomQuickSelects().filter((i) => i.modelId !== modelId); saveCustomQuickSelects(items); chip.remove(); + refreshModelSelector().catch(() => {}); }); chip.appendChild(removeBtn); } @@ -439,6 +440,7 @@ function showAddQuickSelectForm(section: HTMLElement): void { saveCustomQuickSelects(items); form.remove(); rerenderQuickPicker(section); + refreshModelSelector().catch(() => {}); }); btns.appendChild(addConfirm); diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts index 885fb49..9016971 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts @@ -118,8 +118,8 @@ export interface ModelEntry { } // --------------------------------------------------------------------------- -// Curated models — hardcoded presets backed by the nvidia-inference provider. -// All route through inference.local; the NemoClaw proxy injects credentials. +// Curated models — hardcoded presets routed through inference.local. +// The NemoClaw proxy injects credentials based on the providerName. // --------------------------------------------------------------------------- export interface CuratedModel { @@ -130,6 +130,12 @@ export interface CuratedModel { } export const CURATED_MODELS: readonly CuratedModel[] = [ + { + id: "curated-kimi-k25", + name: "Kimi K2.5", + modelId: "moonshotai/kimi-k2.5", + providerName: "nvidia-endpoints", + }, { id: "curated-claude-opus", name: "Claude Opus 4.6", @@ -137,28 +143,28 @@ export const CURATED_MODELS: readonly CuratedModel[] = [ providerName: "nvidia-inference", }, { - id: "curated-gpt-oss", - name: "GPT-OSS 20B", - modelId: "nvidia/openai/gpt-oss-20b", - providerName: "nvidia-inference", + id: "curated-minimax-m25", + name: "MiniMax M2.5", + modelId: "minimaxai/minimax-m2.5", + providerName: "nvidia-endpoints", }, { - id: "curated-nemotron-super", - name: "Nemotron 3 Super", - modelId: "nvidia/nvidia/nemotron-3-super-preview", - providerName: "nvidia-inference", + id: "curated-glm5", + name: "GLM 5", + modelId: "z-ai/glm5", + providerName: "nvidia-endpoints", }, { - id: "curated-qwen3", - name: "Qwen3 Next 80B", - modelId: "nvidia/qwen/qwen3-next-80b-a3b-instruct", - providerName: "nvidia-inference", + id: "curated-qwen35", + name: "Qwen 3.5 397B", + modelId: "qwen/qwen3.5-397b-a17b", + providerName: "nvidia-endpoints", }, { - id: "curated-llama-70b", - name: "Llama 3.3 70B", - modelId: "nvidia/meta/llama-3.3-70b-instruct", - providerName: "nvidia-inference", + id: "curated-gpt-oss-120b", + name: "GPT-OSS 120B", + modelId: "openai/gpt-oss-120b", + providerName: "nvidia-endpoints", }, ]; @@ -167,7 +173,7 @@ export function curatedToModelEntry(c: CuratedModel): ModelEntry { return { id: c.id, name: c.name, - isDefault: c.id === "curated-claude-opus", + isDefault: c.id === "curated-kimi-k25", providerKey: key, modelRef: `${key}/${c.modelId}`, keyType: "inference", @@ -198,27 +204,27 @@ export function getCuratedByModelId(modelId: string): CuratedModel | undefined { // Legacy MODEL_REGISTRY — kept as the default model reference for bootstrap // --------------------------------------------------------------------------- -const DEFAULT_PROVIDER_KEY = "custom-inference-api-nvidia-com"; +const DEFAULT_PROVIDER_KEY = "curated-nvidia-endpoints"; export const MODEL_REGISTRY: readonly ModelEntry[] = [ { - id: "nvidia-claude-opus-4-6", - name: "Claude Opus 4.6", + id: "curated-kimi-k25", + name: "Kimi K2.5", isDefault: true, providerKey: DEFAULT_PROVIDER_KEY, - modelRef: `${DEFAULT_PROVIDER_KEY}/aws/anthropic/bedrock-claude-opus-4-6`, + modelRef: `${DEFAULT_PROVIDER_KEY}/moonshotai/kimi-k2.5`, keyType: "inference", providerConfig: { baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { - id: "aws/anthropic/bedrock-claude-opus-4-6", - name: "Claude Opus 4.6", + id: "moonshotai/kimi-k2.5", + name: "Kimi K2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, + contextWindow: 128_000, maxTokens: 8192, }, ], @@ -299,6 +305,46 @@ export function buildDynamicEntry( }; } +/** + * Build a ModelEntry for a user-defined Quick Select shortcut. + * Uses a unique ID derived from providerName + modelId to avoid + * collisions when multiple shortcuts share the same provider. + */ +export function buildQuickSelectEntry( + providerName: string, + modelId: string, + displayName: string, +): ModelEntry { + const curated = getCuratedByModelId(modelId); + if (curated) return curatedToModelEntry(curated); + + const key = `qs-${providerName}-${modelId.replace(/\//g, "-")}`; + return { + id: key, + name: displayName, + isDefault: false, + providerKey: `qs-${providerName}`, + modelRef: `qs-${providerName}/${modelId}`, + keyType: "inference", + isDynamic: true, + providerConfig: { + baseUrl: "https://inference.local/v1", + api: "openai-completions", + models: [ + { + id: modelId, + name: displayName, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8192, + }, + ], + }, + }; +} + // --------------------------------------------------------------------------- // Deploy targets (used by deploy-modal.ts) // --------------------------------------------------------------------------- diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts index da3bf4d..3c897ce 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts @@ -19,6 +19,7 @@ import { resolveApiKey, isKeyConfigured, buildDynamicEntry, + buildQuickSelectEntry, setDynamicModels, getDynamicModels, CURATED_MODELS, @@ -69,6 +70,9 @@ export function buildModelPatch(entry: ModelEntry): Record | nu agents: { defaults: { model: { primary: entry.modelRef }, + models: { + [entry.modelRef]: { streaming: true }, + }, }, }, }; @@ -119,6 +123,20 @@ async function fetchDynamic(): Promise { entries.push(buildDynamicEntry(route.providerName, route.modelId, provType)); } + const curatedIds = new Set(CURATED_MODELS.map((c) => c.modelId)); + const existingModelIds = new Set(entries.map((e) => e.providerConfig.models[0]?.id)); + try { + const raw = localStorage.getItem("nemoclaw:custom-quick-selects"); + if (raw) { + const customQS: { modelId: string; name: string; providerName: string }[] = JSON.parse(raw); + for (const qs of customQS) { + if (curatedIds.has(qs.modelId) || existingModelIds.has(qs.modelId)) continue; + entries.push(buildQuickSelectEntry(qs.providerName, qs.modelId, qs.name)); + existingModelIds.add(qs.modelId); + } + } + } catch { /* ignore malformed localStorage data */ } + setDynamicModels(entries); } catch { // Non-fatal -- static models still work diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts index c81ade9..3389b89 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts @@ -454,7 +454,7 @@ function buildNetworkPoliciesSection(): HTMLElement { headerRow.className = "nemoclaw-policy-section__header"; headerRow.innerHTML = ` ${ICON_GLOBE} -

Network Policies

+

Allowed Network Policies

${policyCount}`; const searchInput = document.createElement("input"); diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css index 432598d..43ad9e7 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css @@ -1204,7 +1204,7 @@ body.nemoclaw-switching openclaw-app { background: var(--bg-elevated, #1a1d25); padding: 14px; position: relative; - overflow: hidden; + overflow: visible; } :root[data-theme="light"] .nemoclaw-policy-imm-card { @@ -1737,8 +1737,7 @@ body.nemoclaw-switching openclaw-app { content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); + left: 0; padding: 6px 10px; border-radius: 6px; background: var(--card, #181b22);