Skip to content

Commit e62bd4b

Browse files
Mikarina13claude
andcommitted
refactor(llm,vision): canonical codec_llm + codec_vision helpers, first tranche (A-11 + A-12)
PR-3E, Option 2. Two new single-source modules replace hand-rolled duplicates on the hottest path in the repo. A-11 (vision, fully closed): new codec_vision.py — describe_sync + describe_async, Gemini-flash -> local-Qwen-VL fallback, config read live from codec_config. All three consumers now delegate: codec.py vision_describe (deleted _gemini_vision / _local_vision), codec_voice._analyze_screenshot (async, reuses self._http), codec_session.screenshot_ctx (now GAINS the Gemini fallback it lacked — a documented behavioral superset). One file to change for a model/provider swap. A-12 (chat/completions, first tranche): the audit's premise that codec_llm_proxy already had call()/stream() was inaccurate — that module is a priority QUEUE, not an HTTP caller. Built genuinely-new codec_llm.py: call() + strip_think / extract_content (headers, Bearer auth, enable_thinking, <think> strip, choices/reasoning parse, retry+backoff, never-raises). Migrated codec.py voice-reply chat + codec_session.qwen_call; removed the now-dead local extract_content in codec_session (canonical copy lives in codec_llm). Deferred to phased follow-ons (each its own design + PR): codec_session.qwen_stream SSE (needs codec_llm.stream()) and the remaining ~40 sites (dashboard, voice generate_response, agents/agent_plan/agent_runner, telegram/imessage bridges, compaction/self_improve/watcher/textassist/dictate). Net -86 LOC in tracked files. Tests: tests/test_llm_vision_dedup.py (19, async driven via asyncio.run — no pytest-asyncio dep). Full suite: 23 known-baseline failures, zero new. No skills/ touched -> no manifest regen. Docs: design doc flipped to IMPLEMENTED (§8), A-11/A-12 closure notes in PHASE-1-CODE-QUALITY + triage, canonical-helpers note in AGENTS.md §2. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 584e579 commit e62bd4b

10 files changed

Lines changed: 624 additions & 193 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ docs/ API.md, MCP_HTTP_SETUP.md, CONTEXT_REPORT.md, desig
5656

5757
Other engine modules (`codec_overlays`, `codec_metrics`, `codec_logging`, `codec_gdocs`, `codec_google_auth`, `codec_cdp`, `codec_llm_proxy`, `codec_retry`, `codec_alerts`, `codec_search`, `codec_textassist`, `codec_watcher`, `codec_watchdog`) are internal helpers — read them when you need them, but they're not part of the navigation surface for an agent making structural changes. (Keyboard handling — wake word, F13 toggle, F18 voice, double-tap — lives **inline in `codec.py`** in the `codec` PM2 process; the old standalone `codec_keyboard.py` was deleted as a dead duplicate per A-8.)
5858

59+
**Canonical LLM + vision helpers (PR-3E, A-11/A-12).** `codec_vision.py` is the SINGLE source for screen-vision (`describe_sync` / `describe_async`, Gemini-flash → local-Qwen-VL fallback, config read live from `codec_config`) — used by `codec.py`, `codec_voice`, `codec_session`. `codec_llm.py` is the canonical chat/completions caller (`call()` + `strip_think`/`extract_content` — headers, Bearer auth, `enable_thinking`, `<think>` strip, `choices/reasoning` parse, retry+backoff, never-raises). NOTE: `codec_llm_proxy.py` is a priority *queue* (semaphore), NOT an HTTP caller — don't confuse the two. A-12 is migrating the ~45 inline `chat/completions` sites onto `codec_llm` in phased tranches; codec.py voice-reply + `codec_session.qwen_call` are done, streaming (`codec_llm.stream()`) + the rest are pending.
60+
5961
## 3. Agent + Crew runtime
6062

6163
CODEC has its own minimalist multi-agent runtime in `codec_agents.py`. **Zero dependency on CrewAI or LangChain** — it's self-contained, only depends on `requests` and `codec_skill_registry`.

codec.py

