diff --git a/tai.py b/tai.py index e3cc0e5..21259bf 100755 --- a/tai.py +++ b/tai.py @@ -151,6 +151,7 @@ POLLINATIONS_BASE = "https://text.pollinations.ai/openai/v1" POLLINATIONS_MODELS = ("openai",) PROVIDER_LABELS = (MUSE_LABEL, GROK_LABEL, CLAUDE_LABEL, CODEX_LABEL, GEMINI_LABEL, OPENCODE_LABEL, OLLAMA_LABEL, OPENROUTER_LABEL, POLLINATIONS_LABEL, ZEN_LABEL, DEVPLACE_LABEL) MENTION_AUTO = "auto" +MENTION_ROUTES = {"molodetz": (DEVPLACE_LABEL, PREFERRED_MODEL)} DIR_DEFAULTS_CAP = 512 OPENCODE_USER_AGENT = "opencode/1.18.29 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.15" OPENCODE_CLIENT_NAME = "cli" @@ -384,6 +385,8 @@ def resolve_tag(tag, agent, directory): return ("bot", target) if lowered in PROVIDER_LABELS: return ("provider", lowered) + if lowered in MENTION_ROUTES: + return ("route", MENTION_ROUTES[lowered]) for backend in agent.config.backends or []: if ((backend or {}).get("label") or "").strip().lower() == lowered: return ("provider", lowered) @@ -1011,6 +1014,35 @@ def tool_name_clean(raw_name): return str(raw_name or "").split("<|", 1)[0].strip() +TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$") +PERMANENT_STATUSES = (400, 401, 402, 403, 404, 405, 410, 413, 422) + + +def clean_history(messages): + """Copy of messages that strict backends accept: tool calls with an invalid name + are dropped together with their results, and an assistant turn left empty goes.""" + dropped = set() + kept = [] + for message in messages: + calls = message.get("tool_calls") if message.get("role") == "assistant" else None + if calls: + good = [call for call in calls if TOOL_NAME_RE.match(str((call.get("function") or {}).get("name") or ""))] + dropped.update(call.get("id") for call in calls if call not in good) + if len(good) != len(calls): + message = dict(message, tool_calls=good or None) + if not good and not message.get("content"): + continue + kept.append(message) + return [message for message in kept if not (message.get("role") == "tool" and message.get("tool_call_id") in dropped)] + + +def transient_failure(exc): + """True when asking again later can succeed: timeouts, rate limits, 5xx, network.""" + if getattr(exc, "status", 0) in PERMANENT_STATUSES: + return False + return "CLI not found" not in str(exc) + + def tool_args_text(raw_arguments): if not raw_arguments: return "{}" @@ -5108,6 +5140,7 @@ class ChatClient: self.scope = ("default", "main") self.fixed_key = None self.provider_pin = None + self.round_transient = False self.pin_fallback_said = False self.model_pin = None self.prefer = (DEVPLACE_LABEL, PREFERRED_MODEL) @@ -5258,6 +5291,9 @@ class ChatClient: pinned = [cand for cand in pinned if (cand["provider"] or "").lower() == self.provider_pin] if self.model_pin: pinned = [cand for cand in pinned if cand["model"] == self.model_pin] + if not pinned and self.provider_pin == DEVPLACE_LABEL and self.model_pin and getattr(self.config, "devplace_key", ""): + base = self.config.devplace_base + pinned = [{"provider": DEVPLACE_LABEL, "chat_url": base + "/chat/completions", "models_url": base + "/models", "key": self.config.devplace_key, "model": self.model_pin, "profile": ""}] if not pinned and cands and not self.pin_fallback_said: self.pin_fallback_said = True print(paint("%s: trying every other backend" % self.pin_note(), Ansi.DIM), file=sys.stderr) @@ -5324,6 +5360,7 @@ class ChatClient: return text[:width] def complete(self, messages, tools=None, stream_sink=None, ephemeral=False, deadline=None): + messages = clean_history(messages) payload = {"model": self.config.model, "messages": messages} if tools: payload["tools"] = tools @@ -5335,6 +5372,7 @@ class ChatClient: round_no = 0 while True: errors = [] + self.round_transient = False cands = self.ordered_candidates() if round_no == 0 else self.all_candidates() if not cands: raise BackendError(self.pin_note()) @@ -5342,6 +5380,8 @@ class ChatClient: reply = self.try_candidate(cand, messages, tools, payload, stream_sink, ephemeral, errors) if reply is not None: return reply + if not self.round_transient: + raise BackendError("no backend can take this request, retrying will not help (%s)" % self.summarize_errors(errors, shown=6, width=900)) pause = RETRY_BACKOFF[min(round_no, len(RETRY_BACKOFF) - 1)] round_no += 1 if give_up is not None and time.time() + pause > give_up: @@ -5446,10 +5486,12 @@ class ChatClient: if pos == 0 and exc.status == 400 and len(tries) > 1: continue self.health.record(label, model, False) + self.round_transient = self.round_transient or transient_failure(exc) errors.append("%s/%s: %s" % (label, model, exc)) return None except Exception as exc: self.health.record(label, model, False) + self.round_transient = True errors.append("%s/%s: %s" % (label, model, short_error(exc))) return None return None @@ -15155,6 +15197,9 @@ class Agent: if kind == "file": files.append(value) continue + if kind == "route": + selectors["provider"], selectors["model"] = value + continue selectors[kind] = value if cleared or selectors["provider"] is not None: self.chat.provider_pin = selectors["provider"] @@ -15254,6 +15299,7 @@ class Agent: print(paint("(via %s)" % reply["backend"], Ansi.DIM)) if loud and reply["reasoning"] and not reply["content"] and not reply["tool_calls"]: print(paint(reply["reasoning"][:2000], Ansi.GRAY)) + reply["tool_calls"] = [call for call in reply["tool_calls"] if TOOL_NAME_RE.match(call["name"])] history_calls = [{"id": call["id"], "type": "function", "function": {"name": call["name"], "arguments": call["arguments"]}} for call in reply["tool_calls"]] self.messages.append({"role": "assistant", "content": reply["content"] or None, "tool_calls": history_calls or None}) if not reply["tool_calls"]: @@ -16670,8 +16716,12 @@ def apply_dir_defaults(agent, directory=None): return "" bits = [] if saved.get("provider"): - agent.chat.provider_pin = saved["provider"] - bits.append("provider=%s" % saved["provider"]) + offered = set((cand["provider"] or "").lower() for cand in agent.chat.candidates()) + if saved["provider"] in offered: + agent.chat.provider_pin = saved["provider"] + bits.append("provider=%s" % saved["provider"]) + else: + print(paint("dir default provider '%s' is not available here, ignoring it (@auto clears it)" % saved["provider"], Ansi.DIM), file=sys.stderr) if saved.get("model") and not getattr(agent.config, "explicit_model", ""): agent.chat.model_pin = saved["model"] bits.append("model=%s" % saved["model"]) diff --git a/test_tai.py b/test_tai.py index 1f704a9..b2449e5 100644 --- a/test_tai.py +++ b/test_tai.py @@ -2305,6 +2305,7 @@ class MentionTests(unittest.TestCase): self.assertEqual(tai.resolve_tag("opencode", self.agent, os.getcwd()), ("provider", "opencode")) self.assertEqual(tai.resolve_tag("mimo-v2.6-flash-free", self.agent, os.getcwd()), ("model", "mimo-v2.6-flash-free")) self.assertEqual(tai.resolve_tag("auto", self.agent, os.getcwd()), ("auto", "auto")) + self.assertEqual(tai.resolve_tag("molodetz", self.agent, os.getcwd()), ("route", ("devplace", "molodetz"))) kind, value = tai.resolve_tag("note.txt", self.agent, os.getcwd()) self.assertEqual(kind, "file") self.assertTrue(value.endswith("note.txt")) @@ -4734,10 +4735,11 @@ class RetrySummaryTests(unittest.TestCase): calls.append(cand["provider"]) if len(calls) > len(cands): return {"role": "assistant", "content": "ok", "reasoning": "", "tool_calls": [], "backend": cand["provider"]} + chat.round_transient = True if cand["provider"] == "muse": errors.append("muse/muse: muse CLI not found (MUSE_BIN or PATH)") else: - errors.append("openrouter/m1: HTTP 401: No auth credentials found") + errors.append("openrouter/m1: HTTP 503: No auth credentials found") return None with mock.patch.object(tai.ChatClient, "ordered_candidates", return_value=cands), \ @@ -4753,7 +4755,7 @@ class RetrySummaryTests(unittest.TestCase): os.environ.pop("TAI_HOME", None) out = err.getvalue() self.assertIn("all 2 candidates across 2 backends failed (round 1)", out) - self.assertIn("openrouter/m1: HTTP 401", out) + self.assertIn("openrouter/m1: HTTP 503", out) self.assertNotIn("retrying in 2s: muse/muse", out) def test_complete_give_up_error_summarizes(self): @@ -4773,7 +4775,8 @@ class RetrySummaryTests(unittest.TestCase): ] def fake_try(cand, messages, tools, payload, stream_sink, ephemeral, errors): - errors.append("%s/%s: %s" % (cand["provider"], cand["model"], "muse CLI not found (MUSE_BIN or PATH)" if cand["provider"] == "muse" else "HTTP 401: No auth")) + chat.round_transient = True + errors.append("%s/%s: %s" % (cand["provider"], cand["model"], "muse CLI not found (MUSE_BIN or PATH)" if cand["provider"] == "muse" else "HTTP 503: No auth")) return None with mock.patch.object(tai.ChatClient, "ordered_candidates", return_value=cands), \ @@ -4785,6 +4788,27 @@ class RetrySummaryTests(unittest.TestCase): self.assertIn("after 1 rounds", str(raised.exception)) self.assertTrue(str(raised.exception).index("openrouter/m1") < str(raised.exception).index("missing CLI skipped"), str(raised.exception)) + def test_permanent_failures_fail_fast(self): + self.assertFalse(tai.transient_failure(tai.BackendError("HTTP 400: bad name", 400))) + self.assertFalse(tai.transient_failure(tai.BackendError("muse CLI not found (MUSE_BIN or PATH)"))) + self.assertTrue(tai.transient_failure(tai.BackendError("HTTP 503: down", 503))) + self.assertTrue(tai.transient_failure(tai.BackendError("timed out"))) + + def test_clean_history_drops_nameless_calls_and_results(self): + history = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "a", "type": "function", "function": {"name": "", "arguments": "{}"}}, + {"id": "b", "type": "function", "function": {"name": "shell", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "a", "content": "x"}, + {"role": "tool", "tool_call_id": "b", "content": "y"}, + ] + cleaned = tai.clean_history(history) + self.assertEqual([m["role"] for m in cleaned], ["user", "assistant", "tool"]) + self.assertEqual(len(cleaned[1]["tool_calls"]), 1) + self.assertEqual(cleaned[2]["tool_call_id"], "b") + self.assertEqual(len(history[1]["tool_calls"]), 2) + class TuiLayoutTests(unittest.TestCase): def make_app(self, rows=10, cols=20):