Skip to content

Commit e1404d6

Browse files
committed
枠は、相手が言ったそのままも控えて画面に出す
画面に出るのは正規化した名前と割合だけで、窓の名前すら相手のものではない —— 相手が同じ名前の窓を 2 つ返したときは、こちらが窓の長さを添えて呼び分けている。 元を当たる道が無いと「この行は何なのか」に答えられない。実際、codex が返す 2 つ目の primary が何の制限なのか、画面からは読めなかった。 窓に直せたときも控える。これまで生の返事は、窓を組めなかったときの言い訳 としてしか持ち帰っていなかった。 整形も翻訳もしない。そのまま検索できることに値打ちがある —— 相手の資料や issue に当たるとき、こちらで直した文字列では引けない。 取れなかった回は前の控えを消さない(窓と同じ扱い)。一時的に繋がらないだけの ことがあり、直前まで見えていたものが消えるほうが分かりにくい。 codex の切り出しを 500 文字から広げる。500 では rateLimitsByLimitId の手前で 切れて、いちばん知りたいところが落ちていた。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 65bf06a commit e1404d6

7 files changed

Lines changed: 175 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,13 @@ GeoNames 全世界地名辞典 = `geonames`(いずれも 348 言語版・195 か
13381338
使用率が交互に跳ね上がったり 0 に戻ったりして見える。
13391339
**ぶつかったものは全部付け替える** —— 片方に元の名前を残すと、どちらが残るかが
13401340
相手の並べ方次第になり、次に聞いたときに入れ替わる
1341+
- **相手が言ったそのままも控える**(`Quota.raw` / 画面の「相手が言ったそのまま」)。
1342+
**窓に直せたときも控える** —— 画面に出るのは Chiezo が付けた名前と割合だけで、
1343+
窓の名前すら相手のものではない(ぶつかれば長さを添えて呼び分ける)。
1344+
**元を当たる道が無いと「この行は何なのか」に答えられない** ——
1345+
実測で、codex が返す 2 つ目の `primary` が何の制限なのか画面から読めなかった。
1346+
**整形も翻訳もしない**(そのまま検索できることに値打ちがある)。
1347+
**取れなかった回は前の控えを消さない**(窓と同じ扱い)
13411348
- **同じ名前の窓は id を添えて呼び分ける**(`usage.arranged`)。名前は長さから
13421349
作るので、同じ長さの窓が 2 つあると見分けが付かない(実測: codex は 7 日の窓を
13431350
2 つ返す。別勘定で値も違う)。**ぶつかったものは全部添える** ——

app/pages.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,12 @@ def doc_url(source: str, doc_id: int) -> str:
331331
/* 使用量の表。枠の欄は折り返す —— 1 行が「帯 + 使用率 + 戻る時刻」で長く、
332332
nowrap のままだと狭い画面でこの列だけが伸びて他を押し潰す。 */
333333
table.ai-usage td { white-space: normal; }
334+
/* 相手が言ったそのまま。**長いので高さを切って中で送る** —— 枠の欄に
335+
そのまま流すと、開いた瞬間に他の相手の行が画面の外へ出る。 */
336+
details.raw-quota pre { white-space: pre-wrap; word-break: break-all; margin: 0.3rem 0;
337+
max-height: 14rem; overflow: auto; background: #f7f7fa;
338+
border-left: 3px solid #d5d7e8; padding: 0.5rem 0.6rem;
339+
font-size: 0.75rem; line-height: 1.5; }
334340
/* 使用率の帯。数字と併せて出す(色の見え方だけで読ませない)。
335341
70% / 90% で色を変えるのは、押す前に「もう頼めないか」を見分けるため。 */
336342
.meter { display: inline-block; width: 6rem; height: 0.55rem; vertical-align: middle;

app/usage.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,17 @@ class Quota:
9797
fetched_at: str = ""
9898
error: str = ""
9999
windows: list[Window] = field(default_factory=list)
100+
# 相手が言ったそのまま。**窓に直せたときも持つ** —— 画面に出るのは Chiezo が
101+
# 付けた名前と割合だけなので、元を当たれないと「この行は何か」に答えられない
102+
raw: str = ""
100103

101104
def as_dict(self) -> dict:
102105
return {
103106
"supported": self.supported,
104107
"fetched_at": self.fetched_at,
105108
"error": self.error,
106109
"windows": [w.as_dict() for w in self.windows],
110+
"raw": self.raw,
107111
}
108112

109113

@@ -162,7 +166,7 @@ def _window_label(minutes: float | None, fallback: str) -> str:
162166
# ---- 相手ごとの聞き方 -------------------------------------------------------
163167

164168

165-
async def _elevenlabs(spec, credential: str) -> list[Window]:
169+
async def _elevenlabs(spec, credential: str) -> tuple[list[Window], str]:
166170
"""ElevenLabs の枠。声・効果音・曲・絵・動画が同じ 1 つの残量を食う。
167171
168172
`GET /v1/user/subscription` は鍵だけで引ける —— 生成も会話もしないので、
@@ -198,15 +202,15 @@ async def _elevenlabs(spec, credential: str) -> list[Window]:
198202
used=used,
199203
limit=limit,
200204
unit="クレジット",
201-
)]
205+
)], res.text[:RAW_MAX]
202206

203207

204208
def _amount(value) -> float | None:
205209
"""数として読む。読めない値は None(0 と混ぜない)。"""
206210
return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else None
207211

208212

209-
async def _openrouter(spec: providers.Provider, credential: str) -> list[Window]:
213+
async def _openrouter(spec: providers.Provider, credential: str) -> tuple[list[Window], str]:
210214
"""OpenRouter のクレジット。使った額と、上限があれば残高。
211215
212216
上限が無い鍵もある(従量課金)ので、`limit` が null のときは使用額だけ出す ——
@@ -243,10 +247,10 @@ async def _openrouter(spec: providers.Provider, credential: str) -> list[Window]
243247
limit=limit_f,
244248
unit="USD",
245249
)
246-
]
250+
], res.text[:RAW_MAX]
247251

