openclaw - 💡(How to fix) Fix [Feature]: Add a simple Dashboard UX to manage multiple model providers

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Please add a simple provider-management UX in the OpenClaw Dashboard / Control UI so users can add, edit, test, remove, and choose multiple model providers without manually editing JSON or rerunning CLI onboarding.

This is related to #81960, but the scope is different:

  • #81960 asks for multiple providers during openclaw onboard.
  • This issue asks for an easy Dashboard / Control UI flow after OpenClaw is already running.

Error Message

  1. Show a clear success or validation error. testStatus?: "idle" | "testing" | "ok" | "error"; @state() modelProviderActionMessage: { kind: "success" | "error"; text: string } | null = null;

Root Cause

The lowest-risk path is probably to render it inside the existing settings/config area first, because config.ts already owns the save/apply flow.

Fix Action

Fix / Workaround

  • ui/src/ui/controllers/config.ts
    • This is the best place to add provider-specific config helpers.
    • Existing helpers already stage config edits safely:
      • updateConfigFormValue(state, path, value)
      • stageConfigPreset(state, patch)
      • removeConfigFormValue(state, path)
      • saveConfig(state)
      • applyConfig(state)
    • Internal helper mutateConfigForm clones the current config and syncs the draft.
    • submitConfigChange sends the updated raw JSON to config.set / config.apply with baseHash protection.
openModelProviderCreateForm: () => void;
openModelProviderEditForm: (providerId: string) => void;
updateModelProviderForm: (patch: Partial<ModelProviderFormState>) => void;
addModelProviderFormModel: () => void;
removeModelProviderFormModel: (index: number) => void;
saveModelProviderForm: () => Promise<void>;
closeModelProviderForm: () => void;

updateModelProviderForm(patch: Partial<ModelProviderFormState>) { this.modelProviderForm = this.modelProviderForm ? { ...this.modelProviderForm, ...patch } : null; }

Code Example

export type ProviderCapability = "text" | "image" | "embedding";

export type ProviderTransport =
  | "openai-completions"
  | "anthropic-messages"
  | "openai-responses"
  | "ollama"
  | "unknown";

export type ProviderPresetId =
  | "openai"
  | "anthropic"
  | "openrouter"
  | "groq"
  | "google"
  | "mistral"
  | "ollama"
  | "lmstudio"
  | "vllm"
  | "sglang"
  | "custom-openai"
  | "custom-anthropic"
  | "custom";

export type ModelProviderFormState = {
  mode: "create" | "edit";
  originalProviderId?: string;
  preset: ProviderPresetId;
  providerId: string;
  displayName: string;
  baseUrl: string;
  apiKey: string;
  apiKeyEnvVar: string;
  secretMode: "plaintext" | "env-ref" | "none";
  transport: ProviderTransport;
  modelIds: string[];
  capabilities: ProviderCapability[];
  setAsPrimary: boolean;
  setAsFallback: boolean;
  setAsVision: boolean;
  setAsEmbedding: boolean;
  testStatus?: "idle" | "testing" | "ok" | "error";
  testMessage?: string | null;
};

---

@state() modelProviderFormOpen = false;
@state() modelProviderForm: ModelProviderFormState | null = null;
@state() modelProviderActionMessage: { kind: "success" | "error"; text: string } | null = null;

---

openModelProviderCreateForm: () => void;
openModelProviderEditForm: (providerId: string) => void;
updateModelProviderForm: (patch: Partial<ModelProviderFormState>) => void;
addModelProviderFormModel: () => void;
removeModelProviderFormModel: (index: number) => void;
saveModelProviderForm: () => Promise<void>;
closeModelProviderForm: () => void;

---

export function listConfiguredModelProviders(state: ConfigState): ConfiguredModelProviderSummary[];
export function stageModelProviderConfig(state: ConfigState, input: StageModelProviderInput): void;
export function removeModelProviderConfig(state: ConfigState, providerId: string): void;

---

