llm-switcher 1.6.0 → 1.6.3

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,35 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.3 — 2026-10-08
4
+
5
+ - **Interactive Summarization Model Selection & Presets:** Replace raw text input for Idle Compaction's summarization model with a fully searchable combobox dropdown and quick 1-click preset chips. Users can pick directly from deterministic heuristic mode (`""`), slot aliases (`haiku`, `subagent`, `sonnet`, `main`), configured profile models (`zencore/*`, `codex/*`), and discovered catalog models, or type to filter in real-time. Selection automatically saves and updates active highlights across both light and dark themes.
6
+
7
+ ## Release 1.6.2 — 2026-10-08
8
+
9
+ - **Per-Model Slot Target Thinking Effort:** Support granular reasoning effort configuration per model slot (`profile.modelEfforts`). Individual slot selectors in the dashboard allow setting target effort levels (`auto`, `none`, `low`, `medium`, `high`, `xhigh`, `max`) independently for each model tier (e.g. Sonnet, Opus, Haiku, Codex roles) with automatic fallback to profile-level defaults.
10
+ - **Dedicated Idle Compaction Navigation & Collapsible View:** Separate Idle Compaction into its own sidebar menu (`#/compact`) with dedicated overview telemetry cards. Automatically collapse detailed configuration cards when inactive, and replace button-like unit badges with clean native typography and tooltips.
11
+ - **Accurate Bifrost Passthrough Detection:** Refine Bifrost passthrough indicators to activate only for genuine intact-routed native prefix models, keeping reasoning effort selectors fully accessible for normal OpenAI/Anthropic/Vertex/Gemini endpoints.
12
+
13
+ ## Release 1.6.1 — 2026-10-08
14
+
15
+ - **Durable Gateway Prefix Cache:** Replace external Claude Code and Codex transcript/database
16
+ writers with a private, durable gateway prefix cache under `STATE_DIR/idle-compact/`. Exact source-prefix,
17
+ caller/session/profile/context and generation guards preserve every newly appended turn and reject
18
+ stale summaries. Native client files (`~/.claude/projects/` and `thread_history_1.sqlite`) remain untouched.
19
+ - **Reasoning and Tool Pairing Preservation:** Retain assistant decision and reasoning text
20
+ accompanying tool calls; preserve tool dependencies throughout the recent tail and forward
21
+ malformed or unsupported tool histories unchanged.
22
+ - **Effort Mapping Ladder & Dynamic UI:** Introduce an 8-level effort ladder (`none`, `minimal`,
23
+ `low`, `medium`, `high`, `xhigh`, `max`, `ultra`) with automatic nearest-level equivalence mapping
24
+ (`mapEquivalentEffort`) when targeting upstreams with fewer levels (strict OpenAI, Anthropic, Gemini).
25
+ Add `profile.thinkingEffort` override setting with Web UI dropdown sync and automatic Bifrost passthrough hint.
26
+ - **Pure CA Validation & Fail-safe TLS:** Introduce strict file validation for custom CA certificates
27
+ (`usableCACertificate`); automatically unsets loopback proxy when CA is missing, corrupt, or unreadable
28
+ so Codex connects directly via system root certificates instead of failing TLS during daemon startup.
29
+ - **Consistent Idle-compaction Settings:** Validate all idle-compaction settings and budgets consistently.
30
+ An empty/null summary model makes no provider call; a configured summary model routes independently
31
+ with bounded timeout and visible diagnostics. Update dashboard and CLI controls accordingly.
32
+
3
33
  ## Release 1.6.0 — 2026-10-07
4
34
 
5
35
  - **A Codex thread now keeps the shortening, behind the same opt-in switch.** A Codex compaction is
package/README.md CHANGED
@@ -552,43 +552,50 @@ On Linux the unit is a systemd *user* service: it starts when you log in. To sta
552
552
 
553
553
  ### Idle compaction
554
554
 
555
- A conversation that pauses long enough loses the provider's prompt cache. The next request pays full
556
- price for a prefix the provider no longer holds, and pays it again on the turn after that. How long
557
- a provider keeps its cache is its own business: Anthropic 5 minutes by default and 1 hour when the
558
- request asks for it, OpenAI 30 minutes, Gemini and DeepSeek publish no lifetime at all.
555
+ A configurable pause can indicate that the provider's prompt cache is cold; it is a heuristic,
556
+ not a guarantee about any provider's cache lifetime. This feature is off by default.
559
557
 
