Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,11 @@ def init_agent(
"""
_install_safe_stdio()

agent.model = model
# Defensive: strip accidentally duplicated model-name prefix (see #54511).
from agent.chat_completion_helpers import _dedupe_model_name
agent.model = _dedupe_model_name(model)
import _sys as _sys_mod
print(f"[INIT_TRACE] entry: model={model!r} deduped={agent.model!r} provider={provider!r} base_url={base_url!r}", file=_sys_mod.stderr, flush=True)
agent.max_iterations = max_iterations
# Shared iteration budget — parent creates, children inherit.
# Consumed by every LLM turn across parent + all subagents.
Expand Down
10 changes: 9 additions & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,8 @@ def restore_primary_runtime(agent) -> bool:
# ── Reset fallback chain for the new turn ──
agent._fallback_activated = False
agent._fallback_index = 0
agent._primary_model = ""
agent._primary_provider = ""

# Undo the fallback's identity rewrite so the prompt is
# byte-identical to the stored copy again (prefix cache match).
Expand Down Expand Up @@ -1580,7 +1582,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent._config_context_length = None

# ── Swap core runtime fields ──
agent.model = new_model
# Defensive: strip accidentally duplicated model-name prefix
# (see #54511). The /model command can produce doubled names like
# "gemma4:gemma4:e2b" from alias resolution; dedupe before storing.
from agent.chat_completion_helpers import _dedupe_model_name
agent.model = _dedupe_model_name(new_model)
agent.provider = new_provider
# Use new base_url when provided; only fall back to current when the
# new provider genuinely has no endpoint (e.g. native SDK providers).
Expand Down Expand Up @@ -1774,6 +1780,8 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Reset fallback state ──
agent._fallback_activated = False
agent._fallback_index = 0
agent._primary_model = ""
agent._primary_provider = ""

# When the user deliberately swaps primary providers (e.g. openrouter
# → anthropic), drop any fallback entries that target the OLD primary
Expand Down
40 changes: 40 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,9 +586,40 @@ def _call():



def _dedupe_model_name(name: str) -> str:
"""Strip accidentally duplicated provider/model prefixes.

Ollama model tags like ``gemma4:e2b`` can get doubled to
``gemma4:gemma4:e2b`` by an upstream bug (#54511). A doubled tag is
an invalid model name and the API call fails with HTTP 400.

Handles one or more duplications: ``a:a:a:b`` → ``a:b``.
"""
if not name or ":" not in name:
return name
parts = name.split(":")
# Walk from the left, collapsing runs where part[i] == part[i+1].
deduped = [parts[0]]
for p in parts[1:]:
if p != deduped[-1]:
deduped.append(p)
# If the result still has the pattern ``prefix:prefix:model`` after
# adjacent-collapse (e.g. ``gemma4:gemma4:e2b``), also strip a leading
# duplicate of the final segment's prefix.
result = ":".join(deduped)
if ":" in result:
head, _, tail = result.rpartition(":")
if head.endswith(":" + tail) or head == tail:
result = tail
return result


def build_api_kwargs(agent, api_messages: list) -> dict:
"""Build the keyword arguments dict for the active API mode."""
tools_for_api = agent.tools
# Defensive: strip any accidentally duplicated model-name prefix
# (see #54511). This is a no-op for well-formed names.
agent.model = _dedupe_model_name(agent.model)

if agent.api_mode == "anthropic_messages":
_transport = agent._get_transport()
Expand Down Expand Up @@ -1247,6 +1278,13 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool

old_model = agent.model

# Remember the primary (original) model/provider so the UI can show
# "Fallback: X (primary: Y)" — stored once, before the swap.
if not getattr(agent, "_primary_model", ""):
agent._primary_model = getattr(agent, "model", "") or ""
if not getattr(agent, "_primary_provider", ""):
agent._primary_provider = getattr(agent, "provider", "") or ""

# Clear the per-config context_length override so the fallback
# model's actual context window is resolved instead of inheriting
# the stale value from the previous model. See #22387.
Expand Down Expand Up @@ -2190,6 +2228,7 @@ def _call_chat_completions():
content=full_content,
tool_calls=None,
reasoning_content=full_reasoning,
reasoning=full_reasoning,
)
mock_choice = SimpleNamespace(
index=0,
Expand All @@ -2214,6 +2253,7 @@ def _call_chat_completions():
content=full_content,
tool_calls=mock_tool_calls,
reasoning_content=full_reasoning,
reasoning=full_reasoning,
)
mock_choice = SimpleNamespace(
index=0,
Expand Down
7 changes: 7 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -4361,6 +4361,13 @@ def _perform_api_call(next_api_kwargs):
else:
# No tool calls - this is the final response
final_response = assistant_message.content or ""
# Ollama + gemma4 puts vision/multimodal output in `reasoning`
# instead of `content`. Fall back to reasoning when content is
# empty but reasoning carries the actual answer.
if not final_response.strip():
_ollama_reasoning = getattr(assistant_message, "reasoning", None)
if isinstance(_ollama_reasoning, str) and _ollama_reasoning.strip():
final_response = _ollama_reasoning.strip()

# Fix: unmute output when entering the no-tool-call branch
# so the user can see empty-response warnings and recovery
Expand Down
25 changes: 18 additions & 7 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1837,13 +1837,24 @@ def get_model_context_length(
key=lambda x: len(x[0]),
reverse=True,
):
if default_model in model_lower:
logger.info(
"Using hardcoded context length %s for model %r "
"(custom endpoint, catalog match on %r)",
f"{length:,}", model, default_model,
)
return length
dm_lower = default_model.lower()
idx = model_lower.find(dm_lower)
if idx == -1:
continue
# Reject mid-token matches: "gemma" inside "gemma4" is not
# a real match. The char after the catalog key must be a
# delimiter (colon, dash, dot, etc.) or end-of-string.
end = idx + len(dm_lower)
if end < len(model_lower):
next_ch = model_lower[end]
if next_ch.isalnum() or next_ch == "_":
continue
logger.info(
"Using hardcoded context length %s for model %r "
"(custom endpoint, catalog match on %r)",
f"{length:,}", model, default_model,
)
return length
return DEFAULT_FALLBACK_CONTEXT

# 4. Anthropic /v1/models API (only for regular API keys, not OAuth)
Expand Down
23 changes: 21 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3469,6 +3469,24 @@ def _resolve_session_agent_runtime(
override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None
if override:
override_model = override.get("model", model)
# Defense-in-depth: validate override model name before use.
# A corrupted override (doubled prefix, truncated tag) poisons
# every subsequent rebuild until /new. Dedupe silently so a
# bad override still falls through to config rather than
# silently breaking the session. See #54511.
try:
from agent.chat_completion_helpers import _dedupe_model_name
_validated = _dedupe_model_name(override_model)
if _validated != override_model:
logger.warning(
"Session override model name corrected: %r -> %r (session=%s)",
override_model, _validated, resolved_session_key,
)
override_model = _validated
# Patch the override in-place so subsequent turns are clean
override["model"] = _validated
except Exception:
pass
override_runtime = {
"provider": override.get("provider"),
"api_key": override.get("api_key"),
Expand Down Expand Up @@ -16026,8 +16044,8 @@ def run_sync():
session_key=session_key,
user_config=user_config,
)
logger.debug(
"run_agent resolved: model=%s provider=%s session=%s",
logger.warning(
"DBG_RESOLVED model=%s provider=%s session=%s",
model, runtime_kwargs.get("provider"), session_key or "",
)
except Exception as exc:
Expand Down Expand Up @@ -16267,6 +16285,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:

if agent is None:
# Config changed or first message — create fresh agent
logger.warning("DBG_AGENT_CREATE model=%s sig=%s cached=%s", turn_route["model"], _sig, reused_cached_agent)
agent = AIAgent(
model=turn_route["model"],
**turn_route["runtime"],
Expand Down
18 changes: 18 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,24 @@ async def _finish_switch() -> str:
f"Adjust your self-identification accordingly.]"
)

# Validate model name before storing override — a corrupted name
# (doubled prefix like "gemma4:gemma4:e2b" or truncated like "e2b")
# would poison every subsequent turn until /new or manual DB edit.
# See #54511.
_validated_model = result.new_model
try:
from agent.chat_completion_helpers import _dedupe_model_name
_validated_model = _dedupe_model_name(result.new_model)
except Exception:
pass # dedupe is best-effort; fall back to raw name
if _validated_model != result.new_model:
logger.warning(
"Session override model name corrected: %r -> %r",
result.new_model,
_validated_model,
)
result.new_model = _validated_model

# Store session override so next agent creation uses the new model
self._session_model_overrides[session_key] = {
"model": result.new_model,
Expand Down
1 change: 1 addition & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ def _ensure_db_session(self) -> None:
return
source = _session_source_for_agent(self.platform)
try:
logger.warning("DBG_DB_CREATE session=%s model=%s provider=%s", self.session_id, self.model, self.provider)
self._session_db.create_session(
session_id=self.session_id,
source=source,
Expand Down
3 changes: 3 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3085,6 +3085,9 @@ def _session_info(agent, session: dict | None = None) -> dict:
info: dict = {
"model": getattr(agent, "model", ""),
"provider": getattr(agent, "provider", ""),
"fallback_activated": bool(getattr(agent, "_fallback_activated", False)),
"primary_model": getattr(agent, "_primary_model", "") or "",
"primary_provider": getattr(agent, "_primary_provider", "") or "",
"reasoning_effort": reasoning_effort,
"service_tier": service_tier,
"fast": service_tier == "priority",
Expand Down
71 changes: 54 additions & 17 deletions web/src/components/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ interface SessionInfo {
cwd?: string;
model?: string;
provider?: string;
fallback_activated?: boolean;
primary_model?: string;
primary_provider?: string;
credential_warning?: string;
title?: string;
}
Expand Down Expand Up @@ -303,8 +306,21 @@ export function ChatSidebar({

// The picker writes config.yaml over REST and reloads — it doesn't ride the
// sidecar gateway session, so it's available whenever the sidebar is mounted.
const modelName = effectiveModel || info.model || "—";
//
// When fallback is active, prefer the runtime model from the gateway WS
// (info.model) over the config-API value (effectiveModel) so the badge
// reflects what's actually generating responses.
const fallbackActive = !!info.fallback_activated;
const modelName = fallbackActive
? info.model || effectiveModel || "—"
: effectiveModel || info.model || "—";
const modelLabel = modelName.split("/").slice(-1)[0] ?? "—";
const primaryLabel =
(info.primary_model || info.primary_provider) &&
fallbackActive &&
info.model !== info.primary_model
? `${(info.primary_model || "").split("/").slice(-1)[0] || info.primary_provider || ""}`
: null;
const banner = error ?? info.credential_warning ?? null;

return (
Expand All @@ -320,23 +336,44 @@ export function ChatSidebar({
model
</div>

<Button
ghost
size="sm"
onClick={() => setModelOpen(true)}
className={cn(
"max-w-full min-w-0 px-0 py-0",
"self-start normal-case tracking-normal text-sm font-medium",
"hover:underline disabled:no-underline",
<div className="flex items-center gap-1.5">
<Button
ghost
size="sm"
onClick={() => setModelOpen(true)}
className={cn(
"max-w-full min-w-0 px-0 py-0",
"self-start normal-case tracking-normal text-sm font-medium",
"hover:underline disabled:no-underline",
)}
title={
fallbackActive && primaryLabel
? `Fallback: ${modelName} (primary: ${info.primary_model || info.primary_provider || "—"})`
: modelName === "—"
? "switch model"
: modelName
}
>
<span className="flex min-w-0 max-w-full items-center gap-1">
<span className="truncate">{modelLabel}</span>

<ChevronDown className="size-3.5 shrink-0 text-text-secondary" />
</span>
</Button>

{fallbackActive && (
<span
className="shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"
title={
primaryLabel
? `Fallback active — primary ${info.primary_model || info.primary_provider || "—"} unavailable`
: "Fallback active"
}
>
Fallback
</span>
)}
title={modelName === "—" ? "switch model" : modelName}
>
<span className="flex min-w-0 max-w-full items-center gap-1">
<span className="truncate">{modelLabel}</span>

<ChevronDown className="size-3.5 shrink-0 text-text-secondary" />
</span>
</Button>
</div>
</div>

<Badge tone={STATE_TONE[state]} className="shrink-0">
Expand Down