clauderipple 0.9.0 → 0.9.1

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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 — 2026-10-14
4
+
5
+ ### Added
6
+
7
+ - **The request log records more of each request** (`logs/requests.jsonl`, #55). Every new field is
8
+ optional, so older records and readers keep working.
9
+ - Anthropic's request id or the message id, to match a row to the transcript, subagents included.
10
+ - Why a request ended before reaching a model, with the right provider instead of `anthropic`.
11
+ - Whether a call was the auto-mode classifier or a web search.
12
+ - An explicit `cacheWrite: 0`.
13
+ - The service tier that was asked for and the one the provider served. This shows whether a Fast
14
+ request was honoured (#54).
15
+
16
+ ### Fixed
17
+
18
+ - Saving the mod settings failed on Windows with `EPERM … fsync` since 0.9.0 (#53).
19
+ - "Haiku returned no verdict" appeared now and then. When Haiku breaks the end of its JSON, the
20
+ verdict's complete fields are now read instead of the whole answer being dropped (#53).
21
+ - A response over 64KiB that arrived in one piece (every compressed answer, for one) lost its usage
22
+ and stop reason in the request log.
23
+ - Alert cards centre their buttons when the title takes two lines.
24
+ - Ripple Remote no longer lists a resumed session's old halves, and a host restart no longer marks
25
+ idle sessions as just used.
26
+
3
27
  ## 0.9.0 — 2026-10-13
4
28
 
5
29
  ### Added
package/README.es.md CHANGED
@@ -22,16 +22,24 @@
22
22
  </p>
23
23
 
24
24
  <p align="center">
25
- <img src="docs/media/tour.png" width="880" alt="Recorrido por la configuración de ClaudeRipple: estado, asignación de modelos, proveedores, clientes, registro de solicitudes">
25
+ <a href="https://github.com/PBJ-2/clauderipple/releases/download/v0.9.0/clauderipple-demo.mp4"><img src="docs/media/demo.webp" width="880" alt="Recorrido de un minuto: otros modelos en el propio menú de modelos de Claude Desktop, el esfuerzo ajustado en cada prompt y cada subagente enviado al modelo adecuado. Haz clic para ver el vídeo completo."></a><br>
26
+ <sub>Recorrido de un minuto: otros modelos en el propio menú de modelos de Claude Desktop, el esfuerzo ajustado en cada prompt y cada subagente enviado al modelo adecuado. Haz clic para ver el vídeo completo.</sub>
26
27
  </p>
27
28
 
28
29
  <table align="center">
29
30
  <tr>
30
- <td align="center" width="34%"><img src="docs/media/picker-zoom.png" width="300" alt="Nombres reales en el selector de Claude Desktop"><br><sub>Modelos GPT con su nombre real en el selector de Claude Desktop</sub></td>
31
- <td align="center" width="66%"><img src="docs/media/luna-answer.png" alt="GPT-5.6 Luna respondiendo en la pestaña Code de Claude Desktop"><br><sub>GPT-5.6 Luna respondiendo en la pestaña Code, con el esfuerzo que elegiste</sub></td>
31
+ <td align="center" width="34%"><img src="docs/media/picker.png" width="300" alt="GPT, DeepSeek, GLM y Gemini en el propio menú de modelos de Claude Desktop"><br><sub>GPT, DeepSeek, GLM y Gemini en el propio menú de modelos de Claude Desktop</sub></td>
32
+ <td align="center" width="66%"><img src="docs/media/answer.png" alt="GPT-6 Astra trabajando en la pestaña Code, con cada solicitud registrada a la derecha"><br><sub>GPT-6 Astra trabajando en la pestaña Code, con cada solicitud registrada a la derecha</sub></td>
32
33
  </tr>
33
34
  <tr>
34
- <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="Subagentes con GPT-5.6 Terra y Sol, identificados en el panel de tareas en segundo plano"><br><sub>Subagentes con GPT-5.6 Terra y Sol, identificados en el panel de tareas en segundo plano</sub></td>
35
+ <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="Subagentes con DeepSeek y GLM, identificados en el panel de tareas"><br><sub>Subagentes con DeepSeek y GLM, identificados en el panel de tareas</sub></td>
36
+ </tr>
37
+ <tr>
38
+ <td colspan="2" align="center"><img src="docs/media/mod-band.png" alt="El mod ripple: esfuerzo, contexto, temporizador de caché y qué modelo respondió, con su acierto de caché"><br><sub>El mod ripple: esfuerzo, contexto, temporizador de caché y qué modelo respondió, con su acierto de caché</sub></td>
39
+ </tr>
40
+ <tr>
41
+ <td align="center" width="50%"><img src="docs/media/mod-alerts.png" alt="Avisos cuando la caché se enfría o un límite está cerca, y traspaso a un chat nuevo"><br><sub>Avisos cuando la caché se enfría o un límite está cerca, y traspaso a un chat nuevo</sub></td>
42
+ <td align="center" width="50%"><img src="docs/media/mod-settings.png" alt="Ajustes rápidos: esfuerzo automático, el juez, modos de workers"><br><sub>Ajustes rápidos: esfuerzo automático, el juez, modos de workers</sub></td>
35
43
  </tr>
36
44
  </table>
37
45
 
@@ -300,13 +308,15 @@ los modelos que asignas van a tu proveedor, y todo lo demás va a Anthropic byte
300
308
  la última hora.
301
309
  - **Etiquetas de subagentes.** El panel de tareas en segundo plano muestra
302
310
  `Terra·high · Review` en lugar de un genérico "Agent".
303
- - **La ruta, donde escribes.** Un mod de Claude Code añade una línea encima del prompt:
304
- adónde fue tu última solicitud, su esfuerzo, el acierto de caché y el tiempo. `Log` (o
305
- `/ripple-log`) abre el registro de solicitudes en un panel lateral, con colores según el
306
- proveedor y el resultado; `×` (o `/ripple-bar`) oculta la línea. El mismo mod mantiene a
307
- un worker en su propio modelo cuando la llamada a Agent indica otro, y lee su marcador
308
- `[[ripple: name@level]]` esté donde esté en el prompt. Un solo interruptor en la
309
- pantalla Clientes, o `clauderipple mod on`.
311
+ - **El mod ripple, encima del prompt.** Una banda muestra el esfuerzo de razonamiento, el
312
+ contexto, el temporizador de caché y qué modelo respondió tu última solicitud, con su
313
+ acierto de caché. Un pequeño modelo juez (Haiku 5.5 o Jev) lee cada prompt junto con los
314
+ turnos recientes y fija el esfuerzo, de low a max. El trabajo delegado va a `ripple:auto`,
315
+ y el juez envía cada tarea a un worker según su dificultad: lo ligero a DeepSeek o GLM, lo
316
+ difícil a GPT u Opus. Unas tarjetas avisan cuando la caché se ha enfriado, el contexto pesa
317
+ demasiado o una ventana del plan pasa del 80 %, y ofrecen compactar o traspasar a una sesión
318
+ nueva en la misma carpeta. `Log` (o `/ripple-log`) abre el registro de solicitudes en un
319
+ panel lateral. Un solo interruptor en la pantalla Clientes, o `clauderipple mod on`.
310
320
  - **Imágenes con tu suscripción de ChatGPT.** `clauderipple image "<prompt>"`, con
311
321
  imágenes de referencia y fondos transparentes. A los subagentes se les explica cómo
312
322
  hacerlo, así que un worker de GPT o DeepSeek puede crear una imagen y verla sin una
package/README.ja.md CHANGED
@@ -21,16 +21,24 @@
21
21
  </p>
22
22
 
23
23
  <p align="center">
24
- <img src="docs/media/tour.png" width="880" alt="ClaudeRippleの設定画面:ステータス、モデルマッピング、プロバイダー、クライアント、リクエストログ">
24
+ <a href="https://github.com/PBJ-2/clauderipple/releases/download/v0.9.0/clauderipple-demo.mp4"><img src="docs/media/demo.webp" width="880" alt="1分ツアー:Claude Desktop自身のモデルメニューに他のモデルが並び、プロンプトごとに推論強度を決め、サブエージェントは合うモデルに振り分けます。クリックで全編を再生。"></a><br>
25
+ <sub>1分ツアー:Claude Desktop自身のモデルメニューに他のモデルが並び、プロンプトごとに推論強度を決め、サブエージェントは合うモデルに振り分けます。クリックで全編を再生。</sub>
25
26
  </p>
26
27
 
27
28
  <table align="center">
28
29
  <tr>
29
- <td align="center" width="34%"><img src="docs/media/picker-zoom.png" width="300" alt="Claude Desktopのピッカーに実際の名前で表示"><br><sub>Claude DesktopのピッカーにGPTモデルが実際の名前で並ぶ</sub></td>
30
- <td align="center" width="66%"><img src="docs/media/luna-answer.png" alt="Claude DesktopのCodeタブで応答するGPT-5.6 Luna"><br><sub>選んだ推論強度で、Codeタブから応答するGPT-5.6 Luna</sub></td>
30
+ <td align="center" width="34%"><img src="docs/media/picker.png" width="300" alt="Claude Desktop本来のモデルメニューにGPT・DeepSeek・GLM・Gemini"><br><sub>Claude Desktop本来のモデルメニューにGPT・DeepSeek・GLM・Gemini</sub></td>
31
+ <td align="center" width="66%"><img src="docs/media/answer.png" alt="Codeタブで作業するGPT-6 Astra、右側にすべてのリクエストログ"><br><sub>Codeタブで作業するGPT-6 Astra、右側にすべてのリクエストログ</sub></td>
31
32
  </tr>
32
33
  <tr>
33
- <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="GPT-5.6 TerraとSolで動くサブエージェント。バックグラウンドタスクパネルにモデル名が表示される"><br><sub>GPT-5.6 TerraとSolで動くサブエージェント。バックグラウンドタスクパネルにモデル名が表示される</sub></td>
34
+ <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="DeepSeekとGLMで動くサブエージェント。タスクパネルに実際の名前で表示"><br><sub>DeepSeekとGLMで動くサブエージェント。タスクパネルに実際の名前で表示</sub></td>
35
+ </tr>
36
+ <tr>
37
+ <td colspan="2" align="center"><img src="docs/media/mod-band.png" alt="rippleモッド:推論強度、コンテキスト、キャッシュタイマー、実際に応答したモデルとキャッシュヒット率"><br><sub>rippleモッド:推論強度、コンテキスト、キャッシュタイマー、実際に応答したモデルとキャッシュヒット率</sub></td>
38
+ </tr>
39
+ <tr>
40
+ <td align="center" width="50%"><img src="docs/media/mod-alerts.png" alt="キャッシュが冷えたり上限が近づくと警告、新しいチャットへの引き継ぎ"><br><sub>キャッシュが冷えたり上限が近づくと警告、新しいチャットへの引き継ぎ</sub></td>
41
+ <td align="center" width="50%"><img src="docs/media/mod-settings.png" alt="クイック設定:自動推論強度、判定モデル、ワーカーモード"><br><sub>クイック設定:自動推論強度、判定モデル、ワーカーモード</sub></td>
34
42
  </tr>
35
43
  </table>
36
44
 
@@ -163,7 +171,7 @@ Claude Codeのシステムプロンプトとツールの説明はClaude向けに
163
171
  - **設計段階で正しさを確保**。プロンプトキャッシュを保持(Anthropicのキャッシュブレークポイントと、安定したOpenAIのプレフィックス)、Claude Codeのサーバー側スレッドを処理、ツール呼び出しと画像を往復で変換、推論強度を各モデルが受け付ける範囲に調整、互換ベンダー向けにはAnthropic専用のリクエストフィールドを除去。
164
172
  - **本物のリクエストログ**。誰が依頼し、どのモデルが応答したか、入力 / キャッシュ / 出力のトークン数、レイテンシ、ステータスをリクエストごとに記録し、直近1時間のサマリーも表示します。
165
173
  - **サブエージェントの名前タグ**。バックグラウンドタスクパネルに、汎用の「Agent」ではなく`Terra·high · Review`のように表示されます。
166
- - **入力する場所にルートを表示**。Claude Codeのmodがプロンプトの上に1行を表示します。直前のリクエストの行き先、推論強度、キャッシュヒット率、所要時間です。`Log`(または`/ripple-log`)でリクエストログをサイドペインに開き、プロバイダーと結果ごとに色分けして表示します。`×`(または`/ripple-bar`)でこの行を隠せます。同じmodは、Agent呼び出しで別のモデルが指定されてもワーカーを自分のモデルのまま動かし、`[[ripple: name@level]]`マーカーがプロンプトのどこにあっても読み取ります。「クライアント」画面のスイッチ1つ、または`clauderipple mod on`で有効にできます。
174
+ - **プロンプトの上のrippleモッド**。帯1本に推論強度、コンテキスト、キャッシュタイマー、直前のリクエストに実際に応答したモデルとキャッシュヒット率を表示します。小さな判定モデル(Haiku 5.5またはJev)がプロンプトごとに直近の会話と合わせて読み、lowからmaxまで推論強度を決めます。任せた作業は`ripple:auto`に渡り、判定モデルが難しさに応じてワーカーを選びます。軽い作業はDeepSeekやGLM、難しい作業はGPTやOpusへ。キャッシュが冷えたとき、コンテキストが重くなったとき、プランの上限が80%を超えたときにカードで知らせ、圧縮か、同じフォルダの新しいセッションへの引き継ぎを提案します。`Log`(または`/ripple-log`)でリクエストログをサイドペインに開きます。「クライアント」画面のスイッチ1つ、または`clauderipple mod on`で有効にできます。
167
175
  - **ChatGPTのサブスクリプションで画像を生成**。`clauderipple image "<prompt>"`で、参照画像や透明背景にも対応します。サブエージェントにもやり方が伝えられているので、GPTやDeepSeekのワーカーでも、画像ツールなしで画像を作って確認できます。
168
176
  - **必要なら軽量なワーカーを**。スイッチ1つで、生成されるサブエージェントに、セッションのすべてのMCPサーバーとプラグインではなく、ワーカーが使うツールだけを渡します。あるワーカーの最初のリクエストは33kトークンから9kトークンに減りました。
169
177
  - **ターミナルを使いたくない人のために**。ワンクリックの接続テストとモデル検出付きのプロバイダープリセット、ドロップダウンによるモデルマッピング、自動保存、韓国語と英語のUI。独自のランタイムを同梱し、初回起動時に自らセットアップするメニューバーアプリ。署名・公証済みです。
package/README.ko.md CHANGED
@@ -29,16 +29,24 @@
29
29
  </p>
30
30
 
31
31
  <p align="center">
32
- <img src="docs/media/tour.png" width="880" alt="ClaudeRipple 설정 화면: 상태, 모델 매핑, 프로바이더, 클라이언트, 요청 로그">
32
+ <a href="https://github.com/PBJ-2/clauderipple/releases/download/v0.9.0/clauderipple-demo.mp4"><img src="docs/media/demo.webp" width="880" alt="1분 둘러보기: Claude Desktop의 모델 메뉴에 다른 모델이 들어가고, 프롬프트마다 추론 강도를 정하고, 서브에이전트는 맞는 모델로 보냅니다. 누르면 전체 영상이 열립니다."></a><br>
33
+ <sub>1분 둘러보기: Claude Desktop의 모델 메뉴에 다른 모델이 들어가고, 프롬프트마다 추론 강도를 정하고, 서브에이전트는 맞는 모델로 보냅니다. 누르면 전체 영상이 열립니다.</sub>
33
34
  </p>
34
35
 
35
36
  <table align="center">
36
37
  <tr>
37
- <td align="center" width="34%"><img src="docs/media/picker-zoom.png" width="300" alt="Claude Desktop 피커에 실제 이름으로"><br><sub>Claude Desktop 피커에 GPT 모델이 실제 이름으로</sub></td>
38
- <td align="center" width="66%"><img src="docs/media/luna-answer.png" alt="Code 탭에서 답하는 GPT-5.6 Luna"><br><sub>Code 탭에서 고른 추론 강도 그대로 답하는 GPT-5.6 Luna</sub></td>
38
+ <td align="center" width="34%"><img src="docs/media/picker.png" width="300" alt="Claude Desktop의 원래 모델 메뉴에 GPT·DeepSeek·GLM·Gemini"><br><sub>Claude Desktop의 원래 모델 메뉴에 GPT·DeepSeek·GLM·Gemini</sub></td>
39
+ <td align="center" width="66%"><img src="docs/media/answer.png" alt="Code 탭에서 일하는 GPT-6 Astra, 오른쪽에는 모든 요청 기록"><br><sub>Code 탭에서 일하는 GPT-6 Astra, 오른쪽에는 모든 요청 기록</sub></td>
39
40
  </tr>
40
41
  <tr>
41
- <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="백그라운드 작업 패널에 실제 모델 이름으로 표시된 서브에이전트"><br><sub>GPT-5.6 Terra·Sol로 도는 서브에이전트, 작업 패널에 실제 이름으로</sub></td>
42
+ <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="DeepSeek·GLM으로 도는 서브에이전트, 작업 패널에 실제 이름으로"><br><sub>DeepSeek·GLM으로 도는 서브에이전트, 작업 패널에 실제 이름으로</sub></td>
43
+ </tr>
44
+ <tr>
45
+ <td colspan="2" align="center"><img src="docs/media/mod-band.png" alt="리플 모드: 추론 강도, 컨텍스트, 캐시 타이머, 실제로 답한 모델과 캐시 적중률"><br><sub>리플 모드: 추론 강도, 컨텍스트, 캐시 타이머, 실제로 답한 모델과 캐시 적중률</sub></td>
46
+ </tr>
47
+ <tr>
48
+ <td align="center" width="50%"><img src="docs/media/mod-alerts.png" alt="캐시가 식거나 한도가 가까우면 경고, 새 대화로 인수인계"><br><sub>캐시가 식거나 한도가 가까우면 경고, 새 대화로 인수인계</sub></td>
49
+ <td align="center" width="50%"><img src="docs/media/mod-settings.png" alt="빠른 설정: 자동 추론 강도, 판정 모델, 워커 모드"><br><sub>빠른 설정: 자동 추론 강도, 판정 모델, 워커 모드</sub></td>
42
50
  </tr>
43
51
  </table>
44
52
 
@@ -241,10 +249,12 @@ ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시
241
249
  서버 측 스레드 처리, 도구 호출과 이미지 왕복, 모델이 받는 범위로 추론 강도 클램프, 호환 벤더에는 Anthropic 전용 필드 제거.
242
250
  - **제대로 된 요청 로그.** 누가 물었고 어떤 모델이 답했는지, 입력·캐시·출력 토큰, 지연, 상태를 요청마다. 한 시간 요약 포함.
243
251
  - **서브에이전트 이름표.** 백그라운드 작업 패널에 "Agent" 대신 `Terra·high · Review`가 보입니다.
244
- - **입력창에서 보이는 경로.** Claude Code mod가 입력창 위에 한 줄을 띄웁니다. 마지막 요청이 어디로 갔는지, 추론 강도·캐시
245
- 적중률·시간까지. `Log`(또는 `/ripple-log`)로 요청 로그를 옆 패널에 프로바이더·결과별 색으로 띄우고, `×`(또는
246
- `/ripple-bar`)로 줄을 숨깁니다. 같은 mod가 Agent 호출에 다른 모델이 붙어도 워커를 제 모델로 돌리고,
247
- `[[ripple: 이름@강도]]` 표식을 프롬프트 어디에 두든 읽습니다. 클라이언트 화면의 스위치 하나, 또는 `clauderipple mod on`.
252
+ - **입력창 위의 리플 모드.** 띠 하나에 추론 강도, 컨텍스트, 캐시 타이머, 마지막 요청에 실제로 답한 모델과 캐시
253
+ 적중률이 보입니다. 작은 판정 모델(Haiku 5.5 또는 Jev)이 프롬프트마다 최근 대화와 함께 읽고 low부터 max까지 추론
254
+ 강도를 정합니다. 맡긴 일은 `ripple:auto`로 가고, 판정 모델이 난이도에 따라 워커를 고릅니다. 가벼운 일은 DeepSeek·GLM,
255
+ 어려운 일은 GPT·Opus로. 캐시가 식었을 때, 컨텍스트가 무거워졌을 때, 요금제 한도가 80%를 넘었을 때 카드로 알려 주고,
256
+ 압축이나 같은 폴더 새 세션으로의 인수인계를 제안합니다. `Log`(또는 `/ripple-log`)로 요청 로그를 옆 패널에 엽니다.
257
+ 클라이언트 화면의 스위치 하나, 또는 `clauderipple mod on`.
248
258
  - **ChatGPT 구독으로 이미지 생성.** `clauderipple image "<프롬프트>"`. 참고 이미지와 투명 배경도 됩니다. 서브에이전트에도
249
259
  방법이 적혀 있어, GPT나 DeepSeek 워커가 이미지 도구 없이 이미지를 만들고 직접 확인합니다.
250
260
  - **원하면 가벼운 워커.** 스위치 하나로, 생성된 서브에이전트에 세션의 MCP·플러그인 도구 전부 대신 워커가 쓰는 도구만
package/README.md CHANGED
@@ -30,16 +30,24 @@
30
30
  </p>
31
31
 
32
32
  <p align="center">
33
- <img src="docs/media/tour.png" width="880" alt="ClaudeRipple settings tour: status, model mapping, providers, clients, request log">
33
+ <a href="https://github.com/PBJ-2/clauderipple/releases/download/v0.9.0/clauderipple-demo.mp4"><img src="docs/media/demo.webp" width="880" alt="A one-minute tour: other models in Claude Desktop's own model menu, effort set for every prompt, and each subagent sent to the model that fits. Click for the full video."></a><br>
34
+ <sub>A one-minute tour: other models in Claude Desktop's own model menu, effort set for every prompt, and each subagent sent to the model that fits. Click for the full video.</sub>
34
35
  </p>
35
36
 
36
37
  <table align="center">
37
38
  <tr>
38
- <td align="center" width="34%"><img src="docs/media/picker-zoom.png" width="300" alt="Real names in the Claude Desktop picker"><br><sub>GPT models under their real names in the Claude Desktop picker</sub></td>
39
- <td align="center" width="66%"><img src="docs/media/luna-answer.png" alt="GPT-5.6 Luna answering in the Claude Desktop Code tab"><br><sub>GPT-5.6 Luna answering in the Code tab, at the effort you chose</sub></td>
39
+ <td align="center" width="34%"><img src="docs/media/picker.png" width="300" alt="GPT, DeepSeek, GLM and Gemini in Claude Desktop's own model menu"><br><sub>GPT, DeepSeek, GLM and Gemini in Claude Desktop's own model menu</sub></td>
40
+ <td align="center" width="66%"><img src="docs/media/answer.png" alt="GPT-6 Astra working in the Code tab, every request logged on the right"><br><sub>GPT-6 Astra working in the Code tab, every request logged on the right</sub></td>
40
41
  </tr>
41
42
  <tr>
42
- <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="Subagents on GPT-5.6 Terra and Sol, named in the background-task panel"><br><sub>Subagents on GPT-5.6 Terra and Sol, named in the background-task panel</sub></td>
43
+ <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="Subagents on DeepSeek and GLM, named in the background-task panel"><br><sub>Subagents on DeepSeek and GLM, named in the background-task panel</sub></td>
44
+ </tr>
45
+ <tr>
46
+ <td colspan="2" align="center"><img src="docs/media/mod-band.png" alt="The ripple mod: effort, context, cache timer, and which model answered with its cache hit"><br><sub>The ripple mod: effort, context, cache timer, and which model answered with its cache hit</sub></td>
47
+ </tr>
48
+ <tr>
49
+ <td align="center" width="50%"><img src="docs/media/mod-alerts.png" alt="Warnings when the cache goes cold or a limit is near, and a handoff to a fresh chat"><br><sub>Warnings when the cache goes cold or a limit is near, and a handoff to a fresh chat</sub></td>
50
+ <td align="center" width="50%"><img src="docs/media/mod-settings.png" alt="Quick settings: automatic effort, the judge, worker modes"><br><sub>Quick settings: automatic effort, the judge, worker modes</sub></td>
43
51
  </tr>
44
52
  </table>
45
53
 
@@ -290,12 +298,15 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
290
298
  latency, status, per request, with a one-hour summary.
291
299
  - **Subagent name tags.** The background-task panel shows `Terra·high · Review`
292
300
  instead of a generic "Agent".
293
- - **The route, where you type.** A Claude Code mod puts one line above the prompt:
294
- where your last request went, its effort, cache hit and time. `Log` (or `/ripple-log`)
295
- opens the request log in a side pane, color-coded by provider and result; `×` (or
296
- `/ripple-bar`) hides the line. The same mod keeps a worker on its own model when the
297
- Agent call names another, and reads its `[[ripple: name@level]]` marker wherever it sits
298
- in the prompt. One switch on the Clients screen, or `clauderipple mod on`.
301
+ - **The ripple mod, above the prompt.** A band shows the reasoning effort, context, cache
302
+ timer, and which model answered your last request with its cache hit. A small judge
303
+ model (Haiku 5.5 or Jev) reads each prompt with the recent turns and sets the effort,
304
+ low through max. Delegated work goes to `ripple:auto`, and the judge sends each task to
305
+ a worker by difficulty: light work to DeepSeek or GLM, hard work to GPT or Opus. Cards
306
+ warn when the cache has gone cold, the context gets heavy or a plan window passes 80%,
307
+ and offer compact or a handoff to a fresh session in the same folder. `Log` (or
308
+ `/ripple-log`) opens the request log in a side pane. One switch on the Clients screen,
309
+ or `clauderipple mod on`.
299
310
  - **Images from your ChatGPT subscription.** `clauderipple image "<prompt>"`, with
300
311
  reference images and transparent backgrounds. Subagents are told how, so a GPT or
301
312
  DeepSeek worker can make an image and look at it without an image tool.
package/README.pt-BR.md CHANGED
@@ -22,16 +22,24 @@
22
22
  </p>
23
23
 
24
24
  <p align="center">
25
- <img src="docs/media/tour.png" width="880" alt="Tour pelas configurações do ClaudeRipple: status, mapeamento de modelos, provedores, clientes, log de requisições">
25
+ <a href="https://github.com/PBJ-2/clauderipple/releases/download/v0.9.0/clauderipple-demo.mp4"><img src="docs/media/demo.webp" width="880" alt="Tour de um minuto: outros modelos no próprio menu de modelos do Claude Desktop, o esforço ajustado a cada prompt e cada subagente enviado ao modelo certo. Clique para ver o vídeo completo."></a><br>
26
+ <sub>Tour de um minuto: outros modelos no próprio menu de modelos do Claude Desktop, o esforço ajustado a cada prompt e cada subagente enviado ao modelo certo. Clique para ver o vídeo completo.</sub>
26
27
  </p>
27
28
 
28
29
  <table align="center">
29
30
  <tr>
30
- <td align="center" width="34%"><img src="docs/media/picker-zoom.png" width="300" alt="Nomes reais no seletor do Claude Desktop"><br><sub>Modelos GPT com os nomes reais no seletor do Claude Desktop</sub></td>
31
- <td align="center" width="66%"><img src="docs/media/luna-answer.png" alt="GPT-5.6 Luna respondendo na aba Code do Claude Desktop"><br><sub>GPT-5.6 Luna respondendo na aba Code, com o esforço que você escolheu</sub></td>
31
+ <td align="center" width="34%"><img src="docs/media/picker.png" width="300" alt="GPT, DeepSeek, GLM e Gemini no próprio menu de modelos do Claude Desktop"><br><sub>GPT, DeepSeek, GLM e Gemini no próprio menu de modelos do Claude Desktop</sub></td>
32
+ <td align="center" width="66%"><img src="docs/media/answer.png" alt="GPT-6 Astra trabalhando na aba Code, com cada requisição registrada à direita"><br><sub>GPT-6 Astra trabalhando na aba Code, com cada requisição registrada à direita</sub></td>
32
33
  </tr>
33
34
  <tr>
34
- <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="Subagentes no GPT-5.6 Terra e no Sol, identificados no painel de tarefas em segundo plano"><br><sub>Subagentes no GPT-5.6 Terra e no Sol, identificados no painel de tarefas em segundo plano</sub></td>
35
+ <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="Subagentes no DeepSeek e no GLM, identificados no painel de tarefas"><br><sub>Subagentes no DeepSeek e no GLM, identificados no painel de tarefas</sub></td>
36
+ </tr>
37
+ <tr>
38
+ <td colspan="2" align="center"><img src="docs/media/mod-band.png" alt="O mod ripple: esforço, contexto, timer do cache e qual modelo respondeu, com o acerto de cache"><br><sub>O mod ripple: esforço, contexto, timer do cache e qual modelo respondeu, com o acerto de cache</sub></td>
39
+ </tr>
40
+ <tr>
41
+ <td align="center" width="50%"><img src="docs/media/mod-alerts.png" alt="Avisos quando o cache esfria ou um limite está perto, e passagem para um chat novo"><br><sub>Avisos quando o cache esfria ou um limite está perto, e passagem para um chat novo</sub></td>
42
+ <td align="center" width="50%"><img src="docs/media/mod-settings.png" alt="Ajustes rápidos: esforço automático, o juiz, modos de workers"><br><sub>Ajustes rápidos: esforço automático, o juiz, modos de workers</sub></td>
35
43
  </tr>
36
44
  </table>
37
45
 
@@ -295,13 +303,15 @@ que você mapeia vão para o seu provedor, e todo o resto vai para a Anthropic b
295
303
  última hora.
296
304
  - **Identificação dos subagentes.** O painel de tarefas em segundo plano mostra
297
305
  `Terra·high · Review` em vez de um "Agent" genérico.
298
- - **A rota, onde você digita.** Um mod do Claude Code coloca uma linha acima do prompt:
299
- para onde foi sua última requisição, o esforço, o acerto de cache e o tempo. `Log` (ou
300
- `/ripple-log`) abre o log de requisições em um painel lateral, com cores por provedor e
301
- por resultado; `×` (ou `/ripple-bar`) oculta a linha. O mesmo mod mantém um worker no
302
- próprio modelo quando a chamada do Agent indica outro, e lê o marcador
303
- `[[ripple: name@level]]` dele onde quer que esteja no prompt. Basta um botão na tela
304
- **Clientes**, ou `clauderipple mod on`.
306
+ - **O mod ripple, acima do prompt.** Uma faixa mostra o esforço de raciocínio, o contexto, o
307
+ timer do cache e qual modelo respondeu sua última requisição, com o acerto de cache. Um
308
+ pequeno modelo juiz (Haiku 5.5 ou Jev) lê cada prompt junto com os turnos recentes e define
309
+ o esforço, de low a max. O trabalho delegado vai para `ripple:auto`, e o juiz manda cada
310
+ tarefa para um worker conforme a dificuldade: o leve para DeepSeek ou GLM, o difícil para
311
+ GPT ou Opus. Cartões avisam quando o cache esfriou, o contexto ficou pesado ou uma janela do
312
+ plano passou de 80%, e oferecem compactar ou passar para uma sessão nova na mesma pasta.
313
+ `Log` (ou `/ripple-log`) abre o log de requisições em um painel lateral. Basta um botão na
314
+ tela **Clientes**, ou `clauderipple mod on`.
305
315
  - **Imagens pela sua assinatura do ChatGPT.** `clauderipple image "<prompt>"`, com imagens
306
316
  de referência e fundos transparentes. Os subagentes recebem instruções de como fazer,
307
317
  então um worker GPT ou DeepSeek consegue criar uma imagem e olhar para ela sem uma
package/README.zh-CN.md CHANGED
@@ -22,16 +22,24 @@
22
22
  </p>
23
23
 
24
24
  <p align="center">
25
- <img src="docs/media/tour.png" width="880" alt="ClaudeRipple 设置界面一览:状态、模型映射、供应商、客户端、请求日志">
25
+ <a href="https://github.com/PBJ-2/clauderipple/releases/download/v0.9.0/clauderipple-demo.mp4"><img src="docs/media/demo.webp" width="880" alt="一分钟概览:其他模型出现在 Claude Desktop 自带的模型菜单里,每条提示设定推理强度,子代理分派给合适的模型。点击观看完整视频。"></a><br>
26
+ <sub>一分钟概览:其他模型出现在 Claude Desktop 自带的模型菜单里,每条提示设定推理强度,子代理分派给合适的模型。点击观看完整视频。</sub>
26
27
  </p>
27
28
 
28
29
  <table align="center">
29
30
  <tr>
30
- <td align="center" width="34%"><img src="docs/media/picker-zoom.png" width="300" alt="Claude Desktop 模型选择器中的真实模型名称"><br><sub>GPT 模型以真实名称出现在 Claude Desktop 的模型选择器中</sub></td>
31
- <td align="center" width="66%"><img src="docs/media/luna-answer.png" alt="GPT-5.6 Luna 在 Claude Desktop 的 Code 标签页中作答"><br><sub>GPT-5.6 Luna 在 Code 标签页中按你选择的推理强度作答</sub></td>
31
+ <td align="center" width="34%"><img src="docs/media/picker.png" width="300" alt="Claude Desktop 自带的模型菜单里出现 GPT、DeepSeek、GLM、Gemini"><br><sub>Claude Desktop 自带的模型菜单里出现 GPT、DeepSeek、GLM、Gemini</sub></td>
32
+ <td align="center" width="66%"><img src="docs/media/answer.png" alt="GPT-6 Astra 在 Code 标签页中工作,右侧记录每一个请求"><br><sub>GPT-6 Astra 在 Code 标签页中工作,右侧记录每一个请求</sub></td>
32
33
  </tr>
33
34
  <tr>
34
- <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="运行在 GPT-5.6 Terra 和 Sol 上的子代理,在后台任务面板中显示模型名称"><br><sub>运行在 GPT-5.6 Terra 和 Sol 上的子代理,在后台任务面板中显示模型名称</sub></td>
35
+ <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="运行在 DeepSeek 和 GLM 上的子代理,在任务面板中显示真实名称"><br><sub>运行在 DeepSeek 和 GLM 上的子代理,在任务面板中显示真实名称</sub></td>
36
+ </tr>
37
+ <tr>
38
+ <td colspan="2" align="center"><img src="docs/media/mod-band.png" alt="ripple 模组:推理强度、上下文、缓存计时,以及实际作答的模型和缓存命中率"><br><sub>ripple 模组:推理强度、上下文、缓存计时,以及实际作答的模型和缓存命中率</sub></td>
39
+ </tr>
40
+ <tr>
41
+ <td align="center" width="50%"><img src="docs/media/mod-alerts.png" alt="缓存变冷或额度将满时提醒,并可交接到新的对话"><br><sub>缓存变冷或额度将满时提醒,并可交接到新的对话</sub></td>
42
+ <td align="center" width="50%"><img src="docs/media/mod-settings.png" alt="快速设置:自动推理强度、判定模型、工作者模式"><br><sub>快速设置:自动推理强度、判定模型、工作者模式</sub></td>
35
43
  </tr>
36
44
  </table>
37
45
 
@@ -164,7 +172,7 @@ Claude Code 的系统提示词和工具描述是为 Claude 编写的,所以其
164
172
  - **从设计上保证正确。** 保留提示缓存(Anthropic 缓存断点和稳定的 OpenAI 前缀),处理 Claude Code 的服务端线程,完整保留双向传递的工具调用和图片,将推理强度限定在各模型接受的范围内,并为兼容厂商去除 Anthropic 专有的请求字段。
165
173
  - **真正的请求日志。** 逐条记录谁发起了请求、哪个模型作答、输入 / 缓存 / 输出 token、延迟和状态,并附最近一小时的汇总。
166
174
  - **子代理名称标签。** 后台任务面板显示 `Terra·high · Review`,而不是笼统的“Agent”。
167
- - **在输入处看到路由。** 一个 Claude Code mod 会在输入框上方显示一行信息:上一个请求发往何处、推理强度、缓存命中率和耗时。`Log`(或 `/ripple-log`)会在侧边窗格中打开请求日志,按供应商和结果着色;`×`(或 `/ripple-bar`)可隐藏这一行。当 Agent 调用指定了其他模型时,同一个 mod 还会让 worker 留在自己的模型上,并读取它的 `[[ripple: name@level]]` 标记,无论标记位于提示词何处。在“客户端”页面上打开一个开关即可启用,或运行 `clauderipple mod on`。
175
+ - **输入框上方的 ripple 模组。** 一条横栏显示推理强度、上下文、缓存计时,以及上一个请求实际由哪个模型作答和缓存命中率。一个小型判定模型(Haiku 5.5 或 Jev)会结合最近的对话阅读每条提示词,在 low 到 max 之间设定推理强度。委派的工作交给 `ripple:auto`,由判定模型按难度挑选 worker:轻活交给 DeepSeek 或 GLM,难活交给 GPT 或 Opus。缓存变冷、上下文变重或套餐额度超过 80% 时会弹出卡片提醒,并提供压缩或交接到同一文件夹的新会话。`Log`(或 `/ripple-log`)会在侧边窗格中打开请求日志。在“客户端”页面上打开一个开关即可启用,或运行 `clauderipple mod on`。
168
176
  - **用 ChatGPT 订阅生成图片。** `clauderipple image "<prompt>"`,支持参考图和透明背景。子代理会被告知用法,因此 GPT 或 DeepSeek worker 无需图像工具也能生成图片并查看结果。
169
177
  - **按需精简 worker。** 打开一个开关,生成的子代理就只获得 worker 会用到的工具,而不是会话中的所有 MCP 服务器和插件:某个 worker 的首个请求因此从 33k token 降到了 9k token。
170
178
  - **为不想碰终端的人而设计。** 供应商预设支持一键连接测试和模型发现,下拉式模型映射,自动保存,韩语和英语界面。菜单栏应用自带运行时,首次启动时自动完成设置。经过签名和公证。
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "commonjs"
5
5
  }
@@ -90,7 +90,23 @@ export function verdictObject(text: string): Record<string, unknown> | undefined
90
90
  }
91
91
  }