560
- When a Claude Code conversation comes back after a pause and is large enough to be worth it, the
561
- gateway shortens the history and writes the compaction into the session file. The turn that would
562
- have cost the whole history costs the summary instead.
558
+ For an identified Claude Code session, the gateway shortens an old prefix once after a sufficiently
559
+ long pause. It stores the exact original prefix hash and its replacement in a private cache under
560
+ `STATE_DIR/idle-compact`. Later requests reuse that prefix only while the original raw history still
561
+ matches, and preserve every recent and newly appended turn unchanged. The cache survives gateway
562
+ restarts. A native client compaction or edited prefix invalidates it safely. Client JSONL files and
563
+ Codex SQLite databases are never read or modified by this feature.
563
564
 
564
- | | |
565
+ | Command | Behavior |
565
566
  | --- | --- |
566
- | `switch compact status` | Show the settings |
567
- | `switch compact on` / `off` | Turn it on or off (off by default) |
568
- | `switch compact model <id>` | Which model writes the summary. Empty or `default`: the model of the conversation itself |
569
- | `switch compact idle <minutes>` | How long a pause counts as losing the cache (15) |
570
- | `switch compact min <KB>` | How large a conversation must be to be worth it (64) |
571
-
572
- Two things are deliberate.
573
-
574
- **Only once, not on every turn.** Compacting every turn would change the prefix every turn, so no
575
- turn would ever hit the cache and the conversation would be summarized over and over. Compacting
576
- when a pause says the cache is gone keeps the summary stable for the turns that follow.
577
-
578
- **The session file, not the API.** Claude Code builds every request from a chain of entries in
579
- `~/.claude/projects/<project>/<session>.jsonl`, so a compaction written there shortens every
580
- request after it, not just the turn that triggered it. The two entries are the ones Claude Code
581
- writes itself: a `compact_boundary` and a `user` entry marked `isCompactSummary`. The old entries
582
- stay in the file, which is what Claude Code does too, so a transcript a person can still read is
583
- worth more than a small file. The shortening takes effect the next time the session is resumed.
584
-
585
- A free model writes a free summary and a worse one: the summary replaces the history, so what it
586
- leaves out is gone. That is the reader's call, which is why the model is a setting.
587
-
588
- Codex is not handled here. Its compaction is a protocol item rather than a file, which the provider
589
- gateway answers, and the answer is stored by Codex in its own history. Shortening a Codex request at
590
- the client would replace that with a summary no client records. The Antigravity CLI has no session
591
- file of its own, so there would be nothing to make the shortening last.
567
+ | `switch compact status` | Show settings |
568
+ | `switch compact on` / `off` | Enable or disable Claude request compaction |
569
+ | `switch compact codex on` / `off` | Opt Codex Responses requests into the same wire prefix cache; off by default |
570
+ | `switch compact model <id>` | Explicitly choose a summary model |
571
+ | `switch compact model none` / `default` | Disable summary calls; no fallback to the conversation model |
572
+ | `switch compact idle <minutes>` | Pause threshold, default 15; allowed 1–1440 |
573
+ | `switch compact min <KB>` | Minimum request size, default 64; allowed 8–4096 |
574
+
575
+ An unset summary model (empty string or `null`) makes no model call. The deterministic replacement
576
+ keeps earlier user and assistant text within the configured budgets, removes older tool payloads,
577
+ and retains the recent tail with complete call/result pairs. With an explicit summary model, one
578
+ background call summarizes only the replaced prefix and upgrades its cache generation. A late
579
+ summary cannot overwrite a newer generation or remove turns that arrived while it was running.
580
+ The summary model is mapped and its upstream format resolved independently of the main model.
581
+
582
+ Caching requires an explicit session identifier: Claude session headers/metadata, or Codex session,
583
+ thread or prompt-cache identity. Anonymous requests stay unchanged; shared opening prompts are not
584
+ used as session identity. Cache scope includes the client, caller credential digest, profile,
585
+ model, system/tools and compaction policy. Unsupported or malformed tool histories are forwarded
586
+ unchanged. Cache failure or corruption also forwards the original request.
587
+
588
+ The dashboard and `/api/idle-compact` use the same settings as the CLI. Supported fields are
589
+ `enabled`, `codex`, `model`, `idleMinutes`, `minBytes`, `keepRecent`, `userChars`, `summaryMaxChars`,
590
+ and `sessionLookbackHours`. The API rejects unknown fields and invalid budgets with HTTP 400.
591
+ Additional bounds/defaults: `keepRecent` 1–100 (6), `userChars` 128–24000 (3000),
592
+ `summaryMaxChars` 256–128000 (24000), and `sessionLookbackHours` 1–168 (72).
593
+ The private cache keeps at most 256 records and expires idle clocks and prefix mappings after the
594
+ configured lifetime. A later idle window may compact a larger prefix; ordinary appended turns
595
+ reuse a stable prefix. Native Codex compaction remains Codex's own responsibility.
596
+
597
+ When using intact, configure the profile with an authorized intact API key. Client User-Agent
598
+ matching chooses the native protocol route; it does not replace upstream authentication.
592
599
 
