diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-23 16:48:15 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-23 16:48:15 +0200 |
| commit | 5b42682187b78f565bf110f416980c5233075e56 (patch) | |
| tree | 8327a4f9a96fdf29d3bd80775f4e9a2cfe0117f2 | |
| parent | ab7599c49f8933dda8f06a86124e3917163cda28 (diff) | |
| download | desktop-assistant-5b42682187b78f565bf110f416980c5233075e56.tar.gz desktop-assistant-5b42682187b78f565bf110f416980c5233075e56.zip | |
cloud: one-line keys only, a refused pick keeps the chat's model
key_cmd's first line is the key, as pass stores it; anything else with
whitespace would reach the Authorization header, and http.client's
refusal quotes the whole value into the log and the socket. A pick that
cannot be asked is refused before it replaces the chat's model. Titles
and writes no longer move the context meter, a provider's error body
has the key masked, and an empty cloud model id is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | assistant.py | 46 |
1 files changed, 39 insertions, 7 deletions
diff --git a/assistant.py b/assistant.py index c91917f..fbca0ef 100644 --- a/assistant.py +++ b/assistant.py @@ -639,8 +639,16 @@ def command(kind, cmd): text, files, audio = cmd["text"].strip(), cmd.get("files", []), cmd.get("audio") if text or files or audio: interrupt() - # A pick applies from this ask on and stays with the chat. - cur["model"] = cmd.get("model", cur["model"]) + # A pick applies from this ask on and stays with the chat. One + # that cannot be asked (no key) is refused here, so the chat keeps + # the model it is stored with. + model = cmd.get("model", cur["model"]) + try: + route(model) + except Refused as e: + emit({"type": "error", "message": str(e), "turn": True}) + return + cur["model"] = model threading.Thread(target=reply, args=(turn, text, files, audio, cur["model"]), daemon=True).start() elif kind == "stop": @@ -753,7 +761,13 @@ def provider_key(p): raise RuntimeError(f"cloud {p['name']}: key_cmd failed ({type(e).__name__})") from None if r.returncode or not r.stdout.strip(): raise RuntimeError(f"cloud {p['name']}: key_cmd failed (exit {r.returncode})") - return r.stdout.strip() + # The first line, as pass keeps it, with the entry's other lines after. + # Anything not one clean token would go into a header, where http.client + # refuses it with the whole value in the message. + key = r.stdout.strip().splitlines()[0] + if any(c.isspace() or not c.isprintable() for c in key): + raise RuntimeError(f"cloud {p['name']}: key_cmd did not print a key on its first line") + return key def load_providers(cloud): @@ -773,6 +787,8 @@ def route(model): # with a colon stays local. name, sep, rest = (model or "").partition(":") if sep and name in PROVIDERS: + if not rest: + raise Refused(f"cloud {name}: no model named") p = PROVIDERS[name] return p["url"], p["key"], rest, name if sep and any(p["name"] == name for p in CFG["cloud"]): @@ -841,9 +857,11 @@ def ask(messages, calls, tools=None, mine=None, think=None, meter=True, model=No # connection from the other thread (interrupt), which ends the reading. # model is the id model_inputs gave, None for the router's loaded one. url, key, mid, cloud = route(model or llm_model()) - if cloud: - ctx.update(model=model, max=0) # no /props: the meter shows tokens used only - else: + # Only the chat's own replies move the meter: a title or a write still + # running for another chat must not overwrite it. + if meter and cloud and ctx["model"] != model: + ctx.update(model=model, max=0, used=0) # no /props: tokens used only + elif meter and not cloud: context_size(mid) body = {"model": mid, "messages": messages, "stream": True, "stream_options": {"include_usage": True}} @@ -885,7 +903,10 @@ def stream(conn, path, body, calls, meter=True, key=None, who="llama-server"): if r.status >= 400: # The body says why (a full context, a bad request, a spent quota); # the status alone does not. - raise RuntimeError(f"{who} {r.status}: {r.read().decode(errors='replace')[:2000]}") + why = r.read().decode(errors="replace") + if key: + why = why.replace(key, "***") # some providers echo the key back + raise RuntimeError(f"{who} {r.status}: {why[:2000]}") with r: streamed, other = False, b"" for line in r: @@ -971,6 +992,7 @@ def write(mine, files, prompt, d, name): try: # No history goes with a write, so half the whole context is its share. blocks, refs, _ = attach(files, ctx["max"] // 2 * 3 if ctx["max"] else 24000) + # Always the local model, whatever the chat uses: a write has no chat. model, inputs = model_inputs() images = inputs is None or "image" in inputs if refs and not images: @@ -1557,6 +1579,15 @@ def test(): assert False except Refused as e: assert "no key" in str(e) + try: + route("fake:") + assert False + except Refused as e: + assert "no model" in str(e) + # pass prints the key on the first line and the entry's other lines after. + more = [{"name": n, "url": base, "key_cmd": c, "models": []} + for n, c in (("pass", "printf 'sk-x\\nlogin: y\\n'"), ("spaced", "printf 'a b'"))] + assert {n: p["key"] for n, p in load_providers(more).items()} == {"pass": "sk-x"} assert cloud_models("fake") == ["m"] and script["auth"] == "Bearer sk-test" for name, why in (("broken", "key_cmd failed"), ("nope", "no cloud provider")): try: @@ -1884,6 +1915,7 @@ def test(): send(type="ask", text="x", model="broken:y") e = until("error")[-1] assert "no key" in e["message"] and e["turn"] is True + assert cur["model"] == "m" # the chat keeps its model send(type="new") until("conversation") TOOLS = saved |