export type StageModelProviderInput = {
  providerId: string;
  displayName?: string;
  baseUrl?: string;
  api?: string;
  apiKey?: string;
  apiKeyEnvVar?: string;
  secretMode: "plaintext" | "env-ref" | "none";
  modelIds: string[];
  capabilities: Array<"text" | "image" | "embedding">;
  setAsPrimary?: boolean;
  setAsFallback?: boolean;
  setAsVision?: boolean;
  setAsEmbedding?: boolean;
};

export type ConfiguredModelProviderSummary = {
  id: string;
  name?: string;
  baseUrl?: string;
  api?: string;
  modelIds: string[];
  hasApiKey: boolean;
};

---

{
  "models": {
    "providers": {
      "my-provider": {
        "baseURL": "https://api.example.com/v1",
        "apiKey": "... or SecretRef ..."
      }
    },
    "entries": [
      {
        "id": "my-provider/model-a",
        "provider": "my-provider",
        "model": "model-a",
        "api": "openai-completions",
        "input": ["text", "image"]
      }
    ]
  }
}

---

apiKey: {
  source: "env",
  provider: "default",
  id: input.apiKeyEnvVar,
}

---

export function stageModelProviderConfig(state: ConfigState, input: StageModelProviderInput): void {
  mutateConfigForm(state, (draft) => {
    const models = ensureObject(draft, "models");
    const providers = ensureObject(models, "providers");
    const entries = ensureArray(models, "entries");

    providers[input.providerId] = {
      ...(isObject(providers[input.providerId]) ? providers[input.providerId] : {}),
      ...(input.displayName ? { name: input.displayName } : {}),
      ...(input.baseUrl ? { baseURL: input.baseUrl } : {}),
      ...(input.secretMode === "plaintext" && input.apiKey ? { apiKey: input.apiKey } : {}),
      ...(input.secretMode === "env-ref" && input.apiKeyEnvVar
        ? { apiKey: { source: "env", provider: "default", id: input.apiKeyEnvVar } }
        : {}),
    };

    for (const modelId of input.modelIds.map((id) => id.trim()).filter(Boolean)) {
      const entryId = `${input.providerId}/${modelId}`;
      const existingIndex = entries.findIndex((entry) => isObject(entry) && entry.id === entryId);
      const nextEntry = {
        ...(existingIndex >= 0 && isObject(entries[existingIndex]) ? entries[existingIndex] : {}),
        id: entryId,
        provider: input.providerId,
        model: modelId,
        ...(input.api ? { api: input.api } : {}),
        input: input.capabilities.filter((cap) => cap !== "embedding"),
      };
      if (existingIndex >= 0) {
        entries[existingIndex] = nextEntry;
      } else {
        entries.push(nextEntry);
      }
    }

    if (input.setAsPrimary && input.modelIds[0]) {
      setPathValue(draft, ["agents", "defaults", "model"], `${input.providerId}/${input.modelIds[0]}`);
    }

    if (input.setAsVision && input.modelIds[0]) {
      setPathValue(draft, ["imageModel", "primary"], `${input.providerId}/${input.modelIds[0]}`);
    }

    if (input.setAsEmbedding && input.modelIds[0]) {
      setPathValue(draft, ["memorySearch", "provider"], input.providerId);
      setPathValue(draft, ["memorySearch", "model"], input.modelIds[0]);
    }
  });
}

---

