Skip to content

Commit 22dbb38

Browse files
Merge upstream/main (auto-sync feat/copilot)
- e7f4dd4 fix(openai): keep referenced tool call when deduping websocket input IDs - f05d68d refactor(openai): parse dedupe input item metadata in a single pass - bf04a24 feat(models): add support for `grok-composer-2.5-fast` model - 87d813c chore(models): remove legacy GPT 5.2 and GPT 5.3 Codex entries from registry - 7cb466a docs: add Panopticon to "Who is with us?" - c9dc6bd Fix Home auth refresh retry handling - 603a08f feat(codex): cache reasoning replay items - 68282c4 fix(translator): normalize message-level system roles for Gemini - 4abd59c Merge pull request router-for-me#3679 from sususu98/codex/system-role-to-user-upstream-dev - 8306391 Merge pull request router-for-me#3667 from sususu98/feat/codex-reasoning-replay-cache-upstream-dev - 02d0d92 Merge pull request router-for-me#3677 from sususu98/codex/home-auth-loop-upstream-dev - d710248 Merge pull request router-for-me#3676 from eltmon/add-panopticon-to-users - 28c7f41 docs(readme): update project descriptions and add Panopticon link - 0e3c809 fix(codex): handle non-empty reasoning and content items, add test for trailing empty messages - 35ab084 refactor(runtime): enhance `NewUtlsHTTPClient` with context-based RoundTripper - 17af089 fix(codex): avoid replaying orphan tool calls - 7682acc Merge pull request router-for-me#3688 from sususu98/fix-codex-replay-dev - 45f58d4 fix(auth): retry and backoff cloudflare challenge 403 errors - 77061aa refactor(auth): simplify and narrow cloudflare challenge checks - 1c9601f Merge pull request router-for-me#3689 from sususu98/pr/cloudflare-challenge-retry - 9c02454 Merge pull request router-for-me#3657 from catoncat/fix/responses-input-id-dedupe-orphaned-output
2 parents 813ed4f + 9c02454 commit 22dbb38

31 files changed

Lines changed: 2707 additions & 250 deletions

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,7 @@ Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Prox
8080

8181
Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics.
8282

83-
### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
84-
85-
Local-first usage and quota dashboard for CLIProxyAPI. It collects per-request token usage from the Redis-compatible usage queue into SQLite, visualizes daily and recent-window usage by account and model, and shows Codex 5h/7d quota remaining in a local web UI.
86-
87-
### [CPA-Manager](https://github.com/seakee/CPA-Manager)
83+
### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
8884

8985
Full CLIProxyAPI management center with request-level monitoring and cost estimates. CPA-Manager tracks collected requests by account, model, channel, latency, status, and token usage; estimates cost with editable model prices and one-click LiteLLM price sync; persists events in SQLite; and provides Codex account-pool operations with batch inspection, quota detection, unhealthy account discovery, cleanup suggestions, and one-click execution for day-to-day multi-account maintenance.
9086

@@ -205,6 +201,10 @@ Windows-focused, local-first desktop management platform for Codex CLI built on
205201

206202
Native macOS SwiftUI app for monitoring ChatGPT/Codex account quotas in CLIProxyAPI pools. Displays account availability, Plus-base capacity, 5-hour and weekly quota bars, plan weights, and restore forecasts through the Management API.
207203

204+
### [Panopticon](https://github.com/eltmon/panopticon-cli)
205+
206+
Multi-agent orchestration for AI coding assistants. Runs CLIProxyAPI as a local sidecar so its agents can drive GPT models through a ChatGPT subscription, pointing Claude Code at an Anthropic-compatible endpoint with no OpenAI API key required.
207+
208208
> [!NOTE]
209209
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
210210

README_CN.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,7 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo
8080

8181
独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CLIProxyAPI 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。
8282

83-
### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
84-
85-
面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 Token 消耗并写入 SQLite,按账号和模型可视化每日及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。
86-
87-
### [CPA-Manager](https://github.com/seakee/CPA-Manager)
83+
### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
8884