92
92
  }
93
- return undefined
93
+ return looseVerdictObject(text)
94
+ }
95
+
96
+ /**
97
+ * A small judge sometimes breaks its JSON (Haiku answered `…"why":"…","}`, #53). Its complete
98
+ * `"key": value` pairs still carry the verdict, so they are read one by one; the parsers above
99
+ * check every field they use, so nothing here is trusted beyond a JSON scalar.
100
+ */
101
+ function looseVerdictObject(text: string): Record<string, unknown> | undefined {
102
+ const start = text.indexOf('{')
103
+ if (start === -1) return undefined
104
+ const value: Record<string, unknown> = {}
105
+ const pair = /"(\w+)"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)/g
106
+ for (const match of text.slice(start).matchAll(pair)) {
107
+ try { value[match[1]!] ??= JSON.parse(match[2]!) } catch { /* a malformed scalar adds nothing */ }
108
+ }
109
+ return Object.keys(value).length ? value : undefined
94
110
  }
95
111
 
96
112
  const effortCriteria = {
@@ -305,8 +305,11 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
305
305
  const oneRow = !more.length && faceCells + chOf(card.title) * 1.12 + 2 + chOf(card.body) + 2 + actionCells <= room
306
306
  // Too narrow to keep the actions beside the words: they go under them.
307
307
  const actionsBelow = !oneRow && room - faceCells - actionCells < 30
308
+ // Beside a title and a why of two lines, the actions sit at the card's middle, not on the title's
309
+ // line (2026-10-13: they rode high). Beside the handoff's options they stay by the title, the ×
310
+ // where a way out is looked for.
308
311
  const buttons = (
309
- <Box key="actions" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0} flexWrap="wrap" rowGap={1}>
312
+ <Box key="actions" flexDirection="row" alignItems="center" alignSelf={more.length ? 'flex-start' : 'center'} columnGap={1} flexShrink={0} flexWrap="wrap" rowGap={1}>
310
313
  {quiet.map(c => pill(el, c.key, c.label, 'ghost', c.onPress, tone))}
311
314
  {main ? pill(el, main.key, main.label, 'primary', main.onPress, tone) : null}
312
315
  {card.close ? closeButton(el, card.close.key, card.close.label, card.close.onPress) : null}
@@ -80,7 +80,8 @@ function backupModSettings(file, previousRevision) {
80
80
  const backup = `${file}.bak-${previousRevision}`;
81
81
  fs.copyFileSync(file, backup);
82
82
  fs.chmodSync(backup, 0o600);
83
- const fd = fs.openSync(backup, "r");
83
+ // Windows refuses to flush a read-only handle (EPERM, #53), so the backup is opened for writing.
84
+ const fd = fs.openSync(backup, "r+");
84
85
  try {
85
86
  fs.fsyncSync(fd);
86
87
  }
@@ -1039,8 +1039,9 @@ export class ChatGptAdapter {
1039
1039
  }
1040
1040
  res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
1041
1041
  return { status: err.status, bytes: err.body.length, note,
1042
- ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
1043
- };
1042
+ ...(sent.kind === "no-account" ? { upstreamCalled: false, refusal: "no-account" } : {}),
1043
+ ...(sent.kind === "refused" && upstreamReq.service_tier !== undefined ? { requestedTier: upstreamReq.service_tier } : {}),
1044
+ ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}), };
1044
1045
  }
