Skip to content

Commit f9b872a

Browse files
committed
枠が読めなかった理由を、読める形で持ち帰る
使用量の取り直しが失敗したとき、画面に出るのが CLI の返事の JSON の頭 300 字 だけだった。相手は人向けの報告を JSON の中の 1 項目に入れてくるので、並び順 次第でセッション id やトークン数だけが残り、肝心の文面が枠の外へ落ちる。 報告のほうを取り出して渡す。長さも 600 字に揃える —— 外部の相手のエラーで 「300 字では切れて打つ手が読めない」と踏んだのと同じ話だった。 JSON の拾い方も固くする。最初の { から後ろを読む作りだったので、混ざった警告に { が 1 つでもあると、そこから読み始めて必ず失敗していた。しかも落ち方が 「JSON が無い」と同じなので、警告が出た日にだけ静かに読めなくなる。 { を順に試して、中身のあるものを採る —— 空の {} は警告に混ざりうるが、 CLI の本体が空であることはない。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 834cb27 commit f9b872a

3 files changed

Lines changed: 148 additions & 17 deletions

File tree

app/usage.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ async def _elevenlabs(spec, credential: str) -> list[Window]:
177177
if res.status_code in (401, 403):
178178
raise UsageError(f"認証情報が受け付けられませんでした(HTTP {res.status_code})")
179179
if res.status_code >= 400:
180-
raise UsageError(f"HTTP {res.status_code}: {res.text[:300]}")
180+
raise UsageError(f"HTTP {res.status_code}: {res.text[:REASON_MAX]}")
181181
try:
182182
body = res.json()
183183
except ValueError:
@@ -288,10 +288,18 @@ async def _bridge(spec: providers.Provider) -> list[Window]:
288288
)
289289
)
290290
if not windows:
291-
raise UsageError(str(body.get("reason") or "CLI が使用量を返しませんでした")[:300])
291+
raise UsageError(
292+
str(body.get("reason") or "CLI が使用量を返しませんでした")[:REASON_MAX]
293+
)
292294
return windows
293295

294296

297+
# 失敗の理由として持ち帰る長さ。**300 では足りなかった** —— 相手は人向けの報告を
298+
# 返してくるので、頭で切ると「なぜ駄目だったか」が枠の外へ落ちる(実際にそうなった)。
299+
# 絵と音の相手のエラー(`app/media_backends.py` の `remote_error`)と同じ長さにしてある。
300+
REASON_MAX = 600
301+
302+
295303
def _bridge_error(body: dict, status: int) -> str:
296304
"""ブリッジが返した理由を取り出す。
297305
@@ -302,7 +310,7 @@ def _bridge_error(body: dict, status: int) -> str:
302310
if isinstance(detail, dict):
303311
detail = detail.get("error") or detail
304312
reason = body.get("error") or detail
305-
return (str(reason) if reason else f"HTTP {status}")[:300]
313+
return (str(reason) if reason else f"HTTP {status}")[:REASON_MAX]
306314

307315

308316
async def fetch(spec: providers.Provider) -> list[Window]:

bridge/cli_bridge.py

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,11 +1052,8 @@ def _result_of(raw: str) -> tuple[str, dict, str]:
10521052
return _codex_result(raw)
10531053
if CLI not in ("claude", "antigravity"):
10541054
return raw, {}, ""
1055-
try:
1056-
payload = json.loads(_json_tail(raw))
1057-
except ValueError:
1058-
return raw, {}, ""
1059-
if not isinstance(payload, dict):
1055+
payload = _json_object(raw)
1056+
if payload is None:
10601057
return raw, {}, ""
10611058

10621059
if CLI == "claude":
@@ -1301,16 +1298,40 @@ async def health(check: bool = False) -> dict:
13011298
)
13021299

13031300

1304-
def _json_tail(raw: str) -> str:
1305-
"""先に混ざった警告を落として、JSON の本体だけを返す
1301+
def _json_object(raw: str) -> dict | None:
1302+
"""混ざった出力の中から、JSON の本体(辞書)を 1 つ取り出す。読めなければ None
13061303
13071304
**stderr を stdout に混ぜて読んでいる**(`_run_for_usage`)ので、CLI が
13081305
警告を 1 行吐くだけで `json.loads` が落ちる —— 実測で
13091306
`Warning: no stdin data received in 3s, …` が前に付いた。
1310-
最初の `{` から後ろを見る。JSON が無ければそのまま返す(人向けの行として読ませる)。
1307+
1308+
**「最初の `{` から後ろ」では足りない。** 警告のほうに `{` が 1 つでも
1309+
混ざっていると、そこから読み始めて必ず失敗する —— しかも落ち方が
1310+
「JSON が無い」と同じなので、警告が出た日にだけ静かに読めなくなる。
1311+
**`{` を順に試して、最初に読めたものを採る**(`raw_decode` は途中で
1312+
終わっていても、そこまでを 1 つの値として返す)。
1313+
1314+
**中身のあるものを採る。** 空の `{}` は警告のほうに混ざりうるが、CLI の本体が
1315+
空であることはない —— 最初に読めたものを無条件に採ると、飾りの `{}` を
1316+
本体として返してしまう(実際にそうなった)。最後まで空しか無ければそれを返す。
1317+
1318+
入れ子の `{` は外側が先に読めるので、いちばん大きいものを探す必要は無い。
13111319
"""
1320+
decoder = json.JSONDecoder()
1321+
empty: dict | None = None
13121322
at = raw.find("{")
1313-
return raw[at:] if at >= 0 else raw
1323+
while at >= 0:
1324+
try:
1325+
value, _ = decoder.raw_decode(raw, at)
1326+
except ValueError:
1327+
at = raw.find("{", at + 1)
1328+
continue
1329+
if isinstance(value, dict):
1330+
if value:
1331+
return value
1332+
empty = empty if empty is not None else value
1333+
at = raw.find("{", at + 1)
1334+
return empty
13141335

13151336

13161337
# claude が言う戻る時刻。**年を書かない**(実測: `Sep 16, 7:10pm (UTC)` /
@@ -1359,10 +1380,8 @@ def _claude_windows(raw: str) -> list[dict]:
13591380
サブスクの枠ではない。
13601381
"""
13611382
text = raw
1362-
with suppress(ValueError):
1363-
payload = json.loads(_json_tail(raw))
1364-
if isinstance(payload, dict):
1365-
text = str(payload.get("result") or "")
1383+
if (payload := _json_object(raw)) is not None:
1384+
text = str(payload.get("result") or "")
13661385
windows = []
13671386
for line in text.splitlines():
13681387
if not (found := _CLAUDE_USAGE_RE.match(line.strip())):
@@ -1691,6 +1710,31 @@ async def _talk() -> tuple[list[dict], str]:
16911710
_LAST_USAGE: dict | None = None
16921711

16931712

1713+
# 窓を組めなかったときに持ち帰る理由の長さ。**300 では足りなかった** ——
1714+
# CLI は人向けの報告を JSON の奥に入れてくるので、頭で切ると metadata だけが残り、
1715+
# 「なぜ読めなかったか」が枠の外へ落ちる(実際にそうなった)。
1716+
# 外部の相手のエラー(`app/media_backends.py` の `remote_error`)と同じ長さにしてある。
1717+
REASON_MAX = 600
1718+
1719+
1720+
def _usage_reason(raw: str) -> str:
1721+
"""窓を組めなかったときに Chiezo へ渡す理由。**人向けの報告のほうを渡す。**
1722+
1723+
CLI が返すのは envelope で、読ませたい文面はその中の 1 項目にある
1724+
(claude は `result`、agy は `response`)。envelope をそのまま切り詰めると、
1725+
並び順次第でセッション id やトークン数だけが残る。
1726+
1727+
読めなければ元の文字列を返す(codex の JSON-RPC はそれ自体が短い)。
1728+
"""
1729+
text = raw
1730+
if (payload := _json_object(raw)) is not None:
1731+
for key in ("result", "response"):
1732+
if isinstance(payload.get(key), str) and payload[key].strip():
1733+
text = payload[key]
1734+
break
1735+
return text.strip()[:REASON_MAX]
1736+
1737+
16941738
async def _read_usage() -> dict:
16951739
"""CLI に枠を聞いて、窓の一覧に直す。**待ち枠は取らない**(呼ぶ側の仕事)。"""
16961740
if CLI == "codex":
@@ -1705,7 +1749,7 @@ async def _read_usage() -> dict:
17051749
"cli": CLI,
17061750
"windows": windows,
17071751
# 窓を組めなかったときだけ意味を持つ(Chiezo がそのまま画面に出す)。
1708-
"reason": "" if windows else raw[:300],
1752+
"reason": "" if windows else _usage_reason(raw),
17091753
"taken_at": datetime.now(UTC).isoformat(timespec="seconds"),
17101754
"stale": False,
17111755
}

tests/test_bridge.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,3 +1560,82 @@ def test_the_same_window_seen_twice_is_still_one(self, bridge):
15601560
windows = server._windows_in({"a": {"primary": same}, "b": {"primary": dict(same)}})
15611561

15621562
assert [w["id"] for w in windows] == ["primary"]
1563+
1564+
1565+
class TestWhyTheQuotaCouldNotBeRead:
1566+
"""窓を組めなかったときに、**なぜ読めなかったか**を持ち帰る。
1567+
1568+
CLI は人向けの報告を JSON の奥に入れてくる。envelope をそのまま切り詰めると、
1569+
並び順次第でセッション id やトークン数だけが残り、肝心の文面が枠の外へ落ちる
1570+
(実際にそうなった)。
1571+
"""
1572+
1573+
def test_the_human_readable_part_is_what_travels(self, bridge):
1574+
server = bridge(CHIEZO_BRIDGE_CLI="claude")
1575+
raw = json.dumps({
1576+
"is_error": False, "num_turns": 0, "session_id": "x" * 200,
1577+
"usage": {"input_tokens": 0, "output_tokens": 0},
1578+
"result": "Not logged in · Please run /login",
1579+
})
1580+
1581+
assert server._usage_reason(raw) == "Not logged in · Please run /login"
1582+
1583+
def test_antigravity_says_it_in_another_field(self, bridge):
1584+
server = bridge(CHIEZO_BRIDGE_CLI="antigravity")
1585+
raw = json.dumps({"status": "failed", "response": "枠を読めませんでした"})
1586+
1587+
assert server._usage_reason(raw) == "枠を読めませんでした"
1588+
1589+
def test_something_unreadable_travels_as_it_is(self, bridge):
1590+
"""codex の JSON-RPC はそれ自体が短いので、そのまま渡す。"""
1591+
server = bridge(CHIEZO_BRIDGE_CLI="codex")
1592+
1593+
assert server._usage_reason("rate limits unavailable") == "rate limits unavailable"
1594+
1595+
def test_it_is_cut_but_not_at_300(self, bridge):
1596+
"""300 では足りなかった —— 頭で切ると打つ手が読めない。"""
1597+
server = bridge(CHIEZO_BRIDGE_CLI="claude")
1598+
raw = json.dumps({"result": "あ" * 800})
1599+
1600+
assert len(server._usage_reason(raw)) == server.REASON_MAX
1601+
1602+
1603+
class TestFindingTheJson:
1604+
"""混ざった出力の中から JSON の本体を拾う(`_json_object`)。
1605+
1606+
stderr を stdout に混ぜて読んでいるので、CLI が警告を 1 行吐くだけで
1607+
素の `json.loads` は落ちる。
1608+
"""
1609+
1610+
PANEL: ClassVar[str] = json.dumps({"result": "Current session: 20% used"})
1611+
1612+
def test_a_warning_in_front_is_stepped_over(self, bridge):
1613+
server = bridge(CHIEZO_BRIDGE_CLI="claude")
1614+
raw = "Warning: no stdin data received in 3s\n" + self.PANEL
1615+
1616+
assert server._json_object(raw)["result"].startswith("Current session")
1617+
1618+
def test_a_brace_in_the_warning_does_not_break_it(self, bridge):
1619+
"""**ここが「最初の `{` から後ろ」では駄目なところ。** 警告に `{` が 1 つ
1620+
混ざると、そこから読み始めて必ず失敗する —— しかも落ち方が「JSON が無い」と
1621+
同じなので、警告が出た日にだけ静かに読めなくなる。
1622+
"""
1623+
server = bridge(CHIEZO_BRIDGE_CLI="claude")
1624+
raw = "Warning: config {foo} is deprecated\n" + self.PANEL
1625+
1626+
assert server._json_object(raw)["result"].startswith("Current session")
1627+
1628+
def test_no_json_at_all_is_not_an_answer(self, bridge):
1629+
server = bridge(CHIEZO_BRIDGE_CLI="claude")
1630+
1631+
assert server._json_object("Not logged in") is None
1632+
1633+
def test_the_panel_survives_a_noisy_line(self, bridge):
1634+
"""窓まで組めることを通しで見る(拾えても読めなければ意味が無い)。"""
1635+
server = bridge(CHIEZO_BRIDGE_CLI="claude")
1636+
raw = "note: {} \n" + json.dumps({
1637+
"result": ("Current session: 20% used · resets Sep 16, 7:10pm (UTC)\n"
1638+
"Current week (all models): 25% used · resets Sep 20, 5:59am (UTC)"),
1639+
})
1640+
1641+
assert [w["used_percent"] for w in server._claude_windows(raw)] == [20.0, 25.0]

0 commit comments

Comments
 (0)