248252

249-
async def _bridge(spec: providers.Provider) -> list[Window]:
253+
async def _bridge(spec: providers.Provider) -> tuple[list[Window], str]:
250254
"""CLI ブリッジに聞く(ブリッジが CLI に聞く)。
251255
252256
ブリッジが立っていなければ取れない。 枠は CLI の中にしか無いので、
@@ -291,14 +295,21 @@ async def _bridge(spec: providers.Provider) -> list[Window]:
291295
raise UsageError(
292296
str(body.get("reason") or "CLI が使用量を返しませんでした")[:REASON_MAX]
293297
)
294-
return windows
298+
return windows, str(body.get("raw") or "")[:RAW_MAX]
295299

296300

297301
# 失敗の理由として持ち帰る長さ。**300 では足りなかった** —— 相手は人向けの報告を
298302
# 返してくるので、頭で切ると「なぜ駄目だったか」が枠の外へ落ちる(実際にそうなった)。
299303
# 絵と音の相手のエラー(`app/media_backends.py` の `remote_error`)と同じ長さにしてある。
300304
REASON_MAX = 600
301305

306+
# 相手が言ったそのままを控える長さの上限。**読めたときにも控える** ——
307+
# 正規化した後の画面には Chiezo が付けた名前しか出ないので、元を当たれないと
308+
# 「この行は何なのか」に答えられない(実測: codex が同じ名前の窓を 2 つ返し、
309+
# 片方が何の制限なのか画面からは分からなかった)。
310+
# **整形も翻訳もしない** —— そのまま検索できることに値打ちがある。
311+
RAW_MAX = 4000
312+
302313

303314
def _bridge_error(body: dict, status: int) -> str:
304315
"""ブリッジが返した理由を取り出す。
@@ -313,8 +324,13 @@ def _bridge_error(body: dict, status: int) -> str:
313324
return (str(reason) if reason else f"HTTP {status}")[:REASON_MAX]
314325

