feat: detect installed coding agent CLIs in Studio settings (#6909)

* feat: detect installed coding agent CLIs in Studio settings

The API-keys panel only ever showed the "claude" flavor of the
`unsloth start` command, so anyone using Codex, OpenCode, OpenClaw,
Hermes, or Pi had to manually rewrite the copied command by hand.

Add a backend check that looks for each agent's CLI binary on PATH
(shutil.which, mirroring the pattern already used elsewhere in
studio/backend/utils) and expose it as GET /api/settings/coding-agents.
The API-keys panel now renders a picker for all six supported agents,
marks the ones it finds installed, and defaults to one of those instead
of always falling back to claude.

Includes unit tests for the detection helper.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* address review feedback on coding-agent detection

Three fixes from PR review:

- detect_installed_coding_agents now treats a PATH lookup failure as
  "not installed" instead of letting it bubble up and break the
  settings endpoint; added a regression test for it.
- CodingAgentsResponse.agents is now typed as an immutable tuple
  instead of a list built from one, matching CODING_AGENTS itself.
- Fixed a race in the API-keys panel: picking an agent while the
  installed-CLI check is still in flight could get silently overwritten
  once that check resolved. A ref now tracks whether the user has made
  a manual choice, so the auto-detected default only applies before
  that happens.

* Address Codex feedback: GGUF gating and remote-detection scope

- codex refuses to launch against a non-GGUF (transformers-backed) model
  (unsloth_cli's _require_gguf_for_codex), so auto-defaulting to it produced
  a copy-pasteable command that fails immediately whenever the loaded model
  isn't GGUF. Add useActiveModelIsGguf() (looks up the active checkpoint in
  the chat runtime store) and a correction effect that steers the auto-pick
  away from codex unless the loaded model qualifies, without ever touching a
  choice the user made by hand.
- Detection runs via shutil.which on the Studio backend host, which isn't
  the same machine as the browser in a tunnel/remote session. Reword the
  'installed'/'detected' copy to say so explicitly when the tunnel URL is
  in use, instead of implying the check ran on the viewer's own device.

* Rework auto-default per review: loopback gating + inline GGUF check

Replaces the previous approach with the exact shape discussed on the PR:

- Export isLoopbackHost/normalizeHost from agent-command.ts. The detection
  endpoint runs shutil.which on the Studio backend, which only describes the
  browser's own machine when the base this panel targets resolves to
  loopback. For a LAN or tunnel/remote base, gate the whole thing off --
  don't mark anything as "detected" and don't let it drive the default --
  instead of just relabeling the copy.
- Drop the separate GGUF-correction effect and useActiveModelIsGguf hook.
  Read useChatRuntimeStore.getState().activeGgufVariant inline inside the
  existing detection effect's .then() (so it doesn't need to sit in the
  effect's deps), and pick the first detected agent that isn't codex unless
  the loaded model is GGUF, leaving the existing default untouched when no
  compatible agent is detected.

Verified both branches (loopback vs LAN/tunnel base, gguf vs non-gguf,
manual pick preserved, no-compatible-agent fallback) with a standalone
port of the .then() logic.

* Address latest Codex findings: stale detection, model swap, cache

- Clear detectedAgents (and skip the network call entirely) when the panel
  leaves a loopback base, instead of leaving a previous loopback detection
  result marked 'installed' for a command that now targets a LAN/tunnel/
  remote host.
- Add a separate, network-free correction effect keyed on the live
  activeGgufVariant: if codex was auto-picked while a GGUF model was loaded
  and the user then switches to a transformers-backed model while this panel
  stays mounted, steer away from codex instead of leaving a command that
  unsloth_cli's _require_gguf_for_codex will now reject. Never touches a
  manual pick.
- Drop coding-agents.ts's module-lifetime cache. Installed-CLI detection is
  environment state, not a persisted setting, so a stale positive/negative
  from before the user installed something (or reopened the tab) is worse
  than one extra cheap local API call per mount; keep only the in-flight
  de-dupe for concurrent callers.

Verified the correction-effect logic (gguf->non-gguf swap with/without a
fallback, still-gguf no-op, manual pick never overridden) with a standalone
port of the effect.

* Make the codex/GGUF auto-pick symmetric in both directions

The correction effect only steered away from codex when the model stopped
being GGUF; it never steered back toward codex if the model became GGUF
*after* a non-GGUF-gated fallback had already picked something else (e.g.
codex is the only detected CLI, a transformers model is loaded so the
selection correctly falls back to the claude default, then the user loads a
GGUF model while the panel stays mounted -- codex never gets reconsidered).

Consolidate into one effect that re-derives the preferred detected agent
from scratch whenever detectedAgents or activeGgufVariant changes, in either
direction, instead of only reacting to the codex-specific downgrade case.
The fetch effect now only populates detectedAgents/availableAgents; this
effect is the single source of truth for what gets auto-picked from that
list. Never overrides a manual choice.

Verified both transition directions plus the manual-pick-survives and
initial-detection cases with a standalone port of the derivation logic.

* Reset the auto-pick to the default when it stops being trustworthy

Two more real gaps from the latest Codex pass on d988f52:

- The unified derivation effect only handled the case where a *different*
  detected agent could take over. If codex was the only detected agent and
  auto-picked while a GGUF model was loaded, then the model stopped being
  GGUF, 'preferred' came back undefined and the effect silently left the
  selection on codex -- exactly the command unsloth_cli's
  _require_gguf_for_codex now rejects. Fall back to DEFAULT_AGENT in that
  case instead of leaving it untouched.
- Leaving a loopback base cleared detectedAgents (so the 'installed' badges
  correctly disappear) but left whatever agent had been auto-picked from
  that now-stale, server-side-only detection still selected. Reset to
  DEFAULT_AGENT there too, unless the user picked by hand.

Introduces a shared DEFAULT_AGENT constant instead of repeating the "claude"
literal at each reset site. Verified all five cases (both new resets, both
manual-pick-survives variants, and the existing multi-detected-agent
fallback still preferring another compatible agent over resetting) with a
standalone port of the effects.

* Derive GGUF-ness from the actual loaded state, not just the variant string

activeGgufVariant only covers an HF-repo GGUF pick (a specific quant
variant string). A direct local .gguf file -- custom folder, LM
Studio, or drag-drop -- is just as much a GGUF the codex preflight
(unsloth_cli's _require_gguf_for_codex) would accept, but it never has
a "variant" to report, so it read as non-GGUF here even though
/api/inference/status correctly reports is_gguf: true for it. That
mismatch could leave a Codex-only install not auto-selected, or reset
an auto-picked Codex, for a model that actually supports it.

Combined activeGgufVariant with activeNativePathToken (covers the
drag-drop/picked-file case) and ggufContextLength (only ever populated
when the backend last reported is_gguf: true for the active model, see
applyActiveModelStatusToStore) so all three paths a model can be GGUF
through are covered, matching the same is_gguf-or-equivalent check
hasGgufSource already applies to a staged pick elsewhere in this
codebase.

* Clear stale native-path token on a non-GGUF status refresh

When a native (drag-dropped or picked) GGUF was loaded and the backend later
switches to a transformers model outside the UI load path, refresh() adopts the
new /api/inference/status via setCheckpoint and applyActiveModelStatusToStore.
Those reset activeGgufVariant and ggufContextLength but never clear
activeNativePathToken, so the isGguf OR stays true after the switch and a
Codex-only detection auto-selects unsloth start codex for a non-GGUF model its
preflight rejects.

Drop activeNativePathToken in applyActiveModelStatusToStore whenever the status
is non-GGUF. A real GGUF load reports is_gguf: true, so its token is preserved
(the load path owns it); only a non-GGUF status clears it.

* Add the AGPL-3.0 header to the new studio contract test

* Fix/adjust agent detection for PR #6909

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com>
This commit is contained in:
ErenAta16 2026-07-08 15:26:50 +03:00 committed by GitHub
parent 6ef0936180
commit e86b7874d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 361 additions and 17 deletions

View file

@ -32,6 +32,7 @@ from utils.helper_precache_settings import (
helper_model_disabled_by_env,
set_helper_precache_enabled,
)
from utils.coding_agents import CODING_AGENTS, detect_installed_coding_agents
from utils.openai_auto_switch_settings import (
DEFAULT_AUTO_UNLOAD_IDLE_SECONDS,
DEFAULT_OPENAI_AUTO_SWITCH_ENABLED,
@ -174,6 +175,19 @@ def update_helper_precache(
return _helper_precache_response(enabled)
class CodingAgentsResponse(BaseModel):
# All agents `unsloth start` supports, in the CLI's declared order.
agents: tuple[str, ...] = CODING_AGENTS
# Subset of `agents` whose CLI binary was found on PATH; the frontend uses
# this to default the API-keys panel to a command the user can run as-is.
detected: list[str]
@router.get("/coding-agents", response_model = CodingAgentsResponse)
def get_coding_agents(current_subject: str = Depends(get_current_subject)) -> CodingAgentsResponse:
return CodingAgentsResponse(detected = detect_installed_coding_agents())
@router.get("/openai-auto-switch", response_model = OpenAIAutoSwitchResponse)
def get_openai_auto_switch(
current_subject: str = Depends(get_current_subject),

View file

@ -0,0 +1,50 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for coding-agent CLI detection used by the API-keys settings panel."""
from unittest.mock import patch
from utils.coding_agents import CODING_AGENTS, detect_installed_coding_agents
def test_matches_unsloth_start_subcommands():
# Each entry must be an actual `unsloth start <agent>` subcommand name
# (unsloth_cli/commands/start.py). Spelled out here rather than imported
# from that module, which pulls in the CLI's heavier dependencies.
assert CODING_AGENTS == ("claude", "codex", "openclaw", "opencode", "hermes", "pi")
def test_detects_only_agents_present_on_path():
installed = {"claude", "opencode"}
with patch(
"utils.coding_agents.shutil.which",
side_effect = lambda name: f"/usr/bin/{name}" if name in installed else None,
):
assert detect_installed_coding_agents() == ["claude", "opencode"]
def test_returns_empty_list_when_nothing_is_installed():
with patch("utils.coding_agents.shutil.which", return_value = None):
assert detect_installed_coding_agents() == []
def test_preserves_declared_order_regardless_of_path_lookup_order():
with patch(
"utils.coding_agents.shutil.which",
side_effect = lambda name: name if name in ("pi", "claude", "hermes") else None,
):
assert detect_installed_coding_agents() == ["claude", "hermes", "pi"]
def test_treats_a_path_lookup_error_as_not_installed():
# An advisory check: shutil.which raising for one entry (e.g. a permission
# error walking a PATH directory) should not take down the whole endpoint,
# and should not stop the remaining agents from being checked.
def flaky_which(name: str):
if name == "codex":
raise OSError("permission denied")
return name if name == "claude" else None
with patch("utils.coding_agents.shutil.which", side_effect = flaky_which):
assert detect_installed_coding_agents() == ["claude"]

View file

@ -0,0 +1,39 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Detect which `unsloth start <agent>` coding-agent CLIs are on PATH.
The web UI only ever shows the user the "claude" flavor of the `unsloth start`
command (see agent-command.ts), leaving anyone using Codex, OpenCode, and the
other supported agents to manually edit the copied command. This module gives
the frontend a way to ask which of those CLIs are actually installed so it can
default to one the user can run immediately.
"""
import shutil
# Keep in sync with the `unsloth start <agent>` subcommands defined in
# unsloth_cli/commands/start.py. Each entry is the exact executable name that
# subcommand launches, so a hit here means `unsloth start <agent>` can find the
# binary on PATH without the user installing anything first.
CODING_AGENTS: tuple[str, ...] = ("claude", "codex", "openclaw", "opencode", "hermes", "pi")
def _is_on_path(agent: str) -> bool:
# shutil.which is documented to return None on a miss, but PATH lookups can
# still raise (e.g. a permission error while probing a directory entry);
# this is an advisory check, so a lookup failure should read as "not
# installed" instead of breaking the settings endpoint.
try:
return shutil.which(agent) is not None
except OSError:
return False
def detect_installed_coding_agents() -> list[str]:
"""Return the subset of CODING_AGENTS whose CLI binary is on PATH.
Order follows CODING_AGENTS, not discovery order, so callers can treat the
first entry as the preferred default among the installed agents.
"""
return [agent for agent in CODING_AGENTS if _is_on_path(agent)]

View file

@ -3,16 +3,19 @@
import { getInferenceStatus } from "../api/chat-api";
import { mergeBackendRecommendedInference } from "../presets/preset-policy";
import { clampReasoningEffortToLevels } from "../provider-capabilities";
import {
CHAT_REASONING_ENABLED_KEY,
loadOptionalBool,
type ReasoningEffort,
type ReasoningStyle,
loadOptionalBool,
resolveToolsEnabledOnLoad,
useChatRuntimeStore,
} from "../stores/chat-runtime-store";
import { isMultimodalResponse, type InferenceStatusResponse } from "../types/api";
import { clampReasoningEffortToLevels } from "../provider-capabilities";
import {
type InferenceStatusResponse,
isMultimodalResponse,
} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
type LocalReasoningEffort = Extract<ReasoningEffort, "low" | "medium" | "high">;
@ -31,7 +34,10 @@ export function normalizeSpeculativeType(
return "ngram";
}
if (s === "mtp+ngram") return "mtp+ngram";
const parts = s.split(",").map((p) => p.trim()).filter(Boolean);
const parts = s
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const hasMtp = parts.some((p) => p === "mtp" || p === "draft-mtp");
const hasNgram = parts.some(
(p) => p === "ngram" || p === "ngram-mod" || p === "ngram-simple",
@ -197,6 +203,12 @@ export function applyActiveModelStatusToStore(
ggufContextLength: currentGgufContextLength,
ggufMaxContextLength,
ggufNativeContextLength,
// A non-GGUF status must also drop a stale native-path token: without this the
// isGguf OR (activeGgufVariant || activeNativePathToken || ggufContextLength)
// stays true after switching from a native GGUF to a transformers model, so a
// Codex-only detection would auto-select for a model its preflight rejects. A real
// GGUF load reports is_gguf: true, so its token is preserved (the load path owns it).
...(status.is_gguf ? {} : { activeNativePathToken: null }),
modelRequiresTrustRemoteCode: status.requires_trust_remote_code ?? false,
defaultChatTemplate: nextDefaultChatTemplate,
loadedIsMultimodal: isMultimodalResponse(status),
@ -245,7 +257,7 @@ export function applyActiveModelStatusToStore(
const mid = checkpointId.toLowerCase();
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
if (sizeMatch && Number.parseFloat(sizeMatch[1]) < 9) {
reasoningDefault = false;
}
}
@ -281,8 +293,7 @@ export async function tryAdoptServerActiveModel(): Promise<boolean> {
}
// Re-check after the await: keep a checkpoint the user picked meanwhile.
const previousCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
const previousCheckpoint = useChatRuntimeStore.getState().params.checkpoint;
if (previousCheckpoint) {
return true;
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
export type CodingAgentsInfo = {
// Every agent `unsloth start` supports, in the CLI's declared order.
agents: string[];
// Subset of `agents` whose CLI binary was found on PATH by the backend.
detected: string[];
};
type ApiCodingAgentsInfo = {
agents: string[];
detected: string[];
};
// Which CLIs are on PATH is environment state, not a persisted setting -- it
// can change any time the user installs something new, so this only
// de-duplicates concurrent in-flight calls (e.g. React strict-mode's double
// mount) rather than caching the result across the module's lifetime. Every
// fresh call (each time a settings panel mounts) re-checks PATH for real.
let inFlightInfo: Promise<CodingAgentsInfo> | null = null;
function fromApi(info: ApiCodingAgentsInfo): CodingAgentsInfo {
return { agents: info.agents, detected: info.detected };
}
async function fetchCodingAgents(): Promise<CodingAgentsInfo> {
const res = await authFetch("/api/settings/coding-agents");
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to load installed coding agents"),
);
}
return fromApi(await res.json());
}
export async function loadCodingAgents(): Promise<CodingAgentsInfo> {
inFlightInfo ??= fetchCodingAgents().finally(() => {
inFlightInfo = null;
});
return inFlightInfo;
}

View file

@ -12,7 +12,7 @@ const DEFAULT_AGENT = "claude";
// URL.hostname brackets IPv6 literals (`new URL("http://[::1]:8888").hostname` is
// "[::1]"), so strip the brackets before matching the bare "::1" loopback rules below.
function normalizeHost(host: string): string {
export function normalizeHost(host: string): string {
const lower = host.toLowerCase();
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
}
@ -26,7 +26,7 @@ function isDefaultLocalHost(host: string): boolean {
}
// Match the CLI auto-mint rule (is_loopback_url): localhost, ::1, and all of 127.0.0.0/8.
function isLoopbackHost(host: string): boolean {
export function isLoopbackHost(host: string): boolean {
if (host === "localhost" || host === "::1") return true;
const octets = host.split(".");
return (

View file

@ -16,6 +16,7 @@ import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { useChatRuntimeStore } from "@/features/chat";
import { useT } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
@ -25,14 +26,15 @@ import {
InformationCircleIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { loadCodingAgents } from "../api/coding-agents";
import {
type OpenAIAutoSwitchSettings,
loadOpenAIAutoSwitchSettings,
updateOpenAIAutoSwitchSettings,
} from "../api/openai-auto-switch";
import { buildAgentCommand } from "./agent-command";
import { buildAgentCommand, isLoopbackHost, normalizeHost } from "./agent-command";
type ExampleType =
| "curl"
@ -114,6 +116,30 @@ const DOC_LINKS = [
{ label: "Hermes Agent", href: "https://unsloth.ai/docs/integrations/hermes-agent" },
];
// Falls back to this list until the backend's installed-CLI check resolves;
// kept in sync with the `unsloth start <agent>` subcommands and with
// CODING_AGENTS in studio/backend/utils/coding_agents.py.
const DEFAULT_AGENTS = [
"claude",
"codex",
"openclaw",
"opencode",
"hermes",
"pi",
];
// The agent selection resets to this whenever an auto-pick is no longer
// trustworthy (leaving loopback, or the only compatible detected agent
// stops being compatible) rather than lingering on a stale choice.
const DEFAULT_AGENT = "claude";
const AGENT_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
openclaw: "OpenClaw",
opencode: "OpenCode",
hermes: "Hermes",
pi: "Pi",
};
const j = (s: string): string => JSON.stringify(s);
const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
const psSingle = (s: string): string => s.replace(/'/g, "''");
@ -399,6 +425,17 @@ function useLoadedModelName(): string {
}, [checkpoint, ggufVariant]);
}
// Backend PATH detection is only safe in the desktop app, where the UI owns
// the local backend. A browser loopback URL may be an SSH/local port forward.
function canUseLocalAgentDetection(base: string): boolean {
if (!isTauri) return false;
try {
return isLoopbackHost(normalizeHost(new URL(base).hostname));
} catch {
return false;
}
}
const SHIKI_THEMES = [unslothLightTheme, unslothDarkTheme] as [
typeof unslothLightTheme,
typeof unslothDarkTheme,
@ -443,7 +480,18 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
const [copied, setCopied] = useState(false);
const [copiedUrl, setCopiedUrl] = useState(false);
const [copiedAgent, setCopiedAgent] = useState(false);
const [agent, setAgent] = useState<string>(DEFAULT_AGENT);
const [availableAgents, setAvailableAgents] =
useState<string[]>(DEFAULT_AGENTS);
const [detectedAgents, setDetectedAgents] = useState<string[]>([]);
// True once the user has picked an agent themselves; guards the detection
// effect below from clobbering that choice if it resolves afterward.
const agentPickedByUserRef = useRef(false);
const [useTunnel, setUseTunnel] = useState<boolean>(readUseTunnelPref);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const base =
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
const localAgentDetection = canUseLocalAgentDetection(base);
// null while loading; the same setting the General tab exposes (shared cache).
const [autoSwitch, setAutoSwitch] = useState<OpenAIAutoSwitchSettings | null>(
null,
@ -454,6 +502,78 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
void fetchDeviceType({ force: true });
}, []);
// Fetching is the only job of this effect: populate availableAgents/
// detectedAgents (or clear them). Which agent gets auto-picked from that
// list is derived separately below, so it can react to the loaded model
// changing too, not just a fresh fetch.
useEffect(() => {
// Browser loopback URLs can be SSH/local forwards, so only the desktop app
// may use backend PATH checks to mark or auto-pick local agents.
if (!localAgentDetection) {
setDetectedAgents([]);
// A previously auto-picked agent was only ever verified against the
// Studio backend's PATH, which is meaningless now that this panel no
// longer targets a loopback base -- don't leave it selected, but
// never touch a choice the user made by hand.
if (!agentPickedByUserRef.current) {
setAgent(DEFAULT_AGENT);
}
return;
}
let cancelled = false;
void loadCodingAgents()
.then((info) => {
if (cancelled) return;
setAvailableAgents(info.agents);
setDetectedAgents(info.detected);
})
.catch(() => {
// Best-effort: keep the default agent list and let the user pick manually.
});
return () => {
cancelled = true;
};
}, [localAgentDetection]);
// Single source of truth for the auto-picked agent, re-derived whenever
// the detected list or the loaded model's GGUF-ness changes -- in either
// direction. `codex` needs a GGUF model (unsloth_cli's
// _require_gguf_for_codex exits otherwise), so it's only preferred once
// the loaded model actually qualifies; loading a GGUF model *after* a
// non-GGUF-gated fallback picked something else re-steers back to codex
// just as loading a non-GGUF model steers away from it. Never overrides a
// choice the user made by hand.
// activeGgufVariant alone only covers an HF-repo GGUF pick (a specific
// quant variant string) -- a direct local .gguf file (custom folder /
// LM Studio / drag-drop) is just as much a GGUF the codex preflight would
// accept, but never has a "variant" to report, and would otherwise read as
// non-GGUF here. activeNativePathToken covers the drag-drop/picked-file
// case; ggufContextLength is only ever populated when the backend's
// /api/inference/status last reported is_gguf: true for the active model
// (see applyActiveModelStatusToStore), so together these three cover every
// path a model can be GGUF through, matching the same is_gguf-or-equivalent
// check hasGgufSource applies to a staged pick.
const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
const activeNativePathToken = useChatRuntimeStore((s) => s.activeNativePathToken);
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
useEffect(() => {
if (agentPickedByUserRef.current) return;
if (detectedAgents.length === 0) return;
const isGguf =
activeGgufVariant != null || activeNativePathToken != null || ggufContextLength != null;
const preferred = detectedAgents.find((a) => a !== "codex" || isGguf);
if (preferred) {
setAgent(preferred);
} else if (agent === "codex" && !isGguf) {
// codex was auto-picked while a GGUF model was active and it's the
// only detected agent; now that the model isn't GGUF anymore, nothing
// detected is actually runnable, so fall back to the default instead
// of leaving a codex command unsloth_cli will reject.
setAgent(DEFAULT_AGENT);
}
}, [agent, detectedAgents, activeGgufVariant, activeNativePathToken, ggufContextLength]);
useEffect(() => {
let cancelled = false;
void loadOpenAIAutoSwitchSettings()
@ -470,9 +590,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
const model = useLoadedModelName();
const key = apiKey || KEY_PLACEHOLDER;
const origin = typeof window !== "undefined" ? window.location.origin : "";
const base =
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
const autoSwitchOn = autoSwitch?.enabled ?? false;
const snippets = useMemo(
@ -481,8 +598,8 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
);
// Agent command must target the server the panel shows, not the :8888 default.
const agentCommand = useMemo(
() => buildAgentCommand(base, key, os),
[base, key, os],
() => buildAgentCommand(base, key, os, agent),
[base, key, os, agent],
);
const osAware = OS_AWARE[lang];
@ -710,6 +827,42 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
<span className="text-[11px] leading-snug text-muted-foreground">
{t("settings.apiKeys.codingAgentsHint")}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-1">
{availableAgents.map((id) => {
const installed = detectedAgents.includes(id);
const active = agent === id;
return (
<button
key={id}
type="button"
onClick={() => {
agentPickedByUserRef.current = true;
setAgent(id);
}}
aria-pressed={active}
title={
installed
? t("settings.apiKeys.codingAgentDetected")
: undefined
}
className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{AGENT_LABELS[id] ?? id}
{installed ? (
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-emerald-500"
/>
) : null}
</button>
);
})}
</div>
<div className="relative mt-0.5 min-w-0">
<code className="block min-w-0 overflow-x-auto rounded border border-border bg-muted/30 px-2 py-1.5 pr-14 font-mono text-[11px] text-foreground">
{agentCommand}
@ -727,7 +880,13 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
</button>
</div>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("settings.apiKeys.codingAgentsSwap")}
{detectedAgents.length > 0
? t("settings.apiKeys.codingAgentsDetectedHint", {
agents: detectedAgents
.map((id) => AGENT_LABELS[id] ?? id)
.join(", "),
})
: t("settings.apiKeys.codingAgentsSwap")}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">

View file

@ -445,6 +445,8 @@ export const en = {
codingAgentsHint:
"Launch a coding agent against this server. It uses the loaded model; a local server mints an API key automatically, a remote one includes it in the command.",
codingAgentsSwap: "Swap claude for codex, openclaw, opencode, hermes, or pi.",
codingAgentDetected: "Installed on this machine",
codingAgentsDetectedHint: "Detected on this machine: {agents}.",
relativeNever: "never",
relativeJustNow: "just now",
relativeHoursAgo: "{count}h ago",

View file

@ -1,3 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Static contract for the chat response-details action and metadata."""
from __future__ import annotations

View file

@ -0,0 +1,21 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Static contract for API usage-example agent detection scope."""
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
USAGE_EXAMPLES_TSX = REPO / "studio/frontend/src/features/settings/components/usage-examples.tsx"
def test_agent_detection_requires_desktop_scope():
src = USAGE_EXAMPLES_TSX.read_text(encoding = "utf-8")
assert 'import { isTauri } from "@/lib/api-base"' in src
assert "function canUseLocalAgentDetection(base: string): boolean" in src
helper = src[src.find("function canUseLocalAgentDetection") : src.find("const SHIKI_THEMES")]
assert "if (!isTauri) return false" in helper
assert "isLoopbackHost(normalizeHost(new URL(base).hostname))" in helper
assert "const localAgentDetection = canUseLocalAgentDetection(base)" in src
assert "if (!localAgentDetection)" in src
assert "}, [localAgentDetection]);" in src