8985
面向 CLIProxyAPI 的完整管理中心,提供请求级监控和费用预估。CPA-Manager 可按账号、模型、渠道、延迟、状态和 token 用量追踪采集到的请求;支持可编辑模型价格与一键同步 LiteLLM 价格来估算费用;用 SQLite 持久化事件;并提供面向 Codex 账号池的批量巡检、配额识别、异常账号定位、清理建议与一键执行能力,适合多账号池的日常运维管理。
9086

@@ -201,6 +197,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
201197

202198
原生 macOS SwiftUI 应用,用于监控 CLIProxyAPI 池中的 ChatGPT/Codex 账号额度。通过 Management API 展示账号可用状态、Plus 基准容量、5 小时与周额度进度条、套餐权重和恢复预测。
203199

200+
### [Panopticon](https://github.com/eltmon/panopticon-cli)
201+
202+
面向 AI 编程助手的多智能体编排工具。它将 CLIProxyAPI 作为本地 sidecar 运行,使其智能体可以通过 ChatGPT 订阅驱动 GPT 模型,并将 Claude Code 指向 Anthropic 兼容端点,无需 OpenAI API 密钥。
203+
204204
> [!NOTE]
205205
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
206206

README_JA.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,7 @@ v6.10.0以降、CLIProxyAPIおよび [CPAMC](https://github.com/router-for-me/Cl
7878

7979
CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLIProxyAPIデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。
8080

81-
### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
82-
83-
CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのToken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量を可視化し、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。
84-
85-
### [CPA-Manager](https://github.com/seakee/CPA-Manager)
81+
### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
8682

8783
リクエスト単位の監視とコスト推定を備えたCLIProxyAPI向けのフル管理センターです。CPA-Managerは、収集したリクエストをアカウント、モデル、チャネル、レイテンシ、ステータス、Token使用量ごとに追跡し、編集可能なモデル価格とLiteLLM価格のワンクリック同期でコストを推定します。SQLiteでイベントを永続化し、Codexアカウントプール向けに一括検査、クォータ判定、異常アカウント検出、クリーンアップ提案、ワンクリック実行を提供し、日常的なマルチアカウント運用に適しています。
8884

@@ -200,6 +196,10 @@ CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデス
200196

201197
CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視するmacOSネイティブSwiftUIアプリ。Management APIを通じて、アカウントの可用性、Plus基準の容量、5時間/週次クォータバー、プラン重み、復元予測を表示します。
202198

199+
### [Panopticon](https://github.com/eltmon/panopticon-cli)
200+
201+
AIコーディングアシスタント向けのマルチエージェントオーケストレーションツール。CLIProxyAPIをローカルsidecarとして実行することで、エージェントがChatGPTサブスクリプション経由でGPTモデルを利用できるようにし、Claude CodeをAnthropic互換エンドポイントへ向けるため、OpenAI APIキーは不要です。
202+
203203
> [!NOTE]
204204
> CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
205205
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package cache
2+
3+
import (
4+
"sort"
5+
"strings"
6+
"sync"
7+
"time"
8+
9+
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
10+
"github.com/tidwall/gjson"
11+
"github.com/tidwall/sjson"
12+
)
13+
14+
const (
15+
// CodexReasoningReplayCacheTTL limits how long encrypted reasoning replay
16+
// items stay in process memory.
17+
CodexReasoningReplayCacheTTL = 1 * time.Hour
18+
19+
// CodexReasoningReplayCacheMaxEntries bounds process memory for replay
20+
// continuity. Oldest entries are evicted first.
21+
CodexReasoningReplayCacheMaxEntries = 10240
22+
23+
// CodexReasoningReplayCacheEvictBatchSize leaves headroom after the cache
24+
// reaches capacity so high write volume does not rescan the map every turn.
25+
CodexReasoningReplayCacheEvictBatchSize = 128
26+
)
27+
28+
type codexReasoningReplayEntry struct {
29+
Items [][]byte
30+
Timestamp time.Time
31+
}
32+
33+
var (
34+
codexReasoningReplayMu sync.Mutex
35+
codexReasoningReplayEntries = make(map[string]codexReasoningReplayEntry)
36+
)
37+
38+
// CacheCodexReasoningReplayItem stores a final GPT/Codex reasoning item for
39+
// stateless replay. The stored item is normalized to the minimal shape accepted
40+
// by Responses input replay.
41+
func CacheCodexReasoningReplayItem(modelName, sessionKey string, item []byte) bool {
42+
return CacheCodexReasoningReplayItems(modelName, sessionKey, [][]byte{item})
43+
}
44+
45+
// CacheCodexReasoningReplayItems stores the final GPT/Codex assistant output
46+
// items needed to replay a stateless next turn.
47+
func CacheCodexReasoningReplayItems(modelName, sessionKey string, items [][]byte) bool {
48+
key := codexReasoningReplayCacheKey(modelName, sessionKey)
49+
if key == "" {
50+
return false
51+
}
52+
normalized, ok := normalizeCodexReasoningReplayItems(items)
53+
if !ok {
54+
return false
55+
}
56+
57+
cacheCleanupOnce.Do(startCacheCleanup)
58+
now := time.Now()
59+
codexReasoningReplayMu.Lock()
60+
defer codexReasoningReplayMu.Unlock()
61+
codexReasoningReplayEntries[key] = codexReasoningReplayEntry{
62+
Items: normalized,
63+
Timestamp: now,
64+
}
65+
if len(codexReasoningReplayEntries) > CodexReasoningReplayCacheMaxEntries {
66+
evictOldestCodexReasoningReplayEntries(CodexReasoningReplayCacheEvictBatchSize)
67+
}
68+
return true
69+
}
70+
71+
// GetCodexReasoningReplayItem retrieves a normalized reasoning replay item.
72+
func GetCodexReasoningReplayItem(modelName, sessionKey string) ([]byte, bool) {
73+
items, ok := GetCodexReasoningReplayItems(modelName, sessionKey)
74+
if !ok || len(items) == 0 {
75+
return nil, false
76+
}
77+
return items[0], true
78+
}
79+
80+
// GetCodexReasoningReplayItems retrieves normalized assistant output items.
81+
func GetCodexReasoningReplayItems(modelName, sessionKey string) ([][]byte, bool) {
82+
key := codexReasoningReplayCacheKey(modelName, sessionKey)
83+
if key == "" {
84+
return nil, false
85+
}
86+
87+
cacheCleanupOnce.Do(startCacheCleanup)
88+
now := time.Now()
89+
codexReasoningReplayMu.Lock()
90+
defer codexReasoningReplayMu.Unlock()
91+
entry, ok := codexReasoningReplayEntries[key]
92+
if !ok {
93+
return nil, false
94+
}
95+
if now.Sub(entry.Timestamp) > CodexReasoningReplayCacheTTL {
96+
delete(codexReasoningReplayEntries, key)
97+
return nil, false
98+
}
99+
entry.Timestamp = now
100+
codexReasoningReplayEntries[key] = entry
101+
return cloneCodexReasoningReplayItems(entry.Items), true
102+
}
103+
104+
// DeleteCodexReasoningReplayItem removes one replay item after upstream rejects
105+
// it or the caller otherwise knows it is stale.
106+
func DeleteCodexReasoningReplayItem(modelName, sessionKey string) {
107+
key := codexReasoningReplayCacheKey(modelName, sessionKey)
108+
if key == "" {
109+
return
110+
}
111+
codexReasoningReplayMu.Lock()
112+
delete(codexReasoningReplayEntries, key)
113+
codexReasoningReplayMu.Unlock()
114+
}
115+
116+
// ClearCodexReasoningReplayCache clears all Codex reasoning replay state.
117+
func ClearCodexReasoningReplayCache() {
118+
codexReasoningReplayMu.Lock()
119+
codexReasoningReplayEntries = make(map[string]codexReasoningReplayEntry)
120+
codexReasoningReplayMu.Unlock()
121+
}
122+
123+
func codexReasoningReplayCacheKey(modelName, sessionKey string) string {
124+
modelName = strings.TrimSpace(modelName)
125+
sessionKey = strings.TrimSpace(sessionKey)
126+
if modelName == "" || sessionKey == "" {
127+
return ""
128+
}
129+
// The session key is the continuity boundary. Keep this independent from
130+
// the selected upstream Codex credential so auth failover can preserve replay.
131+
return strings.Join([]string{"codex-reasoning-replay", modelName, sessionKey}, "\x00")
132+
}
133+
134+
func normalizeCodexReasoningReplayItems(items [][]byte) ([][]byte, bool) {
135+
normalized := make([][]byte, 0, len(items))
136+
for _, item := range items {
137+
normalizedItem, ok := normalizeCodexReasoningReplayItem(item)
138+
if ok {
139+
normalized = append(normalized, normalizedItem)
140+
}
141+
}
142+
return normalized, len(normalized) > 0
143+
}
144+
145+
func normalizeCodexReasoningReplayItem(item []byte) ([]byte, bool) {
146+
itemResult := gjson.ParseBytes(item)
147+
switch strings.TrimSpace(itemResult.Get("type").String()) {
148+
case "reasoning":
149+
return normalizeCodexReasoningReplayReasoningItem(itemResult)
150+
case "function_call":
151+
return normalizeCodexReasoningReplayFunctionCallItem(itemResult)
152+
case "custom_tool_call":
153+
return normalizeCodexReasoningReplayCustomToolCallItem(itemResult)
154+
default:
155+
return nil, false
156+
}
157+
}
158+
159+
func normalizeCodexReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte, bool) {
160+
encryptedContentResult := itemResult.Get("encrypted_content")
161+
if encryptedContentResult.Type != gjson.String {
162+
return nil, false
163+
}
164+
encryptedContent := encryptedContentResult.String()
165+
if encryptedContent != strings.TrimSpace(encryptedContent) {
166+
return nil, false
167+
}
168+
if _, err := signature.InspectGPTReasoningSignature(encryptedContent); err != nil {
169+
return nil, false
170+
}
171+
172+
normalized := []byte(`{"type":"reasoning","summary":[],"content":null}`)
173+
normalized, _ = sjson.SetBytes(normalized, "encrypted_content", encryptedContent)
174+
return normalized, true
175+
}
176+
177+
func normalizeCodexReasoningReplayFunctionCallItem(itemResult gjson.Result) ([]byte, bool) {
178+
callID := strings.TrimSpace(itemResult.Get("call_id").String())
179+
name := strings.TrimSpace(itemResult.Get("name").String())
180+
arguments := itemResult.Get("arguments")
181+
if callID == "" || name == "" || arguments.Type != gjson.String {
182+
return nil, false
183+
}
184+
185+
normalized := []byte(`{"type":"function_call"}`)
186+
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
187+
normalized, _ = sjson.SetBytes(normalized, "name", name)
188+
normalized, _ = sjson.SetBytes(normalized, "arguments", arguments.String())
189+
return normalized, true
190+
}
191+
192+
func normalizeCodexReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([]byte, bool) {
193+
callID := strings.TrimSpace(itemResult.Get("call_id").String())
194+
name := strings.TrimSpace(itemResult.Get("name").String())
195+
input := itemResult.Get("input")
196+
if callID == "" || name == "" || !input.Exists() {
197+
return nil, false
198+
}
199+
200+
normalized := []byte(`{"type":"custom_tool_call","status":"completed"}`)
201+
if status := strings.TrimSpace(itemResult.Get("status").String()); status != "" {
202+
normalized, _ = sjson.SetBytes(normalized, "status", status)
203+
}
204+
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
205+
normalized, _ = sjson.SetBytes(normalized, "name", name)
206+
if input.Type == gjson.String {
207+
normalized, _ = sjson.SetBytes(normalized, "input", input.String())
208+
} else {
209+
normalized, _ = sjson.SetRawBytes(normalized, "input", []byte(input.Raw))
210+
}
211+
return normalized, true
212+
}
213+
214+
func cloneCodexReasoningReplayItems(items [][]byte) [][]byte {
215+
cloned := make([][]byte, 0, len(items))
216+
for _, item := range items {
217+
cloned = append(cloned, append([]byte(nil), item...))
218+
}
219+
return cloned
220+
}
221+
222+
func evictOldestCodexReasoningReplayEntries(count int) {
223+
if count <= 0 || len(codexReasoningReplayEntries) == 0 {
224+
return
225+
}
226+
type candidate struct {
227+
key string
228+
timestamp time.Time
229+
}
230+
candidates := make([]candidate, 0, len(codexReasoningReplayEntries))
231+
for key, entry := range codexReasoningReplayEntries {
232+
candidates = append(candidates, candidate{key: key, timestamp: entry.Timestamp})
233+
}
234+
sort.Slice(candidates, func(i, j int) bool {
235+
return candidates[i].timestamp.Before(candidates[j].timestamp)
236+
})
237+
if count > len(candidates) {
238+
count = len(candidates)
239+
}
240+
for i := 0; i < count; i++ {
241+
delete(codexReasoningReplayEntries, candidates[i].key)
242+
}
243+
}
244+
245+
func purgeExpiredCodexReasoningReplayCache(now time.Time) {
246+
codexReasoningReplayMu.Lock()
247+
for key, entry := range codexReasoningReplayEntries {
248+
if now.Sub(entry.Timestamp) > CodexReasoningReplayCacheTTL {
249+
delete(codexReasoningReplayEntries, key)
250+
}
251+
}
252+
codexReasoningReplayMu.Unlock()
253+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package cache
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"testing"
7+
)
8+
9+
func validCodexReasoningReplayEncryptedContentForTest(seed byte) string {
10+
payload := make([]byte, 1+8+16+16+32)
11+
payload[0] = 0x80
12+
for i := 9; i < len(payload); i++ {
13+
payload[i] = seed + byte(i)
14+
}
15+
return base64.RawURLEncoding.EncodeToString(payload)
16+
}
17+
18+
func TestCodexReasoningReplayCacheRejectsInvalidItems(t *testing.T) {
19+
ClearCodexReasoningReplayCache()
20+
t.Cleanup(ClearCodexReasoningReplayCache)
21+
22+
if CacheCodexReasoningReplayItem("gpt-5.4", "session", []byte(`{"type":"reasoning","encrypted_content":"bad","summary":[]}`)) {
23+
t.Fatal("invalid encrypted_content should not be cached")
24+
}
25+
if _, ok := GetCodexReasoningReplayItem("gpt-5.4", "session"); ok {
26+
t.Fatal("invalid item was cached")
27+
}
28+
}
29+
30+
func TestCodexReasoningReplayCacheScopesByModelAndSession(t *testing.T) {
31+
ClearCodexReasoningReplayCache()
32+
t.Cleanup(ClearCodexReasoningReplayCache)
33+
34+
encryptedContent := validCodexReasoningReplayEncryptedContentForTest(7)
35+
if !CacheCodexReasoningReplayItem("gpt-5.4", "session-a", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}`)) {
36+
t.Fatal("valid item was not cached")
37+
}
38+
39+
if _, ok := GetCodexReasoningReplayItem("gpt-5.5", "session-a"); ok {
40+
t.Fatal("cache should not hit across models")
41+
}
42+
if _, ok := GetCodexReasoningReplayItem("gpt-5.4", "session-b"); ok {
43+
t.Fatal("cache should not hit across sessions")
44+
}
45+
46+
item, ok := GetCodexReasoningReplayItem("gpt-5.4", "session-a")
47+
if !ok {
48+
t.Fatal("cache miss for original model and session")
49+
}
50+
if string(item) != `{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}` {
51+
t.Fatalf("normalized item = %s", string(item))
52+
}
53+
}
54+
55+
func TestCodexReasoningReplayCacheBatchEvictsWhenFull(t *testing.T) {
56+
ClearCodexReasoningReplayCache()
57+
t.Cleanup(ClearCodexReasoningReplayCache)
58+
59+
encryptedContent := validCodexReasoningReplayEncryptedContentForTest(9)
60+
item := []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"` + encryptedContent + `"}`)
61+
for i := 0; i <= CodexReasoningReplayCacheMaxEntries; i++ {
62+
if !CacheCodexReasoningReplayItem("gpt-5.4", fmt.Sprintf("session-%d", i), item) {
63+
t.Fatalf("cache insert %d failed", i)
64+
}
65+
}
66+
67+
codexReasoningReplayMu.Lock()
68+
gotLen := len(codexReasoningReplayEntries)
69+
codexReasoningReplayMu.Unlock()
70+
if gotLen >= CodexReasoningReplayCacheMaxEntries {
71+
t.Fatalf("cache entries = %d, want batch eviction below max %d", gotLen, CodexReasoningReplayCacheMaxEntries)
72+
}
73+
}

0 commit comments

Comments
 (0)