@r1z4x/tezgah 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -1
- package/MANIFEST +10 -0
- package/bin/tezgah-agents +10 -1
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-doctor +11 -0
- package/bin/tezgah-research +31 -14
- package/bin/tezgah-setup +154 -29
- package/bin/tezgah-skill +59 -0
- package/bin/tezgah-skill.py +59 -0
- package/bin/tezgah-status +62 -1
- package/bin/tezgah-taste +504 -5
- package/bin/tezgah-triage +5 -3
- package/docs/README.md +1 -0
- package/docs/architecture.md +5 -2
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +68 -26
- package/docs/environment.md +6 -3
- package/docs/evidence.md +7 -0
- package/docs/gate.md +13 -7
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +48 -1
- package/docs/index.json +34 -3
- package/docs/judge.md +75 -60
- package/docs/models.md +14 -8
- package/docs/operations.md +80 -25
- package/docs/orca.md +126 -0
- package/docs/research.md +38 -3
- package/docs/skills.md +74 -0
- package/docs/status-line.md +3 -1
- package/docs/testing.md +33 -21
- package/docs/vision.md +12 -8
- package/hooks/projects-posttooluse.py +7 -0
- package/hooks/projects-pretooluse.py +17 -5
- package/hooks/tezgah_agents.py +135 -13
- package/hooks/tezgah_components.py +3 -0
- package/hooks/tezgah_context.py +84 -22
- package/hooks/tezgah_gate.py +133 -17
- package/hooks/tezgah_integrity.py +60 -13
- package/hooks/tezgah_judge.py +288 -66
- package/hooks/tezgah_models.py +41 -5
- package/hooks/tezgah_orca.py +144 -0
- package/hooks/tezgah_paths.py +40 -6
- package/hooks/tezgah_policy.py +109 -35
- package/hooks/tezgah_research.py +104 -8
- package/hooks/tezgah_skill_pick.py +459 -8
- package/hooks/tezgah_stack.py +382 -0
- package/hooks/tezgah_stopspec.py +2 -2
- package/hooks/tezgah_taste.py +35 -9
- package/hooks/tezgah_taste_ledger.py +628 -0
- package/hosts/codex/hook.py +12 -1
- package/hosts/cursor/hook.py +12 -4
- package/hosts/omp/hook.py +18 -6
- package/hosts/omp/tezgah-hook.ts.in +20 -7
- package/hosts/opencode/plugins/tezgah.js +28 -15
- package/output-styles/tezgah.md +34 -19
- package/package.json +1 -1
- package/skills/i-have-adhd/SKILL.md +2 -2
- package/skills/research/SKILL.md +60 -17
- package/skills/tezgah-contract/SKILL.md +55 -13
package/hooks/tezgah_judge.py
CHANGED
|
@@ -29,15 +29,19 @@ non-interactive shell where `.zshenv` never runs - the export is absent there,
|
|
|
29
29
|
and the key file is the channel that survives. That is why the file is read
|
|
30
30
|
rather than the environment trusted.
|
|
31
31
|
|
|
32
|
-
Behind the credential there are
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
Behind the credential there are three providers, asked in this order. First the
|
|
33
|
+
session's own CLI - `omp` or `claude`, the host this process runs under
|
|
34
|
+
(`tp.session_cli()`) - run headless with no tools, so the judgement is paid
|
|
35
|
+
with the session's own credential, OAuth subscription included, and answered by
|
|
36
|
+
the session's own vendor. TypeSafe and OpenRouter are third parties: they are
|
|
37
|
+
asked only as the `fallback` setting allows (`tp.fallback_policy()`): by
|
|
38
|
+
default (`vendor`) only when there is no session CLI at all, with `any` also
|
|
39
|
+
after the session CLI failed, with `none` never. A third party that answers,
|
|
40
|
+
and a fallback that was refused, is never silent: one stderr line, the
|
|
41
|
+
`fallback` field on the result, and the last-use record `tezgah-status
|
|
42
|
+
--judge` prints (`_record`, `last_use`). A reply that does not carry a
|
|
43
|
+
question's answer leaves that question absent, which every caller already reads
|
|
44
|
+
as no judgement.
|
|
41
45
|
|
|
42
46
|
Kill switch: `judge-off`, honoured by `available()`, so a caller can fall back to
|
|
43
47
|
its own deterministic path without knowing why the judgement is gone. It is the
|
|
@@ -64,9 +68,10 @@ FALLBACK_MODEL = "deepseek/deepseek-v4-flash"
|
|
|
64
68
|
# The contract the fallback asks a chat model to answer in, one shape per
|
|
65
69
|
# question type this repository's callers send (`bin/tezgah-triage` and
|
|
66
70
|
# `hooks/tezgah_skill_pick.py` ask `noul`, `bin/tezgah-docs` and the triage's
|
|
67
|
-
# pair rows ask `choice`
|
|
68
|
-
#
|
|
69
|
-
#
|
|
71
|
+
# pair rows ask `choice`, `bin/tezgah-taste` asks `text` for a learning's
|
|
72
|
+
# readable line). It is prose because a chat endpoint has no schema field to
|
|
73
|
+
# carry it; every field it names is validated on the way back, and a question
|
|
74
|
+
# of any other type reads as unanswered rather than as a guess.
|
|
70
75
|
CHAT_SYSTEM = (
|
|
71
76
|
"You answer questions about the state that follows. Reply with one JSON "
|
|
72
77
|
"object and nothing else: {\"answers\": {\"<id>\": {...}}}, one entry per "
|
|
@@ -74,6 +79,7 @@ CHAT_SYSTEM = (
|
|
|
74
79
|
"noul -> {\"noul\": <probability 0..1 that the statement is true>}\n"
|
|
75
80
|
"choice -> {\"choice\": \"<the chosen label>\", \"probabilities\": "
|
|
76
81
|
"{\"<label>\": <probability>, ...}, \"confidence\": <0..1>}\n"
|
|
82
|
+
"text -> {\"text\": \"<the answer as plain prose>\"}\n"
|
|
77
83
|
"Every probability is a number in 0..1 and the ones you list for one "
|
|
78
84
|
"question sum to 1. Answer every id you were given.")
|
|
79
85
|
|
|
@@ -148,58 +154,139 @@ def fallback_model():
|
|
|
148
154
|
return os.environ.get("TEZGAH_JUDGE_MODEL", "").strip() or cheap_default()
|
|
149
155
|
|
|
150
156
|
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
# The session CLIs the seam can ask, as the argv between the binary and the
|
|
158
|
+
# prompt: no tools, no session file, no project rules, skills, extensions, MCP
|
|
159
|
+
# servers or settings, and the chat fallback's own contract as the system
|
|
160
|
+
# prompt. Measured 2026-10-07 on a one-word answer: claude went from 37,038
|
|
161
|
+
# prompt tokens ($0.30) to 725 ($0.007); omp from 43,735 written to cache
|
|
162
|
+
# ($0.35) to about 37,500, 36,700 of them read from cache ($0.015) - omp has
|
|
163
|
+
# no flag here that drops the rest.
|
|
164
|
+
SESSION_ARGV = {
|
|
165
|
+
"omp": ["-p", "--no-tools", "--no-lsp", "--no-session", "--no-rules",
|
|
166
|
+
"--no-skills", "--no-extensions", "--no-title", "--thinking", "off",
|
|
167
|
+
"--mode", "json", "--system-prompt", CHAT_SYSTEM],
|
|
168
|
+
"claude": ["-p", "--tools", "", "--no-session-persistence",
|
|
169
|
+
"--strict-mcp-config", "--disable-slash-commands",
|
|
170
|
+
"--setting-sources", "", "--output-format", "json",
|
|
171
|
+
"--system-prompt", CHAT_SYSTEM],
|
|
172
|
+
}
|
|
173
|
+
NO_SESSION = "no session CLI to ask (OMPCODE/CLAUDECODE unset or the CLI missing)"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def providers():
|
|
177
|
+
"""[(provider, secret), ...] in the order `ask()` tries them, or [].
|
|
178
|
+
|
|
179
|
+
The session's own CLI first (its `secret` is the binary's path); then the
|
|
180
|
+
third parties - TypeSafe, then OpenRouter - only as `tp.fallback_policy()`
|
|
181
|
+
allows: `vendor` (the default) asks them only when no session CLI exists,
|
|
182
|
+
`any` after the session CLI too, `none` never. TypeSafe still wins over
|
|
183
|
+
OpenRouter whenever both resolve."""
|
|
184
|
+
session = tp.session_cli()
|
|
185
|
+
policy = tp.fallback_policy()
|
|
186
|
+
out = [(session, getattr(tp, session + "_bin")())] if session else []
|
|
187
|
+
if policy == "none" or (session and policy == "vendor"):
|
|
188
|
+
return out
|
|
189
|
+
return out + [(name, secret) for name, secret in
|
|
190
|
+
(("typesafe", key()), ("openrouter", openrouter_key())) if secret]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def named(only):
|
|
194
|
+
"""`providers()` for a caller that requires particular ones: every provider
|
|
195
|
+
in `only` that resolves, in `only`'s order. The `vendor` policy's
|
|
196
|
+
session-first order does not apply - the caller named the provider, so
|
|
197
|
+
nothing is a silent fallback - but `none` still keeps every third party
|
|
198
|
+
out."""
|
|
199
|
+
session = tp.session_cli()
|
|
200
|
+
third = tp.fallback_policy() != "none"
|
|
201
|
+
found = {session: getattr(tp, session + "_bin")()} if session else {}
|
|
202
|
+
if third:
|
|
203
|
+
found.update(typesafe=key(), openrouter=openrouter_key())
|
|
204
|
+
return [(name, found[name]) for name in only if found.get(name)]
|
|
205
|
+
|
|
153
206
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if secret:
|
|
160
|
-
return "typesafe", secret
|
|
161
|
-
secret = openrouter_key()
|
|
162
|
-
return ("openrouter", secret) if secret else (None, None)
|
|
207
|
+
def credential():
|
|
208
|
+
"""`(provider, secret)` for the first provider `ask()` will try, else
|
|
209
|
+
`(None, None)` - so `available()` and `ask()` cannot disagree."""
|
|
210
|
+
found = providers()
|
|
211
|
+
return found[0] if found else (None, None)
|
|
163
212
|
|
|
164
213
|
|
|
165
214
|
def available():
|
|
166
|
-
"""True when a judgement can be asked for: a
|
|
215
|
+
"""True when a judgement can be asked for: a provider resolves and no kill switch."""
|
|
167
216
|
return bool(credential()[1]) and not tp.off("judge-off")
|
|
168
217
|
|
|
169
218
|
|
|
170
|
-
def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None
|
|
219
|
+
def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None,
|
|
220
|
+
only=None):
|
|
171
221
|
"""One batched call over `questions`; `{"answers", "usage", "latency_ms",
|
|
172
|
-
"model", "provider"}`.
|
|
222
|
+
"model", "provider", "fallback"}`.
|
|
173
223
|
|
|
174
224
|
`questions` is the API's own map - id -> `{type, instructions, criteria}` -
|
|
175
225
|
so a caller that needs a per-line or per-state pass sends every question in
|
|
176
226
|
one request and pays for the state once. The reply's usage is carried back
|
|
177
227
|
because a caller quotes what the judgement cost, and the model and provider
|
|
178
|
-
beside it because the
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
228
|
+
beside it because the provider that answers is not the caller's choice. The
|
|
229
|
+
model is the one the reply names, else the one asked for: an alias such as
|
|
230
|
+
`jev-latest` hides a silent upgrade the reply's own field shows. `model` is
|
|
231
|
+
TypeSafe's alone; the session CLI and the chat fallback ignore it.
|
|
232
|
+
|
|
233
|
+
The providers are tried in `providers()` order - the session's own CLI
|
|
234
|
+
first - and the next one is asked only when the previous failed. `fallback`
|
|
235
|
+
is None when the session answered, else why someone else did; that answer,
|
|
236
|
+
and a refusal, is recorded (`_record`) and said on stderr. A transient
|
|
237
|
+
failure - a timeout, a connection error, a 5xx - gets one more attempt on
|
|
238
|
+
the same provider, because a live measurement saw 2 of 50 calls lost that
|
|
239
|
+
way while the same cells answered on retry; a 4xx, a CLI that exited
|
|
240
|
+
non-zero and a reply that parsed malformed are never retried, since the
|
|
241
|
+
second request would fail identically and only a call that already worked
|
|
242
|
+
must not be billed twice. A caller on a hook's budget passes `attempts=1`
|
|
243
|
+
and a `deadline` in seconds: urllib's `timeout` bounds one socket operation,
|
|
244
|
+
not the call, so only the deadline bounds its wall time.
|
|
245
|
+
|
|
246
|
+
A call that ends on a 401, 402, 5xx or a session CLI's non-zero exit marks
|
|
247
|
+
that provider down for `DOWN_FOR` seconds, and until then it is skipped
|
|
248
|
+
without a request (`_down`), so a dead credential is not re-paid on every
|
|
249
|
+
prompt.
|
|
250
|
+
|
|
251
|
+
Total by design: no provider, an unreadable state, a refused request, a
|
|
252
|
+
timeout, a reply that is not the documented shape - all `None`, never an
|
|
253
|
+
exception.
|
|
254
|
+
|
|
255
|
+
`only` names the providers the caller requires (`named()`); a caller whose
|
|
256
|
+
decision must come from a typed model passes `only=("typesafe",)` and gets
|
|
257
|
+
None rather than another provider's answer."""
|
|
258
|
+
tried = providers() if only is None else named(only)
|
|
259
|
+
if any(isinstance(q, dict) and q.get("type") == "text" for q in questions.values()):
|
|
260
|
+
# TypeSafe answers no prose: left out up front, not logged as a failure
|
|
261
|
+
tried = [(p, s) for p, s in tried if p != "typesafe"]
|
|
262
|
+
if not tried or not tried[0][1]:
|
|
202
263
|
return None
|
|
264
|
+
stop = None if deadline is None else time.monotonic() + deadline
|
|
265
|
+
failed = []
|
|
266
|
+
for provider, secret in tried:
|
|
267
|
+
result, why = _ask_one(provider, secret, state, questions, model,
|
|
268
|
+
timeout, attempts, stop)
|
|
269
|
+
if result is None:
|
|
270
|
+
failed.append("%s: %s" % (provider, why))
|
|
271
|
+
continue
|
|
272
|
+
# a provider the caller named is not a stand-in: no note, unless an
|
|
273
|
+
# earlier one it named failed
|
|
274
|
+
note = "; ".join(failed) or (None if only is not None or provider in SESSION_ARGV
|
|
275
|
+
else NO_SESSION)
|
|
276
|
+
result["fallback"] = note
|
|
277
|
+
_record(result["provider"], result["model"], fallback=note)
|
|
278
|
+
return result
|
|
279
|
+
refused = None
|
|
280
|
+
session = tp.session_cli()
|
|
281
|
+
if session and (key() or openrouter_key()):
|
|
282
|
+
refused = ("fallback=%s refuses a third-party judge after %s failed"
|
|
283
|
+
% (tp.fallback_policy(), session))
|
|
284
|
+
_record(None, None, failed="; ".join(failed), refused=refused)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _ask_one(provider, secret, state, questions, model, timeout, attempts, stop):
|
|
289
|
+
"""One provider's attempts: `(result, None)`, or `(None, why)`."""
|
|
203
290
|
try:
|
|
204
291
|
if provider == "typesafe":
|
|
205
292
|
used, url = model, endpoint()
|
|
@@ -208,34 +295,43 @@ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None)
|
|
|
208
295
|
|
|
209
296
|
def send():
|
|
210
297
|
return _request(secret, body, timeout)
|
|
211
|
-
|
|
298
|
+
elif provider == "openrouter":
|
|
212
299
|
used, url = fallback_model(), openrouter_url()
|
|
213
300
|
body = json.dumps(_chat_body(state, questions, used)).encode()
|
|
214
301
|
|
|
215
302
|
def send():
|
|
216
303
|
return _chat_request(secret, body, timeout, questions)
|
|
304
|
+
else:
|
|
305
|
+
used, url = None, secret
|
|
306
|
+
prompt = json.dumps({"state": state, "questions": questions})
|
|
307
|
+
|
|
308
|
+
def send():
|
|
309
|
+
left = timeout if stop is None else max(0.1, min(timeout, stop - time.monotonic()))
|
|
310
|
+
return _session_request(provider, secret, prompt, left, questions)
|
|
217
311
|
marker = _down_marker(provider, url, secret)
|
|
218
312
|
if _down(marker):
|
|
219
|
-
return None
|
|
220
|
-
except Exception:
|
|
221
|
-
return None
|
|
222
|
-
stop = None if deadline is None else time.monotonic() + deadline
|
|
313
|
+
return None, "marked down for %ds after a refusal" % DOWN_FOR
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
return None, _why(exc)
|
|
223
316
|
for attempt in range(attempts):
|
|
317
|
+
if stop is not None and time.monotonic() >= stop:
|
|
318
|
+
return None, "deadline passed"
|
|
224
319
|
try:
|
|
225
320
|
result = _bounded(send, stop)
|
|
226
321
|
except Exception as exc:
|
|
227
322
|
late = stop is not None and time.monotonic() >= stop
|
|
228
323
|
if attempt + 1 >= attempts or late or not _transient(exc):
|
|
229
324
|
code = getattr(exc, "code", None)
|
|
230
|
-
if code in (401, 402) or (isinstance(code, int) and code >= 500)
|
|
325
|
+
if code in (401, 402) or (isinstance(code, int) and code >= 500) \
|
|
326
|
+
or isinstance(exc, SessionFailed):
|
|
231
327
|
_mark_down(marker)
|
|
232
|
-
return None
|
|
328
|
+
return None, _why(exc)
|
|
233
329
|
continue
|
|
234
330
|
named = result.get("model")
|
|
235
|
-
result["model"] = named if isinstance(named, str) and named else used
|
|
331
|
+
result["model"] = named if isinstance(named, str) and named else used or "-"
|
|
236
332
|
result["provider"] = provider
|
|
237
|
-
return result
|
|
238
|
-
return None
|
|
333
|
+
return result, None
|
|
334
|
+
return None, "no attempt"
|
|
239
335
|
|
|
240
336
|
|
|
241
337
|
def _answer(result, id):
|
|
@@ -260,6 +356,14 @@ def choice(result, id):
|
|
|
260
356
|
return value if isinstance(value, str) else None
|
|
261
357
|
|
|
262
358
|
|
|
359
|
+
def text(result, id):
|
|
360
|
+
"""The prose a `text` question came back with, stripped, or None. Only a
|
|
361
|
+
generative provider answers one; TypeSafe is never sent one."""
|
|
362
|
+
answer = _answer(result, id)
|
|
363
|
+
value = answer.get("text") if answer else None
|
|
364
|
+
return (value.strip() or None) if isinstance(value, str) else None
|
|
365
|
+
|
|
366
|
+
|
|
263
367
|
def noul(result, id, option=None):
|
|
264
368
|
"""The probability a Noul question came back with, or None.
|
|
265
369
|
|
|
@@ -380,17 +484,20 @@ def _chat_answers(parsed, questions):
|
|
|
380
484
|
def _clean_answer(raw, kind):
|
|
381
485
|
"""One answer in the shape its question type names, or None.
|
|
382
486
|
|
|
383
|
-
The
|
|
384
|
-
statement holds,
|
|
385
|
-
`choice()`/`noul()` already read, so a fallback answer
|
|
386
|
-
are indistinguishable downstream. Any other type reads
|
|
387
|
-
than as a guess. Probabilities are kept only when they
|
|
388
|
-
`noul()` does the arithmetic on them."""
|
|
487
|
+
The shapes this repository's callers send - `noul`, the probability a
|
|
488
|
+
statement holds, `choice`, the label taken, and `text`, prose - map onto
|
|
489
|
+
exactly what `choice()`/`noul()`/`text()` already read, so a fallback answer
|
|
490
|
+
and a TypeSafe answer are indistinguishable downstream. Any other type reads
|
|
491
|
+
as no answer rather than as a guess. Probabilities are kept only when they
|
|
492
|
+
are numbers, because `noul()` does the arithmetic on them."""
|
|
389
493
|
if not isinstance(raw, dict):
|
|
390
494
|
return None
|
|
391
495
|
if kind == "noul":
|
|
392
496
|
value = raw.get("noul")
|
|
393
497
|
return {"noul": float(value)} if isinstance(value, (int, float)) else None
|
|
498
|
+
if kind == "text":
|
|
499
|
+
value = raw.get("text")
|
|
500
|
+
return {"text": value} if isinstance(value, str) and value.strip() else None
|
|
394
501
|
if kind != "choice":
|
|
395
502
|
return None
|
|
396
503
|
if not isinstance(raw.get("choice"), str):
|
|
@@ -485,3 +592,118 @@ def _bounded(send, stop):
|
|
|
485
592
|
if "err" in box:
|
|
486
593
|
raise box["err"]
|
|
487
594
|
return box["ok"]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class SessionFailed(RuntimeError):
|
|
598
|
+
"""The session CLI exited non-zero or printed no answer: a logged-out CLI or
|
|
599
|
+
an exhausted plan fails the same way on the next call, so it is never
|
|
600
|
+
retried and it marks the provider down like a 401."""
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _session_request(name, exe, prompt, timeout, questions):
|
|
604
|
+
"""One headless run of the session's own CLI, mapped into the documented
|
|
605
|
+
return. It starts in an empty temp dir with stdin closed - the answer rests
|
|
606
|
+
on the prompt alone, and a CLI that wants a login exits instead of waiting -
|
|
607
|
+
with TEZGAH_NESTED set so tezgah's own hooks stand down in the child. The
|
|
608
|
+
answer is read from the CLI's JSON output: the model and the usage it names
|
|
609
|
+
are what the call really used."""
|
|
610
|
+
import shutil
|
|
611
|
+
import subprocess
|
|
612
|
+
import tempfile
|
|
613
|
+
cwd = tempfile.mkdtemp(prefix="tezgah-judge-")
|
|
614
|
+
started = time.monotonic()
|
|
615
|
+
try:
|
|
616
|
+
proc = subprocess.run([exe] + SESSION_ARGV[name] + [prompt], cwd=cwd,
|
|
617
|
+
stdin=subprocess.DEVNULL, capture_output=True,
|
|
618
|
+
text=True, timeout=timeout,
|
|
619
|
+
env=dict(os.environ, TEZGAH_NESTED="1"))
|
|
620
|
+
finally:
|
|
621
|
+
shutil.rmtree(cwd, ignore_errors=True)
|
|
622
|
+
if proc.returncode:
|
|
623
|
+
raise SessionFailed("%s exit %d: %s" % (name, proc.returncode,
|
|
624
|
+
(proc.stderr or proc.stdout).strip()[-200:]))
|
|
625
|
+
if name == "claude":
|
|
626
|
+
data = json.loads(proc.stdout)
|
|
627
|
+
if data.get("is_error"):
|
|
628
|
+
raise SessionFailed("claude: %s" % str(data.get("result"))[:200])
|
|
629
|
+
text = data.get("result") or ""
|
|
630
|
+
model = next(iter(data.get("modelUsage") or {}), None)
|
|
631
|
+
usage = data.get("usage") or {}
|
|
632
|
+
tokens_in = sum(int(usage.get(k) or 0) for k in (
|
|
633
|
+
"input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
|
|
634
|
+
tokens_out = int(usage.get("output_tokens") or 0)
|
|
635
|
+
else:
|
|
636
|
+
message = None
|
|
637
|
+
for line in proc.stdout.splitlines():
|
|
638
|
+
try:
|
|
639
|
+
event = json.loads(line)
|
|
640
|
+
except ValueError:
|
|
641
|
+
continue
|
|
642
|
+
if isinstance(event, dict) and event.get("type") == "message_end" \
|
|
643
|
+
and (event.get("message") or {}).get("role") == "assistant":
|
|
644
|
+
message = event["message"]
|
|
645
|
+
if message is None:
|
|
646
|
+
raise SessionFailed("omp printed no answer")
|
|
647
|
+
text = "".join(part.get("text") or "" for part in message.get("content") or []
|
|
648
|
+
if isinstance(part, dict) and part.get("type") == "text")
|
|
649
|
+
model = "/".join(str(message[k]) for k in ("provider", "model") if message.get(k))
|
|
650
|
+
usage = message.get("usage") or {}
|
|
651
|
+
tokens_in = sum(int(usage.get(k) or 0) for k in ("input", "cacheRead", "cacheWrite"))
|
|
652
|
+
tokens_out = int(usage.get("output") or 0)
|
|
653
|
+
# A chat model may fence its object; the outermost braces are the object.
|
|
654
|
+
answers = _chat_answers(json.loads(text[text.find("{"):text.rfind("}") + 1]), questions)
|
|
655
|
+
return {"answers": answers,
|
|
656
|
+
"usage": {"input_tokens": tokens_in, "output_tokens": tokens_out},
|
|
657
|
+
"latency_ms": int((time.monotonic() - started) * 1000),
|
|
658
|
+
"model": model}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _why(exc):
|
|
662
|
+
"""A failure as one short reason: the HTTP code, else the exception's text."""
|
|
663
|
+
code = getattr(exc, "code", None)
|
|
664
|
+
if isinstance(code, int):
|
|
665
|
+
return "http %d" % code
|
|
666
|
+
return ("%s: %s" % (type(exc).__name__, exc))[:200]
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# The last judgement's record, one file: what answered, and why it was not the
|
|
670
|
+
# session's own CLI when it was not. `tezgah-status --judge` prints it.
|
|
671
|
+
LAST = "judge-last.json"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _say(line):
|
|
675
|
+
try:
|
|
676
|
+
import sys
|
|
677
|
+
sys.stderr.write("tezgah-judge: %s\n" % line)
|
|
678
|
+
except Exception:
|
|
679
|
+
pass
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _record(provider, model, fallback=None, failed=None, refused=None):
|
|
683
|
+
"""Write the last-use record and say any fallback or refusal on stderr.
|
|
684
|
+
Best effort like every hook write: a cache that cannot be written loses
|
|
685
|
+
the record, never the answer."""
|
|
686
|
+
if fallback and provider:
|
|
687
|
+
_say("answered by %s/%s - %s" % (provider, model, fallback))
|
|
688
|
+
elif refused:
|
|
689
|
+
_say("no judgement: %s (%s)" % (refused, failed))
|
|
690
|
+
row = {"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "provider": provider,
|
|
691
|
+
"model": model, "fallback": fallback, "failed": failed or None,
|
|
692
|
+
"refused": refused, "policy": tp.fallback_policy()}
|
|
693
|
+
try:
|
|
694
|
+
path = os.path.join(tp.cache_dir(), LAST)
|
|
695
|
+
with open(path + ".tmp", "w", encoding="utf-8") as fh:
|
|
696
|
+
json.dump(row, fh)
|
|
697
|
+
os.replace(path + ".tmp", path)
|
|
698
|
+
except (OSError, TypeError, ValueError):
|
|
699
|
+
pass
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def last_use():
|
|
703
|
+
"""The last judgement's record (see `_record`), or None when none was kept."""
|
|
704
|
+
try:
|
|
705
|
+
with open(os.path.join(tp.cache_dir(), LAST), encoding="utf-8") as fh:
|
|
706
|
+
row = json.load(fh)
|
|
707
|
+
except (OSError, ValueError):
|
|
708
|
+
return None
|
|
709
|
+
return row if isinstance(row, dict) else None
|
package/hooks/tezgah_models.py
CHANGED
|
@@ -105,7 +105,10 @@ SLOTS = {
|
|
|
105
105
|
"any": ("anthropic/claude-opus-5.5", "high")},
|
|
106
106
|
}
|
|
107
107
|
AGENT_SLOT = {"tezgah-cheap": "cheap", "tezgah-standard": "standard",
|
|
108
|
-
"tezgah-frontier": "frontier", "tezgah-reviewer": "frontier"
|
|
108
|
+
"tezgah-frontier": "frontier", "tezgah-reviewer": "frontier",
|
|
109
|
+
"tezgah-tester": "cheap", "tezgah-docs": "standard",
|
|
110
|
+
"tezgah-security": "frontier", "tezgah-ui": "standard",
|
|
111
|
+
"tezgah-researcher": "frontier"}
|
|
109
112
|
# The OpenRouter id of every model the table names, with its list price per 1M
|
|
110
113
|
# tokens (input, output) on READ_ON, so `refresh` can say what moved.
|
|
111
114
|
SNAPSHOT = {"anthropic/claude-opus-5.5": (4.0, 20.0),
|
|
@@ -280,17 +283,50 @@ def _selector(family, slot):
|
|
|
280
283
|
return FAMILY_PREFIX[family] + model + (":" + effort if effort else "")
|
|
281
284
|
|
|
282
285
|
|
|
286
|
+
# The OpenRouter id of a host-column model: the route the default `vendor`
|
|
287
|
+
# fallback keeps - the same model through another provider and account - when
|
|
288
|
+
# the `any` column names another vendor's model on that slot.
|
|
289
|
+
OPENROUTER_ID = {"claude-opus-5-5": "anthropic/claude-opus-5.5",
|
|
290
|
+
"zai/glm-5.3-flash": "z-ai/glm-5.3-flash"}
|
|
291
|
+
VENDOR_STEMS = (("claude", "anthropic"), ("glm", "zai"), ("gpt", "openai"),
|
|
292
|
+
("deepseek", "deepseek"))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _vendor(model):
|
|
296
|
+
"""The vendor a table id's model comes from, or the id itself."""
|
|
297
|
+
name = model.split("/")[-1].lower()
|
|
298
|
+
return next((v for stem, v in VENDOR_STEMS if name.startswith(stem)), name)
|
|
299
|
+
|
|
300
|
+
|
|
283
301
|
def _chain(mode, slot, funded, agent=""):
|
|
284
302
|
"""[selector, ...] for one slot: the mode's family first - the user picked it,
|
|
285
|
-
so it leads even when its credential is not visible from here - then
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
303
|
+
so it leads even when its credential is not visible from here - then the
|
|
304
|
+
fallbacks the `fallback` setting allows (`tp.fallback_policy()`).
|
|
305
|
+
|
|
306
|
+
`vendor`, the default: only the mode's own model vendor - the same model
|
|
307
|
+
through OpenRouter, or another funded column whose model is that vendor's -
|
|
308
|
+
because a session on Opus that ran its subagents on GPT and GLM after one
|
|
309
|
+
Anthropic 429 is the failure the owner reported (2026-10-07). `none`: the
|
|
310
|
+
mode's selector alone. `any`: every other funded family, each on that
|
|
311
|
+
slot's own row, rotated by the agent's own name (`agent`): identical chains
|
|
289
312
|
for every agent meant one family's 429 moved all of them onto the same next
|
|
290
313
|
family in the same moment, which is the cascade the chain exists to break
|
|
291
314
|
(independent review, 2026-10-01). The rotation is deterministic - the same
|
|
292
315
|
agent resolves the same order on every machine - so nothing here is random."""
|
|
316
|
+
policy = tp.fallback_policy()
|
|
293
317
|
families = [mode] + [f for f in OMP_FAMILIES if f != mode and f in funded]
|
|
318
|
+
if policy == "none":
|
|
319
|
+
return [_selector(mode, slot)]
|
|
320
|
+
if policy == "vendor":
|
|
321
|
+
model, effort = SLOTS[slot][mode]
|
|
322
|
+
out = [_selector(mode, slot)]
|
|
323
|
+
for family in families[1:]:
|
|
324
|
+
if _vendor(SLOTS[slot][family][0]) == _vendor(model):
|
|
325
|
+
out.append(_selector(family, slot))
|
|
326
|
+
elif family == "any" and model in OPENROUTER_ID:
|
|
327
|
+
out.append("openrouter/" + OPENROUTER_ID[model]
|
|
328
|
+
+ (":" + effort if effort else ""))
|
|
329
|
+
return list(dict.fromkeys(out))
|
|
294
330
|
tail = families[1:]
|
|
295
331
|
if agent and len(tail) > 1:
|
|
296
332
|
shift = sum(ord(c) for c in agent) % len(tail)
|