aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-23 16:48:15 +0200
committerDanilo M. <danix@danix.xyz>2026-10-23 16:48:15 +0200
commit5b42682187b78f565bf110f416980c5233075e56 (patch)
tree8327a4f9a96fdf29d3bd80775f4e9a2cfe0117f2
parentab7599c49f8933dda8f06a86124e3917163cda28 (diff)
downloaddesktop-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.py46
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