315326

316-
async def fetch(spec: providers.Provider) -> list[Window]:
317-
"""その相手の枠を取りに行く。取れなければ `UsageError`。"""
327+
async def fetch(spec: providers.Provider) -> tuple[list[Window], str]:
328+
"""その相手の枠と、**相手が言ったそのまま**を取りに行く。取れなければ `UsageError`。
329+
330+
生のほうを一緒に持ち帰るのは、正規化で落ちるものがあるから —— 画面に出るのは
331+
Chiezo が付けた名前と割合だけで、**元の資料に当たる道が無い**と「この行は何か」に
332+
答えられない(古い版は捨てていて、実際に答えられなかった)。
333+
"""
318334
if not spec.usage:
319335
raise UsageError("この相手は使用量を出しません")
320336
credential = settings_store.load(spec.id).credential
@@ -343,7 +359,7 @@ async def refresh(provider_id: str) -> Quota:
343359
if spec is None or not spec.usage:
344360
return Quota(supported=False)
345361
try:
346-
windows = await fetch(spec)
362+
windows, raw = await fetch(spec)
347363
except UsageError as e:
348364
usage_store.save_quota(spec.id, [], str(e))
349365
stored = usage_store.load_quota().get(spec.id, {})
@@ -352,10 +368,13 @@ async def refresh(provider_id: str) -> Quota:
352368
fetched_at=stored.get("fetched_at", ""),
353369
error=str(e),
354370
windows=_windows_from(stored.get("windows", [])),
371+
# **失敗しても前の生の返事は残す**(窓と同じ扱い)—— 一時的に繋がらない
372+
# だけのことがあり、直前まで見えていたものが消えるほうが分かりにくい
373+
raw=str(stored.get("raw") or ""),
355374
)
356375
windows = arranged(windows)
357-
usage_store.save_quota(spec.id, [asdict(w) for w in windows])
358-
return Quota(supported=True, fetched_at=_now_iso(), windows=windows)
376+
usage_store.save_quota(spec.id, [asdict(w) for w in windows], raw=raw)
377+
return Quota(supported=True, fetched_at=_now_iso(), windows=windows, raw=raw)
359378

360379

361380
def arranged(windows: list[Window]) -> list[Window]:
@@ -409,6 +428,7 @@ def _stored_quota(provider_id: str, spec_usage: str, stored: dict) -> Quota:
409428
fetched_at=row.get("fetched_at", ""),
410429
error=row.get("error", ""),
411430
windows=_windows_from(row.get("windows", [])),
431+
raw=str(row.get("raw") or ""),
412432
)
413433

414434

app/usage_store.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
payload TEXT NOT NULL DEFAULT '[]',
6969
error TEXT NOT NULL DEFAULT ''
7070
);
71+
-- 後から足した列は `_ADDED_QUOTA_COLUMNS` が接続時に足す(`calls` と同じ流儀)。
7172
-- 枠の推移。**上の `quota` は「いまどうか」しか持たない**(聞くたびに上書きする)ので、
7273
-- 「いつ跳ねたか」も「1 ポイントぶんが何回ぶんか」も読めなかった。2 点無いと差が
7374
-- 取れないので、聞いた値をここに積む。
@@ -115,6 +116,14 @@
115116
"transcript_id": "TEXT",
116117
}
117118

119+
# `quota` に後から足した列。**相手が言ったそのまま** —— 画面に出るのは Chiezo が
120+
# 正規化した名前と割合だけで、元の資料に当たる道が無いと「この行は何か」に
121+
# 答えられない(実測: codex が同じ名前の窓を 2 つ返し、片方が何の制限なのか
122+
# 画面からは分からなかった)。
123+
_ADDED_QUOTA_COLUMNS = {
124+
"raw": "TEXT NOT NULL DEFAULT ''",
125+
}
126+
118127