1045
1046
  const { upstream, credential } = sent;
1046
1047
  const nextTurnState = upstream.headers.get("x-codex-turn-state");
@@ -1058,6 +1059,7 @@ export class ChatGptAdapter {
1058
1059
  let ping = null;
1059
1060
  // Keep early rejection as an HTTP error until an actual content block arrives.
1060
1061
  // A 200 followed by an error loses the status Claude uses for overflow recovery.
1062
+ let messageSent = false;
1061
1063
  const pendingEvents = [];
1062
1064
  const openStream = () => {
1063
1065
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
@@ -1073,8 +1075,12 @@ export class ChatGptAdapter {
1073
1075
  return;
1074
1076
  openStream();
1075
1077
  }
1076
- for (const ev of pendingEvents.splice(0))
1077
- bytes += write(res, formatSse(ev));
1078
+ for (const ev of pendingEvents.splice(0)) {
1079
+ const written = write(res, formatSse(ev));
1080
+ bytes += written;
1081
+ if (ev.event === "message_start" && written > 0)
1082
+ messageSent = true;
1083
+ }
1078
1084
  };
1079
1085
  // The hold waits for upstream's first item, which normally follows the request within a second
1080
1086
  // — and an overflow rejection comes before it. Should it not come, the client must still hear
@@ -1086,8 +1092,12 @@ export class ChatGptAdapter {
1086
1092
  if (res.headersSent || res.writableEnded || mapper.failure)
1087
1093
  return;
1088
1094
  openStream();
1089
- for (const ev of [...pendingEvents.splice(0), ...mapper.start()])
1090
- bytes += write(res, formatSse(ev));
1095
+ for (const ev of [...pendingEvents.splice(0), ...mapper.start()]) {
1096
+ const written = write(res, formatSse(ev));
1097
+ bytes += written;
1098
+ if (ev.event === "message_start" && written > 0)
1099
+ messageSent = true;
1100
+ }
1091
1101
  }, streamHold.ms);
