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
14 changes: 13 additions & 1 deletion src/app/setup-api/ai-models/catalog/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { spawn } from "child_process";
import { promises as fsp } from "fs";
import path from "path";
import { findOpenclawBin } from "@/lib/openclaw-config";
import { findOpenclawBin, openclawIsAbsent } from "@/lib/openclaw-config";
import { DATA_DIR } from "@/lib/config-store";
import { CATALOG_PROVIDERS, isCatalogProvider, PROVIDER_CATALOGS } from "@/lib/provider-models";

Expand Down Expand Up @@ -431,6 +431,18 @@ async function fetchOpenRouterCatalog(): Promise<CatalogModel[]> {
// next service restart.
export function refreshInBackground(provider: string): void {
if (refreshing.has(provider)) return;

// OpenRouter (REST) and ClawBox AI (static) never touch the CLI; every other
// provider's catalog comes from `openclaw models list`. On an edition without
// the binary (Hermes) that spawn is a guaranteed ENOENT, so skip it cleanly
// rather than fork a missing binary for each provider on every boot warmup.
// Hermes surfaces its own model list through the Hermes dashboard, not here.
const usesOpenclawCli = provider !== "openrouter" && provider !== "clawai";
if (usesOpenclawCli && openclawIsAbsent()) {
console.log(`[catalog] skipping ${provider}: the openclaw CLI is not present on this edition`);
return;
}

refreshing.add(provider);

let fetcher: Promise<CatalogModel[]>;
Expand Down
99 changes: 94 additions & 5 deletions src/app/setup-api/ai-models/configure/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import {
applyModelOverrideToAllAgentSessions,
parseFullyQualifiedModel,
setProviderPlugins,
openclawIsAbsent,
OpenclawUnavailableError,
} from "@/lib/openclaw-config";
import { getActiveHarness } from "@/lib/harness";
import { applyLocalAiToHermes } from "@/lib/hermes-local-ai";
import { applyLocalAiToHermes, HermesLocalApplyError } from "@/lib/hermes-local-ai";
import { applyClawaiToHermes, ClawaiApplyError } from "@/lib/hermes-clawai";
import { applyCloudProviderKeyToHermes, HermesCloudApplyError } from "@/lib/hermes-cloud-provider";
import {
getDefaultLlamaCppModel,
getLlamaCppContextWindow,
Expand Down Expand Up @@ -436,6 +440,11 @@ async function writeOpenAICompatProvider(opts: {
}

export async function POST(request: Request) {
// Hoisted so the catch can classify the failure without re-parsing the body —
// a local-model or wrong-edition failure must not be reported as a credential
// problem (there is no credential to check).
let requestProvider: string | undefined;
let requestScope: ConfigureScope = "primary";
try {
let body: {
provider?: string;
Expand Down Expand Up @@ -505,6 +514,8 @@ export async function POST(request: Request) {
}

const { provider, apiKey, authMode = "token", idToken, refreshToken, expiresIn, projectId, scope = "primary", model: bodyModel } = body;
requestProvider = provider;
requestScope = scope;
const requestedClawboxAiTier = normalizeClawboxAiTier(body.clawaiTier);
const normalizedApiKey = typeof apiKey === "string" ? apiKey.trim() : "";
const isOllama = provider === "ollama";
Expand Down Expand Up @@ -681,6 +692,73 @@ export async function POST(request: Request) {
}
}

// ── Hermes edition: no openclaw binary exists here ──────────────────────
// Everything below configures OpenClaw — it writes ~/.openclaw, runs
// `openclaw config set …`, and restarts the gateway. On the Hermes SKU there
// is no openclaw binary, so the API-key path used to fail with `spawn
// openclaw ENOENT` and then blame the user's credentials, while the local
// (Gemma) switch failed the same way. Route to Hermes' own config store
// instead — the same one the OAuth/token path already writes.
if (openclawIsAbsent()) {
try {
if (isLocalScope && (isLlamaCpp || isOllama)) {
// On-device model (Gemma via llama.cpp, or Ollama): persist the same
// config-store keys the OpenClaw path would, then register it with
// Hermes as an available provider (activating it only on a fresh
// device, matching shouldPromoteLocalToPrimary). No gateway restart.
await setMany({
local_ai_configured: true,
local_ai_provider: ocProvider,
local_ai_model: config.defaultModel,
local_ai_configured_at: new Date().toISOString(),
});
await applyLocalAiToHermes({
provider: ocProvider as "llamacpp" | "ollama",
// Hermes wants the bare model id, not the `llamacpp/…` qualified form.
model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""),
makeDefault: shouldPromoteLocalToPrimary,
});
Comment on lines +709 to +720

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist the local-model state only after Hermes accepts it.

setMany marks local_ai_configured: true before applyLocalAiToHermes runs. If the Hermes CLI call fails, the route returns 502 while the config store already reports the local model as configured. Hermes has no matching provider block at that point, so the status route reports a model that the device cannot serve.

applyCloudProviderKeyToHermes in src/lib/hermes-cloud-provider.ts (Lines 121-127) uses the opposite order: it writes ai_model_configured only after activation succeeds. Apply the same order here.

🐛 Proposed reorder
-          await setMany({
-            local_ai_configured: true,
-            local_ai_provider: ocProvider,
-            local_ai_model: config.defaultModel,
-            local_ai_configured_at: new Date().toISOString(),
-          });
           await applyLocalAiToHermes({
             provider: ocProvider as "llamacpp" | "ollama",
             // Hermes wants the bare model id, not the `llamacpp/…` qualified form.
             model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""),
             makeDefault: shouldPromoteLocalToPrimary,
           });
+          await setMany({
+            local_ai_configured: true,
+            local_ai_provider: ocProvider,
+            local_ai_model: config.defaultModel,
+            local_ai_configured_at: new Date().toISOString(),
+          });
           return NextResponse.json({ success: true });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await setMany({
local_ai_configured: true,
local_ai_provider: ocProvider,
local_ai_model: config.defaultModel,
local_ai_configured_at: new Date().toISOString(),
});
await applyLocalAiToHermes({
provider: ocProvider as "llamacpp" | "ollama",
// Hermes wants the bare model id, not the `llamacpp/…` qualified form.
model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""),
makeDefault: shouldPromoteLocalToPrimary,
});
await applyLocalAiToHermes({
provider: ocProvider as "llamacpp" | "ollama",
// Hermes wants the bare model id, not the `llamacpp/…` qualified form.
model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""),
makeDefault: shouldPromoteLocalToPrimary,
});
await setMany({
local_ai_configured: true,
local_ai_provider: ocProvider,
local_ai_model: config.defaultModel,
local_ai_configured_at: new Date().toISOString(),
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/setup-api/ai-models/configure/route.ts` around lines 709 - 720,
Reorder the local-model setup around applyLocalAiToHermes so Hermes activation
succeeds before persisting local_ai_configured, local_ai_provider,
local_ai_model, and local_ai_configured_at via setMany. Keep the existing
provider and model normalization, then perform the state write only after
successful activation, matching the ordering used by
applyCloudProviderKeyToHermes.

return NextResponse.json({ success: true });
}
Comment on lines +702 to +722

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Local providers with primary scope fall into the cloud-provider branch on Hermes.

Branch A gates on isLocalScope. A llama.cpp or Ollama request with the default scope: "primary" skips it.

Trace { provider: "llamacpp", apiKey: "gemma-q4" } (primary scope — the shape configure.test.ts Line 665 already posts):

  1. Line 704: isLocalScope is false, so Branch A does not run.
  2. Line 723: not ClawAI.
  3. Line 727: authMode is "token" and normalizedApiKey is "gemma-q4", so Branch C runs.
  4. applyCloudProviderKeyToHermes({ openclawProvider: "llamacpp", … }) calls hermesKeyProviderFor("llamacpp"), which returns null.
  5. The helper throws HermesCloudApplyError, and the route returns 502 "This provider is set up through the Hermes provider panel on this edition."

For llama.cpp the apiKey field carries the model name, not a credential — Line 627 documents this. The on-device model is therefore treated as an unmapped cloud provider.

{ provider: "llamacpp" } with no apiKey is also wrong: normalizedApiKey is empty, so Branch C is skipped and the Line 745 fallback returns 400 with the same provider-panel message.

Gate Branch A on the provider, not the scope.

🐛 Proposed fix for the branch gate
-        if (isLocalScope && (isLlamaCpp || isOllama)) {
+        if (isLlamaCpp || isOllama) {
           // On-device model (Gemma via llama.cpp, or Ollama): persist the same
           // config-store keys the OpenClaw path would, then register it with
           // Hermes as an available provider (activating it only on a fresh
           // device, matching shouldPromoteLocalToPrimary). No gateway restart.
           await setMany({
             local_ai_configured: true,
             local_ai_provider: ocProvider,
             local_ai_model: config.defaultModel,
             local_ai_configured_at: new Date().toISOString(),
           });
           await applyLocalAiToHermes({
             provider: ocProvider as "llamacpp" | "ollama",
             // Hermes wants the bare model id, not the `llamacpp/…` qualified form.
             model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""),
-            makeDefault: shouldPromoteLocalToPrimary,
+            // A primary-scope request selects the model explicitly, so activate
+            // it; a local-scope request activates only on a fresh device.
+            makeDefault: !isLocalScope || shouldPromoteLocalToPrimary,
           });
           return NextResponse.json({ success: true });
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (openclawIsAbsent()) {
try {
if (isLocalScope && (isLlamaCpp || isOllama)) {
// On-device model (Gemma via llama.cpp, or Ollama): persist the same
// config-store keys the OpenClaw path would, then register it with
// Hermes as an available provider (activating it only on a fresh
// device, matching shouldPromoteLocalToPrimary). No gateway restart.
await setMany({
local_ai_configured: true,
local_ai_provider: ocProvider,
local_ai_model: config.defaultModel,
local_ai_configured_at: new Date().toISOString(),
});
await applyLocalAiToHermes({
provider: ocProvider as "llamacpp" | "ollama",
// Hermes wants the bare model id, not the `llamacpp/…` qualified form.
model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""),
makeDefault: shouldPromoteLocalToPrimary,
});
return NextResponse.json({ success: true });
}
if (openclawIsAbsent()) {
try {
if (isLlamaCpp || isOllama) {
// On-device model (Gemma via llama.cpp, or Ollama): persist the same
// config-store keys the OpenClaw path would, then register it with
// Hermes as an available provider (activating it only on a fresh
// device, matching shouldPromoteLocalToPrimary). No gateway restart.
await setMany({
local_ai_configured: true,
local_ai_provider: ocProvider,
local_ai_model: config.defaultModel,
local_ai_configured_at: new Date().toISOString(),
});
await applyLocalAiToHermes({
provider: ocProvider as "llamacpp" | "ollama",
// Hermes wants the bare model id, not the `llamacpp/…` qualified form.
model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""),
// A primary-scope request selects the model explicitly, so activate
// it; a local-scope request activates only on a fresh device.
makeDefault: !isLocalScope || shouldPromoteLocalToPrimary,
});
return NextResponse.json({ success: true });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/setup-api/ai-models/configure/route.ts` around lines 702 - 722,
Update the Branch A condition in the openclawIsAbsent() flow to recognize local
providers by provider identity, not only isLocalScope: route llama.cpp and
Ollama requests through the existing local configuration and
applyLocalAiToHermes logic even when scope is "primary". Preserve the current
shouldPromoteLocalToPrimary behavior and model normalization, while leaving
non-local providers on their existing branches.

if (isClawAI) {
await applyClawaiToHermes(clawboxAiToken, resolvedClawboxTier ?? CLAWBOX_AI_DEFAULT_TIER);
return NextResponse.json({ success: true });
}
if (authMode !== "subscription" && normalizedApiKey) {
// Cloud API-key providers Hermes supports (anthropic, google→gemini,
// openrouter). The credential lands in Hermes' own store and the
// provider is activated through Hermes' catalog.
const result = await applyCloudProviderKeyToHermes({
openclawProvider: provider,
apiKey: normalizedApiKey,
});
if (result.activated) {
return NextResponse.json({ success: true });
}
return NextResponse.json(
{ error: "Key saved. Open the Hermes provider panel to pick a model for it." },
{ status: 409 },
);
Comment on lines +735 to +741

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the client code that posts to the configure route and inspect its status handling.
rg -nP -C 10 'ai-models/configure' src/components/ src/hooks/ src/app/ -g '!**/route.ts'

Repository: ID-Robots/clawbox

Length of output: 4835


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- route Hermes branch and error mapping ---'
sed -n '680,770p' src/app/setup-api/ai-models/configure/route.ts
printf '%s\n' '--- Hermes helper definitions and error classes ---'
rg -n -P -C 12 'class Hermes(Local|Cloud)ApplyError|apply(LocalAi|Clawai|CloudProviderKey)ToHermes|Hermes.*ApplyError' src
printf '%s\n' '--- client status and error handling ---'
rg -n -P -C 18 'showError|extractError|selectedProvider|provider.*hermes|hermes' src/components/AIModelsStep.tsx

Repository: ID-Robots/clawbox

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Hermes helper error behavior ---'
sed -n '1,180p' src/lib/hermes-local-ai.ts
sed -n '1,150p' src/lib/hermes-cloud-provider.ts
sed -n '1,130p' src/lib/hermes-clawai.ts
printf '%s\n' '--- wizard response handling ---'
sed -n '930,990p' src/components/AIModelsStep.tsx
printf '%s\n' '--- route tests for the disputed responses ---'
rg -n -P -C 10 '409|502|activated|saved|ApplyError|status\)' src/tests/routes/ai-models/configure-hermes.test.ts

Repository: ID-Robots/clawbox

Length of output: 25599


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

client = Path("src/components/AIModelsStep.tsx").read_text()
route = Path("src/app/setup-api/ai-models/configure/route.ts").read_text()
local = Path("src/lib/hermes-local-ai.ts").read_text()
cloud = Path("src/lib/hermes-cloud-provider.ts").read_text()
clawai = Path("src/lib/hermes-clawai.ts").read_text()

assert 'if (!res.ok) return showError(await extractError(res, "Failed to configure"));' in client
assert 'showSuccessAndContinue();' in client
assert '{ status: 409 }' in route
assert '{ error: err.message }, { status: 502 }' in route
assert 'Local model id is missing or malformed.' in local
assert 'An API key is required.' in cloud
assert 'class HermesLocalApplyError extends Error {}' in local
assert 'class HermesCloudApplyError extends Error {' in cloud
assert 'class ClawaiApplyError extends Error {}' in clawai

print("wizard treats every non-2xx configure response as an error")
print("configure route returns 409 for stored-but-unactivated keys")
print("configure route returns 502 for all caught Hermes helper errors")
print("validation messages share helper error classes with CLI failure messages")
PY

Repository: ID-Robots/clawbox

Length of output: 411


Align Hermes response semantics with the wizard.

  • Return a success response for a stored but unactivated key, or handle 409 explicitly in AIModelsStep.tsx. The wizard treats every non-2xx response as an error, so it displays the partial-success message as a failure and does not continue.
  • Add an error discriminator to the Hermes error classes. Map validation errors, such as "Local model id is missing or malformed." and "An API key is required.", to 400; keep Hermes CLI failures at 502.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/setup-api/ai-models/configure/route.ts` around lines 735 - 741,
Update the Hermes setup flow around result.activated so stored but unactivated
keys return a 2xx success response, or explicitly handle the 409 continuation
path in AIModelsStep.tsx. Add an error discriminator to the Hermes error classes
and map validation failures, including missing or malformed local model IDs and
missing API keys, to HTTP 400 while preserving HTTP 502 for Hermes CLI failures.

Source: Path instructions

}
// OAuth / subscription providers (and OpenAI, which is OAuth-only on
// Hermes) sign in through the Hermes provider panel, not this route.
return NextResponse.json(
{ error: "This provider is set up through the Hermes provider panel on this edition." },
{ status: 400 },
);
} catch (err) {
if (
err instanceof HermesCloudApplyError
|| err instanceof HermesLocalApplyError
|| err instanceof ClawaiApplyError
) {
// Author-controlled, non-credential message — safe to echo.
return NextResponse.json({ error: err.message }, { status: 502 });
}
throw err; // unexpected — fall to the outer catch, which classifies it
}
}

// 1. Write token to auth-profiles.json
//
// ── AUDIT: schema-drift risk ───────────────────────────────────
Expand Down Expand Up @@ -1128,9 +1206,20 @@ export async function POST(request: Request) {
// paths. Log it server-side for diagnosis and return a generic, actionable
// message (mirrors the sanitized gateway-restart branch above).
console.error("[configure] Failed to configure AI model:", err instanceof Error ? err.message : err);
return NextResponse.json(
{ error: "Failed to configure AI model. Please check your credentials and try again." },
{ status: 500 }
);
// Classify so the message matches the cause. A local on-device model has no
// credentials, and an edition without the openclaw binary is not something
// the user can fix by re-checking a key — "check your credentials" is wrong
// and un-actionable for both.
const isLocalRequest =
requestScope === "local" || requestProvider === "ollama" || requestProvider === "llamacpp";
let message: string;
if (err instanceof OpenclawUnavailableError) {
message = "This action isn't available on this edition.";
} else if (isLocalRequest) {
message = "Couldn't set up the on-device model. Please try again.";
} else {
message = "Failed to configure AI model. Please check your credentials and try again.";
}
return NextResponse.json({ error: message }, { status: 500 });
}
}
Loading
Loading