Lines changed: 44 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
# ── CONFIG (single source of truth: codec_config.py) ─────────────────────────
2323
from codec_config import (
2424
cfg as _cfg,
25-
QWEN_BASE_URL, QWEN_MODEL, LLM_API_KEY, LLM_KWARGS, QWEN_VISION_URL, QWEN_VISION_MODEL,
25+
QWEN_BASE_URL, QWEN_MODEL, LLM_API_KEY, LLM_KWARGS,
2626
WHISPER_URL,
2727
TASK_QUEUE_FILE, DRAFT_TASK_FILE, SESSION_ALIVE, STREAMING, WAKE_WORD, WAKE_ENERGY, WAKE_CHUNK_SEC,
2828
WAKE_PHRASES,
@@ -71,7 +71,7 @@ def _is_wake_utterance(text: str) -> bool:
7171
# ─��� SHARED (from codec_core.py — single source of truth) ─────────────────────
7272
import codec_core as _core
7373
from codec_core import (
74-
strip_think, is_draft, init_db, save_task, update_session_response, get_memory, get_recent_conversations,
74+
is_draft, init_db, save_task, update_session_response, get_memory, get_recent_conversations,
7575
transcribe, speak_text, focused_app, get_text_dialog,
7676
terminal_session_exists,
7777
# A-14 (PR-3G): `close_session` import dropped — codec.py defines its own
@@ -96,50 +96,16 @@ def _is_wake_utterance(text: str) -> bool:
9696
# safety gate AND plugin lifecycle hooks (run_with_hooks), both of which the
9797
# legacy path bypassed.
9898

99-
# ── VISION (Gemini Flash or local Qwen VL) ──────────────────────────────────
100-
def _gemini_vision(img_b64, prompt, max_tokens=800):
101-
"""Call Gemini Flash vision API. Fast, reliable, free tier."""
102-
import requests
103-
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={GEMINI_API_KEY}"
104-
payload = {
105-
"contents": [{"parts": [
106-
{"inlineData": {"mimeType": "image/png", "data": img_b64}},
107-
{"text": prompt}
108-
]}],
109-
"generationConfig": {"maxOutputTokens": max_tokens}
110-
}
111-
r = requests.post(url, json=payload, timeout=30)
112-
if r.status_code == 200:
113-
candidates = r.json().get("candidates", [])
114-
if candidates:
115-
parts = candidates[0].get("content", {}).get("parts", [])
116-
if parts:
117-
return parts[0].get("text", "").strip()
118-
else:
119-
print(f"[CODEC] Gemini error {r.status_code}: {r.text[:200]}")
120-
return ""
121-
122-
def _local_vision(img_b64, prompt, max_tokens=800):
123-
"""Call local Qwen VL vision API (fallback)."""
124-
import requests
125-
r = requests.post(f"{QWEN_VISION_URL}/chat/completions",
126-
json={"model": QWEN_VISION_MODEL,
127-
"messages": [{"role": "user", "content": [
128-
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}},
129-
{"type": "text", "text": prompt}
130-
]}], "max_tokens": max_tokens}, timeout=60)
131-
if r.status_code == 200:
132-
return r.json()["choices"][0]["message"].get("content", "").strip()
133-
return ""
99+
# ── VISION (A-11, PR-3E: canonical helper in codec_vision) ──────────────────
100+
# The Gemini-Flash → local-Qwen-VL fallback used to be hand-rolled here (and in
101+
# codec_voice + codec_session). It now lives in codec_vision; this is a thin
102+
# delegate kept for any caller of codec.vision_describe.
103+
import codec_vision
104+
import codec_llm # A-12: canonical chat/completions caller
134105

135106
def vision_describe(img_b64, prompt="Read all visible text on this screen. Include app name, window title, and all message/content text. Output raw text only.", max_tokens=800):
136-
"""Route vision to Gemini or local based on config."""
137-
if VISION_PROVIDER == "gemini" and GEMINI_API_KEY:
138-
result = _gemini_vision(img_b64, prompt, max_tokens)
139-
if result:
140-
return result
141-
print("[CODEC] Gemini failed, falling back to local vision...")
142-
return _local_vision(img_b64, prompt, max_tokens)
107+
"""Route vision to Gemini or local based on config (codec_vision)."""
108+
return codec_vision.describe_sync(img_b64, prompt, mime="image/png", max_tokens=max_tokens)
143109

144110
def screenshot_ctx():
145111
try:
@@ -444,55 +410,41 @@ def _post_skill_screenshot():
444410

