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 +24 -0
- package/README.es.md +21 -11
- package/README.ja.md +13 -5
- package/README.ko.md +18 -8
- package/README.md +21 -10
- package/README.pt-BR.md +21 -11
- package/README.zh-CN.md +13 -5
- package/dist/app/package.json +1 -1
- package/dist/mod/hooks/judge.ts +17 -1
- package/dist/mod/hooks/ui/alerts.tsx +4 -1
- package/dist/router/src/mod-settings.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +19 -6
- package/dist/router/src/providers/chatgpt/translate.js +3 -0
- package/dist/router/src/providers/google/index.js +4 -3
- package/dist/router/src/providers/openai/index.js +2 -0
- package/dist/router/src/providers/openai/translate.js +5 -0
- package/dist/router/src/proxy.js +48 -21
- package/dist/router/src/requestlog.js +78 -20
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +13 -0
- package/package.json +1 -1
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/
|
|
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
|
|
31
|
-
<td align="center" width="66%"><img src="docs/media/
|
|
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
|
|
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
|
-
- **
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
un worker
|
|
308
|
-
|
|
309
|
-
|
|
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/
|
|
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
|
|
30
|
-
<td align="center" width="66%"><img src="docs/media/
|
|
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="
|
|
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
|
-
-
|
|
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/
|
|
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
|
|
38
|
-
<td align="center" width="66%"><img src="docs/media/
|
|
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="
|
|
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
|
-
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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/
|
|
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
|
|
39
|
-
<td align="center" width="66%"><img src="docs/media/
|
|
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
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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/
|
|
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
|
|
31
|
-
<td align="center" width="66%"><img src="docs/media/
|
|
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
|
|
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
|
-
- **
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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/
|
|
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
|
|
31
|
-
<td align="center" width="66%"><img src="docs/media/
|
|
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="运行在
|
|
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
|
-
-
|
|
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
|
- **为不想碰终端的人而设计。** 供应商预设支持一键连接测试和模型发现,下拉式模型映射,自动保存,韩语和英语界面。菜单栏应用自带运行时,首次启动时自动完成设置。经过签名和公证。
|
package/dist/app/package.json
CHANGED
package/dist/mod/hooks/judge.ts
CHANGED
|
@@ -90,7 +90,23 @@ export function verdictObject(text: string): Record<string, unknown> | undefined
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
return
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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";
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -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
|
-
|
|
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, { ...
|
|
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, { ...
|
|
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, { ...
|
|
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 {
|
|
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
|
|
222
|
-
|
|
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 =
|
|
239
|
+
const newline = buffer.indexOf("\n", start);
|
|
236
240
|
if (newline < 0)
|
|
237
|
-
|
|
238
|
-
const raw =
|
|
239
|
-
|
|
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
|
|
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
|
|
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.
|
|
8
|
+
export const VERSION = "0.9.1";
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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.
|
|
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": [
|