593
600
  ### Updates
594
601
 
@@ -808,6 +815,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
808
815
  | `LLM_SWITCHER_BIFROST_RETRY_MS` | Time before the gateway asks intact again after a failed Bifrost lookup (default 30000 ms). |
809
816
  | `x-llm-profile: <key>` header (alias `x-profile`) or `?profile=<key>` | Route a single request through a specific profile. An unknown key returns HTTP 400 instead of silently falling back. |
810
817
  | `profile.thinkingMode` | `auto` (default, for gateways like intact or 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort`. `native` (strict OpenAI APIs): send only `reasoning_effort` when the client asks, never touch the prompt, use `max_completion_tokens`. `off`: never send reasoning parameters. |
818
+ | `profile.thinkingEffort` | `auto` (default, caller/client controls effort), or an explicit override level: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Overrides client effort before routing upstream. Automatically maps to the nearest equivalent level accepted by the target upstream (e.g. OpenAI ceiling `high`, Anthropic `max`, Gemini budget bounds). In the Web UI, this selector dynamically hides with a guidance hint when Bifrost passthrough is active. |
811
819
  | `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
812
820
  | `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
813
821
  | `CLAUDE_CONFIG_DIR` | Respected when locating Claude Code's `settings.json`. |
package/README.vi.md CHANGED
@@ -550,43 +550,47 @@ Trên Linux, unit là service systemd *của user*: nó khởi động khi bạn
550
550
 
551
551
  ### Nén hội thoại khi để lâu
552
552
 
553
- Một hội thoại để lâu đủ thì sẽ mất cache của provider. Request kế tiếp phải trả full price cho một
554
- prefix mà provider không còn giữ, và lượt sau đó lại trả tiếp. Mỗi provider giữ cache bao lâu là
555
- việc của riêng họ: Anthropic 5 phút mặc định, 1 giờ nếu request yêu cầu; OpenAI 30 phút; Gemini và
556
- DeepSeek không công bố thời hạn nào.
553
+ Khoảng nghỉ đủ dài có thể là dấu hiệu cache của provider đã nguội. Đây là ngưỡng do bạn chọn,
554
+ không phải bảo đảm cache đã hết hạn. Tính năng mặc định tắt.
557
555
 
558
- Khi một hội thoại Claude Code quay lại sau một khoảng nghỉ, và đủ lớn để đáng xử lý, gateway rút gọn
559
- lịch sử và ghi kết quả nén vào session file. Lượt lẽ ra phải tốn cả lịch sử giờ chỉ tốn phần tóm
560
- tắt.
556
+ Với phiên Claude Code có định danh rõ ràng, gateway rút gọn một prefix cũ sau khoảng nghỉ và lưu
557
+ ánh xạ giữa hash của prefix gốc với bản thay thế trong cache riêng `STATE_DIR/idle-compact`.
558
+ Các request sau chỉ dùng lại khi prefix gốc khớp hoàn toàn, giữ nguyên mọi turn gần đây và turn
559
+ mới thêm. Cache còn hiệu lực sau khi gateway khởi động lại. Nếu client tự compact hoặc sửa lịch
560
+ sử cũ, ánh xạ hết hiệu lực. Tính năng không đọc hay sửa JSONL của client hoặc SQLite của Codex.
561
561
 
562
- | | |
562
+ | Lệnh | Hành vi |
563
563
  | --- | --- |
564
564
  | `switch compact status` | Xem cấu hình |
565
- | `switch compact on` / `off` | Bật hoặc tắt (mặc định tắt) |
566
- | `switch compact model <id>` | Model nào viết phần tóm tắt. Rỗng hoặc `default`: dùng model của chính hội thoại đó |
567
- | `switch compact idle <phút>` | Nghỉ bao lâu thì coi là mất cache (15) |
568
- | `switch compact min <KB>` | Hội thoại phải lớn bao nhiêu mới đáng nén (64) |
569
-
570
- Hai điều là cố ý.
571
-
572
- **Chỉ một lần, không nén mọi lượt.** Nén mỗi lượt sẽ làm prefix đổi mỗi lượt, nên không lượt nào
573
- trúng cache, và hội thoại bị tóm tắt đi đi lại. Nén đúng lúc một khoảng nghỉ báo đã mất cache thì
574
- phần tóm tắt giữ nguyên cho các lượt sau.
575
-
576
- **Ghi vào session file, không qua API.** Claude Code dựng mọi request từ chuỗi entry trong
577
- `~/.claude/projects/<project>/<session>.jsonl`, nên một lần nén ghi vào đó rút gọn **mọi** request sau
578
- đó, chứ không chỉ lượt đã kích hoạt. Hai entry đúng bằng hai entry Claude Code tự ghi: một
579
- `compact_boundary` và một entry `user` mang cờ `isCompactSummary`. Các entry cũ vẫn nằm trong file —
580
- Claude Code cũng vậy — nên một bản transcript người ta vẫn đọc được đáng giá hơn một file nhỏ. Việc
581
- rút gọn có hiệu lực từ lần resume kế tiếp của phiên đó.
582
-
583
- Model rẻ viết phần tóm tắt rẻ và tệ hơn: tóm tắt thay cả lịch sử, nên thứ nó bỏ sót là mất hẳn.
584
- Đó là quyết định của người đọc, nên nó là một setting.
585
-
586
- Codex không xử lý ở đây. Nén của Codex là một item protocol chứ không phải file, provider gateway trả
587
- lời, và Codex tự lưu vào history của nó. Rút gọn request Codex ở phía client sẽ thay bằng một phần
588
- tóm tắt mà không client nào ghi lại. Antigravity CLI không có session file riêng, nên không có gì để
589
- làm cho việc rút gọn kéo dài.
565
+ | `switch compact on` / `off` | Bật hoặc tắt nén request Claude |
566
+ | `switch compact codex on` / `off` | Bật riêng cache prefix cho request Responses của Codex; mặc định tắt |
567
+ | `switch compact model <id>` | Chọn rõ model viết summary |
568
+ | `switch compact model none` / `default` | Không gọi model summary; không dùng model của hội thoại làm dự phòng |
569
+ | `switch compact idle <phút>` | Ngưỡng nghỉ, mặc định 15; phạm vi 1–1440 |
570
+ | `switch compact min <KB>` | Kích thước request tối thiểu, mặc định 64; phạm vi 8–4096 |
571
+
572
+ Không đặt model summary (chuỗi rỗng hoặc `null`) thì không gọi model nào. Bản rút gọn xác định
573
+ sẵn giữ chữ của user và assistant trong ngân sách đã chọn, bỏ payload tool cũ và giữ tail gần
574
+ nhất cùng đủ cặp call/result. Nếu có model summary, một lượt gọi nền chỉ tóm tắt prefix được
575
+ thay thế rồi cập nhật generation cache. Summary đến muộn không ghi đè generation mới hơn và
576
+ không mất các turn xuất hiện trong lúc chờ. Model summary được map và chọn format upstream riêng.
577
+
578
+ Phải có định danh phiên rõ ràng: header/metadata phiên Claude, hoặc session, thread hay prompt
579
+ cache key của Codex. Request không có định danh giữ nguyên; cùng câu mở đầu không được coi là
580
+ cùng phiên. Phạm vi cache gồm client, digest credential của caller, profile, model, system/tool
581
+ và policy nén. Lịch sử tool không được hỗ trợ hoặc sai cặp giữ nguyên; cache lỗi cũng gửi nguyên
582
+ request gốc.
583
+
584
+ Dashboard và `/api/idle-compact` dùng cùng cấu hình với CLI. Các field được nhận: `enabled`,
585
+ `codex`, `model`, `idleMinutes`, `minBytes`, `keepRecent`, `userChars`, `summaryMaxChars` và
586
+ `sessionLookbackHours`. API trả HTTP 400 cho field lạ hoặc ngân sách không hợp lệ.
587
+ Các phạm vi/mặc định còn lại: `keepRecent` 1–100 (6), `userChars` 128–24000 (3000),
588
+ `summaryMaxChars` 256–128000 (24000), `sessionLookbackHours` 1–168 (72).
589
+ Cache riêng giữ tối đa 256 record, hết hạn theo thời gian đã cấu hình. Một khoảng nghỉ mới có
590
+ thể nén prefix lớn hơn; các turn liên tiếp dùng lại prefix ổn định. Codex tự quản lý native compaction.
591
+
592
+ Khi dùng intact, profile cần API key intact được cấp quyền. User-Agent chỉ chọn đường protocol
593
+ gốc, không thay cho xác thực ở upstream.
590
594
 
591
595
  ### Cập nhật
592
596
 
@@ -805,6 +809,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
805
809
  | `LLM_SWITCHER_BIFROST_RETRY_MS` | Thời gian trước khi gateway hỏi lại intact sau một lần tra cứu Bifrost thất bại (mặc định 30000 ms). |
806
810
  | Header `x-llm-profile: <key>` (tên khác `x-profile`) hoặc `?profile=<key>` | Định tuyến riêng 1 request qua profile chỉ định. Key không tồn tại trả HTTP 400 thay vì âm thầm dùng profile khác. |
807
811
  | `profile.thinkingMode` | `auto` (mặc định, cho gateway như intact hoặc 9Router): phục hồi thinking bị xoá, inject hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort`. `native` (API OpenAI nghiêm ngặt): chỉ gửi `reasoning_effort` khi client yêu cầu, không sửa prompt, dùng `max_completion_tokens`. `off`: không bao giờ gửi tham số reasoning. |
812
+ | `profile.thinkingEffort` | `auto` (mặc định, client tự quyết định), hoặc đặt cố định mức override: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Ghi đè mức reasoning effort từ công cụ trước khi gửi lên upstream. Tự động ánh xạ về mức tương đương gần nhất mà upstream đích hỗ trợ (OpenAI trần `high`, Anthropic trần `max`, Gemini điều chỉnh `thinkingBudget`). Trên Web UI, tùy chọn này tự động ẩn cùng thông báo gợi ý khi profile bật chế độ Bifrost passthrough. |
808
813
  | `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
809
814
  | `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
810
815
  | `LLM_SWITCHER_STATE_DIR` | Chuyển file launcher và log ra khỏi thư mục dữ liệu. Test dùng biến này; shim đọc thư mục đã đặt lúc cài shim. |
@@ -0,0 +1,224 @@
1
+ # Báo cáo phiên làm việc — 2026-10-07
2
+
3
+ > Bản ghi của một phiên làm việc, để đọc lại được về sau. Nó nói cả những gì đã kiểm chứng và
4
+ > những gì **chưa**, và các mục trong bảng mục lục là các phần.
5
+
6
+ Một phiên làm việc trên hai repo: **llm-switcher** (gateway ở phía client) và **intact** (gateway
7
+ ở phía provider). Chủ đề xuyên suốt: **một hội thoại dài đáng tiền khi cache đã mất, và cách làm nó
8
+ rẻ đi mà không phá client nào.**
9
+
10
+ Trang này là bản ghi của những gì đã kiểm chứng, những gì đo được, và những gì **chưa** chứng
11
+ minh được. Phần cuối là danh sách việc dở — có một mục đã biết là chưa xong và nói rõ ở đó.
12
+
13
+ ---
14
+
15
+ ## 1. Kết quả, theo thứ tự đã giao
16
+
17
+ ### intact — `5dddbbe`, `ba19457` · deploy `intact`, `intact-af`, `intact-ops`
18
+
19
+ **a. 429 → retry với context đã rút gọn.**
20
+
21
+ Giới hạn của provider tính theo token/phút, nên account kế tiếp gặp đúng cuộc hội thoại đó trước
22
+ cùng bức tường. Tệ hơn: account kế tiếp **không có cache** cho prefix này, vì nó chưa từng thấy lượt
23
+ trả lời đầu — lịch sử dài tốn tiền đầy đủ và không đổi lấy gì.
24
+
25
+ Đo trên một hội thoại agent 32 KB: retry đi ra **10 KB, bớt 68%**.
26
+
27
+ Hai cổng, và không có gì ngoài chúng:
28
+
29
+ | Cổng | Vì sao |
30
+ |---|---|
31
+ | Provider ở chế độ round-robin **và** còn account sẵn sàng | Provider ghim một account thì không có chỗ nào nhận bản rút gọn; rút gọn chỉ là vứt lịch sử đi. Một account standby không tính là sẵn sàng — nó là phương án cuối. |
32
+ | **429 thật** | `500` cũng được retry nhưng rút gọn không giúp: provider **hỏng**, không phải **đầy**. |
33
+
34
+ **b. Codex compaction — trả đúng 1 `compaction` item.**
35
+
36
+ Codex 0.160 gửi một `compaction_trigger` và chỉ nhận đúng một output item loại `compaction`. Provider
37
+ trả lời như một completion thường thì cho 0 item, và Codex coi đó là lỗi chết **trước khi** nó thay
38
+ lịch sử — thread giữ nguyên độ dài, mọi lần `resume` lại compact và chết y hệt. Tính năng báo
39
+ `removed`, không tắt được.
40
+
41
+ Kiểm chứng trên provider codex thật: **HTTP 200, đúng 1 compaction item**, cả stream lẫn non-stream.
42
+
43
+ Ba điều không dễ thấy:
44
+ - Mang theo setting của client. Responses của OpenAI từ chối request không có `store:false`, và từ
45
+ chối không có `stream:true` — nó sẽ từ chối đúng những account đang phục vụ mọi turn khác.
46
+ - Account chỉ nói dạng stream thì một lần retry bật flag.
47
+ - **Không bao giờ lưu summary rỗng.** Provider trả tool call sẽ nhận lỗi, vì summary rỗng thay cả
48
+ lịch sử bằng hư không.
49
+
50
+ **c. Cổng xác thực nhận client chính thức của provider.**
51
+
52
+ Codex nói chuyện với ChatGPT bằng credential của ChatGPT. Trỏ sang intact thì nó vẫn gửi credential
53
+ đó, mà intact không kiểm được — intact chỉ biết key mình phát. Request mà intact sinh ra để phục
54
+ vụ bị chặn ngay cửa.
55
+
56
+ Miễn trừ hẹp **hai chiều**: chỉ route proxy `/v1/*`, không phải `/api/*` (nếu không, một
57
+ User-Agent bịa sẽ mở usage, quota, error log, drift); và chỉ đúng provider khai báo tiền tố — client
58
+ codex hỏi model claude thì **403**. Principal đó không có key, không admin, không model nào riêng;
59
+ credential lấy từ kho intact đã lưu, không lấy từ caller.
60
+
61
+ Hai lỗi test bắt được: `codex_cli_rs/` là của **TUI tương tác**, còn `codex exec` gửi
62
+ `codex_exec/` — nên `BifrostUA` phải nhận nhiều tiền tố. Và một test đang **đòi từ chối** chính Claude
63
+ Code thật — cái sai là test.
64
+
65
+ ### llm-switcher — `281f180` → `4423205`, 1.5.0 → 1.6.0
66
+
67
+ **a. Nén hội thoại khi idle quá lâu (Claude Code).**
68
+
69
+ Hội thoại để lâu thì mất cache. Thời gian giữ cache là việc riêng của từng provider:
70
+
71
+ | Provider | Thời hạn |
72
+ |---|---|
73
+ | Anthropic | 5 phút mặc định, 1 giờ nếu request yêu cầu (`cache_control.ttl`) |
74
+ | OpenAI | `prompt_cache_options.ttl` chỉ có `"30m"`; model cũ dùng `prompt_cache_retention`, `in_memory` ~5–10 phút |
75
+ | Gemini | implicit caching không công bố thời hạn; explicit mặc định 1 giờ |
76
+ | DeepSeek | tự động trên đĩa, không công bố thời hạn |
77
+
78
+ Nên thời gian là **setting**, mặc định 15 phút.
79
+
80
+ Hai quyết định:
81
+
82
+ - **Nén một lần, không nén mọi lượt.** Đổi prefix mỗi lượt nghĩa là không lượt nào trúng cache,
83
+ và hội thoại bị tóm tắt đi đi lại.
84
+ - **Ghi vào session file, không qua API.** Claude Code dựng mọi request từ chuỗi entry trong
85
+ `~/.claude/projects/<project>/<session>.jsonl`, nên một lần nén ghi vào đó rút gọn **mọi** request
86
+ sau, không chỉ lượt kích hoạt.
87
+
88
+ Entry ghi ra đúng hai loại Claude Code tự ghi: `compact_boundary` và một entry `user` mang
89
+ `isCompactSummary`. Entry cũ **vẫn còn** trong file — Claude Code cũng vậy sau khi nén 949k token —
90
+ vì một transcript người ta vẫn đọc được đáng giá hơn một file nhỏ.
91
+
92
+ **Kiểm chứng trên Claude Code thật:** 12 turn giả (~200 KB) + một boundary → `claude --resume` gửi
93
+ **4 message, 80.567 byte** thay vì 13; có marker summary; **0** còn 4000 ký tự filler.
94
+
95
+ **b. Dashboard.** Card trên trang Routes, đọc/ghi `/api/idle-compact` — **cùng key với `switch
96
+ compact`**, nên trang và terminal không thể lệch nhau.
97
+
98
+ **c. Nén cho Codex (1.6.0).** Xem mục 4.
99
+
100
+ ---
101
+
102
+ ## 2. Cách kiểm chứng
103
+
104
+ | Cách | Kết quả |
105
+ |---|---|
106
+ | Test end-to-end có 429 thật, store thật, account thật | retry 32.532 → 10.473 byte |
107
+ | Test Claude Code thật, đọc request nó gửi | 4 message, không còn turn cũ |
108
+ | Gọi thẳng intact với `compaction_trigger`, provider codex thật | đúng 1 compaction item |
109
+ | Cổng xác thực, 5 hành vi trên bản đang chạy | key thật 200 · UA lạ 401 · UA codex 200 · `/api/*` 401 · codex→claude 403 |
110
+ | **Claude Code thật qua gateway đang chạy** | `idle compact h:bbd52533-4 [p1]: 9681 -> 3832 bytes` · `summary stored` · session file có `compact_boundary` + `isCompactSummary` · `compactMetadata` preTokens 2420 → postTokens 958 |
111
+ | Suite | llm-switcher 704 pass · intact toàn bộ xanh · zencore 301 pass |
112
+
113
+ ---
114
+
115
+ ## 3. Những điều đoán sai rồi đính chính
116
+
117
+ Ghi lại vì chúng là phần tốn thời gian nhất và có thể sai lại.
118
+
119
+ 1. **"Không có cách nào báo cho tool biết phải compact."** Sai. llm-switcher ngồi ở phía client, có
120
+ filesystem. Tôi đã nghĩ trong khuôn protocol rồi kết luận là không thể.
121
+ 2. **"Sao 1.4.0 chưa tải được?"** Đúng là registry-side propagation, không phải publish hỏng.
122
+ 3. **Remote compaction của Codex gắn với auth mode.** Sai: nó compact cục bộ cả khi auth là
123
+ ChatGPT và endpoint trỏ về intact. Nó gắn với **endpoint có phải backend ChatGPT thật không**.
124
+ 4. **Ghi vào file jsonl của Codex là đủ.** Sai: SQLite mới là nơi Codex đọc.
125
+ 5. **Viết hai nửa vào store là xong.** Sai: còn phải đẩy projection.
126
+
127
+ ---
128
+
129
+ ## 4. Cơ chế compaction của Codex — đã tìm ra đủ
130
+
131
+ Một compaction của Codex là **ba ghi phải khớp nhau**, đọc từ một thread thật đã compact 7 lần:
132
+
133
+ 1. **rollout jsonl**: entry `{type:"compacted", payload:{message, replacement_history, …}}` — mang
134
+ nội dung tóm tắt.
135
+ 2. **`thread_history_1.sqlite` → `thread_items`**: hàng `item_type='contextCompaction'`, mà
136
+ `item_json` chỉ có `{"type":"contextCompaction","id":…}` — **chỉ là mốc, không có chữ summary**.
137
+ 3. **`thread_history_projection_state`**: `next_rollout_byte_offset` và `next_rollout_ordinal`.
138
+
139
+ Mảnh thứ ba là cái dễ bỏ, và bỏ nó thì **trông y hệt thành công**. Bảng đó ghi store đã đọc tới đâu
140
+ trong file: `next_rollout_byte_offset` **bằng đúng kích thước file** (đã đo: 3.722.618 byte =
141
+ `os.path.getsize`), `next_rollout_ordinal` là ordinal sau entry cuối. **File là bản ghi, store là
142
+ projection của nó.** Ghi thêm vào file mà không đẩy projection thì entry đó không bao giờ được đọc.
143
+
144
+ Kiểm chứng: cài một compaction chứa `CON_VIET_4711` vào thread Codex thật → `resume` → Codex trả
145
+ đúng `CON_VIET_4711`. Qua gateway: `contextCompaction 1` trong store, projection đã đẩy, log ghi
146
+ `summary stored`.
147
+
148
+ Ba lỗi chỉ chạy mới thấy:
149
+ - Ordinal lấy từ **projection**, không phải `MAX(rollout_ordinal)` — hai số khác nhau.
150
+ - Hàng marker cần `started_at_ms` và `completed_at_ms`; hàng của Codex có, để NULL thì không đọc được.
151
+ - Hội thoại **không có message người dùng** thì không có conversation key → không làm gì, trông
152
+ như tính năng tắt chứ không phải input sai.
153
+
154
+ ---
155
+
156
+ ## 5. Bốn quy tắc đã đặt, và trạng thái
157
+
158
+ | Quy tắc | Trạng thái |
159
+ |---|---|
160
+ | Chỉ dùng intact và llm-switcher, không đụng config Codex | **OK** — `~/.codex` không có file config nào; mọi test Codex chạy trong sandbox `CODEX_HOME` riêng |
161
+ | Không tốn token account chính thức | **OK, sau khi sửa** — xem dưới |
162
+ | 429 → compact | **OK** — intact, 4 test e2e |
163
+ | Hết TTL cache → tự compact | **OK** — llm-switcher, test Claude Code thật |
164
+
165
+ **Quy tắc 2 đã bị vi phạm và đã sửa.** Khi không đặt model summary, code gọi model của chính thread —
166
+ với Codex đó là `gpt-5.6-sol` trên tài khoản ChatGPT chính thức, đúng thứ bị cấm. Tệ hơn: lượt
167
+ summary chỉ dùng để ghi session file, nên không có gì để lưu — gọi model chỉ tốn tiền mà không đổi
168
+ lấy gì. Nay **không gọi model nào trừ khi có đặt**; phần tiết kiệm không đổi vì request vẫn ra
169
+ ngắn.
170
+
171
+ ---
172
+
173
+ ## 6. Lỗi của tôi trong phiên này
174
+
175
+ - **Giết nhầm zencore.** Chọn port 8793 cho công cụ thử mà không kiểm tra port đó đã có dịch vụ
176
+ chạy. Đã khởi động lại và xác nhận khỏe, nhưng đó là lỗi của tôi.
177
+ - **Trộn hai kiểu dữ liệu.** Đưa shape của IR vào body Messages, khiến body ra upstream sai định
178
+ dạng. Chỉ thấy khi đọc body thật ra ngoài.
179
+ - **Ghi session file bằng rename.** Sẽ mất entry: Claude Code giữ file mở, append tiếp vào inode cũ.
180
+ - **Bỏ qua kiểm tra ngược.** Viết xong rồi chỉ chạy test, không đọc lại code sạch sau những lần sửa.
181
+
182
+ ---
183
+
184
+ ## 7. Việc dở
185
+
186
+ **Không còn mục nào trong bốn quy tắc.**
187
+
188
+ Đã đóng trong phiên: validate Claude Code qua gateway thật. Còn ba lỗi của *công cụ thử*, không
189
+ phải của tính năng — model thử không tồn tại ở intact; sandbox đặt `CLAUDE_CONFIG_DIR` chỉ cho
190
+ Claude Code nên gateway tìm nhầm `~/.claude` thật; và hàm thử dọn đồ trước khi lượt summary xong.
191
+ Cả ba đã sửa và chạy lại thành công.
192
+
193
+ Còn một việc **không tự làm được**, vì nó nằm trên tài khoản của bạn: thu hồi token npm trong
194
+ `/opt/hermes/.npmrc`. `id_token` OpenAI từng dán trong chat đã hết hạn từ lâu, không cần làm gì.
195
+
196
+ Còn một điều **không xong**: Codex **không** dùng compaction của provider. `remote_compaction_v2` chỉ
197
+ chạy khi Codex nói chuyện với backend ChatGPT thật; `CHATGPT_BASE_URL` bị bỏ qua, và các khoá tắt
198
+ WebSocket không ăn. Để đi hết đường đó phải chặn TLS (`HTTPS_PROXY` + `SSL_CERT_FILE`), mà tôi
199
+ không tự làm vì nó đổi niềm tin TLS của máy.
200
+
201
+ Hệ quả đã ghi rõ trong tài liệu: cờ `idleCompact.codex` tắt mặc định, tiết kiệm đúng **một lượt**,
202
+ và không có lời hứa nào rằng thread sẽ giữ ngắn.
203
+
204
+ ---
205
+
206
+ ## 8. Bàn giao
207
+
208
+ | | |
209
+ |---|---|
210
+ | llm-switcher | `4423205`, 1.6.0, `main` |
211
+ | intact | `ba19457`, `master` |
212
+ | zencore | không đổi |
213
+ | Service | `intact`, `intact-af`, `intact-ops`, `zencore` — đều `active` |
214
+ | Gateway | llm-switcher, Claude Code → `claude-intact` |
215
+ | Tính năng | `switch compact` — **tắt** |
216
+
217
+ ```
218
+ switch compact on # Claude Code
219
+ switch compact on && switch compact model infron/deepseek/deepseek-v4-flash:free
220
+ switch compact idle 30 # phút
221
+ switch compact min 128 # KB
222
+ ```
223
+
224
+ **Còn nợ bạn:** token npm và `id_token` từng dán trong chat — hai thứ đều nên thu hồi.