119128
@dataclass(frozen=True)
120129
class Spent:
@@ -157,10 +166,11 @@ def _connect() -> sqlite3.Connection:
157166
# マウントしていることが多く、共有ファイルシステムでは WAL が使えないことがある。
158167
conn.execute("PRAGMA journal_mode=DELETE")
159168
conn.executescript(_SCHEMA)
160-
have = {row["name"] for row in conn.execute("PRAGMA table_info(calls)")}
161-
for name, kind in _ADDED_COLUMNS.items():
162-
if name not in have:
163-
conn.execute(f"ALTER TABLE calls ADD COLUMN {name} {kind}")
169+
for table, added in (("calls", _ADDED_COLUMNS), ("quota", _ADDED_QUOTA_COLUMNS)):
170+
have = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
171+
for name, kind in added.items():
172+
if name not in have:
173+
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {kind}")
164174
return conn
165175

166176

@@ -360,7 +370,7 @@ def first_recorded_at() -> str | None:
360370
return row[0] if row and row[0] else None
361371

362372

363-
def save_quota(provider: str, windows: list[dict], error: str = "") -> None:
373+
def save_quota(provider: str, windows: list[dict], error: str = "", raw: str = "") -> None:
364374
"""相手から聞いた枠を控える(取れなかったときは理由を控える)。
365375
366376
取れなかったときに前の値を消さない —— 一時的に繋がらないだけのことがあり、
@@ -382,10 +392,12 @@ def save_quota(provider: str, windows: list[dict], error: str = "") -> None:
382392
return
383393
at = _now().isoformat(timespec="seconds")
384394
conn.execute(
385-
"INSERT INTO quota (provider, fetched_at, payload, error) VALUES (?, ?, ?, ?)"
395+
"INSERT INTO quota (provider, fetched_at, payload, error, raw)"
396+
" VALUES (?, ?, ?, ?, ?)"
386397
" ON CONFLICT(provider) DO UPDATE SET"
387-
" fetched_at=excluded.fetched_at, payload=excluded.payload, error=excluded.error",
388-
(provider, at, json.dumps(windows, ensure_ascii=False), error),
398+
" fetched_at=excluded.fetched_at, payload=excluded.payload,"
399+
" error=excluded.error, raw=excluded.raw",
400+
(provider, at, json.dumps(windows, ensure_ascii=False), error, raw),
389401
)
390402
# **聞けた値はここにも積む**(`quota` は上書きなので推移が残らない)。
391403
# 使用率を言わない窓は積まない —— 差を取る相手が無い。
@@ -410,7 +422,9 @@ def load_quota() -> dict[str, dict]:
410422
return {}
411423
try:
412424
with _connect() as conn:
413-
rows = conn.execute("SELECT provider, fetched_at, payload, error FROM quota").fetchall()
425+
rows = conn.execute(
426+
"SELECT provider, fetched_at, payload, error, raw FROM quota"
427+
).fetchall()
414428
except (sqlite3.Error, OSError) as e:
415429
log.warning("usage quota read failed: %s", e)
416430
return {}
@@ -424,6 +438,7 @@ def load_quota() -> dict[str, dict]:
424438
"fetched_at": r["fetched_at"],
425439
"windows": windows if isinstance(windows, list) else [],
426440
"error": r["error"] or "",
441+
"raw": r["raw"] or "",
427442
}
428443
return out
429444

app/views/ai_usage.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,26 @@ def _quota_cell(row: dict) -> str:
8484
lines.append(f'<span class="stale">⚠️ 取れませんでした: {esc(quota.error)}</span>')
8585
elif not quota.windows:
8686
lines.append('<span class="muted">まだ取っていない(「取り直す」を押す)</span>')
87-
return "<br>".join(lines)
87+
return "<br>".join(lines) + _raw_html(quota)
88+
89+
90+
def _raw_html(quota: usage.Quota) -> str:
91+
"""相手が言ったそのまま。**畳んで置く**(読みに来た人だけが開く)。
92+
93+
**正規化した後の画面だけでは答えられないことがある。** 出ているのは Chiezo が
94+
付けた名前と割合で、窓の名前すら相手のものではない —— 相手が同じ名前の窓を
95+
2 つ返したときは、長さを添えて呼び分けている。「この行は何なのか」を確かめるには
96+
元を当たるしかないので、捨てずにここへ置く。
97+
98+
**整形も翻訳もしない。** そのまま検索できることに値打ちがある
99+
(相手の資料や issue に当たるとき、こちらで直した文字列では引けない)。
100+
"""
101+
if not quota.raw:
102+
return ""
103+
return (
104+
'<details class="raw-quota"><summary class="muted">相手が言ったそのまま</summary>'
105+
f"<pre>{esc(quota.raw)}</pre></details>"
106+
)
88107

89108

90109
def _spent_cell(row: dict) -> str:
@@ -478,6 +497,9 @@ def section_html(request: Request | None = None) -> str:
478497
<p><strong>開いたときには聞きに行かない。</strong>控えてある値と「いつ取ったか」を出し、
479498
取り直しはボタンで —— 落ちている相手がいると、その数だけ画面が遅れるため。
480499
まとめて取り直すボタンは<strong>並行に聞く</strong>ので、待つのは一番遅い相手のぶんだけ。
500+
<strong>枠の欄の「相手が言ったそのまま」</strong>には、正規化する前の返事が入っている ——
501+
画面に出ている名前は Chiezo が付けたもの(相手が同じ名前の窓を 2 つ返したときは、
502+
窓の長さを添えて呼び分ける)なので、<strong>その行が何なのかは元を当たらないと読めない</strong>。
481503
API からは <code>GET /v1/ai/usage</code>(取り直すなら <code>?refresh=1</code>)。</p>
482504
</details>
483505
{since_note}

bridge/cli_bridge.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1694,7 +1694,7 @@ async def _talk() -> tuple[list[dict], str]:
16941694
if error := message.get("error"):
16951695
return [], str(error.get("message") or error)[:300]
16961696
result = message.get("result") or {}
1697-
return _windows_in(result), json.dumps(result, ensure_ascii=False)[:500]
1697+
return _windows_in(result), json.dumps(result, ensure_ascii=False)[:RAW_MAX]
16981698
return [], "codex app-server が応答を返しませんでした"
16991699

17001700
try:
@@ -1725,6 +1725,12 @@ async def _talk() -> tuple[list[dict], str]:
17251725
# 外部の相手のエラー(`app/media_backends.py` の `remote_error`)と同じ長さにしてある。
17261726
REASON_MAX = 600
17271727

1728+
# 生の返事をそのまま返す長さの上限。**読めたときにも返す** —— 窓に直せたあとでも
1729+
# 「この行は何なのか」を確かめる手が要る(実測: codex が同じ名前の窓を 2 つ返し、
1730+
# 片方が何の制限なのか、正規化した後の画面からは分からなかった)。
1731+
# **理由より長く取る** —— あちらは読めなかったときの言い訳で、こちらは元の資料
1732+
RAW_MAX = 4000
1733+
17281734

17291735
# 会話を始めずに終わった print モードの締め。**枠のパネルではない。**
17301736
# 数字はどれも 0 で、見せられても打つ手が分からない(実測の文面をそのまま判定に使う)。
@@ -1782,6 +1788,10 @@ async def _read_usage() -> dict:
17821788
"windows": windows,
17831789
# 窓を組めなかったときだけ意味を持つ(Chiezo がそのまま画面に出す)。
17841790
"reason": "" if windows else _usage_reason(raw),
1791+
# **相手が言ったそのまま。** 窓に直せたときも返す —— 正規化した後の画面には
1792+
# こちらが付けた名前しか出ないので、元を当たれないと「この行は何か」に
1793+
# 答えられない。整形も翻訳もしない(そのまま検索できることに値打ちがある)
1794+
"raw": str(raw or "")[:RAW_MAX],
17851795
"taken_at": datetime.now(UTC).isoformat(timespec="seconds"),
17861796
"stale": False,
17871797
}

0 commit comments

Comments
 (0)