function isObject(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function ensureObject(parent: Record<string, unknown>, key: string): Record<string, unknown> {
  const current = parent[key];
  if (isObject(current)) {
    return current;
  }
  const next: Record<string, unknown> = {};
  parent[key] = next;
  return next;
}

function ensureArray(parent: Record<string, unknown>, key: string): unknown[] {
  const current = parent[key];
  if (Array.isArray(current)) {
    return current;
  }
  const next: unknown[] = [];
  parent[key] = next;
  return next;
}

---

export function renderModelProvidersView(state: AppViewState) {
  // list providers from state.configForm or state.configSnapshot.config
  // render provider cards
  // render Add provider button
  // render modal/wizard when state.modelProviderFormOpen is true
}

---

openModelProviderCreateForm() {
  this.modelProviderForm = createDefaultModelProviderForm();
  this.modelProviderFormOpen = true;
}

openModelProviderEditForm(providerId: string) {
  this.modelProviderForm = createModelProviderFormFromConfig(this, providerId);
  this.modelProviderFormOpen = true;
}

updateModelProviderForm(patch: Partial<ModelProviderFormState>) {
  this.modelProviderForm = this.modelProviderForm
    ? { ...this.modelProviderForm, ...patch }
    : null;
}

async saveModelProviderForm() {
  if (!this.modelProviderForm) return;
  stageModelProviderConfig(this, mapFormToStageInput(this.modelProviderForm));
  const saved = await saveConfig(this);
  if (saved) {
    this.modelProviderActionMessage = { kind: "success", text: "Provider saved." };
    this.modelProviderFormOpen = false;
    this.modelProviderForm = null;
  }
}
RAW_BUFFERClick to expand / collapse

Summary

Please add a simple provider-management UX in the OpenClaw Dashboard / Control UI so users can add, edit, test, remove, and choose multiple model providers without manually editing JSON or rerunning CLI onboarding.

This is related to #81960, but the scope is different:

  • #81960 asks for multiple providers during openclaw onboard.
  • This issue asks for an easy Dashboard / Control UI flow after OpenClaw is already running.

Problem

Today, managing multiple model providers is still too hard for normal users. A common real-world setup needs several providers:

  • a primary coding/chat model
  • a fallback model
  • a cheap/fast model
  • a vision model
  • an embedding/memory model
  • a local provider such as Ollama, vLLM, LM Studio, or SGLang
  • one or more cloud providers such as OpenAI, Anthropic, OpenRouter, Groq, Google, Mistral, etc.

Users should not need to manually edit openclaw.json, models.providers, models.entries, or agent defaults just to add another provider.

Desired UX

Add a Models / Providers screen in the Dashboard with a very simple flow:

  1. Show a list of configured providers.
  2. Add a visible Add provider button.
  3. Open a small wizard/modal:
    • Provider type: OpenAI, Anthropic, OpenRouter, Groq, Google, Mistral, Ollama, LM Studio, vLLM, SGLang, Custom OpenAI-compatible, Custom Anthropic-compatible, Unknown/custom.
    • Provider id/name.
    • Base URL when needed.
    • API key or SecretRef mode.
    • One or more model IDs.
    • Capabilities: text, image, embeddings if applicable.
  4. Let users add multiple model IDs for the same provider from the same form.
  5. Provide Test connection and optionally Test model buttons.
  6. Let users assign roles:
    • Primary model
    • Fallback model
    • Vision model
    • Embedding/memory model
  7. Save the config using the existing Dashboard config controller flow.
  8. Show a clear success or validation error.

The UX should be friendly enough that a non-technical user can add a second provider in under one minute.

Code areas inspected

A bot or contributor should start here:

Existing Dashboard app state and rendering

  • ui/src/ui/app.ts

    • Main OpenClawApp LitElement.
    • Holds dashboard state such as configSnapshot, configForm, configSaving, configApplying, chatModelCatalog, and modelAuthStatus-related state.
  • ui/src/ui/app-view-state.ts

    • Defines AppViewState used by render functions.
    • Already includes config editor state:
      • configLoading
      • configRaw
      • configRawOriginal
      • configSnapshot
      • configForm
      • configFormOriginal
      • configFormMode
      • configSettingsMode
      • section/subsection navigation state
    • Also includes chat model catalog state:
      • chatModelCatalog: ModelCatalogEntry[]
      • chatModelOverrides
      • chatModelsLoading
  • ui/src/ui/app-render.ts

    • Main renderer that chooses which tab/view to display.
    • The new provider UX should be mounted from here, either as a new tab or as a card inside the existing config/settings area.

Existing config controller to reuse

  • ui/src/ui/controllers/config.ts
    • This is the best place to add provider-specific config helpers.
    • Existing helpers already stage config edits safely:
      • updateConfigFormValue(state, path, value)
      • stageConfigPreset(state, patch)
      • removeConfigFormValue(state, path)
      • saveConfig(state)
      • applyConfig(state)
    • Internal helper mutateConfigForm clones the current config and syncs the draft.
    • submitConfigChange sends the updated raw JSON to config.set / config.apply with baseHash protection.

Existing config model types

  • ui/src/ui/types.ts
    • Defines ConfigSnapshot, ModelCatalogEntry, ModelAuthStatusResult, and related UI types by importing shared/backend types.

Existing docs

  • docs/web/control-ui.md

    • Should be updated to document the new Dashboard provider-management screen.
  • docs/concepts/model-providers.md

    • Good conceptual reference for providers and model configuration.

Proposed implementation plan

Step 1 — Add provider form state

Add a UI-only form type, probably in a new file:

ui/src/ui/views/model-providers.types.ts

Suggested shape:

export type ProviderCapability = "text" | "image" | "embedding";

export type ProviderTransport =
  | "openai-completions"
  | "anthropic-messages"
  | "openai-responses"
  | "ollama"
  | "unknown";

export type ProviderPresetId =
  | "openai"
  | "anthropic"
  | "openrouter"
  | "groq"
  | "google"
  | "mistral"
  | "ollama"
  | "lmstudio"
  | "vllm"
  | "sglang"
  | "custom-openai"
  | "custom-anthropic"
  | "custom";

export type ModelProviderFormState = {
  mode: "create" | "edit";
  originalProviderId?: string;
  preset: ProviderPresetId;
  providerId: string;
  displayName: string;
  baseUrl: string;
  apiKey: string;
  apiKeyEnvVar: string;
  secretMode: "plaintext" | "env-ref" | "none";
  transport: ProviderTransport;
  modelIds: string[];
  capabilities: ProviderCapability[];
  setAsPrimary: boolean;
  setAsFallback: boolean;
  setAsVision: boolean;
  setAsEmbedding: boolean;
  testStatus?: "idle" | "testing" | "ok" | "error";
  testMessage?: string | null;
};

Then add state fields to OpenClawApp in ui/src/ui/app.ts and to AppViewState in ui/src/ui/app-view-state.ts:

@state() modelProviderFormOpen = false;
@state() modelProviderForm: ModelProviderFormState | null = null;
@state() modelProviderActionMessage: { kind: "success" | "error"; text: string } | null = null;

Also expose handler methods in AppViewState:

openModelProviderCreateForm: () => void;
openModelProviderEditForm: (providerId: string) => void;
updateModelProviderForm: (patch: Partial<ModelProviderFormState>) => void;
addModelProviderFormModel: () => void;
removeModelProviderFormModel: (index: number) => void;
saveModelProviderForm: () => Promise<void>;
closeModelProviderForm: () => void;

Step 2 — Add config helpers in ui/src/ui/controllers/config.ts

Add exported helper functions so the view does not manually mutate nested config JSON.

Suggested helper names:

export function listConfiguredModelProviders(state: ConfigState): ConfiguredModelProviderSummary[];
export function stageModelProviderConfig(state: ConfigState, input: StageModelProviderInput): void;
export function removeModelProviderConfig(state: ConfigState, providerId: string): void;

Suggested input types:

export type StageModelProviderInput = {
  providerId: string;
  displayName?: string;
  baseUrl?: string;
  api?: string;
  apiKey?: string;
  apiKeyEnvVar?: string;
  secretMode: "plaintext" | "env-ref" | "none";
  modelIds: string[];
  capabilities: Array<"text" | "image" | "embedding">;
  setAsPrimary?: boolean;
  setAsFallback?: boolean;
  setAsVision?: boolean;
  setAsEmbedding?: boolean;
};

export type ConfiguredModelProviderSummary = {
  id: string;
  name?: string;
  baseUrl?: string;
  api?: string;
  modelIds: string[];
  hasApiKey: boolean;
};

The helper should stage the equivalent of this config structure:

{
  "models": {
    "providers": {
      "my-provider": {
        "baseURL": "https://api.example.com/v1",
        "apiKey": "... or SecretRef ..."
      }
    },
    "entries": [
      {
        "id": "my-provider/model-a",
        "provider": "my-provider",
        "model": "model-a",
        "api": "openai-completions",
        "input": ["text", "image"]
      }
    ]
  }
}

Important details:

  • Preserve existing providers and existing model entries.
  • Upsert provider config by provider id.
  • Upsert model entries by id.
  • Do not duplicate model entries if a user edits the same provider twice.
  • For SecretRef mode, write something like:
apiKey: {
  source: "env",
  provider: "default",
  id: input.apiKeyEnvVar,
}
  • For plaintext mode, write apiKey: input.apiKey.
  • For no-key local providers, omit apiKey.
  • For local providers, store baseURL if provided.
  • If setAsPrimary is true, update the same config path that existing model selection uses for the primary/default model. If unsure, reuse existing helpers from the model picker/config code rather than inventing a new path.

Pseudo-code:

export function stageModelProviderConfig(state: ConfigState, input: StageModelProviderInput): void {
  mutateConfigForm(state, (draft) => {
    const models = ensureObject(draft, "models");
    const providers = ensureObject(models, "providers");
    const entries = ensureArray(models, "entries");

    providers[input.providerId] = {
      ...(isObject(providers[input.providerId]) ? providers[input.providerId] : {}),
      ...(input.displayName ? { name: input.displayName } : {}),
      ...(input.baseUrl ? { baseURL: input.baseUrl } : {}),
      ...(input.secretMode === "plaintext" && input.apiKey ? { apiKey: input.apiKey } : {}),
      ...(input.secretMode === "env-ref" && input.apiKeyEnvVar
        ? { apiKey: { source: "env", provider: "default", id: input.apiKeyEnvVar } }
        : {}),
    };

    for (const modelId of input.modelIds.map((id) => id.trim()).filter(Boolean)) {
      const entryId = `${input.providerId}/${modelId}`;
      const existingIndex = entries.findIndex((entry) => isObject(entry) && entry.id === entryId);
      const nextEntry = {
        ...(existingIndex >= 0 && isObject(entries[existingIndex]) ? entries[existingIndex] : {}),
        id: entryId,
        provider: input.providerId,
        model: modelId,
        ...(input.api ? { api: input.api } : {}),
        input: input.capabilities.filter((cap) => cap !== "embedding"),
      };
      if (existingIndex >= 0) {
        entries[existingIndex] = nextEntry;
      } else {
        entries.push(nextEntry);
      }
    }

    if (input.setAsPrimary && input.modelIds[0]) {
      setPathValue(draft, ["agents", "defaults", "model"], `${input.providerId}/${input.modelIds[0]}`);
    }

    if (input.setAsVision && input.modelIds[0]) {
      setPathValue(draft, ["imageModel", "primary"], `${input.providerId}/${input.modelIds[0]}`);
    }

    if (input.setAsEmbedding && input.modelIds[0]) {
      setPathValue(draft, ["memorySearch", "provider"], input.providerId);
      setPathValue(draft, ["memorySearch", "model"], input.modelIds[0]);
    }
  });
}

Helper utilities needed inside controllers/config.ts:

function isObject(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function ensureObject(parent: Record<string, unknown>, key: string): Record<string, unknown> {
  const current = parent[key];
  if (isObject(current)) {
    return current;
  }
  const next: Record<string, unknown> = {};
  parent[key] = next;
  return next;
}

function ensureArray(parent: Record<string, unknown>, key: string): unknown[] {
  const current = parent[key];
  if (Array.isArray(current)) {
    return current;
  }
  const next: unknown[] = [];
  parent[key] = next;
  return next;
}

Step 3 — Add a provider management view

Create a new view file:

ui/src/ui/views/model-providers.ts

Suggested exported renderer:

export function renderModelProvidersView(state: AppViewState) {
  // list providers from state.configForm or state.configSnapshot.config
  // render provider cards
  // render Add provider button
  // render modal/wizard when state.modelProviderFormOpen is true
}

Suggested UI sections:

  • Header: Model providers
  • Description: Add multiple providers and models without editing JSON.
  • Provider cards:
    • provider id/name
    • base URL
    • number of models
    • key configured / env ref / no key
    • buttons: Edit, Remove, Test
  • Add provider button
  • Modal/wizard:
    • Step 1: preset/provider type
    • Step 2: credentials/base URL
    • Step 3: model IDs and capabilities
    • Step 4: role assignment
    • Save button

Keep the first PR simple: a single form modal is enough; a multi-step wizard can be follow-up if needed.

Step 4 — Wire the view into Dashboard navigation

Depending on the current navigation structure, either:

  1. Add a new tab named models / providers, or
  2. Add a new card under the existing config/settings area.

The lowest-risk path is probably to render it inside the existing settings/config area first, because config.ts already owns the save/apply flow.

Search targets for a bot:

  • ui/src/ui/navigation.ts
  • ui/src/ui/app-render.ts
  • existing config/settings render functions under ui/src/ui/views/

Step 5 — Add handlers in ui/src/ui/app.ts

Add methods to OpenClawApp:

openModelProviderCreateForm() {
  this.modelProviderForm = createDefaultModelProviderForm();
  this.modelProviderFormOpen = true;
}

openModelProviderEditForm(providerId: string) {
  this.modelProviderForm = createModelProviderFormFromConfig(this, providerId);
  this.modelProviderFormOpen = true;
}

updateModelProviderForm(patch: Partial<ModelProviderFormState>) {
  this.modelProviderForm = this.modelProviderForm
    ? { ...this.modelProviderForm, ...patch }
    : null;
}

async saveModelProviderForm() {
  if (!this.modelProviderForm) return;
  stageModelProviderConfig(this, mapFormToStageInput(this.modelProviderForm));
  const saved = await saveConfig(this);
  if (saved) {
    this.modelProviderActionMessage = { kind: "success", text: "Provider saved." };
    this.modelProviderFormOpen = false;
    this.modelProviderForm = null;
  }
}

Because OpenClawApp already acts as the state object for config controller helpers, these methods can call stageModelProviderConfig(this, ...) and saveConfig(this) if the helper types are compatible.

Step 6 — Add tests

Recommended tests:

  1. ui/src/ui/controllers/config.test.ts

    • stageModelProviderConfig creates models.providers.<id>.
    • It adds multiple models.entries for multiple model IDs.
    • It upserts instead of duplicating existing entries.
    • It writes env SecretRef when secretMode === "env-ref".
    • It omits apiKey for local/no-key provider.
    • It can set primary and vision/embedding roles.
  2. ui/src/ui/views/model-providers.test.ts

    • Add provider button renders.
    • Existing providers render as cards.
    • Multiple model IDs render.
    • Save button calls the save handler.
  3. Optional browser/e2e test:

    • Open dashboard.
    • Open Models/Providers screen.
    • Add a custom OpenAI-compatible provider with two models.
    • Save.
    • Reload config.
    • Confirm both model entries exist.

Acceptance criteria

  • Dashboard has a clear place to manage model providers.
  • User can add at least two providers from the Dashboard without editing raw JSON.
  • User can add multiple model IDs for one provider from one form.
  • Existing provider config is preserved when adding another provider.
  • Existing model entries are not duplicated on edit.
  • API keys can be stored as env SecretRef, not only plaintext.
  • Local/no-key providers are supported.
  • User can select primary model from the Dashboard flow.
  • Documentation is updated in docs/web/control-ui.md.

Notes / risks

  • Avoid introducing a new backend config endpoint unless necessary. The existing config.get, config.set, and config.apply flow already supports draft editing with baseHash protection.
  • Avoid storing secrets in local browser storage.
  • Do not print API keys back into the UI after save. Show only configured, env ref, or not configured.
  • Keep the first PR focused on provider/model CRUD. Advanced routing/fallback policy editing can be a separate PR.

Related issue

Related to #81960, but this request focuses on Dashboard UX rather than CLI onboarding.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix [Feature]: Add a simple Dashboard UX to manage multiple model providers