445411
push(lambda: show_processing_overlay('Thinking...', 15000))
446412
try:
447-
import requests as _llm_req
448-
headers = {}
449-
if LLM_API_KEY:
450-
headers["Authorization"] = f"Bearer {LLM_API_KEY}"
451-
payload = {
452-
"model": QWEN_MODEL,
453-
"messages": llm_messages,
454-
"max_tokens": 400,
455-
"temperature": 0.7,
456-
"chat_template_kwargs": {"enable_thinking": False},
457-
}
458-
payload.update(LLM_KWARGS)
459-
r = _llm_req.post(f"{QWEN_BASE_URL}/chat/completions", json=payload, headers=headers, timeout=120)
460-
if r.status_code == 200:
461-
data = r.json()
462-
answer = data.get("choices", [{}])[0].get("message", {}).get("content", "")
463-
answer = strip_think(answer).strip()
464-
if answer:
465-
print(f"[CODEC] Voice reply (turn {voice_session['turn_count']+1}): {answer[:120]}")
466-
log_event("tts_speak", "open-codec",
467-
f"TTS: {answer[:60]}",
468-
extra={"text_len": len(answer)})
469-
# Add assistant response to session history
470-
voice_session["messages"].append({"role": "assistant", "content": answer})
471-
voice_session["turn_count"] += 1
472-
# Save response to DB (A-20: via codec_core helper with
473-
# WAL + busy_timeout — replaces the inline lock-prone
474-
# sqlite3.connect that risked "database is locked" under
475-
# concurrent agent-runner + voice writes). Never raises.
476-
update_session_response(rid, answer[:500])
477-
# Save to shared memory (same store as Chat)
478-
try:
479-
cm = CodecMemory()
480-
cm.save("voice", "user", task)
481-
cm.save("voice", "assistant", answer)
482-
except Exception as e:
483-
log.warning(f"[CODEC] Memory save failed after LLM: {e}")
484-
_last_tts_text = answer[:200]
485-
speak_text(answer)
486-
_safe_ans = answer[:80].replace('\\', '\\\\').replace('"', '\\"')
487-
subprocess.Popen(["osascript", "-e",
488-
f'display notification "{_safe_ans}" with title "CODEC"'],
489-
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
490-
else:
491-
print("[CODEC] Voice LLM returned empty response")
492-
speak_text("Sorry, I didn't get a response.")
413+
# A-12 (PR-3E): canonical codec_llm.call replaces the inline
414+
# chat/completions POST + headers + enable_thinking + <think> strip +
415+
# choices parse. Returns the stripped answer, or "" on any failure
416+
# (non-200 and empty now collapse to the same apology).
417+
answer = codec_llm.call(
418+
llm_messages, base_url=QWEN_BASE_URL, model=QWEN_MODEL,
419+
api_key=LLM_API_KEY, max_tokens=400, temperature=0.7,
420+
timeout=120, retries=1, extra_kwargs=LLM_KWARGS,
421+
)
422+
if answer:
423+
print(f"[CODEC] Voice reply (turn {voice_session['turn_count']+1}): {answer[:120]}")
424+
log_event("tts_speak", "open-codec",
425+
f"TTS: {answer[:60]}",
426+
extra={"text_len": len(answer)})
427+
# Add assistant response to session history
428+
voice_session["messages"].append({"role": "assistant", "content": answer})
429+
voice_session["turn_count"] += 1
430+
# Save response to DB (A-20: codec_core helper, WAL + busy_timeout).
431+
update_session_response(rid, answer[:500])
432+
# Save to shared memory (same store as Chat)
433+
try:
434+
cm = CodecMemory()
435+
cm.save("voice", "user", task)
436+
cm.save("voice", "assistant", answer)
437+
except Exception as e:
438+
log.warning(f"[CODEC] Memory save failed after LLM: {e}")
439+
_last_tts_text = answer[:200]
440+
speak_text(answer)
441+
_safe_ans = answer[:80].replace('\\', '\\\\').replace('"', '\\"')
442+
subprocess.Popen(["osascript", "-e",
443+
f'display notification "{_safe_ans}" with title "CODEC"'],
444+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
493445
else:
494-
print(f"[CODEC] Voice LLM error: {r.status_code} {r.text[:200]}")
495-
speak_text("Sorry, the language model is not responding.")
446+
print("[CODEC] Voice LLM returned no response")
447+
speak_text("Sorry, I didn't get a response.")
496448
except Exception as e:
497449
log.error("Voice LLM call failed: %s", e)
498450
import traceback; traceback.print_exc()

codec_llm.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""CODEC LLM call helper — the single canonical OpenAI-style chat/completions caller.
2+
3+
A-12 (PR-3E): before this, ~45 sites hand-rolled the same `chat/completions`
4+
POST — build headers (`Authorization: Bearer …`, `Content-Type`), assemble the
5+
payload (`model`/`messages`/`max_tokens`/`temperature`/
6+
`chat_template_kwargs.enable_thinking=False`), parse `choices[0].message`
7+
(content, with a `reasoning` fallback), and strip `<think>…</think>`. A model
8+
upgrade or API-shape fix then meant editing 20+ places.
9+
10+
This module centralizes the **non-streaming** call. It is intentionally
11+
config-agnostic — each caller passes its own `base_url` / `model` / `api_key`
12+
/ tuning — so it's a pure "build payload → POST → parse" helper with no import
13+
cycle into codec_config. (Streaming SSE + the remaining call sites are migrated
14+
in later A-12 tranches; this PR covers the call() API + codec.py + codec_session.)
15+
16+
NOTE: `codec_llm_proxy` is a *priority queue* (semaphore), not an HTTP proxy —
17+
orthogonal to this module. Callers that want prioritization still wrap the call
18+
in `llm_queue_sync(...)`; behavior parity for the migrated sites means we do NOT
19+
add queue acquisition here (none of them used it).
20+
"""
21+
from __future__ import annotations
22+
23+
import logging
24+
import re
25+
import time
26+
from typing import Any, Dict, List, Optional
27+
28+
log = logging.getLogger("codec.llm")
29+
30+
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
31+
32+
33+
def strip_think(text: str) -> str:
34+
"""Remove <think>…</think> reasoning blocks and surrounding whitespace."""
35+
if not text:
36+
return ""
37+
return _THINK_RE.sub("", text).strip()
38+
39+
40+
def extract_content(response_json: Dict[str, Any]) -> str:
41+
"""Pull the assistant text from an OpenAI-style response: prefer
42+
`choices[0].message.content`, fall back to `.reasoning` (some local
43+
servers put the answer there when content is empty). `<think>` stripped.
44+
Returns "" on any shape mismatch."""
45+
try:
46+
msg = response_json["choices"][0]["message"]
47+
except (KeyError, IndexError, TypeError):
48+
return ""
49+
content = (msg.get("content") or "").strip()
50+
if content:
51+
return strip_think(content)
52+
reasoning = (msg.get("reasoning") or "").strip()
53+
if reasoning:
54+
return strip_think(reasoning)
55+
return ""
56+
57+
58+
def call(
59+
messages: List[Dict[str, Any]],
60+
*,
61+
base_url: str,
62+
model: str,
63+
api_key: str = "",
64+
max_tokens: int = 500,
65+
temperature: float = 0.7,
66+
timeout: float = 120.0,
67+
retries: int = 1,
68+
enable_thinking: bool = False,
69+
extra_kwargs: Optional[Dict[str, Any]] = None,
70+
) -> str:
71+
"""POST `messages` to `<base_url>/chat/completions` and return the parsed,
72+
`<think>`-stripped assistant text (or "" on failure).
73+
74+
`retries` includes the first attempt (retries=3 → up to 3 tries with
75+
exponential 2**n backoff between them, matching codec_session.qwen_call).
76+
Never raises — network/parse errors are logged and yield "".
77+
"""
78+
import requests
79+
headers = {"Content-Type": "application/json"}
80+
if api_key:
81+
headers["Authorization"] = "Bearer " + api_key
82+
payload: Dict[str, Any] = {
83+
"model": model,
84+
"messages": messages,
85+
"max_tokens": max_tokens,
86+
"temperature": temperature,
87+
"chat_template_kwargs": {"enable_thinking": enable_thinking},
88+
}
89+
if extra_kwargs:
90+
payload.update(extra_kwargs)
91+
92+
attempts = max(1, retries)
93+
url = base_url.rstrip("/") + "/chat/completions"
94+
for attempt in range(attempts):
95+
try:
96+
r = requests.post(url, json=payload, headers=headers, timeout=timeout)
97+
if r.status_code == 200:
98+
resp = extract_content(r.json())
99+
if resp:
100+
return resp
101+
# 200 but empty/odd shape — don't retry, nothing more to get.
102+
return ""
103+
log.warning("LLM call %s returned %s: %s", url, r.status_code, r.text[:200])
104+
except Exception as e:
105+
log.warning("LLM call attempt %d/%d failed: %s", attempt + 1, attempts, e)
106+
if attempt < attempts - 1:
107+
time.sleep(2 ** attempt)
108+
return ""

codec_session.py

Lines changed: 17 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,9 @@ def strip_think(t):
7272
return re.sub(r"<think>.*?</think>", "", t, flags=re.DOTALL).strip()
7373

7474

75-
def extract_content(rj):
76-
msg = rj["choices"][0]["message"]
77-
c = msg.get("content", "").strip()
78-
if c:
79-
return strip_think(c)
80-
r = msg.get("reasoning", "").strip()
81-
if r:
82-
return strip_think(r)
83-
return ""
75+
# A-12 (PR-3E): local `extract_content` removed — its only caller was `qwen_call`,
76+
# now migrated to codec_llm.call (which owns the canonical content→reasoning
77+
# extraction). `strip_think` above is kept; qwen_stream still uses it.
8478

8579

8680
def clean_resp(text):
@@ -210,26 +204,12 @@ def screenshot_ctx(self):
210204
ib = base64.b64encode(f.read()).decode()
211205
os.unlink(tmp.name)
212206
print("[C] Reading screen...")
213-
import requests
214-
r = requests.post(
215-
self.qwen_vision_url + "/chat/completions",
216-
json={
217-
"model": self.qwen_vision_model,
218-
"messages": [
219-
{
220-
"role": "user",
221-
"content": [
222-
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + ib}},
223-
{"type": "text", "text": "Read all visible text. Include app name and content. Raw text only."},
224-
],
225-
}
226-
],
227-
"max_tokens": 800,
228-
},
229-
timeout=120,
230-
)
231-
if r.status_code == 200:
232-
return r.json()["choices"][0]["message"].get("content", "")[:2000]
207+
# A-11 (PR-3E): canonical vision helper. Was local-Qwen-VL only here;
208+
# now gains the Gemini-Flash fallback for free (config-gated).
209+
import codec_vision
210+
return codec_vision.describe_sync(
211+
ib, "Read all visible text. Include app name and content. Raw text only.",
212+
mime="image/png", max_tokens=800)[:2000]
233213
except Exception as e:
234214
log.warning(f"Screenshot capture or vision analysis failed: {e}")
235215
return ""
@@ -266,28 +246,14 @@ def speak(self, text):
266246
# ── LLM Calls ────────────────────────────────────────────────────────
267247

268248
def qwen_call(self, messages):
269-
import requests
270-
headers = {"Content-Type": "application/json"}
271-
if self.llm_api_key:
272-
headers["Authorization"] = "Bearer " + self.llm_api_key
273-
payload = {"model": self.qwen_model, "messages": messages, "max_tokens": 500, "temperature": 0.5}
274-
payload.update(self.llm_kwargs)
275-
for attempt in range(3):
276-
try:
277-
r = requests.post(
278-
self.qwen_base_url + "/chat/completions",
279-
json=payload,
280-
headers=headers,
281-
timeout=90,
282-
)
283-
if r.status_code == 200:
284-
resp = extract_content(r.json())
285-
if resp:
286-
return resp
287-
except Exception as e:
288-
log.warning(f"LLM API call attempt {attempt+1} failed: {e}")
289-
time.sleep(2 ** attempt)
290-
return ""
249+
# A-12 (PR-3E): canonical codec_llm.call (3 retries + backoff, content→
250+
# reasoning extraction, <think> strip) — was an inline chat/completions POST.
251+
import codec_llm
252+
return codec_llm.call(
253+
messages, base_url=self.qwen_base_url, model=self.qwen_model,
254+
api_key=self.llm_api_key, max_tokens=500, temperature=0.5,
255+
timeout=90, retries=3, extra_kwargs=self.llm_kwargs,
256+
)
291257

292258
def qwen_stream(self, messages):
293259
import requests

0 commit comments

Comments
 (0)