1092
1102
  ping = setInterval(() => {
1093
1103
  if (res.headersSent && !res.writableEnded)
@@ -1170,6 +1180,9 @@ export class ChatGptAdapter {
1170
1180
  output: u.output_tokens,
1171
1181
  },
1172
1182
  stopReason: mapper.stopReason,
1183
+ ...((!wantStream && !failure) || messageSent ? { messageId: mapper.messageId } : {}),
1184
+ ...(upstreamReq.service_tier !== undefined ? { requestedTier: upstreamReq.service_tier } : {}),
1185
+ ...(mapper.servedTier !== undefined ? { servedTier: mapper.servedTier } : {}),
1173
1186
  };
1174
1187
  }
1175
1188
  }
@@ -352,6 +352,7 @@ export class StreamMapper {
352
352
  sawToolCall = false;
353
353
  finished = false;
354
354
  messageId;
355
+ servedTier;
355
356
  model;
356
357
  usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
357
358
  /** Latest `codex.rate_limits` payload, if the backend sent one. */
@@ -493,6 +494,8 @@ export class StreamMapper {
493
494
  case "response.completed":
494
495
  case "response.incomplete": {
495
496
  const r = ev.response;
497
+ if (typeof r?.service_tier === "string")
498
+ this.servedTier = r.service_tier;
496
499
  const u = r?.usage;
497
500
  if (u) {
498
501
  const cached = u.input_tokens_details?.cached_tokens ?? 0;
@@ -281,7 +281,7 @@ export class GoogleAdapter {
281
281
  if (!apiKey) {
282
282
  const out = anthropicError(401, "authentication_error", "Google provider: no API key; set apiKey or GEMINI_API_KEY");
283
283
  res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
284
- return { status: out.status, bytes: Buffer.byteLength(out.body), note: "no api key" };
284
+ return { status: out.status, bytes: Buffer.byteLength(out.body), note: "no api key", upstreamCalled: false, refusal: "no-account" };
285
285
  }
286
286
  let built;
287
287
  try {
@@ -386,8 +386,8 @@ export class GoogleAdapter {
386
386
  }
387
387
  res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
388
388
  return { status: err.status, bytes: Buffer.byteLength(err.body), note,
389
- ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
390
- };
389
+ ...(sent.kind === "no-account" ? { upstreamCalled: false, refusal: "no-account" } : {}),
390
+ ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}), };
391
391
  }
392
392
  return this.streamBack(res, sent.upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
393
393
  }
@@ -494,6 +494,7 @@ export class GoogleAdapter {
494
494
  : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
495
495
  usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
496
496
  stopReason: mapper.stopReason,
497
+ ...(wantStream || !failure ? { messageId: mapper.messageId } : {}),
497
498
  };
498
499
  }
499
500
  /**
@@ -235,6 +235,8 @@ export class OpenAiCompatibleAdapter {
235
235
  : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
236
236
  usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
237
237
  stopReason: mapper.stopReason,
238
+ ...(wantStream || !failure ? { messageId: mapper.messageId } : {}),
239
+ ...(mapper.servedTier !== undefined ? { servedTier: mapper.servedTier } : {}),
238
240
  };
239
241
  }
240
242
  }
@@ -283,6 +283,7 @@ export class OpenAiStreamMapper {
283
283
  responseItems = [];
284
284
  responseItemsById = new Map();
285
285
  messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
286
+ servedTier;
286
287
  content = [];
287
288
  usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
288
289
  stopReason = "end_turn";
@@ -359,6 +360,8 @@ export class OpenAiStreamMapper {
359
360
  }
360
361
  feedChat(ev) {
361
362
  const out = [...this.start()];
363
+ if (typeof ev.service_tier === "string")
364
+ this.servedTier = ev.service_tier;
362
365
  const usage = ev.usage;
363
366
  if (usage)
364
367
  this.setUsage(usage);
@@ -513,6 +516,8 @@ export class OpenAiStreamMapper {
513
516
  }
514
517
  else if (type === "response.completed" || type === "response.incomplete") {
515
518
  const response = ev.response;
519
+ if (typeof response?.service_tier === "string")
520
+ this.servedTier = response.service_tier;
516
521
  this.setUsage(response?.usage);
517
522
  if (response?.incomplete_details?.reason === "max_output_tokens")
518
523
  this.finishReason = "length";
@@ -20,7 +20,7 @@ import tls from "node:tls";
20
20
  import zlib from "node:zlib";
21
21
  import { ProviderHealth, providerExhaustion, UpstreamHealth } from "./health.js";
22
22
  import { BOOTSTRAP_PATH, injectBootstrap } from "./bootstrap.js";
23
- import { THREAD_UNSUPPORTED, effortOf, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
23
+ import { THREAD_UNSUPPORTED, effortOf, isAutoModeClassifier, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
24
24
  import { defaultAgentDir, withAgentAliases } from "./agents.js";
25
25
  import { forwardCompatibleHeader, resolveCompatibleCaps, sanitizeForCompatible } from "./compat.js";
26
26
  import { applyIdentityToAnthropicBody } from "./identity.js";
@@ -629,6 +629,16 @@ export class Proxy {
629
629
  completed.note = note;
630
630
  if (extra?.resent && !completed.ok)
631
631
  completed.resent = true;
632
+ if (extra?.messageId !== undefined)
633
+ completed.messageId = extra.messageId;
634
+ if (extra?.requestedTier !== undefined)
635
+ completed.requestedTier = extra.requestedTier;
636
+ if (extra?.servedTier !== undefined)
637
+ completed.servedTier = extra.servedTier;
638
+ if (extra?.upstreamCalled === false)
639
+ completed.upstreamCalled = false;
640
+ if (extra?.refusal !== undefined)
641
+ completed.refusal = extra.refusal;
632
642
  this.deps.requests.add(completed);
633
643
  }
634
644
  };
@@ -639,7 +649,7 @@ export class Proxy {
639
649
  const msg = JSON.stringify({ type: "error", error: { type: "overloaded_error", message: "ClaudeRipple is restarting; retry" } });
640
650
  res.writeHead(503, { "content-type": "application/json", "retry-after": "3", connection: "close", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
641
651
  req.resume();
642
- finish("503", msg.length, "refused during drain (client retries)", false, { resent: true });
652
+ finish("503", msg.length, "refused during drain (client retries)", false, { resent: true, upstreamCalled: false, refusal: "drain" });
643
653
  return;
644
654
  }
645
655
  let body;
@@ -673,9 +683,17 @@ export class Proxy {
673
683
  // backend is configured: `cfg.webSearch` decides who answers it, but knowing that this is a
674
684
  // search is what lets the guard below refuse a provider that cannot run one. Tying the two
675
685
  // together meant an unconfigured router could not tell a search from any other turn.
686
+ if (json) {
687
+ record.source = record.target = typeof model === "string" ? model : "-";
688
+ record.stream = json.stream === true;
689
+ if (isAutoModeClassifier(json))
690
+ record.purpose = "auto-mode-classifier";
691
+ }
676
692
  const search = json && isApiHost && pathname === "/v1/messages" ? webSearchQuery(json) : null;
693
+ let searchAttempted = false;
694
+ const markSearchAttempted = () => { searchAttempted = true; };
677
695
  if (search && cfg.webSearch) {
678
- const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish);
696
+ const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish, markSearchAttempted);
679
697
  if (served)
680
698
  return;
681
699
  // Fall through on failure: the ordinary path still answers, and a search that quietly
@@ -694,7 +712,7 @@ export class Proxy {
694
712
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }).end(payload);
695
713
  tag = `REFUSE ${model}`;
696
714
  record = { ...record, provider: "refused" };
697
- finish("400", payload.length, `ClaudeRipple: ${reason}`, true);
715
+ finish("400", payload.length, `ClaudeRipple: ${reason}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unroutable" });
698
716
  return;
699
717
  }
700
718
  }
@@ -735,7 +753,7 @@ export class Proxy {
735
753
  // The same holds for a plan that searches on another of its endpoints (`anthropicSearch`).
736
754
  const own = (cfg.providers[route.provider]?.type === "chatgpt" || searchProviderFor(cfg.providers[route.provider], route.model) !== undefined) &&
737
755
  cfg.webSearch?.provider !== route.provider;
738
- if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish)))
756
+ if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish, markSearchAttempted)))
739
757
  return;
740
758
  const blocks = webSearchErrorBlocks(search, "unavailable");
741
759
  const model = typeof json.model === "string" ? json.model : "unknown";
@@ -745,7 +763,8 @@ export class Proxy {
745
763
  ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
746
764
  : { "content-type": "application/json" }).end(payload);
747
765
  log.warn(`web search: ${route.provider} cannot run server tools and no webSearch backend is configured; refused rather than answered with invented results`);
748
- finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false);
766
+ record = { ...record, target: route.model, provider: route.provider, purpose: "web-search" };
767
+ finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "web-search-unsupported" });
749
768
  return;
750
769
  }
751
770
  let compatCaps;
@@ -755,9 +774,10 @@ export class Proxy {
755
774
  let penalised = null;
756
775
  let target;
757
776
  if (route && json) {
777
+ record = { ...record, target: route.model, provider: route.provider };
758
778
  const configured = cfg.providers[route.provider];
759
779
  if (!configured) {
760
- finish("500", 0, `unknown provider ${route.provider}`);
780
+ finish("500", 0, `unknown provider ${route.provider}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unknown-provider" });
761
781
  res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
762
782
  return;
763
783
  }
@@ -771,12 +791,11 @@ export class Proxy {
771
791
  if (td === "refuse") {
772
792
  const out = JSON.stringify(THREAD_UNSUPPORTED);
773
793
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
774
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
794
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
775
795
  return;
776
796
  }
777
797
  if (td === "strip")
778
798
  stripThreadFields(json);
779
- record = { ...record, target: route.model, provider: route.provider };
780
799
  const routeEffort = effortOf(json);
781
800
  if (routeEffort)
782
801
  record.effort = routeEffort;
@@ -787,7 +806,7 @@ export class Proxy {
787
806
  const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
788
807
  if (!path.startsWith("/v1/messages/count_tokens"))
789
808
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
790
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
809
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
791
810
  }
792
811
  catch (e) {
793
812
  finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
@@ -803,12 +822,11 @@ export class Proxy {
803
822
  if (td === "refuse") {
804
823
  const out = JSON.stringify(THREAD_UNSUPPORTED);
805
824
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
806
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
825
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
807
826
  return;
808
827
  }
809
828
  if (td === "strip")
810
829
  stripThreadFields(json);
811
- record = { ...record, target: route.model, provider: route.provider };
812
830
  const routeEffort = effortOf(json);
813
831
  if (routeEffort)
814
832
  record.effort = routeEffort;
@@ -818,7 +836,7 @@ export class Proxy {
818
836
  this.recordOutcome(route.provider, o.status);
819
837
  if (!path.startsWith("/v1/messages/count_tokens"))
820
838
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
821
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
839
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
822
840
  }
823
841
  catch (e) {
824
842
  this.recordOutcome(route.provider, 0);
@@ -837,12 +855,11 @@ export class Proxy {
837
855
  if (td === "refuse") {
838
856
  const out = JSON.stringify(THREAD_UNSUPPORTED);
839
857
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
840
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
858
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
841
859
  return;
842
860
  }
843
861
  if (td === "strip")
844
862
  stripThreadFields(json);
845
- record = { ...record, target: route.model, provider: route.provider };
846
863
  const routeEffort = effortOf(json);
847
864
  if (routeEffort)
848
865
  record.effort = routeEffort;
@@ -852,7 +869,7 @@ export class Proxy {
852
869
  this.recordOutcome(route.provider, o.status);
853
870
  if (!path.startsWith("/v1/messages/count_tokens"))
854
871
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
855
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
872
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
856
873
  }
857
874
  catch (e) {
858
875
  this.recordOutcome(route.provider, 0);
@@ -876,7 +893,7 @@ export class Proxy {
876
893
  if (credentials.length === 0) {
877
894
  const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
878
895
  res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
879
- finish("401", out.length, "no usable Claude account");
896
+ finish("401", out.length, "no usable Claude account", true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "no-account" });
880
897
  return;
881
898
  }
882
899
  dropForeignThinking(json);
@@ -895,7 +912,6 @@ export class Proxy {
895
912
  dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
896
913
  dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
897
914
  };
898
- record = { ...record, target: route.model, provider: route.provider };
899
915
  const routeEffort = effortOf(json);
900
916
  if (routeEffort)
901
917
  record.effort = routeEffort;
@@ -910,7 +926,7 @@ export class Proxy {
910
926
  if (threadDecision(json) === "refuse") {
911
927
  const out = JSON.stringify(THREAD_UNSUPPORTED);
912
928
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
913
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
929
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
914
930
  return;
915
931
  }
916
932
  const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
@@ -961,7 +977,6 @@ export class Proxy {
961
977
  // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
962
978
  basePath: u.pathname.replace(/\/+$/, ""),
963
979
  };
964
- record = { ...record, target: route.model, provider: route.provider };
965
980
  const routeEffort = effortOf(json);
966
981
  if (routeEffort)
967
982
  record.effort = routeEffort;
@@ -979,6 +994,8 @@ export class Proxy {
979
994
  target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
980
995
  tag = `WEB ${reqHost}`;
981
996
  }
997
+ if (json && typeof json.service_tier === "string")
998
+ record.requestedTier = json.service_tier;
982
999
  // Two kinds of response editing: the CLI bootstrap (api host) and the claude.ai bootstrap (picker mode).
983
1000
  const isCliBootstrap = isApiHost && !route && path.startsWith(BOOTSTRAP_PATH);
984
1001
  const isPickerBootstrap = !isApiHost && !!cfg.picker?.enabled && method === "GET" && isBootstrapPath(path);
@@ -1231,6 +1248,9 @@ export class Proxy {
1231
1248
  send(withCredential(headers, retry.headers, usedCredentialHeaders));
1232
1249
  return;
1233
1250
  }
1251
+ const native = isApiHost && (!route || cfg.providers[route.provider]?.type === "anthropic");
1252
+ if (native && typeof upRes.headers["request-id"] === "string")
1253
+ record.upstreamRequestId = upRes.headers["request-id"];
1234
1254
  const outHeaders = [];
1235
1255
  const r = upRes.rawHeaders;
1236
1256
  for (let i = 0; i < r.length; i += 2) {
@@ -1343,6 +1363,10 @@ export class Proxy {
1343
1363
  const observed = tap.finish();
1344
1364
  observedUsage = observed.usage;
1345
1365
  observedStopReason = observed.stopReason;
1366
+ if (!native && observed.messageId !== undefined)
1367
+ record.messageId = observed.messageId;
1368
+ if (observed.servedTier !== undefined)
1369
+ record.servedTier = observed.servedTier;
1346
1370
  res.end();
1347
1371
  const errorBody = status >= 400 ? decodeBodyToText(Buffer.concat(errorHead), upRes.headers["content-encoding"]) : "";
1348
1372
  if (route && path.startsWith("/v1/messages") && !path.startsWith("/v1/messages/count_tokens")) {
@@ -1371,7 +1395,7 @@ export class Proxy {
1371
1395
  * model. Returns false when it could not be served, and the caller falls back to the normal path:
1372
1396
  * an empty search result is the one outcome worth avoiding, because nothing anywhere reports it.
1373
1397
  */
1374
- async serveWebSearch(res, json, query, settings, record, finish) {
1398
+ async serveWebSearch(res, json, query, settings, record, finish, markAttempted) {
1375
1399
  const configured = this.deps.config().providers[settings.provider];
1376
1400
  if (!configured) {
1377
1401
  this.deps.log.warn(`web search: unknown provider ${settings.provider}; leaving the request alone`);
@@ -1422,6 +1446,7 @@ export class Proxy {
1422
1446
  const model = typeof json.model === "string" ? json.model : "unknown";
1423
1447
  let blocks;
1424
1448
  try {
1449
+ markAttempted();
1425
1450
  const outcome = await backend.search(query);
1426
1451
  blocks = webSearchBlocks(query, outcome).blocks;
1427
1452
  }
@@ -1430,6 +1455,8 @@ export class Proxy {
1430
1455
  return false;
1431
1456
  }
1432
1457
  record.target = `${settings.provider}/${settings.model}`;
1458
+ record.provider = settings.provider;
1459
+ record.purpose = "web-search";
1433
1460
  const wantStream = json.stream === true;
1434
1461
  const payload = wantStream ? webSearchSse(model, blocks, 1) : JSON.stringify(webSearchMessage(model, blocks, 1));
1435
1462
  const headers = wantStream
@@ -130,6 +130,7 @@ export class ResponseUsageTap {
130
130
  isSse;
131
131
  decoder = new TextDecoder();
132
132
  carry = "";
133
+ skipSseLine = false;
133
134
  event = "";
134
135
  data = [];
135
136
  dropUntilNewline = false;
@@ -138,6 +139,13 @@ export class ResponseUsageTap {
138
139
  jsonUsageText = "";
139
140
  stopCarry = "";
140
141
  stopReason;
142
+ messageId;
143
+ servedTier;
144
+ jsonDepth = 0;
145
+ jsonString = null;
146
+ jsonEscaped = false;
147
+ jsonKey;
148
+ jsonValue = false;
141
149
  usage;
142
150
  // Compressed responses (Anthropic answers the CLI with gzip/br) are collected side-band up to a cap
143
151
  // and inflated once at the end; the forwarded bytes are never touched.
@@ -215,28 +223,28 @@ export class ResponseUsageTap {
215
223
  }
216
224
  if (this.isSse && this.carry)
217
225
  this.feedSse("\n");
218
- return { ...(this.usage ? { usage: this.usage } : {}), ...(this.stopReason ? { stopReason: this.stopReason } : {}) };
226
+ return {
227
+ ...(this.usage ? { usage: this.usage } : {}),
228
+ ...(this.stopReason ? { stopReason: this.stopReason } : {}),
229
+ ...(this.messageId !== undefined ? { messageId: this.messageId } : {}),
230
+ ...(this.servedTier !== undefined ? { servedTier: this.servedTier } : {}),
231
+ };
219
232
  }
233
+ // Complete lines are consumed before the cap applies, so an inflated body or one large chunk keeps
234
+ // its events; only an unfinished line longer than the cap is dropped, along with its later bytes.
220
235
  feedSse(text) {
221
- this.carry += text;
222
- if (this.carry.length > MAX_CARRY) {
223
- const newline = this.carry.lastIndexOf("\n");
224
- if (newline < 0) {
225
- this.carry = "";
226
- this.event = "";
227
- this.data = [];
228
- return;
229
- }
230
- this.carry = this.carry.slice(newline + 1);
231
- this.event = "";
232
- this.data = [];
233
- }
236
+ const buffer = this.carry + text;
237
+ let start = 0;
234
238
  for (;;) {
235
- const newline = this.carry.indexOf("\n");
239
+ const newline = buffer.indexOf("\n", start);
236
240
  if (newline < 0)
237
- return;
238
- const raw = this.carry.slice(0, newline);
239
- this.carry = this.carry.slice(newline + 1);
241
+ break;
242
+ const raw = buffer.slice(start, newline);
243
+ start = newline + 1;
244
+ if (this.skipSseLine) {
245
+ this.skipSseLine = false;
246
+ continue;
247
+ }
240
248
  const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
241
249
  if (line === "") {
242
250
  this.consumeSseEvent();
@@ -250,6 +258,13 @@ export class ResponseUsageTap {
250
258
  this.data.push(line.slice(5).trimStart());
251
259
  }
252
260
  }
261
+ this.carry = buffer.slice(start);
262
+ if (this.carry.length > MAX_CARRY) {
263
+ this.carry = "";
264
+ this.skipSseLine = true;
265
+ this.event = "";
266
+ this.data = [];
267
+ }
253
268
  }
254
269
  consumeSseEvent() {
255
270
  if (!this.data.length)
@@ -258,6 +273,8 @@ export class ResponseUsageTap {
258
273
  const body = JSON.parse(this.data.join("\n"));
259
274
  if (this.event === "message_start" || body.type === "message_start") {
260
275
  const message = body.message;
276
+ if (typeof message?.id === "string")
277
+ this.messageId = message.id;
261
278
  this.setUsage(message?.usage);
262
279
  }
263
280
  else if (this.event === "message_delta" || body.type === "message_delta") {
@@ -272,6 +289,42 @@ export class ResponseUsageTap {
272
289
  }
273
290
  }
274
291
  feedJson(text) {
292
+ // Read only the top-level id, never an id quoted in content or a tool input.
293
+ for (const char of text) {
294
+ if (this.jsonString !== null) {
295
+ if (this.jsonString.length < 256)
296
+ this.jsonString += char;
297
+ if (this.jsonEscaped)
298
+ this.jsonEscaped = false;
299
+ else if (char === "\\")
300
+ this.jsonEscaped = true;
301
+ else if (char === '"') {
302
+ if (this.jsonDepth === 1) {
303
+ try {
304
+ const value = JSON.parse(this.jsonString);
305
+ if (!this.jsonValue && typeof value === "string")
306
+ this.jsonKey = value;
307
+ else if (this.jsonKey === "id" && typeof value === "string")
308
+ this.messageId = value;
309
+ }
310
+ catch { /* oversized or malformed string */ }
311
+ }
312
+ this.jsonString = null;
313
+ }
314
+ }
315
+ else if (char === '"')
316
+ this.jsonString = '"';
317
+ else if (char === "{" || char === "[")
318
+ this.jsonDepth++;
319
+ else if (char === "}" || char === "]")
320
+ this.jsonDepth--;
321
+ else if (this.jsonDepth === 1 && char === ":")
322
+ this.jsonValue = true;
323
+ else if (this.jsonDepth === 1 && char === ",") {
324
+ this.jsonValue = false;
325
+ this.jsonKey = undefined;
326
+ }
327
+ }
275
328
  this.carry += text;
276
329
  this.stopCarry = (this.stopCarry + text).slice(-MAX_CARRY);
277
330
  const stop = /"stop_reason"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/.exec(this.stopCarry);
@@ -303,6 +356,7 @@ export class ResponseUsageTap {
303
356
  this.jsonUsageText = "";
304
357
  return;
305
358
  }
359
+ this.jsonUsageDepth = 0;
306
360
  let quoted = false;
307
361
  let escaped = false;
308
362
  for (let i = 0; i < this.jsonUsageText.length; i++) {
@@ -335,15 +389,19 @@ export class ResponseUsageTap {
335
389
  if (!value || typeof value !== "object")
336
390
  return;
337
391
  const usage = value;
392
+ if (typeof usage.service_tier === "string")
393
+ this.servedTier = usage.service_tier;
338
394
  const number = (key) => typeof usage[key] === "number" && Number.isFinite(usage[key]) ? Math.max(0, usage[key]) : 0;
339
395
  if (!("input_tokens" in usage || "output_tokens" in usage || "cache_read_input_tokens" in usage || "cache_creation_input_tokens" in usage))
340
396
  return;
341
397
  const current = this.usage ?? { input: 0, cached: 0, output: 0 };
342
398
  const input = "input_tokens" in usage ? number("input_tokens") : current.input;
343
399
  const cached = "cache_read_input_tokens" in usage ? number("cache_read_input_tokens") : current.cached;
344
- const cacheWrite = "cache_creation_input_tokens" in usage ? number("cache_creation_input_tokens") : current.cacheWrite;
400
+ const cacheWrite = "cache_creation_input_tokens" in usage
401
+ ? typeof usage.cache_creation_input_tokens === "number" && Number.isFinite(usage.cache_creation_input_tokens) ? number("cache_creation_input_tokens") : undefined
402
+ : current.cacheWrite;
345
403
  const output = "output_tokens" in usage ? number("output_tokens") : current.output;
346
- this.usage = { input, cached, ...(cacheWrite && cacheWrite > 0 ? { cacheWrite } : {}), output };
404
+ this.usage = { input, cached, ...(cacheWrite !== undefined ? { cacheWrite } : {}), output };
347
405
  }
348
406
  }
349
407
  export function requestId() {
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.9.0";
8
+ export const VERSION = "0.9.1";
@@ -759,6 +759,19 @@ must preserve **Data: Artificial Analysis (artificialanalysis.ai)** attribution.
759
759
 
760
760
  ### 4a. Claude Code request shapes a translator must handle (measured 2026-09-13, CLI 2.1.266)
761
761
 
762
+ **Request-log metadata (`requests.jsonl`, 2026-10-14).** These fields are optional;
763
+ old records and `RequestLog.summary()` remain compatible. No prompt, auth or reasoning
764
+ text is added. Source: `requestlog.ts`, `proxy.ts`, provider adapter outcomes.
765
+
766
+ - `usage.cacheWrite`: native/Anthropic numeric cache creation tokens, including explicit 0; absent/non-number is omitted, as is a translated adapter's synthetic 0.
767
+ - `upstreamRequestId`: the final forwarded native Anthropic response's `request-id` header (passthrough or account pool).
768
+ - `messageId`: the Anthropic message id delivered by a translated adapter or forwarded compatible response; not a generated id from a non-streaming error.
769
+ - `upstreamCalled`: `false` only for a known pre-model-API exit; omitted when a search backend was attempted or the call state is unknown.
770
+ - `refusal`: `thread-continue`, `drain`, `unroutable`, `no-account`, `unknown-provider` or `web-search-unsupported`, from the actual exit branch.
771
+ - `purpose`: `auto-mode-classifier` from the security-monitor request shape regardless of routing settings, or `web-search` for router-served searches.
772
+ - `requestedTier`: the service tier actually sent upstream, including ChatGPT's `speed: "fast"` → `priority`; omitted when no tier was sent.
773
+ - `servedTier`: the response-reported tier (Responses/Chat `service_tier` or Anthropic `usage.service_tier`); never inferred from the request.
774
+
762
775
  - **Server-side threads ("tether").** The first request of a session carries
763
776
  `thread: {type:"create"}` and the full history; later turns carry
764
777
  `thread: {type:"continue", previous_message_id: <our last assistant msg id>}` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Use GPT (any ChatGPT plan with Codex), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [