1M-btvB8J7;#rbg^oY<}!2Tq<1A9;kznp`bP4Va&@fkS2=P5IL%M7QvZqNDzH2{Yhe!0n{F
zfb3hLYT;6kCou(@-kI2X@j0xn-j}C~ZxSDn#kRg9
zM(4o1hYlhojMeVKYHLCch+3RqAx3sMWmm2xMy)ohf(9Z!o_C9Aqy{L&)HuMGJR%;u
z`$g8cZB7DWC~mqW0Sck;0#BH7(WK@?_f02NCrOhHJBd>~-bho%RNk1l|HCk2WPkBL
zt|R-~(Qm+(pQ;Gyhg+fr4N7);`%CCh-VWam%RBvBzpU+fOVfA!#{$huvi+a+U*0!2
z!0#J(xB2fcWbfC9!U3&p#t(!+CD`$0Q2@U#q2JWKGhB%5kpSRg8n*9N3~**lciEOm
zk08bura`MAdBB2^sMIUs00x-;g
z0*vVVwX-+FK}l+16kF{u2iAsZSJr3wR8o=O6!%a#@o^=>GMIv~E?Ei!am2adgyVxb
zdEs@yDiDL1wj+Id*a4uKNoJweH5BpAIl0OGtd;ziXoYdLwg#P}oW^Hw>nVu>m`Ake
zoqJVQm@{!eAzJ6yIt_?H;}C!wX~)m|3LKDxZ02=Y3}7I_m=+x<^_-vti5(U&jJ5D?
zWZ*w@rvn-UiRGklV^HR97K7XVx0NxIq70s3oqT=hAbvd(j((0H4n}_nkQ$kdd&k
z*@uzc=GCqA#ON5_v5cxcd4llG#FYe@0%dqMF$YDo>G(=+A#$V08=)*75Ne-w0`Aj>
zr8irJ1^L=Z{cOunD*xy>cH^x6qI>>-Z+pb#ZyKMf=)=Wnz|dok(k
z=ofNV35?C_Y{?XZi0ou0h0SshBK=li5Zoue1d&e>A-o3IZi#kadipw4@KT1RoR=hb
zI7hEL{;MRz&-r>JVyO>xh`@`Sc(a@e({B!tCgfum2=CFP%u^d9uX##zw63eD@GZLT
zOUv~2qbZsK=x*MR_ahNG%MlJYPLL3_@2<+Y%jPWHk`c&YHTX`k4$mzxb#hdH+$~an
za@}^p=VK_~xWf4fHQ5OUg%iQ~j${-!(5^wztLsIxfo-)Vy>rl=Hj5ShW}&{O#gUu&
zoo^!ly`ce{8vogaH4z2yo<>Iw98Q2%n$&V`UyBU%gXSc<^ABLr#1)CC8LuuRya&7<
zVj``+lwOL>k{h#X;D!wxU&z@Ew?k5^RrI6?P`tap+N~a%8SptEs-`dOSIedY@)z-0
zy9uYzQ*g)PqkrBUlb^f~a0VU@rGq0YxBA3cM}rcV+*Ne;I7ji(zC?(*cYBIYTel_^EGp#vBv`DCc%B+BYOF?R)%%B
z2$!#_gJHL&RAlCUx_ZWhFDfOGk^V?}G$IKADh`cCa|CA-dTkrZ4KV}~owsXi7Ckv%
z0oFCRHX{9Pgbmh3yCScA6zZ;C_T8{cWunGV^Nw7lFS1ra*01YoXvejz@>WXzb$^;L
z$&JU4c|=~!gENrSr15`D)%jxP>(JoTg1#lIL$!!M$VIkc4~(I%diSAcy^Q;&rXPyF
ziD^?B^Li1WtZiwm?!$K9ob)cJ@lJ0d!`_YgCbl@w2)yDTU^1N+(P96{f8pz86;9g-
ze8Dck+%k}YBsyQXgF^f}Bc5ifJv1*fL(acB=Hof=_AoYpjhcx4wYo6Kk1`&`WRL=d~k0rvtp^)ixHixP1Va`DAra}a=U
zv@O^nlmBAAd<+D@&4qjASK7CP}4*1vZM{e?8%k|HVO|FagI7u;q^Fr
z1QoCs)Vs=>oNE9+c*%DVe{npL9}@zQhfZ$yLr?>7R%=uYQBfFv-jDr~Rd`i_iI1gJ
zVkO2=v3?EUrHig!L;8f{O|gIaP!E;_NeG0d@pU@-pt;D3_^#_5zXGvOha)7Xigy;7
z|5pe=KN7}(R4V@Kq$s3|h(fmY%bsk4#Xs^r(vBM{l+
z&r{cRQTawh4-Bh(pxxGJqnA=H{>fDen>!u{>zWqlvD>3ijH;p;_yugenqM+(xKOF;
z)_Gxh`ZhjwCE_+TiNEszW=YFKt~S^Hn}i6pHFBp~4gRje{#YoSxc8kBtdq*z+Fz42o3Z1DW33r3INkMf_uvL6pC;$nb=xXfHRt!VThNH
zh;%n%{1}ZCYdz)db2sWt2Ug!4m?@5PSo%$Dqu_EDajTKa+iF-=0jfc$byA4zh7|
zXYHz|fWT8YRkrl+psMzQQ@;Py@V*Z#xT7r26RJs9xkx(U!AyTFe;tQH*-GMy+F5fU
z09p2eVkvQXSi<*94-rVIIquTuBSt4Sx|o~*#18W;?nii?qu3)h$3mGGQ&d)i?@d?EIxHK1N#Yv_-#ocO!wbi^ID4_mbV#nkl8u@e1*19~k2(v!a
z&Hr3+>SeP&gFQ>gx@5l~3iWf2w^`p`F0ukk3u{`SSO9w3Jqi;Nm+%w?qQFWuNYDmr
z>y5_hM+7*c|0eA~{bY3MkF(BY9Z~I!N)EX}wn9Cyf3rBB@pgF%O$B{SHHMC^u1*f3
zq)#o<;6Kqe3Kc>ok~TUyl$Mvq4+RmLUe#}4O9Z2Ffi#l(TeU%F_y26lI2?mCGL#^A
z!d_a-g8N!kM}-QaUKhZDJqPQ^>UuS}Gx1BAo1R3VOdaq57lvls$!Q(=mpSoY({#tF
z7??A{%8C1Ho}^OEc9!-g0w66I2ODJn>N{ucc9xsZuo!xk9u5y{h(?y=S9kYU(uNni
zXOFyBa{^}C8|(J_^DQSjjk3h51B6JM&YBYS`A#$uB+|?HUhpqvrHMfDI~zv9I|fOd
zYAisN5s0;>jg}fxZ9ntVkzsof^wP(xiO7D=9P-lpz$TRWRF^^{Q=O)?A8>dJY)m>E
zfNr`VweidR2_=A4tBP8r1hZhy5`>eujx>wt{8LlKmFwG?t_esX9UyD`S+FA{l%Mf>
zHi4io$kAi}e2XYr8qjG_J6yDtiqY{sI;-&IG$)R6MT1=ADPsPsKQ@ucfTQnEkQ8Q@
zVaPmOesl6Yh%+n-!{j^!DvP)nLZ66Wb9_sRo-GiFQs9vunlj83>R<2gtuRdM-p
z0oPU!VA%^1_oMreXc}P4Z{(L}*Fa*oDz)^`PX67}!vc@Py^6pb_XkuQx?T*9^7IX?
zkJcxQbq4ez5L3Mv+leFAC)09|Xx1$#(QG4D%QU3JU8vi?(lHH5f$Z@O!Ih%|7`PT?
zl+AhA!OQJ=_V|9Di)v52pE**Tq9!>ZH!TOwy$BN#k}~-^YX^_a*nla0fir9X10=>p
zlNdnMpCMk$TsWR(l(kE>o8#JX4R`x(ng}SoTM~=Jhj+rWvI}FG8CIXG9sz<7Dk=&Y
zcrfYW`bB*&M|>}87f-!-CMTcYftEc7J_o#0&mX5Iq{LB~&*Sa@!YPhZmJdNS^t~Kx
zH#7+dbqA|B>%+WfNL*iSnh_E--ERh!1Cb5pN@fZP!J}#7Zj0d*BAyQk;_?^G!a0mH@j8B=Eqe&?nr$%;$K(yA)=RUKXex;Qh`
zOIX3){I4VhT?mrihqvu{Bra|m4X6xW0bPtV>bvs!Z}#3cD`Bp2!!RT|ZL^k6Ulml$LwdbJ_}msi3p}wNojY1bIA%G4Y4zAD+7?Pg8H2>Y{RZn*zm5
zV2&6><4Ue`b$Q<=u7#Ht&0sZ~do;FXx|hWFt@DMunTEZ$h5DzoNB+nbd{VZnKGcA>
zcIZ+chlSx6*fe0hEk4&o-Xv%EfK*+Qt65DzRM=D|^QHmxfHxNIX4ExPkadNz-woyVmNMUc%T})>+x%`CXL$&pGaHp%Vw(0X!N1q#)JeuCp~=59=)maDG9>_lzPORl%~Yo!hx#
z?$ITN4m2Q*l-3+DSA*NS8!)R7(fR%F;^9ArvoMWnh}kc45xY|&HCBTC=~UyMZW(x{
zn(k}4_o!?8mQHCdrji=gms)on+g8jHt{pd{+vo@~bT%%w(eEUG1gzvtzbU-}YTo`R
zs4}U-t`E;gMHyapEog@QiQ~0+rSttP{I;NOH$G)c+Z$lWrWh?uBsP8qUABel=Kp
z9@Z5<4yNyT*xY&2*whrx_sC%!7H*()WXEJMLqvd_g0JZxFm
zMpUV#>}}bMJ&>@?Th3Xih<5PgfTbas^HjE3dDK
zdva)#^HNN(+%(?5?oYh3jiqC7gMp61Tb?>sQPu7wF~-_XY)tv-v!0HO;#1F{EPItLQa(aoHq_KkC;=sXvXV*ZJg7k?nk3;Q8h0jC%K+hF{2X=1l
zUkj8X0#d|Pc{`bneu7?s;5I7q-dQD@>YLy0{f5sc(~1qv=#!s9>H>CFb9Nf5lwJQ>
z8XaHmU>2}|wBt@@PVv;rP4`{J4utD#-ikfl$AAmWNc_ICBJ^>sP50wdM_<=a;PoG70AzL
zmMazbW}jpqKW*w!LL^Rh=Vkp62w&P%II-O8g?iVWJarm2^V4N6v8MSr{$=k?(#FS_
z=%uahi^vE`1~+|{8V`$HJ$CQsEcaxO_5d~=0^k#&s76H*fX!?{1sEG!IUBgVnC9=-
z-A9FVqUh;tvuUaqs!Z$D!LBGK8@u_SgBkYgp*|DLC)qKpW{kHMr+8nZz~&tTL7uh)
zBW|mKaxSYqjIWnLEyl{H&?y
z_j$AXi{XzHg@?NmpKm>`>sgrre4a^;*!)*SN>8mt-nwn~U($=Xy{?L~Vn5MNir$?Oq>;0y2mDWW?{a*+1R^3Vw#i|>VMvQ
zRW^8@;ZW|#Uh>9+)0e-YPayK+5qzWWxFhAzco(6v?@enU6mpVGQ4YZVx?@3?XO2oo
zgsx8`IkG5*XX0MLTJ8KDBYt}^pw0Ji@`vH=KmcKF*Y!Ti^!>3s{EyWpx10I7y?pgD
zQXj{E+}O|XhhA=cJ_ygU7{x(=)aUyn&j(LK%YB@kp6|5(WMbj(tviKGL^!}x((FH8wS6F398jEW1e#Y~ms$gZ?l@L|@}uiZPsZO!6#`oK2$16;ETfZvtm@Ey)ltAoOba{*b@vLvmtJUTvrV
z!u`-06T7@RRTWIrGmsJlCNIiKM5uGrXwo6TZB(K{03(Q`ApTS3(-bHMRTF&%HfWjr
z*URh@rpfx&D?k?u?hg_Q5QHIlLe-05*IIEF=_S?
z*fAA>Vce|jfURmu2_Rk@Kpwk+o7w;ZGuhY+JtdKP4?$(&UgI)sjeH5@K5Hrp0Z1gV
zgWLXL@oytKUoiZf6tf4?W&q%^hQ0smlG+{UDJ=<6CO~APy2Jio-=F}|40k+2SUrV_
zV6~S(3L?-hG7~!b%7@adsIY+DBJi906eb|(eI|ua^gjD){vxnEYg2BFCw1|Ww>10pui-vXKM_SZPv
zIY*9P#8FR$^x6VT6;+Q`U;y+qI6y{e8=8V&<1v+4Ytvip6QhI6u^BFSRBi+mx1
zfPz8U>5JZbv~T#CWFQ~D}<)^t6sztv0ZuSUL;Q`Chllf(_G~$tjgScUOun0Hz`0R
z);X$o5Is2z`{P&>kh}ZDIoNhrIqVoog~@hyBb{hw4*(%VsKAs^9d=x_IU0H8<>27(
zzc$f&h{3LVJz_Rj!vmlgoh
zj;TjNzU`7rtIDI8+)QR|tE3p~|L-pbzKS;5<>yv$D!ZCY9$4sft?{^;Rhf~YGJ5x
zD;bs35W`s)GA?v9WvRegGfYUKu6!eZ-io|I<|;}mi9wF9KU0~g`FgwUFV)S8#j$7n
z_j@lWe{(=X(kfvLrx|J{`yr8f49W$Js?5x5)qQsAs5fKMg8(DAD3De7Z?at|<9#>Y
z*=uegIf`RtlgL?0(J$%F^jAGEbY~vS+37#jy$VxUPnD~e`O^?4m-6UkuBhGxrfr2a
z)fJ8R4K
zTu0EmNBl|uJ%$O1-CFofzpONDI~DS;*f(-dJT_+v2v9-ZMoLH{82K}FJ_-50DVQ3f
z15*6FkCQFmvpohkb|~}%wQ;CvJ-bCX-)|VGz}l0vuuImE#2X2($@#Mq6}1;2kvmN#
zBKhHE*C|=UJ)+t~6ux%sUZvw#(LT%rWmu#IpxCSOr$smDic=))CZBkTl5aov-$MEp#Chg*vh_Aoi7d?Kc
zEgAjB_F`db+uM!ILk^1v0I7sS=S=10CDmUxN;9d5wmi?Nt4s&s6$x*e(L-@PuN!7q
z@3^{}dC$nwp5FsV7jUhWMP3fw*T3fz41XC7M2BF^i$d%T*7GmqZA#9@vl%k-b?UV0
z{lw7tWWS4}OuV(R;W=l>J$IU)X`x&FNoB^L&;P}&UnKsFI!A_0Uh}Gd?MiZw2z?co
zkb=xbx0c>LM{X6TrsEiOmA~K?*JJtAQ#z2pR6*m>)U}9bH~lRYWptlcoNKgS_(pnR
zRw(|=abBSo0Qsp#2ILsdTjlN!rJs%kRiyg%?7ilY0+;}6gF
z=MCD!a~TemPv1d%=&c{ZglrhTU-*kseQ`pR1Yh_8a5A6Ev5
zct5);CrI->tj!TV+Sj0ZFRrXSAxlB`hzn<(aI|q{#POz1HxaKt_4(7HHu?jdMBwgv
z#C$e>amDQ|!{umKyyo6cV9ICd2g&GVkC)lPjT1`qLVz>?Fv&3q6;1!qc-JC(W!<2=
zD7!m10o-)IZ{^q1;=UJ?MTX>j`snH9e7a`BxhG!|Q1}@jv9Ey04sJOg8^L;`+nmqI
z@+I8Y0`wlwvV)qK_eamwlx}B9N9!|Ot_kmSfq)!VRO;T^RKX1yyCGtE17+rnh~pl~
zDi*-joecqS7RZxo_0{6DH0&r!IblyL#yb$8ls}9~E+6#wULe>bS}Ph__i;712LgVD
z!$Cn`viehDk-35c3(e<~=YfcK0N}Q;4@#SGAj5*;v1q(@DtUM%$1ZhG
zJ}Ql(1K5=b6(P)
Date: Mon, 25 May 2026 11:32:49 +0800
Subject: [PATCH 061/248] feat(cli): add `fetch_codex_models` command for
dynamic Codex model fetching
- Introduced `fetch_codex_models` CLI command to fetch and save Codex model catalogs in JSON format.
- Supports configuration via flags or `config.yaml` for flexible setup.
- Enhanced `fetch_antigravity_models` with `config.yaml` support and improved auth directory resolution logic.
---
cmd/fetch_antigravity_models/main.go | 37 ++-
cmd/fetch_codex_models/main.go | 333 +++++++++++++++++++++++++++
2 files changed, 366 insertions(+), 4 deletions(-)
create mode 100644 cmd/fetch_codex_models/main.go
diff --git a/cmd/fetch_antigravity_models/main.go b/cmd/fetch_antigravity_models/main.go
index 250bcbdfa31..6e34eda19fc 100644
--- a/cmd/fetch_antigravity_models/main.go
+++ b/cmd/fetch_antigravity_models/main.go
@@ -8,7 +8,8 @@
//
// Flags:
//
-// --auths-dir Directory containing auth JSON files (default: "auths")
+// --auths-dir Directory containing auth JSON files (default: config auth-dir)
+// --config Config file path (default: "config.yaml")
// --output Output JSON file path (default: "antigravity_models.json")
// --pretty Pretty-print the output JSON (default: true)
package main
@@ -25,8 +26,10 @@ import (
"strings"
"time"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
@@ -66,23 +69,49 @@ type modelEntry struct {
func main() {
var authsDir string
+ var configPath string
var outputPath string
var pretty bool
- flag.StringVar(&authsDir, "auths-dir", "auths", "Directory containing auth JSON files")
+ flag.StringVar(&authsDir, "auths-dir", "", "Directory containing auth JSON files (overrides config auth-dir)")
+ flag.StringVar(&configPath, "config", "", "Configure File Path")
flag.StringVar(&outputPath, "output", "antigravity_models.json", "Output JSON file path")
flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON")
flag.Parse()
+ authsDirOverridden := false
+ flag.Visit(func(f *flag.Flag) {
+ if f.Name == "auths-dir" {
+ authsDirOverridden = true
+ }
+ })
- // Resolve relative paths against the working directory.
wd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot get working directory: %v\n", err)
os.Exit(1)
}
- if !filepath.IsAbs(authsDir) {
+
+ if strings.TrimSpace(configPath) == "" {
+ configPath = filepath.Join(wd, "config.yaml")
+ }
+ cfg, err := config.LoadConfigOptional(configPath, false)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to load config file %s: %v\n", configPath, err)
+ os.Exit(1)
+ }
+ if cfg == nil {
+ cfg = &config.Config{}
+ }
+
+ if !authsDirOverridden {
+ authsDir = cfg.AuthDir
+ } else if strings.TrimSpace(authsDir) != "" && !strings.HasPrefix(strings.TrimSpace(authsDir), "~") && !filepath.IsAbs(authsDir) {
authsDir = filepath.Join(wd, authsDir)
}
+ if authsDir, err = util.ResolveAuthDir(authsDir); err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to resolve auth directory: %v\n", err)
+ os.Exit(1)
+ }
if !filepath.IsAbs(outputPath) {
outputPath = filepath.Join(wd, outputPath)
}
diff --git a/cmd/fetch_codex_models/main.go b/cmd/fetch_codex_models/main.go
new file mode 100644
index 00000000000..50bb7dcb196
--- /dev/null
+++ b/cmd/fetch_codex_models/main.go
@@ -0,0 +1,333 @@
+// Command fetch_codex_models connects to the Codex API using stored auth
+// credentials and saves the dynamically fetched Codex client model catalog to a
+// JSON file for inspection or offline use.
+//
+// Usage:
+//
+// go run ./cmd/fetch_codex_models [flags]
+//
+// Flags:
+//
+// --auths-dir Directory containing auth JSON files (default: config auth-dir)
+// --config Config file path (default: "config.yaml")
+// --output Output JSON file path (default: "codex_models.json")
+// --client-version Codex client_version query value (default: "0.133.0")
+// --pretty Pretty-print the output JSON (default: true)
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ codexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ codexModelsBaseURL = "https://chatgpt.com/backend-api/codex"
+ codexModelsPath = "/models"
+ defaultClientVersion = "0.133.0"
+ defaultCodexUserAgent = "codex_cli_rs/0.133.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9"
+ defaultCodexOriginator = "codex_cli_rs"
+ accessTokenRefreshLeeway = 30 * time.Second
+)
+
+func init() {
+ logging.SetupBaseLogger()
+ log.SetLevel(log.InfoLevel)
+}
+
+func main() {
+ var authsDir string
+ var configPath string
+ var outputPath string
+ var clientVersion string
+ var pretty bool
+
+ flag.StringVar(&authsDir, "auths-dir", "", "Directory containing auth JSON files (overrides config auth-dir)")
+ flag.StringVar(&configPath, "config", "", "Configure File Path")
+ flag.StringVar(&outputPath, "output", "codex_models.json", "Output JSON file path")
+ flag.StringVar(&clientVersion, "client-version", defaultClientVersion, "Codex client_version query value")
+ flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON")
+ flag.Parse()
+ authsDirOverridden := false
+ flag.Visit(func(f *flag.Flag) {
+ if f.Name == "auths-dir" {
+ authsDirOverridden = true
+ }
+ })
+
+ wd, err := os.Getwd()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: cannot get working directory: %v\n", err)
+ os.Exit(1)
+ }
+
+ if strings.TrimSpace(configPath) == "" {
+ configPath = filepath.Join(wd, "config.yaml")
+ }
+ cfg, err := config.LoadConfigOptional(configPath, false)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to load config file %s: %v\n", configPath, err)
+ os.Exit(1)
+ }
+ if cfg == nil {
+ cfg = &config.Config{}
+ }
+
+ if !authsDirOverridden {
+ authsDir = cfg.AuthDir
+ } else if strings.TrimSpace(authsDir) != "" && !strings.HasPrefix(strings.TrimSpace(authsDir), "~") && !filepath.IsAbs(authsDir) {
+ authsDir = filepath.Join(wd, authsDir)
+ }
+ if authsDir, err = util.ResolveAuthDir(authsDir); err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to resolve auth directory: %v\n", err)
+ os.Exit(1)
+ }
+ if !filepath.IsAbs(outputPath) {
+ outputPath = filepath.Join(wd, outputPath)
+ }
+
+ fmt.Printf("Scanning auth files in: %s\n", authsDir)
+
+ fileStore := sdkauth.NewFileTokenStore()
+ fileStore.SetBaseDir(authsDir)
+
+ ctx := context.Background()
+ auths, err := fileStore.List(ctx)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
+ os.Exit(1)
+ }
+ if len(auths) == 0 {
+ fmt.Fprintf(os.Stderr, "error: no auth files found in %s\n", authsDir)
+ os.Exit(1)
+ }
+
+ chosen := findCodexAuth(auths)
+ if chosen == nil {
+ fmt.Fprintf(os.Stderr, "error: no enabled codex auth found in %s\n", authsDir)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
+
+ accessToken, refreshed, err := ensureAccessToken(ctx, fileStore, chosen)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to prepare codex access token: %v\n", err)
+ os.Exit(1)
+ }
+ if refreshed {
+ fmt.Println("Refreshed Codex access token.")
+ }
+
+ fmt.Println("Fetching Codex model list from upstream...")
+
+ raw, count, err := fetchModels(ctx, chosen, accessToken, clientVersion)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to fetch codex models: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("Fetched %d models.\n", count)
+
+ if pretty {
+ raw, err = prettyJSON(raw)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to format JSON: %v\n", err)
+ os.Exit(1)
+ }
+ }
+
+ if err = os.WriteFile(outputPath, raw, 0o644); err != nil {
+ fmt.Fprintf(os.Stderr, "error: failed to write output file %s: %v\n", outputPath, err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Model list saved to: %s\n", outputPath)
+}
+
+func findCodexAuth(auths []*coreauth.Auth) *coreauth.Auth {
+ for _, auth := range auths {
+ if auth == nil || auth.Disabled {
+ continue
+ }
+ if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
+ continue
+ }
+ if metaStringValue(auth.Metadata, "access_token") == "" && metaStringValue(auth.Metadata, "refresh_token") == "" {
+ continue
+ }
+ return auth
+ }
+ return nil
+}
+
+func ensureAccessToken(ctx context.Context, store *sdkauth.FileTokenStore, auth *coreauth.Auth) (string, bool, error) {
+ accessToken := metaStringValue(auth.Metadata, "access_token")
+ if accessToken != "" {
+ if expiresAt, ok := auth.ExpirationTime(); !ok || time.Now().Add(accessTokenRefreshLeeway).Before(expiresAt) {
+ return accessToken, false, nil
+ }
+ }
+
+ refreshToken := metaStringValue(auth.Metadata, "refresh_token")
+ if refreshToken == "" {
+ if accessToken != "" {
+ return accessToken, false, nil
+ }
+ return "", false, fmt.Errorf("missing access_token and refresh_token")
+ }
+
+ svc := codexauth.NewCodexAuthWithProxyURL(nil, auth.ProxyURL)
+ tokenData, errRefresh := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)
+ if errRefresh != nil {
+ return "", false, errRefresh
+ }
+ if strings.TrimSpace(tokenData.AccessToken) == "" {
+ return "", false, fmt.Errorf("refresh response did not include access_token")
+ }
+
+ if auth.Metadata == nil {
+ auth.Metadata = make(map[string]any)
+ }
+ auth.Metadata["id_token"] = tokenData.IDToken
+ auth.Metadata["access_token"] = tokenData.AccessToken
+ if tokenData.RefreshToken != "" {
+ auth.Metadata["refresh_token"] = tokenData.RefreshToken
+ }
+ if tokenData.AccountID != "" {
+ auth.Metadata["account_id"] = tokenData.AccountID
+ }
+ if tokenData.Email != "" {
+ auth.Metadata["email"] = tokenData.Email
+ }
+ auth.Metadata["expired"] = tokenData.Expire
+ auth.Metadata["type"] = "codex"
+ auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339)
+
+ if _, errSave := store.Save(ctx, auth); errSave != nil {
+ return "", false, fmt.Errorf("failed to save refreshed auth: %w", errSave)
+ }
+
+ return tokenData.AccessToken, true, nil
+}
+
+func fetchModels(ctx context.Context, auth *coreauth.Auth, accessToken, clientVersion string) ([]byte, int, error) {
+ modelsURL, errURL := codexModelsURL(clientVersion)
+ if errURL != nil {
+ return nil, 0, errURL
+ }
+
+ httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
+ if errReq != nil {
+ return nil, 0, errReq
+ }
+ httpReq.Close = true
+ httpReq.Header.Set("Accept", "application/json")
+ httpReq.Header.Set("Authorization", "Bearer "+accessToken)
+ httpReq.Header.Set("Originator", defaultCodexOriginator)
+ httpReq.Header.Set("User-Agent", defaultCodexUserAgent)
+ if accountID := metaStringValue(auth.Metadata, "account_id"); accountID != "" {
+ httpReq.Header.Set("Chatgpt-Account-Id", accountID)
+ }
+ if auth != nil {
+ util.ApplyCustomHeadersFromAttrs(httpReq, auth.Attributes)
+ }
+
+ httpClient := &http.Client{}
+ if auth != nil {
+ if transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {
+ httpClient.Transport = transport
+ }
+ }
+
+ httpResp, errDo := httpClient.Do(httpReq)
+ if errDo != nil {
+ return nil, 0, errDo
+ }
+
+ bodyBytes, errRead := io.ReadAll(httpResp.Body)
+ if errClose := httpResp.Body.Close(); errClose != nil && errRead == nil {
+ errRead = errClose
+ }
+ if errRead != nil {
+ return nil, 0, errRead
+ }
+
+ if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
+ return nil, 0, fmt.Errorf("models request failed with status %d: %s", httpResp.StatusCode, strings.TrimSpace(string(bodyBytes)))
+ }
+
+ count, errCount := countModels(bodyBytes)
+ if errCount != nil {
+ return nil, 0, errCount
+ }
+ return bodyBytes, count, nil
+}
+
+func codexModelsURL(clientVersion string) (string, error) {
+ u, err := url.Parse(codexModelsBaseURL + codexModelsPath)
+ if err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(clientVersion) != "" {
+ q := u.Query()
+ q.Set("client_version", strings.TrimSpace(clientVersion))
+ u.RawQuery = q.Encode()
+ }
+ return u.String(), nil
+}
+
+func countModels(raw []byte) (int, error) {
+ var payload struct {
+ Models []map[string]any `json:"models"`
+ }
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ return 0, fmt.Errorf("failed to parse response JSON: %w", err)
+ }
+ if payload.Models == nil {
+ return 0, fmt.Errorf("response JSON does not contain models array")
+ }
+ return len(payload.Models), nil
+}
+
+func prettyJSON(raw []byte) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := json.Indent(&buf, raw, "", " "); err != nil {
+ return nil, err
+ }
+ buf.WriteByte('\n')
+ return buf.Bytes(), nil
+}
+
+func metaStringValue(m map[string]any, key string) string {
+ if m == nil {
+ return ""
+ }
+ v, ok := m[key]
+ if !ok {
+ return ""
+ }
+ switch val := v.(type) {
+ case string:
+ return strings.TrimSpace(val)
+ default:
+ return ""
+ }
+}
From 412d3442fa858f79f7382944988bd0064baa9456 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Mon, 25 May 2026 20:44:32 +0800
Subject: [PATCH 062/248] feat(logging): add `RequestID` support in home
request logging
- Included `RequestID` field in `homeRequestLogPayload` for better log categorization.
- Updated `forwardRequestLogToHome` and related components to handle `RequestID`.
- Added new test cases to validate `RequestID` propagation in streaming requests.
---
internal/logging/request_logger.go | 11 ++--
internal/logging/request_logger_home_test.go | 57 ++++++++++++++++++++
2 files changed, 65 insertions(+), 3 deletions(-)
diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go
index 44b2c952648..26b2f42b3f7 100644
--- a/internal/logging/request_logger.go
+++ b/internal/logging/request_logger.go
@@ -166,6 +166,7 @@ type FileRequestLogger struct {
type homeRequestLogPayload struct {
Headers map[string][]string `json:"headers,omitempty"`
+ RequestID string `json:"request_id,omitempty"`
RequestLog string `json:"request_log,omitempty"`
}
@@ -192,7 +193,7 @@ func cloneHeaders(headers map[string][]string) map[string][]string {
return out
}
-func (l *FileRequestLogger) forwardRequestLogToHome(ctx context.Context, headers map[string][]string, logText string) error {
+func (l *FileRequestLogger) forwardRequestLogToHome(ctx context.Context, headers map[string][]string, requestID string, logText string) error {
if l == nil || !l.homeEnabled {
return nil
}
@@ -202,6 +203,7 @@ func (l *FileRequestLogger) forwardRequestLogToHome(ctx context.Context, headers
}
payload := homeRequestLogPayload{
Headers: cloneHeaders(headers),
+ RequestID: strings.TrimSpace(requestID),
RequestLog: logText,
}
raw, errMarshal := json.Marshal(&payload)
@@ -334,7 +336,7 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st
if writeErr != nil {
return fmt.Errorf("failed to build request log content: %w", writeErr)
}
- return l.forwardRequestLogToHome(context.Background(), requestHeaders, buf.String())
+ return l.forwardRequestLogToHome(context.Background(), requestHeaders, requestID, buf.String())
}
// Ensure logs directory exists
@@ -1631,11 +1633,12 @@ type homeStreamingLogWriter struct {
apiRequest []byte
apiResponse []byte
apiWebsocketTime []byte
+ requestID string
apiResponseTS time.Time
firstChunkTS time.Time
}
-func newHomeStreamingLogWriter(url, method string, headers map[string][]string, body []byte, _ string) *homeStreamingLogWriter {
+func newHomeStreamingLogWriter(url, method string, headers map[string][]string, body []byte, requestID string) *homeStreamingLogWriter {
requestHeaders := make(map[string][]string, len(headers))
for key, values := range headers {
headerValues := make([]string, len(values))
@@ -1649,6 +1652,7 @@ func newHomeStreamingLogWriter(url, method string, headers map[string][]string,
timestamp: time.Now(),
requestHeaders: requestHeaders,
requestBody: append([]byte(nil), body...),
+ requestID: strings.TrimSpace(requestID),
chunkChan: make(chan []byte, 100),
doneChan: make(chan struct{}),
}
@@ -1766,6 +1770,7 @@ func (w *homeStreamingLogWriter) Close() error {
payload := homeRequestLogPayload{
Headers: cloneHeaders(w.requestHeaders),
+ RequestID: w.requestID,
RequestLog: buf.String(),
}
raw, errMarshal := json.Marshal(&payload)
diff --git a/internal/logging/request_logger_home_test.go b/internal/logging/request_logger_home_test.go
index f8cdf1e453b..4f66cacec70 100644
--- a/internal/logging/request_logger_home_test.go
+++ b/internal/logging/request_logger_home_test.go
@@ -77,6 +77,7 @@ func TestFileRequestLogger_HomeEnabled_ForwardsWhenRequestLogEnabled(t *testing.
var got struct {
Headers map[string][]string `json:"headers"`
+ RequestID string `json:"request_id"`
RequestLog string `json:"request_log"`
}
if errUnmarshal := json.Unmarshal(stub.pushed[0], &got); errUnmarshal != nil {
@@ -88,6 +89,62 @@ func TestFileRequestLogger_HomeEnabled_ForwardsWhenRequestLogEnabled(t *testing.
if got.Headers == nil || got.Headers["Authorization"][0] != "Bearer secret" {
t.Fatalf("headers.authorization = %+v, want Bearer secret", got.Headers["Authorization"])
}
+ if got.RequestID != "req-1" {
+ t.Fatalf("request_id = %q, want req-1", got.RequestID)
+ }
+ if got.RequestLog == "" {
+ t.Fatalf("request_log empty, want non-empty")
+ }
+}
+
+func TestFileRequestLogger_HomeEnabled_ForwardsStreamingRequestID(t *testing.T) {
+ original := currentHomeRequestLogClient
+ defer func() {
+ currentHomeRequestLogClient = original
+ }()
+
+ stub := &stubHomeRequestLogClient{heartbeatOK: true}
+ currentHomeRequestLogClient = func() homeRequestLogClient {
+ return stub
+ }
+
+ logsDir := t.TempDir()
+ logger := NewFileRequestLogger(true, logsDir, "", 0)
+ logger.SetHomeEnabled(true)
+
+ writer, errLog := logger.LogStreamingRequest(
+ "/v1/responses",
+ http.MethodPost,
+ map[string][]string{"Content-Type": {"application/json"}},
+ []byte(`{"input":"hello"}`),
+ "stream-req-1",
+ )
+ if errLog != nil {
+ t.Fatalf("LogStreamingRequest error: %v", errLog)
+ }
+
+ if errStatus := writer.WriteStatus(http.StatusOK, map[string][]string{"Content-Type": {"text/event-stream"}}); errStatus != nil {
+ t.Fatalf("WriteStatus error: %v", errStatus)
+ }
+ writer.WriteChunkAsync([]byte("data: ok\n\n"))
+ if errClose := writer.Close(); errClose != nil {
+ t.Fatalf("Close error: %v", errClose)
+ }
+
+ if len(stub.pushed) != 1 {
+ t.Fatalf("home pushed records = %d, want 1", len(stub.pushed))
+ }
+
+ var got struct {
+ RequestID string `json:"request_id"`
+ RequestLog string `json:"request_log"`
+ }
+ if errUnmarshal := json.Unmarshal(stub.pushed[0], &got); errUnmarshal != nil {
+ t.Fatalf("unmarshal payload: %v payload=%s", errUnmarshal, string(stub.pushed[0]))
+ }
+ if got.RequestID != "stream-req-1" {
+ t.Fatalf("request_id = %q, want stream-req-1", got.RequestID)
+ }
if got.RequestLog == "" {
t.Fatalf("request_log empty, want non-empty")
}
From a0bb1f3a2b85fc0e0904f1c9ff3aa902edf31052 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Mon, 25 May 2026 21:55:16 +0800
Subject: [PATCH 063/248] feat(logging): add file-backed sources for request
logging
- Introduced `FileBodySource` to support large request log sections stored in temp files.
- Added file-backed support for WebSocket timeline and API WebSocket timeline logging.
- Updated `LogRequest` and middleware to integrate optional file-backed sources.
- Implemented clean-up mechanisms to manage temporary log files after processing.
---
internal/api/middleware/request_logging.go | 21 ++
.../api/middleware/request_logging_test.go | 59 ++++
internal/api/middleware/response_writer.go | 105 +++++-
internal/logging/request_logger.go | 308 +++++++++++++++++-
internal/logging/request_logger_home_test.go | 155 +++++++++
.../runtime/executor/helps/logging_helpers.go | 19 ++
.../openai/openai_responses_websocket.go | 208 +++++++++++-
.../openai/openai_responses_websocket_test.go | 57 +++-
8 files changed, 892 insertions(+), 40 deletions(-)
diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go
index 4caa0937d60..561219c4f31 100644
--- a/internal/api/middleware/request_logging.go
+++ b/internal/api/middleware/request_logging.go
@@ -58,6 +58,7 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
wrapper.logOnErrorOnly = true
}
c.Writer = wrapper
+ attachWebsocketLogSources(c, logger, loggerEnabled)
// Process the request
c.Next()
@@ -70,6 +71,26 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
}
}
+type fileBodySourceFactory interface {
+ NewFileBodySource(prefix string) (*logging.FileBodySource, error)
+}
+
+func attachWebsocketLogSources(c *gin.Context, logger logging.RequestLogger, loggerEnabled bool) {
+ if c == nil || !loggerEnabled || !isResponsesWebsocketUpgrade(c.Request) {
+ return
+ }
+ factory, ok := logger.(fileBodySourceFactory)
+ if !ok || factory == nil {
+ return
+ }
+ if source, errSource := factory.NewFileBodySource("websocket-timeline"); errSource == nil {
+ c.Set(logging.WebsocketTimelineSourceContextKey, source)
+ }
+ if source, errSource := factory.NewFileBodySource("api-websocket-timeline"); errSource == nil {
+ c.Set(logging.APIWebsocketTimelineSourceContextKey, source)
+ }
+}
+
func shouldSkipMethodForRequestLogging(req *http.Request) bool {
if req == nil {
return true
diff --git a/internal/api/middleware/request_logging_test.go b/internal/api/middleware/request_logging_test.go
index 7329932533c..c64b844a851 100644
--- a/internal/api/middleware/request_logging_test.go
+++ b/internal/api/middleware/request_logging_test.go
@@ -6,11 +6,13 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "os"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
)
func TestShouldSkipMethodForRequestLogging(t *testing.T) {
@@ -142,6 +144,63 @@ func TestShouldCaptureRequestBody(t *testing.T) {
}
}
+func TestAttachWebsocketLogSourcesUsesLoggerLogsDir(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ logsDir := t.TempDir()
+ logger := logging.NewFileRequestLogger(true, logsDir, "", 0)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
+ c.Request.Header.Set("Upgrade", "websocket")
+
+ attachWebsocketLogSources(c, logger, true)
+ defer cleanupFileBodySourcesFromContext(c)
+
+ for _, key := range []string{
+ logging.WebsocketTimelineSourceContextKey,
+ logging.APIWebsocketTimelineSourceContextKey,
+ } {
+ value, exists := c.Get(key)
+ if !exists {
+ t.Fatalf("expected %s source to be attached", key)
+ }
+ source, ok := value.(*logging.FileBodySource)
+ if !ok || source == nil {
+ t.Fatalf("%s source type = %T", key, value)
+ }
+ file, errPart := source.CreatePart("probe")
+ if errPart != nil {
+ t.Fatalf("CreatePart(%s): %v", key, errPart)
+ }
+ path := file.Name()
+ if errClose := file.Close(); errClose != nil {
+ t.Fatalf("close part: %v", errClose)
+ }
+ if !strings.HasPrefix(path, logsDir+string(os.PathSeparator)) {
+ t.Fatalf("%s part path %s is not under logs dir %s", key, path, logsDir)
+ }
+ }
+}
+
+func cleanupFileBodySourcesFromContext(c *gin.Context) {
+ if c == nil {
+ return
+ }
+ for _, key := range []string{
+ logging.WebsocketTimelineSourceContextKey,
+ logging.APIWebsocketTimelineSourceContextKey,
+ } {
+ value, exists := c.Get(key)
+ if !exists {
+ continue
+ }
+ if source, ok := value.(*logging.FileBodySource); ok && source != nil {
+ _ = source.Cleanup()
+ }
+ }
+}
+
func TestCaptureRequestInfoDecodesZstdRequestBodyForLog(t *testing.T) {
gin.SetMode(gin.TestMode)
diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go
index 5a89ed0fdfd..4d496005472 100644
--- a/internal/api/middleware/response_writer.go
+++ b/internal/api/middleware/response_writer.go
@@ -280,7 +280,10 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
hasAPIError := len(slicesAPIResponseError) > 0 || finalStatusCode >= http.StatusBadRequest
forceLog := w.logOnErrorOnly && hasAPIError && !w.logger.IsEnabled()
+ websocketTimelineSource := w.extractWebsocketTimelineSource(c)
+ apiWebsocketTimelineSource := w.extractAPIWebsocketTimelineSource(c)
if !w.logger.IsEnabled() && !forceLog {
+ cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
return nil
}
@@ -307,6 +310,13 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
_ = w.streamWriter.WriteAPIResponse(apiResponse)
}
apiWebsocketTimeline := w.extractAPIWebsocketTimeline(c)
+ var errMerge error
+ apiWebsocketTimeline, errMerge = mergeFileBodySource(apiWebsocketTimeline, apiWebsocketTimelineSource)
+ if errMerge != nil {
+ cleanupFileBodySources(websocketTimelineSource)
+ return errMerge
+ }
+ cleanupFileBodySources(websocketTimelineSource)
if len(apiWebsocketTimeline) > 0 {
_ = w.streamWriter.WriteAPIWebsocketTimeline(apiWebsocketTimeline)
}
@@ -318,7 +328,7 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
return nil
}
- return w.logRequest(w.extractRequestBody(c), finalStatusCode, w.cloneHeaders(), w.extractResponseBody(c), w.extractWebsocketTimeline(c), w.extractAPIRequest(c), w.extractAPIResponse(c), w.extractAPIWebsocketTimeline(c), w.extractAPIResponseTimestamp(c), slicesAPIResponseError, forceLog)
+ return w.logRequest(w.extractRequestBody(c), finalStatusCode, w.cloneHeaders(), w.extractResponseBody(c), w.extractWebsocketTimeline(c), websocketTimelineSource, w.extractAPIRequest(c), w.extractAPIResponse(c), w.extractAPIWebsocketTimeline(c), apiWebsocketTimelineSource, w.extractAPIResponseTimestamp(c), slicesAPIResponseError, forceLog)
}
func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {
@@ -370,6 +380,10 @@ func (w *ResponseWriterWrapper) extractAPIWebsocketTimeline(c *gin.Context) []by
return bytes.Clone(data)
}
+func (w *ResponseWriterWrapper) extractAPIWebsocketTimelineSource(c *gin.Context) *logging.FileBodySource {
+ return extractFileBodySource(c, logging.APIWebsocketTimelineSourceContextKey)
+}
+
func (w *ResponseWriterWrapper) extractAPIResponseTimestamp(c *gin.Context) time.Time {
ts, isExist := c.Get("API_RESPONSE_TIMESTAMP")
if !isExist {
@@ -405,6 +419,25 @@ func (w *ResponseWriterWrapper) extractWebsocketTimeline(c *gin.Context) []byte
return extractBodyOverride(c, websocketTimelineOverrideContextKey)
}
+func (w *ResponseWriterWrapper) extractWebsocketTimelineSource(c *gin.Context) *logging.FileBodySource {
+ return extractFileBodySource(c, logging.WebsocketTimelineSourceContextKey)
+}
+
+func extractFileBodySource(c *gin.Context, key string) *logging.FileBodySource {
+ if c == nil {
+ return nil
+ }
+ value, exists := c.Get(key)
+ if !exists {
+ return nil
+ }
+ source, ok := value.(*logging.FileBodySource)
+ if !ok || source == nil {
+ return nil
+ }
+ return source
+}
+
func extractBodyOverride(c *gin.Context, key string) []byte {
if c == nil {
return nil
@@ -426,11 +459,48 @@ func extractBodyOverride(c *gin.Context, key string) []byte {
return nil
}
-func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, headers map[string][]string, body, websocketTimeline, apiRequestBody, apiResponseBody, apiWebsocketTimeline []byte, apiResponseTimestamp time.Time, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
+func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, headers map[string][]string, body, websocketTimeline []byte, websocketTimelineSource *logging.FileBodySource, apiRequestBody, apiResponseBody, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *logging.FileBodySource, apiResponseTimestamp time.Time, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
if w.requestInfo == nil {
+ cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
return nil
}
+ if loggerWithSources, ok := w.logger.(interface {
+ LogRequestWithOptionsAndSources(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, *logging.FileBodySource, []byte, []byte, []byte, *logging.FileBodySource, []*interfaces.ErrorMessage, bool, string, time.Time, time.Time) error
+ }); ok {
+ return loggerWithSources.LogRequestWithOptionsAndSources(
+ w.requestInfo.URL,
+ w.requestInfo.Method,
+ w.requestInfo.Headers,
+ requestBody,
+ statusCode,
+ headers,
+ body,
+ websocketTimeline,
+ websocketTimelineSource,
+ apiRequestBody,
+ apiResponseBody,
+ apiWebsocketTimeline,
+ apiWebsocketTimelineSource,
+ apiResponseErrors,
+ forceLog,
+ w.requestInfo.RequestID,
+ w.requestInfo.Timestamp,
+ apiResponseTimestamp,
+ )
+ }
+
+ var errMerge error
+ websocketTimeline, errMerge = mergeFileBodySource(websocketTimeline, websocketTimelineSource)
+ if errMerge != nil {
+ cleanupFileBodySources(apiWebsocketTimelineSource)
+ return errMerge
+ }
+ apiWebsocketTimeline, errMerge = mergeFileBodySource(apiWebsocketTimeline, apiWebsocketTimelineSource)
+ if errMerge != nil {
+ return errMerge
+ }
+
if loggerWithOptions, ok := w.logger.(interface {
LogRequestWithOptions(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []byte, []byte, []*interfaces.ErrorMessage, bool, string, time.Time, time.Time) error
}); ok {
@@ -472,3 +542,34 @@ func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, h
apiResponseTimestamp,
)
}
+
+func mergeFileBodySource(payload []byte, source *logging.FileBodySource) ([]byte, error) {
+ if source == nil {
+ return payload, nil
+ }
+ defer cleanupFileBodySources(source)
+ if !source.HasPayload() {
+ return payload, nil
+ }
+ var buf bytes.Buffer
+ if len(payload) > 0 {
+ buf.Write(payload)
+ if !bytes.HasSuffix(payload, []byte("\n")) {
+ buf.WriteByte('\n')
+ }
+ buf.WriteByte('\n')
+ }
+ if errWrite := source.WriteTo(&buf); errWrite != nil {
+ return nil, errWrite
+ }
+ return buf.Bytes(), nil
+}
+
+func cleanupFileBodySources(sources ...*logging.FileBodySource) {
+ for _, source := range sources {
+ if source == nil {
+ continue
+ }
+ _ = source.Cleanup()
+ }
+}
diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go
index 26b2f42b3f7..8a8b6fbde0f 100644
--- a/internal/logging/request_logger.go
+++ b/internal/logging/request_logger.go
@@ -17,6 +17,7 @@ import (
"regexp"
"sort"
"strings"
+ "sync"
"sync/atomic"
"time"
@@ -32,6 +33,11 @@ import (
var requestLogID atomic.Uint64
+const (
+ WebsocketTimelineSourceContextKey = "WEBSOCKET_TIMELINE_SOURCE"
+ APIWebsocketTimelineSourceContextKey = "API_WEBSOCKET_TIMELINE_SOURCE"
+)
+
type homeRequestLogClient interface {
HeartbeatOK() bool
RPushRequestLog(ctx context.Context, payload []byte) error
@@ -41,6 +47,199 @@ var currentHomeRequestLogClient = func() homeRequestLogClient {
return home.Current()
}
+// FileBodySource stores large log sections as ordered temp-file parts.
+type FileBodySource struct {
+ mu sync.Mutex
+ dir string
+ paths []string
+ cleaned bool
+}
+
+// NewFileBodySourceInDir creates a temp-backed source under baseDir.
+func NewFileBodySourceInDir(baseDir string, prefix string) (*FileBodySource, error) {
+ prefix = sanitizeTempPrefix(prefix)
+ baseDir = strings.TrimSpace(baseDir)
+ if baseDir == "" {
+ return nil, fmt.Errorf("base directory is required")
+ }
+ if errMkdir := os.MkdirAll(baseDir, 0755); errMkdir != nil {
+ return nil, errMkdir
+ }
+ dir, errCreate := os.MkdirTemp(baseDir, "request-log-parts-"+prefix+"-*")
+ if errCreate != nil {
+ return nil, errCreate
+ }
+ return &FileBodySource{dir: dir}, nil
+}
+
+func sanitizeTempPrefix(prefix string) string {
+ prefix = strings.TrimSpace(prefix)
+ if prefix == "" {
+ return "log"
+ }
+ var builder strings.Builder
+ for _, r := range prefix {
+ switch {
+ case r >= 'a' && r <= 'z':
+ builder.WriteRune(r)
+ case r >= 'A' && r <= 'Z':
+ builder.WriteRune(r)
+ case r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ case r == '-' || r == '_':
+ builder.WriteRune(r)
+ default:
+ builder.WriteByte('-')
+ }
+ }
+ out := strings.Trim(builder.String(), "-_")
+ if out == "" {
+ return "log"
+ }
+ return out
+}
+
+// CreatePart creates one ordered detail log part.
+func (s *FileBodySource) CreatePart(prefix string) (*os.File, error) {
+ if s == nil {
+ return nil, fmt.Errorf("file body source is nil")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.cleaned {
+ return nil, fmt.Errorf("file body source has been cleaned")
+ }
+ prefix = sanitizeTempPrefix(prefix)
+ file, errCreate := os.CreateTemp(s.dir, prefix+"-*.tmp")
+ if errCreate != nil {
+ return nil, errCreate
+ }
+ s.paths = append(s.paths, file.Name())
+ return file, nil
+}
+
+// AppendPart appends one complete ordered part to the source.
+func (s *FileBodySource) AppendPart(data []byte) error {
+ data = bytes.TrimSpace(data)
+ if len(data) == 0 {
+ return nil
+ }
+ file, errCreate := s.CreatePart("part")
+ if errCreate != nil {
+ return errCreate
+ }
+ writeErr := writeLogPart(file, data, false)
+ if errClose := file.Close(); errClose != nil {
+ if writeErr == nil {
+ writeErr = errClose
+ }
+ }
+ return writeErr
+}
+
+// HasPayload reports whether any detail parts were recorded.
+func (s *FileBodySource) HasPayload() bool {
+ if s == nil {
+ return false
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return len(s.paths) > 0 && !s.cleaned
+}
+
+// Paths returns a copy of the ordered part paths.
+func (s *FileBodySource) Paths() []string {
+ if s == nil {
+ return nil
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]string, len(s.paths))
+ copy(out, s.paths)
+ return out
+}
+
+// WriteTo merges all ordered parts into w.
+func (s *FileBodySource) WriteTo(w io.Writer) error {
+ if s == nil || w == nil {
+ return nil
+ }
+ paths := s.Paths()
+ for i, path := range paths {
+ if i > 0 {
+ if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
+ return errWrite
+ }
+ }
+ file, errOpen := os.Open(path)
+ if errOpen != nil {
+ return errOpen
+ }
+ _, errCopy := io.Copy(w, file)
+ if errClose := file.Close(); errClose != nil {
+ log.WithError(errClose).Warn("failed to close log part file")
+ if errCopy == nil {
+ errCopy = errClose
+ }
+ }
+ if errCopy != nil {
+ return errCopy
+ }
+ }
+ return nil
+}
+
+// Bytes merges all ordered parts into memory.
+func (s *FileBodySource) Bytes() ([]byte, error) {
+ var buf bytes.Buffer
+ if errWrite := s.WriteTo(&buf); errWrite != nil {
+ return nil, errWrite
+ }
+ return buf.Bytes(), nil
+}
+
+// Cleanup removes all temp detail parts and their directory.
+func (s *FileBodySource) Cleanup() error {
+ if s == nil {
+ return nil
+ }
+ s.mu.Lock()
+ if s.cleaned {
+ s.mu.Unlock()
+ return nil
+ }
+ paths := make([]string, len(s.paths))
+ copy(paths, s.paths)
+ dir := s.dir
+ s.paths = nil
+ s.cleaned = true
+ s.mu.Unlock()
+
+ var firstErr error
+ for _, path := range paths {
+ if errRemove := os.Remove(path); errRemove != nil && !os.IsNotExist(errRemove) && firstErr == nil {
+ firstErr = errRemove
+ }
+ }
+ if dir != "" {
+ if errRemove := os.Remove(dir); errRemove != nil && !os.IsNotExist(errRemove) && firstErr == nil {
+ firstErr = errRemove
+ }
+ }
+ return firstErr
+}
+
+func cleanupFileBodySources(sources ...*FileBodySource) {
+ for _, source := range sources {
+ if source == nil {
+ continue
+ }
+ if errCleanup := source.Cleanup(); errCleanup != nil {
+ log.WithError(errCleanup).Warn("failed to clean up log part files")
+ }
+ }
+}
+
// RequestLogger defines the interface for logging HTTP requests and responses.
// It provides methods for logging both regular and streaming HTTP request/response cycles.
type RequestLogger interface {
@@ -274,6 +473,17 @@ func (l *FileRequestLogger) SetErrorLogsMaxFiles(maxFiles int) {
l.errorLogsMaxFiles = maxFiles
}
+// NewFileBodySource creates a temp-backed source under the request log directory.
+func (l *FileRequestLogger) NewFileBodySource(prefix string) (*FileBodySource, error) {
+ if l == nil {
+ return nil, fmt.Errorf("file request logger is nil")
+ }
+ if errEnsure := l.ensureLogsDir(); errEnsure != nil {
+ return nil, errEnsure
+ }
+ return NewFileBodySourceInDir(l.logsDir, prefix)
+}
+
// LogRequest logs a complete non-streaming request/response cycle to a file.
//
// Parameters:
@@ -299,10 +509,21 @@ func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[st
// LogRequestWithOptions logs a request with optional forced logging behavior.
// The force flag allows writing error logs even when regular request logging is disabled.
func (l *FileRequestLogger) LogRequestWithOptions(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
- return l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
+ return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, apiResponse, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
}
func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
+ return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, apiResponse, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
+}
+
+// LogRequestWithOptionsAndSources logs a request with optional file-backed large sections.
+func (l *FileRequestLogger) LogRequestWithOptionsAndSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
+ return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, websocketTimelineSource, apiRequest, apiResponse, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
+}
+
+func (l *FileRequestLogger) logRequestWithSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
+ defer cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
+
if !l.enabled && !force {
return nil
}
@@ -322,9 +543,11 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st
body,
"",
websocketTimeline,
+ websocketTimelineSource,
apiRequest,
apiResponse,
apiWebsocketTimeline,
+ apiWebsocketTimelineSource,
apiResponseErrors,
statusCode,
responseHeaders,
@@ -382,9 +605,11 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st
body,
requestBodyPath,
websocketTimeline,
+ websocketTimelineSource,
apiRequest,
apiResponse,
apiWebsocketTimeline,
+ apiWebsocketTimelineSource,
apiResponseErrors,
statusCode,
responseHeaders,
@@ -430,7 +655,7 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[
}
if l.homeEnabled {
- client := home.Current()
+ client := currentHomeRequestLogClient()
if client == nil || !client.HeartbeatOK() {
return &NoOpStreamingLogWriter{}, nil
}
@@ -650,9 +875,11 @@ func (l *FileRequestLogger) writeNonStreamingLog(
requestBody []byte,
requestBodyPath string,
websocketTimeline []byte,
+ websocketTimelineSource *FileBodySource,
apiRequest []byte,
apiResponse []byte,
apiWebsocketTimeline []byte,
+ apiWebsocketTimelineSource *FileBodySource,
apiResponseErrors []*interfaces.ErrorMessage,
statusCode int,
responseHeaders map[string][]string,
@@ -664,16 +891,16 @@ func (l *FileRequestLogger) writeNonStreamingLog(
if requestTimestamp.IsZero() {
requestTimestamp = time.Now()
}
- isWebsocketTranscript := hasSectionPayload(websocketTimeline)
- downstreamTransport := inferDownstreamTransport(requestHeaders, websocketTimeline)
- upstreamTransport := inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline, apiResponseErrors)
+ isWebsocketTranscript := hasSectionPayload(websocketTimeline) || hasFileBodySourcePayload(websocketTimelineSource)
+ downstreamTransport := inferDownstreamTransport(requestHeaders, websocketTimeline, websocketTimelineSource)
+ upstreamTransport := inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors)
if errWrite := writeRequestInfoWithBody(w, url, method, requestHeaders, requestBody, requestBodyPath, requestTimestamp, downstreamTransport, upstreamTransport, !isWebsocketTranscript); errWrite != nil {
return errWrite
}
- if errWrite := writeAPISection(w, "=== WEBSOCKET TIMELINE ===\n", "=== WEBSOCKET TIMELINE", websocketTimeline, time.Time{}); errWrite != nil {
+ if errWrite := writeAPISectionWithSource(w, "=== WEBSOCKET TIMELINE ===\n", "=== WEBSOCKET TIMELINE", websocketTimeline, websocketTimelineSource, time.Time{}); errWrite != nil {
return errWrite
}
- if errWrite := writeAPISection(w, "=== API WEBSOCKET TIMELINE ===\n", "=== API WEBSOCKET TIMELINE", apiWebsocketTimeline, time.Time{}); errWrite != nil {
+ if errWrite := writeAPISectionWithSource(w, "=== API WEBSOCKET TIMELINE ===\n", "=== API WEBSOCKET TIMELINE", apiWebsocketTimeline, apiWebsocketTimelineSource, time.Time{}); errWrite != nil {
return errWrite
}
if errWrite := writeAPISection(w, "=== API REQUEST ===\n", "=== API REQUEST", apiRequest, time.Time{}); errWrite != nil {
@@ -829,8 +1056,12 @@ func hasSectionPayload(payload []byte) bool {
return len(bytes.TrimSpace(payload)) > 0
}
-func inferDownstreamTransport(headers map[string][]string, websocketTimeline []byte) string {
- if hasSectionPayload(websocketTimeline) {
+func hasFileBodySourcePayload(source *FileBodySource) bool {
+ return source != nil && source.HasPayload()
+}
+
+func inferDownstreamTransport(headers map[string][]string, websocketTimeline []byte, websocketTimelineSource *FileBodySource) string {
+ if hasSectionPayload(websocketTimeline) || hasFileBodySourcePayload(websocketTimelineSource) {
return "websocket"
}
for key, values := range headers {
@@ -845,9 +1076,9 @@ func inferDownstreamTransport(headers map[string][]string, websocketTimeline []b
return "http"
}
-func inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline []byte, _ []*interfaces.ErrorMessage) string {
+func inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, _ []*interfaces.ErrorMessage) string {
hasHTTP := hasSectionPayload(apiRequest) || hasSectionPayload(apiResponse)
- hasWS := hasSectionPayload(apiWebsocketTimeline)
+ hasWS := hasSectionPayload(apiWebsocketTimeline) || hasFileBodySourcePayload(apiWebsocketTimelineSource)
switch {
case hasHTTP && hasWS:
return "websocket+http"
@@ -860,6 +1091,26 @@ func inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline []byte
}
}
+func writeLogPart(w io.Writer, payload []byte, prependNewline bool) error {
+ if w == nil {
+ return nil
+ }
+ if prependNewline {
+ if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
+ return errWrite
+ }
+ }
+ if _, errWrite := w.Write(payload); errWrite != nil {
+ return errWrite
+ }
+ if !bytes.HasSuffix(payload, []byte("\n")) {
+ if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
+ return errWrite
+ }
+ }
+ return nil
+}
+
func writeAPISection(w io.Writer, sectionHeader string, sectionPrefix string, payload []byte, timestamp time.Time) error {
if len(payload) == 0 {
return nil
@@ -889,6 +1140,33 @@ func writeAPISection(w io.Writer, sectionHeader string, sectionPrefix string, pa
return nil
}
+func writeAPISectionWithSource(w io.Writer, sectionHeader string, sectionPrefix string, payload []byte, source *FileBodySource, timestamp time.Time) error {
+ if !hasFileBodySourcePayload(source) {
+ return writeAPISection(w, sectionHeader, sectionPrefix, payload, timestamp)
+ }
+ if len(payload) > 0 {
+ if errWrite := writeAPISection(w, sectionHeader, sectionPrefix, payload, timestamp); errWrite != nil {
+ return errWrite
+ }
+ }
+ if _, errWrite := io.WriteString(w, sectionHeader); errWrite != nil {
+ return errWrite
+ }
+ if !timestamp.IsZero() {
+ if _, errWrite := io.WriteString(w, fmt.Sprintf("Timestamp: %s\n", timestamp.Format(time.RFC3339Nano))); errWrite != nil {
+ return errWrite
+ }
+ }
+ tracker := &trailingNewlineTrackingWriter{writer: w}
+ if errWrite := source.WriteTo(tracker); errWrite != nil {
+ return errWrite
+ }
+ if errWrite := writeSectionSpacing(w, tracker.trailingNewlines); errWrite != nil {
+ return errWrite
+ }
+ return nil
+}
+
func writeAPIErrorResponses(w io.Writer, apiResponseErrors []*interfaces.ErrorMessage) error {
for i := 0; i < len(apiResponseErrors); i++ {
if apiResponseErrors[i] == nil {
@@ -998,8 +1276,8 @@ func responseBodyStartsWithLeadingNewline(reader *bufio.Reader) bool {
func (l *FileRequestLogger) formatLogContent(url, method string, headers map[string][]string, body, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline, response []byte, status int, responseHeaders map[string][]string, apiResponseErrors []*interfaces.ErrorMessage) string {
var content strings.Builder
isWebsocketTranscript := hasSectionPayload(websocketTimeline)
- downstreamTransport := inferDownstreamTransport(headers, websocketTimeline)
- upstreamTransport := inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline, apiResponseErrors)
+ downstreamTransport := inferDownstreamTransport(headers, websocketTimeline, nil)
+ upstreamTransport := inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline, nil, apiResponseErrors)
// Request info
content.WriteString(l.formatRequestInfo(url, method, headers, body, downstreamTransport, upstreamTransport, !isWebsocketTranscript))
@@ -1510,7 +1788,7 @@ func (w *FileStreamingLogWriter) asyncWriter() {
}
func (w *FileStreamingLogWriter) writeFinalLog(logFile *os.File) error {
- if errWrite := writeRequestInfoWithBody(logFile, w.url, w.method, w.requestHeaders, nil, w.requestBodyPath, w.timestamp, "http", inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTimeline, nil), true); errWrite != nil {
+ if errWrite := writeRequestInfoWithBody(logFile, w.url, w.method, w.requestHeaders, nil, w.requestBodyPath, w.timestamp, "http", inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTimeline, nil, nil), true); errWrite != nil {
return errWrite
}
if errWrite := writeAPISection(logFile, "=== API WEBSOCKET TIMELINE ===\n", "=== API WEBSOCKET TIMELINE", w.apiWebsocketTimeline, time.Time{}); errWrite != nil {
@@ -1751,7 +2029,7 @@ func (w *homeStreamingLogWriter) Close() error {
responsePayload := w.responseBody.Bytes()
var buf bytes.Buffer
- upstreamTransport := inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTime, nil)
+ upstreamTransport := inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTime, nil, nil)
if errWrite := writeRequestInfoWithBody(&buf, w.url, w.method, w.requestHeaders, w.requestBody, "", w.timestamp, "http", upstreamTransport, true); errWrite != nil {
return errWrite
}
diff --git a/internal/logging/request_logger_home_test.go b/internal/logging/request_logger_home_test.go
index 4f66cacec70..2d974f31d8a 100644
--- a/internal/logging/request_logger_home_test.go
+++ b/internal/logging/request_logger_home_test.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"os"
+ "strings"
"testing"
"time"
)
@@ -97,6 +98,160 @@ func TestFileRequestLogger_HomeEnabled_ForwardsWhenRequestLogEnabled(t *testing.
}
}
+func TestFileRequestLogger_LogRequestWithSourcesWritesLocalLogAndCleansParts(t *testing.T) {
+ logsDir := t.TempDir()
+ logger := NewFileRequestLogger(true, logsDir, "", 0)
+
+ timelineSource, errSource := logger.NewFileBodySource("websocket-timeline-test")
+ if errSource != nil {
+ t.Fatalf("logger.NewFileBodySource: %v", errSource)
+ }
+ if errAppend := timelineSource.AppendPart([]byte("Timestamp: 2026-05-25T12:00:00Z\nEvent: websocket.request\n{}")); errAppend != nil {
+ t.Fatalf("AppendPart request: %v", errAppend)
+ }
+ if errAppend := timelineSource.AppendPart([]byte("Timestamp: 2026-05-25T12:00:01Z\nEvent: websocket.response\n{}")); errAppend != nil {
+ t.Fatalf("AppendPart response: %v", errAppend)
+ }
+ partPaths := timelineSource.Paths()
+ for _, path := range partPaths {
+ if !strings.HasPrefix(path, logsDir+string(os.PathSeparator)) {
+ t.Fatalf("part path %s is not under logs dir %s", path, logsDir)
+ }
+ }
+
+ errLog := logger.LogRequestWithOptionsAndSources(
+ "/v1/responses/ws",
+ http.MethodGet,
+ map[string][]string{"Upgrade": {"websocket"}},
+ nil,
+ http.StatusSwitchingProtocols,
+ map[string][]string{"Upgrade": {"websocket"}},
+ nil,
+ nil,
+ timelineSource,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ false,
+ "ws-req-1",
+ time.Now(),
+ time.Now(),
+ )
+ if errLog != nil {
+ t.Fatalf("LogRequestWithOptionsAndSources error: %v", errLog)
+ }
+
+ for _, path := range partPaths {
+ if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
+ t.Fatalf("expected part %s to be removed, stat err=%v", path, errStat)
+ }
+ }
+
+ entries, errRead := os.ReadDir(logsDir)
+ if errRead != nil {
+ t.Fatalf("failed to read logs dir: %v", errRead)
+ }
+ var logPath string
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ logPath = logsDir + string(os.PathSeparator) + entry.Name()
+ break
+ }
+ if logPath == "" {
+ t.Fatal("expected local request log file")
+ }
+ raw, errReadLog := os.ReadFile(logPath)
+ if errReadLog != nil {
+ t.Fatalf("read log file: %v", errReadLog)
+ }
+ if !bytes.Contains(raw, []byte("=== WEBSOCKET TIMELINE ===")) {
+ t.Fatalf("websocket timeline section missing: %s", string(raw))
+ }
+ if !bytes.Contains(raw, []byte("Event: websocket.request")) || !bytes.Contains(raw, []byte("Event: websocket.response")) {
+ t.Fatalf("merged websocket events missing: %s", string(raw))
+ }
+}
+
+func TestFileRequestLogger_HomeEnabled_ForwardsSourceLogAndCleansParts(t *testing.T) {
+ original := currentHomeRequestLogClient
+ defer func() {
+ currentHomeRequestLogClient = original
+ }()
+
+ stub := &stubHomeRequestLogClient{heartbeatOK: true}
+ currentHomeRequestLogClient = func() homeRequestLogClient {
+ return stub
+ }
+
+ logsDir := t.TempDir()
+ logger := NewFileRequestLogger(true, logsDir, "", 0)
+ logger.SetHomeEnabled(true)
+
+ timelineSource, errSource := logger.NewFileBodySource("home-websocket-timeline-test")
+ if errSource != nil {
+ t.Fatalf("logger.NewFileBodySource: %v", errSource)
+ }
+ if errAppend := timelineSource.AppendPart([]byte("Timestamp: 2026-05-25T12:00:00Z\nEvent: websocket.request\n{}")); errAppend != nil {
+ t.Fatalf("AppendPart request: %v", errAppend)
+ }
+ partPaths := timelineSource.Paths()
+ for _, path := range partPaths {
+ if !strings.HasPrefix(path, logsDir+string(os.PathSeparator)) {
+ t.Fatalf("part path %s is not under logs dir %s", path, logsDir)
+ }
+ }
+
+ errLog := logger.LogRequestWithOptionsAndSources(
+ "/v1/responses/ws",
+ http.MethodGet,
+ map[string][]string{"Upgrade": {"websocket"}},
+ nil,
+ http.StatusSwitchingProtocols,
+ map[string][]string{"Upgrade": {"websocket"}},
+ nil,
+ nil,
+ timelineSource,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ false,
+ "home-ws-req-1",
+ time.Now(),
+ time.Now(),
+ )
+ if errLog != nil {
+ t.Fatalf("LogRequestWithOptionsAndSources error: %v", errLog)
+ }
+ if len(stub.pushed) != 1 {
+ t.Fatalf("home pushed records = %d, want 1", len(stub.pushed))
+ }
+
+ var got struct {
+ RequestID string `json:"request_id"`
+ RequestLog string `json:"request_log"`
+ }
+ if errUnmarshal := json.Unmarshal(stub.pushed[0], &got); errUnmarshal != nil {
+ t.Fatalf("unmarshal payload: %v payload=%s", errUnmarshal, string(stub.pushed[0]))
+ }
+ if got.RequestID != "home-ws-req-1" {
+ t.Fatalf("request_id = %q, want home-ws-req-1", got.RequestID)
+ }
+ if !strings.Contains(got.RequestLog, "Event: websocket.request") {
+ t.Fatalf("forwarded request_log missing websocket request: %s", got.RequestLog)
+ }
+ for _, path := range partPaths {
+ if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
+ t.Fatalf("expected part %s to be removed, stat err=%v", path, errStat)
+ }
+ }
+}
+
func TestFileRequestLogger_HomeEnabled_ForwardsStreamingRequestID(t *testing.T) {
original := currentHomeRequestLogClient
defer func() {
diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go
index 87fc7ac342e..c32230585bc 100644
--- a/internal/runtime/executor/helps/logging_helpers.go
+++ b/internal/runtime/executor/helps/logging_helpers.go
@@ -416,6 +416,13 @@ func appendAPIWebsocketTimeline(ginCtx *gin.Context, chunk []byte) {
if len(data) == 0 {
return
}
+ if source, ok := apiWebsocketTimelineSource(ginCtx); ok {
+ if errAppend := source.AppendPart(data); errAppend == nil {
+ return
+ } else {
+ log.WithError(errAppend).Warn("failed to append api websocket timeline log part")
+ }
+ }
if existing, exists := ginCtx.Get(apiWebsocketTimelineKey); exists {
if existingBytes, ok := existing.([]byte); ok && len(existingBytes) > 0 {
combined := make([]byte, 0, len(existingBytes)+len(data)+2)
@@ -432,6 +439,18 @@ func appendAPIWebsocketTimeline(ginCtx *gin.Context, chunk []byte) {
ginCtx.Set(apiWebsocketTimelineKey, bytes.Clone(data))
}
+func apiWebsocketTimelineSource(ginCtx *gin.Context) (*logging.FileBodySource, bool) {
+ if ginCtx == nil {
+ return nil, false
+ }
+ value, exists := ginCtx.Get(logging.APIWebsocketTimelineSourceContextKey)
+ if !exists {
+ return nil, false
+ }
+ source, ok := value.(*logging.FileBodySource)
+ return source, ok && source != nil
+}
+
func markAPIResponseTimestamp(ginCtx *gin.Context) {
if ginCtx == nil {
return
diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go
index 574338fd757..eae042b9ec5 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"net/http"
"strconv"
"strings"
@@ -14,6 +15,7 @@ import (
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ requestlogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
@@ -43,6 +45,166 @@ var responsesWebsocketUpgrader = websocket.Upgrader{
},
}
+type websocketTimelineAppender interface {
+ Append(eventType string, payload []byte, timestamp time.Time)
+}
+
+type websocketTimelineLog struct {
+ enabled bool
+ source *requestlogging.FileBodySource
+ builder *strings.Builder
+
+ currentPart io.WriteCloser
+ currentPartHasLog bool
+}
+
+func newWebsocketTimelineLog(enabled bool, source *requestlogging.FileBodySource) *websocketTimelineLog {
+ if !enabled {
+ return &websocketTimelineLog{}
+ }
+ if source == nil {
+ return newInMemoryWebsocketTimelineLog()
+ }
+ return &websocketTimelineLog{
+ enabled: true,
+ source: source,
+ }
+}
+
+func newInMemoryWebsocketTimelineLog() *websocketTimelineLog {
+ return &websocketTimelineLog{
+ enabled: true,
+ builder: &strings.Builder{},
+ }
+}
+
+func websocketTimelineSourceFromContext(c *gin.Context) *requestlogging.FileBodySource {
+ if c == nil {
+ return nil
+ }
+ value, exists := c.Get(requestlogging.WebsocketTimelineSourceContextKey)
+ if !exists {
+ return nil
+ }
+ source, ok := value.(*requestlogging.FileBodySource)
+ if !ok {
+ return nil
+ }
+ return source
+}
+
+func (l *websocketTimelineLog) BeginRequest() {
+ if l == nil || !l.enabled || l.source == nil {
+ return
+ }
+ l.closeCurrentPart()
+ part, errCreate := l.source.CreatePart("request")
+ if errCreate != nil {
+ log.WithError(errCreate).Warn("failed to create websocket request detail log")
+ return
+ }
+ l.currentPart = part
+ l.currentPartHasLog = false
+}
+
+func (l *websocketTimelineLog) Append(eventType string, payload []byte, timestamp time.Time) {
+ if l == nil || !l.enabled {
+ return
+ }
+ data := formatWebsocketTimelineEvent(eventType, payload, timestamp)
+ if len(data) == 0 {
+ return
+ }
+ if l.source != nil {
+ if l.currentPart == nil {
+ l.BeginRequest()
+ }
+ if l.currentPart == nil {
+ return
+ }
+ if errWrite := writeWebsocketTimelinePart(l.currentPart, data, l.currentPartHasLog); errWrite != nil {
+ log.WithError(errWrite).Warn("failed to write websocket request detail log")
+ return
+ }
+ l.currentPartHasLog = true
+ return
+ }
+ if l.builder != nil {
+ writeWebsocketTimelineBuilder(l.builder, data)
+ }
+}
+
+func (l *websocketTimelineLog) SetContext(c *gin.Context) {
+ if l == nil || !l.enabled {
+ return
+ }
+ l.closeCurrentPart()
+ if l.source != nil {
+ if l.source.HasPayload() {
+ c.Set(requestlogging.WebsocketTimelineSourceContextKey, l.source)
+ return
+ }
+ if errCleanup := l.source.Cleanup(); errCleanup != nil {
+ log.WithError(errCleanup).Warn("failed to clean up empty websocket timeline log parts")
+ }
+ }
+ if l.builder != nil {
+ setWebsocketTimelineBody(c, l.builder.String())
+ }
+}
+
+func (l *websocketTimelineLog) String() string {
+ if l == nil || !l.enabled {
+ return ""
+ }
+ l.closeCurrentPart()
+ if l.source != nil {
+ data, errRead := l.source.Bytes()
+ if errRead != nil {
+ return ""
+ }
+ return string(data)
+ }
+ if l.builder == nil {
+ return ""
+ }
+ return l.builder.String()
+}
+
+func (l *websocketTimelineLog) closeCurrentPart() {
+ if l == nil || l.currentPart == nil {
+ return
+ }
+ if errClose := l.currentPart.Close(); errClose != nil {
+ log.WithError(errClose).Warn("failed to close websocket request detail log")
+ }
+ l.currentPart = nil
+ l.currentPartHasLog = false
+}
+
+func writeWebsocketTimelinePart(w io.Writer, data []byte, prependNewline bool) error {
+ if w == nil || len(data) == 0 {
+ return nil
+ }
+ if prependNewline {
+ if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
+ return errWrite
+ }
+ }
+ _, errWrite := w.Write(data)
+ return errWrite
+}
+
+func writeWebsocketTimelineBuilder(builder *strings.Builder, data []byte) {
+ if builder == nil || len(data) == 0 {
+ return
+ }
+ if builder.Len() > 0 {
+ builder.WriteString("\n")
+ }
+ builder.Write(data)
+}
+
// ResponsesWebsocket handles websocket requests for /v1/responses.
// It accepts `response.create` and `response.append` requests and streams
// response events back as JSON websocket text messages.
@@ -57,6 +219,9 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
clientIP := websocketClientAddress(c)
log.Infof("responses websocket: client connected id=%s remote=%s", passthroughSessionID, clientIP)
+ requestLogEnabled := h != nil && h.Cfg != nil && h.Cfg.RequestLog
+ wsTimelineLog := newWebsocketTimelineLog(requestLogEnabled, websocketTimelineSourceFromContext(c))
+
wsDone := make(chan struct{})
defer close(wsDone)
@@ -82,11 +247,10 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
var wsTerminateErr error
- var wsTimelineLog strings.Builder
defer func() {
releaseResponsesWebsocketToolCaches(downstreamSessionKey)
if wsTerminateErr != nil {
- appendWebsocketTimelineDisconnect(&wsTimelineLog, wsTerminateErr, time.Now())
+ appendWebsocketTimelineDisconnect(wsTimelineLog, wsTerminateErr, time.Now())
// log.Infof("responses websocket: session closing id=%s reason=%v", passthroughSessionID, wsTerminateErr)
} else {
log.Infof("responses websocket: session closing id=%s", passthroughSessionID)
@@ -95,7 +259,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
h.AuthManager.CloseExecutionSession(passthroughSessionID)
log.Infof("responses websocket: upstream execution session closed id=%s", passthroughSessionID)
}
- setWebsocketTimelineBody(c, wsTimelineLog.String())
+ wsTimelineLog.SetContext(c)
if errClose := conn.Close(); errClose != nil {
log.Warnf("responses websocket: close connection error: %v", errClose)
}
@@ -136,7 +300,8 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
// websocketPayloadEventType(payload),
// websocketPayloadPreview(payload),
// )
- appendWebsocketTimelineEvent(&wsTimelineLog, "request", payload, time.Now())
+ wsTimelineLog.BeginRequest()
+ wsTimelineLog.Append("request", payload, time.Now())
allowIncrementalInputWithPreviousResponseID := false
if pinnedAuthID != "" {
@@ -180,7 +345,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
if errMsg != nil {
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg)
markAPIResponseTimestamp(c)
- errorPayload, errWrite := writeResponsesWebsocketError(conn, &wsTimelineLog, errMsg)
+ errorPayload, errWrite := writeResponsesWebsocketError(conn, wsTimelineLog, errMsg)
log.Infof(
"responses websocket: downstream_out id=%s type=%d event=%s payload=%s",
passthroughSessionID,
@@ -208,7 +373,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
lastRequest = updatedLastRequest
lastResponseOutput = []byte("[]")
- if errWrite := writeResponsesWebsocketSyntheticPrewarm(c, conn, requestJSON, &wsTimelineLog, passthroughSessionID); errWrite != nil {
+ if errWrite := writeResponsesWebsocketSyntheticPrewarm(c, conn, requestJSON, wsTimelineLog, passthroughSessionID); errWrite != nil {
wsTerminateErr = errWrite
return
}
@@ -248,7 +413,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
dataChan, _, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, requestJSON, "")
- completedOutput, forwardErrMsg, errForward := h.forwardResponsesWebsocket(c, conn, cliCancel, dataChan, errChan, &wsTimelineLog, passthroughSessionID)
+ completedOutput, forwardErrMsg, errForward := h.forwardResponsesWebsocket(c, conn, cliCancel, dataChan, errChan, wsTimelineLog, passthroughSessionID)
if errForward != nil {
wsTerminateErr = errForward
log.Warnf("responses websocket: forward failed id=%s error=%v", passthroughSessionID, errForward)
@@ -708,7 +873,7 @@ func writeResponsesWebsocketSyntheticPrewarm(
c *gin.Context,
conn *websocket.Conn,
requestJSON []byte,
- wsTimelineLog *strings.Builder,
+ wsTimelineLog websocketTimelineAppender,
sessionID string,
) error {
payloads, errPayloads := syntheticResponsesWebsocketPrewarmPayloads(requestJSON)
@@ -859,7 +1024,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
cancel handlers.APIHandlerCancelFunc,
data <-chan []byte,
errs <-chan *interfaces.ErrorMessage,
- wsTimelineLog *strings.Builder,
+ wsTimelineLog websocketTimelineAppender,
sessionID string,
) ([]byte, *interfaces.ErrorMessage, error) {
completed := false
@@ -1031,7 +1196,7 @@ func websocketJSONPayloadsFromChunk(chunk []byte) [][]byte {
return payloads
}
-func writeResponsesWebsocketError(conn *websocket.Conn, wsTimelineLog *strings.Builder, errMsg *interfaces.ErrorMessage) ([]byte, error) {
+func writeResponsesWebsocketError(conn *websocket.Conn, wsTimelineLog websocketTimelineAppender, errMsg *interfaces.ErrorMessage) ([]byte, error) {
status := http.StatusInternalServerError
errText := http.StatusText(status)
if errMsg != nil {
@@ -1155,29 +1320,35 @@ func setWebsocketBody(c *gin.Context, key string, body string) {
c.Set(key, []byte(trimmedBody))
}
-func writeResponsesWebsocketPayload(conn *websocket.Conn, wsTimelineLog *strings.Builder, payload []byte, timestamp time.Time) error {
- appendWebsocketTimelineEvent(wsTimelineLog, "response", payload, timestamp)
+func writeResponsesWebsocketPayload(conn *websocket.Conn, wsTimelineLog websocketTimelineAppender, payload []byte, timestamp time.Time) error {
+ if wsTimelineLog != nil {
+ wsTimelineLog.Append("response", payload, timestamp)
+ }
return conn.WriteMessage(websocket.TextMessage, payload)
}
-func appendWebsocketTimelineDisconnect(builder *strings.Builder, err error, timestamp time.Time) {
+func appendWebsocketTimelineDisconnect(timeline websocketTimelineAppender, err error, timestamp time.Time) {
if err == nil {
return
}
- appendWebsocketTimelineEvent(builder, "disconnect", []byte(err.Error()), timestamp)
+ if timeline != nil {
+ timeline.Append("disconnect", []byte(err.Error()), timestamp)
+ }
}
func appendWebsocketTimelineEvent(builder *strings.Builder, eventType string, payload []byte, timestamp time.Time) {
if builder == nil {
return
}
+ writeWebsocketTimelineBuilder(builder, formatWebsocketTimelineEvent(eventType, payload, timestamp))
+}
+
+func formatWebsocketTimelineEvent(eventType string, payload []byte, timestamp time.Time) []byte {
trimmedPayload := bytes.TrimSpace(payload)
if len(trimmedPayload) == 0 {
- return
- }
- if builder.Len() > 0 {
- builder.WriteString("\n")
+ return nil
}
+ var builder strings.Builder
builder.WriteString("Timestamp: ")
builder.WriteString(timestamp.Format(time.RFC3339Nano))
builder.WriteString("\n")
@@ -1186,6 +1357,7 @@ func appendWebsocketTimelineEvent(builder *strings.Builder, eventType string, pa
builder.WriteString("\n")
builder.Write(trimmedPayload)
builder.WriteString("\n")
+ return []byte(builder.String())
}
func markAPIResponseTimestamp(c *gin.Context) {
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go
index 7ff58fa3c80..8b945b50cd1 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go
@@ -15,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ requestlogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
@@ -593,6 +594,34 @@ func TestSetWebsocketTimelineBody(t *testing.T) {
}
}
+func TestWebsocketTimelineLogFallsBackToMemoryWithoutSource(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ ts := time.Date(2026, time.April, 1, 12, 34, 56, 789000000, time.UTC)
+
+ timelineLog := newWebsocketTimelineLog(true, nil)
+ timelineLog.BeginRequest()
+ timelineLog.Append("request", []byte(`{"type":"response.create"}`), ts)
+ timelineLog.SetContext(c)
+
+ value, exists := c.Get(wsTimelineBodyKey)
+ if !exists {
+ t.Fatalf("timeline body key not set")
+ }
+ bodyBytes, ok := value.([]byte)
+ if !ok {
+ t.Fatalf("timeline body key type mismatch")
+ }
+ got := string(bodyBytes)
+ if !strings.Contains(got, "Event: websocket.request") {
+ t.Fatalf("timeline event not found: %s", got)
+ }
+ if !strings.Contains(got, `{"type":"response.create"}`) {
+ t.Fatalf("timeline payload not found: %s", got)
+ }
+}
+
func TestRepairResponsesWebsocketToolCallsInsertsCachedOutput(t *testing.T) {
cache := newWebsocketToolOutputCache(time.Minute, 10)
sessionKey := "session-1"
@@ -867,14 +896,14 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
close(data)
close(errCh)
- var timelineLog strings.Builder
+ timelineLog := newInMemoryWebsocketTimelineLog()
completedOutput, errMsg, err := (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket(
ctx,
conn,
func(...interface{}) {},
data,
errCh,
- &timelineLog,
+ timelineLog,
"session-1",
)
if err != nil {
@@ -945,7 +974,7 @@ func TestForwardResponsesWebsocketLogsAttemptedResponseOnWriteFailure(t *testing
close(data)
close(errCh)
- var timelineLog strings.Builder
+ timelineLog := newInMemoryWebsocketTimelineLog()
if errClose := conn.Close(); errClose != nil {
serverErrCh <- errClose
return
@@ -957,7 +986,7 @@ func TestForwardResponsesWebsocketLogsAttemptedResponseOnWriteFailure(t *testing
func(...interface{}) {},
data,
errCh,
- &timelineLog,
+ timelineLog,
"session-1",
)
if err == nil {
@@ -994,18 +1023,36 @@ func TestResponsesWebsocketTimelineRecordsDisconnectEvent(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := coreauth.NewManager(nil, nil, nil)
- base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, manager)
h := NewOpenAIResponsesAPIHandler(base)
+ logsDir := t.TempDir()
timelineCh := make(chan string, 1)
router := gin.New()
router.GET("/v1/responses/ws", func(c *gin.Context) {
+ source, errSource := requestlogging.NewFileBodySourceInDir(logsDir, "websocket-timeline-test")
+ if errSource != nil {
+ timelineCh <- ""
+ return
+ }
+ c.Set(requestlogging.WebsocketTimelineSourceContextKey, source)
h.ResponsesWebsocket(c)
timeline := ""
if value, exists := c.Get(wsTimelineBodyKey); exists {
if body, ok := value.([]byte); ok {
timeline = string(body)
}
+ } else if value, exists := c.Get(requestlogging.WebsocketTimelineSourceContextKey); exists {
+ if source, ok := value.(*requestlogging.FileBodySource); ok {
+ body, _ := source.Bytes()
+ timeline = string(body)
+ _ = source.Cleanup()
+ }
+ }
+ if value, exists := c.Get(requestlogging.APIWebsocketTimelineSourceContextKey); exists {
+ if source, ok := value.(*requestlogging.FileBodySource); ok {
+ _ = source.Cleanup()
+ }
}
timelineCh <- timeline
})
From 167edfec6ccd05c1d5f03bc355050d5ec57ef550 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Tue, 26 May 2026 00:49:36 +0800
Subject: [PATCH 064/248] feat(auth): add support for websockets in auth file
parsing and patching
- Introduced parsing logic to handle `websockets` field in auth files.
- Extended `PatchAuthFileFields` to update `websockets` and arbitrary nested metadata fields.
- Added tests to validate `websockets` parsing, updating, and persistence.
---
.../api/handlers/management/auth_files.go | 465 +++++++++++++-----
.../auth_files_patch_fields_test.go | 118 +++++
.../management/auth_files_project_id_test.go | 56 +++
3 files changed, 513 insertions(+), 126 deletions(-)
diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go
index 291f6ef1e69..c32f41a71a9 100644
--- a/internal/api/handlers/management/auth_files.go
+++ b/internal/api/handlers/management/auth_files.go
@@ -352,6 +352,18 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
fileData["note"] = trimmed
}
}
+ if wv := gjson.GetBytes(data, "websockets"); wv.Exists() {
+ switch wv.Type {
+ case gjson.True:
+ fileData["websockets"] = true
+ case gjson.False:
+ fileData["websockets"] = false
+ case gjson.String:
+ if parsed, errParse := strconv.ParseBool(strings.TrimSpace(wv.String())); errParse == nil {
+ fileData["websockets"] = parsed
+ }
+ }
+ }
}
files = append(files, fileData)
@@ -472,9 +484,43 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
}
}
}
+ if websockets, ok := authWebsocketsValue(auth); ok {
+ entry["websockets"] = websockets
+ }
return entry
}
+func authWebsocketsValue(auth *coreauth.Auth) (bool, bool) {
+ if auth == nil {
+ return false, false
+ }
+ if auth.Attributes != nil {
+ if raw := strings.TrimSpace(auth.Attributes["websockets"]); raw != "" {
+ parsed, errParse := strconv.ParseBool(raw)
+ if errParse == nil {
+ return parsed, true
+ }
+ }
+ }
+ if auth.Metadata == nil {
+ return false, false
+ }
+ raw, ok := auth.Metadata["websockets"]
+ if !ok || raw == nil {
+ return false, false
+ }
+ switch v := raw.(type) {
+ case bool:
+ return v, true
+ case string:
+ parsed, errParse := strconv.ParseBool(strings.TrimSpace(v))
+ if errParse == nil {
+ return parsed, true
+ }
+ }
+ return false, false
+}
+
func authProjectID(auth *coreauth.Auth) string {
if auth == nil {
return ""
@@ -1150,31 +1196,37 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
}
-// PatchAuthFileFields updates editable fields (prefix, proxy_url, headers, priority, note) of an auth file.
+// PatchAuthFileFields updates arbitrary metadata fields of an auth file.
func (h *Handler) PatchAuthFileFields(c *gin.Context) {
if h.authManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
return
}
- var req struct {
- Name string `json:"name"`
- Prefix *string `json:"prefix"`
- ProxyURL *string `json:"proxy_url"`
- Headers map[string]string `json:"headers"`
- Priority *int `json:"priority"`
- Note *string `json:"note"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
+ var req map[string]json.RawMessage
+ decoder := json.NewDecoder(c.Request.Body)
+ decoder.UseNumber()
+ if err := decoder.Decode(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
- name := strings.TrimSpace(req.Name)
+ nameRaw, ok := req["name"]
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
+ return
+ }
+ var nameValue string
+ if err := json.Unmarshal(nameRaw, &nameValue); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
+ return
+ }
+ name := strings.TrimSpace(nameValue)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
+ delete(req, "name")
ctx := c.Request.Context()
@@ -1198,151 +1250,312 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
}
changed := false
- if req.Prefix != nil {
- prefix := strings.TrimSpace(*req.Prefix)
- targetAuth.Prefix = prefix
- if targetAuth.Metadata == nil {
- targetAuth.Metadata = make(map[string]any)
+ touchedRoots := make(map[string]struct{}, len(req))
+ for key, rawValue := range req {
+ fieldPath := strings.TrimSpace(key)
+ if fieldPath == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "field name is required"})
+ return
}
- if prefix == "" {
- delete(targetAuth.Metadata, "prefix")
- } else {
- targetAuth.Metadata["prefix"] = prefix
+ value, errDecode := decodeAuthFileFieldValue(rawValue)
+ if errDecode != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid field %s", fieldPath)})
+ return
}
- changed = true
- }
- if req.ProxyURL != nil {
- proxyURL := strings.TrimSpace(*req.ProxyURL)
- targetAuth.ProxyURL = proxyURL
if targetAuth.Metadata == nil {
targetAuth.Metadata = make(map[string]any)
}
- if proxyURL == "" {
- delete(targetAuth.Metadata, "proxy_url")
- } else {
- targetAuth.Metadata["proxy_url"] = proxyURL
+
+ if fieldPath == "headers" {
+ applyAuthFileHeadersPatch(targetAuth, value)
+ } else if errSet := setAuthFileMetadataValue(targetAuth.Metadata, fieldPath, value); errSet != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": errSet.Error()})
+ return
+ }
+ if root := rootAuthFileField(fieldPath); root != "" {
+ touchedRoots[root] = struct{}{}
}
changed = true
}
- if len(req.Headers) > 0 {
- existingHeaders := coreauth.ExtractCustomHeadersFromMetadata(targetAuth.Metadata)
- nextHeaders := make(map[string]string, len(existingHeaders))
- for k, v := range existingHeaders {
- nextHeaders[k] = v
+ if changed {
+ syncAuthFileMetadataFields(targetAuth, touchedRoots)
+ }
+
+ if !changed {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
+ return
+ }
+
+ targetAuth.UpdatedAt = time.Now()
+
+ if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+func decodeAuthFileFieldValue(raw json.RawMessage) (any, error) {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ var value any
+ if err := decoder.Decode(&value); err != nil {
+ return nil, err
+ }
+ return value, nil
+}
+
+func rootAuthFileField(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return ""
+ }
+ if idx := strings.Index(path, "."); idx >= 0 {
+ return strings.TrimSpace(path[:idx])
+ }
+ return path
+}
+
+func setAuthFileMetadataValue(metadata map[string]any, path string, value any) error {
+ if metadata == nil {
+ return fmt.Errorf("metadata is nil")
+ }
+ parts := strings.Split(path, ".")
+ current := metadata
+ for i, rawPart := range parts {
+ part := strings.TrimSpace(rawPart)
+ if part == "" {
+ return fmt.Errorf("invalid field path: %s", path)
+ }
+ if i == len(parts)-1 {
+ current[part] = value
+ return nil
+ }
+ next, ok := current[part].(map[string]any)
+ if !ok {
+ next = make(map[string]any)
+ current[part] = next
}
- headerChanged := false
+ current = next
+ }
+ return nil
+}
- for key, value := range req.Headers {
- name := strings.TrimSpace(key)
- if name == "" {
- continue
- }
- val := strings.TrimSpace(value)
- attrKey := "header:" + name
- if val == "" {
- if _, ok := nextHeaders[name]; ok {
- delete(nextHeaders, name)
- headerChanged = true
- }
- if targetAuth.Attributes != nil {
- if _, ok := targetAuth.Attributes[attrKey]; ok {
- headerChanged = true
- }
- }
- continue
- }
- if prev, ok := nextHeaders[name]; !ok || prev != val {
- headerChanged = true
- }
- nextHeaders[name] = val
- if targetAuth.Attributes != nil {
- if prev, ok := targetAuth.Attributes[attrKey]; !ok || prev != val {
- headerChanged = true
- }
- } else {
- headerChanged = true
- }
+func applyAuthFileHeadersPatch(auth *coreauth.Auth, value any) {
+ if auth == nil {
+ return
+ }
+ if auth.Metadata == nil {
+ auth.Metadata = make(map[string]any)
+ }
+ headersPatch, ok := authFileHeadersStringMap(value)
+ if !ok {
+ auth.Metadata["headers"] = value
+ return
+ }
+
+ existingHeaders := coreauth.ExtractCustomHeadersFromMetadata(auth.Metadata)
+ nextHeaders := make(map[string]string, len(existingHeaders))
+ for key, val := range existingHeaders {
+ nextHeaders[key] = val
+ }
+ for key, value := range headersPatch {
+ name := strings.TrimSpace(key)
+ if name == "" {
+ continue
+ }
+ val := strings.TrimSpace(value)
+ if val == "" {
+ delete(nextHeaders, name)
+ continue
}
+ nextHeaders[name] = val
+ }
- if headerChanged {
- if targetAuth.Metadata == nil {
- targetAuth.Metadata = make(map[string]any)
- }
- if targetAuth.Attributes == nil {
- targetAuth.Attributes = make(map[string]string)
- }
+ if len(nextHeaders) == 0 {
+ delete(auth.Metadata, "headers")
+ return
+ }
+ metaHeaders := make(map[string]any, len(nextHeaders))
+ for key, value := range nextHeaders {
+ metaHeaders[key] = value
+ }
+ auth.Metadata["headers"] = metaHeaders
+}
- for key, value := range req.Headers {
- name := strings.TrimSpace(key)
- if name == "" {
- continue
- }
- val := strings.TrimSpace(value)
- attrKey := "header:" + name
- if val == "" {
- delete(nextHeaders, name)
- delete(targetAuth.Attributes, attrKey)
- continue
- }
- nextHeaders[name] = val
- targetAuth.Attributes[attrKey] = val
+func authFileHeadersStringMap(value any) (map[string]string, bool) {
+ switch typed := value.(type) {
+ case map[string]string:
+ return typed, true
+ case map[string]any:
+ out := make(map[string]string, len(typed))
+ for key, rawValue := range typed {
+ value, ok := rawValue.(string)
+ if !ok {
+ return nil, false
}
+ out[key] = value
+ }
+ return out, true
+ default:
+ return nil, false
+ }
+}
- if len(nextHeaders) == 0 {
- delete(targetAuth.Metadata, "headers")
- } else {
- metaHeaders := make(map[string]any, len(nextHeaders))
- for k, v := range nextHeaders {
- metaHeaders[k] = v
- }
- targetAuth.Metadata["headers"] = metaHeaders
- }
- changed = true
+func syncAuthFileMetadataFields(auth *coreauth.Auth, touchedRoots map[string]struct{}) {
+ if auth == nil || len(touchedRoots) == 0 {
+ return
+ }
+ if _, ok := touchedRoots["prefix"]; ok {
+ if prefix, okString := auth.Metadata["prefix"].(string); okString {
+ auth.Prefix = strings.TrimSpace(prefix)
}
}
- if req.Priority != nil || req.Note != nil {
- if targetAuth.Metadata == nil {
- targetAuth.Metadata = make(map[string]any)
+ if _, ok := touchedRoots["proxy_url"]; ok {
+ if proxyURL, okString := auth.Metadata["proxy_url"].(string); okString {
+ auth.ProxyURL = strings.TrimSpace(proxyURL)
}
- if targetAuth.Attributes == nil {
- targetAuth.Attributes = make(map[string]string)
+ }
+ if _, ok := touchedRoots["headers"]; ok {
+ syncAuthFileHeaderAttributes(auth)
+ }
+ if _, ok := touchedRoots["priority"]; ok {
+ syncAuthFilePriorityAttribute(auth)
+ }
+ if _, ok := touchedRoots["note"]; ok {
+ syncAuthFileNoteAttribute(auth)
+ }
+ if _, ok := touchedRoots["websockets"]; ok {
+ syncAuthFileWebsocketsAttribute(auth)
+ }
+ if _, ok := touchedRoots["disabled"]; ok {
+ syncAuthFileDisabledState(auth)
+ }
+}
+
+func syncAuthFileHeaderAttributes(auth *coreauth.Auth) {
+ if auth == nil {
+ return
+ }
+ if auth.Attributes == nil {
+ auth.Attributes = make(map[string]string)
+ }
+ for key := range auth.Attributes {
+ if strings.HasPrefix(key, "header:") {
+ delete(auth.Attributes, key)
}
+ }
+ for name, value := range coreauth.ExtractCustomHeadersFromMetadata(auth.Metadata) {
+ auth.Attributes["header:"+name] = value
+ }
+}
- if req.Priority != nil {
- if *req.Priority == 0 {
- delete(targetAuth.Metadata, "priority")
- delete(targetAuth.Attributes, "priority")
- } else {
- targetAuth.Metadata["priority"] = *req.Priority
- targetAuth.Attributes["priority"] = strconv.Itoa(*req.Priority)
- }
+func syncAuthFilePriorityAttribute(auth *coreauth.Auth) {
+ if auth == nil {
+ return
+ }
+ if auth.Attributes == nil {
+ auth.Attributes = make(map[string]string)
+ }
+ priority, ok := authFileIntValue(auth.Metadata["priority"])
+ if !ok {
+ delete(auth.Attributes, "priority")
+ return
+ }
+ if priority == 0 {
+ delete(auth.Attributes, "priority")
+ return
+ }
+ auth.Attributes["priority"] = strconv.Itoa(priority)
+}
+
+func authFileIntValue(value any) (int, bool) {
+ switch typed := value.(type) {
+ case int:
+ return typed, true
+ case int64:
+ return int(typed), true
+ case float64:
+ return int(typed), true
+ case json.Number:
+ if i, err := typed.Int64(); err == nil {
+ return int(i), true
}
- if req.Note != nil {
- trimmedNote := strings.TrimSpace(*req.Note)
- if trimmedNote == "" {
- delete(targetAuth.Metadata, "note")
- delete(targetAuth.Attributes, "note")
- } else {
- targetAuth.Metadata["note"] = trimmedNote
- targetAuth.Attributes["note"] = trimmedNote
- }
+ case string:
+ if i, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil {
+ return i, true
}
- changed = true
}
+ return 0, false
+}
- if !changed {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
+func syncAuthFileNoteAttribute(auth *coreauth.Auth) {
+ if auth == nil {
return
}
+ if auth.Attributes == nil {
+ auth.Attributes = make(map[string]string)
+ }
+ note, ok := auth.Metadata["note"].(string)
+ if !ok {
+ delete(auth.Attributes, "note")
+ return
+ }
+ note = strings.TrimSpace(note)
+ if note == "" {
+ delete(auth.Attributes, "note")
+ return
+ }
+ auth.Attributes["note"] = note
+}
- targetAuth.UpdatedAt = time.Now()
-
- if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
+func syncAuthFileWebsocketsAttribute(auth *coreauth.Auth) {
+ if auth == nil {
+ return
+ }
+ if auth.Attributes == nil {
+ auth.Attributes = make(map[string]string)
+ }
+ websockets, ok := authFileBoolValue(auth.Metadata["websockets"])
+ if !ok {
+ delete(auth.Attributes, "websockets")
return
}
+ auth.Attributes["websockets"] = strconv.FormatBool(websockets)
+}
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
+func authFileBoolValue(value any) (bool, bool) {
+ switch typed := value.(type) {
+ case bool:
+ return typed, true
+ case string:
+ parsed, errParse := strconv.ParseBool(strings.TrimSpace(typed))
+ if errParse == nil {
+ return parsed, true
+ }
+ }
+ return false, false
+}
+
+func syncAuthFileDisabledState(auth *coreauth.Auth) {
+ if auth == nil {
+ return
+ }
+ disabled, ok := authFileBoolValue(auth.Metadata["disabled"])
+ if !ok {
+ return
+ }
+ auth.Disabled = disabled
+ if disabled {
+ auth.Status = coreauth.StatusDisabled
+ if strings.TrimSpace(auth.StatusMessage) == "" {
+ auth.StatusMessage = "disabled via management API"
+ }
+ return
+ }
+ auth.Status = coreauth.StatusActive
+ auth.StatusMessage = ""
}
func (h *Handler) disableAuth(ctx context.Context, id string) {
diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go
index 568700a0d69..072e487ee9a 100644
--- a/internal/api/handlers/management/auth_files_patch_fields_test.go
+++ b/internal/api/handlers/management/auth_files_patch_fields_test.go
@@ -5,11 +5,14 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ fileauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
@@ -162,3 +165,118 @@ func TestPatchAuthFileFields_HeadersEmptyMapIsNoop(t *testing.T) {
t.Fatalf("metadata.headers.X-Kee = %#v, want %q", got, "1")
}
}
+
+func TestPatchAuthFileFields_WebsocketsFalseIsUpdate(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ store := &memoryAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ record := &coreauth.Auth{
+ ID: "codex.json",
+ FileName: "codex.json",
+ Provider: "codex",
+ Attributes: map[string]string{
+ "path": "/tmp/codex.json",
+ "websockets": "true",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "websockets": true,
+ },
+ }
+ if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
+ t.Fatalf("failed to register auth record: %v", errRegister)
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
+
+ body := `{"name":"codex.json","websockets":false}`
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
+ }
+
+ updated, ok := manager.GetByID("codex.json")
+ if !ok || updated == nil {
+ t.Fatalf("expected auth record to exist after patch")
+ }
+ if got := updated.Attributes["websockets"]; got != "false" {
+ t.Fatalf("attrs websockets = %q, want %q", got, "false")
+ }
+ if got, ok := updated.Metadata["websockets"].(bool); !ok || got {
+ t.Fatalf("metadata.websockets = %#v, want false", updated.Metadata["websockets"])
+ }
+}
+
+func TestPatchAuthFileFields_ArbitraryFieldsPersistToFile(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ fileName := "generic.json"
+ filePath := filepath.Join(authDir, fileName)
+ store := fileauth.NewFileTokenStore()
+ store.SetBaseDir(authDir)
+ manager := coreauth.NewManager(store, nil, nil)
+ record := &coreauth.Auth{
+ ID: fileName,
+ FileName: fileName,
+ Provider: "codex",
+ Attributes: map[string]string{
+ "path": filePath,
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ },
+ }
+ if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
+ t.Fatalf("failed to register auth record: %v", errRegister)
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
+
+ body := `{"name":"generic.json","abc":true,"nested.cde":true,"fgh":{"ijk":true}}`
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
+ }
+
+ raw, errRead := os.ReadFile(filePath)
+ if errRead != nil {
+ t.Fatalf("failed to read updated auth file: %v", errRead)
+ }
+ var data map[string]any
+ if errUnmarshal := json.Unmarshal(raw, &data); errUnmarshal != nil {
+ t.Fatalf("failed to unmarshal updated auth file: %v", errUnmarshal)
+ }
+ if got := data["abc"]; got != true {
+ t.Fatalf("abc = %#v, want true", got)
+ }
+ nested, ok := data["nested"].(map[string]any)
+ if !ok {
+ t.Fatalf("nested = %#v, want object", data["nested"])
+ }
+ if got := nested["cde"]; got != true {
+ t.Fatalf("nested.cde = %#v, want true", got)
+ }
+ fgh, ok := data["fgh"].(map[string]any)
+ if !ok {
+ t.Fatalf("fgh = %#v, want object", data["fgh"])
+ }
+ if got := fgh["ijk"]; got != true {
+ t.Fatalf("fgh.ijk = %#v, want true", got)
+ }
+}
diff --git a/internal/api/handlers/management/auth_files_project_id_test.go b/internal/api/handlers/management/auth_files_project_id_test.go
index e9634f5aee8..0c462934892 100644
--- a/internal/api/handlers/management/auth_files_project_id_test.go
+++ b/internal/api/handlers/management/auth_files_project_id_test.go
@@ -71,6 +71,62 @@ func TestListAuthFilesFromDisk_IncludesProjectID(t *testing.T) {
}
}
+func TestListAuthFiles_IncludesWebsocketsFromManager(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ fileName := "codex-user@example.com-pro.json"
+ filePath := filepath.Join(authDir, fileName)
+ if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex","email":"user@example.com"}`), 0o600); errWrite != nil {
+ t.Fatalf("failed to write auth file: %v", errWrite)
+ }
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ record := &coreauth.Auth{
+ ID: fileName,
+ FileName: fileName,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": filePath,
+ "websockets": "true",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ },
+ }
+ if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
+ t.Fatalf("failed to register auth record: %v", errRegister)
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
+ h.tokenStore = &memoryAuthStore{}
+
+ entry := firstAuthFileEntry(t, h)
+ if got := entry["websockets"]; got != true {
+ t.Fatalf("expected websockets true, got %#v", got)
+ }
+}
+
+func TestListAuthFilesFromDisk_IncludesWebsockets(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ filePath := filepath.Join(authDir, "codex-user@example.com-pro.json")
+ if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex","email":"user@example.com","websockets":false}`), 0o600); errWrite != nil {
+ t.Fatalf("failed to write auth file: %v", errWrite)
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+
+ entry := firstAuthFileEntry(t, h)
+ if got := entry["websockets"]; got != false {
+ t.Fatalf("expected websockets false, got %#v", got)
+ }
+}
+
func firstAuthFileEntry(t *testing.T, h *Handler) map[string]any {
t.Helper()
From 70a8cf026f0047c79424a190661a66f5ddc058ae Mon Sep 17 00:00:00 2001
From: sususu98
Date: Tue, 26 May 2026 10:36:59 +0800
Subject: [PATCH 065/248] fix: clean gemini cli request schemas
---
.../runtime/executor/gemini_cli_executor.go | 52 +++++++++++++
.../executor/gemini_cli_executor_test.go | 75 +++++++++++++++++++
2 files changed, 127 insertions(+)
create mode 100644 internal/runtime/executor/gemini_cli_executor_test.go
diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go
index d9cf8456734..af93a3f34ed 100644
--- a/internal/runtime/executor/gemini_cli_executor.go
+++ b/internal/runtime/executor/gemini_cli_executor.go
@@ -141,6 +141,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
basePayload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "gemini", from.String(), "request", basePayload, originalTranslated, requestedModel, requestPath, opts.Headers)
+ basePayload = cleanGeminiCLIRequestSchemas(basePayload)
action := "generateContent"
if req.Metadata != nil {
@@ -297,6 +298,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
basePayload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "gemini", from.String(), "request", basePayload, originalTranslated, requestedModel, requestPath, opts.Headers)
+ basePayload = cleanGeminiCLIRequestSchemas(basePayload)
projectID := resolveGeminiProjectID(auth)
@@ -530,6 +532,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
payload = deleteJSONField(payload, "model")
payload = deleteJSONField(payload, "request.safetySettings")
payload = fixGeminiCLIImageAspectRatio(baseModel, payload)
+ payload = cleanGeminiCLIRequestSchemas(payload)
tok, errTok := tokenSource.Token()
if errTok != nil {
@@ -859,6 +862,55 @@ func deleteJSONField(body []byte, key string) []byte {
return updated
}
+func cleanGeminiCLIRequestSchemas(body []byte) []byte {
+ if len(body) == 0 {
+ return body
+ }
+ hasTools := gjson.GetBytes(body, "request.tools.0").Exists()
+ hasResponseSchema := gjson.GetBytes(body, "request.generationConfig.responseSchema").Exists()
+ hasResponseJSONSchema := gjson.GetBytes(body, "request.generationConfig.responseJsonSchema").Exists()
+ if !hasTools && !hasResponseSchema && !hasResponseJSONSchema {
+ return body
+ }
+
+ tools := gjson.GetBytes(body, "request.tools")
+ if tools.IsArray() {
+ for i, tool := range tools.Array() {
+ for _, declarationsKey := range []string{"function_declarations", "functionDeclarations"} {
+ funcDecls := tool.Get(declarationsKey)
+ if !funcDecls.IsArray() {
+ continue
+ }
+ for j, decl := range funcDecls.Array() {
+ for _, schemaKey := range []string{"parameters", "parametersJsonSchema"} {
+ params := decl.Get(schemaKey)
+ if !params.Exists() || !params.IsObject() {
+ continue
+ }
+ cleaned := util.CleanJSONSchemaForGemini(params.Raw)
+ path := fmt.Sprintf("request.tools.%d.%s.%d.%s", i, declarationsKey, j, schemaKey)
+ body, _ = sjson.SetRawBytes(body, path, []byte(cleaned))
+ }
+ }
+ }
+ }
+ }
+
+ for _, schemaPath := range []string{
+ "request.generationConfig.responseSchema",
+ "request.generationConfig.responseJsonSchema",
+ } {
+ responseSchema := gjson.GetBytes(body, schemaPath)
+ if !responseSchema.IsObject() {
+ continue
+ }
+ cleaned := util.CleanJSONSchemaForGemini(responseSchema.Raw)
+ body, _ = sjson.SetRawBytes(body, schemaPath, []byte(cleaned))
+ }
+
+ return body
+}
+
func fixGeminiCLIImageAspectRatio(modelName string, rawJSON []byte) []byte {
if modelName == "gemini-2.5-flash-image-preview" {
aspectRatioResult := gjson.GetBytes(rawJSON, "request.generationConfig.imageConfig.aspectRatio")
diff --git a/internal/runtime/executor/gemini_cli_executor_test.go b/internal/runtime/executor/gemini_cli_executor_test.go
new file mode 100644
index 00000000000..b77134ed8c5
--- /dev/null
+++ b/internal/runtime/executor/gemini_cli_executor_test.go
@@ -0,0 +1,75 @@
+package executor
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestCleanGeminiCLIRequestSchemasFlattensFunctionDeclarationTypeArray(t *testing.T) {
+ input := []byte(`{
+ "request": {
+ "tools": [
+ {
+ "function_declarations": [
+ {
+ "name": "wecom_mcp",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "description": "call args",
+ "type": ["string", "object"]
+ }
+ }
+ }
+ }
+ ]
+ },
+ {
+ "functionDeclarations": [
+ {
+ "name": "camel_tool",
+ "parametersJsonSchema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": ["integer", "string"]
+ }
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "nonSchema": {
+ "type": ["string", "object"]
+ }
+ }
+ }`)
+
+ out := cleanGeminiCLIRequestSchemas(input)
+
+ argsType := gjson.GetBytes(out, "request.tools.0.function_declarations.0.parameters.properties.args.type")
+ if argsType.String() != "string" {
+ t.Fatalf("args.type = %s, want string; body=%s", argsType.Raw, string(out))
+ }
+ argsDesc := gjson.GetBytes(out, "request.tools.0.function_declarations.0.parameters.properties.args.description").String()
+ if !strings.Contains(argsDesc, "Accepts: string | object") {
+ t.Fatalf("args.description = %q, want accepted type hint", argsDesc)
+ }
+
+ valueType := gjson.GetBytes(out, "request.tools.1.functionDeclarations.0.parametersJsonSchema.properties.value.type")
+ if valueType.String() != "integer" {
+ t.Fatalf("value.type = %s, want integer; body=%s", valueType.Raw, string(out))
+ }
+ valueDesc := gjson.GetBytes(out, "request.tools.1.functionDeclarations.0.parametersJsonSchema.properties.value.description").String()
+ if !strings.Contains(valueDesc, "Accepts: integer | string") {
+ t.Fatalf("value.description = %q, want accepted type hint", valueDesc)
+ }
+
+ if nonSchema := gjson.GetBytes(out, "request.nonSchema.type"); !nonSchema.IsArray() {
+ t.Fatalf("request.nonSchema.type should be preserved outside schema paths, got %s", nonSchema.Raw)
+ }
+}
From 4a85b6b97e19de77b0ffd57baa6af8dc8d20304d Mon Sep 17 00:00:00 2001
From: sususu98
Date: Tue, 26 May 2026 10:52:53 +0800
Subject: [PATCH 066/248] fix: log gemini cli schema cleanup errors
---
internal/runtime/executor/gemini_cli_executor.go | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go
index af93a3f34ed..95fcd9e0c88 100644
--- a/internal/runtime/executor/gemini_cli_executor.go
+++ b/internal/runtime/executor/gemini_cli_executor.go
@@ -889,7 +889,12 @@ func cleanGeminiCLIRequestSchemas(body []byte) []byte {
}
cleaned := util.CleanJSONSchemaForGemini(params.Raw)
path := fmt.Sprintf("request.tools.%d.%s.%d.%s", i, declarationsKey, j, schemaKey)
- body, _ = sjson.SetRawBytes(body, path, []byte(cleaned))
+ updated, errSet := sjson.SetRawBytes(body, path, []byte(cleaned))
+ if errSet != nil {
+ log.Errorf("gemini cli executor: failed to set cleaned schema at %s: %v", path, errSet)
+ continue
+ }
+ body = updated
}
}
}
@@ -905,7 +910,12 @@ func cleanGeminiCLIRequestSchemas(body []byte) []byte {
continue
}
cleaned := util.CleanJSONSchemaForGemini(responseSchema.Raw)
- body, _ = sjson.SetRawBytes(body, schemaPath, []byte(cleaned))
+ updated, errSet := sjson.SetRawBytes(body, schemaPath, []byte(cleaned))
+ if errSet != nil {
+ log.Errorf("gemini cli executor: failed to set cleaned response schema at %s: %v", schemaPath, errSet)
+ continue
+ }
+ body = updated
}
return body
From e399edd3cc9aaa5b42702f792df8a5aae9212206 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Wed, 27 May 2026 00:46:51 +0800
Subject: [PATCH 067/248] feat(images): add support for configurable GPT Image
2 base model and improved SSE handling
- Introduced `GPTImage2BaseModel` configuration for hosted image generation tools with validation for "gpt-" prefix.
- Added logic to dynamically resolve and apply the base model in Codex executor workflows.
- Enhanced server-sent events (SSE) implementation with keep-alive tickers and error events for stream reliability.
- Updated configuration file examples and internal documentation.
---
config.example.yaml | 4 +
internal/config/sdk_config.go | 7 +
.../runtime/executor/codex_openai_images.go | 36 +-
internal/watcher/diff/config_diff.go | 3 +
.../handlers/openai/openai_images_handlers.go | 397 +++++++++++++-----
5 files changed, 324 insertions(+), 123 deletions(-)
diff --git a/config.example.yaml b/config.example.yaml
index 959f1f4018b..6a53c940048 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -100,6 +100,10 @@ disable-cooling: false
# - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled.
disable-image-generation: false
+# Base model used when proxying gpt-image-2 via the hosted image_generation tool (Responses API).
+# Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini".
+# gpt-image-2-base-model: "gpt-5.4-mini"
+
# Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh).
# When > 0, overrides the default worker count (16).
# auth-auto-refresh-workers: 16
diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go
index 48c0fe5f174..d7a49e9d48c 100644
--- a/internal/config/sdk_config.go
+++ b/internal/config/sdk_config.go
@@ -19,6 +19,13 @@ type SDKConfig struct {
// while keeping /v1/images/generations and /v1/images/edits enabled and preserving image_generation there.
DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"`
+ // GPTImage2BaseModel sets the base (mainline) model used when proxying GPT Image 2
+ // requests via the hosted image_generation tool (e.g. Codex OAuth /v1/images/*).
+ //
+ // The value must start with "gpt-" (case-insensitive). If empty or invalid, the
+ // default base model ("gpt-5.4-mini") is used.
+ GPTImage2BaseModel string `yaml:"gpt-image-2-base-model,omitempty" json:"gpt-image-2-base-model,omitempty"`
+
// EnableGeminiCLIEndpoint controls whether Gemini CLI internal endpoints (/v1internal:*) are enabled.
// Default is false for safety; when false, /v1internal:* requests are rejected.
EnableGeminiCLIEndpoint bool `yaml:"enable-gemini-cli-endpoint" json:"enable-gemini-cli-endpoint"`
diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go
index 0db259e411d..142971118a4 100644
--- a/internal/runtime/executor/codex_openai_images.go
+++ b/internal/runtime/executor/codex_openai_images.go
@@ -63,6 +63,20 @@ func codexIsImagesEndpointPath(path string) bool {
return strings.HasSuffix(path, codexImagesGenerationsPath) || strings.HasSuffix(path, codexImagesEditsPath)
}
+func (e *CodexExecutor) resolveGPTImage2BaseModel() string {
+ if e == nil || e.cfg == nil {
+ return codexOpenAIImagesMainModel
+ }
+ model := strings.TrimSpace(e.cfg.GPTImage2BaseModel)
+ if model == "" {
+ return codexOpenAIImagesMainModel
+ }
+ if strings.HasPrefix(strings.ToLower(model), "gpt-") {
+ return model
+ }
+ return codexOpenAIImagesMainModel
+}
+
func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts)
if errPrepare != nil {
@@ -74,10 +88,11 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
baseURL = "https://chatgpt.com/backend-api/codex"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), codexOpenAIImagesMainModel, auth)
+ mainModel := e.resolveGPTImage2BaseModel()
+ reporter := helps.NewUsageReporter(ctx, e.Identifier(), mainModel, auth)
defer reporter.TrackFailure(ctx, &err)
- body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts)
+ body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts, mainModel)
if errBuild != nil {
return resp, errBuild
}
@@ -161,10 +176,11 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
baseURL = "https://chatgpt.com/backend-api/codex"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), codexOpenAIImagesMainModel, auth)
+ mainModel := e.resolveGPTImage2BaseModel()
+ reporter := helps.NewUsageReporter(ctx, e.Identifier(), mainModel, auth)
defer reporter.TrackFailure(ctx, &err)
- body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts)
+ body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts, mainModel)
if errBuild != nil {
return nil, errBuild
}
@@ -277,18 +293,22 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
-func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) {
+func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, mainModel string) ([]byte, error) {
out := body
+ mainModel = strings.TrimSpace(mainModel)
+ if mainModel == "" {
+ mainModel = codexOpenAIImagesMainModel
+ }
var errThinking error
- out, errThinking = thinking.ApplyThinking(out, codexOpenAIImagesMainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier())
+ out, errThinking = thinking.ApplyThinking(out, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier())
if errThinking != nil {
return nil, errThinking
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- out = helps.ApplyPayloadConfigWithRequest(e.cfg, codexOpenAIImagesMainModel, "codex", codexOpenAIImageSourceFormat, "", out, body, requestedModel, requestPath, opts.Headers)
- out, _ = sjson.SetBytes(out, "model", codexOpenAIImagesMainModel)
+ out = helps.ApplyPayloadConfigWithRequest(e.cfg, mainModel, "codex", codexOpenAIImageSourceFormat, "", out, body, requestedModel, requestPath, opts.Headers)
+ out, _ = sjson.SetBytes(out, "model", mainModel)
out, _ = sjson.SetBytes(out, "stream", true)
out, _ = sjson.DeleteBytes(out, "previous_response_id")
out, _ = sjson.DeleteBytes(out, "prompt_cache_retention")
diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go
index dcfa595f6bc..beda1be854f 100644
--- a/internal/watcher/diff/config_diff.go
+++ b/internal/watcher/diff/config_diff.go
@@ -48,6 +48,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.DisableImageGeneration != newCfg.DisableImageGeneration {
changes = append(changes, fmt.Sprintf("disable-image-generation: %v -> %v", oldCfg.DisableImageGeneration, newCfg.DisableImageGeneration))
}
+ if strings.TrimSpace(oldCfg.GPTImage2BaseModel) != strings.TrimSpace(newCfg.GPTImage2BaseModel) {
+ changes = append(changes, fmt.Sprintf("gpt-image-2-base-model: %s -> %s", strings.TrimSpace(oldCfg.GPTImage2BaseModel), strings.TrimSpace(newCfg.GPTImage2BaseModel)))
+ }
if oldCfg.RequestLog != newCfg.RequestLog {
changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog))
}
diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go
index 067471f4db0..479dd3e6b21 100644
--- a/sdk/api/handlers/openai/openai_images_handlers.go
+++ b/sdk/api/handlers/openai/openai_images_handlers.go
@@ -56,6 +56,80 @@ type xaiImageResult struct {
MimeType string
}
+type imagesStreamExecutionResult struct {
+ Data <-chan []byte
+ UpstreamHeaders http.Header
+ Errs <-chan *interfaces.ErrorMessage
+}
+
+func setImagesSSEHeaders(c *gin.Context) {
+ c.Header("Content-Type", "text/event-stream")
+ c.Header("Cache-Control", "no-cache")
+ c.Header("Connection", "keep-alive")
+ c.Header("Access-Control-Allow-Origin", "*")
+}
+
+func (h *OpenAIAPIHandler) newImagesStreamKeepAliveTicker() (*time.Ticker, <-chan time.Time) {
+ if h == nil || h.BaseAPIHandler == nil {
+ return nil, nil
+ }
+ interval := handlers.StreamingKeepAliveInterval(h.Cfg)
+ if interval <= 0 {
+ return nil, nil
+ }
+ ticker := time.NewTicker(interval)
+ return ticker, ticker.C
+}
+
+func writeImagesStreamKeepAlive(c *gin.Context, flusher http.Flusher) {
+ _, _ = c.Writer.Write([]byte(": keep-alive\n\n"))
+ flusher.Flush()
+}
+
+func writeImagesStreamErrorEvent(c *gin.Context, errMsg *interfaces.ErrorMessage) {
+ if errMsg == nil {
+ return
+ }
+ status := http.StatusInternalServerError
+ if errMsg.StatusCode > 0 {
+ status = errMsg.StatusCode
+ }
+ errText := http.StatusText(status)
+ if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" {
+ errText = errMsg.Error.Error()
+ }
+ body := handlers.BuildErrorResponseBody(status, errText)
+ _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", string(body))
+}
+
+func (h *OpenAIAPIHandler) waitImagesStreamExecution(c *gin.Context, flusher http.Flusher, execute func() imagesStreamExecutionResult) (imagesStreamExecutionResult, bool, bool) {
+ resultChan := make(chan imagesStreamExecutionResult, 1)
+ go func() {
+ resultChan <- execute()
+ }()
+
+ keepAlive, keepAliveC := h.newImagesStreamKeepAliveTicker()
+ defer func() {
+ if keepAlive != nil {
+ keepAlive.Stop()
+ }
+ }()
+
+ streamStarted := false
+ for {
+ select {
+ case <-c.Request.Context().Done():
+ return imagesStreamExecutionResult{}, streamStarted, true
+ case result := <-resultChan:
+ return result, streamStarted, false
+ case <-keepAliveC:
+ setImagesSSEHeaders(c)
+ writeImagesStreamKeepAlive(c, flusher)
+ streamStarted = true
+ }
+ }
+}
+
func (a *sseFrameAccumulator) AddChunk(chunk []byte) [][]byte {
if len(chunk) == 0 {
return nil
@@ -1109,14 +1183,26 @@ func (h *OpenAIAPIHandler) streamRoutedImages(c *gin.Context, imageReq []byte, i
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
cliCtx = handlers.WithDisallowFreeAuth(cliCtx)
model := strings.TrimSpace(imageModel)
- dataChan, upstreamHeaders, errChan := h.ExecuteImageStreamWithAuthManager(cliCtx, xaiImagesHandlerType, model, imageReq, "")
-
- setSSEHeaders := func() {
- c.Header("Content-Type", "text/event-stream")
- c.Header("Cache-Control", "no-cache")
- c.Header("Connection", "keep-alive")
- c.Header("Access-Control-Allow-Origin", "*")
+ execution, streamStarted, canceled := h.waitImagesStreamExecution(c, flusher, func() imagesStreamExecutionResult {
+ dataChan, upstreamHeaders, errChan := h.ExecuteImageStreamWithAuthManager(cliCtx, xaiImagesHandlerType, model, imageReq, "")
+ return imagesStreamExecutionResult{Data: dataChan, UpstreamHeaders: upstreamHeaders, Errs: errChan}
+ })
+ if canceled {
+ cliCancel(c.Request.Context().Err())
+ return
+ }
+ dataChan := execution.Data
+ upstreamHeaders := execution.UpstreamHeaders
+ errChan := execution.Errs
+ keepAlive, keepAliveC := h.newImagesStreamKeepAliveTicker()
+ stopKeepAlive := func() {
+ if keepAlive != nil {
+ keepAlive.Stop()
+ keepAlive = nil
+ keepAliveC = nil
+ }
}
+ defer stopKeepAlive()
for {
select {
@@ -1128,7 +1214,12 @@ func (h *OpenAIAPIHandler) streamRoutedImages(c *gin.Context, imageReq []byte, i
errChan = nil
continue
}
- h.WriteErrorResponse(c, errMsg)
+ if streamStarted {
+ writeImagesStreamErrorEvent(c, errMsg)
+ flusher.Flush()
+ } else {
+ h.WriteErrorResponse(c, errMsg)
+ }
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
@@ -1137,7 +1228,8 @@ func (h *OpenAIAPIHandler) streamRoutedImages(c *gin.Context, imageReq []byte, i
return
case chunk, ok := <-dataChan:
if !ok {
- setSSEHeaders()
+ stopKeepAlive()
+ setImagesSSEHeaders(c)
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write([]byte("\n"))
flusher.Flush()
@@ -1145,35 +1237,30 @@ func (h *OpenAIAPIHandler) streamRoutedImages(c *gin.Context, imageReq []byte, i
return
}
- setSSEHeaders()
+ stopKeepAlive()
+ setImagesSSEHeaders(c)
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(chunk)
flusher.Flush()
+ streamStarted = true
h.forwardRawImageStream(cliCtx, c, func(err error) { cliCancel(err) }, dataChan, errChan)
return
+ case <-keepAliveC:
+ setImagesSSEHeaders(c)
+ handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
+ writeImagesStreamKeepAlive(c, flusher)
+ streamStarted = true
}
}
}
func (h *OpenAIAPIHandler) forwardRawImageStream(ctx context.Context, c *gin.Context, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
- emitError := func(errMsg *interfaces.ErrorMessage) {
- if errMsg == nil {
- return
- }
- status := http.StatusInternalServerError
- if errMsg.StatusCode > 0 {
- status = errMsg.StatusCode
- }
- errText := http.StatusText(status)
- if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" {
- errText = errMsg.Error.Error()
- }
- body := handlers.BuildErrorResponseBody(status, errText)
- _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", string(body))
- if flusher, ok := c.Writer.(http.Flusher); ok {
- flusher.Flush()
+ keepAlive, keepAliveC := h.newImagesStreamKeepAliveTicker()
+ defer func() {
+ if keepAlive != nil {
+ keepAlive.Stop()
}
- }
+ }()
for {
select {
@@ -1185,7 +1272,10 @@ func (h *OpenAIAPIHandler) forwardRawImageStream(ctx context.Context, c *gin.Con
return
case errMsg, ok := <-errs:
if ok && errMsg != nil {
- emitError(errMsg)
+ writeImagesStreamErrorEvent(c, errMsg)
+ if flusher, ok := c.Writer.(http.Flusher); ok {
+ flusher.Flush()
+ }
cancel(errMsg.Error)
return
}
@@ -1199,6 +1289,10 @@ func (h *OpenAIAPIHandler) forwardRawImageStream(ctx context.Context, c *gin.Con
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
}
+ case <-keepAliveC:
+ if flusher, ok := c.Writer.(http.Flusher); ok {
+ writeImagesStreamKeepAlive(c, flusher)
+ }
}
}
}
@@ -1217,14 +1311,26 @@ func (h *OpenAIAPIHandler) streamOpenAICompatImages(c *gin.Context, compatReq []
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
model := strings.TrimSpace(imageModel)
- dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, xaiImagesHandlerType, model, compatReq, "")
-
- setSSEHeaders := func() {
- c.Header("Content-Type", "text/event-stream")
- c.Header("Cache-Control", "no-cache")
- c.Header("Connection", "keep-alive")
- c.Header("Access-Control-Allow-Origin", "*")
+ execution, streamStarted, canceled := h.waitImagesStreamExecution(c, flusher, func() imagesStreamExecutionResult {
+ dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, xaiImagesHandlerType, model, compatReq, "")
+ return imagesStreamExecutionResult{Data: dataChan, UpstreamHeaders: upstreamHeaders, Errs: errChan}
+ })
+ if canceled {
+ cliCancel(c.Request.Context().Err())
+ return
+ }
+ dataChan := execution.Data
+ upstreamHeaders := execution.UpstreamHeaders
+ errChan := execution.Errs
+ keepAlive, keepAliveC := h.newImagesStreamKeepAliveTicker()
+ stopKeepAlive := func() {
+ if keepAlive != nil {
+ keepAlive.Stop()
+ keepAlive = nil
+ keepAliveC = nil
+ }
}
+ defer stopKeepAlive()
for {
select {
@@ -1236,7 +1342,12 @@ func (h *OpenAIAPIHandler) streamOpenAICompatImages(c *gin.Context, compatReq []
errChan = nil
continue
}
- h.WriteErrorResponse(c, errMsg)
+ if streamStarted {
+ writeImagesStreamErrorEvent(c, errMsg)
+ flusher.Flush()
+ } else {
+ h.WriteErrorResponse(c, errMsg)
+ }
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
@@ -1245,38 +1356,34 @@ func (h *OpenAIAPIHandler) streamOpenAICompatImages(c *gin.Context, compatReq []
return
case chunk, ok := <-dataChan:
if !ok {
- setSSEHeaders()
+ stopKeepAlive()
+ setImagesSSEHeaders(c)
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
flusher.Flush()
cliCancel(nil)
return
}
- setSSEHeaders()
+ stopKeepAlive()
+ setImagesSSEHeaders(c)
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(chunk)
flusher.Flush()
+ streamStarted = true
h.ForwardStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan, handlers.StreamForwardOptions{
WriteChunk: func(next []byte) {
_, _ = c.Writer.Write(next)
},
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
- if errMsg == nil {
- return
- }
- status := http.StatusInternalServerError
- if errMsg.StatusCode > 0 {
- status = errMsg.StatusCode
- }
- errText := http.StatusText(status)
- if errMsg.Error != nil && errMsg.Error.Error() != "" {
- errText = errMsg.Error.Error()
- }
- body := handlers.BuildErrorResponseBody(status, errText)
- _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", string(body))
+ writeImagesStreamErrorEvent(c, errMsg)
},
})
return
+ case <-keepAliveC:
+ setImagesSSEHeaders(c)
+ handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
+ writeImagesStreamKeepAlive(c, flusher)
+ streamStarted = true
}
}
}
@@ -1337,57 +1444,96 @@ func (h *OpenAIAPIHandler) streamImagesWithModel(c *gin.Context, imageReq []byte
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
model = strings.TrimSpace(model)
- resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiImagesHandlerType, model, imageReq, "")
- if errMsg != nil {
- h.WriteErrorResponse(c, errMsg)
- if errMsg.Error != nil {
+ type imageStreamResult struct {
+ resp []byte
+ upstreamHeaders http.Header
+ errMsg *interfaces.ErrorMessage
+ }
+ resultChan := make(chan imageStreamResult, 1)
+ go func() {
+ resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiImagesHandlerType, model, imageReq, "")
+ resultChan <- imageStreamResult{resp: resp, upstreamHeaders: upstreamHeaders, errMsg: errMsg}
+ }()
+
+ keepAlive, keepAliveC := h.newImagesStreamKeepAliveTicker()
+ stopKeepAlive := func() {
+ if keepAlive != nil {
+ keepAlive.Stop()
+ keepAlive = nil
+ keepAliveC = nil
+ }
+ }
+ defer stopKeepAlive()
+ streamStarted := false
+ writeError := func(errMsg *interfaces.ErrorMessage) {
+ if streamStarted {
+ writeImagesStreamErrorEvent(c, errMsg)
+ flusher.Flush()
+ } else {
+ h.WriteErrorResponse(c, errMsg)
+ }
+ if errMsg != nil && errMsg.Error != nil {
cliCancel(errMsg.Error)
} else {
cliCancel(nil)
}
- return
}
- results, _, usageRaw, err := extractXAIImagesResponse(resp)
- if err != nil {
- errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err}
- h.WriteErrorResponse(c, errMsg)
- cliCancel(err)
- return
- }
+ for {
+ select {
+ case <-c.Request.Context().Done():
+ cliCancel(c.Request.Context().Err())
+ return
+ case <-keepAliveC:
+ setImagesSSEHeaders(c)
+ writeImagesStreamKeepAlive(c, flusher)
+ streamStarted = true
+ case result := <-resultChan:
+ stopKeepAlive()
+ if result.errMsg != nil {
+ writeError(result.errMsg)
+ return
+ }
- c.Header("Content-Type", "text/event-stream")
- c.Header("Cache-Control", "no-cache")
- c.Header("Connection", "keep-alive")
- c.Header("Access-Control-Allow-Origin", "*")
- handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
+ results, _, usageRaw, err := extractXAIImagesResponse(result.resp)
+ if err != nil {
+ writeError(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err})
+ return
+ }
- eventName := streamPrefix + ".completed"
- responseFormat = normalizeImagesResponseFormat(responseFormat)
- for _, img := range results {
- data := []byte(`{"type":""}`)
- data, _ = sjson.SetBytes(data, "type", eventName)
- if responseFormat == "url" {
- if img.URL != "" {
- data, _ = sjson.SetBytes(data, "url", img.URL)
- } else {
- data, _ = sjson.SetBytes(data, "url", "data:"+mimeTypeFromOutputFormat(img.MimeType)+";base64,"+img.B64JSON)
+ setImagesSSEHeaders(c)
+ handlers.WriteUpstreamHeaders(c.Writer.Header(), result.upstreamHeaders)
+
+ eventName := streamPrefix + ".completed"
+ responseFormat = normalizeImagesResponseFormat(responseFormat)
+ for _, img := range results {
+ data := []byte(`{"type":""}`)
+ data, _ = sjson.SetBytes(data, "type", eventName)
+ if responseFormat == "url" {
+ if img.URL != "" {
+ data, _ = sjson.SetBytes(data, "url", img.URL)
+ } else {
+ data, _ = sjson.SetBytes(data, "url", "data:"+mimeTypeFromOutputFormat(img.MimeType)+";base64,"+img.B64JSON)
+ }
+ } else if img.B64JSON != "" {
+ data, _ = sjson.SetBytes(data, "b64_json", img.B64JSON)
+ } else {
+ data, _ = sjson.SetBytes(data, "url", img.URL)
+ }
+ if len(usageRaw) > 0 && json.Valid(usageRaw) {
+ data, _ = sjson.SetRawBytes(data, "usage", usageRaw)
+ }
+ if strings.TrimSpace(eventName) != "" {
+ _, _ = fmt.Fprintf(c.Writer, "event: %s\n", eventName)
+ }
+ _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
+ flusher.Flush()
+ streamStarted = true
}
- } else if img.B64JSON != "" {
- data, _ = sjson.SetBytes(data, "b64_json", img.B64JSON)
- } else {
- data, _ = sjson.SetBytes(data, "url", img.URL)
- }
- if len(usageRaw) > 0 && json.Valid(usageRaw) {
- data, _ = sjson.SetRawBytes(data, "usage", usageRaw)
- }
- if strings.TrimSpace(eventName) != "" {
- _, _ = fmt.Fprintf(c.Writer, "event: %s\n", eventName)
+ cliCancel(nil)
+ return
}
- _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
- flusher.Flush()
}
- cliCancel(nil)
}
func (h *OpenAIAPIHandler) collectImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string) {
@@ -1593,14 +1739,26 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe
if mainModel == "" {
mainModel = defaultImagesMainModel
}
- dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", mainModel, responsesReq, "")
-
- setSSEHeaders := func() {
- c.Header("Content-Type", "text/event-stream")
- c.Header("Cache-Control", "no-cache")
- c.Header("Connection", "keep-alive")
- c.Header("Access-Control-Allow-Origin", "*")
+ execution, streamStarted, canceled := h.waitImagesStreamExecution(c, flusher, func() imagesStreamExecutionResult {
+ dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", mainModel, responsesReq, "")
+ return imagesStreamExecutionResult{Data: dataChan, UpstreamHeaders: upstreamHeaders, Errs: errChan}
+ })
+ if canceled {
+ cliCancel(c.Request.Context().Err())
+ return
+ }
+ dataChan := execution.Data
+ upstreamHeaders := execution.UpstreamHeaders
+ errChan := execution.Errs
+ keepAlive, keepAliveC := h.newImagesStreamKeepAliveTicker()
+ stopKeepAlive := func() {
+ if keepAlive != nil {
+ keepAlive.Stop()
+ keepAlive = nil
+ keepAliveC = nil
+ }
}
+ defer stopKeepAlive()
writeEvent := func(eventName string, dataJSON []byte) {
if strings.TrimSpace(eventName) != "" {
@@ -1610,7 +1768,7 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe
flusher.Flush()
}
- // Peek for first chunk/error so we can still return a JSON error body.
+ // Peek for the first chunk/error while still allowing configured SSE heartbeats.
for {
select {
case <-c.Request.Context().Done():
@@ -1621,7 +1779,12 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe
errChan = nil
continue
}
- h.WriteErrorResponse(c, errMsg)
+ if streamStarted {
+ writeImagesStreamErrorEvent(c, errMsg)
+ flusher.Flush()
+ } else {
+ h.WriteErrorResponse(c, errMsg)
+ }
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
@@ -1630,7 +1793,8 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe
return
case chunk, ok := <-dataChan:
if !ok {
- setSSEHeaders()
+ stopKeepAlive()
+ setImagesSSEHeaders(c)
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write([]byte("\n"))
flusher.Flush()
@@ -1638,11 +1802,17 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe
return
}
- setSSEHeaders()
+ stopKeepAlive()
+ setImagesSSEHeaders(c)
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
h.forwardImagesStream(cliCtx, c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan, chunk, responseFormat, streamPrefix, writeEvent)
return
+ case <-keepAliveC:
+ setImagesSSEHeaders(c)
+ handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
+ writeImagesStreamKeepAlive(c, flusher)
+ streamStarted = true
}
}
}
@@ -1654,21 +1824,16 @@ func (h *OpenAIAPIHandler) forwardImagesStream(ctx context.Context, c *gin.Conte
if responseFormat == "" {
responseFormat = "b64_json"
}
+ keepAlive, keepAliveC := h.newImagesStreamKeepAliveTicker()
+ defer func() {
+ if keepAlive != nil {
+ keepAlive.Stop()
+ }
+ }()
emitError := func(errMsg *interfaces.ErrorMessage) {
- if errMsg == nil {
- return
- }
- status := http.StatusInternalServerError
- if errMsg.StatusCode > 0 {
- status = errMsg.StatusCode
- }
- errText := http.StatusText(status)
- if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" {
- errText = errMsg.Error.Error()
- }
- body := handlers.BuildErrorResponseBody(status, errText)
- writeEvent("error", body)
+ writeImagesStreamErrorEvent(c, errMsg)
+ flusher.Flush()
}
processFrame := func(frame []byte) (done bool) {
@@ -1768,6 +1933,8 @@ func (h *OpenAIAPIHandler) forwardImagesStream(ctx context.Context, c *gin.Conte
return
}
}
+ case <-keepAliveC:
+ writeImagesStreamKeepAlive(c, flusher)
}
}
}
From de280d993d08a1612ee96b969988cd741ca3b71f Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Wed, 27 May 2026 01:01:57 +0800
Subject: [PATCH 068/248] feat(websockets): refine incremental repair logic for
tool call responses
- Updated WebSocket response repair tests to validate incremental preservation of response calls and outputs.
- Added new test cases for custom tool responses ensuring accurate handling of output cache and call cache.
- Refactored `repairResponsesWebsocketToolCallsWithCaches` to handle orphan outputs more consistently.
- Adjusted input filtering logic for clearer incremental repair behavior.
Closes: #3569
---
.../openai/openai_responses_websocket_test.go | 65 ++++++++++++++++---
...nai_responses_websocket_toolcall_repair.go | 15 +++--
2 files changed, 66 insertions(+), 14 deletions(-)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go
index 8b945b50cd1..d37c783db32 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go
@@ -691,7 +691,7 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForOrphanOutput(t *te
}
}
-func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForPreviousResponseOutput(t *testing.T) {
+func TestRepairResponsesWebsocketToolCallsKeepsPreviousResponseOutputIncremental(t *testing.T) {
outputCache := newWebsocketToolOutputCache(time.Minute, 10)
callCache := newWebsocketToolOutputCache(time.Minute, 10)
sessionKey := "session-1"
@@ -705,17 +705,39 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForPreviousResponseOu
t.Fatalf("previous_response_id = %q, want resp-latest", got)
}
input := gjson.GetBytes(repaired, "input").Array()
- if len(input) != 3 {
- t.Fatalf("repaired input len = %d, want 3: %s", len(input), repaired)
+ if len(input) != 2 {
+ t.Fatalf("repaired input len = %d, want 2: %s", len(input), repaired)
}
- if input[0].Get("type").String() != "function_call" || input[0].Get("call_id").String() != "call-1" {
- t.Fatalf("missing inserted call: %s", input[0].Raw)
+ if input[0].Get("type").String() != "function_call_output" || input[0].Get("call_id").String() != "call-1" {
+ t.Fatalf("unexpected output item: %s", input[0].Raw)
}
- if input[1].Get("type").String() != "function_call_output" || input[1].Get("call_id").String() != "call-1" {
- t.Fatalf("unexpected output item: %s", input[1].Raw)
+ if input[1].Get("type").String() != "message" || input[1].Get("id").String() != "msg-1" {
+ t.Fatalf("unexpected trailing item: %s", input[1].Raw)
}
- if input[2].Get("type").String() != "message" || input[2].Get("id").String() != "msg-1" {
- t.Fatalf("unexpected trailing item: %s", input[2].Raw)
+}
+
+func TestRepairResponsesWebsocketToolCallsKeepsPreviousResponseCallIncremental(t *testing.T) {
+ outputCache := newWebsocketToolOutputCache(time.Minute, 10)
+ callCache := newWebsocketToolOutputCache(time.Minute, 10)
+ sessionKey := "session-1"
+
+ outputCache.record(sessionKey, "call-1", []byte(`{"type":"function_call_output","call_id":"call-1","id":"tool-out-1","output":"ok"}`))
+
+ raw := []byte(`{"previous_response_id":"resp-latest","input":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"tool"},{"type":"message","id":"msg-1"}]}`)
+ repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
+
+ if got := gjson.GetBytes(repaired, "previous_response_id").String(); got != "resp-latest" {
+ t.Fatalf("previous_response_id = %q, want resp-latest", got)
+ }
+ input := gjson.GetBytes(repaired, "input").Array()
+ if len(input) != 2 {
+ t.Fatalf("repaired input len = %d, want 2: %s", len(input), repaired)
+ }
+ if input[0].Get("type").String() != "function_call" || input[0].Get("call_id").String() != "call-1" {
+ t.Fatalf("unexpected call item: %s", input[0].Raw)
+ }
+ if input[1].Get("type").String() != "message" || input[1].Get("id").String() != "msg-1" {
+ t.Fatalf("unexpected trailing item: %s", input[1].Raw)
}
}
@@ -805,6 +827,31 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCustomToolCallForOrphanOu
}
}
+func TestRepairResponsesWebsocketToolCallsKeepsPreviousResponseCustomToolOutputIncremental(t *testing.T) {
+ outputCache := newWebsocketToolOutputCache(time.Minute, 10)
+ callCache := newWebsocketToolOutputCache(time.Minute, 10)
+ sessionKey := "session-1"
+
+ callCache.record(sessionKey, "call-1", []byte(`{"type":"custom_tool_call","call_id":"call-1","name":"apply_patch"}`))
+
+ raw := []byte(`{"previous_response_id":"resp-latest","input":[{"type":"custom_tool_call_output","call_id":"call-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
+ repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
+
+ if got := gjson.GetBytes(repaired, "previous_response_id").String(); got != "resp-latest" {
+ t.Fatalf("previous_response_id = %q, want resp-latest", got)
+ }
+ input := gjson.GetBytes(repaired, "input").Array()
+ if len(input) != 2 {
+ t.Fatalf("repaired input len = %d, want 2: %s", len(input), repaired)
+ }
+ if input[0].Get("type").String() != "custom_tool_call_output" || input[0].Get("call_id").String() != "call-1" {
+ t.Fatalf("unexpected output item: %s", input[0].Raw)
+ }
+ if input[1].Get("type").String() != "message" || input[1].Get("id").String() != "msg-1" {
+ t.Fatalf("unexpected trailing item: %s", input[1].Raw)
+ }
+}
+
func TestRepairResponsesWebsocketToolCallsDropsOrphanCustomToolOutputWhenCallMissing(t *testing.T) {
outputCache := newWebsocketToolOutputCache(time.Minute, 10)
callCache := newWebsocketToolOutputCache(time.Minute, 10)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
index c521bec0490..22219a8ab9a 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
@@ -305,6 +305,11 @@ func repairResponsesToolCallsArray(outputCache, callCache *websocketToolOutputCa
continue
}
+ if allowOrphanOutputs {
+ filtered = append(filtered, item)
+ continue
+ }
+
if callCache != nil {
if cached, ok := callCache.get(sessionKey, callID); ok {
if _, already := insertedCalls[callID]; !already {
@@ -317,11 +322,6 @@ func repairResponsesToolCallsArray(outputCache, callCache *websocketToolOutputCa
}
}
- if allowOrphanOutputs {
- filtered = append(filtered, item)
- continue
- }
-
// Drop orphaned function_call_output items; upstream rejects transcripts with missing calls.
continue
}
@@ -341,6 +341,11 @@ func repairResponsesToolCallsArray(outputCache, callCache *websocketToolOutputCa
continue
}
+ if allowOrphanOutputs {
+ filtered = append(filtered, item)
+ continue
+ }
+
if cached, ok := outputCache.get(sessionKey, callID); ok {
filtered = append(filtered, item)
filtered = append(filtered, cached)
From 2cbb8c7b5c77fb5e29de498cac490e628fcd2cad Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Wed, 27 May 2026 01:28:04 +0800
Subject: [PATCH 069/248] fix(translator): correct JSON path for item summary
in response event
- Updated `response.output_item.done` to use `item.summary.0.text` instead of `item.summary.text`.
---
.../openai/openai/responses/openai_openai-responses_response.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go
index 8895b684452..b15feb77480 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go
@@ -341,7 +341,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
outputItemDone, _ = sjson.SetBytes(outputItemDone, "sequence_number", nextSeq())
outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.id", st.ReasoningID)
outputItemDone, _ = sjson.SetBytes(outputItemDone, "output_index", st.ReasoningIndex)
- outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.text", text)
+ outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.0.text", text)
out = append(out, emitRespEvent("response.output_item.done", outputItemDone))
st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex})
From 4b681031bff2acd922c51c34786f20b505b62fd4 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Wed, 27 May 2026 02:10:58 +0800
Subject: [PATCH 070/248] feat(translator): add reasoning signature handling
and tests for Claude-OpenAI conversions
- Introduced support for processing `encrypted_content` reasoning signatures in request and response translations.
- Updated `ConvertOpenAIResponsesRequestToClaude` and `ConvertClaudeResponseToOpenAIResponses` to handle reasoning signatures and summaries.
- Added tests to validate signature preservation and correct reasoning content transformation in both streaming and non-streaming scenarios.
- Refactored processing logic to ensure reasoning content flushing before user messages.
---
.../claude_openai-responses_request.go | 70 +++++++++++-
.../claude_openai-responses_request_test.go | 93 ++++++++++++++++
.../claude_openai-responses_response.go | 59 ++++++++--
.../claude_openai-responses_response_test.go | 101 ++++++++++++++++++
4 files changed, 315 insertions(+), 8 deletions(-)
create mode 100644 internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
create mode 100644 internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
index 1398749573e..2208688b0fb 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
@@ -168,6 +168,19 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
// input array processing
+ var pendingReasoningParts []string
+ flushPendingReasoning := func() {
+ if len(pendingReasoningParts) == 0 {
+ return
+ }
+ asst := []byte(`{"role":"assistant","content":[]}`)
+ for _, partJSON := range pendingReasoningParts {
+ asst, _ = sjson.SetRawBytes(asst, "content.-1", []byte(partJSON))
+ }
+ out, _ = sjson.SetRawBytes(out, "messages.-1", asst)
+ pendingReasoningParts = nil
+ }
+
if input := root.Get("input"); input.Exists() && input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
if extractedFromSystem && strings.EqualFold(item.Get("role").String(), "system") {
@@ -279,10 +292,26 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
}
+ hasReasoningParts := false
+ if len(pendingReasoningParts) > 0 {
+ if role == "assistant" {
+ if len(partsJSON) == 0 && textAggregate.Len() > 0 {
+ contentPart := []byte(`{"type":"text","text":""}`)
+ contentPart, _ = sjson.SetBytes(contentPart, "text", textAggregate.String())
+ partsJSON = append(partsJSON, string(contentPart))
+ }
+ partsJSON = append(append([]string{}, pendingReasoningParts...), partsJSON...)
+ pendingReasoningParts = nil
+ hasReasoningParts = true
+ } else {
+ flushPendingReasoning()
+ }
+ }
+
if len(partsJSON) > 0 {
msg := []byte(`{"role":"","content":[]}`)
msg, _ = sjson.SetBytes(msg, "role", role)
- if len(partsJSON) == 1 && !hasImage && !hasFile {
+ if len(partsJSON) == 1 && !hasImage && !hasFile && !hasReasoningParts {
// Preserve legacy behavior for single text content
msg, _ = sjson.DeleteBytes(msg, "content")
textPart := gjson.Parse(partsJSON[0])
@@ -300,6 +329,11 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
}
+ case "reasoning":
+ if thinkingPart := convertResponsesReasoningToClaudeThinking(item); len(thinkingPart) > 0 {
+ pendingReasoningParts = append(pendingReasoningParts, string(thinkingPart))
+ }
+
case "function_call":
// Map to assistant tool_use
callID := item.Get("call_id").String()
@@ -320,10 +354,15 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
asst := []byte(`{"role":"assistant","content":[]}`)
+ for _, partJSON := range pendingReasoningParts {
+ asst, _ = sjson.SetRawBytes(asst, "content.-1", []byte(partJSON))
+ }
+ pendingReasoningParts = nil
asst, _ = sjson.SetRawBytes(asst, "content.-1", toolUse)
out, _ = sjson.SetRawBytes(out, "messages.-1", asst)
case "function_call_output":
+ flushPendingReasoning()
// Map to user tool_result
callID := item.Get("call_id").String()
outputStr := item.Get("output").String()
@@ -338,6 +377,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
return true
})
}
+ flushPendingReasoning()
includedToolNames := map[string]struct{}{}
toolNameMap := map[string]string{}
@@ -398,6 +438,34 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
return out
}
+func convertResponsesReasoningToClaudeThinking(item gjson.Result) []byte {
+ signature := item.Get("encrypted_content").String()
+ if signature == "" {
+ return nil
+ }
+
+ thinkingText := responsesReasoningSummaryText(item)
+ thinkingPart := []byte(`{"type":"thinking","thinking":"","signature":""}`)
+ thinkingPart, _ = sjson.SetBytes(thinkingPart, "thinking", thinkingText)
+ thinkingPart, _ = sjson.SetBytes(thinkingPart, "signature", signature)
+ return thinkingPart
+}
+
+func responsesReasoningSummaryText(item gjson.Result) string {
+ var builder strings.Builder
+ if summary := item.Get("summary"); summary.Exists() && summary.IsArray() {
+ summary.ForEach(func(_, part gjson.Result) bool {
+ if text := part.Get("text"); text.Exists() {
+ builder.WriteString(text.String())
+ } else if part.Type == gjson.String {
+ builder.WriteString(part.String())
+ }
+ return true
+ })
+ }
+ return builder.String()
+}
+
func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
toolType := strings.TrimSpace(tool.Get("type").String())
switch toolType {
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
new file mode 100644
index 00000000000..cb867e05e76
--- /dev/null
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
@@ -0,0 +1,93 @@
+package responses
+
+import (
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *testing.T) {
+ signature := "claude_sig_request"
+ raw := []byte(`{
+ "model":"claude-test",
+ "input":[
+ {
+ "type":"reasoning",
+ "encrypted_content":"` + signature + `",
+ "summary":[{"type":"summary_text","text":"internal reasoning"}]
+ },
+ {
+ "type":"message",
+ "role":"assistant",
+ "content":[{"type":"output_text","text":"visible answer"}]
+ },
+ {
+ "type":"message",
+ "role":"user",
+ "content":[{"type":"input_text","text":"continue"}]
+ }
+ ]
+ }`)
+
+ out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
+ root := gjson.ParseBytes(out)
+
+ assistant := root.Get("messages.0")
+ if got := assistant.Get("role").String(); got != "assistant" {
+ t.Fatalf("first message role = %q, want assistant. Output: %s", got, string(out))
+ }
+ if got := assistant.Get("content.0.type").String(); got != "thinking" {
+ t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out))
+ }
+ if got := assistant.Get("content.0.signature").String(); got != signature {
+ t.Fatalf("thinking signature = %q, want %q", got, signature)
+ }
+ if got := assistant.Get("content.0.thinking").String(); got != "internal reasoning" {
+ t.Fatalf("thinking text = %q, want internal reasoning", got)
+ }
+ if got := assistant.Get("content.1.type").String(); got != "text" {
+ t.Fatalf("second content type = %q, want text. Output: %s", got, string(out))
+ }
+ if got := assistant.Get("content.1.text").String(); got != "visible answer" {
+ t.Fatalf("assistant text = %q, want visible answer", got)
+ }
+ if got := root.Get("messages.1.role").String(); got != "user" {
+ t.Fatalf("second message role = %q, want user. Output: %s", got, string(out))
+ }
+}
+
+func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBeforeUser(t *testing.T) {
+ signature := "claude_sig_only"
+ raw := []byte(`{
+ "model":"claude-test",
+ "input":[
+ {
+ "type":"reasoning",
+ "encrypted_content":"` + signature + `",
+ "summary":[]
+ },
+ {
+ "type":"message",
+ "role":"user",
+ "content":[{"type":"input_text","text":"continue"}]
+ }
+ ]
+ }`)
+
+ out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
+ root := gjson.ParseBytes(out)
+
+ thinking := root.Get("messages.0.content.0")
+ if got := thinking.Get("type").String(); got != "thinking" {
+ t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out))
+ }
+ if got := thinking.Get("signature").String(); got != signature {
+ t.Fatalf("thinking signature = %q, want %q", got, signature)
+ }
+ if got := thinking.Get("thinking").String(); got != "" {
+ t.Fatalf("thinking text = %q, want empty", got)
+ }
+ if got := root.Get("messages.1.role").String(); got != "user" {
+ t.Fatalf("second message role = %q, want user. Output: %s", got, string(out))
+ }
+}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
index 6c6b96b30d3..6cf8180915a 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
@@ -32,6 +32,7 @@ type claudeToResponsesState struct {
ReasoningActive bool
ReasoningItemID string
ReasoningBuf strings.Builder
+ ReasoningSignature string
ReasoningPartAdded bool
ReasoningIndex int
// usage aggregation
@@ -89,6 +90,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.CurrentMsgID = ""
st.CurrentFCID = ""
st.ReasoningItemID = ""
+ st.ReasoningSignature = ""
st.ReasoningIndex = 0
st.ReasoningPartAdded = false
st.FuncArgsBuf = make(map[int]*strings.Builder)
@@ -163,11 +165,16 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.ReasoningActive = true
st.ReasoningIndex = idx
st.ReasoningBuf.Reset()
+ st.ReasoningSignature = ""
+ if signature := cb.Get("signature"); signature.Exists() && signature.String() != "" {
+ st.ReasoningSignature = signature.String()
+ }
st.ReasoningItemID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
+ item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
item, _ = sjson.SetBytes(item, "output_index", idx)
item, _ = sjson.SetBytes(item, "item.id", st.ReasoningItemID)
+ item, _ = sjson.SetBytes(item, "item.encrypted_content", st.ReasoningSignature)
out = append(out, emitEvent("response.output_item.added", item))
// add a summary part placeholder
part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
@@ -220,6 +227,12 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
}
}
+ } else if dt == "signature_delta" {
+ if st.ReasoningActive {
+ if signature := d.Get("signature"); signature.Exists() && signature.String() != "" {
+ st.ReasoningSignature = signature.String()
+ }
+ }
}
case "content_block_stop":
idx := int(root.Get("index").Int())
@@ -277,6 +290,17 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
partDone, _ = sjson.SetBytes(partDone, "part.text", full)
out = append(out, emitEvent("response.reasoning_summary_part.done", partDone))
+ itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[]}}`)
+ itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
+ itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID)
+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex)
+ itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", st.ReasoningSignature)
+ if full != "" {
+ summary := []byte(`{"type":"summary_text","text":""}`)
+ summary, _ = sjson.SetBytes(summary, "text", full)
+ itemDone, _ = sjson.SetRawBytes(itemDone, "item.summary.-1", summary)
+ }
+ out = append(out, emitEvent("response.output_item.done", itemDone))
st.ReasoningActive = false
st.ReasoningPartAdded = false
}
@@ -367,10 +391,15 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
// Build response.output from aggregated state
outputsWrapper := []byte(`{"arr":[]}`)
// reasoning item (if any)
- if st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded {
- item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
+ if st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded || st.ReasoningSignature != "" {
+ item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
item, _ = sjson.SetBytes(item, "id", st.ReasoningItemID)
- item, _ = sjson.SetBytes(item, "summary.0.text", st.ReasoningBuf.String())
+ item, _ = sjson.SetBytes(item, "encrypted_content", st.ReasoningSignature)
+ if st.ReasoningBuf.Len() > 0 {
+ summary := []byte(`{"type":"summary_text","text":""}`)
+ summary, _ = sjson.SetBytes(summary, "text", st.ReasoningBuf.String())
+ item, _ = sjson.SetRawBytes(item, "summary.-1", summary)
+ }
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
}
// assistant message item (if any text)
@@ -476,6 +505,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
reasoningBuf strings.Builder
reasoningActive bool
reasoningItemID string
+ reasoningSig string
inputTokens int64
outputTokens int64
)
@@ -525,6 +555,10 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
case "thinking":
reasoningActive = true
reasoningItemID = fmt.Sprintf("rs_%s_%d", responseID, idx)
+ reasoningSig = ""
+ if signature := cb.Get("signature"); signature.Exists() && signature.String() != "" {
+ reasoningSig = signature.String()
+ }
}
case "content_block_delta":
@@ -552,6 +586,12 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
reasoningBuf.WriteString(t.String())
}
}
+ case "signature_delta":
+ if reasoningActive {
+ if signature := d.Get("signature"); signature.Exists() && signature.String() != "" {
+ reasoningSig = signature.String()
+ }
+ }
}
case "content_block_stop":
@@ -637,10 +677,15 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
// Build output array
outputsWrapper := []byte(`{"arr":[]}`)
- if reasoningBuf.Len() > 0 {
- item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
+ if reasoningBuf.Len() > 0 || reasoningSig != "" {
+ item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
item, _ = sjson.SetBytes(item, "id", reasoningItemID)
- item, _ = sjson.SetBytes(item, "summary.0.text", reasoningBuf.String())
+ item, _ = sjson.SetBytes(item, "encrypted_content", reasoningSig)
+ if reasoningBuf.Len() > 0 {
+ summary := []byte(`{"type":"summary_text","text":""}`)
+ summary, _ = sjson.SetBytes(summary, "text", reasoningBuf.String())
+ item, _ = sjson.SetRawBytes(item, "summary.-1", summary)
+ }
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
}
if currentMsgID != "" || textBuf.Len() > 0 {
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
new file mode 100644
index 00000000000..8161d0b2910
--- /dev/null
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
@@ -0,0 +1,101 @@
+package responses
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func parseClaudeResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) {
+ t.Helper()
+
+ var event string
+ var data string
+ for _, line := range strings.Split(string(chunk), "\n") {
+ if strings.HasPrefix(line, "event: ") {
+ event = strings.TrimPrefix(line, "event: ")
+ continue
+ }
+ if strings.HasPrefix(line, "data: ") {
+ data = strings.TrimPrefix(line, "data: ")
+ }
+ }
+ if data == "" {
+ t.Fatalf("SSE chunk has no data line: %s", string(chunk))
+ }
+
+ return event, gjson.Parse(data)
+}
+
+func TestConvertClaudeResponseToOpenAIResponses_ThinkingIncludesSignature(t *testing.T) {
+ signature := "claude_sig_123"
+ chunks := [][]byte{
+ []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"output_tokens":0}}}`),
+ []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`),
+ []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"internal "}}`),
+ []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"reasoning"}}`),
+ []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + signature + `"}}`),
+ []byte(`data: {"type":"content_block_stop","index":0}`),
+ []byte(`data: {"type":"message_stop"}`),
+ }
+
+ var param any
+ var outputs [][]byte
+ for _, chunk := range chunks {
+ outputs = append(outputs, ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", nil, nil, chunk, ¶m)...)
+ }
+
+ var reasoningDone gjson.Result
+ var completed gjson.Result
+ for _, output := range outputs {
+ event, data := parseClaudeResponsesSSEEvent(t, output)
+ switch event {
+ case "response.output_item.done":
+ if data.Get("item.type").String() == "reasoning" {
+ reasoningDone = data
+ }
+ case "response.completed":
+ completed = data
+ }
+ }
+
+ if !reasoningDone.Exists() {
+ t.Fatal("expected reasoning output_item.done event")
+ }
+ if got := reasoningDone.Get("item.encrypted_content").String(); got != signature {
+ t.Fatalf("reasoning encrypted_content = %q, want %q", got, signature)
+ }
+ if got := reasoningDone.Get("item.summary.0.text").String(); got != "internal reasoning" {
+ t.Fatalf("reasoning summary text = %q", got)
+ }
+ if got := completed.Get("response.output.0.encrypted_content").String(); got != signature {
+ t.Fatalf("completed reasoning encrypted_content = %q, want %q", got, signature)
+ }
+ if got := completed.Get("response.output.0.summary.0.text").String(); got != "internal reasoning" {
+ t.Fatalf("completed reasoning summary text = %q", got)
+ }
+}
+
+func TestConvertClaudeResponseToOpenAIResponsesNonStream_ThinkingIncludesSignature(t *testing.T) {
+ signature := "claude_sig_nonstream"
+ raw := []byte(strings.Join([]string{
+ `data: {"type":"message_start","message":{"id":"msg_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`,
+ `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`,
+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"nonstream reasoning"}}`,
+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + signature + `"}}`,
+ `data: {"type":"content_block_stop","index":0}`,
+ `data: {"type":"message_stop"}`,
+ }, "\n"))
+
+ out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, raw, nil)
+ root := gjson.ParseBytes(out)
+
+ if got := root.Get("output.0.encrypted_content").String(); got != signature {
+ t.Fatalf("non-stream reasoning encrypted_content = %q, want %q", got, signature)
+ }
+ if got := root.Get("output.0.summary.0.text").String(); got != "nonstream reasoning" {
+ t.Fatalf("non-stream reasoning summary text = %q", got)
+ }
+}
From 11f0f906bd687d687c61093d6efa525d9015ce73 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 28 May 2026 02:19:45 +0800
Subject: [PATCH 071/248] feat(logging): add `SetTranslatedReasoningEffort` to
track reasoning levels in usage reporting
- Introduced `SetTranslatedReasoningEffort` method in `UsageReporter` to capture and log reasoning efforts from translated payloads.
- Updated executors to incorporate the new reporting functionality for handling reasoning efforts across various providers.
- Enhanced logging for thinking level extraction with new helper function `ExtractTranslatedReasoningEffort`.
---
internal/runtime/executor/aistudio_executor.go | 2 ++
.../runtime/executor/antigravity_executor.go | 3 +++
internal/runtime/executor/claude_executor.go | 2 ++
internal/runtime/executor/codex_executor.go | 3 +++
.../runtime/executor/codex_openai_images.go | 2 ++
.../executor/codex_websockets_executor.go | 2 ++
.../runtime/executor/gemini_cli_executor.go | 2 ++
internal/runtime/executor/gemini_executor.go | 2 ++
.../runtime/executor/gemini_vertex_executor.go | 4 ++++
.../runtime/executor/helps/usage_helpers.go | 8 ++++++++
internal/runtime/executor/kimi_executor.go | 2 ++
.../runtime/executor/openai_compat_executor.go | 4 ++++
internal/runtime/executor/xai_executor.go | 2 ++
internal/thinking/apply.go | 17 +++++++++++++++++
sdk/cliproxy/usage/manager.go | 2 +-
15 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go
index 97c217e7154..ad15114a393 100644
--- a/internal/runtime/executor/aistudio_executor.go
+++ b/internal/runtime/executor/aistudio_executor.go
@@ -135,6 +135,7 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
if err != nil {
return resp, err
}
+ reporter.SetTranslatedReasoningEffort(body.payload, body.toFormat.String())
endpoint := e.buildEndpoint(baseModel, body.action, opts.Alt)
wsReq := &wsrelay.HTTPRequest{
@@ -199,6 +200,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
if err != nil {
return nil, err
}
+ reporter.SetTranslatedReasoningEffort(body.payload, body.toFormat.String())
endpoint := e.buildEndpoint(baseModel, body.action, opts.Alt)
wsReq := &wsrelay.HTTPRequest{
diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go
index 5527bece9e5..77f840cb137 100644
--- a/internal/runtime/executor/antigravity_executor.go
+++ b/internal/runtime/executor/antigravity_executor.go
@@ -523,6 +523,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
+ reporter.SetTranslatedReasoningEffort(translated, to.String())
useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
@@ -721,6 +722,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
+ reporter.SetTranslatedReasoningEffort(translated, to.String())
useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
@@ -1182,6 +1184,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
+ reporter.SetTranslatedReasoningEffort(translated, to.String())
useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 9450de88d74..8d8ea4dbfbd 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -200,6 +200,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
}
+ reporter.SetTranslatedReasoningEffort(bodyForUpstream, to.String())
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
@@ -374,6 +375,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
}
+ reporter.SetTranslatedReasoningEffort(bodyForUpstream, to.String())
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index 3db2100f9ca..317bc4d257e 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -285,6 +285,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
@@ -441,6 +442,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
@@ -542,6 +544,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go
index 142971118a4..211f89357a8 100644
--- a/internal/runtime/executor/codex_openai_images.go
+++ b/internal/runtime/executor/codex_openai_images.go
@@ -96,6 +96,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
if errBuild != nil {
return resp, errBuild
}
+ reporter.SetTranslatedReasoningEffort(body, "codex")
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)
@@ -184,6 +185,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
if errBuild != nil {
return nil, errBuild
}
+ reporter.SetTranslatedReasoningEffort(body, "codex")
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index 6400c07a9cf..e3ce9ce0cdf 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -221,6 +221,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
+ reporter.SetTranslatedReasoningEffort(body, to.String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
var authID, authLabel, authType, authValue string
@@ -421,6 +422,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
}
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
+ reporter.SetTranslatedReasoningEffort(body, to.String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
var authID, authLabel, authType, authValue string
diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go
index 95fcd9e0c88..da444040038 100644
--- a/internal/runtime/executor/gemini_cli_executor.go
+++ b/internal/runtime/executor/gemini_cli_executor.go
@@ -142,6 +142,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
requestPath := helps.PayloadRequestPath(opts)
basePayload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "gemini", from.String(), "request", basePayload, originalTranslated, requestedModel, requestPath, opts.Headers)
basePayload = cleanGeminiCLIRequestSchemas(basePayload)
+ reporter.SetTranslatedReasoningEffort(basePayload, to.String())
action := "generateContent"
if req.Metadata != nil {
@@ -299,6 +300,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
requestPath := helps.PayloadRequestPath(opts)
basePayload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "gemini", from.String(), "request", basePayload, originalTranslated, requestedModel, requestPath, opts.Headers)
basePayload = cleanGeminiCLIRequestSchemas(basePayload)
+ reporter.SetTranslatedReasoningEffort(basePayload, to.String())
projectID := resolveGeminiProjectID(auth)
diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go
index 4046c8ea0ff..99c06dbdc24 100644
--- a/internal/runtime/executor/gemini_executor.go
+++ b/internal/runtime/executor/gemini_executor.go
@@ -151,6 +151,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
}
body, _ = sjson.DeleteBytes(body, "session_id")
+ reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -256,6 +257,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
body, _ = sjson.DeleteBytes(body, "session_id")
+ reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go
index 6e7e2965d54..98e46221bcb 100644
--- a/internal/runtime/executor/gemini_vertex_executor.go
+++ b/internal/runtime/executor/gemini_vertex_executor.go
@@ -356,6 +356,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
body, _ = sjson.DeleteBytes(body, "session_id")
+ reporter.SetTranslatedReasoningEffort(body, "gemini")
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
@@ -481,6 +482,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
body, _ = sjson.DeleteBytes(body, "session_id")
+ reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
@@ -589,6 +591,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
}
}
body, _ = sjson.DeleteBytes(body, "session_id")
+ reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
@@ -734,6 +737,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
}
}
body, _ = sjson.DeleteBytes(body, "session_id")
+ reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go
index f6958221c58..82f82a4407c 100644
--- a/internal/runtime/executor/helps/usage_helpers.go
+++ b/internal/runtime/executor/helps/usage_helpers.go
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/tidwall/gjson"
@@ -66,6 +67,13 @@ func (r *UsageReporter) PublishAdditionalModel(ctx context.Context, model string
r.publishRecord(ctx, record)
}
+func (r *UsageReporter) SetTranslatedReasoningEffort(payload []byte, format string) {
+ if r == nil {
+ return
+ }
+ r.reasoning = thinking.ExtractTranslatedReasoningEffort(payload, format)
+}
+
func (r *UsageReporter) buildAdditionalModelRecord(model string, detail usage.Detail) (usage.Record, bool) {
if r == nil {
return usage.Record{}, false
diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go
index 69cf7218796..15421582354 100644
--- a/internal/runtime/executor/kimi_executor.go
+++ b/internal/runtime/executor/kimi_executor.go
@@ -114,6 +114,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
if err != nil {
return resp, err
}
+ reporter.SetTranslatedReasoningEffort(body, e.Identifier())
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
@@ -224,6 +225,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
if err != nil {
return nil, err
}
+ reporter.SetTranslatedReasoningEffort(body, e.Identifier())
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index d8c46a63b36..24aa661dde9 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -126,6 +126,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
translated = updated
}
}
+ reporter.SetTranslatedReasoningEffort(translated, to.String())
url := strings.TrimSuffix(baseURL, "/") + endpoint
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
@@ -215,6 +216,7 @@ func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxy
if contentType == "" {
contentType = "application/json"
}
+ reporter.SetTranslatedReasoningEffort(payload, "openai")
url := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
@@ -320,6 +322,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
// Request usage data in the final streaming chunk so that token statistics
// are captured even when the upstream is an OpenAI-compatible provider.
translated, _ = sjson.SetBytes(translated, "stream_options.include_usage", true)
+ reporter.SetTranslatedReasoningEffort(translated, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
@@ -469,6 +472,7 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl
if contentType == "" {
contentType = "application/json"
}
+ reporter.SetTranslatedReasoningEffort(payload, "openai")
url := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go
index ef46a131419..aabd5772d1f 100644
--- a/internal/runtime/executor/xai_executor.go
+++ b/internal/runtime/executor/xai_executor.go
@@ -116,6 +116,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
reporter := helps.NewUsageReporter(ctx, e.Identifier(), prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
+ reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
@@ -302,6 +303,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
reporter := helps.NewUsageReporter(ctx, e.Identifier(), prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
+ reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go
index 614d15ca010..3936cc9dde1 100644
--- a/internal/thinking/apply.go
+++ b/internal/thinking/apply.go
@@ -360,6 +360,23 @@ func ExtractReasoningEffort(body []byte, provider, model string) string {
return reasoningEffortFromConfig(config)
}
+// ExtractTranslatedReasoningEffort returns the final provider payload's thinking
+// setting as a canonical reasoning_effort label for usage logging.
+func ExtractTranslatedReasoningEffort(body []byte, provider string) string {
+ provider = strings.ToLower(strings.TrimSpace(provider))
+ config := extractThinkingConfig(body, provider)
+ if !hasThinkingConfig(config) {
+ switch provider {
+ case "openai", "openai-response":
+ config = extractCodexConfig(body)
+ if !hasThinkingConfig(config) {
+ config = extractOpenAIConfig(body)
+ }
+ }
+ }
+ return reasoningEffortFromConfig(config)
+}
+
func reasoningEffortFromSuffix(suffix SuffixResult) string {
if !suffix.HasSuffix {
return ""
diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go
index 1bda0188aa0..731fd8d0471 100644
--- a/sdk/cliproxy/usage/manager.go
+++ b/sdk/cliproxy/usage/manager.go
@@ -20,7 +20,7 @@ type Record struct {
AuthIndex string
AuthType string
Source string
- // ReasoningEffort stores the client-requested thinking level for request event logs.
+ // ReasoningEffort stores the translated upstream thinking level for request event logs.
ReasoningEffort string
RequestedAt time.Time
Latency time.Duration
From 94c1b25146a82d50369a69c579d8cbb1373286fc Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 28 May 2026 02:59:24 +0800
Subject: [PATCH 072/248] feat(executor): add TTFT tracking and reporting for
enhanced performance metrics
- Introduced Time-To-First-Token (TTFT) measurement and reporting across major executors.
- Added TTFT calculation to `UsageReporter`, including support for HTTP clients and WebSocket communication.
- Updated tests to validate TTFT tracking in streamed and non-streamed scenarios.
- Ensured integration with `usage` plugin and augmented usage records with TTFT data.
---
internal/redisqueue/plugin.go | 2 +
.../runtime/executor/aistudio_executor.go | 12 ++
.../executor/aistudio_executor_test.go | 138 ++++++++++++++++++
.../runtime/executor/antigravity_executor.go | 3 +
internal/runtime/executor/claude_executor.go | 2 +
internal/runtime/executor/codex_executor.go | 3 +
.../runtime/executor/codex_openai_images.go | 2 +
.../executor/codex_websockets_executor.go | 6 +
.../runtime/executor/gemini_cli_executor.go | 2 +
internal/runtime/executor/gemini_executor.go | 2 +
.../executor/gemini_vertex_executor.go | 4 +
.../runtime/executor/helps/usage_helpers.go | 124 ++++++++++++++++
.../executor/helps/usage_helpers_test.go | 44 ++++++
internal/runtime/executor/kimi_executor.go | 2 +
.../executor/openai_compat_executor.go | 4 +
internal/runtime/executor/xai_executor.go | 2 +
sdk/cliproxy/usage/manager.go | 1 +
17 files changed, 353 insertions(+)
create mode 100644 internal/runtime/executor/aistudio_executor_test.go
diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go
index eb3c8c8222a..ac48d0c1391 100644
--- a/internal/redisqueue/plugin.go
+++ b/internal/redisqueue/plugin.go
@@ -78,6 +78,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
detail := requestDetail{
Timestamp: timestamp,
LatencyMs: record.Latency.Milliseconds(),
+ TTFTMs: record.TTFT.Milliseconds(),
Source: record.Source,
AuthIndex: record.AuthIndex,
Tokens: tokens,
@@ -118,6 +119,7 @@ type queuedUsageDetail struct {
type requestDetail struct {
Timestamp time.Time `json:"timestamp"`
LatencyMs int64 `json:"latency_ms"`
+ TTFTMs int64 `json:"ttft_ms"`
Source string `json:"source"`
AuthIndex string `json:"auth_index"`
Tokens tokenStats `json:"tokens"`
diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go
index ad15114a393..0e2718c7244 100644
--- a/internal/runtime/executor/aistudio_executor.go
+++ b/internal/runtime/executor/aistudio_executor.go
@@ -168,13 +168,16 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
AuthValue: authValue,
})
+ reporter.StartResponseTTFT()
wsResp, err := e.relay.NonStream(ctx, authID, wsReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
helps.RecordAPIResponseMetadata(ctx, e.cfg, wsResp.Status, wsResp.Headers.Clone())
+ reporter.StartResponseTTFT()
if len(wsResp.Body) > 0 {
+ reporter.MarkFirstResponseByte()
helps.AppendAPIResponseChunk(ctx, e.cfg, wsResp.Body)
}
if wsResp.Status < 200 || wsResp.Status >= 300 {
@@ -231,6 +234,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
AuthType: authType,
AuthValue: authValue,
})
+ reporter.StartResponseTTFT()
wsStream, err := e.relay.Stream(ctx, authID, wsReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -246,10 +250,12 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
metadataLogged := false
if firstEvent.Status > 0 {
helps.RecordAPIResponseMetadata(ctx, e.cfg, firstEvent.Status, firstEvent.Headers.Clone())
+ reporter.StartResponseTTFT()
metadataLogged = true
}
var body bytes.Buffer
if len(firstEvent.Payload) > 0 {
+ reporter.MarkFirstResponseByte()
helps.AppendAPIResponseChunk(ctx, e.cfg, firstEvent.Payload)
body.Write(firstEvent.Payload)
}
@@ -266,9 +272,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
if !metadataLogged && event.Status > 0 {
helps.RecordAPIResponseMetadata(ctx, e.cfg, event.Status, event.Headers.Clone())
+ reporter.StartResponseTTFT()
metadataLogged = true
}
if len(event.Payload) > 0 {
+ reporter.MarkFirstResponseByte()
helps.AppendAPIResponseChunk(ctx, e.cfg, event.Payload)
body.Write(event.Payload)
}
@@ -297,10 +305,12 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
case wsrelay.MessageTypeStreamStart:
if !metadataLogged && event.Status > 0 {
helps.RecordAPIResponseMetadata(ctx, e.cfg, event.Status, event.Headers.Clone())
+ reporter.StartResponseTTFT()
metadataLogged = true
}
case wsrelay.MessageTypeStreamChunk:
if len(event.Payload) > 0 {
+ reporter.MarkFirstResponseByte()
helps.AppendAPIResponseChunk(ctx, e.cfg, event.Payload)
filtered := helps.FilterSSEUsageMetadata(event.Payload)
if detail, ok := helps.ParseGeminiStreamUsage(filtered); ok {
@@ -321,9 +331,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
case wsrelay.MessageTypeHTTPResp:
if !metadataLogged && event.Status > 0 {
helps.RecordAPIResponseMetadata(ctx, e.cfg, event.Status, event.Headers.Clone())
+ reporter.StartResponseTTFT()
metadataLogged = true
}
if len(event.Payload) > 0 {
+ reporter.MarkFirstResponseByte()
helps.AppendAPIResponseChunk(ctx, e.cfg, event.Payload)
}
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, ¶m)
diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go
new file mode 100644
index 00000000000..52ce6147a86
--- /dev/null
+++ b/internal/runtime/executor/aistudio_executor_test.go
@@ -0,0 +1,138 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
+)
+
+func TestAIStudioExecutorExecuteStartsTTFTBeforeRelayWait(t *testing.T) {
+ const authID = "aistudio-ttft-auth"
+ delay := 40 * time.Millisecond
+ connected := make(chan struct{})
+ var connectedOnce sync.Once
+ relay := wsrelay.NewManager(wsrelay.Options{
+ ProviderFactory: func(*http.Request) (string, error) {
+ return authID, nil
+ },
+ OnConnected: func(provider string) {
+ if provider == authID {
+ connectedOnce.Do(func() {
+ close(connected)
+ })
+ }
+ },
+ })
+ server := httptest.NewServer(relay.Handler())
+ defer server.Close()
+ defer func() {
+ if errStop := relay.Stop(context.Background()); errStop != nil {
+ t.Errorf("relay stop error = %v", errStop)
+ }
+ }()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + relay.Path()
+ conn, _, errDial := websocket.DefaultDialer.Dial(wsURL, nil)
+ if errDial != nil {
+ t.Fatalf("dial websocket: %v", errDial)
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Errorf("websocket close error = %v", errClose)
+ }
+ }()
+ select {
+ case <-connected:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for relay connection")
+ }
+
+ clientDone := make(chan error, 1)
+ go func() {
+ var msg wsrelay.Message
+ if errReadJSON := conn.ReadJSON(&msg); errReadJSON != nil {
+ clientDone <- fmt.Errorf("read relay request: %w", errReadJSON)
+ return
+ }
+ if msg.Type != wsrelay.MessageTypeHTTPReq {
+ clientDone <- fmt.Errorf("relay message type = %q, want %q", msg.Type, wsrelay.MessageTypeHTTPReq)
+ return
+ }
+ time.Sleep(delay)
+ response := wsrelay.Message{
+ ID: msg.ID,
+ Type: wsrelay.MessageTypeHTTPResp,
+ Payload: map[string]any{
+ "status": float64(http.StatusOK),
+ "headers": map[string]any{"Content-Type": "application/json"},
+ "body": `{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`,
+ },
+ }
+ if errWriteJSON := conn.WriteJSON(response); errWriteJSON != nil {
+ clientDone <- fmt.Errorf("write relay response: %w", errWriteJSON)
+ return
+ }
+ clientDone <- nil
+ }()
+
+ plugin := &captureAIStudioUsagePlugin{records: make(chan usage.Record, 16)}
+ usage.RegisterPlugin(plugin)
+ exec := NewAIStudioExecutor(&config.Config{}, "aistudio", relay)
+ _, errExecute := exec.Execute(context.Background(), &cliproxyauth.Auth{ID: authID, Provider: "aistudio"}, cliproxyexecutor.Request{
+ Model: "gemini-3.1-pro-preview",
+ Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini})
+ if errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+ if errClient := <-clientDone; errClient != nil {
+ t.Fatal(errClient)
+ }
+
+ record := waitForAIStudioUsageRecord(t, plugin.records, "gemini-3.1-pro-preview")
+ if record.TTFT < delay {
+ t.Fatalf("ttft = %v, want >= %v", record.TTFT, delay)
+ }
+}
+
+type captureAIStudioUsagePlugin struct {
+ records chan usage.Record
+}
+
+func (p *captureAIStudioUsagePlugin) HandleUsage(_ context.Context, record usage.Record) {
+ if p == nil {
+ return
+ }
+ select {
+ case p.records <- record:
+ default:
+ }
+}
+
+func waitForAIStudioUsageRecord(t *testing.T, records <-chan usage.Record, model string) usage.Record {
+ t.Helper()
+ timeout := time.After(2 * time.Second)
+ for {
+ select {
+ case record := <-records:
+ if record.Provider == "aistudio" && record.Model == model {
+ return record
+ }
+ case <-timeout:
+ t.Fatalf("timed out waiting for AI Studio usage record")
+ }
+ }
+}
diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go
index 77f840cb137..408a490d03d 100644
--- a/internal/runtime/executor/antigravity_executor.go
+++ b/internal/runtime/executor/antigravity_executor.go
@@ -529,6 +529,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
baseURLs := antigravityBaseURLFallbackOrder(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
attempts := antigravityRetryAttempts(auth, e.cfg)
attemptLoop:
@@ -728,6 +729,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
baseURLs := antigravityBaseURLFallbackOrder(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
attempts := antigravityRetryAttempts(auth, e.cfg)
@@ -1190,6 +1192,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
baseURLs := antigravityBaseURLFallbackOrder(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
attempts := antigravityRetryAttempts(auth, e.cfg)
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 8d8ea4dbfbd..626a90abe27 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -227,6 +227,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
})
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -402,6 +403,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
})
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index 317bc4d257e..a5899efbb3d 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -311,6 +311,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
AuthValue: authValue,
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -468,6 +469,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
AuthValue: authValue,
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -571,6 +573,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go
index 211f89357a8..415cdf1c737 100644
--- a/internal/runtime/executor/codex_openai_images.go
+++ b/internal/runtime/executor/codex_openai_images.go
@@ -107,6 +107,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
@@ -196,6 +197,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index e3ce9ce0cdf..5594356bbd4 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -269,6 +269,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
return resp, errDial
}
recordAPIWebsocketHandshake(ctx, e.cfg, respHS)
+ reporter.StartResponseTTFT()
if sess == nil {
logCodexWebsocketConnected(executionSessionID, authID, wsURL)
defer func() {
@@ -312,6 +313,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
AuthValue: authValue,
})
recordAPIWebsocketHandshake(ctx, e.cfg, respHSRetry)
+ reporter.StartResponseTTFT()
if errSendRetry := writeCodexWebsocketMessage(sess, connRetry, wsReqBodyRetry); errSendRetry == nil {
conn = connRetry
wsReqBody = wsReqBodyRetry
@@ -356,6 +358,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
if len(payload) == 0 {
continue
}
+ reporter.MarkFirstResponseByte()
helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload)
if wsErr, ok := parseCodexWebsocketError(payload); ok {
@@ -476,6 +479,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
return nil, errDial
}
recordAPIWebsocketHandshake(ctx, e.cfg, respHS)
+ reporter.StartResponseTTFT()
if sess == nil {
logCodexWebsocketConnected(executionSessionID, authID, wsURL)
@@ -514,6 +518,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
AuthValue: authValue,
})
recordAPIWebsocketHandshake(ctx, e.cfg, respHSRetry)
+ reporter.StartResponseTTFT()
if errSendRetry := writeCodexWebsocketMessage(sess, connRetry, wsReqBodyRetry); errSendRetry != nil {
helps.RecordAPIWebsocketError(ctx, e.cfg, "send_retry", errSendRetry)
e.invalidateUpstreamConn(sess, connRetry, "send_error", errSendRetry)
@@ -606,6 +611,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
if len(payload) == 0 {
continue
}
+ reporter.MarkFirstResponseByte()
helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload)
if wsErr, ok := parseCodexWebsocketError(payload); ok {
diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go
index da444040038..d6b97021bef 100644
--- a/internal/runtime/executor/gemini_cli_executor.go
+++ b/internal/runtime/executor/gemini_cli_executor.go
@@ -158,6 +158,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
}
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
respCtx := context.WithValue(ctx, "alt", opts.Alt)
var authID, authLabel, authType, authValue string
@@ -310,6 +311,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
}
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
respCtx := context.WithValue(ctx, "alt", opts.Alt)
var authID, authLabel, authType, authValue string
diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go
index 99c06dbdc24..2f4f1935e95 100644
--- a/internal/runtime/executor/gemini_executor.go
+++ b/internal/runtime/executor/gemini_executor.go
@@ -183,6 +183,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -289,6 +290,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go
index 98e46221bcb..50c22b9cd01 100644
--- a/internal/runtime/executor/gemini_vertex_executor.go
+++ b/internal/runtime/executor/gemini_vertex_executor.go
@@ -395,6 +395,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
@@ -518,6 +519,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
@@ -630,6 +632,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
@@ -773,6 +776,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go
index 82f82a4407c..1c4f4cdf7c4 100644
--- a/internal/runtime/executor/helps/usage_helpers.go
+++ b/internal/runtime/executor/helps/usage_helpers.go
@@ -5,6 +5,8 @@ import (
"context"
"errors"
"fmt"
+ "io"
+ "net/http"
"strings"
"sync"
"time"
@@ -29,6 +31,10 @@ type UsageReporter struct {
source string
reasoning string
requestedAt time.Time
+ ttftMu sync.RWMutex
+ ttft time.Duration
+ ttftStart time.Time
+ ttftSet bool
once sync.Once
}
@@ -74,6 +80,64 @@ func (r *UsageReporter) SetTranslatedReasoningEffort(payload []byte, format stri
r.reasoning = thinking.ExtractTranslatedReasoningEffort(payload, format)
}
+func (r *UsageReporter) TrackHTTPClient(client *http.Client) *http.Client {
+ if r == nil || client == nil {
+ return client
+ }
+ tracked := *client
+ transport := tracked.Transport
+ if transport == nil {
+ transport = http.DefaultTransport
+ }
+ tracked.Transport = usageTTFTRoundTripper{
+ base: transport,
+ reporter: r,
+ }
+ return &tracked
+}
+
+func (r *UsageReporter) ObserveResponse(resp *http.Response) {
+ if r == nil || resp == nil || resp.Body == nil {
+ return
+ }
+ r.StartResponseTTFT()
+ resp.Body = &usageTTFTReadCloser{
+ ReadCloser: resp.Body,
+ mark: func() {
+ r.MarkFirstResponseByte()
+ },
+ }
+}
+
+func (r *UsageReporter) StartResponseTTFT() {
+ if r == nil {
+ return
+ }
+ r.ttftMu.Lock()
+ if !r.ttftSet && r.ttftStart.IsZero() {
+ r.ttftStart = time.Now()
+ }
+ r.ttftMu.Unlock()
+}
+
+func (r *UsageReporter) MarkFirstResponseByte() {
+ if r == nil {
+ return
+ }
+ r.ttftMu.Lock()
+ if r.ttftSet {
+ r.ttftMu.Unlock()
+ return
+ }
+ start := r.ttftStart
+ r.ttftStart = time.Time{}
+ r.ttftMu.Unlock()
+ if start.IsZero() {
+ return
+ }
+ r.setTTFT(time.Since(start))
+}
+
func (r *UsageReporter) buildAdditionalModelRecord(model string, detail usage.Detail) (usage.Record, bool) {
if r == nil {
return usage.Record{}, false
@@ -177,6 +241,7 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f
ReasoningEffort: r.reasoning,
RequestedAt: r.requestedAt,
Latency: r.latency(),
+ TTFT: r.ttftDuration(),
Failed: failed,
Fail: fail,
Detail: detail,
@@ -211,6 +276,65 @@ func (r *UsageReporter) latency() time.Duration {
return latency
}
+func (r *UsageReporter) setTTFT(ttft time.Duration) {
+ if r == nil {
+ return
+ }
+ if ttft < 0 {
+ ttft = 0
+ }
+ r.ttftMu.Lock()
+ if r.ttftSet {
+ r.ttftMu.Unlock()
+ return
+ }
+ r.ttft = ttft
+ r.ttftSet = true
+ r.ttftStart = time.Time{}
+ r.ttftMu.Unlock()
+}
+
+func (r *UsageReporter) ttftDuration() time.Duration {
+ if r == nil {
+ return 0
+ }
+ r.ttftMu.RLock()
+ defer r.ttftMu.RUnlock()
+ return r.ttft
+}
+
+type usageTTFTRoundTripper struct {
+ base http.RoundTripper
+ reporter *UsageReporter
+}
+
+func (t usageTTFTRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ t.reporter.StartResponseTTFT()
+ resp, errRoundTrip := t.base.RoundTrip(req)
+ if errRoundTrip != nil {
+ return resp, errRoundTrip
+ }
+ t.reporter.ObserveResponse(resp)
+ return resp, nil
+}
+
+type usageTTFTReadCloser struct {
+ io.ReadCloser
+ once sync.Once
+ mark func()
+}
+
+func (r *usageTTFTReadCloser) Read(p []byte) (int, error) {
+ if r == nil || r.ReadCloser == nil {
+ return 0, io.ErrClosedPipe
+ }
+ n, errRead := r.ReadCloser.Read(p)
+ if n > 0 && r.mark != nil {
+ r.once.Do(r.mark)
+ }
+ return n, errRead
+}
+
func APIKeyFromContext(ctx context.Context) string {
if ctx == nil {
return ""
diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go
index 330641c6142..58b175f3b6f 100644
--- a/internal/runtime/executor/helps/usage_helpers_test.go
+++ b/internal/runtime/executor/helps/usage_helpers_test.go
@@ -2,6 +2,9 @@ package helps
import (
"context"
+ "io"
+ "net/http"
+ "strings"
"testing"
"time"
@@ -146,6 +149,41 @@ func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) {
}
}
+func TestUsageReporterTrackHTTPClientStartsTTFTBeforeRoundTrip(t *testing.T) {
+ delay := 40 * time.Millisecond
+ reporter := NewUsageReporter(context.Background(), "openai", "gpt-5.4", nil)
+ client := reporter.TrackHTTPClient(&http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ time.Sleep(delay)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Status: "200 OK",
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("ok")),
+ Request: req,
+ }, nil
+ }),
+ })
+
+ req, errNewRequest := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.invalid/v1/chat/completions", strings.NewReader("{}"))
+ if errNewRequest != nil {
+ t.Fatalf("NewRequestWithContext() error = %v", errNewRequest)
+ }
+ resp, errDo := client.Do(req)
+ if errDo != nil {
+ t.Fatalf("Do() error = %v", errDo)
+ }
+ if _, errRead := io.ReadAll(resp.Body); errRead != nil {
+ t.Fatalf("ReadAll() error = %v", errRead)
+ }
+ if errClose := resp.Body.Close(); errClose != nil {
+ t.Fatalf("response body close error = %v", errClose)
+ }
+ if got := reporter.ttftDuration(); got < delay {
+ t.Fatalf("ttft = %v, want >= %v", got, delay)
+ }
+}
+
func TestUsageReporterBuildRecordIncludesRequestedModelAlias(t *testing.T) {
ctx := usage.WithRequestedModelAlias(context.Background(), "client-gpt")
reporter := NewUsageReporter(ctx, "openai", "gpt-5.4", nil)
@@ -186,3 +224,9 @@ func TestUsageReporterBuildAdditionalModelRecordSkipsZeroTokens(t *testing.T) {
t.Fatalf("expected non-zero cached token usage to be recorded")
}
}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go
index 15421582354..d7ab643ad34 100644
--- a/internal/runtime/executor/kimi_executor.go
+++ b/internal/runtime/executor/kimi_executor.go
@@ -146,6 +146,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -257,6 +258,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index 24aa661dde9..8475e372a6c 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -162,6 +162,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -252,6 +253,7 @@ func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxy
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -360,6 +362,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -510,6 +513,7 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl
})
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go
index aabd5772d1f..cb42f93935c 100644
--- a/internal/runtime/executor/xai_executor.go
+++ b/internal/runtime/executor/xai_executor.go
@@ -127,6 +127,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
@@ -314,6 +315,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go
index 731fd8d0471..6113ca1ebc3 100644
--- a/sdk/cliproxy/usage/manager.go
+++ b/sdk/cliproxy/usage/manager.go
@@ -24,6 +24,7 @@ type Record struct {
ReasoningEffort string
RequestedAt time.Time
Latency time.Duration
+ TTFT time.Duration
Failed bool
Fail Failure
Detail Detail
From d9c01a638d81bb21d46f7ea1424d458a79a1458d Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 28 May 2026 09:41:51 +0800
Subject: [PATCH 073/248] chore(models): remove deprecated GPT-5.x models from
`codex-free` catalog
---
internal/registry/models/models.json | 70 ----------------------------
1 file changed, 70 deletions(-)
diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json
index 2ee5caafe8a..41d191f024d 100644
--- a/internal/registry/models/models.json
+++ b/internal/registry/models/models.json
@@ -1314,76 +1314,6 @@
}
],
"codex-free": [
- {
- "id": "gpt-5.2",
- "object": "model",
- "created": 1765440000,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.2",
- "version": "gpt-5.2",
- "description": "Stable version of GPT 5.2",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "none",
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
- {
- "id": "gpt-5.3-codex",
- "object": "model",
- "created": 1770307200,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.3 Codex",
- "version": "gpt-5.3",
- "description": "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
- {
- "id": "gpt-5.4",
- "object": "model",
- "created": 1772668800,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.4",
- "version": "gpt-5.4",
- "description": "Stable version of GPT 5.4",
- "context_length": 1050000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
{
"id": "gpt-5.4-mini",
"object": "model",
From 2bcc76220c582e0892b1204647c172cc7332ce2e Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 28 May 2026 10:42:24 +0800
Subject: [PATCH 074/248] feat(logging): improve file-backed source cleanup and
directory recreation logic
- Added `assertFileBodySourceCleaned` helper to streamline cleanup validations in tests.
- Introduced handling to recreate missing directories during file source operations.
- Enhanced tests to verify behavior after manual directory removal, ensuring robustness.
- Fixed edge cases in log file merging when parts are missing.
---
internal/logging/request_logger.go | 25 +++++---
internal/logging/request_logger_home_test.go | 64 +++++++++++++++++---
2 files changed, 72 insertions(+), 17 deletions(-)
diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go
index 8a8b6fbde0f..e1c7a9cc4ad 100644
--- a/internal/logging/request_logger.go
+++ b/internal/logging/request_logger.go
@@ -110,6 +110,9 @@ func (s *FileBodySource) CreatePart(prefix string) (*os.File, error) {
return nil, fmt.Errorf("file body source has been cleaned")
}
prefix = sanitizeTempPrefix(prefix)
+ if errMkdir := os.MkdirAll(s.dir, 0755); errMkdir != nil {
+ return nil, errMkdir
+ }
file, errCreate := os.CreateTemp(s.dir, prefix+"-*.tmp")
if errCreate != nil {
return nil, errCreate
@@ -165,16 +168,23 @@ func (s *FileBodySource) WriteTo(w io.Writer) error {
return nil
}
paths := s.Paths()
- for i, path := range paths {
- if i > 0 {
- if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
- return errWrite
- }
- }
+ wrote := false
+ for _, path := range paths {
file, errOpen := os.Open(path)
if errOpen != nil {
+ if os.IsNotExist(errOpen) {
+ continue
+ }
return errOpen
}
+ if wrote {
+ if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
+ if errClose := file.Close(); errClose != nil {
+ log.WithError(errClose).Warn("failed to close log part file")
+ }
+ return errWrite
+ }
+ }
_, errCopy := io.Copy(w, file)
if errClose := file.Close(); errClose != nil {
log.WithError(errClose).Warn("failed to close log part file")
@@ -185,6 +195,7 @@ func (s *FileBodySource) WriteTo(w io.Writer) error {
if errCopy != nil {
return errCopy
}
+ wrote = true
}
return nil
}
@@ -222,7 +233,7 @@ func (s *FileBodySource) Cleanup() error {
}
}
if dir != "" {
- if errRemove := os.Remove(dir); errRemove != nil && !os.IsNotExist(errRemove) && firstErr == nil {
+ if errRemove := os.RemoveAll(dir); errRemove != nil && firstErr == nil {
firstErr = errRemove
}
}
diff --git a/internal/logging/request_logger_home_test.go b/internal/logging/request_logger_home_test.go
index 2d974f31d8a..451eab41a7b 100644
--- a/internal/logging/request_logger_home_test.go
+++ b/internal/logging/request_logger_home_test.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -23,6 +24,57 @@ func (c *stubHomeRequestLogClient) RPushRequestLog(_ context.Context, payload []
return nil
}
+func assertFileBodySourceCleaned(t *testing.T, partPaths []string) {
+ t.Helper()
+
+ dirs := make(map[string]struct{}, len(partPaths))
+ for _, path := range partPaths {
+ if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
+ t.Fatalf("expected part %s to be removed, stat err=%v", path, errStat)
+ }
+ dirs[filepath.Dir(path)] = struct{}{}
+ }
+ for dir := range dirs {
+ if _, errStat := os.Stat(dir); !os.IsNotExist(errStat) {
+ t.Fatalf("expected part dir %s to be removed, stat err=%v", dir, errStat)
+ }
+ }
+}
+
+func TestFileBodySource_RecreatesPartDirAfterManualCleanup(t *testing.T) {
+ logsDir := t.TempDir()
+ source, errSource := NewFileBodySourceInDir(logsDir, "websocket-timeline-test")
+ if errSource != nil {
+ t.Fatalf("NewFileBodySourceInDir: %v", errSource)
+ }
+ if errAppend := source.AppendPart([]byte("before manual cleanup")); errAppend != nil {
+ t.Fatalf("AppendPart before cleanup: %v", errAppend)
+ }
+ if errRemove := os.RemoveAll(logsDir); errRemove != nil {
+ t.Fatalf("RemoveAll logs dir: %v", errRemove)
+ }
+ if errAppend := source.AppendPart([]byte("after manual cleanup")); errAppend != nil {
+ t.Fatalf("AppendPart after cleanup: %v", errAppend)
+ }
+
+ raw, errBytes := source.Bytes()
+ if errBytes != nil {
+ t.Fatalf("Bytes after cleanup: %v", errBytes)
+ }
+ if bytes.Contains(raw, []byte("before manual cleanup")) {
+ t.Fatalf("expected manually removed part to be skipped, got %q", string(raw))
+ }
+ if !bytes.Contains(raw, []byte("after manual cleanup")) {
+ t.Fatalf("expected recreated part content, got %q", string(raw))
+ }
+
+ partPaths := source.Paths()
+ if errCleanup := source.Cleanup(); errCleanup != nil {
+ t.Fatalf("Cleanup: %v", errCleanup)
+ }
+ assertFileBodySourceCleaned(t, partPaths)
+}
+
func TestFileRequestLogger_HomeEnabled_ForwardsWhenRequestLogEnabled(t *testing.T) {
original := currentHomeRequestLogClient
defer func() {
@@ -143,11 +195,7 @@ func TestFileRequestLogger_LogRequestWithSourcesWritesLocalLogAndCleansParts(t *
t.Fatalf("LogRequestWithOptionsAndSources error: %v", errLog)
}
- for _, path := range partPaths {
- if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
- t.Fatalf("expected part %s to be removed, stat err=%v", path, errStat)
- }
- }
+ assertFileBodySourceCleaned(t, partPaths)
entries, errRead := os.ReadDir(logsDir)
if errRead != nil {
@@ -245,11 +293,7 @@ func TestFileRequestLogger_HomeEnabled_ForwardsSourceLogAndCleansParts(t *testin
if !strings.Contains(got.RequestLog, "Event: websocket.request") {
t.Fatalf("forwarded request_log missing websocket request: %s", got.RequestLog)
}
- for _, path := range partPaths {
- if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
- t.Fatalf("expected part %s to be removed, stat err=%v", path, errStat)
- }
- }
+ assertFileBodySourceCleaned(t, partPaths)
}
func TestFileRequestLogger_HomeEnabled_ForwardsStreamingRequestID(t *testing.T) {
From b3d6d5d71a7c6c3bb30159476e2bc59e1d4bd820 Mon Sep 17 00:00:00 2001
From: sususu98
Date: Wed, 27 May 2026 17:09:40 +0800
Subject: [PATCH 075/248] refactor: extract signature validation
---
internal/signature/claude.go | 113 ++++
.../signature/claude_messages_sanitize.go | 249 +++++++++
internal/signature/claude_test.go | 161 ++++++
internal/signature/claude_validation.go | 484 +++++++++++++++++
internal/signature/gemini_validation.go | 497 ++++++++++++++++++
internal/signature/gemini_validation_test.go | 393 ++++++++++++++
internal/signature/gpt_validation.go | 83 +++
internal/signature/gpt_validation_test.go | 35 ++
internal/signature/provider_compatibility.go | 283 ++++++++++
.../signature/provider_compatibility_test.go | 248 +++++++++
.../claude/signature_validation.go | 436 +--------------
11 files changed, 2561 insertions(+), 421 deletions(-)
create mode 100644 internal/signature/claude.go
create mode 100644 internal/signature/claude_messages_sanitize.go
create mode 100644 internal/signature/claude_test.go
create mode 100644 internal/signature/claude_validation.go
create mode 100644 internal/signature/gemini_validation.go
create mode 100644 internal/signature/gemini_validation_test.go
create mode 100644 internal/signature/gpt_validation.go
create mode 100644 internal/signature/gpt_validation_test.go
create mode 100644 internal/signature/provider_compatibility.go
create mode 100644 internal/signature/provider_compatibility_test.go
diff --git a/internal/signature/claude.go b/internal/signature/claude.go
new file mode 100644
index 00000000000..4b3fbde2530
--- /dev/null
+++ b/internal/signature/claude.go
@@ -0,0 +1,113 @@
+package signature
+
+import (
+ "bytes"
+ "strings"
+
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+// StripInvalidClaudeThinkingBlocks removes Claude thinking blocks whose
+// signatures are empty or not valid Claude thinking signatures after stripping
+// an optional cache prefix, unless the validation options allow an empty
+// thinking placeholder.
+func StripInvalidClaudeThinkingBlocks(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
+ messages := gjson.GetBytes(payload, "messages")
+ if !messages.IsArray() {
+ return payload
+ }
+ opt := claudeSignatureValidationOptions(opts)
+ messageResults := messages.Array()
+ keptMessages := make([]string, 0, len(messageResults))
+ modified := false
+ for _, msg := range messageResults {
+ content := msg.Get("content")
+ if !content.IsArray() {
+ keptMessages = append(keptMessages, msg.Raw)
+ continue
+ }
+ contentResults := content.Array()
+ keptParts := make([]string, 0, len(contentResults))
+ stripped := false
+ for _, part := range contentResults {
+ if part.Get("type").String() == "thinking" && shouldStripClaudeThinkingBlock(part, opt) {
+ stripped = true
+ continue
+ }
+ keptParts = append(keptParts, part.Raw)
+ }
+ if stripped {
+ modified = true
+ updated, _ := sjson.SetRaw(msg.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
+ keptMessages = append(keptMessages, updated)
+ continue
+ }
+ keptMessages = append(keptMessages, msg.Raw)
+ }
+ if !modified {
+ return payload
+ }
+ output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
+ return output
+}
+
+// StripInvalidClaudeThinkingBlocksAndEmptyMessages also removes messages whose
+// content becomes empty after invalid thinking blocks are removed.
+func StripInvalidClaudeThinkingBlocksAndEmptyMessages(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
+ stripped := StripInvalidClaudeThinkingBlocks(payload, opts...)
+ if bytes.Equal(stripped, payload) {
+ return payload
+ }
+ messages := gjson.GetBytes(stripped, "messages")
+ if !messages.IsArray() {
+ return stripped
+ }
+ kept := make([]string, 0, len(messages.Array()))
+ for _, message := range messages.Array() {
+ content := message.Get("content")
+ if content.IsArray() && len(content.Array()) == 0 {
+ continue
+ }
+ kept = append(kept, message.Raw)
+ }
+ stripped, _ = sjson.SetRawBytes(stripped, "messages", []byte("["+strings.Join(kept, ",")+"]"))
+ return stripped
+}
+
+func shouldStripClaudeThinkingBlock(part gjson.Result, opt ClaudeSignatureValidationOptions) bool {
+ if opt.AllowEmptySignatureWithEmptyText && isEmptyClaudeThinkingPlaceholder(part) {
+ return false
+ }
+ return !IsValidClaudeThinkingSignature(part.Get("signature").String(), opt)
+}
+
+func isEmptyClaudeThinkingPlaceholder(part gjson.Result) bool {
+ if strings.TrimSpace(part.Get("signature").String()) != "" {
+ return false
+ }
+ return strings.TrimSpace(claudeThinkingBlockText(part)) == ""
+}
+
+func claudeThinkingBlockText(part gjson.Result) string {
+ if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
+ return text.String()
+ }
+
+ thinkingField := part.Get("thinking")
+ if !thinkingField.Exists() {
+ return ""
+ }
+ if thinkingField.Type == gjson.String {
+ return thinkingField.String()
+ }
+ if thinkingField.IsObject() {
+ if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
+ return inner.String()
+ }
+ if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
+ return inner.String()
+ }
+ }
+ return ""
+}
diff --git a/internal/signature/claude_messages_sanitize.go b/internal/signature/claude_messages_sanitize.go
new file mode 100644
index 00000000000..aec08879d32
--- /dev/null
+++ b/internal/signature/claude_messages_sanitize.go
@@ -0,0 +1,249 @@
+package signature
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+type ClaudeMessagesSignatureSanitizeOptions struct {
+ TargetProvider SignatureProvider
+ TargetModel string
+ DropEmptyMessages bool
+ DropToolSignatures bool
+}
+
+type SignatureSanitizeReport struct {
+ TargetProvider SignatureProvider
+ Preserved int
+ DroppedBlocks int
+ DroppedSignatures int
+ ReplacedSignatures int
+ Decisions []SignatureCompatibilityDecision
+}
+
+// SanitizeClaudeMessagesSignaturesForModel removes or preserves Claude
+// /v1/messages signed history according to the provider family implied by
+// targetModel.
+func SanitizeClaudeMessagesSignaturesForModel(payload []byte, targetModel string) ([]byte, SignatureSanitizeReport) {
+ return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{
+ TargetProvider: SignatureProviderFromModelName(targetModel),
+ TargetModel: targetModel,
+ DropEmptyMessages: true,
+ })
+}
+
+// SanitizeClaudeMessagesSignaturesForTarget applies provider-aware signature
+// compatibility rules to Claude /v1/messages history. Compatible thinking
+// signatures are preserved. Incompatible thinking blocks are removed so a user
+// can continue a conversation after switching between Claude, GPT/Codex,
+// and Gemini models.
+func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessagesSignatureSanitizeOptions) ([]byte, SignatureSanitizeReport) {
+ targetProvider := normalizeSignatureTargetProvider(opts.TargetProvider)
+ if targetProvider == SignatureProviderUnknown && opts.TargetModel != "" {
+ targetProvider = SignatureProviderFromModelName(opts.TargetModel)
+ }
+ report := SignatureSanitizeReport{TargetProvider: targetProvider}
+
+ messages := gjson.GetBytes(payload, "messages")
+ if !messages.IsArray() {
+ return payload, report
+ }
+
+ messageResults := messages.Array()
+ keptMessages := make([]string, 0, len(messageResults))
+ modified := false
+
+ for i, message := range messageResults {
+ content := message.Get("content")
+ if !content.IsArray() {
+ keptMessages = append(keptMessages, message.Raw)
+ continue
+ }
+
+ contentResults := content.Array()
+ keptParts := make([]string, 0, len(contentResults))
+ messageModified := false
+
+ for j, part := range contentResults {
+ partType := part.Get("type").String()
+ if partType == "tool_use" {
+ if opts.DropToolSignatures {
+ updatedPart, changed := stripClaudeToolUseSignatureFields(part)
+ if changed {
+ messageModified = true
+ report.DroppedSignatures++
+ }
+ keptParts = append(keptParts, updatedPart)
+ continue
+ }
+ updatedPart, changed, decisions := sanitizeClaudeToolUseSignature(part, targetProvider, i, j)
+ report.Decisions = append(report.Decisions, decisions...)
+ if changed {
+ messageModified = true
+ }
+ for _, decision := range decisions {
+ switch decision.Action {
+ case SignatureActionPreserve:
+ report.Preserved++
+ case SignatureActionReplaceWithGeminiBypass:
+ report.ReplacedSignatures++
+ default:
+ report.DroppedSignatures++
+ }
+ }
+ keptParts = append(keptParts, updatedPart)
+ continue
+ }
+
+ if partType != "thinking" {
+ keptParts = append(keptParts, part.Raw)
+ continue
+ }
+
+ if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) {
+ keptParts = append(keptParts, part.Raw)
+ continue
+ }
+
+ rawSignature := part.Get("signature").String()
+ decision := DecideSignatureCompatibility(targetProvider, rawSignature, SignatureBlockKindClaudeThinking)
+ decision.Reason = fmt.Sprintf("messages[%d].content[%d]: %s", i, j, decision.Reason)
+ report.Decisions = append(report.Decisions, decision)
+
+ switch decision.Action {
+ case SignatureActionPreserve:
+ report.Preserved++
+ if decision.NormalizedSignature != "" && decision.NormalizedSignature != rawSignature {
+ updated, _ := sjson.Set(part.Raw, "signature", decision.NormalizedSignature)
+ keptParts = append(keptParts, updated)
+ messageModified = true
+ continue
+ }
+ keptParts = append(keptParts, part.Raw)
+ case SignatureActionReplaceWithGeminiBypass:
+ report.ReplacedSignatures++
+ updated, _ := sjson.Set(part.Raw, "signature", decision.ReplacementSignature)
+ keptParts = append(keptParts, updated)
+ messageModified = true
+ case SignatureActionDropSignature:
+ report.DroppedSignatures++
+ updated, _ := sjson.Delete(part.Raw, "signature")
+ keptParts = append(keptParts, updated)
+ messageModified = true
+ default:
+ report.DroppedBlocks++
+ messageModified = true
+ }
+ }
+
+ if messageModified {
+ modified = true
+ if len(keptParts) == 0 && opts.DropEmptyMessages {
+ continue
+ }
+ updated, _ := sjson.SetRaw(message.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
+ keptMessages = append(keptMessages, updated)
+ continue
+ }
+
+ keptMessages = append(keptMessages, message.Raw)
+ }
+
+ if !modified {
+ return payload, report
+ }
+ output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
+ return output, report
+}
+
+func stripClaudeToolUseSignatureFields(part gjson.Result) (string, bool) {
+ updated := part.Raw
+ changed := false
+ for _, sigPath := range claudeToolUseSignaturePaths() {
+ if !gjson.Get(updated, sigPath).Exists() {
+ continue
+ }
+ updated, _ = sjson.Delete(updated, sigPath)
+ changed = true
+ }
+ if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
+ updated = cleaned
+ changed = true
+ }
+ if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
+ updated = cleaned
+ changed = true
+ }
+ return updated, changed
+}
+
+func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureProvider, messageIdx, partIdx int) (string, bool, []SignatureCompatibilityDecision) {
+ updated := part.Raw
+ changed := false
+ var decisions []SignatureCompatibilityDecision
+
+ for _, sigPath := range claudeToolUseSignaturePaths() {
+ sigResult := part.Get(sigPath)
+ if !sigResult.Exists() {
+ continue
+ }
+
+ blockKind := SignatureBlockKindGeminiFunctionCall
+ if targetProvider == SignatureProviderClaude {
+ blockKind = SignatureBlockKindClaudeThinking
+ } else if targetProvider == SignatureProviderGPT {
+ blockKind = SignatureBlockKindGPTReasoning
+ }
+ decision := DecideSignatureCompatibility(targetProvider, sigResult.String(), blockKind)
+ decision.Reason = fmt.Sprintf("messages[%d].content[%d].%s: %s", messageIdx, partIdx, sigPath, decision.Reason)
+ decisions = append(decisions, decision)
+
+ switch decision.Action {
+ case SignatureActionPreserve:
+ if decision.NormalizedSignature != "" && decision.NormalizedSignature != sigResult.String() {
+ updated, _ = sjson.Set(updated, sigPath, decision.NormalizedSignature)
+ changed = true
+ }
+ case SignatureActionReplaceWithGeminiBypass:
+ updated, _ = sjson.Set(updated, sigPath, decision.ReplacementSignature)
+ changed = true
+ default:
+ updated, _ = sjson.Delete(updated, sigPath)
+ changed = true
+ }
+ }
+
+ if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
+ updated = cleaned
+ changed = true
+ }
+ if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
+ updated = cleaned
+ changed = true
+ }
+
+ return updated, changed, decisions
+}
+
+func claudeToolUseSignaturePaths() []string {
+ return []string{
+ "signature",
+ "thought_signature",
+ "extra_content.google.thought_signature",
+ }
+}
+
+func deleteEmptyJSONObjectPath(raw, path string) (string, bool) {
+ result := gjson.Get(raw, path)
+ if !result.Exists() || !result.IsObject() || len(result.Map()) != 0 {
+ return raw, false
+ }
+ updated, err := sjson.Delete(raw, path)
+ if err != nil {
+ return raw, false
+ }
+ return updated, true
+}
diff --git a/internal/signature/claude_test.go b/internal/signature/claude_test.go
new file mode 100644
index 00000000000..4c929dc21dc
--- /dev/null
+++ b/internal/signature/claude_test.go
@@ -0,0 +1,161 @@
+package signature
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestStripInvalidClaudeThinkingBlocks_RemovesGPTEncryptedContent(t *testing.T) {
+ input := []byte(`{
+ "messages": [
+ {"role":"assistant","content":[
+ {"type":"thinking","thinking":"codex reasoning","signature":"gAAAAABopenai-encrypted-content"},
+ {"type":"text","text":"Answer"}
+ ]},
+ {"role":"user","content":[{"type":"text","text":"next"}]}
+ ]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocks(input)
+ content := gjson.GetBytes(out, "messages.0.content").Array()
+ if len(content) != 1 {
+ t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(out))
+ }
+ if got := content[0].Get("text").String(); got != "Answer" {
+ t.Fatalf("remaining content text = %q, want Answer", got)
+ }
+ if strings.Contains(string(out), "gAAAAABopenai-encrypted-content") || strings.Contains(string(out), "codex reasoning") {
+ t.Fatalf("invalid thinking block was preserved: %s", string(out))
+ }
+}
+
+func TestStripInvalidClaudeThinkingBlocksAndEmptyMessages_DropsMessagesLeftEmpty(t *testing.T) {
+ input := []byte(`{
+ "messages": [
+ {"role":"assistant","content":[
+ {"type":"thinking","thinking":"codex reasoning","signature":"gAAAAABopenai-encrypted-content"}
+ ]},
+ {"role":"user","content":[{"type":"text","text":"next"}]}
+ ]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocksAndEmptyMessages(input)
+ messages := gjson.GetBytes(out, "messages").Array()
+ if len(messages) != 1 {
+ t.Fatalf("messages length = %d, want 1: %s", len(messages), string(out))
+ }
+ if got := messages[0].Get("role").String(); got != "user" {
+ t.Fatalf("remaining role = %q, want user", got)
+ }
+ if strings.Contains(string(out), "gAAAAABopenai-encrypted-content") || strings.Contains(string(out), "codex reasoning") {
+ t.Fatalf("invalid thinking block was preserved: %s", string(out))
+ }
+}
+
+func TestStripInvalidClaudeThinkingBlocks_RemovesMalformedEPrefix(t *testing.T) {
+ input := []byte(`{
+ "messages": [{"role":"assistant","content":[
+ {"type":"thinking","thinking":"bad","signature":"Ebad"},
+ {"type":"text","text":"Answer"}
+ ]}]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocks(input)
+ content := gjson.GetBytes(out, "messages.0.content").Array()
+ if len(content) != 1 {
+ t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
+ }
+ if strings.Contains(string(out), "Ebad") || strings.Contains(string(out), "bad") {
+ t.Fatalf("malformed E-prefix thinking block was preserved: %s", string(out))
+ }
+}
+
+func TestStripInvalidClaudeThinkingBlocks_Base64OnlyKeepsDecodableEPrefix(t *testing.T) {
+ input := []byte(`{
+ "messages": [{"role":"assistant","content":[
+ {"type":"thinking","thinking":"bad","signature":"Ebad"},
+ {"type":"text","text":"Answer"}
+ ]}]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
+ content := gjson.GetBytes(out, "messages.0.content").Array()
+ if len(content) != 2 {
+ t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
+ }
+}
+
+func TestStripInvalidClaudeThinkingBlocks_Base64OnlyRemovesInvalidBase64(t *testing.T) {
+ input := []byte(`{
+ "messages": [{"role":"assistant","content":[
+ {"type":"thinking","thinking":"bad","signature":"E!!!invalid!!!"},
+ {"type":"text","text":"Answer"}
+ ]}]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
+ content := gjson.GetBytes(out, "messages.0.content").Array()
+ if len(content) != 1 {
+ t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
+ }
+ if strings.Contains(string(out), "E!!!invalid!!!") || strings.Contains(string(out), "bad") {
+ t.Fatalf("invalid-base64 thinking block was preserved: %s", string(out))
+ }
+}
+
+func TestStripInvalidClaudeThinkingBlocks_AllowsEmptySignatureEmptyTextPlaceholder(t *testing.T) {
+ input := []byte(`{
+ "messages": [{"role":"assistant","content":[
+ {"type":"thinking","text":"","signature":""},
+ {"type":"text","text":"Answer"}
+ ]}]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{
+ Base64Only: true,
+ AllowEmptySignatureWithEmptyText: true,
+ })
+ content := gjson.GetBytes(out, "messages.0.content").Array()
+ if len(content) != 2 {
+ t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
+ }
+}
+
+func TestStripInvalidClaudeThinkingBlocks_StrictRemovesMalformedClaudeTree(t *testing.T) {
+ sig := base64.StdEncoding.EncodeToString([]byte{0x12, 0xFF, 0xFE, 0xFD})
+ input := []byte(`{
+ "messages": [{"role":"assistant","content":[
+ {"type":"thinking","thinking":"bad","signature":"` + sig + `"},
+ {"type":"text","text":"Answer"}
+ ]}]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Strict: true})
+ content := gjson.GetBytes(out, "messages.0.content").Array()
+ if len(content) != 1 {
+ t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
+ }
+ if strings.Contains(string(out), sig) || strings.Contains(string(out), "bad") {
+ t.Fatalf("strict-invalid thinking block was preserved: %s", string(out))
+ }
+}
+
+func TestStripInvalidClaudeThinkingBlocks_KeepsClaudeSignaturePrefixes(t *testing.T) {
+ singleLayer := base64.StdEncoding.EncodeToString([]byte{0x12, 0x34})
+ doubleLayer := base64.StdEncoding.EncodeToString([]byte(singleLayer))
+ input := []byte(`{
+ "messages": [{"role":"assistant","content":[
+ {"type":"thinking","thinking":"one","signature":"` + singleLayer + `"},
+ {"type":"thinking","thinking":"two","signature":"modelGroup#` + doubleLayer + `"}
+ ]}]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocks(input)
+ content := gjson.GetBytes(out, "messages.0.content").Array()
+ if len(content) != 2 {
+ t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
+ }
+}
diff --git a/internal/signature/claude_validation.go b/internal/signature/claude_validation.go
new file mode 100644
index 00000000000..4bad747ed45
--- /dev/null
+++ b/internal/signature/claude_validation.go
@@ -0,0 +1,484 @@
+// Claude thinking signature validation.
+//
+// Spec reference: SIGNATURE-CHANNEL-SPEC.md
+//
+// Encoding detection (Spec section 3)
+//
+// Claude signatures use base64 encoding in one or two layers. The raw string's
+// first character determines the encoding depth. This is mathematically
+// equivalent to the spec's "decode first, check byte" approach:
+//
+// - E prefix: single-layer, payload[0] == 0x12, first 6 bits = 000100,
+// base64 index 4 = E.
+// - R prefix: double-layer, inner[0] == E (0x45), first 6 bits = 010001,
+// base64 index 17 = R.
+//
+// Valid signatures can be normalized to R-form (double-layer base64) before
+// sending to the Antigravity backend.
+//
+// # Protobuf structure (Spec sections 4.1 and 4.2) in strict mode only
+//
+// After base64 decoding to raw bytes, the first byte must be 0x12:
+//
+// Top-level protobuf
+// |- Field 2 (bytes): container -> extractClaudeBytesField(payload, 2)
+// | |- Field 1 (bytes): channel block -> extractClaudeBytesField(container, 1)
+// | | |- Field 1 (varint): channel_id [required] -> routing_class (11 | 12)
+// | | |- Field 2 (varint): infra [optional] -> infrastructure_class (aws=1 | google=2)
+// | | |- Field 3 (varint): version=2 -> skipped
+// | | |- Field 5 (bytes): ECDSA sig -> skipped, per Spec section 11
+// | | |- Field 6 (bytes): model_text [optional] -> schema_features
+// | | `- Field 7 (varint): unknown [optional] -> schema_features
+// | |- Field 2 (bytes): nonce 12B -> skipped
+// | |- Field 3 (bytes): session 12B -> skipped
+// | |- Field 4 (bytes): SHA-384 48B -> skipped
+// | `- Field 5 (bytes): metadata -> skipped, per Spec section 11
+// `- Field 3 (varint): =1 -> skipped
+//
+// Output dimensions (Spec section 8)
+//
+// routing_class: routing_class_11 | routing_class_12 | unknown
+// infrastructure_class: infra_default (absent) | infra_aws (1) | infra_google (2) | infra_unknown
+// schema_features: compact_schema (len 70-72, no f6/f7) | extended_model_tagged_schema (f6 exists) | unknown
+// legacy_route_hint: only for ch=11, legacy_default_group | legacy_aws_group | legacy_vertex_direct/proxy
+//
+// # Compatibility
+//
+// Verified against all confirmed spec samples (Anthropic Max 20x, Azure,
+// Vertex, Bedrock) and legacy ch=11 signatures. Both single-layer (E) and
+// double-layer (R) encodings are supported. Historical cache-mode modelGroup#
+// prefixes are stripped.
+package signature
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/tidwall/gjson"
+ "google.golang.org/protobuf/encoding/protowire"
+)
+
+const MaxClaudeThinkingSignatureLen = 32 * 1024 * 1024
+
+// ClaudeSignatureValidationOptions controls how far Claude thinking signatures
+// are inspected. The base validation always checks the cache prefix, base64
+// layers, and decoded 0x12 Claude payload marker. Strict mode additionally
+// verifies the known protobuf tree used by Claude thinking signatures.
+type ClaudeSignatureValidationOptions struct {
+ // PrefixOnly only checks for an optional cache prefix followed by an E/R
+ // Claude signature prefix. Use it to preserve legacy shallow cleanup.
+ PrefixOnly bool
+ // Base64Only checks the optional cache prefix, E/R Claude signature prefix,
+ // and base64 layers without validating the decoded Claude marker or protobuf
+ // tree. Use it for conservative request cleanup.
+ Base64Only bool
+ // AllowEmptySignatureWithEmptyText preserves empty thinking placeholders with
+ // no signature and no thinking/text payload during strip operations.
+ AllowEmptySignatureWithEmptyText bool
+ Strict bool
+}
+
+// ClaudeSignatureTree describes the protobuf fields currently used for Claude
+// thinking signature routing.
+type ClaudeSignatureTree struct {
+ EncodingLayers int
+ ChannelID uint64
+ Field2 *uint64
+ RoutingClass string
+ InfrastructureClass string
+ SchemaFeatures string
+ ModelText string
+ LegacyRouteHint string
+ HasField7 bool
+}
+
+func claudeSignatureValidationOptions(opts []ClaudeSignatureValidationOptions) ClaudeSignatureValidationOptions {
+ if len(opts) == 0 {
+ return ClaudeSignatureValidationOptions{}
+ }
+ return opts[0]
+}
+
+// IsValidClaudeThinkingSignature returns whether rawSignature is a valid Claude
+// thinking signature under the requested validation options.
+func IsValidClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) bool {
+ opt := claudeSignatureValidationOptions(opts)
+ if opt.PrefixOnly {
+ return HasClaudeThinkingSignaturePrefix(rawSignature)
+ }
+ if opt.Base64Only {
+ return HasDecodableClaudeThinkingSignature(rawSignature)
+ }
+ _, err := NormalizeClaudeThinkingSignature(rawSignature, opts...)
+ return err == nil
+}
+
+// HasDecodableClaudeThinkingSignature reports whether rawSignature has the
+// Claude E/R shape and its expected base64 layer(s) can be decoded.
+func HasDecodableClaudeThinkingSignature(rawSignature string) bool {
+ sig := stripClaudeSignaturePrefix(rawSignature)
+ if sig == "" || len(sig) > MaxClaudeThinkingSignatureLen {
+ return false
+ }
+
+ switch sig[0] {
+ case 'E':
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ return err == nil && len(decoded) > 0
+ case 'R':
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ if err != nil || len(decoded) == 0 || decoded[0] != 'E' {
+ return false
+ }
+ innerDecoded, err := base64.StdEncoding.DecodeString(string(decoded))
+ return err == nil && len(innerDecoded) > 0
+ default:
+ return false
+ }
+}
+
+// HasClaudeThinkingSignaturePrefix reports whether rawSignature has the Claude
+// E/R signature prefix after stripping an optional cache prefix.
+func HasClaudeThinkingSignaturePrefix(rawSignature string) bool {
+ sig := stripClaudeSignaturePrefix(rawSignature)
+ if sig == "" {
+ return false
+ }
+ return sig[0] == 'E' || sig[0] == 'R'
+}
+
+func stripClaudeSignaturePrefix(rawSignature string) string {
+ sig := strings.TrimSpace(rawSignature)
+ if sig == "" {
+ return ""
+ }
+ if idx := strings.IndexByte(sig, '#'); idx >= 0 {
+ sig = strings.TrimSpace(sig[idx+1:])
+ }
+ return sig
+}
+
+// ValidateClaudeThinkingSignatures validates every thinking block signature in a
+// Claude messages payload.
+func ValidateClaudeThinkingSignatures(inputRawJSON []byte, opts ...ClaudeSignatureValidationOptions) error {
+ messages := gjson.GetBytes(inputRawJSON, "messages")
+ if !messages.IsArray() {
+ return nil
+ }
+
+ opt := claudeSignatureValidationOptions(opts)
+ messageResults := messages.Array()
+ for i := 0; i < len(messageResults); i++ {
+ contentResults := messageResults[i].Get("content")
+ if !contentResults.IsArray() {
+ continue
+ }
+ parts := contentResults.Array()
+ for j := 0; j < len(parts); j++ {
+ part := parts[j]
+ if part.Get("type").String() != "thinking" {
+ continue
+ }
+
+ rawSignature := strings.TrimSpace(part.Get("signature").String())
+ if rawSignature == "" {
+ return fmt.Errorf("messages[%d].content[%d]: missing thinking signature", i, j)
+ }
+
+ if _, err := NormalizeClaudeThinkingSignature(rawSignature, opt); err != nil {
+ return fmt.Errorf("messages[%d].content[%d]: %w", i, j, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// NormalizeClaudeThinkingSignature strips any cache prefix, validates the
+// signature, and returns the double-layer R-form expected by Antigravity bypass
+// mode.
+func NormalizeClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) (string, error) {
+ opt := claudeSignatureValidationOptions(opts)
+ sig := stripClaudeSignaturePrefix(rawSignature)
+ if sig == "" {
+ return "", fmt.Errorf("empty signature")
+ }
+
+ if len(sig) > MaxClaudeThinkingSignatureLen {
+ return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", MaxClaudeThinkingSignatureLen)
+ }
+
+ switch sig[0] {
+ case 'R':
+ if err := validateClaudeDoubleLayerSignature(sig, opt); err != nil {
+ return "", err
+ }
+ return sig, nil
+ case 'E':
+ if err := validateClaudeSingleLayerSignature(sig, opt); err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString([]byte(sig)), nil
+ default:
+ return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0]))
+ }
+}
+
+func validateClaudeDoubleLayerSignature(sig string, opt ClaudeSignatureValidationOptions) error {
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ if err != nil {
+ return fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
+ }
+ if len(decoded) == 0 {
+ return fmt.Errorf("invalid double-layer signature: empty after decode")
+ }
+ if decoded[0] != 'E' {
+ return fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
+ }
+ return validateClaudeSingleLayerSignatureContent(string(decoded), 2, opt)
+}
+
+func validateClaudeSingleLayerSignature(sig string, opt ClaudeSignatureValidationOptions) error {
+ return validateClaudeSingleLayerSignatureContent(sig, 1, opt)
+}
+
+func validateClaudeSingleLayerSignatureContent(sig string, encodingLayers int, opt ClaudeSignatureValidationOptions) error {
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ if err != nil {
+ return fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
+ }
+ if len(decoded) == 0 {
+ return fmt.Errorf("invalid single-layer signature: empty after decode")
+ }
+ if decoded[0] != 0x12 {
+ return fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", decoded[0])
+ }
+ if !opt.Strict {
+ return nil
+ }
+ _, err = InspectClaudeSignaturePayload(decoded, encodingLayers)
+ return err
+}
+
+// InspectClaudeDoubleLayerSignature decodes and inspects a double-layer Claude
+// thinking signature.
+func InspectClaudeDoubleLayerSignature(sig string) (*ClaudeSignatureTree, error) {
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ if err != nil {
+ return nil, fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
+ }
+ if len(decoded) == 0 {
+ return nil, fmt.Errorf("invalid double-layer signature: empty after decode")
+ }
+ if decoded[0] != 'E' {
+ return nil, fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
+ }
+ return inspectClaudeSingleLayerSignatureWithLayers(string(decoded), 2)
+}
+
+// InspectClaudeSingleLayerSignature decodes and inspects a single-layer Claude
+// thinking signature.
+func InspectClaudeSingleLayerSignature(sig string) (*ClaudeSignatureTree, error) {
+ return inspectClaudeSingleLayerSignatureWithLayers(sig, 1)
+}
+
+func inspectClaudeSingleLayerSignatureWithLayers(sig string, encodingLayers int) (*ClaudeSignatureTree, error) {
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ if err != nil {
+ return nil, fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
+ }
+ if len(decoded) == 0 {
+ return nil, fmt.Errorf("invalid single-layer signature: empty after decode")
+ }
+ return InspectClaudeSignaturePayload(decoded, encodingLayers)
+}
+
+// InspectClaudeSignaturePayload inspects the decoded Claude thinking signature
+// protobuf payload.
+func InspectClaudeSignaturePayload(payload []byte, encodingLayers int) (*ClaudeSignatureTree, error) {
+ if len(payload) == 0 {
+ return nil, fmt.Errorf("invalid Claude signature: empty payload")
+ }
+ if payload[0] != 0x12 {
+ return nil, fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", payload[0])
+ }
+ container, err := extractClaudeBytesField(payload, 2, "top-level protobuf")
+ if err != nil {
+ return nil, err
+ }
+ channelBlock, err := extractClaudeBytesField(container, 1, "Claude Field 2 container")
+ if err != nil {
+ return nil, err
+ }
+ return inspectClaudeChannelBlock(channelBlock, encodingLayers)
+}
+
+func inspectClaudeChannelBlock(channelBlock []byte, encodingLayers int) (*ClaudeSignatureTree, error) {
+ tree := &ClaudeSignatureTree{
+ EncodingLayers: encodingLayers,
+ RoutingClass: "unknown",
+ InfrastructureClass: "infra_unknown",
+ SchemaFeatures: "unknown_schema_features",
+ }
+ haveChannelID := false
+ hasField6 := false
+ hasField7 := false
+
+ err := walkClaudeProtobufFields(channelBlock, func(num protowire.Number, typ protowire.Type, raw []byte) error {
+ switch num {
+ case 1:
+ if typ != protowire.VarintType {
+ return fmt.Errorf("invalid Claude signature: Field 2.1.1 channel_id must be varint")
+ }
+ channelID, err := decodeClaudeVarintField(raw, "Field 2.1.1 channel_id")
+ if err != nil {
+ return err
+ }
+ tree.ChannelID = channelID
+ haveChannelID = true
+ case 2:
+ if typ != protowire.VarintType {
+ return fmt.Errorf("invalid Claude signature: Field 2.1.2 field2 must be varint")
+ }
+ field2, err := decodeClaudeVarintField(raw, "Field 2.1.2 field2")
+ if err != nil {
+ return err
+ }
+ tree.Field2 = &field2
+ case 6:
+ if typ != protowire.BytesType {
+ return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text must be bytes")
+ }
+ modelBytes, err := decodeClaudeBytesField(raw, "Field 2.1.6 model_text")
+ if err != nil {
+ return err
+ }
+ if !utf8.Valid(modelBytes) {
+ return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text is not valid UTF-8")
+ }
+ tree.ModelText = string(modelBytes)
+ hasField6 = true
+ case 7:
+ if typ != protowire.VarintType {
+ return fmt.Errorf("invalid Claude signature: Field 2.1.7 must be varint")
+ }
+ if _, err := decodeClaudeVarintField(raw, "Field 2.1.7"); err != nil {
+ return err
+ }
+ hasField7 = true
+ tree.HasField7 = true
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if !haveChannelID {
+ return nil, fmt.Errorf("invalid Claude signature: missing Field 2.1.1 channel_id")
+ }
+
+ switch tree.ChannelID {
+ case 11:
+ tree.RoutingClass = "routing_class_11"
+ case 12:
+ tree.RoutingClass = "routing_class_12"
+ }
+
+ if tree.Field2 == nil {
+ tree.InfrastructureClass = "infra_default"
+ } else {
+ switch *tree.Field2 {
+ case 1:
+ tree.InfrastructureClass = "infra_aws"
+ case 2:
+ tree.InfrastructureClass = "infra_google"
+ default:
+ tree.InfrastructureClass = "infra_unknown"
+ }
+ }
+
+ switch {
+ case hasField6:
+ tree.SchemaFeatures = "extended_model_tagged_schema"
+ case !hasField6 && !hasField7 && len(channelBlock) >= 70 && len(channelBlock) <= 72:
+ tree.SchemaFeatures = "compact_schema"
+ }
+
+ if tree.ChannelID == 11 {
+ switch {
+ case tree.Field2 == nil:
+ tree.LegacyRouteHint = "legacy_default_group"
+ case *tree.Field2 == 1:
+ tree.LegacyRouteHint = "legacy_aws_group"
+ case *tree.Field2 == 2 && tree.EncodingLayers == 2:
+ tree.LegacyRouteHint = "legacy_vertex_direct"
+ case *tree.Field2 == 2 && tree.EncodingLayers == 1:
+ tree.LegacyRouteHint = "legacy_vertex_proxy"
+ }
+ }
+
+ return tree, nil
+}
+
+func extractClaudeBytesField(msg []byte, fieldNum protowire.Number, scope string) ([]byte, error) {
+ var value []byte
+ err := walkClaudeProtobufFields(msg, func(num protowire.Number, typ protowire.Type, raw []byte) error {
+ if num != fieldNum {
+ return nil
+ }
+ if typ != protowire.BytesType {
+ return fmt.Errorf("invalid Claude signature: %s field %d must be bytes", scope, fieldNum)
+ }
+ bytesValue, err := decodeClaudeBytesField(raw, fmt.Sprintf("%s field %d", scope, fieldNum))
+ if err != nil {
+ return err
+ }
+ value = bytesValue
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if value == nil {
+ return nil, fmt.Errorf("invalid Claude signature: missing %s field %d", scope, fieldNum)
+ }
+ return value, nil
+}
+
+func walkClaudeProtobufFields(msg []byte, visit func(num protowire.Number, typ protowire.Type, raw []byte) error) error {
+ for offset := 0; offset < len(msg); {
+ num, typ, n := protowire.ConsumeTag(msg[offset:])
+ if n < 0 {
+ return fmt.Errorf("invalid Claude signature: malformed protobuf tag: %w", protowire.ParseError(n))
+ }
+ offset += n
+ valueLen := protowire.ConsumeFieldValue(num, typ, msg[offset:])
+ if valueLen < 0 {
+ return fmt.Errorf("invalid Claude signature: malformed protobuf field %d: %w", num, protowire.ParseError(valueLen))
+ }
+ fieldRaw := msg[offset : offset+valueLen]
+ if err := visit(num, typ, fieldRaw); err != nil {
+ return err
+ }
+ offset += valueLen
+ }
+ return nil
+}
+
+func decodeClaudeVarintField(raw []byte, label string) (uint64, error) {
+ value, n := protowire.ConsumeVarint(raw)
+ if n < 0 {
+ return 0, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
+ }
+ return value, nil
+}
+
+func decodeClaudeBytesField(raw []byte, label string) ([]byte, error) {
+ value, n := protowire.ConsumeBytes(raw)
+ if n < 0 {
+ return nil, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
+ }
+ return value, nil
+}
diff --git a/internal/signature/gemini_validation.go b/internal/signature/gemini_validation.go
new file mode 100644
index 00000000000..d3a6551126a
--- /dev/null
+++ b/internal/signature/gemini_validation.go
@@ -0,0 +1,497 @@
+// Gemini thought signature validation notes.
+//
+// The Antigravity Gemini request translator can preserve provider-compatible
+// Gemini thought signatures and uses the skip sentinel only for synthetic or
+// incompatible model parts.
+//
+// Gemini 3 and later models can return thoughtSignature on model content parts.
+// Function-call parts are the strict case: when a model functionCall is replayed
+// with a following functionResponse, Gemini validates that the original
+// functionCall part still carries its provider-issued thoughtSignature. Text or
+// other non-functionCall parts may also carry a signature; those should be
+// preserved when replaying native Gemini history, but they are not the primary
+// validation gate.
+//
+// Synthetic history and migration from other model families are different. If a
+// functionCall part was not produced by Gemini API, there is no real signature
+// to preserve. Gemini documents two bypass sentinels for that case:
+//
+// - "skip_thought_signature_validator"
+// - "context_engineering_is_the_way_to_go"
+//
+// This repo currently emits "skip_thought_signature_validator" for non-Claude
+// Antigravity Gemini model parts that contain functionCall, thought, or an
+// existing thoughtSignature. That is a request-shape compatibility policy, not a
+// proof that the replaced signature was malformed.
+//
+// This validator is intentionally more conservative than a decrypting verifier.
+// Claude has a known E/R base64 envelope and a protobuf tree in this package.
+// Gemini thought signatures are opaque provider state here, so local validation
+// checks only the transport-level protobuf envelope and leaves the wrapped
+// provider payload uninterpreted.
+//
+// Validation tiers:
+//
+// - Sentinel tier: accept the documented bypass sentinels only when the
+// model functionCall is synthetic, migrated, or otherwise not traceable to a
+// prior Gemini model response in the same conversation.
+// - Opaque-shape tier: for real Gemini signatures, require a non-empty string,
+// bounded length, successful standard base64 decoding, and a known protobuf
+// envelope when the caller needs provider compatibility. Observed samples
+// currently include Gemini 3.x field-2 -> field-1 payloads and Gemini 2.5
+// repeated field-1 payloads. Base64 UUID payloads are classified separately
+// and should be replaced with the bypass sentinel rather than replayed.
+// - Replay tier: real validation means preserving the exact model part that
+// came from Gemini, including its thoughtSignature, id/name/function args,
+// part index, and ordering relative to sibling parallel function calls.
+// - Tool pairing tier: functionResponse parts must match the preceding
+// functionCall id/name and must not be interleaved between parallel calls.
+// The valid shape is all model functionCalls first, then their responses.
+// - Compatibility tier: GPT-compatible Gemini traffic stores the same state
+// under tool_calls[].extra_content.google.thought_signature. If that path is
+// translated back to native Gemini, the value must stay attached to the same
+// assistant tool call.
+//
+// Important non-goals:
+//
+// - Do not treat a Gemini thoughtSignature as a Claude signature. Similar
+// base64 prefixes are not provenance.
+// - Do not attach a signature to user functionResponse/tool-result parts.
+// - Do not log complete signatures during validation failures; log only field
+// paths, lengths, and redacted prefixes.
+// - Do not preserve client-provided signatures across model/provider/session
+// boundaries unless the request pipeline can prove they came from the same
+// Gemini conversation state.
+package signature
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+
+ "github.com/tidwall/gjson"
+ "google.golang.org/protobuf/encoding/protowire"
+)
+
+const (
+ MaxGeminiThoughtSignatureLen = 32 * 1024 * 1024
+
+ GeminiSkipThoughtSignatureValidator = "skip_thought_signature_validator"
+ GeminiContextEngineeringBypass = "context_engineering_is_the_way_to_go"
+)
+
+// GeminiThoughtSignatureValidationOptions controls how much local validation is
+// applied to Gemini thought signatures. This validation checks only the opaque
+// transport envelope; it does not prove that a signature came from Gemini or can
+// be decrypted by Gemini.
+type GeminiThoughtSignatureValidationOptions struct {
+ // AllowBypassSentinel accepts Gemini's documented synthetic-history bypass
+ // sentinels. Keep this false when validating provider-issued signatures.
+ AllowBypassSentinel bool
+ // RequireKnownEnvelope requires the decoded payload to match one of the
+ // protobuf envelopes observed in Gemini samples. This rejects opaque base64
+ // values such as base64 UUIDs.
+ RequireKnownEnvelope bool
+ // RequireObservedMarker requires the decoded payload to start with 0x12.
+ // Current Gemini 3.x samples show this marker, but Gemini 2.5 samples use a
+ // different protobuf prefix, so this should be used only for narrow Gemini 3
+ // experiments.
+ RequireObservedMarker bool
+}
+
+type GeminiThoughtSignatureEnvelope string
+
+const (
+ GeminiThoughtSignatureEnvelopeUnknown GeminiThoughtSignatureEnvelope = "unknown"
+ GeminiThoughtSignatureEnvelopeProtobufField1 GeminiThoughtSignatureEnvelope = "protobuf_field_1"
+ GeminiThoughtSignatureEnvelopeProtobufField2 GeminiThoughtSignatureEnvelope = "protobuf_field_2"
+ GeminiThoughtSignatureEnvelopeASCIIUUID GeminiThoughtSignatureEnvelope = "ascii_uuid"
+)
+
+// GeminiThoughtSignatureInfo describes the locally inspectable properties of an
+// opaque Gemini thought signature.
+type GeminiThoughtSignatureInfo struct {
+ IsBypassSentinel bool
+ BypassSentinel string
+ DecodedLen int
+ FirstByte byte
+ HasObservedMarker bool
+ KnownEnvelope bool
+ Envelope GeminiThoughtSignatureEnvelope
+ RecordCount int
+ OpaquePayloadLen int
+}
+
+type geminiFunctionCallRef struct {
+ id string
+ name string
+ path string
+}
+
+type geminiFunctionResponseRef struct {
+ part gjson.Result
+ path string
+}
+
+func geminiThoughtSignatureValidationOptions(opts []GeminiThoughtSignatureValidationOptions) GeminiThoughtSignatureValidationOptions {
+ if len(opts) == 0 {
+ return GeminiThoughtSignatureValidationOptions{}
+ }
+ return opts[0]
+}
+
+// IsGeminiThoughtSignatureBypass reports whether rawSignature is one of
+// Gemini's documented bypass sentinels for synthetic or migrated function-call
+// history.
+func IsGeminiThoughtSignatureBypass(rawSignature string) bool {
+ switch strings.TrimSpace(rawSignature) {
+ case GeminiSkipThoughtSignatureValidator, GeminiContextEngineeringBypass:
+ return true
+ default:
+ return false
+ }
+}
+
+// IsValidGeminiThoughtSignature returns whether rawSignature has a valid local
+// Gemini thought-signature shape under opts.
+func IsValidGeminiThoughtSignature(rawSignature string, opts ...GeminiThoughtSignatureValidationOptions) bool {
+ _, err := InspectGeminiThoughtSignature(rawSignature, opts...)
+ return err == nil
+}
+
+// InspectGeminiThoughtSignature validates and inspects the local transport
+// shape of a Gemini thought signature. It intentionally treats provider-issued
+// signatures as opaque base64 payloads.
+func InspectGeminiThoughtSignature(rawSignature string, opts ...GeminiThoughtSignatureValidationOptions) (*GeminiThoughtSignatureInfo, error) {
+ opt := geminiThoughtSignatureValidationOptions(opts)
+ sig := strings.TrimSpace(rawSignature)
+ if sig == "" {
+ return nil, fmt.Errorf("empty Gemini thought signature")
+ }
+
+ if IsGeminiThoughtSignatureBypass(sig) {
+ if !opt.AllowBypassSentinel {
+ return nil, fmt.Errorf("Gemini thought signature bypass sentinel is not allowed")
+ }
+ return &GeminiThoughtSignatureInfo{
+ IsBypassSentinel: true,
+ BypassSentinel: sig,
+ }, nil
+ }
+
+ decoded, err := decodeGeminiThoughtSignature(sig)
+ if err != nil {
+ return nil, err
+ }
+ if len(decoded) == 0 {
+ return nil, fmt.Errorf("invalid Gemini thought signature: empty decoded payload")
+ }
+
+ info := &GeminiThoughtSignatureInfo{
+ DecodedLen: len(decoded),
+ FirstByte: decoded[0],
+ HasObservedMarker: decoded[0] == 0x12,
+ }
+ info.Envelope, info.KnownEnvelope = classifyGeminiThoughtSignatureEnvelope(decoded)
+ info.RecordCount, info.OpaquePayloadLen = inspectGeminiEnvelope(decoded, info.Envelope)
+ if opt.RequireKnownEnvelope && !info.KnownEnvelope {
+ return nil, fmt.Errorf("invalid Gemini thought signature: unknown envelope %q", info.Envelope)
+ }
+ if opt.RequireObservedMarker && !info.HasObservedMarker {
+ return nil, fmt.Errorf("invalid Gemini thought signature: expected observed marker 0x12, got 0x%02x", info.FirstByte)
+ }
+
+ return info, nil
+}
+
+// ValidateGeminiThoughtSignatures validates thoughtSignature fields in a Gemini
+// native payload. Function-call parts must have a valid signature. Other parts
+// are optional, but if a thoughtSignature field is present it must be valid.
+func ValidateGeminiThoughtSignatures(inputRawJSON []byte, opts ...GeminiThoughtSignatureValidationOptions) error {
+ contents, contentsPath := geminiContents(inputRawJSON)
+ if !contents.IsArray() {
+ return nil
+ }
+
+ contentResults := contents.Array()
+ for i := 0; i < len(contentResults); i++ {
+ parts := contentResults[i].Get("parts")
+ if !parts.IsArray() {
+ continue
+ }
+
+ partResults := parts.Array()
+ for j := 0; j < len(partResults); j++ {
+ part := partResults[j]
+ hasFunctionCall := part.Get("functionCall").Exists()
+ hasSignature := part.Get("thoughtSignature").Exists()
+ if !hasFunctionCall && !hasSignature {
+ continue
+ }
+
+ partPath := fmt.Sprintf("%s[%d].parts[%d]", contentsPath, i, j)
+ rawSignature := strings.TrimSpace(part.Get("thoughtSignature").String())
+ if rawSignature == "" {
+ if hasFunctionCall {
+ return fmt.Errorf("%s: missing thoughtSignature on functionCall", partPath)
+ }
+ return fmt.Errorf("%s: empty thoughtSignature", partPath)
+ }
+
+ if _, err := InspectGeminiThoughtSignature(rawSignature, opts...); err != nil {
+ return fmt.Errorf("%s: %w", partPath, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// ValidateGeminiFunctionCallPairing validates the replay shape around Gemini
+// functionCall and functionResponse parts. It checks id/name pairing and
+// prevents response parts from being interleaved inside the same content as
+// function calls. It allows a final pending functionCall group because callers
+// may validate a freshly returned model step before tool outputs exist.
+func ValidateGeminiFunctionCallPairing(inputRawJSON []byte) error {
+ contents, contentsPath := geminiContents(inputRawJSON)
+ if !contents.IsArray() {
+ return nil
+ }
+
+ var pending []geminiFunctionCallRef
+ contentResults := contents.Array()
+ for i := 0; i < len(contentResults); i++ {
+ parts := contentResults[i].Get("parts")
+ if !parts.IsArray() {
+ continue
+ }
+
+ var calls []geminiFunctionCallRef
+ var responses []geminiFunctionResponseRef
+ partResults := parts.Array()
+ for j := 0; j < len(partResults); j++ {
+ part := partResults[j]
+ partPath := fmt.Sprintf("%s[%d].parts[%d]", contentsPath, i, j)
+ if call := part.Get("functionCall"); call.Exists() {
+ if call.Get("name").String() == "" {
+ return fmt.Errorf("%s: missing functionCall.name", partPath)
+ }
+ calls = append(calls, geminiFunctionCallRef{
+ id: call.Get("id").String(),
+ name: call.Get("name").String(),
+ path: partPath,
+ })
+ }
+ if response := part.Get("functionResponse"); response.Exists() {
+ responses = append(responses, geminiFunctionResponseRef{
+ part: part,
+ path: partPath,
+ })
+ }
+ }
+
+ if len(calls) > 0 && len(responses) > 0 {
+ return fmt.Errorf("%s[%d]: functionCall and functionResponse parts must not be interleaved in the same content", contentsPath, i)
+ }
+
+ if len(calls) > 0 {
+ if len(pending) > 0 {
+ return fmt.Errorf("%s[%d]: functionCall appears before %d pending functionResponse part(s)", contentsPath, i, len(pending))
+ }
+ pending = calls
+ continue
+ }
+
+ if len(responses) == 0 {
+ continue
+ }
+ if len(pending) == 0 {
+ return fmt.Errorf("%s[%d]: functionResponse without preceding functionCall", contentsPath, i)
+ }
+ if len(responses) != len(pending) {
+ return fmt.Errorf("%s[%d]: functionResponse count %d does not match pending functionCall count %d", contentsPath, i, len(responses), len(pending))
+ }
+
+ for j := 0; j < len(responses); j++ {
+ partPath := responses[j].path
+ response := responses[j].part.Get("functionResponse")
+ call := pending[j]
+ responseID := response.Get("id").String()
+ responseName := response.Get("name").String()
+
+ if call.id != "" && responseID == "" {
+ return fmt.Errorf("%s: missing functionResponse.id for %s", partPath, call.path)
+ }
+ if call.id != "" && responseID != call.id {
+ return fmt.Errorf("%s: functionResponse.id %q does not match functionCall.id %q at %s", partPath, responseID, call.id, call.path)
+ }
+ if responseName == "" {
+ return fmt.Errorf("%s: missing functionResponse.name", partPath)
+ }
+ if call.name != "" && responseName != call.name {
+ return fmt.Errorf("%s: functionResponse.name %q does not match functionCall.name %q at %s", partPath, responseName, call.name, call.path)
+ }
+ }
+
+ pending = nil
+ }
+
+ return nil
+}
+
+func decodeGeminiThoughtSignature(sig string) ([]byte, error) {
+ if len(sig) > MaxGeminiThoughtSignatureLen {
+ return nil, fmt.Errorf("Gemini thought signature exceeds maximum length (%d bytes)", MaxGeminiThoughtSignatureLen)
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ if err == nil {
+ return decoded, nil
+ }
+ if decoded, rawErr := base64.RawStdEncoding.DecodeString(sig); rawErr == nil {
+ return decoded, nil
+ }
+
+ return nil, fmt.Errorf("invalid Gemini thought signature: base64 decode failed: %w", err)
+}
+
+func classifyGeminiThoughtSignatureEnvelope(decoded []byte) (GeminiThoughtSignatureEnvelope, bool) {
+ if len(decoded) == 0 {
+ return GeminiThoughtSignatureEnvelopeUnknown, false
+ }
+ if isASCIIUUIDBytes(decoded) {
+ return GeminiThoughtSignatureEnvelopeASCIIUUID, false
+ }
+ switch {
+ case isGeminiField1Envelope(decoded):
+ return GeminiThoughtSignatureEnvelopeProtobufField1, true
+ case isGeminiField2Envelope(decoded):
+ return GeminiThoughtSignatureEnvelopeProtobufField2, true
+ default:
+ return GeminiThoughtSignatureEnvelopeUnknown, false
+ }
+}
+
+func isGeminiField1Envelope(decoded []byte) bool {
+ info, ok := inspectGeminiField1Envelope(decoded)
+ return ok && info.RecordCount > 0
+}
+
+func isGeminiField2Envelope(decoded []byte) bool {
+ info, ok := inspectGeminiField2Envelope(decoded)
+ return ok && info.RecordCount == 1 && info.OpaquePayloadLen > 0
+}
+
+func inspectGeminiEnvelope(decoded []byte, envelope GeminiThoughtSignatureEnvelope) (recordCount int, opaquePayloadLen int) {
+ switch envelope {
+ case GeminiThoughtSignatureEnvelopeProtobufField1:
+ if info, ok := inspectGeminiField1Envelope(decoded); ok {
+ return info.RecordCount, info.OpaquePayloadLen
+ }
+ case GeminiThoughtSignatureEnvelopeProtobufField2:
+ if info, ok := inspectGeminiField2Envelope(decoded); ok {
+ return info.RecordCount, info.OpaquePayloadLen
+ }
+ }
+ return 0, 0
+}
+
+type geminiEnvelopeInfo struct {
+ RecordCount int
+ OpaquePayloadLen int
+}
+
+func inspectGeminiField1Envelope(decoded []byte) (geminiEnvelopeInfo, bool) {
+ var info geminiEnvelopeInfo
+ offset := 0
+ for offset < len(decoded) {
+ num, typ, n := protowire.ConsumeTag(decoded[offset:])
+ if n < 0 || num != 1 || typ != protowire.BytesType {
+ return geminiEnvelopeInfo{}, false
+ }
+ offset += n
+ value, n := protowire.ConsumeBytes(decoded[offset:])
+ if n < 0 || !isLikelyGeminiOpaquePayload(value) {
+ return geminiEnvelopeInfo{}, false
+ }
+ info.RecordCount++
+ info.OpaquePayloadLen += len(value)
+ offset += n
+ }
+ return info, offset == len(decoded) && info.RecordCount > 0
+}
+
+func inspectGeminiField2Envelope(decoded []byte) (geminiEnvelopeInfo, bool) {
+ value, ok := consumeGeminiField2Field1Value(decoded)
+ if !ok || !isLikelyGeminiOpaquePayload(value) {
+ return geminiEnvelopeInfo{}, false
+ }
+ return geminiEnvelopeInfo{
+ RecordCount: 1,
+ OpaquePayloadLen: len(value),
+ }, true
+}
+
+func consumeGeminiField2Field1Value(decoded []byte) ([]byte, bool) {
+ num, typ, n := protowire.ConsumeTag(decoded)
+ if n < 0 || num != 2 || typ != protowire.BytesType {
+ return nil, false
+ }
+ offset := n
+ container, n := protowire.ConsumeBytes(decoded[offset:])
+ if n < 0 {
+ return nil, false
+ }
+ offset += n
+ if offset != len(decoded) {
+ return nil, false
+ }
+
+ num, typ, n = protowire.ConsumeTag(container)
+ if n < 0 || num != 1 || typ != protowire.BytesType {
+ return nil, false
+ }
+ containerOffset := n
+ value, n := protowire.ConsumeBytes(container[containerOffset:])
+ if n < 0 {
+ return nil, false
+ }
+ containerOffset += n
+ if containerOffset != len(container) {
+ return nil, false
+ }
+ return value, true
+}
+
+func isLikelyGeminiOpaquePayload(value []byte) bool {
+ // Observed Gemini 2.5 and Gemini 3.x envelopes wrap provider-opaque
+ // payloads that start with an internal version byte 0x01. The bytes after
+ // that are high-entropy provider state and must remain opaque.
+ return len(value) > 0 && value[0] == 0x01
+}
+
+func isASCIIUUIDBytes(decoded []byte) bool {
+ if len(decoded) != 36 {
+ return false
+ }
+ for i, b := range decoded {
+ switch i {
+ case 8, 13, 18, 23:
+ if b != '-' {
+ return false
+ }
+ default:
+ if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func geminiContents(inputRawJSON []byte) (gjson.Result, string) {
+ if contents := gjson.GetBytes(inputRawJSON, "contents"); contents.Exists() {
+ return contents, "contents"
+ }
+ return gjson.GetBytes(inputRawJSON, "request.contents"), "request.contents"
+}
diff --git a/internal/signature/gemini_validation_test.go b/internal/signature/gemini_validation_test.go
new file mode 100644
index 00000000000..add57a6b3aa
--- /dev/null
+++ b/internal/signature/gemini_validation_test.go
@@ -0,0 +1,393 @@
+package signature
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "google.golang.org/protobuf/encoding/protowire"
+)
+
+func testGeminiThoughtSignature(payload []byte) string {
+ return base64.StdEncoding.EncodeToString(payload)
+}
+
+func testGemini25ThoughtSignature(records ...[]byte) string {
+ var payload []byte
+ for _, record := range records {
+ payload = protowire.AppendTag(payload, 1, protowire.BytesType)
+ payload = protowire.AppendBytes(payload, record)
+ }
+ return testGeminiThoughtSignature(payload)
+}
+
+func testGemini3ThoughtSignature(payload []byte) string {
+ var inner []byte
+ inner = protowire.AppendTag(inner, 1, protowire.BytesType)
+ inner = protowire.AppendBytes(inner, payload)
+
+ var outer []byte
+ outer = protowire.AppendTag(outer, 2, protowire.BytesType)
+ outer = protowire.AppendBytes(outer, inner)
+ return testGeminiThoughtSignature(outer)
+}
+
+func TestInspectGeminiThoughtSignature_AcceptsOpaqueBase64(t *testing.T) {
+ sig := testGeminiThoughtSignature([]byte{0x12, 0x34, 0x56})
+
+ info, err := InspectGeminiThoughtSignature(sig)
+ if err != nil {
+ t.Fatalf("InspectGeminiThoughtSignature failed: %v", err)
+ }
+ if info.IsBypassSentinel {
+ t.Fatal("real signature should not be marked as bypass sentinel")
+ }
+ if info.DecodedLen != 3 {
+ t.Fatalf("DecodedLen = %d, want 3", info.DecodedLen)
+ }
+ if info.FirstByte != 0x12 {
+ t.Fatalf("FirstByte = 0x%02x, want 0x12", info.FirstByte)
+ }
+ if !info.HasObservedMarker {
+ t.Fatal("HasObservedMarker should be true")
+ }
+ if info.Envelope != GeminiThoughtSignatureEnvelopeUnknown {
+ t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeUnknown)
+ }
+ if info.KnownEnvelope {
+ t.Fatal("KnownEnvelope should be false for incomplete opaque payload")
+ }
+}
+
+func TestInspectGeminiThoughtSignature_AcceptsGemini31ProField2Envelope(t *testing.T) {
+ // Shape observed in CPA-API/signatures/gemini/gemini-3.1-pro.txt.
+ sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
+
+ info, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true})
+ if err != nil {
+ t.Fatalf("Gemini 3.1 Pro field-2 envelope should be known: %v", err)
+ }
+ if info.Envelope != GeminiThoughtSignatureEnvelopeProtobufField2 {
+ t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeProtobufField2)
+ }
+ if !info.HasObservedMarker {
+ t.Fatal("Gemini 3.1 Pro envelope should be marked as 0x12")
+ }
+ if info.RecordCount != 1 {
+ t.Fatalf("RecordCount = %d, want 1", info.RecordCount)
+ }
+ if info.OpaquePayloadLen != 6 {
+ t.Fatalf("OpaquePayloadLen = %d, want 6", info.OpaquePayloadLen)
+ }
+}
+
+func TestInspectGeminiThoughtSignature_AcceptsCapturedGemini31FlashLiteEnvelope(t *testing.T) {
+ // Captured in CPA-API/signatures/gemini/gemini-3.1-flash-lite.txt.
+ const sig = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
+
+ info, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true})
+ if err != nil {
+ t.Fatalf("captured Gemini 3.1 Flash Lite envelope should be known: %v", err)
+ }
+ if info.Envelope != GeminiThoughtSignatureEnvelopeProtobufField2 {
+ t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeProtobufField2)
+ }
+ if info.RecordCount != 1 {
+ t.Fatalf("RecordCount = %d, want 1", info.RecordCount)
+ }
+ if info.OpaquePayloadLen != 50 {
+ t.Fatalf("OpaquePayloadLen = %d, want 50", info.OpaquePayloadLen)
+ }
+}
+
+func TestInspectGeminiThoughtSignature_AcceptsGemini25Field1Envelope(t *testing.T) {
+ sig := testGemini25ThoughtSignature([]byte{0x01, 0x8f}, []byte{0x01, 0x90, 0x91})
+
+ info, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true})
+ if err != nil {
+ t.Fatalf("Gemini 2.5 field-1 envelope should be known: %v", err)
+ }
+ if info.Envelope != GeminiThoughtSignatureEnvelopeProtobufField1 {
+ t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeProtobufField1)
+ }
+ if info.HasObservedMarker {
+ t.Fatal("Gemini 2.5 field-1 envelope should not be marked as 0x12")
+ }
+ if info.RecordCount != 2 {
+ t.Fatalf("RecordCount = %d, want 2", info.RecordCount)
+ }
+ if info.OpaquePayloadLen != 5 {
+ t.Fatalf("OpaquePayloadLen = %d, want 5", info.OpaquePayloadLen)
+ }
+}
+
+func TestInspectGeminiThoughtSignature_RejectsMalformedKnownEnvelope(t *testing.T) {
+ // Field 2 with a nested field 1 is not enough. Observed Gemini 3 payloads
+ // wrap an opaque blob that starts with internal version byte 0x01.
+ sig := testGemini3ThoughtSignature([]byte{0x02, 0x0c, 0x39})
+
+ if IsValidGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
+ t.Fatal("malformed Gemini 3 envelope should fail known-envelope validation")
+ }
+}
+
+func TestInspectGeminiThoughtSignature_ClassifiesASCIIUUIDAsOpaque(t *testing.T) {
+ sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
+
+ info, err := InspectGeminiThoughtSignature(sig)
+ if err != nil {
+ t.Fatalf("opaque base64 UUID should pass default validation: %v", err)
+ }
+ if info.Envelope != GeminiThoughtSignatureEnvelopeASCIIUUID {
+ t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeASCIIUUID)
+ }
+ if info.KnownEnvelope {
+ t.Fatal("base64 UUID should not be a known protobuf envelope")
+ }
+ if IsValidGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
+ t.Fatal("base64 UUID should fail when known envelope is required")
+ }
+}
+
+func TestInspectGeminiThoughtSignature_ObservedMarkerOption(t *testing.T) {
+ sig := testGeminiThoughtSignature([]byte{0x45, 0x12})
+
+ if _, err := InspectGeminiThoughtSignature(sig); err != nil {
+ t.Fatalf("default validation should accept opaque base64 payload: %v", err)
+ }
+ _, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireObservedMarker: true})
+ if err == nil {
+ t.Fatal("RequireObservedMarker should reject payloads without 0x12 marker")
+ }
+ if !strings.Contains(err.Error(), "expected observed marker") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestInspectGeminiThoughtSignature_BypassSentinelRequiresOption(t *testing.T) {
+ if IsValidGeminiThoughtSignature(GeminiSkipThoughtSignatureValidator) {
+ t.Fatal("bypass sentinel should not be valid by default")
+ }
+
+ info, err := InspectGeminiThoughtSignature(GeminiSkipThoughtSignatureValidator, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true})
+ if err != nil {
+ t.Fatalf("bypass sentinel should be accepted when explicitly allowed: %v", err)
+ }
+ if !info.IsBypassSentinel {
+ t.Fatal("sentinel should be marked as bypass")
+ }
+ if info.BypassSentinel != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("BypassSentinel = %q, want %q", info.BypassSentinel, GeminiSkipThoughtSignatureValidator)
+ }
+}
+
+func TestInspectGeminiThoughtSignature_RejectsInvalidBase64(t *testing.T) {
+ if IsValidGeminiThoughtSignature("not valid base64!!!") {
+ t.Fatal("invalid base64 should be rejected")
+ }
+}
+
+func TestValidateGeminiThoughtSignatures_FunctionCallRequiresSignature(t *testing.T) {
+ input := []byte(`{
+ "contents": [{
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call-1", "name": "read_file", "args": {}}}
+ ]
+ }]
+ }`)
+
+ err := ValidateGeminiThoughtSignatures(input)
+ if err == nil {
+ t.Fatal("missing functionCall thoughtSignature should fail")
+ }
+ if !strings.Contains(err.Error(), "missing thoughtSignature on functionCall") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateGeminiThoughtSignatures_AcceptsWrappedRequestAndSentinelWhenAllowed(t *testing.T) {
+ input := []byte(`{
+ "request": {
+ "contents": [{
+ "role": "model",
+ "parts": [
+ {
+ "functionCall": {"id": "call-1", "name": "read_file", "args": {}},
+ "thoughtSignature": "skip_thought_signature_validator"
+ }
+ ]
+ }]
+ }
+ }`)
+
+ err := ValidateGeminiThoughtSignatures(input, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true})
+ if err != nil {
+ t.Fatalf("sentinel should be valid when explicitly allowed: %v", err)
+ }
+}
+
+func TestValidateGeminiThoughtSignatures_RejectsInvalidTextPartSignature(t *testing.T) {
+ input := []byte(`{
+ "contents": [{
+ "role": "model",
+ "parts": [
+ {"text": "previous answer", "thoughtSignature": "bad!!!"}
+ ]
+ }]
+ }`)
+
+ err := ValidateGeminiThoughtSignatures(input)
+ if err == nil {
+ t.Fatal("invalid text-part thoughtSignature should fail")
+ }
+ if !strings.Contains(err.Error(), "base64 decode failed") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateGeminiFunctionCallPairing_ValidParallelGroup(t *testing.T) {
+ input := []byte(`{
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call-1", "name": "weather", "args": {"city": "Paris"}}},
+ {"functionCall": {"id": "call-2", "name": "weather", "args": {"city": "London"}}}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [
+ {"functionResponse": {"id": "call-1", "name": "weather", "response": {"temp": "15C"}}},
+ {"functionResponse": {"id": "call-2", "name": "weather", "response": {"temp": "12C"}}}
+ ]
+ }
+ ]
+ }`)
+
+ if err := ValidateGeminiFunctionCallPairing(input); err != nil {
+ t.Fatalf("valid pairing failed: %v", err)
+ }
+}
+
+func TestValidateGeminiFunctionCallPairing_RejectsResponseCountMismatch(t *testing.T) {
+ input := []byte(`{
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call-1", "name": "weather", "args": {}}},
+ {"functionCall": {"id": "call-2", "name": "weather", "args": {}}}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [
+ {"functionResponse": {"id": "call-1", "name": "weather", "response": {}}}
+ ]
+ }
+ ]
+ }`)
+
+ err := ValidateGeminiFunctionCallPairing(input)
+ if err == nil {
+ t.Fatal("response count mismatch should fail")
+ }
+ if !strings.Contains(err.Error(), "does not match pending functionCall count") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateGeminiFunctionCallPairing_RejectsMissingFunctionCallName(t *testing.T) {
+ input := []byte(`{
+ "contents": [{
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call-1", "args": {}}}
+ ]
+ }]
+ }`)
+
+ err := ValidateGeminiFunctionCallPairing(input)
+ if err == nil {
+ t.Fatal("missing functionCall name should fail")
+ }
+ if !strings.Contains(err.Error(), "missing functionCall.name") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateGeminiFunctionCallPairing_RejectsIDMismatch(t *testing.T) {
+ input := []byte(`{
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call-1", "name": "weather", "args": {}}}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [
+ {"functionResponse": {"id": "call-other", "name": "weather", "response": {}}}
+ ]
+ }
+ ]
+ }`)
+
+ err := ValidateGeminiFunctionCallPairing(input)
+ if err == nil {
+ t.Fatal("id mismatch should fail")
+ }
+ if !strings.Contains(err.Error(), "does not match functionCall.id") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateGeminiFunctionCallPairing_RejectsMissingResponseName(t *testing.T) {
+ input := []byte(`{
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call-1", "name": "weather", "args": {}}}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [
+ {"functionResponse": {"id": "call-1", "response": {}}}
+ ]
+ }
+ ]
+ }`)
+
+ err := ValidateGeminiFunctionCallPairing(input)
+ if err == nil {
+ t.Fatal("missing response name should fail")
+ }
+ if !strings.Contains(err.Error(), "missing functionResponse.name") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateGeminiFunctionCallPairing_RejectsSameContentInterleaving(t *testing.T) {
+ input := []byte(`{
+ "contents": [{
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call-1", "name": "weather", "args": {}}},
+ {"functionResponse": {"id": "call-1", "name": "weather", "response": {}}}
+ ]
+ }]
+ }`)
+
+ err := ValidateGeminiFunctionCallPairing(input)
+ if err == nil {
+ t.Fatal("same-content interleaving should fail")
+ }
+ if !strings.Contains(err.Error(), "must not be interleaved") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
diff --git a/internal/signature/gpt_validation.go b/internal/signature/gpt_validation.go
new file mode 100644
index 00000000000..8cbd66281c7
--- /dev/null
+++ b/internal/signature/gpt_validation.go
@@ -0,0 +1,83 @@
+package signature
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+)
+
+const MaxGPTReasoningSignatureLen = 32 * 1024 * 1024
+
+type GPTReasoningSignatureInfo struct {
+ DecodedLen int
+ CiphertextLen int
+}
+
+func IsValidGPTReasoningSignature(rawSignature string) bool {
+ _, err := InspectGPTReasoningSignature(rawSignature)
+ return err == nil
+}
+
+// InspectGPTReasoningSignature validates the Fernet-like outer format used
+// by GPT/Codex reasoning encrypted_content. This is only a transport-shape
+// check; it does not prove decryptability.
+func InspectGPTReasoningSignature(rawSignature string) (*GPTReasoningSignatureInfo, error) {
+ sig := strings.TrimSpace(rawSignature)
+ if sig == "" {
+ return nil, fmt.Errorf("empty GPT reasoning signature")
+ }
+ if len(sig) > MaxGPTReasoningSignatureLen {
+ return nil, fmt.Errorf("GPT reasoning signature exceeds maximum length (%d bytes)", MaxGPTReasoningSignatureLen)
+ }
+ if index, r, ok := firstInvalidGPTReasoningSignatureChar(sig); ok {
+ return nil, fmt.Errorf("invalid GPT reasoning signature: contains non-base64url character U+%04X at byte %d", r, index)
+ }
+ if !strings.HasPrefix(sig, "gAAAA") {
+ return nil, fmt.Errorf("invalid GPT reasoning signature: expected gAAAA prefix")
+ }
+
+ decoded, err := decodeGPTReasoningSignature(sig)
+ if err != nil {
+ return nil, err
+ }
+ if len(decoded) < 73 {
+ return nil, fmt.Errorf("invalid GPT reasoning signature: decoded payload too short")
+ }
+ if decoded[0] != 0x80 {
+ return nil, fmt.Errorf("invalid GPT reasoning signature: expected version 0x80, got 0x%02x", decoded[0])
+ }
+
+ ciphertextLen := len(decoded) - 1 - 8 - 16 - 32
+ if ciphertextLen <= 0 || ciphertextLen%16 != 0 {
+ return nil, fmt.Errorf("invalid GPT reasoning signature: ciphertext length %d is not a positive AES block multiple", ciphertextLen)
+ }
+
+ return &GPTReasoningSignatureInfo{
+ DecodedLen: len(decoded),
+ CiphertextLen: ciphertextLen,
+ }, nil
+}
+
+func decodeGPTReasoningSignature(sig string) ([]byte, error) {
+ if decoded, err := base64.RawURLEncoding.DecodeString(sig); err == nil {
+ return decoded, nil
+ }
+ if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil {
+ return decoded, nil
+ }
+ return nil, fmt.Errorf("invalid GPT reasoning signature: base64url decode failed")
+}
+
+func firstInvalidGPTReasoningSignatureChar(sig string) (int, rune, bool) {
+ for index, r := range sig {
+ switch {
+ case r >= 'A' && r <= 'Z':
+ case r >= 'a' && r <= 'z':
+ case r >= '0' && r <= '9':
+ case r == '-' || r == '_' || r == '=':
+ default:
+ return index, r, true
+ }
+ }
+ return 0, 0, false
+}
diff --git a/internal/signature/gpt_validation_test.go b/internal/signature/gpt_validation_test.go
new file mode 100644
index 00000000000..21befa8285f
--- /dev/null
+++ b/internal/signature/gpt_validation_test.go
@@ -0,0 +1,35 @@
+package signature
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+func testGPTReasoningSignature() string {
+ payload := make([]byte, 1+8+16+16+32)
+ payload[0] = 0x80
+ for i := 9; i < len(payload); i++ {
+ payload[i] = byte(i)
+ }
+ return base64.RawURLEncoding.EncodeToString(payload)
+}
+
+func TestDetectSignatureProvider_GPTReasoning(t *testing.T) {
+ if got := DetectSignatureProvider(testGPTReasoningSignature()); got != SignatureProviderGPT {
+ t.Fatalf("DetectSignatureProvider(GPT) = %q, want %q", got, SignatureProviderGPT)
+ }
+}
+
+func TestInspectGPTReasoningSignatureRejectsUnicodeEllipsis(t *testing.T) {
+ sig := testGPTReasoningSignature()
+ polluted := sig[:20] + string(rune(0x2026)) + sig[20:]
+
+ _, err := InspectGPTReasoningSignature(polluted)
+ if err == nil {
+ t.Fatal("expected invalid GPT reasoning signature")
+ }
+ if !strings.Contains(err.Error(), "non-base64url character U+2026") {
+ t.Fatalf("error = %q, want U+2026 base64url detail", err.Error())
+ }
+}
diff --git a/internal/signature/provider_compatibility.go b/internal/signature/provider_compatibility.go
new file mode 100644
index 00000000000..6cdb896fb0c
--- /dev/null
+++ b/internal/signature/provider_compatibility.go
@@ -0,0 +1,283 @@
+package signature
+
+import "strings"
+
+type SignatureProvider string
+
+const (
+ SignatureProviderUnknown SignatureProvider = "unknown"
+ SignatureProviderClaude SignatureProvider = "claude"
+ SignatureProviderGemini SignatureProvider = "gemini"
+ SignatureProviderGeminiBypass SignatureProvider = "gemini_bypass"
+ SignatureProviderGPT SignatureProvider = "gpt"
+)
+
+type SignatureBlockKind string
+
+const (
+ SignatureBlockKindUnknown SignatureBlockKind = "unknown"
+ SignatureBlockKindClaudeThinking SignatureBlockKind = "claude_thinking"
+ SignatureBlockKindGeminiModelPart SignatureBlockKind = "gemini_model_part"
+ SignatureBlockKindGeminiFunctionCall SignatureBlockKind = "gemini_function_call"
+ SignatureBlockKindGPTReasoning SignatureBlockKind = "gpt_reasoning"
+)
+
+type SignatureCompatibilityAction string
+
+const (
+ SignatureActionPreserve SignatureCompatibilityAction = "preserve"
+ SignatureActionDropBlock SignatureCompatibilityAction = "drop_block"
+ SignatureActionDropSignature SignatureCompatibilityAction = "drop_signature"
+ SignatureActionReplaceWithGeminiBypass SignatureCompatibilityAction = "replace_with_gemini_bypass"
+ SignatureActionNoCompatibleReplacement SignatureCompatibilityAction = "no_compatible_replacement"
+)
+
+type SignatureCompatibilityDecision struct {
+ TargetProvider SignatureProvider
+ DetectedProvider SignatureProvider
+ BlockKind SignatureBlockKind
+ Compatible bool
+ Action SignatureCompatibilityAction
+ ReplacementSignature string
+ NormalizedSignature string
+ Reason string
+}
+
+// SignatureProviderFromModelName maps common model names to the provider family
+// whose signed history can be safely replayed for that model.
+func SignatureProviderFromModelName(modelName string) SignatureProvider {
+ lower := strings.ToLower(strings.TrimSpace(modelName))
+ switch {
+ case strings.Contains(lower, "claude"):
+ return SignatureProviderClaude
+ case strings.Contains(lower, "gemini"):
+ return SignatureProviderGemini
+ case strings.Contains(lower, "gpt"),
+ strings.Contains(lower, "openai"),
+ strings.Contains(lower, "codex"),
+ strings.HasPrefix(lower, "o1"),
+ strings.HasPrefix(lower, "o3"),
+ strings.HasPrefix(lower, "o4"):
+ return SignatureProviderGPT
+ default:
+ return SignatureProviderUnknown
+ }
+}
+
+// DetectSignatureProvider classifies the provider family that can replay
+// rawSignature. It intentionally uses Claude strict validation before Gemini
+// detection because Gemini 3 signatures also decode from an E-prefixed base64
+// string and can look Claude-like under shallow prefix checks.
+func DetectSignatureProvider(rawSignature string) SignatureProvider {
+ return DetectSignatureProviderForBlock(rawSignature, SignatureBlockKindUnknown)
+}
+
+// DetectSignatureProviderForBlock classifies rawSignature with block-kind
+// context. UUID-shaped payloads are deliberately not classified as replay-safe
+// provider signatures; callers targeting Gemini should replace them with the
+// bypass sentinel.
+func DetectSignatureProviderForBlock(rawSignature string, blockKind SignatureBlockKind) SignatureProvider {
+ sig := strings.TrimSpace(rawSignature)
+ if sig == "" {
+ return SignatureProviderUnknown
+ }
+
+ if prefixedProvider, unprefixed, ok := SplitSignatureProviderPrefix(sig); ok {
+ switch prefixedProvider {
+ case SignatureProviderGemini:
+ if IsGeminiThoughtSignatureBypass(unprefixed) {
+ return SignatureProviderGeminiBypass
+ }
+ if isRecognizedGeminiProviderSignature(unprefixed, blockKind) {
+ return SignatureProviderGemini
+ }
+ case SignatureProviderClaude:
+ if IsValidClaudeThinkingSignature(unprefixed, ClaudeSignatureValidationOptions{Strict: true}) {
+ return SignatureProviderClaude
+ }
+ case SignatureProviderGPT:
+ if IsValidGPTReasoningSignature(unprefixed) {
+ return SignatureProviderGPT
+ }
+ }
+ return SignatureProviderUnknown
+ }
+ if strings.Contains(sig, "#") {
+ return SignatureProviderUnknown
+ }
+
+ if IsGeminiThoughtSignatureBypass(sig) {
+ return SignatureProviderGeminiBypass
+ }
+ if IsValidGPTReasoningSignature(sig) {
+ return SignatureProviderGPT
+ }
+ if IsValidClaudeThinkingSignature(sig, ClaudeSignatureValidationOptions{Strict: true}) {
+ return SignatureProviderClaude
+ }
+ if isRecognizedGeminiProviderSignature(sig, blockKind) {
+ return SignatureProviderGemini
+ }
+ return SignatureProviderUnknown
+}
+
+func IsSignatureCompatibleWithProvider(targetProvider SignatureProvider, rawSignature string) bool {
+ decision := DecideSignatureCompatibility(targetProvider, rawSignature, SignatureBlockKindUnknown)
+ return decision.Compatible
+}
+
+// DecideSignatureCompatibility returns the safe handling policy for replaying a
+// signed block into targetProvider.
+func DecideSignatureCompatibility(targetProvider SignatureProvider, rawSignature string, blockKind SignatureBlockKind) SignatureCompatibilityDecision {
+ targetProvider = normalizeSignatureTargetProvider(targetProvider)
+ if blockKind == "" {
+ blockKind = SignatureBlockKindUnknown
+ }
+
+ detected := DetectSignatureProviderForBlock(rawSignature, blockKind)
+ decision := SignatureCompatibilityDecision{
+ TargetProvider: targetProvider,
+ DetectedProvider: detected,
+ BlockKind: blockKind,
+ }
+
+ if signatureProviderMatchesTarget(targetProvider, detected) {
+ decision.Compatible = true
+ decision.Action = SignatureActionPreserve
+ decision.NormalizedSignature = normalizeCompatibleSignatureForProvider(targetProvider, rawSignature, blockKind)
+ decision.Reason = "signature provider matches target provider"
+ return decision
+ }
+
+ decision.Compatible = false
+ switch targetProvider {
+ case SignatureProviderGemini:
+ if blockKind == SignatureBlockKindGeminiFunctionCall || blockKind == SignatureBlockKindGeminiModelPart || blockKind == SignatureBlockKindUnknown {
+ decision.Action = SignatureActionReplaceWithGeminiBypass
+ decision.ReplacementSignature = GeminiSkipThoughtSignatureValidator
+ decision.Reason = "Gemini can bypass synthetic or incompatible model-part signatures with the documented sentinel"
+ return decision
+ }
+ decision.Action = SignatureActionDropBlock
+ decision.Reason = "signature is not compatible with Gemini and this block is not a bypass-safe Gemini model part"
+ case SignatureProviderClaude:
+ decision.Action = SignatureActionDropBlock
+ decision.Reason = "Claude has no cross-provider bypass sentinel for thinking blocks"
+ case SignatureProviderGPT:
+ decision.Action = SignatureActionDropBlock
+ decision.Reason = "GPT reasoning encrypted_content cannot be synthesized from another provider signature"
+ default:
+ decision.Action = SignatureActionNoCompatibleReplacement
+ decision.Reason = "unknown target provider"
+ }
+ return decision
+}
+
+func SplitSignatureProviderPrefix(rawSignature string) (SignatureProvider, string, bool) {
+ prefix, rest, ok := strings.Cut(strings.TrimSpace(rawSignature), "#")
+ if !ok {
+ return SignatureProviderUnknown, rawSignature, false
+ }
+ provider := SignatureProviderFromCachePrefix(prefix)
+ if provider == SignatureProviderUnknown {
+ return SignatureProviderUnknown, rawSignature, false
+ }
+ return provider, strings.TrimSpace(rest), true
+}
+
+// SignatureProviderFromCachePrefix maps this repo's explicit provider-prefix
+// envelope to a provider family. This is intentionally stricter than
+// SignatureProviderFromModelName so arbitrary model names such as
+// "claude-cache#..." cannot be mistaken for trusted provider provenance.
+func SignatureProviderFromCachePrefix(prefix string) SignatureProvider {
+ switch strings.ToLower(strings.TrimSpace(prefix)) {
+ case "claude", "anthropic":
+ return SignatureProviderClaude
+ case "gemini", "google":
+ return SignatureProviderGemini
+ case "openai", "gpt", "codex":
+ return SignatureProviderGPT
+ default:
+ return SignatureProviderUnknown
+ }
+}
+
+// SignaturePayloadWithoutProviderPrefix strips this repo's provider cache prefix
+// when present. The returned string is the value that should be replayed to an
+// upstream provider.
+func SignaturePayloadWithoutProviderPrefix(rawSignature string) string {
+ if _, unprefixed, ok := SplitSignatureProviderPrefix(rawSignature); ok {
+ return unprefixed
+ }
+ return strings.TrimSpace(rawSignature)
+}
+
+// CompatibleSignatureForProvider returns a replayable provider-native signature
+// for targetProvider. It strips this repo's provider prefix and normalizes
+// Claude signatures to the format expected by the target when possible.
+func CompatibleSignatureForProvider(targetProvider SignatureProvider, rawSignature string) (string, bool) {
+ return CompatibleSignatureForProviderBlock(targetProvider, rawSignature, SignatureBlockKindUnknown)
+}
+
+// CompatibleSignatureForProviderBlock returns a replayable provider-native
+// signature for targetProvider when the source block kind is known.
+func CompatibleSignatureForProviderBlock(targetProvider SignatureProvider, rawSignature string, blockKind SignatureBlockKind) (string, bool) {
+ decision := DecideSignatureCompatibility(targetProvider, rawSignature, blockKind)
+ if !decision.Compatible || decision.NormalizedSignature == "" {
+ return "", false
+ }
+ return decision.NormalizedSignature, true
+}
+
+func normalizeSignatureTargetProvider(provider SignatureProvider) SignatureProvider {
+ switch provider {
+ case SignatureProviderGeminiBypass:
+ return SignatureProviderGemini
+ default:
+ return provider
+ }
+}
+
+func signatureProviderMatchesTarget(target, detected SignatureProvider) bool {
+ switch target {
+ case SignatureProviderGemini:
+ return detected == SignatureProviderGemini || detected == SignatureProviderGeminiBypass
+ case SignatureProviderClaude:
+ return detected == SignatureProviderClaude
+ case SignatureProviderGPT:
+ return detected == SignatureProviderGPT
+ default:
+ return false
+ }
+}
+
+func normalizeCompatibleSignatureForProvider(targetProvider SignatureProvider, rawSignature string, blockKind SignatureBlockKind) string {
+ payload := SignaturePayloadWithoutProviderPrefix(rawSignature)
+ switch normalizeSignatureTargetProvider(targetProvider) {
+ case SignatureProviderClaude:
+ normalized, err := NormalizeClaudeThinkingSignature(payload)
+ if err != nil {
+ return ""
+ }
+ return normalized
+ case SignatureProviderGemini:
+ if IsGeminiThoughtSignatureBypass(payload) {
+ return payload
+ }
+ if isRecognizedGeminiProviderSignature(payload, blockKind) {
+ return payload
+ }
+ case SignatureProviderGPT:
+ if IsValidGPTReasoningSignature(payload) {
+ return payload
+ }
+ }
+ return ""
+}
+
+func isRecognizedGeminiProviderSignature(rawSignature string, blockKind SignatureBlockKind) bool {
+ if IsValidGeminiThoughtSignature(rawSignature, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
+ return true
+ }
+ return false
+}
diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go
new file mode 100644
index 00000000000..5768d11cb4b
--- /dev/null
+++ b/internal/signature/provider_compatibility_test.go
@@ -0,0 +1,248 @@
+package signature
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "github.com/tidwall/gjson"
+ "google.golang.org/protobuf/encoding/protowire"
+)
+
+func testClaudeThinkingSignature() string {
+ channelBlock := []byte{}
+ channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 12)
+ channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 2)
+ channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
+ channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
+
+ container := []byte{}
+ container = protowire.AppendTag(container, 1, protowire.BytesType)
+ container = protowire.AppendBytes(container, channelBlock)
+
+ payload := []byte{}
+ payload = protowire.AppendTag(payload, 2, protowire.BytesType)
+ payload = protowire.AppendBytes(payload, container)
+ payload = protowire.AppendTag(payload, 3, protowire.VarintType)
+ payload = protowire.AppendVarint(payload, 1)
+ return base64.StdEncoding.EncodeToString(payload)
+}
+
+func TestDetectSignatureProvider_UsesProviderPrefix(t *testing.T) {
+ claudeSig := "claude#" + testClaudeThinkingSignature()
+ if got := DetectSignatureProvider(claudeSig); got != SignatureProviderClaude {
+ t.Fatalf("DetectSignatureProvider(claude#...) = %q, want %q", got, SignatureProviderClaude)
+ }
+
+ geminiSig := "gemini#" + testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
+ if got := DetectSignatureProvider(geminiSig); got != SignatureProviderGemini {
+ t.Fatalf("DetectSignatureProvider(gemini#...) = %q, want %q", got, SignatureProviderGemini)
+ }
+}
+
+func TestDetectSignatureProvider_RejectsMisleadingClaudePrefix(t *testing.T) {
+ mislabeledGeminiSig := "claude#" + testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
+ if got := DetectSignatureProvider(mislabeledGeminiSig); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(mislabeled claude#Gemini) = %q, want %q", got, SignatureProviderUnknown)
+ }
+}
+
+func TestDetectSignatureProvider_Gemini3EPrefixDoesNotLookClaude(t *testing.T) {
+ // This byte shape base64-encodes with an E prefix but is a Gemini field-2
+ // envelope, not a Claude thinking-signature tree.
+ geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
+ if !strings.HasPrefix(geminiSig, "E") {
+ t.Fatalf("test signature should start with E, got %q", geminiSig[:1])
+ }
+ if got := DetectSignatureProvider(geminiSig); got != SignatureProviderGemini {
+ t.Fatalf("DetectSignatureProvider(Gemini E-prefix) = %q, want %q", got, SignatureProviderGemini)
+ }
+}
+
+func TestDetectSignatureProvider_DoesNotClassifyArbitraryBase64AsGemini(t *testing.T) {
+ opaque := testGeminiThoughtSignature([]byte{0x45, 0x12})
+ if got := DetectSignatureProvider(opaque); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(arbitrary base64) = %q, want %q", got, SignatureProviderUnknown)
+ }
+}
+
+func TestGeminiASCIIUUIDSignatureUsesBypass(t *testing.T) {
+ plainUUID := "e24830a7-5cd6-42fe-998b-ee539e72b9c3"
+ sig := testGeminiThoughtSignature([]byte(plainUUID))
+
+ if got := DetectSignatureProvider(plainUUID); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(plain UUID) = %q, want %q", got, SignatureProviderUnknown)
+ }
+ if got := DetectSignatureProvider("gemini#" + plainUUID); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(gemini#plain UUID) = %q, want %q", got, SignatureProviderUnknown)
+ }
+
+ if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(UUID) = %q, want %q", got, SignatureProviderUnknown)
+ }
+ if got := DetectSignatureProvider("gemini#" + sig); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(gemini#UUID) = %q, want %q", got, SignatureProviderUnknown)
+ }
+ if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProviderForBlock(UUID tool call) = %q, want %q", got, SignatureProviderUnknown)
+ }
+ if _, ok := CompatibleSignatureForProvider(SignatureProviderGemini, sig); ok {
+ t.Fatal("UUID signature should not be compatible")
+ }
+ if normalized, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall); ok || normalized != "" {
+ t.Fatalf("UUID tool-call signature normalized=%q ok=%v, want empty and false", normalized, ok)
+ }
+ decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall)
+ if decision.Action != SignatureActionReplaceWithGeminiBypass {
+ t.Fatalf("function-call UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
+ }
+ if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("function-call UUID replacement = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
+ }
+ decision = DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiModelPart)
+ if decision.Action != SignatureActionReplaceWithGeminiBypass {
+ t.Fatalf("model-part UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
+ }
+}
+
+func TestGeminiWrappedUUIDFunctionCallSignatureIsUnknown(t *testing.T) {
+ sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
+
+ if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(wrapped UUID) = %q, want %q", got, SignatureProviderUnknown)
+ }
+ if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProviderForBlock(wrapped UUID tool call) = %q, want %q", got, SignatureProviderUnknown)
+ }
+ if normalized, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall); ok || normalized != "" {
+ t.Fatalf("wrapped UUID tool-call signature normalized=%q ok=%v, want empty and false", normalized, ok)
+ }
+ decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall)
+ if decision.Action != SignatureActionReplaceWithGeminiBypass {
+ t.Fatalf("function-call wrapped UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
+ }
+ if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("function-call wrapped UUID replacement = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
+ }
+ decision = DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiModelPart)
+ if decision.Action != SignatureActionReplaceWithGeminiBypass {
+ t.Fatalf("model-part wrapped UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
+ }
+}
+
+func TestCompatibleSignatureForProvider_StripsGeminiPrefix(t *testing.T) {
+ sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
+ normalized, ok := CompatibleSignatureForProvider(SignatureProviderGemini, "gemini#"+sig)
+ if !ok {
+ t.Fatal("gemini-prefixed signature should be compatible with Gemini")
+ }
+ if normalized != sig {
+ t.Fatalf("normalized = %q, want %q", normalized, sig)
+ }
+}
+
+func TestSplitSignatureProviderPrefix_UsesStrictProviderAliases(t *testing.T) {
+ gptSig := "gpt#" + testGPTReasoningSignature()
+ if got := DetectSignatureProvider(gptSig); got != SignatureProviderGPT {
+ t.Fatalf("DetectSignatureProvider(gpt#...) = %q, want %q", got, SignatureProviderGPT)
+ }
+
+ mislabeledPrefix := "claude-cache#" + testClaudeThinkingSignature()
+ if _, _, ok := SplitSignatureProviderPrefix(mislabeledPrefix); ok {
+ t.Fatal("claude-cache# should not be accepted as an explicit provider prefix")
+ }
+ if got := DetectSignatureProvider(mislabeledPrefix); got != SignatureProviderUnknown {
+ t.Fatalf("DetectSignatureProvider(claude-cache#...) = %q, want %q", got, SignatureProviderUnknown)
+ }
+}
+
+func TestDecideSignatureCompatibility_GeminiFunctionCallUsesBypass(t *testing.T) {
+ decision := DecideSignatureCompatibility(SignatureProviderGemini, "claude#"+testClaudeThinkingSignature(), SignatureBlockKindGeminiFunctionCall)
+ if decision.Action != SignatureActionReplaceWithGeminiBypass {
+ t.Fatalf("Action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
+ }
+ if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("ReplacementSignature = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
+ }
+}
+
+func TestSanitizeClaudeMessagesSignaturesForModel_NormalizesSameProviderClaude(t *testing.T) {
+ nativeSig := testClaudeThinkingSignature()
+ sig := "claude#" + nativeSig
+ input := []byte(`{"model":"claude-sonnet","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
+ expectedSig, err := NormalizeClaudeThinkingSignature(nativeSig)
+ if err != nil {
+ t.Fatalf("NormalizeClaudeThinkingSignature failed: %v", err)
+ }
+
+ output, report := SanitizeClaudeMessagesSignaturesForModel(input, "claude-sonnet-4-5")
+ if report.Preserved != 1 || report.DroppedBlocks != 0 {
+ t.Fatalf("unexpected report: %+v", report)
+ }
+ if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != expectedSig {
+ t.Fatalf("signature = %q, want normalized %q", got, expectedSig)
+ }
+}
+
+func TestSanitizeClaudeMessagesSignaturesForModel_DropsClaudeThinkingForGemini(t *testing.T) {
+ sig := "claude#" + testClaudeThinkingSignature()
+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
+
+ output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gemini-3.5-flash")
+ if report.DroppedBlocks != 1 {
+ t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
+ }
+ content := gjson.GetBytes(output, "messages.0.content").Array()
+ if len(content) != 1 {
+ t.Fatalf("content length = %d, want 1: %s", len(content), output)
+ }
+ if got := content[0].Get("text").String(); got != "answer" {
+ t.Fatalf("remaining text = %q, want answer", got)
+ }
+}
+
+func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGeminiThinkingForGemini(t *testing.T) {
+ nativeSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
+ sig := "gemini#" + nativeSig
+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
+
+ output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gemini-3.5-flash")
+ if report.Preserved != 1 || report.DroppedBlocks != 0 {
+ t.Fatalf("unexpected report: %+v", report)
+ }
+ if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != nativeSig {
+ t.Fatalf("signature = %q, want normalized %q", got, nativeSig)
+ }
+}
+
+func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGPTForGPT(t *testing.T) {
+ sig := testGPTReasoningSignature()
+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
+
+ output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gpt-5.2")
+ if report.Preserved != 1 || report.DroppedBlocks != 0 {
+ t.Fatalf("unexpected report: %+v", report)
+ }
+ if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
+ t.Fatalf("signature = %q, want preserved %q", got, sig)
+ }
+}
+
+func TestSanitizeClaudeMessagesSignaturesForModel_DropsEmptyAssistantMessage(t *testing.T) {
+ sig := "claude#" + testClaudeThinkingSignature()
+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop","signature":"` + sig + `"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`)
+
+ output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gpt-5.2")
+ if report.DroppedBlocks != 1 {
+ t.Fatalf("DroppedBlocks = %d, want 1", report.DroppedBlocks)
+ }
+ messages := gjson.GetBytes(output, "messages").Array()
+ if len(messages) != 1 {
+ t.Fatalf("messages length = %d, want 1: %s", len(messages), output)
+ }
+ if got := messages[0].Get("role").String(); got != "user" {
+ t.Fatalf("remaining role = %q, want user", got)
+ }
+}
diff --git a/internal/translator/antigravity/claude/signature_validation.go b/internal/translator/antigravity/claude/signature_validation.go
index f82fc2e364a..f0acbf8e7d8 100644
--- a/internal/translator/antigravity/claude/signature_validation.go
+++ b/internal/translator/antigravity/claude/signature_validation.go
@@ -1,448 +1,42 @@
-// Claude thinking signature validation for Antigravity bypass mode.
-//
-// Spec reference: SIGNATURE-CHANNEL-SPEC.md
-//
-// # Encoding Detection (Spec §3)
-//
-// Claude signatures use base64 encoding in one or two layers. The raw string's
-// first character determines the encoding depth — this is mathematically equivalent
-// to the spec's "decode first, check byte" approach:
-//
-// - 'E' prefix → single-layer: payload[0]==0x12, first 6 bits = 000100 = base64 index 4 = 'E'
-// - 'R' prefix → double-layer: inner[0]=='E' (0x45), first 6 bits = 010001 = base64 index 17 = 'R'
-//
-// All valid signatures are normalized to R-form (double-layer base64) before
-// sending to the Antigravity backend.
-//
-// # Protobuf Structure (Spec §4.1, §4.2) — strict mode only
-//
-// After base64 decoding to raw bytes (first byte must be 0x12):
-//
-// Top-level protobuf
-// ├── Field 2 (bytes): container ← extractBytesField(payload, 2)
-// │ ├── Field 1 (bytes): channel block ← extractBytesField(container, 1)
-// │ │ ├── Field 1 (varint): channel_id [required] → routing_class (11 | 12)
-// │ │ ├── Field 2 (varint): infra [optional] → infrastructure_class (aws=1 | google=2)
-// │ │ ├── Field 3 (varint): version=2 [skipped]
-// │ │ ├── Field 5 (bytes): ECDSA sig [skipped, per Spec §11]
-// │ │ ├── Field 6 (bytes): model_text [optional] → schema_features
-// │ │ └── Field 7 (varint): unknown [optional] → schema_features
-// │ ├── Field 2 (bytes): nonce 12B [skipped]
-// │ ├── Field 3 (bytes): session 12B [skipped]
-// │ ├── Field 4 (bytes): SHA-384 48B [skipped]
-// │ └── Field 5 (bytes): metadata [skipped, per Spec §11]
-// └── Field 3 (varint): =1 [skipped]
-//
-// # Output Dimensions (Spec §8)
-//
-// routing_class: routing_class_11 | routing_class_12 | unknown
-// infrastructure_class: infra_default (absent) | infra_aws (1) | infra_google (2) | infra_unknown
-// schema_features: compact_schema (len 70-72, no f6/f7) | extended_model_tagged_schema (f6 exists) | unknown
-// legacy_route_hint: only for ch=11 — legacy_default_group | legacy_aws_group | legacy_vertex_direct/proxy
-//
-// # Compatibility
-//
-// Verified against all confirmed spec samples (Anthropic Max 20x, Azure, Vertex,
-// Bedrock) and legacy ch=11 signatures. Both single-layer (E) and double-layer (R)
-// encodings are supported. Historical cache-mode 'modelGroup#' prefixes are stripped.
+// Claude thinking signature validation wrappers for Antigravity bypass mode.
package claude
import (
- "encoding/base64"
- "fmt"
- "strings"
- "unicode/utf8"
-
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
- "google.golang.org/protobuf/encoding/protowire"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
)
-const maxBypassSignatureLen = 32 * 1024 * 1024
+const maxBypassSignatureLen = signature.MaxClaudeThinkingSignatureLen
-type claudeSignatureTree struct {
- EncodingLayers int
- ChannelID uint64
- Field2 *uint64
- RoutingClass string
- InfrastructureClass string
- SchemaFeatures string
- ModelText string
- LegacyRouteHint string
- HasField7 bool
-}
+type claudeSignatureTree = signature.ClaudeSignatureTree
-// StripInvalidSignatureThinkingBlocks removes thinking blocks whose signatures
-// are empty or not valid Claude format (must start with 'E' or 'R' after
-// stripping any cache prefix). These come from proxy-generated responses
-// (Antigravity/Gemini) where no real Claude signature exists.
+// StripEmptySignatureThinkingBlocks removes thinking blocks whose signatures
+// are empty or not valid Claude thinking signatures. These usually come from
+// proxy-generated responses where no real Claude signature exists.
func StripEmptySignatureThinkingBlocks(payload []byte) []byte {
- messages := gjson.GetBytes(payload, "messages")
- if !messages.IsArray() {
- return payload
- }
- modified := false
- for i, msg := range messages.Array() {
- content := msg.Get("content")
- if !content.IsArray() {
- continue
- }
- var kept []string
- stripped := false
- for _, part := range content.Array() {
- if part.Get("type").String() == "thinking" && !hasValidClaudeSignature(part.Get("signature").String()) {
- stripped = true
- continue
- }
- kept = append(kept, part.Raw)
- }
- if stripped {
- modified = true
- if len(kept) == 0 {
- payload, _ = sjson.SetRawBytes(payload, fmt.Sprintf("messages.%d.content", i), []byte("[]"))
- } else {
- payload, _ = sjson.SetRawBytes(payload, fmt.Sprintf("messages.%d.content", i), []byte("["+strings.Join(kept, ",")+"]"))
- }
- }
- }
- if !modified {
- return payload
- }
- return payload
-}
-
-// hasValidClaudeSignature returns true if sig looks like a real Claude thinking
-// signature: non-empty and starts with 'E' or 'R' (after stripping optional
-// cache prefix like "modelGroup#").
-func hasValidClaudeSignature(sig string) bool {
- sig = strings.TrimSpace(sig)
- if sig == "" {
- return false
- }
- if idx := strings.IndexByte(sig, '#'); idx >= 0 {
- sig = strings.TrimSpace(sig[idx+1:])
- }
- if sig == "" {
- return false
- }
- return sig[0] == 'E' || sig[0] == 'R'
+ return signature.StripInvalidClaudeThinkingBlocks(payload, signature.ClaudeSignatureValidationOptions{PrefixOnly: true})
}
func ValidateClaudeBypassSignatures(inputRawJSON []byte) error {
- messages := gjson.GetBytes(inputRawJSON, "messages")
- if !messages.IsArray() {
- return nil
- }
-
- messageResults := messages.Array()
- for i := 0; i < len(messageResults); i++ {
- contentResults := messageResults[i].Get("content")
- if !contentResults.IsArray() {
- continue
- }
- parts := contentResults.Array()
- for j := 0; j < len(parts); j++ {
- part := parts[j]
- if part.Get("type").String() != "thinking" {
- continue
- }
-
- rawSignature := strings.TrimSpace(part.Get("signature").String())
- if rawSignature == "" {
- return fmt.Errorf("messages[%d].content[%d]: missing thinking signature", i, j)
- }
-
- if _, err := normalizeClaudeBypassSignature(rawSignature); err != nil {
- return fmt.Errorf("messages[%d].content[%d]: %w", i, j, err)
- }
- }
- }
-
- return nil
+ return signature.ValidateClaudeThinkingSignatures(inputRawJSON, claudeBypassSignatureValidationOptions())
}
func normalizeClaudeBypassSignature(rawSignature string) (string, error) {
- sig := strings.TrimSpace(rawSignature)
- if sig == "" {
- return "", fmt.Errorf("empty signature")
- }
-
- if idx := strings.IndexByte(sig, '#'); idx >= 0 {
- sig = strings.TrimSpace(sig[idx+1:])
- }
-
- if sig == "" {
- return "", fmt.Errorf("empty signature after stripping prefix")
- }
-
- if len(sig) > maxBypassSignatureLen {
- return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", maxBypassSignatureLen)
- }
-
- switch sig[0] {
- case 'R':
- if err := validateDoubleLayerSignature(sig); err != nil {
- return "", err
- }
- return sig, nil
- case 'E':
- if err := validateSingleLayerSignature(sig); err != nil {
- return "", err
- }
- return base64.StdEncoding.EncodeToString([]byte(sig)), nil
- default:
- return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0]))
- }
-}
-
-func validateDoubleLayerSignature(sig string) error {
- decoded, err := base64.StdEncoding.DecodeString(sig)
- if err != nil {
- return fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
- }
- if len(decoded) == 0 {
- return fmt.Errorf("invalid double-layer signature: empty after decode")
- }
- if decoded[0] != 'E' {
- return fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
- }
- return validateSingleLayerSignatureContent(string(decoded), 2)
-}
-
-func validateSingleLayerSignature(sig string) error {
- return validateSingleLayerSignatureContent(sig, 1)
-}
-
-func validateSingleLayerSignatureContent(sig string, encodingLayers int) error {
- decoded, err := base64.StdEncoding.DecodeString(sig)
- if err != nil {
- return fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
- }
- if len(decoded) == 0 {
- return fmt.Errorf("invalid single-layer signature: empty after decode")
- }
- if decoded[0] != 0x12 {
- return fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", decoded[0])
- }
- if !cache.SignatureBypassStrictMode() {
- return nil
- }
- _, err = inspectClaudeSignaturePayload(decoded, encodingLayers)
- return err
+ return signature.NormalizeClaudeThinkingSignature(rawSignature, claudeBypassSignatureValidationOptions())
}
func inspectDoubleLayerSignature(sig string) (*claudeSignatureTree, error) {
- decoded, err := base64.StdEncoding.DecodeString(sig)
- if err != nil {
- return nil, fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
- }
- if len(decoded) == 0 {
- return nil, fmt.Errorf("invalid double-layer signature: empty after decode")
- }
- if decoded[0] != 'E' {
- return nil, fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
- }
- return inspectSingleLayerSignatureWithLayers(string(decoded), 2)
+ return signature.InspectClaudeDoubleLayerSignature(sig)
}
func inspectSingleLayerSignature(sig string) (*claudeSignatureTree, error) {
- return inspectSingleLayerSignatureWithLayers(sig, 1)
-}
-
-func inspectSingleLayerSignatureWithLayers(sig string, encodingLayers int) (*claudeSignatureTree, error) {
- decoded, err := base64.StdEncoding.DecodeString(sig)
- if err != nil {
- return nil, fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
- }
- if len(decoded) == 0 {
- return nil, fmt.Errorf("invalid single-layer signature: empty after decode")
- }
- return inspectClaudeSignaturePayload(decoded, encodingLayers)
+ return signature.InspectClaudeSingleLayerSignature(sig)
}
func inspectClaudeSignaturePayload(payload []byte, encodingLayers int) (*claudeSignatureTree, error) {
- if len(payload) == 0 {
- return nil, fmt.Errorf("invalid Claude signature: empty payload")
- }
- if payload[0] != 0x12 {
- return nil, fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", payload[0])
- }
- container, err := extractBytesField(payload, 2, "top-level protobuf")
- if err != nil {
- return nil, err
- }
- channelBlock, err := extractBytesField(container, 1, "Claude Field 2 container")
- if err != nil {
- return nil, err
- }
- return inspectClaudeChannelBlock(channelBlock, encodingLayers)
-}
-
-func inspectClaudeChannelBlock(channelBlock []byte, encodingLayers int) (*claudeSignatureTree, error) {
- tree := &claudeSignatureTree{
- EncodingLayers: encodingLayers,
- RoutingClass: "unknown",
- InfrastructureClass: "infra_unknown",
- SchemaFeatures: "unknown_schema_features",
- }
- haveChannelID := false
- hasField6 := false
- hasField7 := false
-
- err := walkProtobufFields(channelBlock, func(num protowire.Number, typ protowire.Type, raw []byte) error {
- switch num {
- case 1:
- if typ != protowire.VarintType {
- return fmt.Errorf("invalid Claude signature: Field 2.1.1 channel_id must be varint")
- }
- channelID, err := decodeVarintField(raw, "Field 2.1.1 channel_id")
- if err != nil {
- return err
- }
- tree.ChannelID = channelID
- haveChannelID = true
- case 2:
- if typ != protowire.VarintType {
- return fmt.Errorf("invalid Claude signature: Field 2.1.2 field2 must be varint")
- }
- field2, err := decodeVarintField(raw, "Field 2.1.2 field2")
- if err != nil {
- return err
- }
- tree.Field2 = &field2
- case 6:
- if typ != protowire.BytesType {
- return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text must be bytes")
- }
- modelBytes, err := decodeBytesField(raw, "Field 2.1.6 model_text")
- if err != nil {
- return err
- }
- if !utf8.Valid(modelBytes) {
- return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text is not valid UTF-8")
- }
- tree.ModelText = string(modelBytes)
- hasField6 = true
- case 7:
- if typ != protowire.VarintType {
- return fmt.Errorf("invalid Claude signature: Field 2.1.7 must be varint")
- }
- if _, err := decodeVarintField(raw, "Field 2.1.7"); err != nil {
- return err
- }
- hasField7 = true
- tree.HasField7 = true
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- if !haveChannelID {
- return nil, fmt.Errorf("invalid Claude signature: missing Field 2.1.1 channel_id")
- }
-
- switch tree.ChannelID {
- case 11:
- tree.RoutingClass = "routing_class_11"
- case 12:
- tree.RoutingClass = "routing_class_12"
- }
-
- if tree.Field2 == nil {
- tree.InfrastructureClass = "infra_default"
- } else {
- switch *tree.Field2 {
- case 1:
- tree.InfrastructureClass = "infra_aws"
- case 2:
- tree.InfrastructureClass = "infra_google"
- default:
- tree.InfrastructureClass = "infra_unknown"
- }
- }
-
- switch {
- case hasField6:
- tree.SchemaFeatures = "extended_model_tagged_schema"
- case !hasField6 && !hasField7 && len(channelBlock) >= 70 && len(channelBlock) <= 72:
- tree.SchemaFeatures = "compact_schema"
- }
-
- if tree.ChannelID == 11 {
- switch {
- case tree.Field2 == nil:
- tree.LegacyRouteHint = "legacy_default_group"
- case *tree.Field2 == 1:
- tree.LegacyRouteHint = "legacy_aws_group"
- case *tree.Field2 == 2 && tree.EncodingLayers == 2:
- tree.LegacyRouteHint = "legacy_vertex_direct"
- case *tree.Field2 == 2 && tree.EncodingLayers == 1:
- tree.LegacyRouteHint = "legacy_vertex_proxy"
- }
- }
-
- return tree, nil
-}
-
-func extractBytesField(msg []byte, fieldNum protowire.Number, scope string) ([]byte, error) {
- var value []byte
- err := walkProtobufFields(msg, func(num protowire.Number, typ protowire.Type, raw []byte) error {
- if num != fieldNum {
- return nil
- }
- if typ != protowire.BytesType {
- return fmt.Errorf("invalid Claude signature: %s field %d must be bytes", scope, fieldNum)
- }
- bytesValue, err := decodeBytesField(raw, fmt.Sprintf("%s field %d", scope, fieldNum))
- if err != nil {
- return err
- }
- value = bytesValue
- return nil
- })
- if err != nil {
- return nil, err
- }
- if value == nil {
- return nil, fmt.Errorf("invalid Claude signature: missing %s field %d", scope, fieldNum)
- }
- return value, nil
-}
-
-func walkProtobufFields(msg []byte, visit func(num protowire.Number, typ protowire.Type, raw []byte) error) error {
- for offset := 0; offset < len(msg); {
- num, typ, n := protowire.ConsumeTag(msg[offset:])
- if n < 0 {
- return fmt.Errorf("invalid Claude signature: malformed protobuf tag: %w", protowire.ParseError(n))
- }
- offset += n
- valueLen := protowire.ConsumeFieldValue(num, typ, msg[offset:])
- if valueLen < 0 {
- return fmt.Errorf("invalid Claude signature: malformed protobuf field %d: %w", num, protowire.ParseError(valueLen))
- }
- fieldRaw := msg[offset : offset+valueLen]
- if err := visit(num, typ, fieldRaw); err != nil {
- return err
- }
- offset += valueLen
- }
- return nil
-}
-
-func decodeVarintField(raw []byte, label string) (uint64, error) {
- value, n := protowire.ConsumeVarint(raw)
- if n < 0 {
- return 0, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
- }
- return value, nil
+ return signature.InspectClaudeSignaturePayload(payload, encodingLayers)
}
-func decodeBytesField(raw []byte, label string) ([]byte, error) {
- value, n := protowire.ConsumeBytes(raw)
- if n < 0 {
- return nil, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
- }
- return value, nil
+func claudeBypassSignatureValidationOptions() signature.ClaudeSignatureValidationOptions {
+ return signature.ClaudeSignatureValidationOptions{Strict: cache.SignatureBypassStrictMode()}
}
From 01a7cc4a45880c9f49152131ebd529a099f3a294 Mon Sep 17 00:00:00 2001
From: Progress-infinitely
<102594894+Progress-infinitely@users.noreply.github.com>
Date: Thu, 28 May 2026 17:34:06 +0800
Subject: [PATCH 076/248] fix(amp): restore response tool casing from request
---
internal/api/modules/amp/fallback_handlers.go | 4 +-
.../api/modules/amp/fallback_handlers_test.go | 32 +++++++
internal/api/modules/amp/response_rewriter.go | 72 ++++++++++++++-
.../api/modules/amp/response_rewriter_test.go | 90 +++++++++++++++++++
4 files changed, 192 insertions(+), 6 deletions(-)
diff --git a/internal/api/modules/amp/fallback_handlers.go b/internal/api/modules/amp/fallback_handlers.go
index 06e0a035d0b..4949ef7a416 100644
--- a/internal/api/modules/amp/fallback_handlers.go
+++ b/internal/api/modules/amp/fallback_handlers.go
@@ -252,7 +252,7 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
// Log: Model was mapped to another model
log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel)
logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
- rewriter := NewResponseRewriter(c.Writer, modelName)
+ rewriter := NewResponseRewriterForRequest(c.Writer, modelName, bodyBytes)
rewriter.suppressThinking = true
c.Writer = rewriter
// Filter Anthropic-Beta header only for local handling paths
@@ -267,7 +267,7 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
// Wrap with ResponseRewriter for local providers too, because upstream
// proxies (e.g. NewAPI) may return a different model name and lack
// Amp-required fields like thinking.signature.
- rewriter := NewResponseRewriter(c.Writer, modelName)
+ rewriter := NewResponseRewriterForRequest(c.Writer, modelName, bodyBytes)
rewriter.suppressThinking = providerName != "claude"
c.Writer = rewriter
// Filter Anthropic-Beta header only for local handling paths
diff --git a/internal/api/modules/amp/fallback_handlers_test.go b/internal/api/modules/amp/fallback_handlers_test.go
index 1aacaae21fb..7e6f10a2fe2 100644
--- a/internal/api/modules/amp/fallback_handlers_test.go
+++ b/internal/api/modules/amp/fallback_handlers_test.go
@@ -13,6 +13,38 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
+func TestFallbackHandler_RequestToolCasing_RewritesStreamingResponse(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient("test-client-amp-tool-casing", "codex", []*registry.ModelInfo{
+ {ID: "test/gpt-tool-casing", OwnedBy: "openai", Type: "codex"},
+ })
+ defer reg.UnregisterClient("test-client-amp-tool-casing")
+
+ fallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy { return nil }, nil, nil)
+ handler := func(c *gin.Context) {
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ _, _ = c.Writer.Write([]byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"name\":\"glob\",\"id\":\"toolu_01\",\"input\":{}}}\n\n"))
+ }
+
+ r := gin.New()
+ r.POST("/messages", fallback.WrapHandler(handler))
+
+ reqBody := []byte(`{"model":"test/gpt-tool-casing","tools":[{"name":"Glob","input_schema":{"type":"object"}}]}`)
+ req := httptest.NewRequest(http.MethodPost, "/messages", bytes.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", w.Code)
+ }
+ if !bytes.Contains(w.Body.Bytes(), []byte(`"name":"Glob"`)) {
+ t.Fatalf("expected streaming response to restore glob->Glob, got %s", w.Body.String())
+ }
+}
+
func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
diff --git a/internal/api/modules/amp/response_rewriter.go b/internal/api/modules/amp/response_rewriter.go
index 895c494e74f..86318119ece 100644
--- a/internal/api/modules/amp/response_rewriter.go
+++ b/internal/api/modules/amp/response_rewriter.go
@@ -22,6 +22,7 @@ type ResponseRewriter struct {
originalModel string
isStreaming bool
suppressThinking bool
+ requestToolNames map[string]string
}
// NewResponseRewriter creates a new response rewriter for model name substitution.
@@ -33,6 +34,12 @@ func NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRe
}
}
+func NewResponseRewriterForRequest(w gin.ResponseWriter, originalModel string, requestBody []byte) *ResponseRewriter {
+ rw := NewResponseRewriter(w, originalModel)
+ rw.requestToolNames = collectRequestToolNames(requestBody)
+ return rw
+}
+
const maxBufferedResponseBytes = 2 * 1024 * 1024 // 2MB safety cap
func looksLikeSSEChunk(data []byte) bool {
@@ -134,17 +141,70 @@ var ampCanonicalToolNames = map[string]string{
"check": "Check",
}
+func collectRequestToolNames(data []byte) map[string]string {
+ if len(data) == 0 {
+ return nil
+ }
+ parsed := gjson.ParseBytes(data)
+ names := map[string]string{}
+ conflicts := map[string]bool{}
+ record := func(name string) {
+ if name == "" {
+ return
+ }
+ key := strings.ToLower(name)
+ if conflicts[key] {
+ return
+ }
+ if existing, exists := names[key]; exists {
+ if existing != name {
+ names[key] = ""
+ conflicts[key] = true
+ }
+ return
+ }
+ names[key] = name
+ }
+
+ for _, tool := range parsed.Get("tools").Array() {
+ record(tool.Get("name").String())
+ }
+ if parsed.Get("tool_choice.type").String() == "tool" {
+ record(parsed.Get("tool_choice.name").String())
+ }
+ if len(names) == 0 {
+ return nil
+ }
+ return names
+}
+
+func canonicalAmpToolName(name string, requestToolNames map[string]string) (string, bool) {
+ key := strings.ToLower(name)
+ if canonical, ok := requestToolNames[key]; ok {
+ if canonical == "" {
+ return "", false
+ }
+ return canonical, true
+ }
+ canonical, ok := ampCanonicalToolNames[key]
+ return canonical, ok
+}
+
// normalizeAmpToolNames fixes tool_use block names to match Amp's canonical casing.
// Some upstream models return lowercase tool names (e.g. "bash" instead of "Bash")
// which causes Amp's case-sensitive mode whitelist to reject them.
func normalizeAmpToolNames(data []byte) []byte {
+ return normalizeAmpToolNamesForRequest(data, nil)
+}
+
+func normalizeAmpToolNamesForRequest(data []byte, requestToolNames map[string]string) []byte {
// Non-streaming: content[].name in tool_use blocks
for index, block := range gjson.GetBytes(data, "content").Array() {
if block.Get("type").String() != "tool_use" {
continue
}
name := block.Get("name").String()
- if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
+ if canonical, ok := canonicalAmpToolName(name, requestToolNames); ok && name != canonical {
path := fmt.Sprintf("content.%d.name", index)
var err error
data, err = sjson.SetBytes(data, path, canonical)
@@ -157,7 +217,7 @@ func normalizeAmpToolNames(data []byte) []byte {
// Streaming: content_block.name in content_block_start events
if gjson.GetBytes(data, "content_block.type").String() == "tool_use" {
name := gjson.GetBytes(data, "content_block.name").String()
- if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
+ if canonical, ok := canonicalAmpToolName(name, requestToolNames); ok && name != canonical {
var err error
data, err = sjson.SetBytes(data, "content_block.name", canonical)
if err != nil {
@@ -169,6 +229,10 @@ func normalizeAmpToolNames(data []byte) []byte {
return data
}
+func (rw *ResponseRewriter) normalizeToolNames(data []byte) []byte {
+ return normalizeAmpToolNamesForRequest(data, rw.requestToolNames)
+}
+
// ensureAmpSignature injects empty signature fields into tool_use/thinking blocks
// in API responses so that the Amp TUI does not crash on P.signature.length.
func ensureAmpSignature(data []byte) []byte {
@@ -225,7 +289,7 @@ func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte {
func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
data = ensureAmpSignature(data)
- data = normalizeAmpToolNames(data)
+ data = rw.normalizeToolNames(data)
data = rw.suppressAmpThinking(data)
if len(data) == 0 {
return data
@@ -326,7 +390,7 @@ func (rw *ResponseRewriter) rewriteStreamEvent(data []byte) []byte {
data = ensureAmpSignature(data)
// Normalize tool names to canonical casing
- data = normalizeAmpToolNames(data)
+ data = rw.normalizeToolNames(data)
// Rewrite model name
if rw.originalModel != "" {
diff --git a/internal/api/modules/amp/response_rewriter_test.go b/internal/api/modules/amp/response_rewriter_test.go
index a3a350cb233..609942edd35 100644
--- a/internal/api/modules/amp/response_rewriter_test.go
+++ b/internal/api/modules/amp/response_rewriter_test.go
@@ -217,6 +217,96 @@ func TestNormalizeAmpToolNames_GlobPreserved(t *testing.T) {
}
}
+func TestNormalizeAmpToolNames_RequestToolCasing_NonStreaming(t *testing.T) {
+ input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
+ result := normalizeAmpToolNamesForRequest(input, map[string]string{"glob": "Glob"})
+
+ if !contains(result, []byte(`"name":"Glob"`)) {
+ t.Errorf("expected glob->Glob when request advertised Glob, got %s", string(result))
+ }
+}
+
+func TestNormalizeAmpToolNames_RequestToolCasing_Streaming(t *testing.T) {
+ input := []byte(`{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"glob","id":"toolu_01","input":{}}}`)
+ result := normalizeAmpToolNamesForRequest(input, map[string]string{"glob": "Glob"})
+
+ if !contains(result, []byte(`"name":"Glob"`)) {
+ t.Errorf("expected glob->Glob in streaming when request advertised Glob, got %s", string(result))
+ }
+}
+
+func TestResponseRewriter_RequestToolCasingFromBody(t *testing.T) {
+ requestBody := []byte(`{"tools":[{"name":"Glob","input_schema":{"type":"object"}}]}`)
+ rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(requestBody)}
+ input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
+
+ result := rw.rewriteModelInResponse(input)
+
+ if !contains(result, []byte(`"name":"Glob"`)) {
+ t.Errorf("expected request body casing to restore glob->Glob, got %s", string(result))
+ }
+}
+
+func TestResponseRewriter_LowercaseNativeRequestPreserved(t *testing.T) {
+ requestBody := []byte(`{"tools":[{"name":"glob","input_schema":{"type":"object"}}]}`)
+ rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(requestBody)}
+ input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
+
+ result := rw.rewriteModelInResponse(input)
+
+ if string(result) == string(input) {
+ return
+ }
+ if !contains(result, []byte(`"name":"glob"`)) {
+ t.Errorf("expected lowercase-native request to preserve glob, got %s", string(result))
+ }
+}
+
+func TestCollectRequestToolNames_CollisionIgnored(t *testing.T) {
+ tests := []struct {
+ requestBody []byte
+ input []byte
+ forbidden []byte
+ }{
+ {
+ requestBody: []byte(`{"tools":[{"name":"Glob","input_schema":{"type":"object"}},{"name":"glob","input_schema":{"type":"object"}}]}`),
+ input: []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`),
+ forbidden: []byte(`"name":"Glob"`),
+ },
+ {
+ requestBody: []byte(`{"tools":[{"name":"glob","input_schema":{"type":"object"}},{"name":"Glob","input_schema":{"type":"object"}}]}`),
+ input: []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`),
+ forbidden: []byte(`"name":"Glob"`),
+ },
+ {
+ requestBody: []byte(`{"tools":[{"name":"Bash","input_schema":{"type":"object"}},{"name":"bash","input_schema":{"type":"object"}}]}`),
+ input: []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"bash","input":{"cmd":"ls"}}]}`),
+ forbidden: []byte(`"name":"Bash"`),
+ },
+ }
+
+ for _, tt := range tests {
+ rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(tt.requestBody)}
+ result := rw.rewriteModelInResponse(tt.input)
+
+ if contains(result, tt.forbidden) {
+ t.Errorf("expected conflicting tool casing not to force %s, got %s", string(tt.forbidden), string(result))
+ }
+ }
+}
+
+func TestResponseRewriter_RequestToolCasingFromBody_Streaming(t *testing.T) {
+ requestBody := []byte(`{"tools":[{"name":"Glob","input_schema":{"type":"object"}}]}`)
+ rw := &ResponseRewriter{requestToolNames: collectRequestToolNames(requestBody)}
+ input := []byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"name\":\"glob\",\"id\":\"toolu_01\",\"input\":{}}}\n\n")
+
+ result := rw.rewriteStreamChunk(input)
+
+ if !contains(result, []byte(`"name":"Glob"`)) {
+ t.Errorf("expected streaming response to restore glob->Glob from request body, got %s", string(result))
+ }
+}
+
func TestNormalizeAmpToolNames_UnknownToolUntouched(t *testing.T) {
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"edit_file","input":{"path":"/tmp/x"}}]}`)
result := normalizeAmpToolNames(input)
From 65e760aa1a0ffef2b7a9e5a92115885acf97769b Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 28 May 2026 21:34:54 +0800
Subject: [PATCH 077/248] feat(usage): include cache tokens in total token
calculation and add tests
- Updated `TotalTokens` calculation to account for `CacheReadTokens` and `CacheCreationTokens`.
- Added tests to validate accurate token aggregation and fallback behavior for `CachedTokens`.
---
.../runtime/executor/helps/usage_helpers.go | 2 +-
.../executor/helps/usage_helpers_test.go | 34 +++++++++++++++++++
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go
index 1c4f4cdf7c4..295b797d752 100644
--- a/internal/runtime/executor/helps/usage_helpers.go
+++ b/internal/runtime/executor/helps/usage_helpers.go
@@ -527,7 +527,7 @@ func parseClaudeUsageNode(usageNode gjson.Result) usage.Detail {
if detail.CachedTokens == 0 {
detail.CachedTokens = detail.CacheCreationTokens
}
- detail.TotalTokens = detail.InputTokens + detail.OutputTokens
+ detail.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.CacheReadTokens + detail.CacheCreationTokens
return detail
}
diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go
index 58b175f3b6f..b14a389a06d 100644
--- a/internal/runtime/executor/helps/usage_helpers_test.go
+++ b/internal/runtime/executor/helps/usage_helpers_test.go
@@ -89,6 +89,40 @@ func TestParseOpenAIStreamUsageResponsesFields(t *testing.T) {
}
}
+func TestParseClaudeUsageIncludesCacheTokensInTotal(t *testing.T) {
+ data := []byte(`{"usage":{"input_tokens":3085,"output_tokens":253,"cache_read_input_tokens":7,"cache_creation_input_tokens":19514}}`)
+ detail := ParseClaudeUsage(data)
+ if detail.InputTokens != 3085 {
+ t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 3085)
+ }
+ if detail.OutputTokens != 253 {
+ t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 253)
+ }
+ if detail.CacheReadTokens != 7 {
+ t.Fatalf("cache read tokens = %d, want %d", detail.CacheReadTokens, 7)
+ }
+ if detail.CacheCreationTokens != 19514 {
+ t.Fatalf("cache creation tokens = %d, want %d", detail.CacheCreationTokens, 19514)
+ }
+ if detail.CachedTokens != 7 {
+ t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 7)
+ }
+ if detail.TotalTokens != 22859 {
+ t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 22859)
+ }
+}
+
+func TestParseClaudeUsageFallsBackCachedTokensToCacheCreation(t *testing.T) {
+ data := []byte(`{"usage":{"input_tokens":3085,"output_tokens":253,"cache_creation_input_tokens":19514}}`)
+ detail := ParseClaudeUsage(data)
+ if detail.CachedTokens != 19514 {
+ t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 19514)
+ }
+ if detail.TotalTokens != 22852 {
+ t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 22852)
+ }
+}
+
func TestParseGeminiCLIUsage_TopLevelUsageMetadata(t *testing.T) {
data := []byte(`{"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":7,"thoughtsTokenCount":3,"totalTokenCount":21,"cachedContentTokenCount":5}}`)
detail := ParseGeminiCLIUsage(data)
From 71c185f6144ca185aff18f1486d36d1d3504bc1f Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 28 May 2026 22:15:54 +0800
Subject: [PATCH 078/248] feat(usage): add service tier tracking and defaults
in usage reporting
- Introduced `service_tier` metadata key to capture client-requested service tiers.
- Updated usage records, context propagation, and plugins to include service tier data.
- Added default handling logic for cases where `service_tier` is absent.
- Implemented tests for `service_tier` extraction, defaults, and updates across components.
---
internal/redisqueue/plugin.go | 6 +++
internal/redisqueue/plugin_test.go | 2 +
.../runtime/executor/helps/usage_helpers.go | 17 ++++++
.../executor/helps/usage_helpers_test.go | 33 ++++++++++++
sdk/api/handlers/handlers.go | 20 +++++++
sdk/api/handlers/handlers_metadata_test.go | 22 ++++++++
sdk/cliproxy/auth/conductor.go | 25 ++++++++-
sdk/cliproxy/auth/conductor_usage_test.go | 5 ++
sdk/cliproxy/executor/types.go | 3 ++
sdk/cliproxy/usage/manager.go | 54 ++++++++++++++++---
10 files changed, 180 insertions(+), 7 deletions(-)
diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go
index ac48d0c1391..f6c8e52ca6c 100644
--- a/internal/redisqueue/plugin.go
+++ b/internal/redisqueue/plugin.go
@@ -52,6 +52,10 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
if reasoningEffort == "" {
reasoningEffort = coreusage.ReasoningEffortFromContext(ctx)
}
+ serviceTier := strings.TrimSpace(record.ServiceTier)
+ if serviceTier == "" {
+ serviceTier = coreusage.ServiceTierFromContext(ctx)
+ }
tokens := tokenStats{
InputTokens: record.Detail.InputTokens,
@@ -97,6 +101,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
APIKey: apiKey,
RequestID: requestID,
ReasoningEffort: reasoningEffort,
+ ServiceTier: serviceTier,
})
if err != nil {
return
@@ -114,6 +119,7 @@ type queuedUsageDetail struct {
APIKey string `json:"api_key"`
RequestID string `json:"request_id"`
ReasoningEffort string `json:"reasoning_effort"`
+ ServiceTier string `json:"service_tier"`
}
type requestDetail struct {
diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go
index 4917955cd17..09ee681a370 100644
--- a/internal/redisqueue/plugin_test.go
+++ b/internal/redisqueue/plugin_test.go
@@ -33,6 +33,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
AuthType: "apikey",
Source: "user@example.com",
ReasoningEffort: "medium",
+ ServiceTier: "priority",
RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
Latency: 1500 * time.Millisecond,
Detail: coreusage.Detail{
@@ -53,6 +54,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
requireMissingField(t, payload, "user_api_key")
requireStringField(t, payload, "request_id", "ctx-request-id")
requireStringField(t, payload, "reasoning_effort", "medium")
+ requireStringField(t, payload, "service_tier", "priority")
requireHeaderField(t, payload, "response_headers", "X-Upstream-Request-Id", []string{"upstream-req-1"})
requireHeaderField(t, payload, "response_headers", "Retry-After", []string{"30"})
requireBoolField(t, payload, "failed", false)
diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go
index 295b797d752..10c4108c1f6 100644
--- a/internal/runtime/executor/helps/usage_helpers.go
+++ b/internal/runtime/executor/helps/usage_helpers.go
@@ -30,6 +30,7 @@ type UsageReporter struct {
apiKey string
source string
reasoning string
+ serviceTier string
requestedAt time.Time
ttftMu sync.RWMutex
ttft time.Duration
@@ -53,6 +54,7 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox
source: resolveUsageSource(auth, apiKey),
authType: resolveUsageAuthType(auth),
reasoning: usage.ReasoningEffortFromContext(ctx),
+ serviceTier: usage.ServiceTierFromContext(ctx),
}
if auth != nil {
reporter.authID = auth.ID
@@ -78,6 +80,7 @@ func (r *UsageReporter) SetTranslatedReasoningEffort(payload []byte, format stri
return
}
r.reasoning = thinking.ExtractTranslatedReasoningEffort(payload, format)
+ r.serviceTier = extractServiceTierFromPayload(payload)
}
func (r *UsageReporter) TrackHTTPClient(client *http.Client) *http.Client {
@@ -239,6 +242,7 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f
AuthIndex: r.authIndex,
AuthType: r.authType,
ReasoningEffort: r.reasoning,
+ ServiceTier: r.serviceTier,
RequestedAt: r.requestedAt,
Latency: r.latency(),
TTFT: r.ttftDuration(),
@@ -248,6 +252,19 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f
}
}
+func extractServiceTierFromPayload(payload []byte) string {
+ if len(payload) == 0 {
+ return usage.DefaultServiceTier
+ }
+ for _, path := range []string{"service_tier", "request.service_tier", "response.service_tier"} {
+ serviceTier := strings.TrimSpace(gjson.GetBytes(payload, path).String())
+ if serviceTier != "" {
+ return serviceTier
+ }
+ }
+ return usage.DefaultServiceTier
+}
+
func failFromErrors(errs ...error) usage.Failure {
for _, err := range errs {
if err == nil {
diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go
index b14a389a06d..483d8ef595d 100644
--- a/internal/runtime/executor/helps/usage_helpers_test.go
+++ b/internal/runtime/executor/helps/usage_helpers_test.go
@@ -241,6 +241,39 @@ func TestUsageReporterBuildRecordIncludesReasoningEffort(t *testing.T) {
}
}
+func TestUsageReporterBuildRecordIncludesServiceTier(t *testing.T) {
+ ctx := usage.WithServiceTier(context.Background(), "priority")
+ reporter := NewUsageReporter(ctx, "openai", "gpt-5.4", nil)
+
+ record := reporter.buildRecord(usage.Detail{TotalTokens: 3}, false)
+ if record.ServiceTier != "priority" {
+ t.Fatalf("service tier = %q, want %q", record.ServiceTier, "priority")
+ }
+}
+
+func TestUsageReporterSetTranslatedReasoningEffortUpdatesServiceTier(t *testing.T) {
+ reporter := NewUsageReporter(context.Background(), "openai", "gpt-5.4", nil)
+
+ reporter.SetTranslatedReasoningEffort([]byte(`{"service_tier":"priority"}`), "openai")
+
+ record := reporter.buildRecord(usage.Detail{TotalTokens: 3}, false)
+ if record.ServiceTier != "priority" {
+ t.Fatalf("service tier = %q, want %q", record.ServiceTier, "priority")
+ }
+}
+
+func TestUsageReporterSetTranslatedReasoningEffortDefaultsServiceTierWhenRemoved(t *testing.T) {
+ ctx := usage.WithServiceTier(context.Background(), "priority")
+ reporter := NewUsageReporter(ctx, "openai", "gpt-5.4", nil)
+
+ reporter.SetTranslatedReasoningEffort([]byte(`{"model":"gpt-5.4"}`), "openai")
+
+ record := reporter.buildRecord(usage.Detail{TotalTokens: 3}, false)
+ if record.ServiceTier != usage.DefaultServiceTier {
+ t.Fatalf("service tier = %q, want %q", record.ServiceTier, usage.DefaultServiceTier)
+ }
+}
+
func TestUsageReporterBuildAdditionalModelRecordSkipsZeroTokens(t *testing.T) {
reporter := &UsageReporter{
provider: "codex",
diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go
index 5a25681dcbc..55b4d6ab531 100644
--- a/sdk/api/handlers/handlers.go
+++ b/sdk/api/handlers/handlers.go
@@ -20,8 +20,10 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
+ "github.com/tidwall/gjson"
"golang.org/x/net/context"
)
@@ -242,6 +244,21 @@ func setReasoningEffortMetadata(meta map[string]any, handlerType, model string,
meta[coreexecutor.ReasoningEffortMetadataKey] = effort
}
+func setServiceTierMetadata(meta map[string]any, rawJSON []byte) {
+ if meta == nil {
+ return
+ }
+ serviceTier := coreusage.DefaultServiceTier
+ node := gjson.GetBytes(rawJSON, "service_tier")
+ if node.Exists() {
+ value := strings.TrimSpace(node.String())
+ if value != "" {
+ serviceTier = value
+ }
+ }
+ meta[coreexecutor.ServiceTierMetadataKey] = serviceTier
+}
+
// headersFromContext extracts the original HTTP request headers from the gin context
// embedded in the provided context. This allows session affinity selectors to read
// client headers like X-Amp-Thread-Id.
@@ -562,6 +579,7 @@ func (h *BaseAPIHandler) executeWithAuthManager(ctx context.Context, handlerType
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName
setReasoningEffortMetadata(reqMeta, handlerType, normalizedModel, rawJSON)
+ setServiceTierMetadata(reqMeta, rawJSON)
payload := rawJSON
if len(payload) == 0 {
payload = nil
@@ -611,6 +629,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName
setReasoningEffortMetadata(reqMeta, handlerType, normalizedModel, rawJSON)
+ setServiceTierMetadata(reqMeta, rawJSON)
payload := rawJSON
if len(payload) == 0 {
payload = nil
@@ -673,6 +692,7 @@ func (h *BaseAPIHandler) executeStreamWithAuthManager(ctx context.Context, handl
reqMeta := requestExecutionMetadata(ctx)
reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName
setReasoningEffortMetadata(reqMeta, handlerType, normalizedModel, rawJSON)
+ setServiceTierMetadata(reqMeta, rawJSON)
payload := rawJSON
if len(payload) == 0 {
payload = nil
diff --git a/sdk/api/handlers/handlers_metadata_test.go b/sdk/api/handlers/handlers_metadata_test.go
index d2bdab683fa..24a9130f3d4 100644
--- a/sdk/api/handlers/handlers_metadata_test.go
+++ b/sdk/api/handlers/handlers_metadata_test.go
@@ -38,3 +38,25 @@ func TestSetReasoningEffortMetadataSupportsOpenAIResponses(t *testing.T) {
t.Fatalf("ReasoningEffortMetadataKey = %v, want %q", got, "medium")
}
}
+
+func TestSetServiceTierMetadataExtractsValue(t *testing.T) {
+ meta := make(map[string]any)
+
+ setServiceTierMetadata(meta, []byte(`{"service_tier":"priority"}`))
+
+ gotServiceTier := meta[coreexecutor.ServiceTierMetadataKey]
+ if gotServiceTier != "priority" {
+ t.Fatalf("ServiceTierMetadataKey = %v, want %q", gotServiceTier, "priority")
+ }
+}
+
+func TestSetServiceTierMetadataDefaultsWhenMissing(t *testing.T) {
+ meta := make(map[string]any)
+
+ setServiceTierMetadata(meta, []byte(`{"model":"gpt-5.4"}`))
+
+ gotServiceTier := meta[coreexecutor.ServiceTierMetadataKey]
+ if gotServiceTier != "default" {
+ t.Fatalf("ServiceTierMetadataKey = %v, want %q", gotServiceTier, "default")
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index ac1a9298153..5413dcf4ba7 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -1731,9 +1731,14 @@ func (m *Manager) prepareRequestAuth(ctx context.Context, executor ProviderExecu
func contextWithRequestedModelAlias(ctx context.Context, opts cliproxyexecutor.Options, fallback string) context.Context {
alias := requestedModelAliasFromOptions(opts, fallback)
ctx = coreusage.WithRequestedModelAlias(ctx, alias)
- if effort := reasoningEffortFromOptions(opts); effort != "" {
+ effort := reasoningEffortFromOptions(opts)
+ if effort != "" {
ctx = coreusage.WithReasoningEffort(ctx, effort)
}
+ serviceTier := serviceTierFromOptions(opts)
+ if serviceTier != "" {
+ ctx = coreusage.WithServiceTier(ctx, serviceTier)
+ }
return ctx
}
@@ -1780,6 +1785,24 @@ func reasoningEffortFromOptions(opts cliproxyexecutor.Options) string {
}
}
+func serviceTierFromOptions(opts cliproxyexecutor.Options) string {
+ if len(opts.Metadata) == 0 {
+ return ""
+ }
+ raw, ok := opts.Metadata[cliproxyexecutor.ServiceTierMetadataKey]
+ if !ok || raw == nil {
+ return ""
+ }
+ switch value := raw.(type) {
+ case string:
+ return strings.TrimSpace(value)
+ case []byte:
+ return strings.TrimSpace(string(value))
+ default:
+ return ""
+ }
+}
+
func pinnedAuthIDFromMetadata(meta map[string]any) string {
if len(meta) == 0 {
return ""
diff --git a/sdk/cliproxy/auth/conductor_usage_test.go b/sdk/cliproxy/auth/conductor_usage_test.go
index 23a70ea2881..af6c1ee237e 100644
--- a/sdk/cliproxy/auth/conductor_usage_test.go
+++ b/sdk/cliproxy/auth/conductor_usage_test.go
@@ -13,6 +13,7 @@ func TestContextWithRequestedModelAliasIncludesReasoningEffort(t *testing.T) {
Metadata: map[string]any{
cliproxyexecutor.RequestedModelMetadataKey: "client-model",
cliproxyexecutor.ReasoningEffortMetadataKey: "medium",
+ cliproxyexecutor.ServiceTierMetadataKey: "priority",
},
}, "fallback-model")
@@ -22,4 +23,8 @@ func TestContextWithRequestedModelAliasIncludesReasoningEffort(t *testing.T) {
if got := coreusage.ReasoningEffortFromContext(ctx); got != "medium" {
t.Fatalf("reasoning effort = %q, want %q", got, "medium")
}
+ gotServiceTier := coreusage.ServiceTierFromContext(ctx)
+ if gotServiceTier != "priority" {
+ t.Fatalf("service tier = %q, want %q", gotServiceTier, "priority")
+ }
}
diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go
index fc003540ec6..8f0fc56758f 100644
--- a/sdk/cliproxy/executor/types.go
+++ b/sdk/cliproxy/executor/types.go
@@ -20,6 +20,9 @@ const DisallowFreeAuthMetadataKey = "disallow_free_auth"
// ReasoningEffortMetadataKey stores the client-requested reasoning effort for usage logs.
const ReasoningEffortMetadataKey = "reasoning_effort"
+// ServiceTierMetadataKey stores the client-requested service tier for usage logs.
+const ServiceTierMetadataKey = "service_tier"
+
const (
// PinnedAuthMetadataKey locks execution to a specific auth ID.
PinnedAuthMetadataKey = "pinned_auth_id"
diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go
index 6113ca1ebc3..6c113b12680 100644
--- a/sdk/cliproxy/usage/manager.go
+++ b/sdk/cliproxy/usage/manager.go
@@ -10,6 +10,9 @@ import (
log "github.com/sirupsen/logrus"
)
+// DefaultServiceTier is used when a request does not specify service_tier.
+const DefaultServiceTier = "default"
+
// Record contains the usage statistics captured for a single provider request.
type Record struct {
Provider string
@@ -22,12 +25,14 @@ type Record struct {
Source string
// ReasoningEffort stores the translated upstream thinking level for request event logs.
ReasoningEffort string
- RequestedAt time.Time
- Latency time.Duration
- TTFT time.Duration
- Failed bool
- Fail Failure
- Detail Detail
+ // ServiceTier stores the client-requested service tier for request event logs.
+ ServiceTier string
+ RequestedAt time.Time
+ Latency time.Duration
+ TTFT time.Duration
+ Failed bool
+ Fail Failure
+ Detail Detail
// ResponseHeaders stores a snapshot of upstream response headers for usage sinks.
ResponseHeaders http.Header
}
@@ -51,6 +56,7 @@ type Detail struct {
type requestedModelAliasContextKey struct{}
type reasoningEffortContextKey struct{}
+type serviceTierContextKey struct{}
// WithRequestedModelAlias stores the client-requested model name for usage sinks.
func WithRequestedModelAlias(ctx context.Context, alias string) context.Context {
@@ -108,6 +114,42 @@ func ReasoningEffortFromContext(ctx context.Context) string {
}
}
+// WithServiceTier stores the client-requested service tier for usage sinks.
+func WithServiceTier(ctx context.Context, tier string) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ tier = strings.TrimSpace(tier)
+ if tier == "" {
+ tier = DefaultServiceTier
+ }
+ return context.WithValue(ctx, serviceTierContextKey{}, tier)
+}
+
+// ServiceTierFromContext returns the client-requested service tier stored in ctx.
+func ServiceTierFromContext(ctx context.Context) string {
+ if ctx == nil {
+ return DefaultServiceTier
+ }
+ raw := ctx.Value(serviceTierContextKey{})
+ switch value := raw.(type) {
+ case string:
+ tier := strings.TrimSpace(value)
+ if tier == "" {
+ return DefaultServiceTier
+ }
+ return tier
+ case []byte:
+ tier := strings.TrimSpace(string(value))
+ if tier == "" {
+ return DefaultServiceTier
+ }
+ return tier
+ default:
+ return DefaultServiceTier
+ }
+}
+
// Plugin consumes usage records emitted by the proxy runtime.
type Plugin interface {
HandleUsage(ctx context.Context, record Record)
From df0176a188cd4fcd71f32e57faa52dfc3773a765 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Fri, 29 May 2026 01:22:46 +0800
Subject: [PATCH 079/248] feat(models): add Claude Opus 4.8 model to registry
---
internal/registry/models/models.json | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json
index 41d191f024d..93e0376404d 100644
--- a/internal/registry/models/models.json
+++ b/internal/registry/models/models.json
@@ -95,6 +95,29 @@
]
}
},
+ {
+ "id": "claude-opus-4-8",
+ "object": "model",
+ "created": 1779984000,
+ "owned_by": "anthropic",
+ "type": "claude",
+ "display_name": "Claude Opus 4.8",
+ "description": "Premium model combining maximum intelligence with practical performance",
+ "context_length": 1000000,
+ "max_completion_tokens": 128000,
+ "thinking": {
+ "min": 1024,
+ "max": 128000,
+ "zero_allowed": true,
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ }
+ },
{
"id": "claude-opus-4-5-20251101",
"object": "model",
From c4ee063b958a6a2bed2afae8698256f2c1bdf977 Mon Sep 17 00:00:00 2001
From: hkfires <10558748+hkfires@users.noreply.github.com>
Date: Fri, 29 May 2026 08:12:52 +0800
Subject: [PATCH 080/248] feat(logging): add HomeAppLogForwarder for
application log forwarding
---
internal/home/client.go | 12 ++
internal/logging/home_app_log_forwarder.go | 167 ++++++++++++++++++
.../logging/home_app_log_forwarder_test.go | 159 +++++++++++++++++
sdk/cliproxy/service.go | 15 +-
4 files changed, 351 insertions(+), 2 deletions(-)
create mode 100644 internal/logging/home_app_log_forwarder.go
create mode 100644 internal/logging/home_app_log_forwarder_test.go
diff --git a/internal/home/client.go b/internal/home/client.go
index 0357529e68d..fd7f98a25a5 100644
--- a/internal/home/client.go
+++ b/internal/home/client.go
@@ -28,6 +28,7 @@ const (
redisKeyModels = "models"
redisKeyUsage = "usage"
redisKeyRequestLog = "request-log"
+ redisKeyAppLog = "app-log"
homeReconnectInterval = time.Second
homeReconnectFailoverThreshold = 3
@@ -650,6 +651,17 @@ func (c *Client) RPushRequestLog(ctx context.Context, payload []byte) error {
return cmd.RPush(ctx, redisKeyRequestLog, payload).Err()
}
+func (c *Client) RPushAppLog(ctx context.Context, payload []byte) error {
+ cmd, errClient := c.commandClient()
+ if errClient != nil {
+ return errClient
+ }
+ if len(payload) == 0 {
+ return nil
+ }
+ return cmd.RPush(ctx, redisKeyAppLog, payload).Err()
+}
+
func (c *Client) handleSubscriptionPayload(channel string, payload string, onConfig func([]byte) error) error {
payload = strings.TrimSpace(payload)
if payload == "" {
diff --git a/internal/logging/home_app_log_forwarder.go b/internal/logging/home_app_log_forwarder.go
new file mode 100644
index 00000000000..e74e47a1c8e
--- /dev/null
+++ b/internal/logging/home_app_log_forwarder.go
@@ -0,0 +1,167 @@
+package logging
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ log "github.com/sirupsen/logrus"
+)
+
+const defaultHomeAppLogQueueSize = 1024
+
+type homeAppLogClient interface {
+ HeartbeatOK() bool
+ RPushAppLog(ctx context.Context, payload []byte) error
+}
+
+type homeAppLogPayload struct {
+ Line string `json:"line"`
+ Level string `json:"level,omitempty"`
+ Timestamp string `json:"timestamp,omitempty"`
+}
+
+var currentHomeAppLogClient = func() homeAppLogClient {
+ return home.Current()
+}
+
+// HomeAppLogForwarder forwards application logs to Home after the control connection is healthy.
+type HomeAppLogForwarder struct {
+ formatter log.Formatter
+ queue chan homeAppLogPayload
+ stop chan struct{}
+ stopOnce sync.Once
+ wg sync.WaitGroup
+ enabled atomic.Bool
+}
+
+// StartHomeAppLogForwarder installs a logrus hook that forwards future application logs to Home.
+func StartHomeAppLogForwarder(queueSize int) *HomeAppLogForwarder {
+ if queueSize <= 0 {
+ queueSize = defaultHomeAppLogQueueSize
+ }
+ forwarder := &HomeAppLogForwarder{
+ formatter: &LogFormatter{},
+ queue: make(chan homeAppLogPayload, queueSize),
+ stop: make(chan struct{}),
+ }
+ forwarder.enabled.Store(true)
+ forwarder.wg.Add(1)
+ go forwarder.run()
+ log.AddHook(forwarder)
+ return forwarder
+}
+
+// Stop disables forwarding and waits for the background sender to exit.
+func (f *HomeAppLogForwarder) Stop() {
+ if f == nil {
+ return
+ }
+ f.stopOnce.Do(func() {
+ f.enabled.Store(false)
+ close(f.stop)
+ f.wg.Wait()
+ })
+}
+
+// Levels implements logrus.Hook.
+func (f *HomeAppLogForwarder) Levels() []log.Level {
+ return log.AllLevels
+}
+
+// Fire implements logrus.Hook.
+func (f *HomeAppLogForwarder) Fire(entry *log.Entry) error {
+ if f == nil || entry == nil || !f.enabled.Load() {
+ return nil
+ }
+ client := currentHomeAppLogClient()
+ if client == nil || !client.HeartbeatOK() {
+ return nil
+ }
+ line, errFormat := f.formatEntry(entry)
+ if errFormat != nil || strings.TrimSpace(line) == "" {
+ return nil
+ }
+
+ payload := homeAppLogPayload{
+ Line: line,
+ Level: entry.Level.String(),
+ Timestamp: entry.Time.Format(time.RFC3339Nano),
+ }
+ select {
+ case f.queue <- payload:
+ default:
+ }
+ return nil
+}
+
+func (f *HomeAppLogForwarder) formatEntry(entry *log.Entry) (string, error) {
+ formatter := f.formatter
+ if formatter == nil {
+ formatter = &LogFormatter{}
+ }
+ raw, errFormat := formatter.Format(entry)
+ if errFormat != nil {
+ return "", errFormat
+ }
+ return string(raw), nil
+}
+
+func (f *HomeAppLogForwarder) run() {
+ defer f.wg.Done()
+ for {
+ select {
+ case <-f.stop:
+ return
+ case payload := <-f.queue:
+ f.forward(payload)
+ }
+ }
+}
+
+func (f *HomeAppLogForwarder) forward(payload homeAppLogPayload) {
+ if !f.enabled.Load() {
+ return
+ }
+ client := currentHomeAppLogClient()
+ if client == nil || !client.HeartbeatOK() {
+ return
+ }
+ raw, errMarshal := json.Marshal(&payload)
+ if errMarshal != nil {
+ return
+ }
+ if errPush := client.RPushAppLog(context.Background(), raw); errPush != nil && isHomeAppLogUnsupported(errPush) {
+ f.enabled.Store(false)
+ }
+}
+
+func isHomeAppLogUnsupported(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(strings.TrimSpace(err.Error()))
+ if msg == "" {
+ return false
+ }
+ for {
+ switch {
+ case strings.Contains(msg, "unsupported key"):
+ return true
+ case strings.Contains(msg, "unknown command"):
+ return true
+ case strings.Contains(msg, "unsupported command"):
+ return true
+ }
+ err = errors.Unwrap(err)
+ if err == nil {
+ return false
+ }
+ msg = strings.ToLower(strings.TrimSpace(err.Error()))
+ }
+}
diff --git a/internal/logging/home_app_log_forwarder_test.go b/internal/logging/home_app_log_forwarder_test.go
new file mode 100644
index 00000000000..59476d1c0dc
--- /dev/null
+++ b/internal/logging/home_app_log_forwarder_test.go
@@ -0,0 +1,159 @@
+package logging
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+type stubHomeAppLogClient struct {
+ mu sync.Mutex
+ heartbeatOK bool
+ err error
+ pushed [][]byte
+}
+
+func (c *stubHomeAppLogClient) HeartbeatOK() bool { return c.heartbeatOK }
+
+func (c *stubHomeAppLogClient) RPushAppLog(_ context.Context, payload []byte) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.err != nil {
+ return c.err
+ }
+ c.pushed = append(c.pushed, bytes.Clone(payload))
+ return nil
+}
+
+func (c *stubHomeAppLogClient) pushedCount() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return len(c.pushed)
+}
+
+func (c *stubHomeAppLogClient) pushedAt(index int) []byte {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if index < 0 || index >= len(c.pushed) {
+ return nil
+ }
+ return bytes.Clone(c.pushed[index])
+}
+
+func TestHomeAppLogForwarder_ForwardsFormattedLogWhenHomeHealthy(t *testing.T) {
+ original := currentHomeAppLogClient
+ defer func() {
+ currentHomeAppLogClient = original
+ }()
+
+ stub := &stubHomeAppLogClient{heartbeatOK: true}
+ currentHomeAppLogClient = func() homeAppLogClient {
+ return stub
+ }
+
+ forwarder := &HomeAppLogForwarder{
+ formatter: &LogFormatter{},
+ queue: make(chan homeAppLogPayload, 4),
+ stop: make(chan struct{}),
+ }
+ forwarder.enabled.Store(true)
+ forwarder.wg.Add(1)
+ go forwarder.run()
+ defer forwarder.Stop()
+
+ entry := log.NewEntry(log.StandardLogger())
+ entry.Time = time.Date(2026, 5, 29, 8, 0, 0, 0, time.Local)
+ entry.Level = log.DebugLevel
+ entry.Message = "debug details"
+
+ if errFire := forwarder.Fire(entry); errFire != nil {
+ t.Fatalf("Fire error: %v", errFire)
+ }
+
+ deadline := time.Now().Add(time.Second)
+ for stub.pushedCount() == 0 && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if stub.pushedCount() != 1 {
+ t.Fatalf("pushed records = %d, want 1", stub.pushedCount())
+ }
+
+ var got homeAppLogPayload
+ if errUnmarshal := json.Unmarshal(stub.pushedAt(0), &got); errUnmarshal != nil {
+ t.Fatalf("unmarshal payload: %v", errUnmarshal)
+ }
+ if got.Level != "debug" {
+ t.Fatalf("level = %q, want debug", got.Level)
+ }
+ if !strings.Contains(got.Line, "debug details") {
+ t.Fatalf("line %q missing log message", got.Line)
+ }
+ if strings.TrimSpace(got.Timestamp) == "" {
+ t.Fatal("timestamp empty, want non-empty")
+ }
+}
+
+func TestHomeAppLogForwarder_SkipsWhenHomeHeartbeatIsDown(t *testing.T) {
+ original := currentHomeAppLogClient
+ defer func() {
+ currentHomeAppLogClient = original
+ }()
+
+ stub := &stubHomeAppLogClient{heartbeatOK: false}
+ currentHomeAppLogClient = func() homeAppLogClient {
+ return stub
+ }
+
+ forwarder := &HomeAppLogForwarder{
+ formatter: &LogFormatter{},
+ queue: make(chan homeAppLogPayload, 4),
+ stop: make(chan struct{}),
+ }
+ forwarder.enabled.Store(true)
+
+ entry := log.NewEntry(log.StandardLogger())
+ entry.Time = time.Now()
+ entry.Level = log.InfoLevel
+ entry.Message = "should stay local"
+
+ if errFire := forwarder.Fire(entry); errFire != nil {
+ t.Fatalf("Fire error: %v", errFire)
+ }
+ if stub.pushedCount() != 0 {
+ t.Fatalf("pushed records = %d, want 0", stub.pushedCount())
+ }
+}
+
+func TestHomeAppLogForwarder_DisablesForwardingWhenHomeDoesNotSupportAppLog(t *testing.T) {
+ original := currentHomeAppLogClient
+ defer func() {
+ currentHomeAppLogClient = original
+ }()
+
+ stub := &stubHomeAppLogClient{
+ heartbeatOK: true,
+ err: errors.New("ERR unsupported key"),
+ }
+ currentHomeAppLogClient = func() homeAppLogClient {
+ return stub
+ }
+
+ forwarder := &HomeAppLogForwarder{
+ formatter: &LogFormatter{},
+ queue: make(chan homeAppLogPayload, 4),
+ stop: make(chan struct{}),
+ }
+ forwarder.enabled.Store(true)
+
+ forwarder.forward(homeAppLogPayload{Line: "legacy home cannot receive app logs"})
+ if forwarder.enabled.Load() {
+ t.Fatal("forwarder still enabled, want disabled after unsupported app-log response")
+ }
+}
diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go
index cd16ebcefa7..10c3d0dd938 100644
--- a/sdk/cliproxy/service.go
+++ b/sdk/cliproxy/service.go
@@ -14,6 +14,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
@@ -96,8 +97,9 @@ type Service struct {
// wsGateway manages websocket Gemini providers.
wsGateway *wsrelay.Manager
- homeClient *home.Client
- homeCancel context.CancelFunc
+ homeClient *home.Client
+ homeCancel context.CancelFunc
+ homeLogForwarder *logging.HomeAppLogForwarder
}
// RegisterUsagePlugin registers a usage plugin on the global usage manager.
@@ -717,6 +719,10 @@ func (s *Service) startHomeSubscriber(ctx context.Context) {
s.homeClient.Close()
s.homeClient = nil
}
+ if s.homeLogForwarder != nil {
+ s.homeLogForwarder.Stop()
+ s.homeLogForwarder = nil
+ }
homeCtx := ctx
if homeCtx == nil {
@@ -739,6 +745,7 @@ func (s *Service) startHomeSubscriber(ctx context.Context) {
return nil
})
s.startHomeUsageForwarder(homeCtx, client)
+ s.homeLogForwarder = logging.StartHomeAppLogForwarder(0)
}
// Run starts the service and blocks until the context is cancelled or the server stops.
@@ -971,6 +978,10 @@ func (s *Service) Shutdown(ctx context.Context) error {
s.homeClient.Close()
s.homeClient = nil
}
+ if s.homeLogForwarder != nil {
+ s.homeLogForwarder.Stop()
+ s.homeLogForwarder = nil
+ }
home.ClearCurrent()
// legacy refresh loop removed; only stopping core auth manager below
From d2c5f279f6fa865dde792d31776cbf86c643deac Mon Sep 17 00:00:00 2001
From: hkfires <10558748+hkfires@users.noreply.github.com>
Date: Fri, 29 May 2026 10:58:19 +0800
Subject: [PATCH 081/248] feat(logging): add request_id handling in
HomeAppLogForwarder and tests
---
internal/logging/home_app_log_forwarder.go | 14 ++++++++++++++
internal/logging/home_app_log_forwarder_test.go | 16 ++++++++++++++++
2 files changed, 30 insertions(+)
diff --git a/internal/logging/home_app_log_forwarder.go b/internal/logging/home_app_log_forwarder.go
index e74e47a1c8e..e86e660322f 100644
--- a/internal/logging/home_app_log_forwarder.go
+++ b/internal/logging/home_app_log_forwarder.go
@@ -24,6 +24,7 @@ type homeAppLogPayload struct {
Line string `json:"line"`
Level string `json:"level,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
+ RequestID string `json:"request_id,omitempty"`
}
var currentHomeAppLogClient = func() homeAppLogClient {
@@ -92,6 +93,7 @@ func (f *HomeAppLogForwarder) Fire(entry *log.Entry) error {
Line: line,
Level: entry.Level.String(),
Timestamp: entry.Time.Format(time.RFC3339Nano),
+ RequestID: appLogRequestID(entry),
}
select {
case f.queue <- payload:
@@ -100,6 +102,18 @@ func (f *HomeAppLogForwarder) Fire(entry *log.Entry) error {
return nil
}
+func appLogRequestID(entry *log.Entry) string {
+ if entry == nil {
+ return ""
+ }
+ requestID, _ := entry.Data["request_id"].(string)
+ requestID = strings.TrimSpace(requestID)
+ if requestID == "--------" {
+ return ""
+ }
+ return requestID
+}
+
func (f *HomeAppLogForwarder) formatEntry(entry *log.Entry) (string, error) {
formatter := f.formatter
if formatter == nil {
diff --git a/internal/logging/home_app_log_forwarder_test.go b/internal/logging/home_app_log_forwarder_test.go
index 59476d1c0dc..b6a1b68080e 100644
--- a/internal/logging/home_app_log_forwarder_test.go
+++ b/internal/logging/home_app_log_forwarder_test.go
@@ -72,6 +72,7 @@ func TestHomeAppLogForwarder_ForwardsFormattedLogWhenHomeHealthy(t *testing.T) {
entry.Time = time.Date(2026, 5, 29, 8, 0, 0, 0, time.Local)
entry.Level = log.DebugLevel
entry.Message = "debug details"
+ entry.Data["request_id"] = "req-app-1"
if errFire := forwarder.Fire(entry); errFire != nil {
t.Fatalf("Fire error: %v", errFire)
@@ -92,14 +93,29 @@ func TestHomeAppLogForwarder_ForwardsFormattedLogWhenHomeHealthy(t *testing.T) {
if got.Level != "debug" {
t.Fatalf("level = %q, want debug", got.Level)
}
+ if got.RequestID != "req-app-1" {
+ t.Fatalf("request_id = %q, want req-app-1", got.RequestID)
+ }
if !strings.Contains(got.Line, "debug details") {
t.Fatalf("line %q missing log message", got.Line)
}
+ if !strings.Contains(got.Line, "[req-app-1]") {
+ t.Fatalf("line %q missing matching request id", got.Line)
+ }
if strings.TrimSpace(got.Timestamp) == "" {
t.Fatal("timestamp empty, want non-empty")
}
}
+func TestHomeAppLogForwarder_OmitsPlaceholderRequestID(t *testing.T) {
+ entry := log.NewEntry(log.StandardLogger())
+ entry.Data["request_id"] = "--------"
+
+ if got := appLogRequestID(entry); got != "" {
+ t.Fatalf("request id = %q, want empty for placeholder", got)
+ }
+}
+
func TestHomeAppLogForwarder_SkipsWhenHomeHeartbeatIsDown(t *testing.T) {
original := currentHomeAppLogClient
defer func() {
From 7d9980e8fa2c0ffe58c60550774d8b61c0a224dd Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Fri, 29 May 2026 11:24:58 +0800
Subject: [PATCH 082/248] fix(logging): log errors during file-backed source
cleanup
---
internal/api/middleware/response_writer.go | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go
index 4d496005472..5eabd08dca6 100644
--- a/internal/api/middleware/response_writer.go
+++ b/internal/api/middleware/response_writer.go
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ log "github.com/sirupsen/logrus"
)
const requestBodyOverrideContextKey = "REQUEST_BODY_OVERRIDE"
@@ -570,6 +571,8 @@ func cleanupFileBodySources(sources ...*logging.FileBodySource) {
if source == nil {
continue
}
- _ = source.Cleanup()
+ if errCleanup := source.Cleanup(); errCleanup != nil {
+ log.WithError(errCleanup).Warn("failed to clean up log part files")
+ }
}
}
From 86cb9c150b5cc9aa99be2d513d0a0e0eaf68abba Mon Sep 17 00:00:00 2001
From: sususu98
Date: Fri, 29 May 2026 12:17:25 +0800
Subject: [PATCH 083/248] feat(signature): upgrade provider signature checks
---
internal/signature/claude_validation.go | 34 ++++++++++++++++
internal/signature/provider_compatibility.go | 20 +++++++++-
.../signature/provider_compatibility_test.go | 40 ++++++++++++++++++-
3 files changed, 91 insertions(+), 3 deletions(-)
diff --git a/internal/signature/claude_validation.go b/internal/signature/claude_validation.go
index 4bad747ed45..a44f741be5e 100644
--- a/internal/signature/claude_validation.go
+++ b/internal/signature/claude_validation.go
@@ -226,6 +226,40 @@ func NormalizeClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatu
}
}
+// NormalizeClaudeProviderNativeThinkingSignature strips any cache prefix,
+// validates the signature, and returns the single-layer E-form expected by
+// Claude-native providers.
+func NormalizeClaudeProviderNativeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) (string, error) {
+ opt := claudeSignatureValidationOptions(opts)
+ sig := stripClaudeSignaturePrefix(rawSignature)
+ if sig == "" {
+ return "", fmt.Errorf("empty signature")
+ }
+
+ if len(sig) > MaxClaudeThinkingSignatureLen {
+ return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", MaxClaudeThinkingSignatureLen)
+ }
+
+ switch sig[0] {
+ case 'E':
+ if err := validateClaudeSingleLayerSignature(sig, opt); err != nil {
+ return "", err
+ }
+ return sig, nil
+ case 'R':
+ if err := validateClaudeDoubleLayerSignature(sig, opt); err != nil {
+ return "", err
+ }
+ decoded, err := base64.StdEncoding.DecodeString(sig)
+ if err != nil {
+ return "", fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
+ }
+ return string(decoded), nil
+ default:
+ return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0]))
+ }
+}
+
func validateClaudeDoubleLayerSignature(sig string, opt ClaudeSignatureValidationOptions) error {
decoded, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
diff --git a/internal/signature/provider_compatibility.go b/internal/signature/provider_compatibility.go
index 6cdb896fb0c..885a92e9018 100644
--- a/internal/signature/provider_compatibility.go
+++ b/internal/signature/provider_compatibility.go
@@ -229,6 +229,24 @@ func CompatibleSignatureForProviderBlock(targetProvider SignatureProvider, rawSi
return decision.NormalizedSignature, true
}
+// CompatibleAntigravityClaudeThinkingSignature returns the double-layer R-form
+// required by Antigravity Claude replay. It only accepts signatures that are
+// strictly identifiable as Claude, so Gemini E-prefixed envelopes cannot slip
+// through the looser Antigravity bypass normalization path.
+func CompatibleAntigravityClaudeThinkingSignature(rawSignature string) (string, bool) {
+ if DetectSignatureProviderForBlock(rawSignature, SignatureBlockKindClaudeThinking) != SignatureProviderClaude {
+ return "", false
+ }
+ normalized, err := NormalizeClaudeThinkingSignature(
+ SignaturePayloadWithoutProviderPrefix(rawSignature),
+ ClaudeSignatureValidationOptions{Strict: true},
+ )
+ if err != nil {
+ return "", false
+ }
+ return normalized, true
+}
+
func normalizeSignatureTargetProvider(provider SignatureProvider) SignatureProvider {
switch provider {
case SignatureProviderGeminiBypass:
@@ -255,7 +273,7 @@ func normalizeCompatibleSignatureForProvider(targetProvider SignatureProvider, r
payload := SignaturePayloadWithoutProviderPrefix(rawSignature)
switch normalizeSignatureTargetProvider(targetProvider) {
case SignatureProviderClaude:
- normalized, err := NormalizeClaudeThinkingSignature(payload)
+ normalized, err := NormalizeClaudeProviderNativeThinkingSignature(payload)
if err != nil {
return ""
}
diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go
index 5768d11cb4b..dcb5b829964 100644
--- a/internal/signature/provider_compatibility_test.go
+++ b/internal/signature/provider_compatibility_test.go
@@ -61,6 +61,42 @@ func TestDetectSignatureProvider_Gemini3EPrefixDoesNotLookClaude(t *testing.T) {
}
}
+func TestCompatibleSignatureForProvider_ClaudeUsesProviderNativeEForm(t *testing.T) {
+ nativeSig := testClaudeThinkingSignature()
+ doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig))
+
+ normalized, ok := CompatibleSignatureForProvider(SignatureProviderClaude, doubleEncoded)
+ if !ok {
+ t.Fatal("double-layer Claude signature should be compatible")
+ }
+ if normalized != nativeSig {
+ t.Fatalf("CompatibleSignatureForProvider(Claude) = %q, want provider-native %q", normalized, nativeSig)
+ }
+}
+
+func TestCompatibleAntigravityClaudeThinkingSignature_UsesDoubleLayerRForm(t *testing.T) {
+ nativeSig := testClaudeThinkingSignature()
+ expected := base64.StdEncoding.EncodeToString([]byte(nativeSig))
+
+ normalized, ok := CompatibleAntigravityClaudeThinkingSignature(nativeSig)
+ if !ok {
+ t.Fatal("Claude signature should be compatible with Antigravity Claude")
+ }
+ if normalized != expected {
+ t.Fatalf("CompatibleAntigravityClaudeThinkingSignature = %q, want %q", normalized, expected)
+ }
+}
+
+func TestCompatibleAntigravityClaudeThinkingSignature_RejectsGeminiEPrefix(t *testing.T) {
+ geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
+ if !strings.HasPrefix(geminiSig, "E") {
+ t.Fatalf("test signature should start with E, got %q", geminiSig[:1])
+ }
+ if normalized, ok := CompatibleAntigravityClaudeThinkingSignature(geminiSig); ok || normalized != "" {
+ t.Fatalf("Gemini E-prefix signature normalized=%q ok=%v, want rejected", normalized, ok)
+ }
+}
+
func TestDetectSignatureProvider_DoesNotClassifyArbitraryBase64AsGemini(t *testing.T) {
opaque := testGeminiThoughtSignature([]byte{0x45, 0x12})
if got := DetectSignatureProvider(opaque); got != SignatureProviderUnknown {
@@ -172,9 +208,9 @@ func TestSanitizeClaudeMessagesSignaturesForModel_NormalizesSameProviderClaude(t
nativeSig := testClaudeThinkingSignature()
sig := "claude#" + nativeSig
input := []byte(`{"model":"claude-sonnet","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
- expectedSig, err := NormalizeClaudeThinkingSignature(nativeSig)
+ expectedSig, err := NormalizeClaudeProviderNativeThinkingSignature(nativeSig)
if err != nil {
- t.Fatalf("NormalizeClaudeThinkingSignature failed: %v", err)
+ t.Fatalf("NormalizeClaudeProviderNativeThinkingSignature failed: %v", err)
}
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "claude-sonnet-4-5")
From aee7a5fbc533298974e4ba5ccb5392b9143279dd Mon Sep 17 00:00:00 2001
From: sususu98
Date: Fri, 29 May 2026 12:18:25 +0800
Subject: [PATCH 084/248] feat: intercept incompatible signature replay
---
.../runtime/executor/antigravity_executor.go | 47 +++-
.../antigravity_executor_signature_test.go | 203 ++++++++++----
internal/runtime/executor/claude_executor.go | 36 +++
.../runtime/executor/claude_executor_test.go | 57 ++++
internal/runtime/executor/codex_executor.go | 3 +
.../executor/codex_executor_signature_test.go | 138 ++++++++++
.../executor/codex_websockets_executor.go | 2 +
.../executor/openai_compat_executor.go | 1 +
.../executor/openai_responses_signature.go | 68 +++++
.../signature/claude_messages_sanitize.go | 32 ++-
internal/signature/gemini_sanitize.go | 140 ++++++++++
internal/signature/gemini_sanitize_test.go | 122 +++++++++
.../signature/provider_compatibility_test.go | 55 ++++
.../claude/antigravity_claude_request.go | 194 ++++++++++++--
.../claude/antigravity_claude_request_test.go | 248 ++++++++++++++++--
.../claude/signature_validation.go | 4 +
.../gemini/antigravity_gemini_request.go | 221 ++++++++++++++--
.../gemini/antigravity_gemini_request_test.go | 147 +++++++++++
.../antigravity_openai-responses_request.go | 192 ++++++++++++++
...tigravity_openai-responses_request_test.go | 176 +++++++++++++
.../claude_openai-responses_request.go | 5 +-
.../claude_openai-responses_request_test.go | 87 +++++-
.../codex/claude/codex_claude_request.go | 39 +--
.../gemini/gemini-cli_gemini_request.go | 15 +-
.../gemini-cli_openai_request.go | 17 +-
.../gemini-cli/gemini_gemini-cli_request.go | 15 +-
.../gemini/gemini/gemini_gemini_request.go | 15 +-
.../chat-completions/gemini_openai_request.go | 17 +-
.../gemini_openai_signature_test.go | 51 ++++
.../gemini_openai-responses_request.go | 7 +-
.../gemini_openai-responses_request_test.go | 66 +++++
.../openai/claude/openai_claude_request.go | 13 +
.../claude/openai_claude_request_test.go | 134 +++++++---
.../openai/openai_responses_signature_test.go | 86 ++++++
34 files changed, 2400 insertions(+), 253 deletions(-)
create mode 100644 internal/runtime/executor/codex_executor_signature_test.go
create mode 100644 internal/runtime/executor/openai_responses_signature.go
create mode 100644 internal/signature/gemini_sanitize.go
create mode 100644 internal/signature/gemini_sanitize_test.go
create mode 100644 internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go
create mode 100644 internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go
create mode 100644 internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
create mode 100644 sdk/api/handlers/openai/openai_responses_signature_test.go
diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go
index 408a490d03d..6388856ee9e 100644
--- a/internal/runtime/executor/antigravity_executor.go
+++ b/internal/runtime/executor/antigravity_executor.go
@@ -245,7 +245,9 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b
return rawJSON, nil
}
// Always strip thinking blocks with invalid signatures (empty or non-Claude-format).
+ before := countClaudeThinkingBlocks(rawJSON)
rawJSON = antigravityclaude.StripEmptySignatureThinkingBlocks(rawJSON)
+ logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "prefix_cleanup", "empty_or_non_claude_signature")
if cache.SignatureCacheEnabled() {
return rawJSON, nil
}
@@ -254,12 +256,51 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b
// by dropping unsigned thinking blocks silently (no 400).
return rawJSON, nil
}
- if err := antigravityclaude.ValidateClaudeBypassSignatures(rawJSON); err != nil {
- return rawJSON, statusErr{code: http.StatusBadRequest, msg: err.Error()}
- }
+ before = countClaudeThinkingBlocks(rawJSON)
+ rawJSON = antigravityclaude.StripInvalidBypassSignatureThinkingBlocks(rawJSON)
+ logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "strict_bypass", "invalid_antigravity_claude_signature")
return rawJSON, nil
}
+func countClaudeThinkingBlocks(rawJSON []byte) int {
+ messages := gjson.GetBytes(rawJSON, "messages")
+ if !messages.IsArray() {
+ return 0
+ }
+
+ count := 0
+ messages.ForEach(func(_, message gjson.Result) bool {
+ content := message.Get("content")
+ if !content.IsArray() {
+ return true
+ }
+ content.ForEach(func(_, part gjson.Result) bool {
+ if part.Get("type").String() == "thinking" {
+ count++
+ }
+ return true
+ })
+ return true
+ })
+ return count
+}
+
+func logAntigravitySignatureStrip(before, after int, stage, reason string) {
+ removed := before - after
+ if removed <= 0 {
+ return
+ }
+ log.WithFields(log.Fields{
+ "component": "signature_sanitizer",
+ "executor": "antigravity",
+ "target_provider": "claude",
+ "action": "drop_thinking_blocks",
+ "stage": stage,
+ "reason": reason,
+ "count": removed,
+ }).Debug("antigravity executor: dropped Claude thinking blocks with invalid signatures")
+}
+
// Identifier returns the executor identifier.
func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go
index 7d84bfe8902..8383614dc2a 100644
--- a/internal/runtime/executor/antigravity_executor_signature_test.go
+++ b/internal/runtime/executor/antigravity_executor_signature_test.go
@@ -4,16 +4,17 @@ import (
"bytes"
"context"
"encoding/base64"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
+ "fmt"
+ "strings"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
+ log "github.com/sirupsen/logrus"
+ "github.com/sirupsen/logrus/hooks/test"
+ "github.com/tidwall/gjson"
)
func testGeminiSignaturePayload() string {
@@ -56,7 +57,38 @@ func invalidClaudeThinkingPayload() []byte {
}`)
}
-func TestAntigravityExecutor_StrictBypassRejectsInvalidSignature(t *testing.T) {
+func newSignatureDebugHook(t *testing.T) *test.Hook {
+ t.Helper()
+
+ previousLevel := log.GetLevel()
+ log.SetLevel(log.DebugLevel)
+ hook := test.NewLocal(log.StandardLogger())
+ t.Cleanup(func() {
+ hook.Reset()
+ log.SetLevel(previousLevel)
+ })
+ return hook
+}
+
+func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden string) {
+ t.Helper()
+
+ if forbidden == "" {
+ return
+ }
+ for _, entry := range hook.AllEntries() {
+ if strings.Contains(entry.Message, forbidden) {
+ t.Fatalf("debug log leaked signature in message: %q", entry.Message)
+ }
+ for key, value := range entry.Data {
+ if strings.Contains(fmt.Sprint(value), forbidden) {
+ t.Fatalf("debug log leaked signature in field %q: %v", key, value)
+ }
+ }
+ }
+}
+
+func TestAntigravityExecutor_StrictBypassStripsInvalidSignature(t *testing.T) {
previousCache := cache.SignatureCacheEnabled()
previousStrict := cache.SignatureBypassStrictMode()
cache.SetSignatureCacheEnabled(false)
@@ -66,67 +98,122 @@ func TestAntigravityExecutor_StrictBypassRejectsInvalidSignature(t *testing.T) {
cache.SetSignatureBypassStrictMode(previousStrict)
})
- var hits atomic.Int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- hits.Add(1)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}}`))
- }))
- defer server.Close()
-
- executor := NewAntigravityExecutor(nil)
- auth := testAntigravityAuth(server.URL)
payload := invalidClaudeThinkingPayload()
- opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude"), OriginalRequest: payload}
- req := cliproxyexecutor.Request{Model: "claude-sonnet-4-5-thinking", Payload: payload}
-
- tests := []struct {
- name string
- invoke func() error
- }{
- {
- name: "execute",
- invoke: func() error {
- _, err := executor.Execute(context.Background(), auth, req, opts)
- return err
- },
- },
- {
- name: "stream",
- invoke: func() error {
- _, err := executor.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{SourceFormat: opts.SourceFormat, OriginalRequest: payload, Stream: true})
- return err
- },
- },
- {
- name: "count tokens",
- invoke: func() error {
- _, err := executor.CountTokens(context.Background(), auth, req, opts)
- return err
- },
- },
+ from := sdktranslator.FromString("claude")
+
+ output, err := validateAntigravityRequestSignatures(from, payload)
+ if err != nil {
+ t.Fatalf("strict bypass should strip invalid signatures instead of rejecting request: %v", err)
+ }
+ parts := gjson.GetBytes(output, "messages.0.content").Array()
+ if len(parts) != 1 {
+ t.Fatalf("content length = %d, want 1 after invalid thinking strip: %s", len(parts), output)
}
+ if got := parts[0].Get("type").String(); got != "text" {
+ t.Fatalf("remaining part type = %q, want text: %s", got, output)
+ }
+}
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- err := tt.invoke()
- if err == nil {
- t.Fatal("expected invalid signature to return an error")
- }
- statusProvider, ok := err.(interface{ StatusCode() int })
- if !ok {
- t.Fatalf("expected status error, got %T: %v", err, err)
+func TestAntigravityExecutor_StrictBypassLogsStrippedInvalidSignature(t *testing.T) {
+ previousCache := cache.SignatureCacheEnabled()
+ previousStrict := cache.SignatureBypassStrictMode()
+ cache.SetSignatureCacheEnabled(false)
+ cache.SetSignatureBypassStrictMode(true)
+ t.Cleanup(func() {
+ cache.SetSignatureCacheEnabled(previousCache)
+ cache.SetSignatureBypassStrictMode(previousStrict)
+ })
+
+ hook := newSignatureDebugHook(t)
+ rawSignature := testFakeClaudeSignature()
+ payload := []byte(`{
+ "model": "claude-sonnet-4-5-thinking",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "bad", "signature": "` + rawSignature + `"},
+ {"type": "text", "text": "hello"}
+ ]
}
- if statusProvider.StatusCode() != http.StatusBadRequest {
- t.Fatalf("status = %d, want %d", statusProvider.StatusCode(), http.StatusBadRequest)
+ ]
+ }`)
+ from := sdktranslator.FromString("claude")
+
+ if _, err := validateAntigravityRequestSignatures(from, payload); err != nil {
+ t.Fatalf("strict bypass should strip invalid signatures instead of rejecting request: %v", err)
+ }
+
+ found := false
+ for _, entry := range hook.AllEntries() {
+ if entry.Level != log.DebugLevel {
+ continue
+ }
+ if entry.Data["component"] != "signature_sanitizer" ||
+ entry.Data["executor"] != "antigravity" ||
+ entry.Data["action"] != "drop_thinking_blocks" ||
+ entry.Data["stage"] != "strict_bypass" {
+ continue
+ }
+ if entry.Data["count"] != 1 {
+ t.Fatalf("debug drop count = %v, want 1", entry.Data["count"])
+ }
+ found = true
+ }
+ if !found {
+ t.Fatal("expected debug log for stripped Antigravity Claude thinking signature")
+ }
+ assertSignatureDebugDoesNotLeak(t, hook, rawSignature)
+}
+
+func TestClaudeExecutor_LogsSanitizedClaudeUpstreamSignatures(t *testing.T) {
+ hook := newSignatureDebugHook(t)
+ rawSignature := "skip_thought_signature_validator"
+ body := []byte(`{
+ "model": "claude-sonnet-4-5",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "bad", "signature": "` + rawSignature + `"},
+ {"type": "text", "text": "hello"},
+ {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {}, "signature": "` + rawSignature + `"}
+ ]
}
- })
+ ]
+ }`)
+
+ output := sanitizeClaudeMessagesForClaudeUpstreamWithDebug(context.Background(), body, "claude-sonnet-4-5")
+ parts := gjson.GetBytes(output, "messages.0.content").Array()
+ if len(parts) != 2 {
+ t.Fatalf("content length = %d, want 2 after invalid thinking strip: %s", len(parts), output)
+ }
+ if parts[1].Get("signature").Exists() {
+ t.Fatalf("tool_use signature should be removed before Claude upstream: %s", output)
}
- if got := hits.Load(); got != 0 {
- t.Fatalf("expected invalid signature to be rejected before upstream request, got %d upstream hits", got)
+ found := false
+ for _, entry := range hook.AllEntries() {
+ if entry.Level != log.DebugLevel {
+ continue
+ }
+ if entry.Data["component"] != "signature_sanitizer" ||
+ entry.Data["executor"] != "claude" ||
+ entry.Data["action"] != "sanitize_claude_messages" {
+ continue
+ }
+ if entry.Data["dropped_blocks"] != 1 {
+ t.Fatalf("dropped_blocks = %v, want 1", entry.Data["dropped_blocks"])
+ }
+ if entry.Data["dropped_signatures"] != 1 {
+ t.Fatalf("dropped_signatures = %v, want 1", entry.Data["dropped_signatures"])
+ }
+ found = true
+ }
+ if !found {
+ t.Fatal("expected debug log for Claude upstream signature sanitization")
}
+ assertSignatureDebugDoesNotLeak(t, hook, rawSignature)
}
func TestAntigravityExecutor_NonStrictBypassSkipsPrecheck(t *testing.T) {
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 626a90abe27..6d6b975fd5e 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -22,6 +22,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
@@ -44,6 +45,38 @@ type ClaudeExecutor struct {
// Previously "proxy_" was used but this is a detectable fingerprint difference.
const claudeToolPrefix = ""
+func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string) []byte {
+ sanitized, report := sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel)
+ logClaudeSignatureSanitizeReport(ctx, baseModel, report)
+ return sanitized
+}
+
+func logClaudeSignatureSanitizeReport(ctx context.Context, baseModel string, report sigcompat.SignatureSanitizeReport) {
+ if report.DroppedBlocks == 0 && report.DroppedSignatures == 0 && report.ReplacedSignatures == 0 {
+ return
+ }
+
+ fields := log.Fields{
+ "component": "signature_sanitizer",
+ "executor": "claude",
+ "action": "sanitize_claude_messages",
+ "target_provider": string(report.TargetProvider),
+ "target_model": baseModel,
+ "preserved": report.Preserved,
+ "dropped_blocks": report.DroppedBlocks,
+ "dropped_signatures": report.DroppedSignatures,
+ "replaced_signatures": report.ReplacedSignatures,
+ }
+ if len(report.Decisions) > 0 {
+ decision := report.Decisions[0]
+ fields["first_block_kind"] = string(decision.BlockKind)
+ fields["first_detected_provider"] = string(decision.DetectedProvider)
+ fields["first_reason"] = decision.Reason
+ }
+
+ helps.LogWithRequestID(ctx).WithFields(fields).Debug("claude executor: sanitized signature history before upstream")
+}
+
// oauthToolRenameMap maps OpenCode-style (lowercase) tool names to Claude Code-style
// (TitleCase) names. Anthropic uses tool name fingerprinting to detect third-party
// clients on OAuth traffic. Renaming to official names avoids extra-usage billing.
@@ -195,6 +228,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
if oauthToken {
bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled())
}
+ bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel)
// Enable cch signing by default for OAuth tokens (not just experimental flag).
// Claude Code always computes cch; missing or invalid cch is a detectable fingerprint.
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
@@ -372,6 +406,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if oauthToken {
bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled())
}
+ bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel)
// Enable cch signing by default for OAuth tokens (not just experimental flag).
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
@@ -613,6 +648,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
if isClaudeOAuthToken(apiKey) {
body, _ = prepareClaudeOAuthToolNamesForUpstream(body, claudeToolPrefix, auth.ToolPrefixDisabled())
}
+ body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel)
url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go
index f5bca55ab78..2ac32ebdeec 100644
--- a/internal/runtime/executor/claude_executor_test.go
+++ b/internal/runtime/executor/claude_executor_test.go
@@ -1251,6 +1251,63 @@ func TestClaudeExecutor_CountTokens_AppliesCacheControlGuards(t *testing.T) {
}
}
+func TestClaudeExecutor_ExecuteSanitizesSignaturesBeforeUpstream(t *testing.T) {
+ var seenBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ seenBody = bytes.Clone(body)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-4-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
+ }))
+ defer server.Close()
+
+ executor := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": server.URL,
+ }}
+
+ payload := []byte(`{
+ "model": "claude-sonnet-4-5",
+ "max_tokens": 16,
+ "messages": [
+ {"role":"assistant","content":[
+ {"type":"thinking","thinking":"drop this","signature":""},
+ {"type":"text","text":"I will run git status."},
+ {"type":"tool_use","id":"Bash-1","name":"Bash","input":{"command":"git status"},"signature":"bad","thoughtSignature":"bad2","model":"claude-opus-4-1"}
+ ]},
+ {"role":"user","content":[{"type":"tool_result","tool_use_id":"Bash-1","content":"ok"}]}
+ ]
+ }`)
+
+ if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-sonnet-4-5",
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ }); err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ parts := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ if len(parts) != 2 {
+ t.Fatalf("messages.0.content length = %d, want 2; body=%s", len(parts), seenBody)
+ }
+ if parts[0].Get("type").String() != "text" {
+ t.Fatalf("first remaining part = %s, want text", parts[0].Raw)
+ }
+ toolUse := parts[1]
+ if toolUse.Get("type").String() != "tool_use" {
+ t.Fatalf("second remaining part = %s, want tool_use", toolUse.Raw)
+ }
+ for _, path := range []string{"signature", "thoughtSignature", "model"} {
+ if toolUse.Get(path).Exists() {
+ t.Fatalf("tool_use.%s should be removed before upstream: %s", path, seenBody)
+ }
+ }
+}
+
func hasTTLOrderingViolation(payload []byte) bool {
seen5m := false
violates := false
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index a5899efbb3d..a96e805cbc0 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -285,6 +285,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
@@ -443,6 +444,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
@@ -546,6 +548,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
diff --git a/internal/runtime/executor/codex_executor_signature_test.go b/internal/runtime/executor/codex_executor_signature_test.go
new file mode 100644
index 00000000000..0702dd6ced7
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_signature_test.go
@@ -0,0 +1,138 @@
+package executor
+
+import (
+ "context"
+ "encoding/base64"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+func validCodexReasoningEncryptedContentForTest() string {
+ payload := make([]byte, 1+8+16+16+32)
+ payload[0] = 0x80
+ for i := 9; i < len(payload); i++ {
+ payload[i] = byte(i)
+ }
+ return base64.RawURLEncoding.EncodeToString(payload)
+}
+
+func newCodexSignatureTestAuth(serverURL string) *cliproxyauth.Auth {
+ return &cliproxyauth.Auth{Attributes: map[string]string{
+ "base_url": serverURL,
+ "api_key": "test",
+ }}
+}
+
+func TestCodexExecutorDropsInvalidReasoningEncryptedContentFromFinalRequest(t *testing.T) {
+ validEncryptedContent := validCodexReasoningEncryptedContentForTest()
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), newCodexSignatureTestAuth(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","input":[` +
+ `{"id":"rs_bad","type":"reasoning","encrypted_content":"gAAAAABqFTIa\u2026abc","summary":[]},` +
+ `{"id":"rs_non_string","type":"reasoning","encrypted_content":123,"summary":[]},` +
+ `{"id":"rs_good","type":"reasoning","encrypted_content":"` + validEncryptedContent + `","summary":[]},` +
+ `{"role":"user","content":"hello","encrypted_content":"leave-message-alone"}` +
+ `]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ t.Fatalf("invalid reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
+ }
+ if gjson.GetBytes(gotBody, "input.1.encrypted_content").Exists() {
+ t.Fatalf("non-string reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
+ }
+ if got := gjson.GetBytes(gotBody, "input.2.encrypted_content").String(); got != validEncryptedContent {
+ t.Fatalf("valid reasoning encrypted_content = %q, want preserved", got)
+ }
+ if got := gjson.GetBytes(gotBody, "input.3.encrypted_content").String(); got != "leave-message-alone" {
+ t.Fatalf("non-reasoning encrypted_content = %q, want untouched", got)
+ }
+}
+
+func TestCodexExecutorExecuteStreamDropsInvalidReasoningEncryptedContentFromFinalRequest(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ result, err := executor.ExecuteStream(context.Background(), newCodexSignatureTestAuth(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","stream":true,"input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"bad","summary":[]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream error: %v", err)
+ }
+ for range result.Chunks {
+ }
+ if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ t.Fatalf("invalid stream reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
+ }
+}
+
+func TestCodexExecutorCompactDropsInvalidReasoningEncryptedContentFromFinalRequest(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ gotBody = body
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"resp_1","object":"response.compaction","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), newCodexSignatureTestAuth(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"bad","summary":[]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Alt: "responses/compact",
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute compact error: %v", err)
+ }
+ if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ t.Fatalf("invalid compact reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
+ }
+}
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index 5594356bbd4..8339114fef9 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -213,6 +213,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body)
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
@@ -417,6 +418,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth)
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body)
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index 8475e372a6c..2be71afc3a7 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -125,6 +125,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil {
translated = updated
}
+ translated = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "openai compat executor", translated)
}
reporter.SetTranslatedReasoningEffort(translated, to.String())
diff --git a/internal/runtime/executor/openai_responses_signature.go b/internal/runtime/executor/openai_responses_signature.go
new file mode 100644
index 00000000000..e3a59f2f9ad
--- /dev/null
+++ b/internal/runtime/executor/openai_responses_signature.go
@@ -0,0 +1,68 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+func sanitizeOpenAIResponsesReasoningEncryptedContent(ctx context.Context, provider string, body []byte) []byte {
+ input := gjson.GetBytes(body, "input")
+ if !input.Exists() || !input.IsArray() {
+ return body
+ }
+ provider = strings.TrimSpace(provider)
+ if provider == "" {
+ provider = "openai responses upstream"
+ }
+
+ updated := body
+ for index, item := range input.Array() {
+ if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
+ continue
+ }
+
+ encryptedContentPath := fmt.Sprintf("input.%d.encrypted_content", index)
+ encryptedContent := gjson.GetBytes(updated, encryptedContentPath)
+ if !encryptedContent.Exists() {
+ continue
+ }
+
+ reason := ""
+ switch encryptedContent.Type {
+ case gjson.String:
+ rawSignature := encryptedContent.String()
+ if rawSignature != strings.TrimSpace(rawSignature) {
+ reason = "encrypted_content has leading or trailing whitespace"
+ } else if _, err := signature.InspectGPTReasoningSignature(rawSignature); err != nil {
+ reason = err.Error()
+ }
+ case gjson.Null:
+ reason = "encrypted_content is null"
+ default:
+ reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String())
+ }
+ if reason == "" {
+ continue
+ }
+
+ next, err := sjson.DeleteBytes(updated, encryptedContentPath)
+ if err != nil {
+ helps.LogWithRequestID(ctx).Debugf("%s: failed to drop invalid reasoning encrypted_content at input[%d]: %v", provider, index, err)
+ continue
+ }
+ updated = next
+
+ itemID := strings.TrimSpace(gjson.GetBytes(updated, fmt.Sprintf("input.%d.id", index)).String())
+ if itemID == "" {
+ itemID = fmt.Sprintf("input[%d]", index)
+ }
+ helps.LogWithRequestID(ctx).Debugf("%s: dropped invalid reasoning encrypted_content at input[%d] item_id=%q reason=%s", provider, index, itemID, reason)
+ }
+ return updated
+}
diff --git a/internal/signature/claude_messages_sanitize.go b/internal/signature/claude_messages_sanitize.go
index aec08879d32..4389704c637 100644
--- a/internal/signature/claude_messages_sanitize.go
+++ b/internal/signature/claude_messages_sanitize.go
@@ -9,10 +9,11 @@ import (
)
type ClaudeMessagesSignatureSanitizeOptions struct {
- TargetProvider SignatureProvider
- TargetModel string
- DropEmptyMessages bool
- DropToolSignatures bool
+ TargetProvider SignatureProvider
+ TargetModel string
+ DropEmptyMessages bool
+ DropToolSignatures bool
+ DropEmptyThinkingPlaceholders bool
}
type SignatureSanitizeReport struct {
@@ -35,6 +36,20 @@ func SanitizeClaudeMessagesSignaturesForModel(payload []byte, targetModel string
})
}
+// SanitizeClaudeMessagesForClaudeUpstream prepares a Claude /v1/messages body
+// for native Claude upstreams. Invalid thinking blocks are dropped, valid
+// thinking signatures are normalized to Claude provider-native E-form, and
+// tool_use blocks keep only their tool-call payload.
+func SanitizeClaudeMessagesForClaudeUpstream(payload []byte, targetModel string) ([]byte, SignatureSanitizeReport) {
+ return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{
+ TargetProvider: SignatureProviderClaude,
+ TargetModel: targetModel,
+ DropEmptyMessages: true,
+ DropToolSignatures: true,
+ DropEmptyThinkingPlaceholders: true,
+ })
+}
+
// SanitizeClaudeMessagesSignaturesForTarget applies provider-aware signature
// compatibility rules to Claude /v1/messages history. Compatible thinking
// signatures are preserved. Incompatible thinking blocks are removed so a user
@@ -103,7 +118,7 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag
continue
}
- if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) {
+ if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders {
keptParts = append(keptParts, part.Raw)
continue
}
@@ -162,7 +177,7 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag
func stripClaudeToolUseSignatureFields(part gjson.Result) (string, bool) {
updated := part.Raw
changed := false
- for _, sigPath := range claudeToolUseSignaturePaths() {
+ for _, sigPath := range claudeToolUseProvenancePaths() {
if !gjson.Get(updated, sigPath).Exists() {
continue
}
@@ -231,11 +246,16 @@ func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureP
func claudeToolUseSignaturePaths() []string {
return []string{
"signature",
+ "thoughtSignature",
"thought_signature",
"extra_content.google.thought_signature",
}
}
+func claudeToolUseProvenancePaths() []string {
+ return append(claudeToolUseSignaturePaths(), "model")
+}
+
func deleteEmptyJSONObjectPath(raw, path string) (string, bool) {
result := gjson.Get(raw, path)
if !result.Exists() || !result.IsObject() || len(result.Map()) != 0 {
diff --git a/internal/signature/gemini_sanitize.go b/internal/signature/gemini_sanitize.go
new file mode 100644
index 00000000000..e639255ccec
--- /dev/null
+++ b/internal/signature/gemini_sanitize.go
@@ -0,0 +1,140 @@
+package signature
+
+import (
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+// GeminiReplaySignatureOrBypass returns a Gemini-replayable thoughtSignature.
+// Compatible Gemini signatures are normalized and preserved. Missing, unknown,
+// or cross-provider signatures are replaced with Gemini's bypass sentinel.
+func GeminiReplaySignatureOrBypass(rawSignature string, blockKind SignatureBlockKind) string {
+ if signature, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, rawSignature, blockKind); ok {
+ return signature
+ }
+ decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind)
+ if decision.Action == SignatureActionReplaceWithGeminiBypass && decision.ReplacementSignature != "" {
+ return decision.ReplacementSignature
+ }
+ return GeminiSkipThoughtSignatureValidator
+}
+
+// SanitizeGeminiRequestThoughtSignatures applies Gemini replay policy to a
+// Gemini-shaped request. Model-turn functionCall, thought, and signed parts keep
+// compatible Gemini signatures and use the bypass sentinel otherwise. User-turn
+// functionResponse parts must not carry thoughtSignature fields.
+func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) []byte {
+ contentsPath = strings.TrimSpace(contentsPath)
+ if contentsPath == "" {
+ contentsPath = "contents"
+ }
+
+ contents := gjson.GetBytes(payload, contentsPath)
+ if !contents.IsArray() {
+ return payload
+ }
+
+ contents.ForEach(func(contentIdx, content gjson.Result) bool {
+ isModelTurn := content.Get("role").String() == "model"
+ parts := content.Get("parts")
+ if !parts.IsArray() {
+ return true
+ }
+
+ parts.ForEach(func(partIdx, part gjson.Result) bool {
+ partPath := fmt.Sprintf("%s.%d.parts.%d", contentsPath, contentIdx.Int(), partIdx.Int())
+ if part.Get("functionResponse").Exists() {
+ _, hadSignature := geminiPartThoughtSignature(part)
+ payload = deleteGeminiPartThoughtSignatureFields(payload, partPath)
+ if hadSignature {
+ logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), SignatureCompatibilityDecision{
+ TargetProvider: SignatureProviderGemini,
+ BlockKind: SignatureBlockKindGeminiModelPart,
+ Action: SignatureActionDropSignature,
+ Reason: "user-turn functionResponse parts cannot replay thought signatures",
+ }, "", true)
+ }
+ return true
+ }
+ if !isModelTurn {
+ return true
+ }
+
+ hasFunctionCall := part.Get("functionCall").Exists()
+ hasThought := part.Get("thought").Exists()
+ rawSignature, hasSignature := geminiPartThoughtSignature(part)
+ if !hasFunctionCall && !hasThought && !hasSignature {
+ return true
+ }
+
+ blockKind := SignatureBlockKindGeminiModelPart
+ if hasFunctionCall {
+ blockKind = SignatureBlockKindGeminiFunctionCall
+ }
+ payload = deleteGeminiPartThoughtSignatureFields(payload, partPath)
+ decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind)
+ replaySignature := GeminiReplaySignatureOrBypass(rawSignature, blockKind)
+ payload, _ = sjson.SetBytes(payload, partPath+".thoughtSignature", replaySignature)
+ if decision.Action != SignatureActionPreserve {
+ logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), decision, rawSignature, hasSignature)
+ }
+ return true
+ })
+ return true
+ })
+
+ return payload
+}
+
+func logGeminiThoughtSignatureSanitize(contentsPath string, contentIndex, partIndex int, decision SignatureCompatibilityDecision, rawSignature string, hasSignature bool) {
+ log.WithFields(log.Fields{
+ "component": "signature_sanitizer",
+ "target_provider": string(SignatureProviderGemini),
+ "action": string(decision.Action),
+ "reason": decision.Reason,
+ "contents_path": contentsPath,
+ "content_index": contentIndex,
+ "part_index": partIndex,
+ "block_kind": string(decision.BlockKind),
+ "detected_provider": string(decision.DetectedProvider),
+ "has_signature": hasSignature,
+ "signature_length": len(strings.TrimSpace(rawSignature)),
+ }).Debug("gemini request: sanitized thoughtSignature before upstream")
+}
+
+func geminiPartThoughtSignature(part gjson.Result) (string, bool) {
+ for _, path := range []string{
+ "thoughtSignature",
+ "thought_signature",
+ "functionCall.thoughtSignature",
+ "functionCall.thought_signature",
+ "functionResponse.thoughtSignature",
+ "functionResponse.thought_signature",
+ "extra_content.google.thought_signature",
+ } {
+ result := part.Get(path)
+ if result.Exists() {
+ return result.String(), true
+ }
+ }
+ return "", false
+}
+
+func deleteGeminiPartThoughtSignatureFields(payload []byte, partPath string) []byte {
+ for _, path := range []string{
+ "thoughtSignature",
+ "thought_signature",
+ "functionCall.thoughtSignature",
+ "functionCall.thought_signature",
+ "functionResponse.thoughtSignature",
+ "functionResponse.thought_signature",
+ "extra_content.google.thought_signature",
+ } {
+ payload, _ = sjson.DeleteBytes(payload, partPath+"."+path)
+ }
+ return payload
+}
diff --git a/internal/signature/gemini_sanitize_test.go b/internal/signature/gemini_sanitize_test.go
new file mode 100644
index 00000000000..8faf8a85766
--- /dev/null
+++ b/internal/signature/gemini_sanitize_test.go
@@ -0,0 +1,122 @@
+package signature
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/sirupsen/logrus/hooks/test"
+ "github.com/tidwall/gjson"
+)
+
+func newSignatureDebugHook(t *testing.T) *test.Hook {
+ t.Helper()
+
+ previousLevel := log.GetLevel()
+ log.SetLevel(log.DebugLevel)
+ hook := test.NewLocal(log.StandardLogger())
+ t.Cleanup(func() {
+ hook.Reset()
+ log.SetLevel(previousLevel)
+ })
+ return hook
+}
+
+func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden string) {
+ t.Helper()
+
+ if forbidden == "" {
+ return
+ }
+ for _, entry := range hook.AllEntries() {
+ if strings.Contains(entry.Message, forbidden) {
+ t.Fatalf("debug log leaked signature in message: %q", entry.Message)
+ }
+ for key, value := range entry.Data {
+ if strings.Contains(fmt.Sprint(value), forbidden) {
+ t.Fatalf("debug log leaked signature in field %q: %v", key, value)
+ }
+ }
+ }
+}
+
+func TestSanitizeGeminiRequestThoughtSignaturesPreservesGeminiSignature(t *testing.T) {
+ sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}`)
+
+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
+
+ if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != sig {
+ t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, sig, string(out))
+ }
+}
+
+func TestSanitizeGeminiRequestThoughtSignaturesReplacesBase64UUIDFunctionCall(t *testing.T) {
+ sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{},"thoughtSignature":"` + sig + `"}}]}]}`)
+
+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
+
+ if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out))
+ }
+ if gjson.GetBytes(out, "contents.0.parts.0.functionCall.thoughtSignature").Exists() {
+ t.Fatalf("nested functionCall thoughtSignature should be removed. Output: %s", string(out))
+ }
+}
+
+func TestSanitizeGeminiRequestThoughtSignaturesLogsBypassReplacement(t *testing.T) {
+ hook := newSignatureDebugHook(t)
+ sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{},"thoughtSignature":"` + sig + `"}}]}]}`)
+
+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
+ if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out))
+ }
+
+ found := false
+ for _, entry := range hook.AllEntries() {
+ if entry.Level != log.DebugLevel {
+ continue
+ }
+ if entry.Data["component"] != "signature_sanitizer" ||
+ entry.Data["target_provider"] != string(SignatureProviderGemini) ||
+ entry.Data["action"] != "replace_with_gemini_bypass" {
+ continue
+ }
+ if entry.Data["block_kind"] != string(SignatureBlockKindGeminiFunctionCall) {
+ t.Fatalf("block_kind = %v, want %s", entry.Data["block_kind"], SignatureBlockKindGeminiFunctionCall)
+ }
+ found = true
+ }
+ if !found {
+ t.Fatal("expected debug log for Gemini thoughtSignature bypass replacement")
+ }
+ assertSignatureDebugDoesNotLeak(t, hook, sig)
+}
+
+func TestSanitizeGeminiRequestThoughtSignaturesReplacesField2WrappedUUIDFunctionCall(t *testing.T) {
+ sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
+ input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}}`)
+
+ out := SanitizeGeminiRequestThoughtSignatures(input, "request.contents")
+
+ if got := gjson.GetBytes(out, "request.contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out))
+ }
+}
+
+func TestSanitizeGeminiRequestThoughtSignaturesRemovesFunctionResponseSignature(t *testing.T) {
+ input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"},"thoughtSignature":"bad"},"thoughtSignature":"bad"}]}]}`)
+
+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
+
+ if gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").Exists() {
+ t.Fatalf("functionResponse top-level thoughtSignature should be removed. Output: %s", string(out))
+ }
+ if gjson.GetBytes(out, "contents.0.parts.0.functionResponse.thoughtSignature").Exists() {
+ t.Fatalf("functionResponse nested thoughtSignature should be removed. Output: %s", string(out))
+ }
+}
diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go
index dcb5b829964..541bfa1563b 100644
--- a/internal/signature/provider_compatibility_test.go
+++ b/internal/signature/provider_compatibility_test.go
@@ -282,3 +282,58 @@ func TestSanitizeClaudeMessagesSignaturesForModel_DropsEmptyAssistantMessage(t *
t.Fatalf("remaining role = %q, want user", got)
}
}
+
+func TestSanitizeClaudeMessagesForClaudeUpstream_DropsInvalidThinkingAndCleansToolUse(t *testing.T) {
+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop me","signature":""},{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"git status"},"signature":"bad","thoughtSignature":"bad2","thought_signature":"bad3","model":"claude-sonnet-4-5","extra_content":{"google":{"thought_signature":"bad4"}}}]}]}`)
+
+ output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
+ if report.DroppedBlocks != 1 {
+ t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
+ }
+ parts := gjson.GetBytes(output, "messages.0.content").Array()
+ if len(parts) != 2 {
+ t.Fatalf("content length = %d, want 2: %s", len(parts), output)
+ }
+ if parts[0].Get("type").String() != "text" {
+ t.Fatalf("first remaining part = %s, want text", parts[0].Raw)
+ }
+ toolUse := parts[1]
+ if toolUse.Get("type").String() != "tool_use" {
+ t.Fatalf("second remaining part = %s, want tool_use", toolUse.Raw)
+ }
+ if got := toolUse.Get("id").String(); got != "toolu_1" {
+ t.Fatalf("tool_use id = %q, want toolu_1", got)
+ }
+ for _, path := range []string{
+ "signature",
+ "thoughtSignature",
+ "thought_signature",
+ "model",
+ "extra_content",
+ } {
+ if toolUse.Get(path).Exists() {
+ t.Fatalf("tool_use.%s should be removed: %s", path, toolUse.Raw)
+ }
+ }
+}
+
+func TestSanitizeClaudeMessagesForClaudeUpstream_NormalizesValidThinkingAndDropsEmptyMessage(t *testing.T) {
+ nativeSig := testClaudeThinkingSignature()
+ doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig))
+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + doubleEncoded + `"},{"type":"text","text":"answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"drop"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`)
+
+ output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
+ if report.Preserved != 1 || report.DroppedBlocks != 1 {
+ t.Fatalf("unexpected report: %+v", report)
+ }
+ messages := gjson.GetBytes(output, "messages").Array()
+ if len(messages) != 2 {
+ t.Fatalf("messages length = %d, want 2: %s", len(messages), output)
+ }
+ if got := messages[0].Get("content.0.signature").String(); got != nativeSig {
+ t.Fatalf("signature = %q, want provider-native %q", got, nativeSig)
+ }
+ if got := messages[1].Get("role").String(); got != "user" {
+ t.Fatalf("remaining second role = %q, want user", got)
+ }
+}
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go
index 456475f1f76..fe2c8cde904 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
@@ -18,15 +19,30 @@ import (
)
func resolveThinkingSignature(modelName, thinkingText, rawSignature string) string {
+ targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
+ if targetProvider == sigcompat.SignatureProviderGemini {
+ return resolveProviderCompatibleSignature(targetProvider, rawSignature, sigcompat.SignatureBlockKindGeminiModelPart)
+ }
if cache.SignatureCacheEnabled() {
return resolveCacheModeSignature(modelName, thinkingText, rawSignature)
}
- return resolveBypassModeSignature(rawSignature)
+ if signature := resolveProviderCompatibleSignature(targetProvider, rawSignature, sigcompat.SignatureBlockKindUnknown); signature != "" {
+ return signature
+ }
+ return resolveBypassModeSignatureForProvider(targetProvider, rawSignature)
}
func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) string {
+ targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
if thinkingText != "" {
if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
+ if targetProvider == sigcompat.SignatureProviderClaude {
+ signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(cachedSig)
+ if !ok {
+ return ""
+ }
+ return signature
+ }
return cachedSig
}
}
@@ -43,6 +59,13 @@ func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) str
}
}
if cache.HasValidSignature(modelName, clientSignature) {
+ if targetProvider == sigcompat.SignatureProviderClaude {
+ signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(clientSignature)
+ if !ok {
+ return ""
+ }
+ return signature
+ }
return clientSignature
}
@@ -50,9 +73,23 @@ func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) str
}
func resolveBypassModeSignature(rawSignature string) string {
+ return resolveBypassModeSignatureForProvider(sigcompat.SignatureProviderClaude, rawSignature)
+}
+
+func resolveBypassModeSignatureForProvider(targetProvider sigcompat.SignatureProvider, rawSignature string) string {
if rawSignature == "" {
return ""
}
+ if targetProvider != sigcompat.SignatureProviderClaude && targetProvider != sigcompat.SignatureProviderUnknown {
+ return ""
+ }
+ if targetProvider == sigcompat.SignatureProviderClaude {
+ signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
+ if !ok {
+ return ""
+ }
+ return signature
+ }
normalized, err := normalizeClaudeBypassSignature(rawSignature)
if err != nil {
return ""
@@ -61,12 +98,143 @@ func resolveBypassModeSignature(rawSignature string) string {
}
func hasResolvedThinkingSignature(modelName, signature string) bool {
+ targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
+ if targetProvider == sigcompat.SignatureProviderClaude {
+ _, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(signature)
+ return ok
+ }
+ if _, ok := sigcompat.CompatibleSignatureForProvider(targetProvider, signature); ok {
+ return true
+ }
if cache.SignatureCacheEnabled() {
return cache.HasValidSignature(modelName, signature)
}
return signature != ""
}
+func resolveProviderCompatibleSignature(targetProvider sigcompat.SignatureProvider, rawSignature string, blockKind sigcompat.SignatureBlockKind) string {
+ if rawSignature == "" {
+ return ""
+ }
+ if targetProvider == sigcompat.SignatureProviderClaude {
+ signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
+ if !ok {
+ return ""
+ }
+ return signature
+ }
+ signature, ok := sigcompat.CompatibleSignatureForProviderBlock(targetProvider, rawSignature, blockKind)
+ if !ok {
+ return ""
+ }
+ return signature
+}
+
+func resolveToolUseThoughtSignature(modelName string, contentResult gjson.Result, allowSyntheticFallback bool) string {
+ targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
+ if targetProvider == sigcompat.SignatureProviderGemini {
+ for _, path := range []string{
+ "signature",
+ "thought_signature",
+ "extra_content.google.thought_signature",
+ } {
+ if signatureResult := contentResult.Get(path); signatureResult.Exists() {
+ if signature := resolveProviderCompatibleSignature(targetProvider, signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall); signature != "" {
+ return signature
+ }
+ }
+ }
+ if allowSyntheticFallback {
+ return sigcompat.GeminiSkipThoughtSignatureValidator
+ }
+ return ""
+ }
+
+ for _, path := range []string{
+ "signature",
+ "thought_signature",
+ "extra_content.google.thought_signature",
+ } {
+ if signatureResult := contentResult.Get(path); signatureResult.Exists() {
+ if signature := resolveProviderCompatibleSignature(targetProvider, signatureResult.String(), sigcompat.SignatureBlockKindUnknown); signature != "" {
+ return signature
+ }
+ }
+ }
+ if targetProvider == sigcompat.SignatureProviderClaude {
+ return ""
+ }
+ return sigcompat.GeminiSkipThoughtSignatureValidator
+}
+
+func firstToolUseSignatureField(contentResult gjson.Result) (string, string, bool) {
+ for _, path := range []string{
+ "signature",
+ "thought_signature",
+ "extra_content.google.thought_signature",
+ } {
+ signatureResult := contentResult.Get(path)
+ if signatureResult.Exists() {
+ return path, signatureResult.String(), true
+ }
+ }
+ return "", "", false
+}
+
+func logDroppedAntigravityThinkingSignature(modelName string, messageIndex, contentIndex int, thinkingText string, signatureResult gjson.Result) {
+ rawSignature := signatureResult.String()
+ fields := log.Fields{
+ "component": "signature_sanitizer",
+ "translator": "antigravity_claude",
+ "target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)),
+ "action": "drop_thinking_block",
+ "reason": "missing_or_incompatible_signature",
+ "model": modelName,
+ "message_index": messageIndex,
+ "content_index": contentIndex,
+ "thinking_length": len(thinkingText),
+ "has_signature": signatureResult.Exists(),
+ "signature_length": len(strings.TrimSpace(rawSignature)),
+ }
+ if signatureResult.Exists() {
+ fields["detected_provider"] = string(sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking))
+ }
+ log.WithFields(fields).Debug("antigravity claude translator: dropped thinking block with incompatible signature")
+}
+
+func logDroppedAntigravityEmptyThinking(modelName string, messageIndex, contentIndex int) {
+ log.WithFields(log.Fields{
+ "component": "signature_sanitizer",
+ "translator": "antigravity_claude",
+ "target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)),
+ "action": "drop_thinking_block",
+ "reason": "empty_thinking_text",
+ "model": modelName,
+ "message_index": messageIndex,
+ "content_index": contentIndex,
+ }).Debug("antigravity claude translator: dropped empty thinking block")
+}
+
+func logDroppedAntigravityToolUseSignature(modelName string, messageIndex, contentIndex int, contentResult gjson.Result) {
+ path, rawSignature, ok := firstToolUseSignatureField(contentResult)
+ if !ok {
+ return
+ }
+ log.WithFields(log.Fields{
+ "component": "signature_sanitizer",
+ "translator": "antigravity_claude",
+ "target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)),
+ "action": "drop_tool_use_signature",
+ "reason": "missing_or_incompatible_signature",
+ "model": modelName,
+ "message_index": messageIndex,
+ "content_index": contentIndex,
+ "signature_path": path,
+ "signature_length": len(strings.TrimSpace(rawSignature)),
+ "detected_provider": string(sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindUnknown)),
+ }).Debug("antigravity claude translator: dropped tool_use signature field")
+}
+
// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Gemini CLI API.
@@ -147,19 +315,14 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if contentsResult.IsArray() {
contentResults := contentsResult.Array()
numContents := len(contentResults)
- var currentMessageThinkingSignature string
for j := 0; j < numContents; j++ {
contentResult := contentResults[j]
contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
// Use GetThinkingText to handle wrapped thinking objects
thinkingText := thinking.GetThinkingText(contentResult)
- signature := resolveThinkingSignature(modelName, thinkingText, contentResult.Get("signature").String())
-
- // Store for subsequent tool_use in the same message
- if hasResolvedThinkingSignature(modelName, signature) {
- currentMessageThinkingSignature = signature
- }
+ signatureResult := contentResult.Get("signature")
+ signature := resolveThinkingSignature(modelName, thinkingText, signatureResult.String())
// Skip unsigned thinking blocks instead of converting them to text.
isUnsigned := !hasResolvedThinkingSignature(modelName, signature)
@@ -168,7 +331,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
// Converting to text would break this requirement
if isUnsigned {
- // log.Debugf("Dropping unsigned thinking block (no valid signature)")
+ logDroppedAntigravityThinkingSignature(modelName, i, j, thinkingText, signatureResult)
enableThoughtTranslate = false
continue
}
@@ -178,6 +341,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// omits the required inner "thinking" field, causing:
// 400 "messages.N.content.0.thinking.thinking: Field required"
if thinkingText == "" {
+ logDroppedAntigravityEmptyThinking(modelName, i, j)
continue
}
@@ -226,15 +390,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if argsRaw != "" {
partJSON := []byte(`{}`)
- // Use skip_thought_signature_validator for tool calls without valid thinking signature
- // This is the approach used in opencode-google-antigravity-auth for Gemini
- // and also works for Claude through Antigravity API
- const skipSentinel = "skip_thought_signature_validator"
- if hasResolvedThinkingSignature(modelName, currentMessageThinkingSignature) {
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", currentMessageThinkingSignature)
+ signature := resolveToolUseThoughtSignature(modelName, contentResult, true)
+ if signature != "" {
+ partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
} else {
- // No valid signature - use skip sentinel to bypass validation
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", skipSentinel)
+ logDroppedAntigravityToolUseSignature(modelName, i, j, contentResult)
}
if functionID != "" {
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
index f4ffa3e41ec..017078d432d 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
@@ -3,10 +3,13 @@ package claude
import (
"bytes"
"encoding/base64"
+ "fmt"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ log "github.com/sirupsen/logrus"
+ "github.com/sirupsen/logrus/hooks/test"
"github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -22,6 +25,13 @@ func testAnthropicNativeSignature(t *testing.T) string {
return signature
}
+func testAntigravityClaudeSignature(t *testing.T) (string, string) {
+ t.Helper()
+
+ native := testAnthropicNativeSignature(t)
+ return native, base64.StdEncoding.EncodeToString([]byte(native))
+}
+
func testMinimalAnthropicSignature(t *testing.T) string {
t.Helper()
@@ -70,6 +80,37 @@ func uint64Ptr(v uint64) *uint64 {
return &v
}
+func newSignatureDebugHook(t *testing.T) *test.Hook {
+ t.Helper()
+
+ previousLevel := log.GetLevel()
+ log.SetLevel(log.DebugLevel)
+ hook := test.NewLocal(log.StandardLogger())
+ t.Cleanup(func() {
+ hook.Reset()
+ log.SetLevel(previousLevel)
+ })
+ return hook
+}
+
+func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden string) {
+ t.Helper()
+
+ if forbidden == "" {
+ return
+ }
+ for _, entry := range hook.AllEntries() {
+ if strings.Contains(entry.Message, forbidden) {
+ t.Fatalf("debug log leaked signature in message: %q", entry.Message)
+ }
+ for key, value := range entry.Data {
+ if strings.Contains(fmt.Sprint(value), forbidden) {
+ t.Fatalf("debug log leaked signature in field %q: %v", key, value)
+ }
+ }
+ }
+}
+
func TestConvertClaudeRequestToAntigravity_StripsClaudeCodeAttribution(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-sonnet-4-5",
@@ -114,6 +155,23 @@ func testGeminiRawSignature(t *testing.T) string {
return signature
}
+func testGeminiEPrefixSignature(t *testing.T) string {
+ t.Helper()
+
+ inner := []byte{}
+ inner = protowire.AppendTag(inner, 1, protowire.BytesType)
+ inner = protowire.AppendBytes(inner, []byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
+
+ payload := []byte{}
+ payload = protowire.AppendTag(payload, 2, protowire.BytesType)
+ payload = protowire.AppendBytes(payload, inner)
+ signature := base64.StdEncoding.EncodeToString(payload)
+ if !strings.HasPrefix(signature, "E") {
+ t.Fatalf("test signature should start with E, got %q", signature[:1])
+ }
+ return signature
+}
+
func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
@@ -182,8 +240,7 @@ func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
cache.ClearSignatureCache("")
- // Valid signature must be at least 50 characters
- validSignature := "abc123validSignature1234567890123456789012345678901234567890"
+ nativeSignature, antigravitySignature := testAntigravityClaudeSignature(t)
thinkingText := "Let me think..."
// Pre-cache the signature (simulating a previous response for the same thinking text)
@@ -197,14 +254,14 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
{
"role": "assistant",
"content": [
- {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
+ {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"},
{"type": "text", "text": "Answer"}
]
}
]
}`)
- cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
+ cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
@@ -217,8 +274,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
if firstPart.Get("text").String() != thinkingText {
t.Error("thinking text mismatch")
}
- if firstPart.Get("thoughtSignature").String() != validSignature {
- t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, firstPart.Get("thoughtSignature").String())
+ if firstPart.Get("thoughtSignature").String() != antigravitySignature {
+ t.Errorf("Expected thoughtSignature '%s', got '%s'", antigravitySignature, firstPart.Get("thoughtSignature").String())
}
}
@@ -563,7 +620,7 @@ func TestConvertClaudeRequestToAntigravity_BypassModeNormalizesESignature(t *tes
})
thinkingText := "Let me think..."
- cachedSignature := "cachedSignature1234567890123456789012345678901234567890123"
+ cachedSignature := base64.StdEncoding.EncodeToString([]byte(testMinimalAnthropicSignature(t)))
rawSignature := testAnthropicNativeSignature(t)
expectedSignature := base64.StdEncoding.EncodeToString([]byte(rawSignature))
@@ -750,6 +807,57 @@ func TestConvertClaudeRequestToAntigravity_BypassModeDropsInvalidSignature(t *te
}
}
+func TestConvertClaudeRequestToAntigravity_LogsDroppedInvalidThinkingSignature(t *testing.T) {
+ cache.ClearSignatureCache("")
+ previous := cache.SignatureCacheEnabled()
+ cache.SetSignatureCacheEnabled(false)
+ t.Cleanup(func() {
+ cache.SetSignatureCacheEnabled(previous)
+ cache.ClearSignatureCache("")
+ })
+
+ hook := newSignatureDebugHook(t)
+ invalidRawSignature := testNonAnthropicRawSignature(t)
+ inputJSON := []byte(`{
+ "model": "claude-sonnet-4-5-thinking",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "Let me think...", "signature": "` + invalidRawSignature + `"},
+ {"type": "text", "text": "Answer"}
+ ]
+ }
+ ]
+ }`)
+
+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ if len(parts) != 1 || parts[0].Get("text").String() != "Answer" {
+ t.Fatalf("expected invalid thinking block to be dropped, output: %s", output)
+ }
+
+ found := false
+ for _, entry := range hook.AllEntries() {
+ if entry.Level != log.DebugLevel {
+ continue
+ }
+ if entry.Data["component"] != "signature_sanitizer" ||
+ entry.Data["translator"] != "antigravity_claude" ||
+ entry.Data["action"] != "drop_thinking_block" {
+ continue
+ }
+ if entry.Data["model"] != "claude-sonnet-4-5-thinking" {
+ t.Fatalf("model field = %v, want claude-sonnet-4-5-thinking", entry.Data["model"])
+ }
+ found = true
+ }
+ if !found {
+ t.Fatal("expected debug log for dropped Antigravity Claude thinking signature")
+ }
+ assertSignatureDebugDoesNotLeak(t, hook, invalidRawSignature)
+}
+
func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiSignature(t *testing.T) {
cache.ClearSignatureCache("")
previous := cache.SignatureCacheEnabled()
@@ -784,6 +892,42 @@ func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiSignature(t *tes
}
}
+func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiEPrefixSignature(t *testing.T) {
+ cache.ClearSignatureCache("")
+ previous := cache.SignatureCacheEnabled()
+ cache.SetSignatureCacheEnabled(false)
+ t.Cleanup(func() {
+ cache.SetSignatureCacheEnabled(previous)
+ cache.ClearSignatureCache("")
+ })
+
+ geminiSig := testGeminiEPrefixSignature(t)
+ inputJSON := []byte(`{
+ "model": "claude-sonnet-4-5-thinking",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "hmm", "signature": "` + geminiSig + `"},
+ {"type": "text", "text": "Answer"}
+ ]
+ }
+ ]
+ }`)
+
+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ if len(parts) != 1 {
+ t.Fatalf("expected Gemini E-prefix signed thinking block to be dropped, got %d parts: %s", len(parts), output)
+ }
+ if parts[0].Get("text").String() != "Answer" {
+ t.Fatalf("expected remaining text part, got %s", parts[0].Raw)
+ }
+ if strings.Contains(string(output), geminiSig) {
+ t.Fatalf("Gemini E-prefix signature should not be forwarded. Output: %s", output)
+ }
+}
+
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
cache.ClearSignatureCache("")
@@ -935,18 +1079,67 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
if funcCall.Get("id").String() != "call_123" {
t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String())
}
- // Verify skip_thought_signature_validator is added (bypass for tools without valid thinking)
- expectedSig := "skip_thought_signature_validator"
- actualSig := parts[0].Get("thoughtSignature").String()
- if actualSig != expectedSig {
- t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig)
+ if parts[0].Get("thoughtSignature").Exists() {
+ t.Errorf("Expected no thoughtSignature without valid Claude thinking signature, got '%s'", parts[0].Get("thoughtSignature").String())
+ }
+}
+
+func TestConvertClaudeRequestToAntigravity_ToolUse_DropsInvalidThoughtSignatureOnly(t *testing.T) {
+ hook := newSignatureDebugHook(t)
+ rawSignature := "skip_thought_signature_validator"
+ inputJSON := []byte(`{
+ "model": "claude-sonnet-4-5",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "tool_use",
+ "id": "call_123",
+ "name": "get_weather",
+ "input": "{\"location\": \"Paris\"}",
+ "signature": "` + rawSignature + `"
+ }
+ ]
+ }
+ ]
+ }`)
+
+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
+ part := gjson.GetBytes(output, "request.contents.0.parts.0")
+
+ if !part.Get("functionCall").Exists() {
+ t.Fatalf("functionCall should be preserved, output: %s", output)
+ }
+ if got := part.Get("functionCall.name").String(); got != "get_weather" {
+ t.Fatalf("functionCall.name = %q, want get_weather", got)
+ }
+ if part.Get("thoughtSignature").Exists() {
+ t.Fatalf("invalid thoughtSignature should be removed, output: %s", output)
+ }
+
+ found := false
+ for _, entry := range hook.AllEntries() {
+ if entry.Level != log.DebugLevel {
+ continue
+ }
+ if entry.Data["component"] != "signature_sanitizer" ||
+ entry.Data["translator"] != "antigravity_claude" ||
+ entry.Data["action"] != "drop_tool_use_signature" {
+ continue
+ }
+ found = true
+ }
+ if !found {
+ t.Fatal("expected debug log for dropped Antigravity Claude tool_use signature")
}
+ assertSignatureDebugDoesNotLeak(t, hook, rawSignature)
}
-func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
+func TestConvertClaudeRequestToAntigravity_ToolUse_DoesNotReuseThinkingSignature(t *testing.T) {
cache.ClearSignatureCache("")
- validSignature := "abc123validSignature1234567890123456789012345678901234567890"
+ nativeSignature, _ := testAntigravityClaudeSignature(t)
thinkingText := "Let me think..."
inputJSON := []byte(`{
@@ -959,7 +1152,7 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
{
"role": "assistant",
"content": [
- {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
+ {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"},
{
"type": "tool_use",
"id": "call_123",
@@ -971,18 +1164,17 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
]
}`)
- cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
+ cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
- // Check function call has the signature from the preceding thinking block (now in contents.1)
part := gjson.Get(outputStr, "request.contents.1.parts.1")
if part.Get("functionCall.name").String() != "get_weather" {
t.Errorf("Expected functionCall, got %s", part.Raw)
}
- if part.Get("thoughtSignature").String() != validSignature {
- t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String())
+ if part.Get("thoughtSignature").Exists() {
+ t.Fatalf("tool_use should not reuse preceding thinking thoughtSignature, output: %s", output)
}
}
@@ -990,7 +1182,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
cache.ClearSignatureCache("")
// Case: text block followed by thinking block -> should be reordered to thinking first
- validSignature := "abc123validSignature1234567890123456789012345678901234567890"
+ nativeSignature, _ := testAntigravityClaudeSignature(t)
thinkingText := "Planning..."
inputJSON := []byte(`{
@@ -1004,13 +1196,13 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
"role": "assistant",
"content": [
{"type": "text", "text": "Here is the plan."},
- {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}
+ {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"}
]
}
]
}`)
- cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
+ cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
@@ -1137,7 +1329,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderParallelFunctionCalls(t *testi
func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionCall(t *testing.T) {
cache.ClearSignatureCache("")
- validSignature := "abc123validSignature1234567890123456789012345678901234567890"
+ nativeSignature, _ := testAntigravityClaudeSignature(t)
thinkingText := "Let me think about this..."
inputJSON := []byte(`{
@@ -1151,7 +1343,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionC
"role": "assistant",
"content": [
{"type": "text", "text": "Before thinking"},
- {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
+ {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"},
{
"type": "tool_use",
"id": "call_xyz",
@@ -1164,7 +1356,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionC
]
}`)
- cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
+ cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
@@ -1536,7 +1728,7 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin
cache.ClearSignatureCache("")
// Last assistant message ends with signed thinking block - should be kept
- validSignature := "abc123validSignature1234567890123456789012345678901234567890"
+ nativeSignature, _ := testAntigravityClaudeSignature(t)
thinkingText := "Valid thinking..."
inputJSON := []byte(`{
@@ -1550,13 +1742,13 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin
"role": "assistant",
"content": [
{"type": "text", "text": "Here is my answer"},
- {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}
+ {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"}
]
}
]
}`)
- cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
+ cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
diff --git a/internal/translator/antigravity/claude/signature_validation.go b/internal/translator/antigravity/claude/signature_validation.go
index f0acbf8e7d8..9431a4c7e73 100644
--- a/internal/translator/antigravity/claude/signature_validation.go
+++ b/internal/translator/antigravity/claude/signature_validation.go
@@ -17,6 +17,10 @@ func StripEmptySignatureThinkingBlocks(payload []byte) []byte {
return signature.StripInvalidClaudeThinkingBlocks(payload, signature.ClaudeSignatureValidationOptions{PrefixOnly: true})
}
+func StripInvalidBypassSignatureThinkingBlocks(payload []byte) []byte {
+ return signature.StripInvalidClaudeThinkingBlocks(payload, claudeBypassSignatureValidationOptions())
+}
+
func ValidateClaudeBypassSignatures(inputRawJSON []byte) error {
return signature.ValidateClaudeThinkingSignatures(inputRawJSON, claudeBypassSignatureValidationOptions())
}
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go
index f00821755f6..1beaecff4c6 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go
@@ -6,9 +6,11 @@
package gemini
import (
+ "encoding/json"
"fmt"
"strings"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
@@ -98,28 +100,213 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
}
- // Gemini-specific handling for non-Claude models:
- // - Replace client-provided thoughtSignature values with the skip sentinel.
- // - Add the same sentinel to functionCall and thinking parts so upstream can bypass signature validation.
- if !strings.Contains(strings.ToLower(modelName), "claude") {
- const skipSentinel = "skip_thought_signature_validator"
-
- gjson.GetBytes(rawJSON, "request.contents").ForEach(func(contentIdx, content gjson.Result) bool {
- if content.Get("role").String() == "model" {
- content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool {
- if part.Get("functionCall").Exists() || part.Get("thought").Exists() || part.Get("thoughtSignature").Exists() {
- rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), partIdx.Int()), skipSentinel)
- }
- return true
- })
- }
- return true
- })
+ if strings.Contains(strings.ToLower(modelName), "claude") {
+ rawJSON = sanitizeAntigravityClaudeGeminiRequestSignatures(modelName, rawJSON)
+ } else {
+ rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents")
}
return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings")
}
+func sanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON []byte) []byte {
+ var root map[string]any
+ if err := json.Unmarshal(rawJSON, &root); err != nil {
+ log.WithError(err).Debug("antigravity gemini translator: failed to parse request for Claude signature sanitize")
+ return rawJSON
+ }
+
+ request, ok := root["request"].(map[string]any)
+ if !ok {
+ return rawJSON
+ }
+ contents, ok := request["contents"].([]any)
+ if !ok {
+ return rawJSON
+ }
+
+ changed := false
+ rewrittenContents := make([]any, 0, len(contents))
+ for contentIndex, contentValue := range contents {
+ content, ok := contentValue.(map[string]any)
+ if !ok {
+ rewrittenContents = append(rewrittenContents, contentValue)
+ continue
+ }
+
+ parts, ok := content["parts"].([]any)
+ if !ok {
+ rewrittenContents = append(rewrittenContents, content)
+ continue
+ }
+
+ isModelTurn := content["role"] == "model"
+ rewrittenParts := make([]any, 0, len(parts))
+ for partIndex, partValue := range parts {
+ part, ok := partValue.(map[string]any)
+ if !ok {
+ rewrittenParts = append(rewrittenParts, partValue)
+ continue
+ }
+
+ rawSignature, hasSignature := antigravityClaudeGeminiPartThoughtSignature(part)
+ if hasFunctionResponsePart(part) {
+ if hasSignature {
+ changed = true
+ deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
+ logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "functionResponse parts cannot replay Claude thinking signatures", contentIndex, partIndex, rawSignature)
+ }
+ rewrittenParts = append(rewrittenParts, part)
+ continue
+ }
+ if !isModelTurn {
+ if hasSignature {
+ changed = true
+ deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
+ logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "non-model parts cannot replay Claude thinking signatures", contentIndex, partIndex, rawSignature)
+ }
+ rewrittenParts = append(rewrittenParts, part)
+ continue
+ }
+
+ if part["thought"] == true {
+ normalized, compatible := signature.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
+ if !compatible {
+ changed = true
+ logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_thinking_block", "missing_or_incompatible_signature", contentIndex, partIndex, rawSignature)
+ continue
+ }
+ if text, _ := part["text"].(string); strings.TrimSpace(text) == "" {
+ changed = true
+ logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_thinking_block", "empty_thinking_text", contentIndex, partIndex, rawSignature)
+ continue
+ }
+ if normalized != rawSignature {
+ changed = true
+ logAntigravityClaudeGeminiSignatureSanitize(modelName, "normalize_signature", "compatible_claude_signature", contentIndex, partIndex, rawSignature)
+ }
+ deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
+ part["thoughtSignature"] = normalized
+ rewrittenParts = append(rewrittenParts, part)
+ continue
+ }
+
+ if hasSignature {
+ changed = true
+ deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part)
+ logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "non-thinking parts should not carry Claude thinking signatures", contentIndex, partIndex, rawSignature)
+ }
+ rewrittenParts = append(rewrittenParts, part)
+ }
+
+ if len(rewrittenParts) == 0 {
+ changed = true
+ continue
+ }
+ content["parts"] = rewrittenParts
+ rewrittenContents = append(rewrittenContents, content)
+ }
+
+ if !changed {
+ return rawJSON
+ }
+ request["contents"] = rewrittenContents
+ out, err := json.Marshal(root)
+ if err != nil {
+ log.WithError(err).Debug("antigravity gemini translator: failed to marshal Claude signature sanitize")
+ return rawJSON
+ }
+ return out
+}
+
+func antigravityClaudeGeminiPartThoughtSignature(part map[string]any) (string, bool) {
+ for _, path := range [][]string{
+ {"thoughtSignature"},
+ {"thought_signature"},
+ {"functionCall", "thoughtSignature"},
+ {"functionCall", "thought_signature"},
+ {"functionResponse", "thoughtSignature"},
+ {"functionResponse", "thought_signature"},
+ {"extra_content", "google", "thought_signature"},
+ } {
+ if value, ok := stringAtPath(part, path...); ok {
+ return value, true
+ }
+ }
+ return "", false
+}
+
+func deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part map[string]any) {
+ for _, path := range [][]string{
+ {"thoughtSignature"},
+ {"thought_signature"},
+ {"functionCall", "thoughtSignature"},
+ {"functionCall", "thought_signature"},
+ {"functionResponse", "thoughtSignature"},
+ {"functionResponse", "thought_signature"},
+ {"extra_content", "google", "thought_signature"},
+ } {
+ deleteAtPath(part, path...)
+ }
+}
+
+func hasFunctionResponsePart(part map[string]any) bool {
+ _, ok := part["functionResponse"]
+ if ok {
+ return true
+ }
+ _, ok = part["function_response"]
+ return ok
+}
+
+func stringAtPath(value map[string]any, path ...string) (string, bool) {
+ var current any = value
+ for _, key := range path {
+ m, ok := current.(map[string]any)
+ if !ok {
+ return "", false
+ }
+ current, ok = m[key]
+ if !ok {
+ return "", false
+ }
+ }
+ s, ok := current.(string)
+ return s, ok
+}
+
+func deleteAtPath(value map[string]any, path ...string) {
+ if len(path) == 0 {
+ return
+ }
+ current := value
+ for _, key := range path[:len(path)-1] {
+ next, ok := current[key].(map[string]any)
+ if !ok {
+ return
+ }
+ current = next
+ }
+ delete(current, path[len(path)-1])
+}
+
+func logAntigravityClaudeGeminiSignatureSanitize(modelName, action, reason string, contentIndex, partIndex int, rawSignature string) {
+ fields := log.Fields{
+ "component": "signature_sanitizer",
+ "translator": "antigravity_gemini",
+ "target_provider": string(signature.SignatureProviderClaude),
+ "action": action,
+ "reason": reason,
+ "model": modelName,
+ "content_index": contentIndex,
+ "part_index": partIndex,
+ "has_signature": strings.TrimSpace(rawSignature) != "",
+ "signature_length": len(strings.TrimSpace(rawSignature)),
+ "detected_provider": string(signature.DetectSignatureProviderForBlock(rawSignature, signature.SignatureBlockKindClaudeThinking)),
+ }
+ log.WithFields(fields).Debug("antigravity gemini translator: sanitized Claude target thoughtSignature before upstream")
+}
+
// FunctionCallGroup represents a group of function calls and their responses
type FunctionCallGroup struct {
ResponsesNeeded int
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go
index 3ee381d896f..9707f39cfa2 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go
@@ -1,10 +1,13 @@
package gemini
import (
+ "encoding/base64"
"fmt"
"testing"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/tidwall/gjson"
+ "google.golang.org/protobuf/encoding/protowire"
)
func TestConvertGeminiRequestToAntigravity_ReplacesClientSignatureOnFunctionCall(t *testing.T) {
@@ -105,6 +108,128 @@ func TestConvertGeminiRequestToAntigravity_SkipsUppercaseClaudeModel(t *testing.
}
}
+func TestConvertGeminiRequestToAntigravity_ClaudeModelNormalizesStrictClaudeThoughtSignature(t *testing.T) {
+ nativeSig := testAntigravityGeminiClaudeSignature(t)
+ expectedSig, ok := signature.CompatibleAntigravityClaudeThinkingSignature(nativeSig)
+ if !ok {
+ t.Fatal("test Claude signature should be compatible with Antigravity Claude")
+ }
+
+ inputJSON := []byte(`{
+ "model": "claude-opus-4-6-thinking",
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"text": "internal reasoning", "thought": true, "thoughtSignature": "` + nativeSig + `"},
+ {"text": "visible answer"}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [{"text": "continue"}]
+ }
+ ]
+ }`)
+
+ output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
+
+ part := gjson.GetBytes(output, "request.contents.0.parts.0")
+ if !part.Get("thought").Bool() {
+ t.Fatalf("first part should remain thought. Output: %s", output)
+ }
+ if got := part.Get("thoughtSignature").String(); got != expectedSig {
+ t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, expectedSig, output)
+ }
+}
+
+func TestConvertGeminiRequestToAntigravity_ClaudeModelDropsNonStrictEPrefixThoughtSignature(t *testing.T) {
+ looseEPrefix := base64.StdEncoding.EncodeToString([]byte{0x12, 0x01, 0x02})
+ if looseEPrefix[0] != 'E' {
+ t.Fatalf("test signature should start with E, got %q", looseEPrefix[:1])
+ }
+
+ inputJSON := []byte(`{
+ "model": "claude-opus-4-6-thinking",
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"text": "must not reach Claude", "thought": true, "thoughtSignature": "` + looseEPrefix + `"},
+ {"text": "visible answer"}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [{"text": "continue"}]
+ }
+ ]
+ }`)
+
+ output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
+
+ if gjson.GetBytes(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ t.Fatalf("non-strict E-prefix thought block should be dropped. Output: %s", output)
+ }
+ if got := gjson.GetBytes(output, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ t.Fatalf("visible text = %q, want visible answer. Output: %s", got, output)
+ }
+}
+
+func TestConvertGeminiRequestToAntigravity_ClaudeModelDropsEmptyThoughtText(t *testing.T) {
+ nativeSig := testAntigravityGeminiClaudeSignature(t)
+ inputJSON := []byte(`{
+ "model": "claude-opus-4-6-thinking",
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"text": "", "thought": true, "thoughtSignature": "` + nativeSig + `"},
+ {"text": "visible answer"}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [{"text": "continue"}]
+ }
+ ]
+ }`)
+
+ output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
+
+ if gjson.GetBytes(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ t.Fatalf("empty-text thought block should be dropped for Antigravity Claude. Output: %s", output)
+ }
+ if got := gjson.GetBytes(output, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ t.Fatalf("visible text = %q, want visible answer. Output: %s", got, output)
+ }
+}
+
+func TestConvertGeminiRequestToAntigravity_ClaudeModelStripsUnneededFunctionCallSignature(t *testing.T) {
+ nativeSig := testAntigravityGeminiClaudeSignature(t)
+ inputJSON := []byte(`{
+ "model": "claude-opus-4-6-thinking",
+ "contents": [
+ {
+ "role": "model",
+ "parts": [
+ {"functionCall": {"name": "test_tool", "args": {}}, "thoughtSignature": "` + nativeSig + `"}
+ ]
+ }
+ ]
+ }`)
+
+ output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
+
+ part := gjson.GetBytes(output, "request.contents.0.parts.0")
+ if !part.Get("functionCall").Exists() {
+ t.Fatalf("functionCall should be preserved. Output: %s", output)
+ }
+ if part.Get("thoughtSignature").Exists() {
+ t.Fatalf("functionCall thoughtSignature should be stripped for Claude target. Output: %s", output)
+ }
+}
+
func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *testing.T) {
// functionCall without signature should get skip_thought_signature_validator
inputJSON := []byte(`{
@@ -130,6 +255,28 @@ func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *test
}
}
+func testAntigravityGeminiClaudeSignature(t *testing.T) string {
+ t.Helper()
+ channelBlock := []byte{}
+ channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 12)
+ channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 2)
+ channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
+ channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
+
+ container := []byte{}
+ container = protowire.AppendTag(container, 1, protowire.BytesType)
+ container = protowire.AppendBytes(container, channelBlock)
+
+ payload := []byte{}
+ payload = protowire.AppendTag(payload, 2, protowire.BytesType)
+ payload = protowire.AppendBytes(payload, container)
+ payload = protowire.AppendTag(payload, 3, protowire.VarintType)
+ payload = protowire.AppendVarint(payload, 1)
+ return base64.StdEncoding.EncodeToString(payload)
+}
+
func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) {
// Multiple functionCalls should all get skip_thought_signature_validator
inputJSON := []byte(`{
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
index 94a6b852b0f..491fcded2b7 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
@@ -1,12 +1,204 @@
package responses
import (
+ "encoding/json"
+ "strings"
+
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
+ log "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
)
func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON
rawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)
+ rawJSON = rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName, inputRawJSON, rawJSON)
return ConvertGeminiRequestToAntigravity(modelName, rawJSON, stream)
}
+
+type antigravityClaudeReasoningSignature struct {
+ Signature string
+ HasRawSignature bool
+ RawSignatureLen int
+ DetectedProvider sigcompat.SignatureProvider
+}
+
+func rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName string, inputRawJSON, geminiJSON []byte) []byte {
+ if sigcompat.SignatureProviderFromModelName(modelName) != sigcompat.SignatureProviderClaude {
+ return geminiJSON
+ }
+
+ reasoningSignatures := antigravityClaudeReasoningSignatures(inputRawJSON)
+ if len(reasoningSignatures) == 0 {
+ return geminiJSON
+ }
+
+ var root map[string]any
+ if err := json.Unmarshal(geminiJSON, &root); err != nil {
+ log.WithError(err).Debug("antigravity responses translator: failed to parse Gemini request for Claude signature rewrite")
+ return geminiJSON
+ }
+
+ contents, ok := root["contents"].([]any)
+ if !ok {
+ return geminiJSON
+ }
+
+ reasoningIndex := 0
+ changed := false
+ rewrittenContents := make([]any, 0, len(contents))
+ for contentIndex, contentValue := range contents {
+ content, ok := contentValue.(map[string]any)
+ if !ok {
+ rewrittenContents = append(rewrittenContents, contentValue)
+ continue
+ }
+
+ parts, ok := content["parts"].([]any)
+ if !ok {
+ rewrittenContents = append(rewrittenContents, content)
+ continue
+ }
+
+ rewrittenParts := make([]any, 0, len(parts))
+ for partIndex, partValue := range parts {
+ part, ok := partValue.(map[string]any)
+ if !ok || part["thought"] != true {
+ rewrittenParts = append(rewrittenParts, partValue)
+ continue
+ }
+
+ var reasoningSig antigravityClaudeReasoningSignature
+ if reasoningIndex < len(reasoningSignatures) {
+ reasoningSig = reasoningSignatures[reasoningIndex]
+ }
+ reasoningIndex++
+
+ if reasoningSig.Signature == "" {
+ changed = true
+ logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
+ continue
+ }
+ if text, _ := part["text"].(string); strings.TrimSpace(text) == "" {
+ changed = true
+ logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
+ continue
+ }
+
+ if currentSignature, _ := part["thoughtSignature"].(string); currentSignature != reasoningSig.Signature {
+ changed = true
+ logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
+ }
+ part["thoughtSignature"] = reasoningSig.Signature
+ rewrittenParts = append(rewrittenParts, part)
+ }
+
+ if len(rewrittenParts) == 0 {
+ changed = true
+ continue
+ }
+ content["parts"] = rewrittenParts
+ rewrittenContents = append(rewrittenContents, content)
+ }
+
+ if !changed {
+ return geminiJSON
+ }
+
+ root["contents"] = rewrittenContents
+ out, err := json.Marshal(root)
+ if err != nil {
+ log.WithError(err).Debug("antigravity responses translator: failed to marshal Claude signature rewrite")
+ return geminiJSON
+ }
+ return out
+}
+
+func antigravityClaudeReasoningSignatures(inputRawJSON []byte) []antigravityClaudeReasoningSignature {
+ input := gjson.GetBytes(inputRawJSON, "input")
+ if !input.IsArray() {
+ return nil
+ }
+
+ signatures := make([]antigravityClaudeReasoningSignature, 0)
+ input.ForEach(func(_, item gjson.Result) bool {
+ itemType := item.Get("type").String()
+ if itemType == "" && item.Get("role").Exists() {
+ itemType = "message"
+ }
+ if itemType != "reasoning" {
+ return true
+ }
+
+ rawSignatureResult := item.Get("encrypted_content")
+ rawSignature := rawSignatureResult.String()
+ signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
+ reasoningSignature := antigravityClaudeReasoningSignature{
+ HasRawSignature: rawSignatureResult.Exists(),
+ RawSignatureLen: len(rawSignature),
+ DetectedProvider: sigcompat.SignatureProviderUnknown,
+ }
+ if rawSignature != "" {
+ reasoningSignature.DetectedProvider = sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking)
+ }
+ if ok {
+ reasoningSignature.Signature = signature
+ }
+ signatures = append(signatures, reasoningSignature)
+ return true
+ })
+ return signatures
+}
+
+func logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
+ log.WithFields(log.Fields{
+ "component": "signature_sanitizer",
+ "translator": "antigravity_openai_responses",
+ "target_provider": string(sigcompat.SignatureProviderClaude),
+ "action": "drop_thinking_block",
+ "reason": "missing_or_incompatible_signature",
+ "model": modelName,
+ "content_index": contentIndex,
+ "part_index": partIndex,
+ "reasoning_index": reasoningIndex,
+ "has_signature": sig.HasRawSignature,
+ "signature_length": sig.RawSignatureLen,
+ "detected_provider": string(sig.DetectedProvider),
+ }).Debug("antigravity responses translator: dropped Claude reasoning block with incompatible encrypted_content")
+}
+
+func logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
+ log.WithFields(log.Fields{
+ "component": "signature_sanitizer",
+ "translator": "antigravity_openai_responses",
+ "target_provider": string(sigcompat.SignatureProviderClaude),
+ "action": "drop_thinking_block",
+ "reason": "empty_thinking_text",
+ "model": modelName,
+ "content_index": contentIndex,
+ "part_index": partIndex,
+ "reasoning_index": reasoningIndex,
+ "has_signature": sig.HasRawSignature,
+ "signature_length": sig.RawSignatureLen,
+ "detected_provider": string(sig.DetectedProvider),
+ }).Debug("antigravity responses translator: dropped Claude reasoning block with empty thinking text")
+}
+
+func logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
+ log.WithFields(log.Fields{
+ "component": "signature_sanitizer",
+ "translator": "antigravity_openai_responses",
+ "target_provider": string(sigcompat.SignatureProviderClaude),
+ "action": "normalize_signature",
+ "reason": "compatible_claude_signature",
+ "model": modelName,
+ "content_index": contentIndex,
+ "part_index": partIndex,
+ "reasoning_index": reasoningIndex,
+ "has_signature": sig.HasRawSignature,
+ "signature_length": sig.RawSignatureLen,
+ "detected_provider": string(sig.DetectedProvider),
+ }).Debug("antigravity responses translator: normalized Claude reasoning encrypted_content before upstream")
+}
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go
new file mode 100644
index 00000000000..7fce3b20ad1
--- /dev/null
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go
@@ -0,0 +1,176 @@
+package responses
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
+ "github.com/tidwall/gjson"
+ "google.golang.org/protobuf/encoding/protowire"
+)
+
+func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningKeepsClaudeSignature(t *testing.T) {
+ nativeSig := testAntigravityResponsesClaudeSignature(t)
+ antigravitySig, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(nativeSig)
+ if !ok {
+ t.Fatal("test Claude signature should be compatible with Antigravity Claude")
+ }
+
+ tests := []struct {
+ name string
+ encrypted string
+ }{
+ {
+ name: "Claude native E signature",
+ encrypted: nativeSig,
+ },
+ {
+ name: "Antigravity double-layer R signature",
+ encrypted: antigravitySig,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ raw := []byte(`{
+ "model": "claude-opus-4-6-thinking",
+ "input": [
+ {
+ "id": "rs_prev",
+ "type": "reasoning",
+ "encrypted_content": "` + tt.encrypted + `",
+ "summary": [{"type": "summary_text", "text": "internal reasoning"}]
+ },
+ {
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "visible answer"}]
+ },
+ {
+ "role": "user",
+ "content": [{"type": "input_text", "text": "continue"}]
+ }
+ ]
+ }`)
+
+ out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
+ part := gjson.GetBytes(out, "request.contents.0.parts.0")
+ if !part.Get("thought").Bool() {
+ t.Fatalf("first part should remain a thought block. Output: %s", out)
+ }
+ if got := part.Get("thoughtSignature").String(); got != antigravitySig {
+ t.Fatalf("thoughtSignature prefix/len = %q/%d, want %q/%d. Output: %s",
+ firstByte(got), len(got), firstByte(antigravitySig), len(antigravitySig), out)
+ }
+ if got := part.Get("text").String(); got != "internal reasoning" {
+ t.Fatalf("thought text = %q, want internal reasoning. Output: %s", got, out)
+ }
+ })
+ }
+}
+
+func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsIncompatibleSignature(t *testing.T) {
+ raw := []byte(`{
+ "model": "claude-opus-4-6-thinking",
+ "input": [
+ {
+ "id": "rs_prev",
+ "type": "reasoning",
+ "encrypted_content": "` + testAntigravityResponsesGPTSignature() + `",
+ "summary": [{"type": "summary_text", "text": "must not reach Claude"}]
+ },
+ {
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "visible answer"}]
+ },
+ {
+ "role": "user",
+ "content": [{"type": "input_text", "text": "continue"}]
+ }
+ ]
+ }`)
+
+ out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
+ if strings.Contains(string(out), sigcompat.GeminiSkipThoughtSignatureValidator) {
+ t.Fatalf("Claude target must not receive Gemini bypass signature. Output: %s", out)
+ }
+ if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ t.Fatalf("incompatible reasoning block should be dropped. Output: %s", out)
+ }
+ if strings.Contains(string(out), "must not reach Claude") {
+ t.Fatalf("incompatible reasoning text should be dropped. Output: %s", out)
+ }
+ if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
+ }
+}
+
+func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsEmptyThinkingText(t *testing.T) {
+ rawSignature := testAntigravityResponsesClaudeSignature(t)
+ raw := []byte(`{
+ "model": "claude-opus-4-6-thinking",
+ "input": [
+ {
+ "id": "rs_prev",
+ "type": "reasoning",
+ "encrypted_content": "` + rawSignature + `",
+ "summary": []
+ },
+ {
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "visible answer"}]
+ },
+ {
+ "role": "user",
+ "content": [{"type": "input_text", "text": "continue"}]
+ }
+ ]
+ }`)
+
+ out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
+ if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ t.Fatalf("empty-text reasoning block should be dropped for Antigravity Claude. Output: %s", out)
+ }
+ if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
+ }
+}
+
+func testAntigravityResponsesClaudeSignature(t *testing.T) string {
+ t.Helper()
+ channelBlock := []byte{}
+ channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 12)
+ channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 2)
+ channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
+ channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
+
+ container := []byte{}
+ container = protowire.AppendTag(container, 1, protowire.BytesType)
+ container = protowire.AppendBytes(container, channelBlock)
+
+ payload := []byte{}
+ payload = protowire.AppendTag(payload, 2, protowire.BytesType)
+ payload = protowire.AppendBytes(payload, container)
+ payload = protowire.AppendTag(payload, 3, protowire.VarintType)
+ payload = protowire.AppendVarint(payload, 1)
+ return base64.StdEncoding.EncodeToString(payload)
+}
+
+func testAntigravityResponsesGPTSignature() string {
+ payload := make([]byte, 1+8+16+16+32)
+ payload[0] = 0x80
+ payload[8] = 1
+ for i := 9; i < len(payload); i++ {
+ payload[i] = byte(i)
+ }
+ return base64.URLEncoding.EncodeToString(payload)
+}
+
+func firstByte(s string) string {
+ if s == "" {
+ return ""
+ }
+ return s[:1]
+}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
index 2208688b0fb..d37b7156351 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -439,8 +440,8 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
func convertResponsesReasoningToClaudeThinking(item gjson.Result) []byte {
- signature := item.Get("encrypted_content").String()
- if signature == "" {
+ signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderClaude, item.Get("encrypted_content").String())
+ if !ok {
return nil
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
index cb867e05e76..da3cfc39525 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
@@ -1,19 +1,22 @@
package responses
import (
+ "encoding/base64"
"testing"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/tidwall/gjson"
+ "google.golang.org/protobuf/encoding/protowire"
)
func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *testing.T) {
- signature := "claude_sig_request"
+ rawSignature, expectedSignature := testClaudeResponsesThinkingSignature(t)
raw := []byte(`{
"model":"claude-test",
"input":[
{
"type":"reasoning",
- "encrypted_content":"` + signature + `",
+ "encrypted_content":"` + rawSignature + `",
"summary":[{"type":"summary_text","text":"internal reasoning"}]
},
{
@@ -39,8 +42,8 @@ func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *t
if got := assistant.Get("content.0.type").String(); got != "thinking" {
t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out))
}
- if got := assistant.Get("content.0.signature").String(); got != signature {
- t.Fatalf("thinking signature = %q, want %q", got, signature)
+ if got := assistant.Get("content.0.signature").String(); got != expectedSignature {
+ t.Fatalf("thinking signature = %q, want %q", got, expectedSignature)
}
if got := assistant.Get("content.0.thinking").String(); got != "internal reasoning" {
t.Fatalf("thinking text = %q, want internal reasoning", got)
@@ -57,13 +60,13 @@ func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *t
}
func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBeforeUser(t *testing.T) {
- signature := "claude_sig_only"
+ rawSignature, expectedSignature := testClaudeResponsesThinkingSignature(t)
raw := []byte(`{
"model":"claude-test",
"input":[
{
"type":"reasoning",
- "encrypted_content":"` + signature + `",
+ "encrypted_content":"` + rawSignature + `",
"summary":[]
},
{
@@ -81,8 +84,8 @@ func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBefo
if got := thinking.Get("type").String(); got != "thinking" {
t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out))
}
- if got := thinking.Get("signature").String(); got != signature {
- t.Fatalf("thinking signature = %q, want %q", got, signature)
+ if got := thinking.Get("signature").String(); got != expectedSignature {
+ t.Fatalf("thinking signature = %q, want %q", got, expectedSignature)
}
if got := thinking.Get("thinking").String(); got != "" {
t.Fatalf("thinking text = %q, want empty", got)
@@ -91,3 +94,71 @@ func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBefo
t.Fatalf("second message role = %q, want user. Output: %s", got, string(out))
}
}
+
+func TestConvertOpenAIResponsesRequestToClaude_DropsIncompatibleReasoningSignature(t *testing.T) {
+ raw := []byte(`{
+ "model":"claude-test",
+ "input":[
+ {
+ "type":"reasoning",
+ "encrypted_content":"` + testGPTResponsesReasoningSignature() + `",
+ "summary":[{"type":"summary_text","text":"must not become Claude thinking"}]
+ },
+ {
+ "type":"message",
+ "role":"user",
+ "content":[{"type":"input_text","text":"continue"}]
+ }
+ ]
+ }`)
+
+ out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
+
+ if gjson.GetBytes(out, "messages.0.content.0.type").String() == "thinking" {
+ t.Fatalf("GPT encrypted_content should not become Claude thinking. Output: %s", string(out))
+ }
+ if gjson.GetBytes(out, "messages.0.content.0.signature").Exists() {
+ t.Fatalf("incompatible signature should not be forwarded. Output: %s", string(out))
+ }
+ if got := gjson.GetBytes(out, "messages.0.role").String(); got != "user" {
+ t.Fatalf("first message role = %q, want user. Output: %s", got, string(out))
+ }
+}
+
+func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) {
+ t.Helper()
+ channelBlock := []byte{}
+ channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 12)
+ channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
+ channelBlock = protowire.AppendVarint(channelBlock, 2)
+ channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
+ channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
+
+ container := []byte{}
+ container = protowire.AppendTag(container, 1, protowire.BytesType)
+ container = protowire.AppendBytes(container, channelBlock)
+
+ payload := []byte{}
+ payload = protowire.AppendTag(payload, 2, protowire.BytesType)
+ payload = protowire.AppendBytes(payload, container)
+ payload = protowire.AppendTag(payload, 3, protowire.VarintType)
+ payload = protowire.AppendVarint(payload, 1)
+
+ rawSignature := base64.StdEncoding.EncodeToString(payload)
+ normalized, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderClaude, rawSignature)
+ if !ok {
+ t.Fatal("test Claude signature should be compatible")
+ }
+ return rawSignature, normalized
+}
+
+func testGPTResponsesReasoningSignature() string {
+ payload := make([]byte, 1+8+16+16+32)
+ payload[0] = 0x80
+ payload[8] = 1
+ for i := 9; i < len(payload); i++ {
+ payload[i] = byte(i)
+ }
+ return base64.URLEncoding.EncodeToString(payload)
+}
diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go
index b7a42d2c408..d9f889e2704 100644
--- a/internal/translator/codex/claude/codex_claude_request.go
+++ b/internal/translator/codex/claude/codex_claude_request.go
@@ -7,12 +7,12 @@ package claude
import (
"crypto/sha256"
- "encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
@@ -133,8 +133,8 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
return
}
- signature := part.Get("signature").String()
- if !isFernetLikeReasoningSignature(signature) {
+ signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, part.Get("signature").String())
+ if !ok {
return
}
@@ -334,39 +334,6 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
return template
}
-// isFernetLikeReasoningSignature checks only the encrypted_content envelope shape
-// observed in OpenAI reasoning signatures. It does not authenticate source or payload type.
-func isFernetLikeReasoningSignature(signature string) bool {
- const (
- fernetVersionLen = 1
- fernetTimestamp = 8
- fernetIV = 16
- fernetHMAC = 32
- aesBlockSize = 16
- )
-
- signature = strings.TrimSpace(signature)
- if !strings.HasPrefix(signature, "gAAAA") {
- return false
- }
-
- decoded, err := base64.URLEncoding.DecodeString(signature)
- if err != nil {
- decoded, err = base64.RawURLEncoding.DecodeString(signature)
- if err != nil {
- return false
- }
- }
-
- minLen := fernetVersionLen + fernetTimestamp + fernetIV + aesBlockSize + fernetHMAC
- if len(decoded) < minLen || decoded[0] != 0x80 {
- return false
- }
-
- ciphertextLen := len(decoded) - fernetVersionLen - fernetTimestamp - fernetIV - fernetHMAC
- return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0
-}
-
// shortenCodexCallIDIfNeeded keeps Claude tool IDs within the OpenAI Responses
// API call_id limit while preserving a stable, low-collision mapping.
func shortenCodexCallIDIfNeeded(id string) string {
diff --git a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go
index 83dc6260412..3627757502d 100644
--- a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go
+++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go
@@ -9,6 +9,7 @@ import (
"fmt"
"strings"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
@@ -97,19 +98,7 @@ func ConvertGeminiRequestToGeminiCLI(_ string, inputRawJSON []byte, _ bool) []by
}
}
- gjson.GetBytes(rawJSON, "request.contents").ForEach(func(key, content gjson.Result) bool {
- if content.Get("role").String() == "model" {
- content.Get("parts").ForEach(func(partKey, part gjson.Result) bool {
- if part.Get("functionCall").Exists() {
- rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator")
- } else if part.Get("thoughtSignature").Exists() {
- rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator")
- }
- return true
- })
- }
- return true
- })
+ rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents")
// Filter out contents with empty parts to avoid Gemini API error:
// "required oneof field 'data' must have one initialized field"
diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go
index 1aa3132b497..c0c7a8deb83 100644
--- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go
+++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
@@ -255,7 +256,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
fargs := tc.Get("function.arguments").String()
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
- node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
+ node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", openAIToolCallGeminiThoughtSignature(tc))
p++
if fid != "" {
fIDs = append(fIDs, fid)
@@ -397,5 +398,19 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
return common.AttachDefaultSafetySettings(out, "request.safetySettings")
}
+func openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string {
+ for _, path := range []string{
+ "extra_content.google.thought_signature",
+ "function.extra_content.google.thought_signature",
+ "thoughtSignature",
+ "thought_signature",
+ } {
+ if signatureResult := toolCall.Get(path); signatureResult.Exists() {
+ return sigcompat.GeminiReplaySignatureOrBypass(signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall)
+ }
+ }
+ return geminiCLIFunctionThoughtSignature
+}
+
// itoa converts int to string without strconv import for few usages.
func itoa(i int) string { return fmt.Sprintf("%d", i) }
diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go
index 71e7b4a5fd7..0d1da6c79aa 100644
--- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go
+++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go
@@ -8,6 +8,7 @@ package geminiCLI
import (
"fmt"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
@@ -45,19 +46,7 @@ func ConvertGeminiCLIRequestToGemini(_ string, inputRawJSON []byte, _ bool) []by
}
}
- gjson.GetBytes(rawJSON, "contents").ForEach(func(key, content gjson.Result) bool {
- if content.Get("role").String() == "model" {
- content.Get("parts").ForEach(func(partKey, part gjson.Result) bool {
- if part.Get("functionCall").Exists() {
- rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator")
- } else if part.Get("thoughtSignature").Exists() {
- rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator")
- }
- return true
- })
- }
- return true
- })
+ rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "contents")
return common.AttachDefaultSafetySettings(rawJSON, "safetySettings")
}
diff --git a/internal/translator/gemini/gemini/gemini_gemini_request.go b/internal/translator/gemini/gemini/gemini_gemini_request.go
index 35e22d7160d..6c36dfd8004 100644
--- a/internal/translator/gemini/gemini/gemini_gemini_request.go
+++ b/internal/translator/gemini/gemini/gemini_gemini_request.go
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
@@ -78,19 +79,7 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte
return true
})
- gjson.GetBytes(out, "contents").ForEach(func(key, content gjson.Result) bool {
- if content.Get("role").String() == "model" {
- content.Get("parts").ForEach(func(partKey, part gjson.Result) bool {
- if part.Get("functionCall").Exists() {
- out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator")
- } else if part.Get("thoughtSignature").Exists() {
- out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator")
- }
- return true
- })
- }
- return true
- })
+ out = signature.SanitizeGeminiRequestThoughtSignatures(out, "contents")
if gjson.GetBytes(rawJSON, "generationConfig.responseSchema").Exists() {
strJson, _ := util.RenameKey(string(out), "generationConfig.responseSchema", "generationConfig.responseJsonSchema")
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
index 20eaec76f9a..bf4e9805ade 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
@@ -261,7 +262,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
fargs := tc.Get("function.arguments").String()
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
- node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature)
+ node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", openAIToolCallGeminiThoughtSignature(tc))
p++
if fid != "" {
fIDs = append(fIDs, fid)
@@ -411,5 +412,19 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
return out
}
+func openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string {
+ for _, path := range []string{
+ "extra_content.google.thought_signature",
+ "function.extra_content.google.thought_signature",
+ "thoughtSignature",
+ "thought_signature",
+ } {
+ if signatureResult := toolCall.Get(path); signatureResult.Exists() {
+ return sigcompat.GeminiReplaySignatureOrBypass(signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall)
+ }
+ }
+ return geminiFunctionThoughtSignature
+}
+
// itoa converts int to string without strconv import for few usages.
func itoa(i int) string { return fmt.Sprintf("%d", i) }
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go
new file mode 100644
index 00000000000..4d4326a8dc7
--- /dev/null
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go
@@ -0,0 +1,51 @@
+package chat_completions
+
+import (
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
+ "github.com/tidwall/gjson"
+)
+
+const capturedGeminiToolCallThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
+
+func TestConvertOpenAIRequestToGemini_ToolCallSignatureCompatibility(t *testing.T) {
+ tests := []struct {
+ name string
+ rawSignature string
+ wantSignature string
+ }{
+ {
+ name: "Gemini signature is preserved",
+ rawSignature: "gemini#" + capturedGeminiToolCallThoughtSignature,
+ wantSignature: capturedGeminiToolCallThoughtSignature,
+ },
+ {
+ name: "unknown signature uses bypass",
+ rawSignature: "not-a-provider-signature",
+ wantSignature: signature.GeminiSkipThoughtSignatureValidator,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ input := []byte(`{
+ "model": "gemini-3.5-flash",
+ "messages": [{
+ "role": "assistant",
+ "tool_calls": [{
+ "id": "call_123",
+ "type": "function",
+ "function": {"name": "lookup", "arguments": "{\"q\":\"Paris\"}"},
+ "extra_content": {"google": {"thought_signature": "` + tt.rawSignature + `"}}
+ }]
+ }]
+ }`)
+
+ output := ConvertOpenAIRequestToGemini("gemini-3.5-flash", input, false)
+ if got := gjson.GetBytes(output, "contents.0.parts.0.thoughtSignature").String(); got != tt.wantSignature {
+ t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output)
+ }
+ })
+ }
+}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
index e741757641c..29d66df54c1 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"strings"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
@@ -355,7 +356,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
thoughtContent := []byte(`{"role":"model","parts":[]}`)
thought := []byte(`{"text":"","thoughtSignature":"","thought":true}`)
thought, _ = sjson.SetBytes(thought, "text", item.Get("summary.0.text").String())
- thought, _ = sjson.SetBytes(thought, "thoughtSignature", item.Get("encrypted_content").String())
+ thought, _ = sjson.SetBytes(thought, "thoughtSignature", openAIResponsesGeminiThoughtSignature(item.Get("encrypted_content").String()))
thoughtContent, _ = sjson.SetRawBytes(thoughtContent, "parts.-1", thought)
out, _ = sjson.SetRawBytes(out, "contents.-1", thoughtContent)
@@ -454,3 +455,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
result = common.AttachDefaultSafetySettings(result, "safetySettings")
return result
}
+
+func openAIResponsesGeminiThoughtSignature(rawSignature string) string {
+ return sigcompat.GeminiReplaySignatureOrBypass(rawSignature, sigcompat.SignatureBlockKindGeminiModelPart)
+}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
new file mode 100644
index 00000000000..35418689823
--- /dev/null
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
@@ -0,0 +1,66 @@
+package responses
+
+import (
+ "encoding/base64"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+const testResponsesGeminiThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
+
+func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t *testing.T) {
+ tests := []struct {
+ name string
+ encrypted string
+ wantSignature string
+ }{
+ {
+ name: "GPT encrypted_content uses Gemini bypass",
+ encrypted: validResponsesGPTReasoningSignature(),
+ wantSignature: geminiResponsesThoughtSignature,
+ },
+ {
+ name: "Gemini encrypted_content is preserved",
+ encrypted: "gemini#" + testResponsesGeminiThoughtSignature,
+ wantSignature: testResponsesGeminiThoughtSignature,
+ },
+ {
+ name: "Missing encrypted_content uses Gemini bypass",
+ encrypted: "",
+ wantSignature: geminiResponsesThoughtSignature,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ input := []byte(`{
+ "model": "gpt-5",
+ "input": [{
+ "type": "reasoning",
+ "encrypted_content": "` + tt.encrypted + `",
+ "summary": [{"type": "summary_text", "text": "reasoning summary"}]
+ }]
+ }`)
+
+ output := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", input, false)
+ part := gjson.GetBytes(output, "contents.0.parts.0")
+ if got := part.Get("thoughtSignature").String(); got != tt.wantSignature {
+ t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output)
+ }
+ if got := part.Get("text").String(); got != "reasoning summary" {
+ t.Fatalf("thought text = %q, want reasoning summary. Output: %s", got, output)
+ }
+ })
+ }
+}
+
+func validResponsesGPTReasoningSignature() string {
+ raw := make([]byte, 1+8+16+16+32)
+ raw[0] = 0x80
+ raw[8] = 1
+ for i := 9; i < len(raw); i++ {
+ raw[i] = byte(i)
+ }
+ return base64.URLEncoding.EncodeToString(raw)
+}
diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go
index 98954b3830b..7ff7a582be1 100644
--- a/internal/translator/openai/claude/openai_claude_request.go
+++ b/internal/translator/openai/claude/openai_claude_request.go
@@ -8,6 +8,7 @@ package claude
import (
"strings"
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
@@ -147,6 +148,9 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
case "thinking":
// Only map thinking to reasoning_content for assistant messages (security: prevent injection)
if role == "assistant" {
+ if !shouldMapClaudeThinkingToGPTReasoning(part) {
+ return true
+ }
thinkingText := thinking.GetThinkingText(part)
// Skip empty or whitespace-only thinking
if strings.TrimSpace(thinkingText) != "" {
@@ -329,6 +333,15 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
return out
}
+func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result) bool {
+ signature := part.Get("signature")
+ if !signature.Exists() || strings.TrimSpace(signature.String()) == "" {
+ return false
+ }
+ _, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, signature.String())
+ return ok
+}
+
func convertClaudeContentPart(part gjson.Result) (string, bool) {
partType := part.Get("type").String()
diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go
index 9c6ba77c33f..9e2d771a27d 100644
--- a/internal/translator/openai/claude/openai_claude_request_test.go
+++ b/internal/translator/openai/claude/openai_claude_request_test.go
@@ -1,6 +1,7 @@
package claude
import (
+ "encoding/base64"
"testing"
"github.com/tidwall/gjson"
@@ -18,7 +19,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
wantHasContent bool
}{
{
- name: "AC1: assistant message with thinking and text",
+ name: "AC1: unsigned assistant thinking is dropped",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
@@ -29,8 +30,8 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
]
}]
}`,
- wantReasoningContent: "Let me analyze this step by step...",
- wantHasReasoningContent: true,
+ wantReasoningContent: "",
+ wantHasReasoningContent: false,
wantContentText: "Here is my response.",
wantHasContent: true,
},
@@ -52,7 +53,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
wantHasContent: true,
},
{
- name: "AC3: thinking-only message preserved with reasoning_content",
+ name: "AC3: unsigned thinking-only message is dropped",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
@@ -62,11 +63,10 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
]
}]
}`,
- wantReasoningContent: "Internal reasoning only.",
- wantHasReasoningContent: true,
+ wantReasoningContent: "",
+ wantHasReasoningContent: false,
wantContentText: "",
- // For OpenAI compatibility, content field is set to empty string "" when no text content exists
- wantHasContent: false,
+ wantHasContent: false,
},
{
name: "AC4: thinking in user role must be ignored",
@@ -139,7 +139,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
wantHasContent: true,
},
{
- name: "Multiple thinking parts concatenated",
+ name: "Unsigned thinking parts are dropped",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
@@ -151,13 +151,13 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
]
}]
}`,
- wantReasoningContent: "First thought.\n\nSecond thought.",
- wantHasReasoningContent: true,
+ wantReasoningContent: "",
+ wantHasReasoningContent: false,
wantContentText: "Final answer.",
wantHasContent: true,
},
{
- name: "Mixed thinking and redacted_thinking",
+ name: "Mixed unsigned thinking and redacted_thinking",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
@@ -169,8 +169,8 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
]
}]
}`,
- wantReasoningContent: "Visible thought.",
- wantHasReasoningContent: true,
+ wantReasoningContent: "",
+ wantHasReasoningContent: false,
wantContentText: "Answer.",
wantHasContent: true,
},
@@ -246,9 +246,73 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
}
}
-// TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved tests AC3:
-// that a message with only thinking content is preserved (not dropped).
-func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) {
+func TestConvertClaudeRequestToOpenAI_SignedThinkingCompatibility(t *testing.T) {
+ tests := []struct {
+ name string
+ signature string
+ wantReasoningContent string
+ wantHasReasoningContent bool
+ }{
+ {
+ name: "GPT-compatible signature keeps reasoning_content",
+ signature: validGPTChatReasoningSignature(),
+ wantReasoningContent: "provider state",
+ wantHasReasoningContent: true,
+ },
+ {
+ name: "Claude signature drops reasoning_content",
+ signature: "claude#EjQ=",
+ wantReasoningContent: "",
+ wantHasReasoningContent: false,
+ },
+ {
+ name: "Gemini signature drops reasoning_content",
+ signature: "gemini#EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA",
+ wantReasoningContent: "",
+ wantHasReasoningContent: false,
+ },
+ {
+ name: "Unknown signature drops reasoning_content",
+ signature: "not-a-provider-signature",
+ wantReasoningContent: "",
+ wantHasReasoningContent: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inputJSON := `{
+ "model": "claude-3-opus",
+ "messages": [{
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "provider state", "signature": "` + tt.signature + `"},
+ {"type": "text", "text": "visible answer"}
+ ]
+ }]
+ }`
+
+ result := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
+ assistantMsg := gjson.GetBytes(result, "messages.0")
+ gotReasoningContent := assistantMsg.Get("reasoning_content").String()
+ gotHasReasoningContent := assistantMsg.Get("reasoning_content").Exists()
+
+ if gotHasReasoningContent != tt.wantHasReasoningContent {
+ t.Fatalf("reasoning_content exists = %v, want %v. Output: %s", gotHasReasoningContent, tt.wantHasReasoningContent, string(result))
+ }
+ if gotReasoningContent != tt.wantReasoningContent {
+ t.Fatalf("reasoning_content = %q, want %q. Output: %s", gotReasoningContent, tt.wantReasoningContent, string(result))
+ }
+ if got := assistantMsg.Get("content.0.text").String(); got != "visible answer" {
+ t.Fatalf("visible content = %q, want visible answer. Output: %s", got, string(result))
+ }
+ })
+ }
+}
+
+// TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped verifies
+// that unsigned Claude thinking is not migrated into GPT reasoning state.
+func TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
@@ -272,24 +336,24 @@ func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T)
messages := resultJSON.Get("messages").Array()
- // Should have: user + assistant (thinking-only) + user = 3 messages
- if len(messages) != 3 {
- t.Fatalf("Expected 3 messages, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw)
- }
-
- // Check the assistant message (index 1) has reasoning_content
- assistantMsg := messages[1]
- if assistantMsg.Get("role").String() != "assistant" {
- t.Errorf("Expected message[1] to be assistant, got %s", assistantMsg.Get("role").String())
+ if len(messages) != 2 {
+ t.Fatalf("Expected unsigned thinking-only assistant message to be dropped, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw)
}
-
- if !assistantMsg.Get("reasoning_content").Exists() {
- t.Error("Expected assistant message to have reasoning_content")
+ for _, message := range messages {
+ if message.Get("reasoning_content").Exists() {
+ t.Fatalf("unsigned thinking should not produce reasoning_content. Messages: %v", resultJSON.Get("messages").Raw)
+ }
}
+}
- if assistantMsg.Get("reasoning_content").String() != "Let me calculate: 2+2=4" {
- t.Errorf("Unexpected reasoning_content: %s", assistantMsg.Get("reasoning_content").String())
+func validGPTChatReasoningSignature() string {
+ raw := make([]byte, 1+8+16+16+32)
+ raw[0] = 0x80
+ raw[8] = 1
+ for i := 9; i < len(raw); i++ {
+ raw[i] = byte(i)
}
+ return base64.URLEncoding.EncodeToString(raw)
}
func TestConvertClaudeRequestToOpenAI_SystemMessageScenarios(t *testing.T) {
@@ -667,8 +731,7 @@ func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *t
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
- // New behavior: all content, thinking, and tool_calls unified in single assistant message
- // Expect: assistant(content[pre,post] + tool_calls + reasoning_content[t1+t2])
+ // Unsigned thinking is dropped, while text and tool_calls remain unified.
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
@@ -691,9 +754,8 @@ func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *t
t.Fatalf("Expected assistant message to have tool_calls")
}
- // Should have combined reasoning_content from both thinking blocks
- if got := assistantMsg.Get("reasoning_content").String(); got != "t1\n\nt2" {
- t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got)
+ if assistantMsg.Get("reasoning_content").Exists() {
+ t.Fatalf("unsigned thinking should not produce reasoning_content: %s", assistantMsg.Raw)
}
}
diff --git a/sdk/api/handlers/openai/openai_responses_signature_test.go b/sdk/api/handlers/openai/openai_responses_signature_test.go
new file mode 100644
index 00000000000..7bb610ae725
--- /dev/null
+++ b/sdk/api/handlers/openai/openai_responses_signature_test.go
@@ -0,0 +1,86 @@
+package openai
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+)
+
+func TestOpenAIResponsesForwardsInvalidReasoningEncryptedContentToExecutor(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ executor := &compactCaptureExecutor{}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+
+ auth := &coreauth.Auth{ID: "signature-auth-responses", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, err := manager.Register(context.Background(), auth); err != nil {
+ t.Fatalf("Register auth: %v", err)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-signature-model"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/responses", h.Responses)
+
+ body := `{"model":"test-signature-model","stream":false,"input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"gAAAAABqFTIa\u2026abc","summary":[]}]}`
+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, req)
+
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ if executor.calls != 1 {
+ t.Fatalf("executor calls = %d, want 1", executor.calls)
+ }
+}
+
+func TestOpenAIResponsesCompactForwardsInvalidReasoningEncryptedContentToExecutor(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ executor := &compactCaptureExecutor{}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+
+ auth := &coreauth.Auth{ID: "signature-auth-compact", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, err := manager.Register(context.Background(), auth); err != nil {
+ t.Fatalf("Register auth: %v", err)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-signature-compact-model"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/responses/compact", h.Compact)
+
+ body := `{"model":"test-signature-compact-model","input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"bad","summary":[]}]}`
+ req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, req)
+
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ if executor.calls != 1 {
+ t.Fatalf("executor calls = %d, want 1", executor.calls)
+ }
+ if executor.alt != "responses/compact" {
+ t.Fatalf("alt = %q, want responses/compact", executor.alt)
+ }
+}
From e9dafc709344835873329bc9639264a1f22a1956 Mon Sep 17 00:00:00 2001
From: iBenzene
Date: Fri, 29 May 2026 04:58:47 +0800
Subject: [PATCH 085/248] fix(openai): dedupe response websocket input item IDs
---
.../openai/openai_responses_websocket.go | 63 +++++++++++++++++++
.../openai/openai_responses_websocket_test.go | 40 ++++++++++++
2 files changed, 103 insertions(+)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go
index eae042b9ec5..142719aa268 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket.go
@@ -381,6 +381,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
requestJSON = repairResponsesWebsocketToolCalls(downstreamSessionKey, requestJSON)
+ requestJSON = dedupeResponsesWebsocketInputItemsByID(requestJSON)
updatedLastRequest = bytes.Clone(requestJSON)
previousLastRequest := bytes.Clone(lastRequest)
previousLastResponseOutput := bytes.Clone(lastResponseOutput)
@@ -582,6 +583,10 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last
if errDedupeFunctionCalls == nil {
mergedInput = dedupedInput
}
+ dedupedInput, errDedupeItemIDs := dedupeInputItemsByID(mergedInput)
+ if errDedupeItemIDs == nil {
+ mergedInput = dedupedInput
+ }
normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
if errDelete != nil {
@@ -697,6 +702,64 @@ func dedupeFunctionCallsByCallID(rawArray string) (string, error) {
return string(out), nil
}
+func dedupeResponsesWebsocketInputItemsByID(payload []byte) []byte {
+ input := gjson.GetBytes(payload, "input")
+ if !input.Exists() || !input.IsArray() {
+ return payload
+ }
+ dedupedInput, errDedupe := dedupeInputItemsByID(input.Raw)
+ if errDedupe != nil || dedupedInput == input.Raw {
+ return payload
+ }
+ updated, errSet := sjson.SetRawBytes(payload, "input", []byte(dedupedInput))
+ if errSet != nil {
+ return payload
+ }
+ return updated
+}
+
+func dedupeInputItemsByID(rawArray string) (string, error) {
+ rawArray = strings.TrimSpace(rawArray)
+ if rawArray == "" {
+ return "[]", nil
+ }
+ var items []json.RawMessage
+ if errUnmarshal := json.Unmarshal([]byte(rawArray), &items); errUnmarshal != nil {
+ return "", errUnmarshal
+ }
+
+ lastIndexByID := make(map[string]int, len(items))
+ for i, item := range items {
+ if len(item) == 0 {
+ continue
+ }
+ itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
+ if itemID != "" {
+ lastIndexByID[itemID] = i
+ }
+ }
+
+ filtered := make([]json.RawMessage, 0, len(items))
+ for i, item := range items {
+ if len(item) == 0 {
+ continue
+ }
+ itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
+ if itemID != "" {
+ if lastIndexByID[itemID] != i {
+ continue
+ }
+ }
+ filtered = append(filtered, item)
+ }
+
+ out, errMarshal := json.Marshal(filtered)
+ if errMarshal != nil {
+ return "", errMarshal
+ }
+ return string(out), nil
+}
+
func websocketUpstreamSupportsIncrementalInput(attributes map[string]string, metadata map[string]any) bool {
if len(attributes) > 0 {
if raw := strings.TrimSpace(attributes["websockets"]); raw != "" {
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go
index d37c783db32..9f23af82dab 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go
@@ -1603,6 +1603,30 @@ func TestNormalizeResponsesWebsocketRequestDropsDuplicateFunctionCallsByCallID(t
}
}
+func TestNormalizeResponsesWebsocketRequestDropsDuplicateInputItemsByID(t *testing.T) {
+ lastRequest := []byte(`{"model":"test-model","stream":true,"input":[{"type":"message","id":"msg-1","role":"user"}]}`)
+ lastResponseOutput := []byte(`[
+ {"type":"function_call","id":"fc-1","call_id":"call-1","name":"tool"}
+ ]`)
+ raw := []byte(`{"type":"response.create","previous_response_id":"resp-1","input":[{"type":"function_call","id":"fc-1","call_id":"call-2","name":"tool"},{"type":"function_call_output","id":"tool-out-1","call_id":"call-2"}]}`)
+
+ normalized, _, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false, true)
+ if errMsg != nil {
+ t.Fatalf("unexpected error: %v", errMsg.Error)
+ }
+
+ items := gjson.GetBytes(normalized, "input").Array()
+ if len(items) != 3 {
+ t.Fatalf("merged input len = %d, want 3: %s", len(items), normalized)
+ }
+ if items[0].Get("id").String() != "msg-1" ||
+ items[1].Get("id").String() != "fc-1" ||
+ items[1].Get("call_id").String() != "call-2" ||
+ items[2].Get("id").String() != "tool-out-1" {
+ t.Fatalf("unexpected merged input order: %s", normalized)
+ }
+}
+
func TestNormalizeResponsesWebsocketRequestTreatsCustomToolTranscriptReplacementAsReset(t *testing.T) {
lastRequest := []byte(`{"model":"test-model","stream":true,"input":[{"type":"message","id":"msg-1"},{"type":"custom_tool_call","id":"ctc-1","call_id":"call-1","name":"apply_patch"},{"type":"custom_tool_call_output","id":"tool-out-1","call_id":"call-1"},{"type":"message","id":"assistant-1","role":"assistant"}]}`)
lastResponseOutput := []byte(`[
@@ -1654,6 +1678,22 @@ func TestNormalizeResponsesWebsocketRequestDropsDuplicateCustomToolCallsByCallID
}
}
+func TestDedupeResponsesWebsocketInputItemsByIDAfterRepair(t *testing.T) {
+ payload := []byte(`{"input":[{"type":"custom_tool_call","id":"ctc-1","call_id":"call-1","name":"tool"},{"type":"custom_tool_call","id":"ctc-1","call_id":"call-2","name":"tool"},{"type":"custom_tool_call_output","id":"tool-out-1","call_id":"call-2"}]}`)
+
+ deduped := dedupeResponsesWebsocketInputItemsByID(payload)
+
+ items := gjson.GetBytes(deduped, "input").Array()
+ if len(items) != 2 {
+ t.Fatalf("deduped input len = %d, want 2: %s", len(items), deduped)
+ }
+ if items[0].Get("id").String() != "ctc-1" ||
+ items[0].Get("call_id").String() != "call-2" ||
+ items[1].Get("id").String() != "tool-out-1" {
+ t.Fatalf("unexpected deduped input: %s", deduped)
+ }
+}
+
func TestResponsesWebsocketCompactionResetsTurnStateOnCustomToolTranscriptReplacement(t *testing.T) {
gin.SetMode(gin.TestMode)
From fc0615b171213b5aa8482e2110b30900feb2c842 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Fri, 29 May 2026 23:04:35 +0800
Subject: [PATCH 086/248] test(oauth): ensure missing auth directories are
created and callback payloads are validated
Closes: #3619
---
.../api/handlers/management/oauth_callback.go | 2 +
.../management/oauth_callback_test.go | 82 +++++++++++++++++++
.../api/handlers/management/oauth_sessions.go | 3 +
3 files changed, 87 insertions(+)
create mode 100644 internal/api/handlers/management/oauth_callback_test.go
diff --git a/internal/api/handlers/management/oauth_callback.go b/internal/api/handlers/management/oauth_callback.go
index c7f7be5ec02..251f999e074 100644
--- a/internal/api/handlers/management/oauth_callback.go
+++ b/internal/api/handlers/management/oauth_callback.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
+ log "github.com/sirupsen/logrus"
)
type oauthCallbackRequest struct {
@@ -97,6 +98,7 @@ func (h *Handler) PostOAuthCallback(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
return
}
+ log.WithError(errWrite).Error("failed to persist oauth callback")
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to persist oauth callback"})
return
}
diff --git a/internal/api/handlers/management/oauth_callback_test.go b/internal/api/handlers/management/oauth_callback_test.go
new file mode 100644
index 00000000000..a9ff971fbbb
--- /dev/null
+++ b/internal/api/handlers/management/oauth_callback_test.go
@@ -0,0 +1,82 @@
+package management
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+)
+
+func TestPostOAuthCallbackCreatesMissingAuthDir(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ authDir := filepath.Join(t.TempDir(), "missing-auth")
+ state := "test-antigravity-state"
+ RegisterOAuthSession(state, "antigravity")
+ defer CompleteOAuthSession(state)
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ router := gin.New()
+ router.POST("/v0/management/oauth-callback", h.PostOAuthCallback)
+
+ body := `{"provider":"antigravity","redirect_url":"http://localhost:59788/oauth-callback?state=test-antigravity-state&code=test-code"}`
+ req := httptest.NewRequest(http.MethodPost, "/v0/management/oauth-callback", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, w.Code, w.Body.String())
+ }
+
+ callbackPath := filepath.Join(authDir, ".oauth-antigravity-"+state+".oauth")
+ data, errRead := os.ReadFile(callbackPath)
+ if errRead != nil {
+ t.Fatalf("expected callback file to be written: %v", errRead)
+ }
+
+ var payload oauthCallbackFilePayload
+ if errUnmarshal := json.Unmarshal(data, &payload); errUnmarshal != nil {
+ t.Fatalf("failed to decode callback payload: %v", errUnmarshal)
+ }
+ if payload.State != state || payload.Code != "test-code" || payload.Error != "" {
+ t.Fatalf("unexpected callback payload: %+v", payload)
+ }
+}
+
+func TestWriteOAuthCallbackFileForPendingSessionCreatesMissingAuthDirForCallbackProviders(t *testing.T) {
+ providers := []string{"anthropic", "codex", "gemini", "antigravity", "xai"}
+ for _, provider := range providers {
+ t.Run(provider, func(t *testing.T) {
+ authDir := filepath.Join(t.TempDir(), "missing-auth")
+ state := provider + "-state"
+ RegisterOAuthSession(state, provider)
+ defer CompleteOAuthSession(state)
+
+ path, errWrite := WriteOAuthCallbackFileForPendingSession(authDir, provider, state, "code-"+provider, "")
+ if errWrite != nil {
+ t.Fatalf("expected callback file write to succeed: %v", errWrite)
+ }
+
+ data, errRead := os.ReadFile(path)
+ if errRead != nil {
+ t.Fatalf("expected callback file to be written: %v", errRead)
+ }
+
+ var payload oauthCallbackFilePayload
+ if errUnmarshal := json.Unmarshal(data, &payload); errUnmarshal != nil {
+ t.Fatalf("failed to decode callback payload: %v", errUnmarshal)
+ }
+ if payload.State != state || payload.Code != "code-"+provider || payload.Error != "" {
+ t.Fatalf("unexpected callback payload: %+v", payload)
+ }
+ })
+ }
+}
diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go
index a74f7d560b5..d861b788ebf 100644
--- a/internal/api/handlers/management/oauth_sessions.go
+++ b/internal/api/handlers/management/oauth_sessions.go
@@ -269,6 +269,9 @@ func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string)
fileName := fmt.Sprintf(".oauth-%s-%s.oauth", canonicalProvider, state)
filePath := filepath.Join(authDir, fileName)
+ if err := os.MkdirAll(authDir, 0o700); err != nil {
+ return "", fmt.Errorf("create oauth callback dir: %w", err)
+ }
payload := oauthCallbackFilePayload{
Code: strings.TrimSpace(code),
State: strings.TrimSpace(state),
From 776a9c00497cde17ea80263db7f4de4f86129ffa Mon Sep 17 00:00:00 2001
From: zzmc
Date: Fri, 29 May 2026 09:24:48 -0700
Subject: [PATCH 087/248] fix(translator/gemini): support developer role in
OpenAI Responses requests
---
.../gemini_openai-responses_request.go | 2 +-
.../gemini_openai-responses_request_test.go | 109 ++++++++++++++++++
2 files changed, 110 insertions(+), 1 deletion(-)
create mode 100644 internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
index e741757641c..781776d744f 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
@@ -118,7 +118,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
switch itemType {
case "message":
- if strings.EqualFold(itemRole, "system") {
+ if strings.EqualFold(itemRole, "system") || strings.EqualFold(itemRole, "developer") {
if contentArray := item.Get("content"); contentArray.Exists() {
systemInstr := []byte(`{"parts":[]}`)
if systemInstructionResult := gjson.GetBytes(out, "systemInstruction"); systemInstructionResult.Exists() {
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
new file mode 100644
index 00000000000..ceb672363da
--- /dev/null
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
@@ -0,0 +1,109 @@
+package responses
+
+import (
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestConvertOpenAIResponsesRequestToGemini_SystemAndDeveloperRoles(t *testing.T) {
+ // Test system role conversion
+ systemInput := []byte(`{
+ "instructions": "Be a helpful assistant",
+ "input": [
+ {
+ "type": "message",
+ "role": "system",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "System message text"
+ }
+ ]
+ },
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }`)
+
+ outSystem := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", systemInput, false)
+ resSystem := gjson.ParseBytes(outSystem)
+
+ systemInstruction := resSystem.Get("systemInstruction")
+ if !systemInstruction.Exists() {
+ t.Errorf("Expected systemInstruction field to exist")
+ }
+ parts := systemInstruction.Get("parts")
+ if parts.Get("#").Int() != 2 {
+ t.Errorf("Expected 2 parts in systemInstruction, got %d", parts.Get("#").Int())
+ }
+ if parts.Get("0.text").String() != "Be a helpful assistant" {
+ t.Errorf("Expected first part to be 'Be a helpful assistant', got '%s'", parts.Get("0.text").String())
+ }
+ if parts.Get("1.text").String() != "System message text" {
+ t.Errorf("Expected second part to be 'System message text', got '%s'", parts.Get("1.text").String())
+ }
+
+ // Test developer role conversion (which is the main bug we're addressing)
+ developerInput := []byte(`{
+ "instructions": "Be a helpful assistant",
+ "input": [
+ {
+ "type": "message",
+ "role": "developer",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "Developer message text"
+ }
+ ]
+ },
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "Hello"
+ }
+ ]
+ }
+ ]
+ }`)
+
+ outDev := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", developerInput, false)
+ resDev := gjson.ParseBytes(outDev)
+
+ systemInstructionDev := resDev.Get("systemInstruction")
+ if !systemInstructionDev.Exists() {
+ t.Errorf("Expected systemInstruction field to exist for developer role")
+ }
+ partsDev := systemInstructionDev.Get("parts")
+ if partsDev.Get("#").Int() != 2 {
+ t.Errorf("Expected 2 parts in systemInstruction for developer role, got %d", partsDev.Get("#").Int())
+ }
+ if partsDev.Get("0.text").String() != "Be a helpful assistant" {
+ t.Errorf("Expected first part to be 'Be a helpful assistant', got '%s'", partsDev.Get("0.text").String())
+ }
+ if partsDev.Get("1.text").String() != "Developer message text" {
+ t.Errorf("Expected second part to be 'Developer message text', got '%s'", partsDev.Get("1.text").String())
+ }
+
+ // Ensure role 'developer' is not sent inside contents array as a regular message
+ contents := resDev.Get("contents")
+ contents.ForEach(func(_, value gjson.Result) bool {
+ role := value.Get("role").String()
+ if role == "developer" {
+ t.Errorf("Role 'developer' leaked into contents array")
+ }
+ return true
+ })
+}
From 430e679e2a603294248d9ff90e97fa4fe8e88090 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Sat, 30 May 2026 05:14:05 +0800
Subject: [PATCH 088/248] fix(auth): strip "generate" from payload during
WebSocket HTTP fallback
- Added `sanitizeDownstreamWebsocketFallbackRequest` to clean `generate` from payload for HTTP fallback requests.
- Implemented tests to validate payload handling logic in WebSocket-to-HTTP transitions.
Closes: #3556
---
.../openai/openai_responses_websocket_test.go | 151 ++++++++++++++++++
sdk/cliproxy/auth/conductor.go | 16 +-
2 files changed, 166 insertions(+), 1 deletion(-)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go
index 9f23af82dab..6502ae0c834 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go
@@ -77,6 +77,12 @@ type websocketPinnedFailoverExecutor struct {
payloads map[string][][]byte
}
+type websocketBootstrapFallbackExecutor struct {
+ mu sync.Mutex
+ authIDs []string
+ payloads map[string][][]byte
+}
+
type websocketPinnedFailoverStatusError struct {
status int
msg string
@@ -86,6 +92,70 @@ func (e websocketPinnedFailoverStatusError) Error() string { return e.msg }
func (e websocketPinnedFailoverStatusError) StatusCode() int { return e.status }
+func (e *websocketBootstrapFallbackExecutor) Identifier() string { return "test-provider" }
+
+func (e *websocketBootstrapFallbackExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (e *websocketBootstrapFallbackExecutor) ExecuteStream(_ context.Context, auth *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ authID := ""
+ if auth != nil {
+ authID = auth.ID
+ }
+
+ e.mu.Lock()
+ if e.payloads == nil {
+ e.payloads = make(map[string][][]byte)
+ }
+ e.authIDs = append(e.authIDs, authID)
+ e.payloads[authID] = append(e.payloads[authID], bytes.Clone(req.Payload))
+ e.mu.Unlock()
+
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ if authID == "auth-ws" {
+ chunks <- coreexecutor.StreamChunk{Err: websocketPinnedFailoverStatusError{
+ status: http.StatusServiceUnavailable,
+ msg: `{"error":{"message":"websocket bootstrap failed","type":"server_error","code":"ws_failed"}}`,
+ }}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ }
+
+ chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"type":"response.completed","response":{"id":"resp-http","output":[{"type":"message","id":"out-http"}]}}`)}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+}
+
+func (e *websocketBootstrapFallbackExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
+ return auth, nil
+}
+
+func (e *websocketBootstrapFallbackExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (e *websocketBootstrapFallbackExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (e *websocketBootstrapFallbackExecutor) AuthIDs() []string {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return append([]string(nil), e.authIDs...)
+}
+
+func (e *websocketBootstrapFallbackExecutor) Payloads(authID string) [][]byte {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ src := e.payloads[authID]
+ out := make([][]byte, len(src))
+ for i := range src {
+ out[i] = bytes.Clone(src[i])
+ }
+ return out
+}
+
type websocketUpstreamDisconnectExecutor struct {
mu sync.Mutex
subscribed chan string
@@ -1340,6 +1410,87 @@ func TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) {
}
}
+func TestResponsesWebsocketStripsGenerateWhenWebsocketAttemptFallsBackToHTTP(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ selector := &orderedWebsocketSelector{order: []string{"auth-ws", "auth-http"}}
+ executor := &websocketBootstrapFallbackExecutor{}
+ manager := coreauth.NewManager(nil, selector, nil)
+ manager.RegisterExecutor(executor)
+
+ authWS := &coreauth.Auth{
+ ID: "auth-ws",
+ Provider: executor.Identifier(),
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{"websockets": "true"},
+ }
+ if _, err := manager.Register(context.Background(), authWS); err != nil {
+ t.Fatalf("Register websocket auth: %v", err)
+ }
+ authHTTP := &coreauth.Auth{ID: "auth-http", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, err := manager.Register(context.Background(), authHTTP); err != nil {
+ t.Fatalf("Register HTTP auth: %v", err)
+ }
+
+ registry.GetGlobalRegistry().RegisterClient(authWS.ID, authWS.Provider, []*registry.ModelInfo{{ID: "test-model"}})
+ registry.GetGlobalRegistry().RegisterClient(authHTTP.ID, authHTTP.Provider, []*registry.ModelInfo{{ID: "test-model"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(authWS.ID)
+ registry.GetGlobalRegistry().UnregisterClient(authHTTP.ID)
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ router := gin.New()
+ router.GET("/v1/responses/ws", h.ResponsesWebsocket)
+
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses/ws"
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("dial websocket: %v", err)
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Fatalf("close websocket: %v", errClose)
+ }
+ }()
+
+ request := `{"type":"response.create","model":"test-model","generate":false,"input":[{"type":"message","id":"msg-1"}]}`
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(request)); errWrite != nil {
+ t.Fatalf("write websocket message: %v", errWrite)
+ }
+ _, payload, errReadMessage := conn.ReadMessage()
+ if errReadMessage != nil {
+ t.Fatalf("read websocket message: %v", errReadMessage)
+ }
+ if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ t.Fatalf("payload type = %s, want %s: %s", got, wsEventTypeCompleted, payload)
+ }
+
+ if got := executor.AuthIDs(); len(got) != 2 || got[0] != "auth-ws" || got[1] != "auth-http" {
+ t.Fatalf("selected auth IDs = %v, want [auth-ws auth-http]", got)
+ }
+
+ wsPayloads := executor.Payloads("auth-ws")
+ if len(wsPayloads) != 1 {
+ t.Fatalf("auth-ws payload count = %d, want 1", len(wsPayloads))
+ }
+ if !gjson.GetBytes(wsPayloads[0], "generate").Exists() {
+ t.Fatalf("websocket attempt payload unexpectedly stripped generate: %s", wsPayloads[0])
+ }
+
+ httpPayloads := executor.Payloads("auth-http")
+ if len(httpPayloads) != 1 {
+ t.Fatalf("auth-http payload count = %d, want 1", len(httpPayloads))
+ }
+ if gjson.GetBytes(httpPayloads[0], "generate").Exists() {
+ t.Fatalf("generate leaked after HTTP fallback: %s", httpPayloads[0])
+ }
+}
+
func TestWebsocketClientAddressUsesGinClientIP(t *testing.T) {
gin.SetMode(gin.TestMode)
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index 5413dcf4ba7..33116fba8f5 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -25,6 +25,7 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
log "github.com/sirupsen/logrus"
+ "github.com/tidwall/sjson"
)
// ProviderExecutor defines the contract required by Manager to execute provider calls.
@@ -1581,7 +1582,8 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
lastErr = errPrepare
continue
}
- streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, req, opts, routeModel, models, pooled)
+ execReq := sanitizeDownstreamWebsocketFallbackRequest(execCtx, auth, req)
+ streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, opts, routeModel, models, pooled)
if errStream != nil {
if errCtx := execCtx.Err(); errCtx != nil {
return nil, errCtx
@@ -1599,6 +1601,18 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
}
}
+func sanitizeDownstreamWebsocketFallbackRequest(ctx context.Context, auth *Auth, req cliproxyexecutor.Request) cliproxyexecutor.Request {
+ if !cliproxyexecutor.DownstreamWebsocket(ctx) || authWebsocketsEnabled(auth) || len(req.Payload) == 0 {
+ return req
+ }
+ updated, errDelete := sjson.DeleteBytes(req.Payload, "generate")
+ if errDelete != nil {
+ return req
+ }
+ req.Payload = updated
+ return req
+}
+
func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel string) cliproxyexecutor.Options {
requestedModel = strings.TrimSpace(requestedModel)
if requestedModel == "" {
From 33983b6f3e0ecff7619d96241e60d900ff5d0514 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Sun, 31 May 2026 14:38:54 +0800
Subject: [PATCH 089/248] refactor(executor): consolidate Codex request
translation logic
- Introduced `translateCodexRequestPair` to simplify and reuse translation logic for handling original and modified payloads.
- Updated relevant methods to use the new function.
- Added unit tests to cover payload reuse and differentiation scenarios.
---
internal/runtime/executor/codex_executor.go | 19 ++++--
.../executor/codex_executor_translate_test.go | 59 +++++++++++++++++++
.../executor/codex_websockets_executor.go | 3 +-
3 files changed, 73 insertions(+), 8 deletions(-)
create mode 100644 internal/runtime/executor/codex_executor_translate_test.go
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index a96e805cbc0..7b6079440bc 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -207,6 +207,16 @@ func NewCodexExecutor(cfg *config.Config) *CodexExecutor { return &CodexExecutor
func (e *CodexExecutor) Identifier() string { return "codex" }
+func translateCodexRequestPair(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool) ([]byte, []byte) {
+ if bytes.Equal(originalPayload, payload) {
+ body := sdktranslator.TranslateRequest(from, to, model, payload, stream)
+ return body, body
+ }
+ originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, stream)
+ body := sdktranslator.TranslateRequest(from, to, model, payload, stream)
+ return originalTranslated, body
+}
+
// PrepareRequest injects Codex credentials into the outgoing HTTP request.
func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
@@ -264,8 +274,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
- body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
+ originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -427,8 +436,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
- body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
+ originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -528,8 +536,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
- body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
+ originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
diff --git a/internal/runtime/executor/codex_executor_translate_test.go b/internal/runtime/executor/codex_executor_translate_test.go
new file mode 100644
index 00000000000..5b28f9e7929
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_translate_test.go
@@ -0,0 +1,59 @@
+package executor
+
+import (
+ "bytes"
+ "sync/atomic"
+ "testing"
+
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
+)
+
+func TestTranslateCodexRequestPairReusesEqualPayload(t *testing.T) {
+ from := sdktranslator.Format("codex-test-from-equal")
+ to := sdktranslator.Format("codex-test-to-equal")
+ var calls int32
+ sdktranslator.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
+ atomic.AddInt32(&calls, 1)
+ if model != "test-model" {
+ t.Errorf("model = %q, want test-model", model)
+ }
+ if !stream {
+ t.Error("stream = false, want true")
+ }
+ return append([]byte(nil), rawJSON...)
+ }, sdktranslator.ResponseTransform{})
+
+ payload := []byte(`{"model":"test-model","input":[{"role":"user"}]}`)
+ originalTranslated, body := translateCodexRequestPair(from, to, "test-model", payload, bytes.Clone(payload), true)
+
+ if gotCalls := atomic.LoadInt32(&calls); gotCalls != 1 {
+ t.Fatalf("TranslateRequest calls = %d, want 1", gotCalls)
+ }
+ if !bytes.Equal(originalTranslated, body) {
+ t.Fatalf("translated payloads differ: original=%s body=%s", originalTranslated, body)
+ }
+}
+
+func TestTranslateCodexRequestPairTranslatesDifferentPayloads(t *testing.T) {
+ from := sdktranslator.Format("codex-test-from-different")
+ to := sdktranslator.Format("codex-test-to-different")
+ var calls int32
+ sdktranslator.Register(from, to, func(_ string, rawJSON []byte, _ bool) []byte {
+ atomic.AddInt32(&calls, 1)
+ return append([]byte(nil), rawJSON...)
+ }, sdktranslator.ResponseTransform{})
+
+ originalPayload := []byte(`{"model":"test-model","input":[{"role":"system"}]}`)
+ payload := []byte(`{"model":"test-model","input":[{"role":"user"}]}`)
+ originalTranslated, body := translateCodexRequestPair(from, to, "test-model", originalPayload, payload, false)
+
+ if gotCalls := atomic.LoadInt32(&calls); gotCalls != 2 {
+ t.Fatalf("TranslateRequest calls = %d, want 2", gotCalls)
+ }
+ if !bytes.Equal(originalTranslated, originalPayload) {
+ t.Fatalf("original translated = %s, want %s", originalTranslated, originalPayload)
+ }
+ if !bytes.Equal(body, payload) {
+ t.Fatalf("body = %s, want %s", body, payload)
+ }
+}
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index 8339114fef9..4a2fb1f9fd2 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -194,8 +194,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
- body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
+ originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false)
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
From 0f24cafbddbf457473093d652ef6fb365533b049 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Sun, 31 May 2026 22:59:40 +0800
Subject: [PATCH 090/248] feat(executor): implement identity obfuscation for
Codex requests and responses
- Added `applyCodexIdentityConfuse*` functions for remapping request and response payloads and headers to enhance security.
- Updated WebSocket and HTTP logic to handle identity state transformations seamlessly.
- Introduced unit tests to verify remapping and restoration of identity-related fields.
---
config.example.yaml | 10 +-
.../codex_websocket_header_defaults_test.go | 21 +++
internal/config/config.go | 10 +-
internal/runtime/executor/codex_executor.go | 143 +++++++++++++++---
.../executor/codex_executor_cache_test.go | 88 ++++++++++-
.../runtime/executor/codex_openai_images.go | 12 +-
.../executor/codex_websockets_executor.go | 45 ++++--
.../codex_websockets_executor_test.go | 100 ++++++++++--
internal/watcher/diff/config_diff.go | 4 +
...nai_responses_websocket_toolcall_repair.go | 3 -
sdk/cliproxy/auth/selector.go | 39 ++---
sdk/cliproxy/auth/selector_test.go | 13 +-
12 files changed, 397 insertions(+), 91 deletions(-)
diff --git a/config.example.yaml b/config.example.yaml
index 6a53c940048..be84de3b5a5 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -119,13 +119,21 @@ routing:
strategy: "round-robin" # round-robin (default), fill-first
# Enable universal session-sticky routing for all clients.
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
- # X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
+ # X-Session-ID, X-Amp-Thread-Id (Amp CLI),
# X-Client-Request-Id (PI), conversation_id, or first few messages hash.
# Automatic failover is always enabled when bound auth becomes unavailable.
session-affinity: false # default: false
# How long session-to-auth bindings are retained. Default: 1h
session-affinity-ttl: "1h"
+# Codex provider behavior.
+codex:
+ # When true, and routing.strategy is fill-first or routing.session-affinity is true,
+ # remap Codex prompt_cache_key and installation identity per selected auth.
+ # Some superstitious users believe request tracking identifiers can be used
+ # as evidence for TOS enforcement bans; this option only satisfies those odd concerns.
+ identity-confuse: false
+
# When true, enable authentication for the WebSocket API (/v1/ws).
ws-auth: true
diff --git a/internal/config/codex_websocket_header_defaults_test.go b/internal/config/codex_websocket_header_defaults_test.go
index 49947c1cf64..1ccb82e4e2e 100644
--- a/internal/config/codex_websocket_header_defaults_test.go
+++ b/internal/config/codex_websocket_header_defaults_test.go
@@ -30,3 +30,24 @@ codex-header-defaults:
t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b")
}
}
+
+func TestLoadConfigOptional_CodexIdentityConfuse(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.yaml")
+ configYAML := []byte(`
+codex:
+ identity-confuse: true
+`)
+ if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ cfg, err := LoadConfigOptional(configPath, false)
+ if err != nil {
+ t.Fatalf("LoadConfigOptional() error = %v", err)
+ }
+
+ if !cfg.Codex.IdentityConfuse {
+ t.Fatalf("IdentityConfuse = false, want true")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index dd0b05c7285..7c660cd23e0 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -111,6 +111,9 @@ type Config struct {
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
+ // Codex configures provider-wide Codex request behavior.
+ Codex CodexConfig `yaml:"codex" json:"codex"`
+
// CodexHeaderDefaults configures fallback headers for Codex OAuth model requests.
// These are used only when the client does not send its own headers.
CodexHeaderDefaults CodexHeaderDefaults `yaml:"codex-header-defaults" json:"codex-header-defaults"`
@@ -172,6 +175,11 @@ type CodexHeaderDefaults struct {
BetaFeatures string `yaml:"beta-features" json:"beta-features"`
}
+// CodexConfig configures provider-wide Codex request behavior.
+type CodexConfig struct {
+ IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"`
+}
+
// TLSConfig holds HTTPS server settings.
type TLSConfig struct {
// Enable toggles HTTPS server mode.
@@ -229,7 +237,7 @@ type RoutingConfig struct {
// SessionAffinity enables universal session-sticky routing for all clients.
// Session IDs are extracted from multiple sources:
- // metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex),
+ // metadata.user_id (Claude Code session format), X-Session-ID,
// X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id,
// conversation_id, or message hash.
// Automatic failover is always enabled when bound auth becomes unavailable.
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index 7b6079440bc..c8a9246e4a1 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -298,11 +298,13 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- httpReq, err := e.cacheHelper(ctx, from, url, req, body)
+ var identityState codexIdentityConfuseState
+ httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
if err != nil {
return resp, err
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -313,7 +315,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
- Body: body,
+ Body: upstreamBody,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
@@ -335,6 +337,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
+ b = applyCodexIdentityConfuseResponsePayload(b, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
err = newCodexStatusErr(httpResp.StatusCode, b)
@@ -345,9 +348,10 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
- helps.AppendAPIResponseChunk(ctx, e.cfg, data)
+ upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState)
+ helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData)
- lines := bytes.Split(data, []byte("\n"))
+ lines := bytes.Split(upstreamData, []byte("\n"))
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
for _, line := range lines {
@@ -410,7 +414,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
var param any
- out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, completedData, ¶m)
+ clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState)
+ out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientCompletedData, ¶m)
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
return resp, nil
}
@@ -456,11 +461,13 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
- httpReq, err := e.cacheHelper(ctx, from, url, req, body)
+ var identityState codexIdentityConfuseState
+ httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
if err != nil {
return resp, err
}
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -471,7 +478,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
- Body: body,
+ Body: upstreamBody,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
@@ -493,6 +500,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
+ b = applyCodexIdentityConfuseResponsePayload(b, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
err = newCodexStatusErr(httpResp.StatusCode, b)
@@ -503,11 +511,13 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
- helps.AppendAPIResponseChunk(ctx, e.cfg, data)
- reporter.Publish(ctx, helps.ParseOpenAIUsage(data))
+ upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState)
+ helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData)
+ reporter.Publish(ctx, helps.ParseOpenAIUsage(upstreamData))
reporter.EnsurePublished(ctx)
var param any
- out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, data, ¶m)
+ clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState)
+ out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientData, ¶m)
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
return resp, nil
}
@@ -559,11 +569,13 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- httpReq, err := e.cacheHelper(ctx, from, url, req, body)
+ var identityState codexIdentityConfuseState
+ httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
if err != nil {
return nil, err
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -574,7 +586,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
- Body: body,
+ Body: upstreamBody,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
@@ -599,6 +611,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
return nil, readErr
}
+ data = applyCodexIdentityConfuseResponsePayload(data, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
err = newCodexStatusErr(httpResp.StatusCode, data)
@@ -618,7 +631,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
for scanner.Scan() {
- line := scanner.Bytes()
+ line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
translatedLine := bytes.Clone(line)
@@ -646,6 +659,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
}
+ translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState)
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, ¶m)
for i := range chunks {
select {
@@ -866,7 +880,12 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
return auth, nil
}
-func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte) (*http.Request, error) {
+type codexIdentityConfuseState struct {
+ originalPromptCacheKey string
+ promptCacheKey string
+}
+
+func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte) (*http.Request, []byte, codexIdentityConfuseState, error) {
var cache helps.CodexCache
if from == "claude" {
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
@@ -895,14 +914,98 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
if cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
}
+ var identityState codexIdentityConfuseState
+ rawJSON, identityState = applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, rawJSON)
+ if identityState.promptCacheKey != "" {
+ cache.ID = identityState.promptCacheKey
+ }
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
if err != nil {
- return nil, err
+ return nil, nil, codexIdentityConfuseState{}, err
}
- if cache.ID != "" {
- httpReq.Header.Set("Session_id", cache.ID)
+ return httpReq, rawJSON, identityState, nil
+}
+
+func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth, userPayload []byte, rawJSON []byte) ([]byte, codexIdentityConfuseState) {
+ if !codexIdentityConfuseEnabled(cfg) || auth == nil || strings.TrimSpace(auth.ID) == "" || len(rawJSON) == 0 {
+ return rawJSON, codexIdentityConfuseState{}
+ }
+
+ state := codexIdentityConfuseState{}
+ if promptCacheKey := strings.TrimSpace(gjson.GetBytes(userPayload, "prompt_cache_key").String()); promptCacheKey != "" {
+ state.originalPromptCacheKey = promptCacheKey
+ state.promptCacheKey = codexIdentityConfuseUUID(auth.ID, "prompt-cache", promptCacheKey)
+ rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", state.promptCacheKey)
+ }
+ if installationID := strings.TrimSpace(gjson.GetBytes(userPayload, "client_metadata.x-codex-installation-id").String()); installationID != "" {
+ rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-installation-id", codexIdentityConfuseUUID(auth.ID, "installation", installationID))
}
- return httpReq, nil
+ if state.promptCacheKey != "" {
+ if turnMetadata := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
+ rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-turn-metadata", applyCodexTurnMetadataIdentityConfuse(turnMetadata, state))
+ }
+ if windowID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-window-id").String()); windowID != "" {
+ rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-window-id", state.promptCacheKey+":0")
+ }
+ }
+
+ return rawJSON, state
+}
+
+func applyCodexIdentityConfuseHeaders(headers http.Header, state codexIdentityConfuseState) {
+ if headers == nil || state.promptCacheKey == "" {
+ return
+ }
+
+ setHeaderCasePreserved(headers, "Session-Id", state.promptCacheKey)
+ headers.Set("Conversation_id", state.promptCacheKey)
+ headers.Set("X-Client-Request-Id", state.promptCacheKey)
+ headers.Set("Thread-Id", state.promptCacheKey)
+ headers.Set("X-Codex-Window-Id", state.promptCacheKey+":0")
+
+ if rawTurnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); rawTurnMetadata != "" {
+ headers.Set("X-Codex-Turn-Metadata", applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata, state))
+ }
+}
+
+func applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata string, state codexIdentityConfuseState) string {
+ updatedTurnMetadata := rawTurnMetadata
+ if gjson.Get(rawTurnMetadata, "prompt_cache_key").Exists() {
+ updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "prompt_cache_key", state.promptCacheKey)
+ } else if state.originalPromptCacheKey != "" {
+ updatedTurnMetadata = strings.ReplaceAll(updatedTurnMetadata, state.originalPromptCacheKey, state.promptCacheKey)
+ }
+ return updatedTurnMetadata
+}
+
+func applyCodexIdentityConfuseResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
+ return replaceCodexIdentityResponsePayload(payload, state.originalPromptCacheKey, state.promptCacheKey)
+}
+
+func applyCodexIdentityExposeResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
+ return replaceCodexIdentityResponsePayload(payload, state.promptCacheKey, state.originalPromptCacheKey)
+}
+
+func replaceCodexIdentityResponsePayload(payload []byte, from string, to string) []byte {
+ from = strings.TrimSpace(from)
+ to = strings.TrimSpace(to)
+ if len(payload) == 0 || from == "" || to == "" || from == to || !bytes.Contains(payload, []byte(from)) {
+ return payload
+ }
+ return bytes.ReplaceAll(payload, []byte(from), []byte(to))
+}
+
+func codexIdentityConfuseEnabled(cfg *config.Config) bool {
+ if cfg == nil || !cfg.Codex.IdentityConfuse {
+ return false
+ }
+ strategy := strings.ToLower(strings.TrimSpace(cfg.Routing.Strategy))
+ return cfg.Routing.SessionAffinity || strategy == "fill-first" || strategy == "fillfirst" || strategy == "ff"
+}
+
+func codexIdentityConfuseUUID(authID string, kind string, value string) string {
+ name := strings.Join([]string{"cli-proxy-api", "codex", "identity-confuse", kind, strings.TrimSpace(authID), strings.TrimSpace(value)}, ":")
+ return uuid.NewSHA1(uuid.NameSpaceOID, []byte(name)).String()
}
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
@@ -923,10 +1026,6 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
- if strings.Contains(r.Header.Get("User-Agent"), "Mac OS") {
- misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
- }
-
if stream {
r.Header.Set("Accept", "text/event-stream")
} else {
diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go
index cb96a902893..2cf2b373bae 100644
--- a/internal/runtime/executor/codex_executor_cache_test.go
+++ b/internal/runtime/executor/codex_executor_cache_test.go
@@ -8,6 +8,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
@@ -27,7 +29,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
}
url := "https://example.com/responses"
- httpReq, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
+ httpReq, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}
@@ -45,11 +47,11 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
if gotConversation := httpReq.Header.Get("Conversation_id"); gotConversation != "" {
t.Fatalf("Conversation_id = %q, want empty", gotConversation)
}
- if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedKey {
- t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey)
+ if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" {
+ t.Fatalf("Session_id = %q, want empty", gotSession)
}
- httpReq2, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
+ httpReq2, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error (second call): %v", err)
}
@@ -62,3 +64,81 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
t.Fatalf("prompt_cache_key (second call) = %q, want %q", gotKey2, expectedKey)
}
}
+
+func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(recorder)
+ ginCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
+ ginCtx.Request.Header.Set("X-Codex-Turn-Metadata", `{"prompt_cache_key":"cache-1","turn_id":"turn-1"}`)
+ ginCtx.Request.Header.Set("X-Client-Request-Id", "client-request-1")
+
+ ctx := context.WithValue(context.Background(), "gin", ginCtx)
+ executor := &CodexExecutor{cfg: &config.Config{
+ Routing: config.RoutingConfig{Strategy: "fill-first"},
+ Codex: config.CodexConfig{IdentityConfuse: true},
+ }}
+ auth := &cliproxyauth.Auth{ID: "auth-1", Provider: "codex"}
+ rawJSON := []byte(`{"model":"gpt-5-codex","stream":true,"client_metadata":{"x-codex-turn-metadata":"{\"prompt_cache_key\":\"cache-1\",\"turn_id\":\"turn-1\"}","x-codex-window-id":"cache-1:0"}}`)
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5-codex",
+ Payload: []byte(`{"model":"gpt-5-codex","prompt_cache_key":"cache-1","client_metadata":{"x-codex-installation-id":"install-1"}}`),
+ }
+ url := "https://example.com/responses"
+
+ httpReq, body, identityState, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai-response"), url, auth, req, req.Payload, rawJSON)
+ if err != nil {
+ t.Fatalf("cacheHelper error: %v", err)
+ }
+ applyCodexHeaders(httpReq, auth, "oauth-token", true, executor.cfg)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
+
+ expectedPromptCacheKey := codexIdentityConfuseUUID("auth-1", "prompt-cache", "cache-1")
+ if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
+ t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
+ }
+ expectedInstallationID := codexIdentityConfuseUUID("auth-1", "installation", "install-1")
+ if gotID := gjson.GetBytes(body, "client_metadata.x-codex-installation-id").String(); gotID != expectedInstallationID {
+ t.Fatalf("installation id = %q, want %q", gotID, expectedInstallationID)
+ }
+ if gotMetadata := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String(); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` {
+ t.Fatalf("client_metadata.x-codex-turn-metadata = %s", gotMetadata)
+ }
+ if gotWindowID := gjson.GetBytes(body, "client_metadata.x-codex-window-id").String(); gotWindowID != expectedPromptCacheKey+":0" {
+ t.Fatalf("client_metadata.x-codex-window-id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0")
+ }
+ for _, headerName := range []string{"Session-Id", "X-Client-Request-Id", "Thread-Id"} {
+ if gotHeader := httpReq.Header.Get(headerName); gotHeader != expectedPromptCacheKey {
+ t.Fatalf("%s = %q, want %q", headerName, gotHeader, expectedPromptCacheKey)
+ }
+ }
+ if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" {
+ t.Fatalf("Session_id = %q, want empty", gotSession)
+ }
+ if gotWindow := httpReq.Header.Get("X-Codex-Window-Id"); gotWindow != expectedPromptCacheKey+":0" {
+ t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindow, expectedPromptCacheKey+":0")
+ }
+ if gotMetadata := httpReq.Header.Get("X-Codex-Turn-Metadata"); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` {
+ t.Fatalf("X-Codex-Turn-Metadata = %s", gotMetadata)
+ }
+}
+
+func TestCodexIdentityConfuseKeepsClientBodySeparateFromUpstreamBody(t *testing.T) {
+ cfg := &config.Config{
+ Routing: config.RoutingConfig{Strategy: "fill-first"},
+ Codex: config.CodexConfig{IdentityConfuse: true},
+ }
+ auth := &cliproxyauth.Auth{ID: "auth-1", Provider: "codex"}
+ clientBody := []byte(`{"model":"gpt-5-codex","prompt_cache_key":"cache-1"}`)
+
+ upstreamBody, identityState := applyCodexIdentityConfuseBody(cfg, auth, clientBody, clientBody)
+ expectedPromptCacheKey := codexIdentityConfuseUUID("auth-1", "prompt-cache", "cache-1")
+ if identityState.promptCacheKey != expectedPromptCacheKey {
+ t.Fatalf("identity prompt_cache_key = %q, want %q", identityState.promptCacheKey, expectedPromptCacheKey)
+ }
+ if gotKey := gjson.GetBytes(upstreamBody, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
+ t.Fatalf("upstream prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
+ }
+ if gotKey := gjson.GetBytes(clientBody, "prompt_cache_key").String(); gotKey != "cache-1" {
+ t.Fatalf("client prompt_cache_key = %q, want cache-1", gotKey)
+ }
+}
diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go
index 415cdf1c737..90fe4ad3e7e 100644
--- a/internal/runtime/executor/codex_openai_images.go
+++ b/internal/runtime/executor/codex_openai_images.go
@@ -99,11 +99,13 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
reporter.SetTranslatedReasoningEffort(body, "codex")
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)
+ var identityState codexIdentityConfuseState
+ httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body)
if errCache != nil {
return resp, errCache
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -125,6 +127,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
return resp, errRead
}
+ data = applyCodexIdentityConfuseResponsePayload(data, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
@@ -189,11 +192,13 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
reporter.SetTranslatedReasoningEffort(body, "codex")
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)
+ var identityState codexIdentityConfuseState
+ httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body)
if errCache != nil {
return nil, errCache
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -213,6 +218,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
return nil, errRead
}
+ data = applyCodexIdentityConfuseResponsePayload(data, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
err = newCodexStatusErr(httpResp.StatusCode, data)
@@ -250,7 +256,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
for scanner.Scan() {
- line := scanner.Bytes()
+ line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
if !bytes.HasPrefix(line, dataTag) {
continue
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index 4a2fb1f9fd2..2680e729b73 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -221,8 +221,15 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
- reporter.SetTranslatedReasoningEffort(body, to.String())
+ clientBody := body
+ var identityState codexIdentityConfuseState
+ upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
+ if identityState.promptCacheKey != "" {
+ wsHeaders.Set("Conversation_id", identityState.promptCacheKey)
+ }
+ reporter.SetTranslatedReasoningEffort(clientBody, to.String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
+ applyCodexIdentityConfuseHeaders(wsHeaders, identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
@@ -239,7 +246,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
defer sess.reqMu.Unlock()
}
- wsReqBody := buildCodexWebsocketRequestBody(body)
+ wsReqBody := buildCodexWebsocketRequestBody(upstreamBody)
wsReqLog := helps.UpstreamRequestLog{
URL: wsURL,
Method: "WEBSOCKET",
@@ -300,7 +307,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
// execution session.
connRetry, respHSRetry, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders)
if errDialRetry == nil && connRetry != nil {
- wsReqBodyRetry := buildCodexWebsocketRequestBody(body)
+ wsReqBodyRetry := buildCodexWebsocketRequestBody(upstreamBody)
helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: wsURL,
Method: "WEBSOCKET",
@@ -359,6 +366,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
continue
}
reporter.MarkFirstResponseByte()
+ payload = applyCodexIdentityConfuseResponsePayload(payload, identityState)
helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload)
if wsErr, ok := parseCodexWebsocketError(payload); ok {
@@ -376,7 +384,8 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
reporter.Publish(ctx, detail)
}
var param any
- out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, payload, ¶m)
+ clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState)
+ out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, clientBody, clientPayload, ¶m)
resp = cliproxyexecutor.Response{Payload: out}
return resp, nil
}
@@ -404,6 +413,10 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
from := opts.SourceFormat
to := sdktranslator.FromString("codex")
body := req.Payload
+ userPayload := req.Payload
+ if len(opts.OriginalRequest) > 0 {
+ userPayload = opts.OriginalRequest
+ }
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
@@ -426,8 +439,15 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
}
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
- reporter.SetTranslatedReasoningEffort(body, to.String())
+ clientBody := body
+ var identityState codexIdentityConfuseState
+ upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, body)
+ if identityState.promptCacheKey != "" {
+ wsHeaders.Set("Conversation_id", identityState.promptCacheKey)
+ }
+ reporter.SetTranslatedReasoningEffort(clientBody, to.String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
+ applyCodexIdentityConfuseHeaders(wsHeaders, identityState)
var authID, authLabel, authType, authValue string
authID = auth.ID
@@ -443,7 +463,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
}
}
- wsReqBody := buildCodexWebsocketRequestBody(body)
+ wsReqBody := buildCodexWebsocketRequestBody(upstreamBody)
wsReqLog := helps.UpstreamRequestLog{
URL: wsURL,
Method: "WEBSOCKET",
@@ -506,7 +526,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
sess.reqMu.Unlock()
return nil, errDialRetry
}
- wsReqBodyRetry := buildCodexWebsocketRequestBody(body)
+ wsReqBodyRetry := buildCodexWebsocketRequestBody(upstreamBody)
helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: wsURL,
Method: "WEBSOCKET",
@@ -613,6 +633,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
continue
}
reporter.MarkFirstResponseByte()
+ payload = applyCodexIdentityConfuseResponsePayload(payload, identityState)
helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload)
if wsErr, ok := parseCodexWebsocketError(payload); ok {
@@ -635,8 +656,9 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
}
}
- line := encodeCodexWebsocketAsSSE(payload)
- chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, body, body, line, ¶m)
+ clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState)
+ line := encodeCodexWebsocketAsSSE(clientPayload)
+ chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, clientBody, clientBody, line, ¶m)
for i := range chunks {
if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) {
terminateReason = "context_done"
@@ -841,7 +863,6 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
if cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
- setHeaderCasePreserved(headers, "session_id", cache.ID)
headers.Set("Conversation_id", cache.ID)
}
@@ -883,10 +904,6 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
betaHeader = codexResponsesWebsocketBetaHeaderValue
}
headers.Set("OpenAI-Beta", betaHeader)
- if strings.Contains(headers.Get("User-Agent"), "Mac OS") {
- ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", uuid.NewString())
- }
- ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", "")
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
headers.Set("Originator", originator)
} else if !isAPIKey {
diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go
index 4342ed88823..4ea1e87fa8b 100644
--- a/internal/runtime/executor/codex_websockets_executor_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_test.go
@@ -197,7 +197,7 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
"Version": "0.115.0-alpha.27",
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
- "session_id": "sess-client",
+ "session_id": "legacy-session",
})
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
@@ -217,11 +217,8 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
}
- if got := headerValueCaseInsensitive(headers, "session_id"); got != "sess-client" {
- t.Fatalf("session_id = %s, want sess-client", got)
- }
- if _, ok := headers["session_id"]; !ok {
- t.Fatalf("expected lowercase session_id header key, got %#v", headers)
+ if got := headerValueCaseInsensitive(headers, "session_id"); got != "" {
+ t.Fatalf("session_id = %q, want empty", got)
}
}
@@ -344,22 +341,101 @@ func TestApplyCodexWebsocketHeadersPreservesExplicitAPIKeyUserAgent(t *testing.T
}
}
-func TestApplyCodexPromptCacheHeadersSetsLowercaseSessionAndLegacyConversation(t *testing.T) {
+func TestApplyCodexPromptCacheHeadersSetsLegacyConversationOnly(t *testing.T) {
req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)}
_, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`))
- if got := headerValueCaseInsensitive(headers, "session_id"); got != "cache-1" {
- t.Fatalf("session_id = %s, want cache-1", got)
- }
- if _, ok := headers["session_id"]; !ok {
- t.Fatalf("expected lowercase session_id key, got %#v", headers)
+ if got := headerValueCaseInsensitive(headers, "session_id"); got != "" {
+ t.Fatalf("session_id = %q, want empty", got)
}
if got := headers.Get("Conversation_id"); got != "cache-1" {
t.Fatalf("Conversation_id = %s, want cache-1", got)
}
}
+func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testing.T) {
+ cfg := &config.Config{
+ Routing: config.RoutingConfig{SessionAffinity: true},
+ Codex: config.CodexConfig{IdentityConfuse: true},
+ }
+ auth := &cliproxyauth.Auth{ID: "auth-ws-1", Provider: "codex"}
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5-codex",
+ Payload: []byte(`{"prompt_cache_key":"cache-ws-1","client_metadata":{"x-codex-installation-id":"install-ws-1"}}`),
+ }
+
+ body, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`))
+ body, identityState := applyCodexIdentityConfuseBody(cfg, auth, req.Payload, body)
+ if identityState.promptCacheKey != "" {
+ headers.Set("Conversation_id", identityState.promptCacheKey)
+ }
+ ctx := contextWithGinHeaders(map[string]string{
+ "X-Codex-Turn-Metadata": `{"prompt_cache_key":"cache-ws-1"}`,
+ "X-Client-Request-Id": "client-request-1",
+ })
+ headers = applyCodexWebsocketHeaders(ctx, headers, auth, "oauth-token", cfg)
+ applyCodexIdentityConfuseHeaders(headers, identityState)
+
+ expectedPromptCacheKey := codexIdentityConfuseUUID("auth-ws-1", "prompt-cache", "cache-ws-1")
+ if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
+ t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
+ }
+ if gotSession := headerValueCaseInsensitive(headers, "session_id"); gotSession != "" {
+ t.Fatalf("session_id = %q, want empty", gotSession)
+ }
+ if gotRequestID := headers.Get("X-Client-Request-Id"); gotRequestID != expectedPromptCacheKey {
+ t.Fatalf("X-Client-Request-Id = %q, want %q", gotRequestID, expectedPromptCacheKey)
+ }
+ if gotThreadID := headers.Get("Thread-Id"); gotThreadID != expectedPromptCacheKey {
+ t.Fatalf("Thread-Id = %q, want %q", gotThreadID, expectedPromptCacheKey)
+ }
+ if gotWindowID := headers.Get("X-Codex-Window-Id"); gotWindowID != expectedPromptCacheKey+":0" {
+ t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0")
+ }
+ if gotMetadata := headers.Get("X-Codex-Turn-Metadata"); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`"}` {
+ t.Fatalf("X-Codex-Turn-Metadata = %s", gotMetadata)
+ }
+ expectedInstallationID := codexIdentityConfuseUUID("auth-ws-1", "installation", "install-ws-1")
+ if gotInstallationID := gjson.GetBytes(body, "client_metadata.x-codex-installation-id").String(); gotInstallationID != expectedInstallationID {
+ t.Fatalf("installation id = %q, want %q", gotInstallationID, expectedInstallationID)
+ }
+}
+
+func TestCodexIdentityConfuseResponsePayloadHidesUpstreamAndRestoresClient(t *testing.T) {
+ state := codexIdentityConfuseState{
+ originalPromptCacheKey: "cache-ws-1",
+ promptCacheKey: codexIdentityConfuseUUID("auth-ws-1", "prompt-cache", "cache-ws-1"),
+ }
+ rawPayload := []byte(`{"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1"},"prompt_cache_key":"cache-ws-1"}`)
+
+ upstreamPayload := applyCodexIdentityConfuseResponsePayload(rawPayload, state)
+ if bytes.Contains(upstreamPayload, []byte(`cache-ws-1`)) {
+ t.Fatalf("upstream payload still contains original prompt_cache_key: %s", string(upstreamPayload))
+ }
+ if !bytes.Contains(upstreamPayload, []byte(state.promptCacheKey)) {
+ t.Fatalf("upstream payload missing confused prompt_cache_key: %s", string(upstreamPayload))
+ }
+
+ clientPayload := applyCodexIdentityExposeResponsePayload(upstreamPayload, state)
+ if bytes.Contains(clientPayload, []byte(state.promptCacheKey)) {
+ t.Fatalf("client payload still contains confused prompt_cache_key: %s", string(clientPayload))
+ }
+ if !bytes.Contains(clientPayload, []byte(`cache-ws-1`)) {
+ t.Fatalf("client payload missing original prompt_cache_key: %s", string(clientPayload))
+ }
+
+ rawSSE := []byte(`data: {"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1"}}`)
+ upstreamSSE := applyCodexIdentityConfuseResponsePayload(rawSSE, state)
+ if bytes.Contains(upstreamSSE, []byte(`cache-ws-1`)) {
+ t.Fatalf("upstream SSE still contains original prompt_cache_key: %s", string(upstreamSSE))
+ }
+ clientSSE := applyCodexIdentityExposeResponsePayload(upstreamSSE, state)
+ if !bytes.Contains(clientSSE, []byte(`cache-ws-1`)) || bytes.Contains(clientSSE, []byte(state.promptCacheKey)) {
+ t.Fatalf("client SSE prompt_cache_key was not restored: %s", string(clientSSE))
+ }
+}
+
func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) {
auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"account_id": "acct-1"}}
diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go
index beda1be854f..023b2f0be79 100644
--- a/internal/watcher/diff/config_diff.go
+++ b/internal/watcher/diff/config_diff.go
@@ -93,6 +93,10 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
changes = append(changes, fmt.Sprintf("quota-exceeded.antigravity-credits: %t -> %t", oldCfg.QuotaExceeded.AntigravityCredits, newCfg.QuotaExceeded.AntigravityCredits))
}
+ if oldCfg.Codex.IdentityConfuse != newCfg.Codex.IdentityConfuse {
+ changes = append(changes, fmt.Sprintf("codex.identity-confuse: %t -> %t", oldCfg.Codex.IdentityConfuse, newCfg.Codex.IdentityConfuse))
+ }
+
if oldCfg.Routing.Strategy != newCfg.Routing.Strategy {
changes = append(changes, fmt.Sprintf("routing.strategy: %s -> %s", oldCfg.Routing.Strategy, newCfg.Routing.Strategy))
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
index 22219a8ab9a..6e1e7a6738f 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
@@ -147,9 +147,6 @@ func websocketDownstreamSessionKey(req *http.Request) string {
return sessionID
}
}
- if sessionID := strings.TrimSpace(req.Header.Get("Session_id")); sessionID != "" {
- return sessionID
- }
return ""
}
diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go
index 5e23c46f552..3cf11cf148f 100644
--- a/sdk/cliproxy/auth/selector.go
+++ b/sdk/cliproxy/auth/selector.go
@@ -471,12 +471,11 @@ func NewSessionAffinitySelectorWithConfig(cfg SessionAffinityConfig) *SessionAff
// Priority for session ID extraction:
// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority
// 2. X-Session-ID header
-// 3. Session_id header (Codex)
-// 4. X-Amp-Thread-Id header (Amp CLI thread ID)
-// 5. X-Client-Request-Id header (PI)
-// 6. metadata.user_id (non-Claude Code format)
-// 7. conversation_id field in request body
-// 8. Stable hash from first few messages content (fallback)
+// 3. X-Amp-Thread-Id header (Amp CLI thread ID)
+// 4. X-Client-Request-Id header (PI)
+// 5. metadata.user_id (non-Claude Code format)
+// 6. conversation_id field in request body
+// 7. Stable hash from first few messages content (fallback)
//
// Note: The cache key includes provider, session ID, and model to handle cases where
// a session uses multiple models (e.g., gemini-2.5-pro and gemini-3-flash-preview)
@@ -573,12 +572,11 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) {
// Priority order:
// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients
// 2. X-Session-ID header
-// 3. Session_id header (Codex)
-// 4. X-Amp-Thread-Id header (Amp CLI thread ID)
-// 5. X-Client-Request-Id header (PI)
-// 6. metadata.user_id (non-Claude Code format)
-// 7. conversation_id field in request body
-// 8. Stable hash from first few messages content (fallback)
+// 3. X-Amp-Thread-Id header (Amp CLI thread ID)
+// 4. X-Client-Request-Id header (PI)
+// 5. metadata.user_id (non-Claude Code format)
+// 6. conversation_id field in request body
+// 7. Stable hash from first few messages content (fallback)
func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string {
primary, _ := extractSessionIDs(headers, payload, metadata)
return primary
@@ -614,21 +612,14 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string]
}
}
- // 3. Session_id header (Codex)
- if headers != nil {
- if sid := headers.Get("Session_id"); sid != "" {
- return "codex:" + sid, ""
- }
- }
-
- // 4. X-Amp-Thread-Id header (Amp CLI thread ID)
+ // 3. X-Amp-Thread-Id header (Amp CLI thread ID)
if headers != nil {
if tid := headers.Get("X-Amp-Thread-Id"); tid != "" {
return "amp:" + tid, ""
}
}
- // 5. X-Client-Request-Id header (PI)
+ // 4. X-Client-Request-Id header (PI)
if headers != nil {
if rid := headers.Get("X-Client-Request-Id"); rid != "" {
return "clientreq:" + rid, ""
@@ -639,18 +630,18 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string]
return "", ""
}
- // 6. metadata.user_id (non-Claude Code format)
+ // 5. metadata.user_id (non-Claude Code format)
userID := gjson.GetBytes(payload, "metadata.user_id").String()
if userID != "" {
return "user:" + userID, ""
}
- // 7. conversation_id field
+ // 6. conversation_id field
if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" {
return "conv:" + convID, ""
}
- // 8. Hash-based fallback from message content
+ // 7. Hash-based fallback from message content
return extractMessageHashIDs(payload)
}
diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go
index 99231bdf78d..0e2eb9521e0 100644
--- a/sdk/cliproxy/auth/selector_test.go
+++ b/sdk/cliproxy/auth/selector_test.go
@@ -776,16 +776,15 @@ func TestExtractSessionID_Headers(t *testing.T) {
}
}
-func TestExtractSessionID_CodexSessionIDHeader(t *testing.T) {
+func TestExtractSessionID_IgnoresCodexSessionIDHeader(t *testing.T) {
t.Parallel()
headers := make(http.Header)
headers.Set("Session_id", "codex-session-123")
got := ExtractSessionID(headers, nil, nil)
- want := "codex:codex-session-123"
- if got != want {
- t.Errorf("ExtractSessionID() with Session_id = %q, want %q", got, want)
+ if got != "" {
+ t.Errorf("ExtractSessionID() with deprecated Session_id = %q, want empty", got)
}
}
@@ -802,7 +801,7 @@ func TestExtractSessionID_ClientRequestIDHeader(t *testing.T) {
}
}
-func TestExtractSessionID_CodexSessionIDPriorityOverClientRequestID(t *testing.T) {
+func TestExtractSessionID_ClientRequestIDIgnoresDeprecatedCodexSessionID(t *testing.T) {
t.Parallel()
headers := make(http.Header)
@@ -810,9 +809,9 @@ func TestExtractSessionID_CodexSessionIDPriorityOverClientRequestID(t *testing.T
headers.Set("Session_id", "codex-session-456")
got := ExtractSessionID(headers, nil, nil)
- want := "codex:codex-session-456"
+ want := "clientreq:pi-session-123"
if got != want {
- t.Errorf("ExtractSessionID() = %q, want %q (Session_id should take priority over X-Client-Request-Id)", got, want)
+ t.Errorf("ExtractSessionID() = %q, want %q (deprecated Session_id should be ignored)", got, want)
}
}
From 303685c230bf76e69b2e563fdfa0005a8be4beaa Mon Sep 17 00:00:00 2001
From: lamtran
Date: Sun, 31 May 2026 22:49:23 +0700
Subject: [PATCH 091/248] fix(executor/xai): drop orphaned tool_choice when
Claude tools array is empty
When Claude Code sends a stop-hook evaluator request (or any request
without tools), the payload includes "tools": [] (empty array). The
claude->codex translator unconditionally emits tools: [] + tool_choice:
"auto" + parallel_tool_calls: true into the Codex Responses shape.
When that payload is routed to xAI, the upstream rejects with HTTP 400:
"A tool_choice was set on the request but no tools were specified."
Fix entirely in the xAI executor (translator package is policy-locked):
add normalizeXAIToolChoiceForTools() after normalizeXAITools() to drop
tool_choice and parallel_tool_calls whenever tools end up absent or
empty (covering both the empty-from-source case and the
all-filtered-out case where every tool was an unsupported type such as
tool_search or image_generation).
Per code-review feedback: always remove parallel_tool_calls when tools
are missing (not gated on tool_choice presence) and existence-check
each key before sjson delete to avoid unnecessary JSON parse/copy.
Verification:
- go build -o test-output ./cmd/server
- go test ./internal/runtime/executor/... -count=1
- 5 new regression tests cover empty / missing / present / orphaned
parallel_tool_calls / no-op-when-both-absent.
---
internal/runtime/executor/xai_executor.go | 23 ++++++++
.../runtime/executor/xai_executor_test.go | 54 +++++++++++++++++++
2 files changed, 77 insertions(+)
diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go
index cb42f93935c..5cb27949854 100644
--- a/internal/runtime/executor/xai_executor.go
+++ b/internal/runtime/executor/xai_executor.go
@@ -506,6 +506,7 @@ func (e *XAIExecutor) prepareResponsesRequest(ctx context.Context, req cliproxye
body, _ = sjson.DeleteBytes(body, "safety_identifier")
body, _ = sjson.DeleteBytes(body, "stream_options")
body = normalizeXAITools(body)
+ body = normalizeXAIToolChoiceForTools(body)
body = normalizeXAIInputReasoningItems(body)
body = normalizeCodexInstructions(body)
body = sanitizeXAIResponsesBody(body, baseModel)
@@ -715,6 +716,28 @@ func normalizeXAITools(body []byte) []byte {
return updated
}
+// normalizeXAIToolChoiceForTools drops tool_choice and parallel_tool_calls
+// when tools are absent or empty (including after normalizeXAITools filtering).
+// xAI rejects payloads that include tool_choice without any tools defined.
+// Existence checks avoid unnecessary sjson parse/copy passes.
+func normalizeXAIToolChoiceForTools(body []byte) []byte {
+ tools := gjson.GetBytes(body, "tools")
+ hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0
+ if hasTools {
+ return body
+ }
+ if tools.Exists() {
+ body, _ = sjson.DeleteBytes(body, "tools")
+ }
+ if gjson.GetBytes(body, "tool_choice").Exists() {
+ body, _ = sjson.DeleteBytes(body, "tool_choice")
+ }
+ if gjson.GetBytes(body, "parallel_tool_calls").Exists() {
+ body, _ = sjson.DeleteBytes(body, "parallel_tool_calls")
+ }
+ return body
+}
+
func normalizeXAITool(tool gjson.Result) ([]byte, bool, bool) {
toolType := tool.Get("type").String()
changed := false
diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go
index 5579cd904d3..e8c11cf6ed0 100644
--- a/internal/runtime/executor/xai_executor_test.go
+++ b/internal/runtime/executor/xai_executor_test.go
@@ -592,3 +592,57 @@ func TestXAIExecutorExecuteVideosUsesNativeEndpointFromRequestPath(t *testing.T)
})
}
}
+
+func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsEmpty(t *testing.T) {
+ body := []byte(`{"model":"grok-4","tools":[],"tool_choice":"auto","parallel_tool_calls":true,"input":"hi"}`)
+ out := normalizeXAIToolChoiceForTools(body)
+
+ if gjson.GetBytes(out, "tools").Exists() {
+ t.Fatalf("empty tools should be removed: %s", string(out))
+ }
+ if gjson.GetBytes(out, "tool_choice").Exists() {
+ t.Fatalf("tool_choice should be removed when tools empty: %s", string(out))
+ }
+ if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
+ t.Fatalf("parallel_tool_calls should be removed when tools empty: %s", string(out))
+ }
+}
+
+func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsMissing(t *testing.T) {
+ body := []byte(`{"model":"grok-4","tool_choice":"auto","input":"hi"}`)
+ out := normalizeXAIToolChoiceForTools(body)
+
+ if gjson.GetBytes(out, "tool_choice").Exists() {
+ t.Fatalf("tool_choice should be removed when tools missing: %s", string(out))
+ }
+}
+
+func TestNormalizeXAIToolChoiceForTools_DropsOrphanedParallelToolCalls(t *testing.T) {
+ body := []byte(`{"model":"grok-4","parallel_tool_calls":true,"input":"hi"}`)
+ out := normalizeXAIToolChoiceForTools(body)
+
+ if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
+ t.Fatalf("parallel_tool_calls should be removed when tools missing even without tool_choice: %s", string(out))
+ }
+}
+
+func TestNormalizeXAIToolChoiceForTools_KeepsWhenToolsPresent(t *testing.T) {
+ body := []byte(`{"model":"grok-4","tools":[{"type":"function","name":"Bash"}],"tool_choice":"auto","input":"hi"}`)
+ out := normalizeXAIToolChoiceForTools(body)
+
+ if !gjson.GetBytes(out, "tools").Exists() {
+ t.Fatalf("tools should be kept: %s", string(out))
+ }
+ if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" {
+ t.Fatalf("tool_choice = %q, want auto: %s", got, string(out))
+ }
+}
+
+func TestNormalizeXAIToolChoiceForTools_NoOpWhenBothAbsent(t *testing.T) {
+ body := []byte(`{"model":"grok-4","input":"hi"}`)
+ out := normalizeXAIToolChoiceForTools(body)
+
+ if gjson.GetBytes(out, "tool_choice").Exists() {
+ t.Fatalf("tool_choice should not appear: %s", string(out))
+ }
+}
From bbcdaab79d852d3d70dce30b10829d4afcd4d218 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Mon, 1 Jun 2026 00:50:46 +0800
Subject: [PATCH 092/248] feat(executor): enhance Codex identity obfuscation
with turn and window metadata handling
- Modified `applyCodexIdentityConfuse*` functions to include `turn_id` and `window_id` in metadata transformations.
- Updated test cases to validate the inclusion and restoration of these fields.
- Removed deprecated `Conversation_id` header support and related logic for cleaner implementation.
---
internal/runtime/executor/codex_executor.go | 103 +++++++++++++-----
.../executor/codex_executor_cache_test.go | 29 +++--
.../runtime/executor/codex_openai_images.go | 4 +-
.../executor/codex_websockets_executor.go | 34 +++---
.../codex_websockets_executor_test.go | 68 +++++++-----
5 files changed, 155 insertions(+), 83 deletions(-)
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index c8a9246e4a1..c7dd2d3ec11 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -30,8 +30,8 @@ import (
)
const (
- codexUserAgent = "codex_cli_rs/0.118.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9"
- codexOriginator = "codex_cli_rs"
+ codexUserAgent = "codex-tui/0.135.0 (Mac OS 26.5.0; arm64) iTerm.app/3.6.10 (codex-tui; 0.135.0)"
+ codexOriginator = "codex-tui"
codexDefaultImageToolModel = "gpt-image-2"
)
@@ -304,7 +304,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
return resp, err
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
- applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -467,7 +467,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
return resp, err
}
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
- applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -575,7 +575,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
return nil, err
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
- applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -881,8 +881,16 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
}
type codexIdentityConfuseState struct {
+ enabled bool
+ authID string
originalPromptCacheKey string
promptCacheKey string
+ turnIDs []codexIdentityReplacement
+}
+
+type codexIdentityReplacement struct {
+ original string
+ confused string
}
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte) (*http.Request, []byte, codexIdentityConfuseState, error) {
@@ -931,7 +939,7 @@ func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth,
return rawJSON, codexIdentityConfuseState{}
}
- state := codexIdentityConfuseState{}
+ state := codexIdentityConfuseState{enabled: true, authID: strings.TrimSpace(auth.ID)}
if promptCacheKey := strings.TrimSpace(gjson.GetBytes(userPayload, "prompt_cache_key").String()); promptCacheKey != "" {
state.originalPromptCacheKey = promptCacheKey
state.promptCacheKey = codexIdentityConfuseUUID(auth.ID, "prompt-cache", promptCacheKey)
@@ -940,10 +948,10 @@ func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth,
if installationID := strings.TrimSpace(gjson.GetBytes(userPayload, "client_metadata.x-codex-installation-id").String()); installationID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-installation-id", codexIdentityConfuseUUID(auth.ID, "installation", installationID))
}
+ if turnMetadata := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
+ rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-turn-metadata", applyCodexTurnMetadataIdentityConfuse(turnMetadata, &state))
+ }
if state.promptCacheKey != "" {
- if turnMetadata := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
- rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-turn-metadata", applyCodexTurnMetadataIdentityConfuse(turnMetadata, state))
- }
if windowID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-window-id").String()); windowID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-window-id", state.promptCacheKey+":0")
}
@@ -952,38 +960,76 @@ func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth,
return rawJSON, state
}
-func applyCodexIdentityConfuseHeaders(headers http.Header, state codexIdentityConfuseState) {
- if headers == nil || state.promptCacheKey == "" {
+func applyCodexIdentityConfuseHeaders(headers http.Header, state *codexIdentityConfuseState) {
+ if headers == nil {
+ return
+ }
+ defer deleteDeprecatedCodexConversationHeader(headers)
+ if state == nil || !state.enabled {
+ return
+ }
+
+ if rawTurnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); rawTurnMetadata != "" {
+ headers.Set("X-Codex-Turn-Metadata", applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata, state))
+ }
+ if state.promptCacheKey == "" {
return
}
setHeaderCasePreserved(headers, "Session-Id", state.promptCacheKey)
- headers.Set("Conversation_id", state.promptCacheKey)
headers.Set("X-Client-Request-Id", state.promptCacheKey)
headers.Set("Thread-Id", state.promptCacheKey)
headers.Set("X-Codex-Window-Id", state.promptCacheKey+":0")
-
- if rawTurnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); rawTurnMetadata != "" {
- headers.Set("X-Codex-Turn-Metadata", applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata, state))
- }
}
-func applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata string, state codexIdentityConfuseState) string {
+func applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata string, state *codexIdentityConfuseState) string {
updatedTurnMetadata := rawTurnMetadata
- if gjson.Get(rawTurnMetadata, "prompt_cache_key").Exists() {
+ if state == nil || !state.enabled {
+ return updatedTurnMetadata
+ }
+ if state.promptCacheKey != "" && gjson.Get(rawTurnMetadata, "prompt_cache_key").Exists() {
updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "prompt_cache_key", state.promptCacheKey)
- } else if state.originalPromptCacheKey != "" {
+ } else if state.promptCacheKey != "" && state.originalPromptCacheKey != "" {
updatedTurnMetadata = strings.ReplaceAll(updatedTurnMetadata, state.originalPromptCacheKey, state.promptCacheKey)
}
+ if turnID := strings.TrimSpace(gjson.Get(rawTurnMetadata, "turn_id").String()); turnID != "" {
+ updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "turn_id", state.confuseTurnID(turnID))
+ }
+ if state.promptCacheKey != "" && gjson.Get(rawTurnMetadata, "window_id").Exists() {
+ updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "window_id", state.promptCacheKey+":0")
+ }
return updatedTurnMetadata
}
func applyCodexIdentityConfuseResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
- return replaceCodexIdentityResponsePayload(payload, state.originalPromptCacheKey, state.promptCacheKey)
+ payload = replaceCodexIdentityResponsePayload(payload, state.originalPromptCacheKey, state.promptCacheKey)
+ for _, turnID := range state.turnIDs {
+ payload = replaceCodexIdentityResponsePayload(payload, turnID.original, turnID.confused)
+ }
+ return payload
}
func applyCodexIdentityExposeResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
- return replaceCodexIdentityResponsePayload(payload, state.promptCacheKey, state.originalPromptCacheKey)
+ payload = replaceCodexIdentityResponsePayload(payload, state.promptCacheKey, state.originalPromptCacheKey)
+ for _, turnID := range state.turnIDs {
+ payload = replaceCodexIdentityResponsePayload(payload, turnID.confused, turnID.original)
+ }
+ return payload
+}
+
+func (state *codexIdentityConfuseState) confuseTurnID(turnID string) string {
+ turnID = strings.TrimSpace(turnID)
+ if state == nil || !state.enabled || strings.TrimSpace(state.authID) == "" || turnID == "" {
+ return turnID
+ }
+ for _, replacement := range state.turnIDs {
+ if replacement.original == turnID || replacement.confused == turnID {
+ return replacement.confused
+ }
+ }
+ confusedTurnID := codexIdentityConfuseUUID(state.authID, "turn", turnID)
+ state.turnIDs = append(state.turnIDs, codexIdentityReplacement{original: turnID, confused: confusedTurnID})
+ return confusedTurnID
}
func replaceCodexIdentityResponsePayload(payload []byte, from string, to string) []byte {
@@ -1044,18 +1090,19 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
} else if !isAPIKey {
r.Header.Set("Originator", codexOriginator)
}
- if !isAPIKey {
- if auth != nil && auth.Metadata != nil {
- if accountID, ok := auth.Metadata["account_id"].(string); ok {
- r.Header.Set("Chatgpt-Account-Id", accountID)
- }
- }
- }
+ // if !isAPIKey {
+ // if auth != nil && auth.Metadata != nil {
+ // if accountID, ok := auth.Metadata["account_id"].(string); ok {
+ // r.Header.Set("Chatgpt-Account-Id", accountID)
+ // }
+ // }
+ // }
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(r, attrs)
+ deleteDeprecatedCodexConversationHeader(r.Header)
}
func newCodexStatusErr(statusCode int, body []byte) statusErr {
diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go
index 2cf2b373bae..29d244e68f7 100644
--- a/internal/runtime/executor/codex_executor_cache_test.go
+++ b/internal/runtime/executor/codex_executor_cache_test.go
@@ -69,7 +69,7 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
ginCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
- ginCtx.Request.Header.Set("X-Codex-Turn-Metadata", `{"prompt_cache_key":"cache-1","turn_id":"turn-1"}`)
+ ginCtx.Request.Header.Set("X-Codex-Turn-Metadata", `{"prompt_cache_key":"cache-1","turn_id":"turn-1","window_id":"cache-1:0"}`)
ginCtx.Request.Header.Set("X-Client-Request-Id", "client-request-1")
ctx := context.WithValue(context.Background(), "gin", ginCtx)
@@ -78,7 +78,7 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
Codex: config.CodexConfig{IdentityConfuse: true},
}}
auth := &cliproxyauth.Auth{ID: "auth-1", Provider: "codex"}
- rawJSON := []byte(`{"model":"gpt-5-codex","stream":true,"client_metadata":{"x-codex-turn-metadata":"{\"prompt_cache_key\":\"cache-1\",\"turn_id\":\"turn-1\"}","x-codex-window-id":"cache-1:0"}}`)
+ rawJSON := []byte(`{"model":"gpt-5-codex","stream":true,"client_metadata":{"x-codex-turn-metadata":"{\"prompt_cache_key\":\"cache-1\",\"turn_id\":\"turn-1\",\"window_id\":\"cache-1:0\"}","x-codex-window-id":"cache-1:0"}}`)
req := cliproxyexecutor.Request{
Model: "gpt-5-codex",
Payload: []byte(`{"model":"gpt-5-codex","prompt_cache_key":"cache-1","client_metadata":{"x-codex-installation-id":"install-1"}}`),
@@ -90,9 +90,10 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
t.Fatalf("cacheHelper error: %v", err)
}
applyCodexHeaders(httpReq, auth, "oauth-token", true, executor.cfg)
- applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
expectedPromptCacheKey := codexIdentityConfuseUUID("auth-1", "prompt-cache", "cache-1")
+ expectedTurnID := codexIdentityConfuseUUID("auth-1", "turn", "turn-1")
if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
}
@@ -100,8 +101,15 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
if gotID := gjson.GetBytes(body, "client_metadata.x-codex-installation-id").String(); gotID != expectedInstallationID {
t.Fatalf("installation id = %q, want %q", gotID, expectedInstallationID)
}
- if gotMetadata := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String(); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` {
- t.Fatalf("client_metadata.x-codex-turn-metadata = %s", gotMetadata)
+ gotBodyMetadata := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String()
+ if gotMetadataPromptCacheKey := gjson.Get(gotBodyMetadata, "prompt_cache_key").String(); gotMetadataPromptCacheKey != expectedPromptCacheKey {
+ t.Fatalf("client_metadata.x-codex-turn-metadata.prompt_cache_key = %q, want %q", gotMetadataPromptCacheKey, expectedPromptCacheKey)
+ }
+ if gotMetadataTurnID := gjson.Get(gotBodyMetadata, "turn_id").String(); gotMetadataTurnID != expectedTurnID {
+ t.Fatalf("client_metadata.x-codex-turn-metadata.turn_id = %q, want %q", gotMetadataTurnID, expectedTurnID)
+ }
+ if gotMetadataWindowID := gjson.Get(gotBodyMetadata, "window_id").String(); gotMetadataWindowID != expectedPromptCacheKey+":0" {
+ t.Fatalf("client_metadata.x-codex-turn-metadata.window_id = %q, want %q", gotMetadataWindowID, expectedPromptCacheKey+":0")
}
if gotWindowID := gjson.GetBytes(body, "client_metadata.x-codex-window-id").String(); gotWindowID != expectedPromptCacheKey+":0" {
t.Fatalf("client_metadata.x-codex-window-id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0")
@@ -117,8 +125,15 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
if gotWindow := httpReq.Header.Get("X-Codex-Window-Id"); gotWindow != expectedPromptCacheKey+":0" {
t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindow, expectedPromptCacheKey+":0")
}
- if gotMetadata := httpReq.Header.Get("X-Codex-Turn-Metadata"); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` {
- t.Fatalf("X-Codex-Turn-Metadata = %s", gotMetadata)
+ gotHeaderMetadata := httpReq.Header.Get("X-Codex-Turn-Metadata")
+ if gotMetadataPromptCacheKey := gjson.Get(gotHeaderMetadata, "prompt_cache_key").String(); gotMetadataPromptCacheKey != expectedPromptCacheKey {
+ t.Fatalf("X-Codex-Turn-Metadata.prompt_cache_key = %q, want %q", gotMetadataPromptCacheKey, expectedPromptCacheKey)
+ }
+ if gotMetadataTurnID := gjson.Get(gotHeaderMetadata, "turn_id").String(); gotMetadataTurnID != expectedTurnID {
+ t.Fatalf("X-Codex-Turn-Metadata.turn_id = %q, want %q", gotMetadataTurnID, expectedTurnID)
+ }
+ if gotMetadataWindowID := gjson.Get(gotHeaderMetadata, "window_id").String(); gotMetadataWindowID != expectedPromptCacheKey+":0" {
+ t.Fatalf("X-Codex-Turn-Metadata.window_id = %q, want %q", gotMetadataWindowID, expectedPromptCacheKey+":0")
}
}
diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go
index 90fe4ad3e7e..ffece021961 100644
--- a/internal/runtime/executor/codex_openai_images.go
+++ b/internal/runtime/executor/codex_openai_images.go
@@ -105,7 +105,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
return resp, errCache
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
- applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -198,7 +198,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
return nil, errCache
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
- applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
+ applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index 2680e729b73..ecbf2171052 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -224,12 +224,9 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
clientBody := body
var identityState codexIdentityConfuseState
upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
- if identityState.promptCacheKey != "" {
- wsHeaders.Set("Conversation_id", identityState.promptCacheKey)
- }
reporter.SetTranslatedReasoningEffort(clientBody, to.String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
- applyCodexIdentityConfuseHeaders(wsHeaders, identityState)
+ applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)
var authID, authLabel, authType, authValue string
if auth != nil {
@@ -442,12 +439,9 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
clientBody := body
var identityState codexIdentityConfuseState
upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, body)
- if identityState.promptCacheKey != "" {
- wsHeaders.Set("Conversation_id", identityState.promptCacheKey)
- }
reporter.SetTranslatedReasoningEffort(clientBody, to.String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
- applyCodexIdentityConfuseHeaders(wsHeaders, identityState)
+ applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)
var authID, authLabel, authType, authValue string
authID = auth.ID
@@ -863,7 +857,6 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
if cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
- headers.Set("Conversation_id", cache.ID)
}
return rawJSON, headers
@@ -909,21 +902,22 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
} else if !isAPIKey {
headers.Set("Originator", codexOriginator)
}
- if !isAPIKey {
- if auth != nil && auth.Metadata != nil {
- if accountID, ok := auth.Metadata["account_id"].(string); ok {
- if trimmed := strings.TrimSpace(accountID); trimmed != "" {
- setHeaderCasePreserved(headers, "ChatGPT-Account-ID", trimmed)
- }
- }
- }
- }
+ // if !isAPIKey {
+ // if auth != nil && auth.Metadata != nil {
+ // if accountID, ok := auth.Metadata["account_id"].(string); ok {
+ // if trimmed := strings.TrimSpace(accountID); trimmed != "" {
+ // setHeaderCasePreserved(headers, "ChatGPT-Account-ID", trimmed)
+ // }
+ // }
+ // }
+ // }
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs)
+ deleteDeprecatedCodexConversationHeader(headers)
return headers
}
@@ -999,6 +993,10 @@ func deleteHeaderCaseInsensitive(headers http.Header, key string) {
}
}
+func deleteDeprecatedCodexConversationHeader(headers http.Header) {
+ deleteHeaderCaseInsensitive(headers, "Conversation_id")
+}
+
func codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) {
if cfg == nil || auth == nil {
return "", ""
diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go
index 4ea1e87fa8b..a2ef16c2ca1 100644
--- a/internal/runtime/executor/codex_websockets_executor_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_test.go
@@ -341,7 +341,7 @@ func TestApplyCodexWebsocketHeadersPreservesExplicitAPIKeyUserAgent(t *testing.T
}
}
-func TestApplyCodexPromptCacheHeadersSetsLegacyConversationOnly(t *testing.T) {
+func TestApplyCodexPromptCacheHeadersDoesNotSetDeprecatedConversationHeader(t *testing.T) {
req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)}
_, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`))
@@ -349,8 +349,8 @@ func TestApplyCodexPromptCacheHeadersSetsLegacyConversationOnly(t *testing.T) {
if got := headerValueCaseInsensitive(headers, "session_id"); got != "" {
t.Fatalf("session_id = %q, want empty", got)
}
- if got := headers.Get("Conversation_id"); got != "cache-1" {
- t.Fatalf("Conversation_id = %s, want cache-1", got)
+ if got := headers.Get("Conversation_id"); got != "" {
+ t.Fatalf("Conversation_id = %q, want empty", got)
}
}
@@ -367,17 +367,15 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin
body, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`))
body, identityState := applyCodexIdentityConfuseBody(cfg, auth, req.Payload, body)
- if identityState.promptCacheKey != "" {
- headers.Set("Conversation_id", identityState.promptCacheKey)
- }
ctx := contextWithGinHeaders(map[string]string{
- "X-Codex-Turn-Metadata": `{"prompt_cache_key":"cache-ws-1"}`,
+ "X-Codex-Turn-Metadata": `{"prompt_cache_key":"cache-ws-1","turn_id":"turn-ws-1","window_id":"cache-ws-1:0"}`,
"X-Client-Request-Id": "client-request-1",
})
headers = applyCodexWebsocketHeaders(ctx, headers, auth, "oauth-token", cfg)
- applyCodexIdentityConfuseHeaders(headers, identityState)
+ applyCodexIdentityConfuseHeaders(headers, &identityState)
expectedPromptCacheKey := codexIdentityConfuseUUID("auth-ws-1", "prompt-cache", "cache-ws-1")
+ expectedTurnID := codexIdentityConfuseUUID("auth-ws-1", "turn", "turn-ws-1")
if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
}
@@ -390,11 +388,21 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin
if gotThreadID := headers.Get("Thread-Id"); gotThreadID != expectedPromptCacheKey {
t.Fatalf("Thread-Id = %q, want %q", gotThreadID, expectedPromptCacheKey)
}
+ if gotConversation := headers.Get("Conversation_id"); gotConversation != "" {
+ t.Fatalf("Conversation_id = %q, want empty", gotConversation)
+ }
if gotWindowID := headers.Get("X-Codex-Window-Id"); gotWindowID != expectedPromptCacheKey+":0" {
t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0")
}
- if gotMetadata := headers.Get("X-Codex-Turn-Metadata"); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`"}` {
- t.Fatalf("X-Codex-Turn-Metadata = %s", gotMetadata)
+ gotMetadata := headers.Get("X-Codex-Turn-Metadata")
+ if gotMetadataPromptCacheKey := gjson.Get(gotMetadata, "prompt_cache_key").String(); gotMetadataPromptCacheKey != expectedPromptCacheKey {
+ t.Fatalf("X-Codex-Turn-Metadata.prompt_cache_key = %q, want %q", gotMetadataPromptCacheKey, expectedPromptCacheKey)
+ }
+ if gotMetadataTurnID := gjson.Get(gotMetadata, "turn_id").String(); gotMetadataTurnID != expectedTurnID {
+ t.Fatalf("X-Codex-Turn-Metadata.turn_id = %q, want %q", gotMetadataTurnID, expectedTurnID)
+ }
+ if gotMetadataWindowID := gjson.Get(gotMetadata, "window_id").String(); gotMetadataWindowID != expectedPromptCacheKey+":0" {
+ t.Fatalf("X-Codex-Turn-Metadata.window_id = %q, want %q", gotMetadataWindowID, expectedPromptCacheKey+":0")
}
expectedInstallationID := codexIdentityConfuseUUID("auth-ws-1", "installation", "install-ws-1")
if gotInstallationID := gjson.GetBytes(body, "client_metadata.x-codex-installation-id").String(); gotInstallationID != expectedInstallationID {
@@ -404,52 +412,56 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin
func TestCodexIdentityConfuseResponsePayloadHidesUpstreamAndRestoresClient(t *testing.T) {
state := codexIdentityConfuseState{
+ enabled: true,
+ authID: "auth-ws-1",
originalPromptCacheKey: "cache-ws-1",
promptCacheKey: codexIdentityConfuseUUID("auth-ws-1", "prompt-cache", "cache-ws-1"),
}
- rawPayload := []byte(`{"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1"},"prompt_cache_key":"cache-ws-1"}`)
+ expectedTurnID := state.confuseTurnID("turn-ws-1")
+ rawPayload := []byte(`{"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1","turn_id":"turn-ws-1"},"prompt_cache_key":"cache-ws-1","turn_id":"turn-ws-1"}`)
upstreamPayload := applyCodexIdentityConfuseResponsePayload(rawPayload, state)
if bytes.Contains(upstreamPayload, []byte(`cache-ws-1`)) {
t.Fatalf("upstream payload still contains original prompt_cache_key: %s", string(upstreamPayload))
}
+ if bytes.Contains(upstreamPayload, []byte(`turn-ws-1`)) {
+ t.Fatalf("upstream payload still contains original turn_id: %s", string(upstreamPayload))
+ }
if !bytes.Contains(upstreamPayload, []byte(state.promptCacheKey)) {
t.Fatalf("upstream payload missing confused prompt_cache_key: %s", string(upstreamPayload))
}
+ if !bytes.Contains(upstreamPayload, []byte(expectedTurnID)) {
+ t.Fatalf("upstream payload missing confused turn_id: %s", string(upstreamPayload))
+ }
clientPayload := applyCodexIdentityExposeResponsePayload(upstreamPayload, state)
if bytes.Contains(clientPayload, []byte(state.promptCacheKey)) {
t.Fatalf("client payload still contains confused prompt_cache_key: %s", string(clientPayload))
}
+ if bytes.Contains(clientPayload, []byte(expectedTurnID)) {
+ t.Fatalf("client payload still contains confused turn_id: %s", string(clientPayload))
+ }
if !bytes.Contains(clientPayload, []byte(`cache-ws-1`)) {
t.Fatalf("client payload missing original prompt_cache_key: %s", string(clientPayload))
}
+ if !bytes.Contains(clientPayload, []byte(`turn-ws-1`)) {
+ t.Fatalf("client payload missing original turn_id: %s", string(clientPayload))
+ }
- rawSSE := []byte(`data: {"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1"}}`)
+ rawSSE := []byte(`data: {"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1","turn_id":"turn-ws-1"}}`)
upstreamSSE := applyCodexIdentityConfuseResponsePayload(rawSSE, state)
if bytes.Contains(upstreamSSE, []byte(`cache-ws-1`)) {
t.Fatalf("upstream SSE still contains original prompt_cache_key: %s", string(upstreamSSE))
}
+ if bytes.Contains(upstreamSSE, []byte(`turn-ws-1`)) {
+ t.Fatalf("upstream SSE still contains original turn_id: %s", string(upstreamSSE))
+ }
clientSSE := applyCodexIdentityExposeResponsePayload(upstreamSSE, state)
if !bytes.Contains(clientSSE, []byte(`cache-ws-1`)) || bytes.Contains(clientSSE, []byte(state.promptCacheKey)) {
t.Fatalf("client SSE prompt_cache_key was not restored: %s", string(clientSSE))
}
-}
-
-func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) {
- auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"account_id": "acct-1"}}
-
- headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", nil)
-
- if got := headerValueCaseInsensitive(headers, "ChatGPT-Account-ID"); got != "acct-1" {
- t.Fatalf("ChatGPT-Account-ID = %s, want acct-1", got)
- }
- values, ok := headers["ChatGPT-Account-ID"]
- if !ok {
- t.Fatalf("expected exact ChatGPT-Account-ID key, got %#v", headers)
- }
- if len(values) != 1 || values[0] != "acct-1" {
- t.Fatalf("ChatGPT-Account-ID values = %#v, want [acct-1]", values)
+ if !bytes.Contains(clientSSE, []byte(`turn-ws-1`)) || bytes.Contains(clientSSE, []byte(expectedTurnID)) {
+ t.Fatalf("client SSE turn_id was not restored: %s", string(clientSSE))
}
}
From ac1360f479b8a70c5db2571790a71ecd7f54ff5a Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Mon, 1 Jun 2026 02:56:15 +0800
Subject: [PATCH 093/248] feat(models): add support for
`grok-imagine-video-1.5-preview` model
- Introduced `grok-imagine-video-1.5-preview` as a new XAI video model.
- Updated handlers, registry, and validation logic to include support for the new model.
- Enhanced test coverage to validate integration and functionality of the preview model.
---
internal/api/server_test.go | 10 ++++---
internal/registry/model_definitions.go | 24 +++++++++++----
.../handlers/openai/codex_client_models.go | 2 +-
.../handlers/openai/openai_videos_handlers.go | 13 +++++---
.../openai/openai_videos_handlers_test.go | 30 ++++++++++++++++++-
5 files changed, 64 insertions(+), 15 deletions(-)
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 9f426686f11..155f2fa40c7 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -269,6 +269,7 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
{ID: "gpt-image-2", Object: "model", OwnedBy: "openai", Type: "openai"},
{ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", Type: "openai"},
{ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", Type: "openai"},
+ {ID: "grok-imagine-video-1.5-preview", Object: "model", OwnedBy: "xai", Type: "openai"},
})
t.Cleanup(func() {
modelRegistry.UnregisterClient(clientID)
@@ -355,10 +356,11 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
}
hiddenModels := map[string]bool{
- "grok-imagine-image-quality": false,
- "gpt-image-2": false,
- "grok-imagine-image": false,
- "grok-imagine-video": false,
+ "grok-imagine-image-quality": false,
+ "gpt-image-2": false,
+ "grok-imagine-image": false,
+ "grok-imagine-video": false,
+ "grok-imagine-video-1.5-preview": false,
}
for _, model := range resp.Models {
slug, _ := model["slug"].(string)
diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go
index f160325f65b..22fd15f3a79 100644
--- a/internal/registry/model_definitions.go
+++ b/internal/registry/model_definitions.go
@@ -7,10 +7,11 @@ import (
)
const (
- codexBuiltinImageModelID = "gpt-image-2"
- xaiBuiltinImageModelID = "grok-imagine-image"
- xaiBuiltinImageQualityModelID = "grok-imagine-image-quality"
- xaiBuiltinVideoModelID = "grok-imagine-video"
+ codexBuiltinImageModelID = "gpt-image-2"
+ xaiBuiltinImageModelID = "grok-imagine-image"
+ xaiBuiltinImageQualityModelID = "grok-imagine-image-quality"
+ xaiBuiltinVideoModelID = "grok-imagine-video"
+ xaiBuiltinVideo15PreviewModelID = "grok-imagine-video-1.5-preview"
)
// staticModelsJSON mirrors the top-level structure of models.json.
@@ -99,7 +100,7 @@ func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo {
// WithXAIBuiltins injects hard-coded xAI image/video model definitions that should
// not depend on remote models.json updates.
func WithXAIBuiltins(models []*ModelInfo) []*ModelInfo {
- return upsertModelInfos(models, xaiBuiltinImageModelInfo(), xaiBuiltinImageQualityModelInfo(), xaiBuiltinVideoModelInfo())
+ return upsertModelInfos(models, xaiBuiltinImageModelInfo(), xaiBuiltinImageQualityModelInfo(), xaiBuiltinVideoModelInfo(), xaiBuiltinVideo15PreviewModelInfo())
}
func codexBuiltinImageModelInfo() *ModelInfo {
@@ -153,6 +154,19 @@ func xaiBuiltinVideoModelInfo() *ModelInfo {
}
}
+func xaiBuiltinVideo15PreviewModelInfo() *ModelInfo {
+ return &ModelInfo{
+ ID: xaiBuiltinVideo15PreviewModelID,
+ Object: "model",
+ Created: 1735689600, // 2025-01-01
+ OwnedBy: "xai",
+ Type: "xai",
+ DisplayName: "Grok Imagine Video 1.5 Preview",
+ Name: xaiBuiltinVideo15PreviewModelID,
+ Description: "xAI Grok preview video generation model.",
+ }
+}
+
func upsertModelInfos(models []*ModelInfo, extras ...*ModelInfo) []*ModelInfo {
if len(extras) == 0 {
return models
diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go
index 5f9a254ee7e..cc894468be2 100644
--- a/sdk/api/handlers/openai/codex_client_models.go
+++ b/sdk/api/handlers/openai/codex_client_models.go
@@ -151,7 +151,7 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st
func applyCodexClientVisibilityOverride(entry map[string]any, id string) {
switch strings.TrimSpace(id) {
- case "grok-imagine-image-quality", "gpt-image-2", "grok-imagine-image", "grok-imagine-video":
+ case "grok-imagine-image-quality", "gpt-image-2", "grok-imagine-image", "grok-imagine-video", "grok-imagine-video-1.5-preview":
entry["visibility"] = "hide"
}
}
diff --git a/sdk/api/handlers/openai/openai_videos_handlers.go b/sdk/api/handlers/openai/openai_videos_handlers.go
index 15e69a68969..2319c1e86ac 100644
--- a/sdk/api/handlers/openai/openai_videos_handlers.go
+++ b/sdk/api/handlers/openai/openai_videos_handlers.go
@@ -22,6 +22,7 @@ const (
xaiVideosEditsAPI = "/v1/videos/edits"
xaiVideosExtensionsAPI = "/v1/videos/extensions"
defaultXAIVideosModel = "grok-imagine-video"
+ xaiVideos15PreviewModel = "grok-imagine-video-1.5-preview"
xaiVideosHandlerType = "openai-video"
defaultVideosSeconds = "4"
defaultVideosSize = "720x1280"
@@ -45,7 +46,7 @@ func videosModelBase(model string) string {
func isXAIVideosModel(model string) bool {
prefix, baseModel := imagesModelParts(model)
baseModel = strings.ToLower(strings.TrimSpace(baseModel))
- if baseModel != defaultXAIVideosModel {
+ if baseModel != defaultXAIVideosModel && baseModel != xaiVideos15PreviewModel {
return false
}
@@ -86,8 +87,11 @@ func rejectUnsupportedNativeVideosModel(c *gin.Context, model string) bool {
}
func canonicalXAIVideosModel(model string) string {
- if videosModelBase(model) == defaultXAIVideosModel {
+ switch videosModelBase(model) {
+ case defaultXAIVideosModel:
return defaultXAIVideosModel
+ case xaiVideos15PreviewModel:
+ return xaiVideos15PreviewModel
}
return defaultXAIVideosModel
}
@@ -190,8 +194,9 @@ func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideo
seconds = "10"
}
+ videoModel := canonicalXAIVideosModel(model)
req := []byte(`{}`)
- req, _ = sjson.SetBytes(req, "model", canonicalXAIVideosModel(model))
+ req, _ = sjson.SetBytes(req, "model", videoModel)
req, _ = sjson.SetBytes(req, "prompt", prompt)
req, _ = sjson.SetRawBytes(req, "duration", []byte(strconv.FormatInt(duration, 10)))
req, _ = sjson.SetBytes(req, "aspect_ratio", aspectRatio)
@@ -204,7 +209,7 @@ func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideo
}
meta := xaiVideoCreateMetadata{
- Model: defaultXAIVideosModel,
+ Model: videoModel,
Prompt: prompt,
Seconds: seconds,
Size: size,
diff --git a/sdk/api/handlers/openai/openai_videos_handlers_test.go b/sdk/api/handlers/openai/openai_videos_handlers_test.go
index d4fed8b41c7..5e4568b4ca1 100644
--- a/sdk/api/handlers/openai/openai_videos_handlers_test.go
+++ b/sdk/api/handlers/openai/openai_videos_handlers_test.go
@@ -33,7 +33,16 @@ func performVideosEndpointRequest(t *testing.T, method string, endpointPath stri
}
func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) {
- for _, model := range []string{"grok-imagine-video", "xai/grok-imagine-video", "x-ai/grok-imagine-video", "grok/grok-imagine-video"} {
+ for _, model := range []string{
+ "grok-imagine-video",
+ "xai/grok-imagine-video",
+ "x-ai/grok-imagine-video",
+ "grok/grok-imagine-video",
+ "grok-imagine-video-1.5-preview",
+ "xai/grok-imagine-video-1.5-preview",
+ "x-ai/grok-imagine-video-1.5-preview",
+ "grok/grok-imagine-video-1.5-preview",
+ } {
if !isSupportedVideosModel(model) {
t.Fatalf("expected %s to be supported", model)
}
@@ -44,6 +53,9 @@ func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) {
if isSupportedVideosModel("codex/grok-imagine-video") {
t.Fatal("expected codex/grok-imagine-video to be rejected")
}
+ if isSupportedVideosModel("codex/grok-imagine-video-1.5-preview") {
+ t.Fatal("expected codex/grok-imagine-video-1.5-preview to be rejected")
+ }
}
func TestBuildXAIVideosCreateRequest(t *testing.T) {
@@ -77,6 +89,22 @@ func TestBuildXAIVideosCreateRequest(t *testing.T) {
}
}
+func TestBuildXAIVideosCreateRequestAllowsPreviewModel(t *testing.T) {
+ rawJSON := []byte(`{"model":"xai/grok-imagine-video-1.5-preview","prompt":"a cat playing piano","seconds":"8"}`)
+
+ req, meta, err := buildXAIVideosCreateRequest(rawJSON, "xai/grok-imagine-video-1.5-preview")
+ if err != nil {
+ t.Fatalf("buildXAIVideosCreateRequest() error = %v", err)
+ }
+
+ if got := gjson.GetBytes(req, "model").String(); got != xaiVideos15PreviewModel {
+ t.Fatalf("model = %q, want %s", got, xaiVideos15PreviewModel)
+ }
+ if meta.Model != xaiVideos15PreviewModel {
+ t.Fatalf("meta model = %q, want %s", meta.Model, xaiVideos15PreviewModel)
+ }
+}
+
func TestBuildXAIVideosCreateRequestAllowsCustomSeconds(t *testing.T) {
rawJSON := []byte(`{"model":"grok-imagine-video","prompt":"a cat playing piano","seconds":"6"}`)
From fb4f39d300cac0177c99f618dd6e597035eb3b5d Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Mon, 1 Jun 2026 02:59:31 +0800
Subject: [PATCH 094/248] test(models, executor): add XAI video model test and
fix Codex User-Agent assertions
---
internal/registry/model_definitions_test.go | 18 ++++++++++++++++++
.../executor/codex_websockets_executor_test.go | 8 ++++----
2 files changed, 22 insertions(+), 4 deletions(-)
create mode 100644 internal/registry/model_definitions_test.go
diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go
new file mode 100644
index 00000000000..15e2a167f4f
--- /dev/null
+++ b/internal/registry/model_definitions_test.go
@@ -0,0 +1,18 @@
+package registry
+
+import "testing"
+
+func TestWithXAIBuiltinsIncludesVideoPreviewModel(t *testing.T) {
+ models := WithXAIBuiltins(nil)
+
+ for _, model := range models {
+ if model == nil {
+ continue
+ }
+ if model.ID == xaiBuiltinVideo15PreviewModelID {
+ return
+ }
+ }
+
+ t.Fatalf("expected xAI builtin model %s", xaiBuiltinVideo15PreviewModelID)
+}
diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go
index a2ef16c2ca1..ba01d2b66a8 100644
--- a/internal/runtime/executor/codex_websockets_executor_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_test.go
@@ -163,11 +163,11 @@ func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T)
if !strings.HasPrefix(codexUserAgent, codexOriginator+"/") {
t.Fatalf("default Codex User-Agent = %s, want prefix %s/", codexUserAgent, codexOriginator)
}
- if strings.HasPrefix(codexUserAgent, "codex-tui/") {
- t.Fatalf("default Codex User-Agent = %s, must not use stale codex-tui prefix", codexUserAgent)
+ if !strings.HasPrefix(codexUserAgent, "codex-tui/") {
+ t.Fatalf("default Codex User-Agent = %s, want codex-tui prefix", codexUserAgent)
}
- if strings.Contains(codexUserAgent, "(codex-tui;") {
- t.Fatalf("default Codex User-Agent = %s, must not include stale codex-tui suffix", codexUserAgent)
+ if !strings.Contains(codexUserAgent, "(codex-tui;") {
+ t.Fatalf("default Codex User-Agent = %s, want codex-tui suffix", codexUserAgent)
}
if got := headers.Get("Originator"); got != codexOriginator {
t.Fatalf("Originator = %s, want %s", got, codexOriginator)
From 05b972479aeb6885235e8d363cdc8a15be41fd6f Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Mon, 1 Jun 2026 11:27:10 +0800
Subject: [PATCH 095/248] feat(executor): refine session and conversation
header handling for Codex
- Updated session handling to replace `Session_id` and `Conversation_id` headers with new logic ensuring consistent use of `Cache.ID` and prompt keys.
- Restored `Session_id` as a priority extraction source for `ExtractSessionID`.
- Added tests to validate case-sensitive and case-insensitive headers, canonical account header usage, and session key preservation.
- Removed legacy support for deprecated `Conversation_id` header to clean up API.
---
config.example.yaml | 2 +-
internal/config/config.go | 2 +-
internal/runtime/executor/codex_executor.go | 29 ++++++++----
.../executor/codex_executor_cache_test.go | 22 +++++++--
.../executor/codex_websockets_executor.go | 29 ++++++------
.../codex_websockets_executor_test.go | 45 ++++++++++++++-----
...nai_responses_websocket_toolcall_repair.go | 6 +++
sdk/cliproxy/auth/selector.go | 42 ++++++++++-------
sdk/cliproxy/auth/selector_test.go | 13 +++---
9 files changed, 129 insertions(+), 61 deletions(-)
diff --git a/config.example.yaml b/config.example.yaml
index be84de3b5a5..bb9307cc6bc 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -119,7 +119,7 @@ routing:
strategy: "round-robin" # round-robin (default), fill-first
# Enable universal session-sticky routing for all clients.
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
- # X-Session-ID, X-Amp-Thread-Id (Amp CLI),
+ # X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
# X-Client-Request-Id (PI), conversation_id, or first few messages hash.
# Automatic failover is always enabled when bound auth becomes unavailable.
session-affinity: false # default: false
diff --git a/internal/config/config.go b/internal/config/config.go
index 7c660cd23e0..0e193938835 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -237,7 +237,7 @@ type RoutingConfig struct {
// SessionAffinity enables universal session-sticky routing for all clients.
// Session IDs are extracted from multiple sources:
- // metadata.user_id (Claude Code session format), X-Session-ID,
+ // metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex),
// X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id,
// conversation_id, or message hash.
// Automatic failover is always enabled when bound auth becomes unavailable.
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index c7dd2d3ec11..26f2327e6d1 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -931,6 +931,9 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
if err != nil {
return nil, nil, codexIdentityConfuseState{}, err
}
+ if cache.ID != "" {
+ httpReq.Header.Set("Session_id", cache.ID)
+ }
return httpReq, rawJSON, identityState, nil
}
@@ -964,7 +967,6 @@ func applyCodexIdentityConfuseHeaders(headers http.Header, state *codexIdentityC
if headers == nil {
return
}
- defer deleteDeprecatedCodexConversationHeader(headers)
if state == nil || !state.enabled {
return
}
@@ -977,6 +979,12 @@ func applyCodexIdentityConfuseHeaders(headers http.Header, state *codexIdentityC
}
setHeaderCasePreserved(headers, "Session-Id", state.promptCacheKey)
+ if headerValueCaseInsensitive(headers, "session_id") != "" {
+ setHeaderCasePreserved(headers, "session_id", state.promptCacheKey)
+ }
+ if headerValueCaseInsensitive(headers, "Conversation_id") != "" {
+ setHeaderCasePreserved(headers, "Conversation_id", state.promptCacheKey)
+ }
headers.Set("X-Client-Request-Id", state.promptCacheKey)
headers.Set("Thread-Id", state.promptCacheKey)
headers.Set("X-Codex-Window-Id", state.promptCacheKey+":0")
@@ -1072,6 +1080,10 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
+ if strings.Contains(r.Header.Get("User-Agent"), "Mac OS") {
+ misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
+ }
+
if stream {
r.Header.Set("Accept", "text/event-stream")
} else {
@@ -1090,19 +1102,18 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
} else if !isAPIKey {
r.Header.Set("Originator", codexOriginator)
}
- // if !isAPIKey {
- // if auth != nil && auth.Metadata != nil {
- // if accountID, ok := auth.Metadata["account_id"].(string); ok {
- // r.Header.Set("Chatgpt-Account-Id", accountID)
- // }
- // }
- // }
+ if !isAPIKey {
+ if auth != nil && auth.Metadata != nil {
+ if accountID, ok := auth.Metadata["account_id"].(string); ok {
+ r.Header.Set("Chatgpt-Account-Id", accountID)
+ }
+ }
+ }
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(r, attrs)
- deleteDeprecatedCodexConversationHeader(r.Header)
}
func newCodexStatusErr(statusCode int, body []byte) statusErr {
diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go
index 29d244e68f7..3f7d412ba93 100644
--- a/internal/runtime/executor/codex_executor_cache_test.go
+++ b/internal/runtime/executor/codex_executor_cache_test.go
@@ -47,8 +47,8 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
if gotConversation := httpReq.Header.Get("Conversation_id"); gotConversation != "" {
t.Fatalf("Conversation_id = %q, want empty", gotConversation)
}
- if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" {
- t.Fatalf("Session_id = %q, want empty", gotSession)
+ if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedKey {
+ t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey)
}
httpReq2, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON)
@@ -119,8 +119,8 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
t.Fatalf("%s = %q, want %q", headerName, gotHeader, expectedPromptCacheKey)
}
}
- if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" {
- t.Fatalf("Session_id = %q, want empty", gotSession)
+ if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedPromptCacheKey {
+ t.Fatalf("Session_id = %q, want %q", gotSession, expectedPromptCacheKey)
}
if gotWindow := httpReq.Header.Get("X-Codex-Window-Id"); gotWindow != expectedPromptCacheKey+":0" {
t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindow, expectedPromptCacheKey+":0")
@@ -137,6 +137,20 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
}
}
+func TestApplyCodexHeadersUsesAccountHeaderForOAuth(t *testing.T) {
+ httpReq := httptest.NewRequest("POST", "https://example.com/responses", nil)
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Metadata: map[string]any{"account_id": "acct-1"},
+ }
+
+ applyCodexHeaders(httpReq, auth, "oauth-token", true, nil)
+
+ if got := httpReq.Header.Get("Chatgpt-Account-Id"); got != "acct-1" {
+ t.Fatalf("Chatgpt-Account-Id = %q, want acct-1", got)
+ }
+}
+
func TestCodexIdentityConfuseKeepsClientBodySeparateFromUpstreamBody(t *testing.T) {
cfg := &config.Config{
Routing: config.RoutingConfig{Strategy: "fill-first"},
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index ecbf2171052..2cb9bc98f57 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -857,6 +857,8 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
if cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
+ setHeaderCasePreserved(headers, "session_id", cache.ID)
+ headers.Set("Conversation_id", cache.ID)
}
return rawJSON, headers
@@ -897,27 +899,30 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
betaHeader = codexResponsesWebsocketBetaHeaderValue
}
headers.Set("OpenAI-Beta", betaHeader)
+ if strings.Contains(headers.Get("User-Agent"), "Mac OS") {
+ ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", uuid.NewString())
+ }
+ ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", "")
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
headers.Set("Originator", originator)
} else if !isAPIKey {
headers.Set("Originator", codexOriginator)
}
- // if !isAPIKey {
- // if auth != nil && auth.Metadata != nil {
- // if accountID, ok := auth.Metadata["account_id"].(string); ok {
- // if trimmed := strings.TrimSpace(accountID); trimmed != "" {
- // setHeaderCasePreserved(headers, "ChatGPT-Account-ID", trimmed)
- // }
- // }
- // }
- // }
+ if !isAPIKey {
+ if auth != nil && auth.Metadata != nil {
+ if accountID, ok := auth.Metadata["account_id"].(string); ok {
+ if trimmed := strings.TrimSpace(accountID); trimmed != "" {
+ setHeaderCasePreserved(headers, "ChatGPT-Account-ID", trimmed)
+ }
+ }
+ }
+ }
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs)
- deleteDeprecatedCodexConversationHeader(headers)
return headers
}
@@ -993,10 +998,6 @@ func deleteHeaderCaseInsensitive(headers http.Header, key string) {
}
}
-func deleteDeprecatedCodexConversationHeader(headers http.Header) {
- deleteHeaderCaseInsensitive(headers, "Conversation_id")
-}
-
func codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) {
if cfg == nil || auth == nil {
return "", ""
diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go
index ba01d2b66a8..5dbfbce9457 100644
--- a/internal/runtime/executor/codex_websockets_executor_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_test.go
@@ -217,8 +217,11 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
}
- if got := headerValueCaseInsensitive(headers, "session_id"); got != "" {
- t.Fatalf("session_id = %q, want empty", got)
+ if got := headerValueCaseInsensitive(headers, "session_id"); got != "legacy-session" {
+ t.Fatalf("session_id = %s, want legacy-session", got)
+ }
+ if _, ok := headers["session_id"]; !ok {
+ t.Fatalf("expected lowercase session_id header key, got %#v", headers)
}
}
@@ -341,16 +344,36 @@ func TestApplyCodexWebsocketHeadersPreservesExplicitAPIKeyUserAgent(t *testing.T
}
}
-func TestApplyCodexPromptCacheHeadersDoesNotSetDeprecatedConversationHeader(t *testing.T) {
+func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) {
+ auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"account_id": "acct-1"}}
+
+ headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", nil)
+
+ if got := headerValueCaseInsensitive(headers, "ChatGPT-Account-ID"); got != "acct-1" {
+ t.Fatalf("ChatGPT-Account-ID = %s, want acct-1", got)
+ }
+ values, ok := headers["ChatGPT-Account-ID"]
+ if !ok {
+ t.Fatalf("expected exact ChatGPT-Account-ID key, got %#v", headers)
+ }
+ if len(values) != 1 || values[0] != "acct-1" {
+ t.Fatalf("ChatGPT-Account-ID values = %#v, want [acct-1]", values)
+ }
+}
+
+func TestApplyCodexPromptCacheHeadersSetsLowercaseSessionAndLegacyConversation(t *testing.T) {
req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)}
_, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`))
- if got := headerValueCaseInsensitive(headers, "session_id"); got != "" {
- t.Fatalf("session_id = %q, want empty", got)
+ if got := headerValueCaseInsensitive(headers, "session_id"); got != "cache-1" {
+ t.Fatalf("session_id = %s, want cache-1", got)
+ }
+ if _, ok := headers["session_id"]; !ok {
+ t.Fatalf("expected lowercase session_id key, got %#v", headers)
}
- if got := headers.Get("Conversation_id"); got != "" {
- t.Fatalf("Conversation_id = %q, want empty", got)
+ if got := headers.Get("Conversation_id"); got != "cache-1" {
+ t.Fatalf("Conversation_id = %s, want cache-1", got)
}
}
@@ -379,8 +402,8 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin
if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
}
- if gotSession := headerValueCaseInsensitive(headers, "session_id"); gotSession != "" {
- t.Fatalf("session_id = %q, want empty", gotSession)
+ if gotSession := headerValueCaseInsensitive(headers, "session_id"); gotSession != expectedPromptCacheKey {
+ t.Fatalf("session_id = %q, want %q", gotSession, expectedPromptCacheKey)
}
if gotRequestID := headers.Get("X-Client-Request-Id"); gotRequestID != expectedPromptCacheKey {
t.Fatalf("X-Client-Request-Id = %q, want %q", gotRequestID, expectedPromptCacheKey)
@@ -388,8 +411,8 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin
if gotThreadID := headers.Get("Thread-Id"); gotThreadID != expectedPromptCacheKey {
t.Fatalf("Thread-Id = %q, want %q", gotThreadID, expectedPromptCacheKey)
}
- if gotConversation := headers.Get("Conversation_id"); gotConversation != "" {
- t.Fatalf("Conversation_id = %q, want empty", gotConversation)
+ if gotConversation := headers.Get("Conversation_id"); gotConversation != expectedPromptCacheKey {
+ t.Fatalf("Conversation_id = %q, want %q", gotConversation, expectedPromptCacheKey)
}
if gotWindowID := headers.Get("X-Codex-Window-Id"); gotWindowID != expectedPromptCacheKey+":0" {
t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0")
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
index 6e1e7a6738f..dc3857b2614 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
@@ -147,6 +147,12 @@ func websocketDownstreamSessionKey(req *http.Request) string {
return sessionID
}
}
+ if sessionID := strings.TrimSpace(req.Header.Get("Session-Id")); sessionID != "" {
+ return sessionID
+ }
+ if sessionID := strings.TrimSpace(req.Header.Get("Session_id")); sessionID != "" {
+ return sessionID
+ }
return ""
}
diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go
index 3cf11cf148f..19d1843feec 100644
--- a/sdk/cliproxy/auth/selector.go
+++ b/sdk/cliproxy/auth/selector.go
@@ -471,11 +471,12 @@ func NewSessionAffinitySelectorWithConfig(cfg SessionAffinityConfig) *SessionAff
// Priority for session ID extraction:
// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority
// 2. X-Session-ID header
-// 3. X-Amp-Thread-Id header (Amp CLI thread ID)
-// 4. X-Client-Request-Id header (PI)
-// 5. metadata.user_id (non-Claude Code format)
-// 6. conversation_id field in request body
-// 7. Stable hash from first few messages content (fallback)
+// 3. Session_id header (Codex)
+// 4. X-Amp-Thread-Id header (Amp CLI thread ID)
+// 5. X-Client-Request-Id header (PI)
+// 6. metadata.user_id (non-Claude Code format)
+// 7. conversation_id field in request body
+// 8. Stable hash from first few messages content (fallback)
//
// Note: The cache key includes provider, session ID, and model to handle cases where
// a session uses multiple models (e.g., gemini-2.5-pro and gemini-3-flash-preview)
@@ -572,11 +573,12 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) {
// Priority order:
// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients
// 2. X-Session-ID header
-// 3. X-Amp-Thread-Id header (Amp CLI thread ID)
-// 4. X-Client-Request-Id header (PI)
-// 5. metadata.user_id (non-Claude Code format)
-// 6. conversation_id field in request body
-// 7. Stable hash from first few messages content (fallback)
+// 3. Session_id header (Codex)
+// 4. X-Amp-Thread-Id header (Amp CLI thread ID)
+// 5. X-Client-Request-Id header (PI)
+// 6. metadata.user_id (non-Claude Code format)
+// 7. conversation_id field in request body
+// 8. Stable hash from first few messages content (fallback)
func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string {
primary, _ := extractSessionIDs(headers, payload, metadata)
return primary
@@ -612,14 +614,24 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string]
}
}
- // 3. X-Amp-Thread-Id header (Amp CLI thread ID)
+ // 3. Session_id header (Codex)
+ if headers != nil {
+ if sid := headers.Get("Session-Id"); sid != "" {
+ return "codex:" + sid, ""
+ }
+ if sid := headers.Get("Session_id"); sid != "" {
+ return "codex:" + sid, ""
+ }
+ }
+
+ // 4. X-Amp-Thread-Id header (Amp CLI thread ID)
if headers != nil {
if tid := headers.Get("X-Amp-Thread-Id"); tid != "" {
return "amp:" + tid, ""
}
}
- // 4. X-Client-Request-Id header (PI)
+ // 5. X-Client-Request-Id header (PI)
if headers != nil {
if rid := headers.Get("X-Client-Request-Id"); rid != "" {
return "clientreq:" + rid, ""
@@ -630,18 +642,18 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string]
return "", ""
}
- // 5. metadata.user_id (non-Claude Code format)
+ // 6. metadata.user_id (non-Claude Code format)
userID := gjson.GetBytes(payload, "metadata.user_id").String()
if userID != "" {
return "user:" + userID, ""
}
- // 6. conversation_id field
+ // 7. conversation_id field
if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" {
return "conv:" + convID, ""
}
- // 7. Hash-based fallback from message content
+ // 8. Hash-based fallback from message content
return extractMessageHashIDs(payload)
}
diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go
index 0e2eb9521e0..99231bdf78d 100644
--- a/sdk/cliproxy/auth/selector_test.go
+++ b/sdk/cliproxy/auth/selector_test.go
@@ -776,15 +776,16 @@ func TestExtractSessionID_Headers(t *testing.T) {
}
}
-func TestExtractSessionID_IgnoresCodexSessionIDHeader(t *testing.T) {
+func TestExtractSessionID_CodexSessionIDHeader(t *testing.T) {
t.Parallel()
headers := make(http.Header)
headers.Set("Session_id", "codex-session-123")
got := ExtractSessionID(headers, nil, nil)
- if got != "" {
- t.Errorf("ExtractSessionID() with deprecated Session_id = %q, want empty", got)
+ want := "codex:codex-session-123"
+ if got != want {
+ t.Errorf("ExtractSessionID() with Session_id = %q, want %q", got, want)
}
}
@@ -801,7 +802,7 @@ func TestExtractSessionID_ClientRequestIDHeader(t *testing.T) {
}
}
-func TestExtractSessionID_ClientRequestIDIgnoresDeprecatedCodexSessionID(t *testing.T) {
+func TestExtractSessionID_CodexSessionIDPriorityOverClientRequestID(t *testing.T) {
t.Parallel()
headers := make(http.Header)
@@ -809,9 +810,9 @@ func TestExtractSessionID_ClientRequestIDIgnoresDeprecatedCodexSessionID(t *test
headers.Set("Session_id", "codex-session-456")
got := ExtractSessionID(headers, nil, nil)
- want := "clientreq:pi-session-123"
+ want := "codex:codex-session-456"
if got != want {
- t.Errorf("ExtractSessionID() = %q, want %q (deprecated Session_id should be ignored)", got, want)
+ t.Errorf("ExtractSessionID() = %q, want %q (Session_id should take priority over X-Client-Request-Id)", got, want)
}
}
From e7f4dd470d3601072476dd722386ab9b489b378e Mon Sep 17 00:00:00 2001
From: cat
Date: Mon, 1 Jun 2026 13:10:41 +0800
Subject: [PATCH 096/248] fix(openai): keep referenced tool call when deduping
websocket input IDs
The input item ID dedupe added in #3620 keeps only the last occurrence of
each item id. When an upstream reuses the same item id across a re-sent or
repaired tool call (so two function_call items share an id but carry
different call_ids), the last-wins rule can drop the function_call whose
call_id still has a matching function_call_output. The upstream then rejects
the request with HTTP 400 "No tool call found for function call output with
call_id ...", breaking every subsequent turn over the Codex WebSocket path.
Make the dedupe orphan-aware: when several input items share an id, never
replace an item whose call_id is still referenced by a tool-call output with
one that is not. This keeps a single item per id (preserving the original
intent) while ensuring retained tool calls stay paired with their outputs.
Adds a regression test covering two function_call items that share an id
where only the earlier call_id has a surviving output.
---
.../openai/openai_responses_websocket.go | 44 +++++++++++++++++--
.../openai/openai_responses_websocket_test.go | 23 ++++++++++
2 files changed, 63 insertions(+), 4 deletions(-)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go
index 142719aa268..08017c3a8e5 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket.go
@@ -728,14 +728,50 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
return "", errUnmarshal
}
- lastIndexByID := make(map[string]int, len(items))
+ // Collect the call_ids that are still referenced by tool-call output
+ // items. When several input items share the same id, the one we keep must
+ // preserve any call_id that has a matching output; otherwise the upstream
+ // rejects the request with "No tool call found for function call output".
+ referencedCallIDs := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ if len(item) == 0 {
+ continue
+ }
+ switch strings.TrimSpace(gjson.GetBytes(item, "type").String()) {
+ case "function_call_output", "custom_tool_call_output":
+ callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
+ if callID != "" {
+ referencedCallIDs[callID] = struct{}{}
+ }
+ }
+ }
+
+ // For each id, choose the index to keep. The default is the last
+ // occurrence (matching the original dedupe behavior), but we never replace
+ // an item whose call_id still has a matching output with one that does not.
+ // This keeps a single item per id while ensuring retained tool calls stay
+ // paired with their outputs.
+ keepIndexByID := make(map[string]int, len(items))
+ keepReferencedByID := make(map[string]bool, len(items))
for i, item := range items {
if len(item) == 0 {
continue
}
itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
- if itemID != "" {
- lastIndexByID[itemID] = i
+ if itemID == "" {
+ continue
+ }
+ callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
+ _, referenced := referencedCallIDs[callID]
+ referenced = referenced && callID != ""
+ if _, seen := keepIndexByID[itemID]; !seen {
+ keepIndexByID[itemID] = i
+ keepReferencedByID[itemID] = referenced
+ continue
+ }
+ if referenced || !keepReferencedByID[itemID] {
+ keepIndexByID[itemID] = i
+ keepReferencedByID[itemID] = referenced
}
}
@@ -746,7 +782,7 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
}
itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
if itemID != "" {
- if lastIndexByID[itemID] != i {
+ if keepIndexByID[itemID] != i {
continue
}
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go
index 6502ae0c834..6796023e034 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go
@@ -1845,6 +1845,29 @@ func TestDedupeResponsesWebsocketInputItemsByIDAfterRepair(t *testing.T) {
}
}
+func TestDedupeResponsesWebsocketInputItemsByIDKeepsReferencedToolCall(t *testing.T) {
+ // Two function_call items share the same id but carry different call_ids
+ // (e.g. the upstream reused the item id across a re-sent/repaired call).
+ // Only the first call_id has a matching function_call_output. Deduping by
+ // id must keep the referenced call so the output is not orphaned, which
+ // previously triggered an upstream 400 "No tool call found for function
+ // call output with call_id ...".
+ payload := []byte(`{"input":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"exec_command"},{"type":"function_call","id":"fc-1","call_id":"call-2","name":"exec_command"},{"type":"function_call_output","id":"fco-1","call_id":"call-1"}]}`)
+
+ deduped := dedupeResponsesWebsocketInputItemsByID(payload)
+
+ items := gjson.GetBytes(deduped, "input").Array()
+ if len(items) != 2 {
+ t.Fatalf("deduped input len = %d, want 2: %s", len(items), deduped)
+ }
+ if items[0].Get("id").String() != "fc-1" ||
+ items[0].Get("call_id").String() != "call-1" ||
+ items[1].Get("id").String() != "fco-1" ||
+ items[1].Get("call_id").String() != "call-1" {
+ t.Fatalf("unexpected deduped input: %s", deduped)
+ }
+}
+
func TestResponsesWebsocketCompactionResetsTurnStateOnCustomToolTranscriptReplacement(t *testing.T) {
gin.SetMode(gin.TestMode)
From f05d68d4ec434e60f7e7c153efc7393bd20682a8 Mon Sep 17 00:00:00 2001
From: cat
Date: Mon, 1 Jun 2026 15:01:31 +0800
Subject: [PATCH 097/248] refactor(openai): parse dedupe input item metadata in
a single pass
Address review feedback: parse each item's type/id/call_id once with
gjson.GetManyBytes and reuse it across the dedupe loops instead of
rescanning every item up to five times. Behavior is unchanged.
---
.../openai/openai_responses_websocket.go | 47 ++++++++++++-------
1 file changed, 30 insertions(+), 17 deletions(-)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go
index 08017c3a8e5..0e6cfce48fd 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket.go
@@ -728,20 +728,37 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
return "", errUnmarshal
}
+ // Parse each item's type, id and call_id once; gjson is a scan-based
+ // parser, so reusing this metadata avoids rescanning every item in each of
+ // the loops below as the conversation history grows.
+ type itemMetadata struct {
+ itemType string
+ id string
+ callID string
+ }
+ meta := make([]itemMetadata, len(items))
+ for i, item := range items {
+ if len(item) == 0 {
+ continue
+ }
+ res := gjson.GetManyBytes(item, "type", "id", "call_id")
+ meta[i] = itemMetadata{
+ itemType: strings.TrimSpace(res[0].String()),
+ id: strings.TrimSpace(res[1].String()),
+ callID: strings.TrimSpace(res[2].String()),
+ }
+ }
+
// Collect the call_ids that are still referenced by tool-call output
// items. When several input items share the same id, the one we keep must
// preserve any call_id that has a matching output; otherwise the upstream
// rejects the request with "No tool call found for function call output".
referencedCallIDs := make(map[string]struct{}, len(items))
- for _, item := range items {
- if len(item) == 0 {
- continue
- }
- switch strings.TrimSpace(gjson.GetBytes(item, "type").String()) {
+ for i := range items {
+ switch meta[i].itemType {
case "function_call_output", "custom_tool_call_output":
- callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
- if callID != "" {
- referencedCallIDs[callID] = struct{}{}
+ if meta[i].callID != "" {
+ referencedCallIDs[meta[i].callID] = struct{}{}
}
}
}
@@ -753,17 +770,13 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
// paired with their outputs.
keepIndexByID := make(map[string]int, len(items))
keepReferencedByID := make(map[string]bool, len(items))
- for i, item := range items {
- if len(item) == 0 {
- continue
- }
- itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
+ for i := range items {
+ itemID := meta[i].id
if itemID == "" {
continue
}
- callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
- _, referenced := referencedCallIDs[callID]
- referenced = referenced && callID != ""
+ _, referenced := referencedCallIDs[meta[i].callID]
+ referenced = referenced && meta[i].callID != ""
if _, seen := keepIndexByID[itemID]; !seen {
keepIndexByID[itemID] = i
keepReferencedByID[itemID] = referenced
@@ -780,7 +793,7 @@ func dedupeInputItemsByID(rawArray string) (string, error) {
if len(item) == 0 {
continue
}
- itemID := strings.TrimSpace(gjson.GetBytes(item, "id").String())
+ itemID := meta[i].id
if itemID != "" {
if keepIndexByID[itemID] != i {
continue
From 959067edfbf8d01c978e9de5d801a8bbb0343abf Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Tue, 2 Jun 2026 00:43:16 +0800
Subject: [PATCH 098/248] feat(usage): introduce executor type tracking in
usage reporting
- Replaced `NewUsageReporter` with `NewExecutorUsageReporter` to include executor type in usage records.
- Updated all executors to use the new reporter implementation.
- Extended `UsageReporter` to track and publish executor type.
- Added tests to validate proper executor type recording and handling.
- Enhanced RedisQueue plugin and payload schema with executor type support.
---
internal/redisqueue/plugin.go | 6 ++
internal/redisqueue/plugin_test.go | 2 +
.../runtime/executor/aistudio_executor.go | 4 +-
.../runtime/executor/antigravity_executor.go | 6 +-
internal/runtime/executor/claude_executor.go | 4 +-
internal/runtime/executor/codex_executor.go | 6 +-
.../runtime/executor/codex_openai_images.go | 4 +-
.../executor/codex_websockets_executor.go | 4 +-
.../runtime/executor/gemini_cli_executor.go | 4 +-
internal/runtime/executor/gemini_executor.go | 4 +-
.../executor/gemini_vertex_executor.go | 8 +--
.../runtime/executor/helps/usage_helpers.go | 60 ++++++++++++++-----
.../executor/helps/usage_helpers_test.go | 18 ++++++
internal/runtime/executor/kimi_executor.go | 4 +-
.../executor/openai_compat_executor.go | 8 +--
internal/runtime/executor/xai_executor.go | 4 +-
sdk/cliproxy/usage/manager.go | 18 +++---
17 files changed, 110 insertions(+), 54 deletions(-)
diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go
index f6c8e52ca6c..029dd13f12d 100644
--- a/internal/redisqueue/plugin.go
+++ b/internal/redisqueue/plugin.go
@@ -42,6 +42,10 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
if provider == "" {
provider = "unknown"
}
+ executorType := strings.TrimSpace(record.ExecutorType)
+ if executorType == "" {
+ executorType = "unknown"
+ }
authType := strings.TrimSpace(record.AuthType)
if authType == "" {
authType = "unknown"
@@ -94,6 +98,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
payload, err := json.Marshal(queuedUsageDetail{
requestDetail: detail,
Provider: provider,
+ ExecutorType: executorType,
Model: modelName,
Alias: aliasName,
Endpoint: resolveEndpoint(ctx),
@@ -112,6 +117,7 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
type queuedUsageDetail struct {
requestDetail
Provider string `json:"provider"`
+ ExecutorType string `json:"executor_type"`
Model string `json:"model"`
Alias string `json:"alias"`
Endpoint string `json:"endpoint"`
diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go
index 09ee681a370..16c0a270af7 100644
--- a/internal/redisqueue/plugin_test.go
+++ b/internal/redisqueue/plugin_test.go
@@ -26,6 +26,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
plugin := &usageQueuePlugin{}
plugin.HandleUsage(ctx, coreusage.Record{
Provider: "openai",
+ ExecutorType: "KimiExecutor",
Model: "gpt-5.4",
Alias: "client-gpt",
APIKey: "test-key",
@@ -47,6 +48,7 @@ func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
payload := popSinglePayload(t)
requireStringField(t, payload, "provider", "openai")
+ requireStringField(t, payload, "executor_type", "KimiExecutor")
requireStringField(t, payload, "model", "gpt-5.4")
requireStringField(t, payload, "alias", "client-gpt")
requireStringField(t, payload, "endpoint", "POST /v1/chat/completions")
diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go
index 0e2718c7244..ea6fccf83c7 100644
--- a/internal/runtime/executor/aistudio_executor.go
+++ b/internal/runtime/executor/aistudio_executor.go
@@ -128,7 +128,7 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
translatedReq, body, err := e.translateRequest(req, opts, false)
@@ -196,7 +196,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
translatedReq, body, err := e.translateRequest(req, opts, true)
diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go
index 6388856ee9e..c4c94e20087 100644
--- a/internal/runtime/executor/antigravity_executor.go
+++ b/internal/runtime/executor/antigravity_executor.go
@@ -530,7 +530,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
return e.executeClaudeNonStream(ctx, auth, req, opts)
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -730,7 +730,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -1192,7 +1192,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 6d6b975fd5e..5e95cb1dc8d 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -171,7 +171,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
baseURL = "https://api.anthropic.com"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
to := sdktranslator.FromString("claude")
@@ -354,7 +354,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
baseURL = "https://api.anthropic.com"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
to := sdktranslator.FromString("claude")
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index 26f2327e6d1..d3c3925ed36 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -264,7 +264,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
baseURL = "https://chatgpt.com/backend-api/codex"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -431,7 +431,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
baseURL = "https://chatgpt.com/backend-api/codex"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -536,7 +536,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
baseURL = "https://chatgpt.com/backend-api/codex"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go
index ffece021961..aff67d87e9a 100644
--- a/internal/runtime/executor/codex_openai_images.go
+++ b/internal/runtime/executor/codex_openai_images.go
@@ -89,7 +89,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
}
mainModel := e.resolveGPTImage2BaseModel()
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), mainModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, mainModel, auth)
defer reporter.TrackFailure(ctx, &err)
body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts, mainModel)
@@ -182,7 +182,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
}
mainModel := e.resolveGPTImage2BaseModel()
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), mainModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, mainModel, auth)
defer reporter.TrackFailure(ctx, &err)
body, errBuild := e.prepareCodexOpenAIImageBody(prepared.Body, req, opts, mainModel)
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index 2cb9bc98f57..e1c9ce34412 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -184,7 +184,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
baseURL = "https://chatgpt.com/backend-api/codex"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -404,7 +404,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
baseURL = "https://chatgpt.com/backend-api/codex"
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go
index d6b97021bef..0d15e1d0e36 100644
--- a/internal/runtime/executor/gemini_cli_executor.go
+++ b/internal/runtime/executor/gemini_cli_executor.go
@@ -118,7 +118,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
return resp, err
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -277,7 +277,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
return nil, err
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go
index 2f4f1935e95..585a064253d 100644
--- a/internal/runtime/executor/gemini_executor.go
+++ b/internal/runtime/executor/gemini_executor.go
@@ -112,7 +112,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
apiKey, bearer := geminiCreds(auth)
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
// Official Gemini API via API key or OAuth bearer
@@ -224,7 +224,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
apiKey, bearer := geminiCreds(auth)
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go
index 50c22b9cd01..75d31844b23 100644
--- a/internal/runtime/executor/gemini_vertex_executor.go
+++ b/internal/runtime/executor/gemini_vertex_executor.go
@@ -306,7 +306,7 @@ func (e *GeminiVertexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.A
func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (resp cliproxyexecutor.Response, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
var body []byte
@@ -441,7 +441,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (resp cliproxyexecutor.Response, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -555,7 +555,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (_ *cliproxyexecutor.StreamResult, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
@@ -699,7 +699,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (_ *cliproxyexecutor.StreamResult, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go
index 10c4108c1f6..551bd02ad3c 100644
--- a/internal/runtime/executor/helps/usage_helpers.go
+++ b/internal/runtime/executor/helps/usage_helpers.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "reflect"
"strings"
"sync"
"time"
@@ -21,22 +22,37 @@ import (
)
type UsageReporter struct {
- provider string
- model string
- alias string
- authID string
- authIndex string
- authType string
- apiKey string
- source string
- reasoning string
- serviceTier string
- requestedAt time.Time
- ttftMu sync.RWMutex
- ttft time.Duration
- ttftStart time.Time
- ttftSet bool
- once sync.Once
+ provider string
+ executorType string
+ model string
+ alias string
+ authID string
+ authIndex string
+ authType string
+ apiKey string
+ source string
+ reasoning string
+ serviceTier string
+ requestedAt time.Time
+ ttftMu sync.RWMutex
+ ttft time.Duration
+ ttftStart time.Time
+ ttftSet bool
+ once sync.Once
+}
+
+type usageExecutor interface {
+ Identifier() string
+}
+
+func NewExecutorUsageReporter(ctx context.Context, executor usageExecutor, model string, auth *cliproxyauth.Auth) *UsageReporter {
+ provider := ""
+ if executor != nil {
+ provider = executor.Identifier()
+ }
+ reporter := NewUsageReporter(ctx, provider, model, auth)
+ reporter.executorType = ExecutorTypeName(executor)
+ return reporter
}
func NewUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *UsageReporter {
@@ -63,6 +79,17 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox
return reporter
}
+func ExecutorTypeName(executor any) string {
+ if executor == nil {
+ return ""
+ }
+ executorType := reflect.TypeOf(executor)
+ for executorType.Kind() == reflect.Pointer {
+ executorType = executorType.Elem()
+ }
+ return strings.TrimSpace(executorType.Name())
+}
+
func (r *UsageReporter) Publish(ctx context.Context, detail usage.Detail) {
r.publishWithOutcome(ctx, detail, false, usage.Failure{})
}
@@ -234,6 +261,7 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f
}
return usage.Record{
Provider: r.provider,
+ ExecutorType: r.executorType,
Model: model,
Alias: r.alias,
Source: r.source,
diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go
index 483d8ef595d..5cca50acac3 100644
--- a/internal/runtime/executor/helps/usage_helpers_test.go
+++ b/internal/runtime/executor/helps/usage_helpers_test.go
@@ -231,6 +231,18 @@ func TestUsageReporterBuildRecordIncludesRequestedModelAlias(t *testing.T) {
}
}
+func TestNewExecutorUsageReporterIncludesExecutorType(t *testing.T) {
+ reporter := NewExecutorUsageReporter(context.Background(), &TestUsageExecutor{}, "gpt-5.4", nil)
+
+ record := reporter.buildRecord(usage.Detail{TotalTokens: 3}, false)
+ if record.Provider != "test-provider" {
+ t.Fatalf("provider = %q, want %q", record.Provider, "test-provider")
+ }
+ if record.ExecutorType != "TestUsageExecutor" {
+ t.Fatalf("executor type = %q, want %q", record.ExecutorType, "TestUsageExecutor")
+ }
+}
+
func TestUsageReporterBuildRecordIncludesReasoningEffort(t *testing.T) {
ctx := usage.WithReasoningEffort(context.Background(), "medium")
reporter := NewUsageReporter(ctx, "openai", "gpt-5.4", nil)
@@ -297,3 +309,9 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+
+type TestUsageExecutor struct{}
+
+func (TestUsageExecutor) Identifier() string {
+ return "test-provider"
+}
diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go
index d7ab643ad34..ef3fff11c9d 100644
--- a/internal/runtime/executor/kimi_executor.go
+++ b/internal/runtime/executor/kimi_executor.go
@@ -83,7 +83,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
token := kimiCreds(auth)
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
to := sdktranslator.FromString("openai")
@@ -191,7 +191,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
baseModel := thinking.ParseSuffix(req.Model).ModelName
token := kimiCreds(auth)
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
to := sdktranslator.FromString("openai")
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index 2be71afc3a7..5013eb90919 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -89,7 +89,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
@@ -201,7 +201,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
@@ -294,7 +294,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
@@ -459,7 +459,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (_ *cliproxyexecutor.StreamResult, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
baseURL, apiKey := e.resolveCredentials(auth)
diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go
index cb42f93935c..92203f3d3eb 100644
--- a/internal/runtime/executor/xai_executor.go
+++ b/internal/runtime/executor/xai_executor.go
@@ -114,7 +114,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
return resp, err
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), prepared.baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
@@ -302,7 +302,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
return nil, err
}
- reporter := helps.NewUsageReporter(ctx, e.Identifier(), prepared.baseModel, auth)
+ reporter := helps.NewExecutorUsageReporter(ctx, e, prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go
index 6c113b12680..b68d6f41736 100644
--- a/sdk/cliproxy/usage/manager.go
+++ b/sdk/cliproxy/usage/manager.go
@@ -15,14 +15,16 @@ const DefaultServiceTier = "default"
// Record contains the usage statistics captured for a single provider request.
type Record struct {
- Provider string
- Model string
- Alias string
- APIKey string
- AuthID string
- AuthIndex string
- AuthType string
- Source string
+ Provider string
+ // ExecutorType stores the concrete executor type that handled the request.
+ ExecutorType string
+ Model string
+ Alias string
+ APIKey string
+ AuthID string
+ AuthIndex string
+ AuthType string
+ Source string
// ReasoningEffort stores the translated upstream thinking level for request event logs.
ReasoningEffort string
// ServiceTier stores the client-requested service tier for request event logs.
From f353979e0a6f4d5a9fcc4a1a8d4fb616a710852a Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Tue, 2 Jun 2026 02:52:27 +0800
Subject: [PATCH 099/248] feat(watcher, redisqueue): add usage refresh
notification support
- Introduced `NotifyUsageRefresh` in `redisqueue` to notify subscribers of usage refresh events.
- Enhanced `Watcher` logic to trigger usage refresh notifications on client changes (add/update/remove).
- Updated tests to validate proper broadcast of usage refresh messages to subscribers.
- Added support for initial `support_refresh` payload upon subscription initialization.
---
.../redis_queue_protocol_integration_test.go | 124 ++++++++++++++++++
internal/redisqueue/queue.go | 8 ++
internal/redisqueue/queue_test.go | 23 ++++
internal/watcher/clients.go | 4 +
internal/watcher/watcher_test.go | 64 +++++++++
5 files changed, 223 insertions(+)
diff --git a/internal/api/redis_queue_protocol_integration_test.go b/internal/api/redis_queue_protocol_integration_test.go
index 834e4a86a1a..7d443f67f99 100644
--- a/internal/api/redis_queue_protocol_integration_test.go
+++ b/internal/api/redis_queue_protocol_integration_test.go
@@ -159,6 +159,68 @@ func readRESPArrayOfBulkStrings(r *bufio.Reader) ([][]byte, error) {
return out, nil
}
+func readTestRESPPubSubSubscribe(r *bufio.Reader) (string, int, error) {
+ prefix, errRead := r.ReadByte()
+ if errRead != nil {
+ return "", 0, errRead
+ }
+ if prefix != '*' {
+ return "", 0, fmt.Errorf("expected array prefix '*', got %q", prefix)
+ }
+ line, errLine := readTestRESPLine(r)
+ if errLine != nil {
+ return "", 0, errLine
+ }
+ count, errParse := strconv.Atoi(line)
+ if errParse != nil {
+ return "", 0, fmt.Errorf("invalid array length %q: %v", line, errParse)
+ }
+ if count != 3 {
+ return "", 0, fmt.Errorf("subscribe ack length = %d, want 3", count)
+ }
+ kind, errKind := readTestRESPBulkString(r)
+ if errKind != nil {
+ return "", 0, errKind
+ }
+ if string(kind) != "subscribe" {
+ return "", 0, fmt.Errorf("subscribe ack kind = %q", string(kind))
+ }
+ channel, errChannel := readTestRESPBulkString(r)
+ if errChannel != nil {
+ return "", 0, errChannel
+ }
+ prefix, errRead = r.ReadByte()
+ if errRead != nil {
+ return "", 0, errRead
+ }
+ if prefix != ':' {
+ return "", 0, fmt.Errorf("expected integer prefix ':', got %q", prefix)
+ }
+ line, errLine = readTestRESPLine(r)
+ if errLine != nil {
+ return "", 0, errLine
+ }
+ subscriptions, errParse := strconv.Atoi(line)
+ if errParse != nil {
+ return "", 0, fmt.Errorf("invalid subscription count %q: %v", line, errParse)
+ }
+ return string(channel), subscriptions, nil
+}
+
+func readTestRESPPubSubMessage(r *bufio.Reader) (string, []byte, error) {
+ items, errItems := readRESPArrayOfBulkStrings(r)
+ if errItems != nil {
+ return "", nil, errItems
+ }
+ if len(items) != 3 {
+ return "", nil, fmt.Errorf("pubsub message length = %d, want 3", len(items))
+ }
+ if string(items[0]) != "message" {
+ return "", nil, fmt.Errorf("pubsub message kind = %q", string(items[0]))
+ }
+ return string(items[1]), items[2], nil
+}
+
func TestRedisProtocol_ManagementDisabled_RejectsConnection(t *testing.T) {
t.Setenv("MANAGEMENT_PASSWORD", "")
redisqueue.SetEnabled(false)
@@ -235,6 +297,68 @@ func TestRedisProtocol_HomeEnabled_DisablesConnection(t *testing.T) {
}
}
+func TestRedisProtocol_SUBSCRIBE_UsageSendsSupportRefresh(t *testing.T) {
+ const managementPassword = "test-management-password"
+
+ t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ server := newTestServer(t)
+ if !server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be true")
+ }
+
+ addr, stop := startRedisMuxListener(t, server)
+ t.Cleanup(stop)
+
+ conn, errDial := net.DialTimeout("tcp", addr, time.Second)
+ if errDial != nil {
+ t.Fatalf("failed to dial redis listener: %v", errDial)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ reader := bufio.NewReader(conn)
+ _ = conn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil {
+ t.Fatalf("failed to write AUTH command: %v", errWrite)
+ }
+ if msg, errRead := readTestRESPSimpleString(reader); errRead != nil {
+ t.Fatalf("failed to read AUTH response: %v", errRead)
+ } else if msg != "OK" {
+ t.Fatalf("unexpected AUTH response: %q", msg)
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "SUBSCRIBE", "usage"); errWrite != nil {
+ t.Fatalf("failed to write SUBSCRIBE command: %v", errWrite)
+ }
+ channel, subscriptions, errSubscribe := readTestRESPPubSubSubscribe(reader)
+ if errSubscribe != nil {
+ t.Fatalf("failed to read subscribe response: %v", errSubscribe)
+ }
+ if channel != "usage" || subscriptions != 1 {
+ t.Fatalf("unexpected subscribe response channel=%q subscriptions=%d", channel, subscriptions)
+ }
+
+ channel, payload, errMessage := readTestRESPPubSubMessage(reader)
+ if errMessage != nil {
+ t.Fatalf("failed to read support refresh message: %v", errMessage)
+ }
+ if channel != "usage" || string(payload) != `{"support_refresh":true}` {
+ t.Fatalf("unexpected support refresh message channel=%q payload=%q", channel, string(payload))
+ }
+
+ redisqueue.Enqueue([]byte(`{"id":1}`))
+ channel, payload, errMessage = readTestRESPPubSubMessage(reader)
+ if errMessage != nil {
+ t.Fatalf("failed to read usage message: %v", errMessage)
+ }
+ if channel != "usage" || string(payload) != `{"id":1}` {
+ t.Fatalf("unexpected usage message channel=%q payload=%q", channel, string(payload))
+ }
+}
+
func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) {
const managementPassword = "test-management-password"
diff --git a/internal/redisqueue/queue.go b/internal/redisqueue/queue.go
index 6a2a594ed14..60aecdff823 100644
--- a/internal/redisqueue/queue.go
+++ b/internal/redisqueue/queue.go
@@ -10,6 +10,9 @@ const (
defaultRetentionSeconds int64 = 60
maxRetentionSeconds int64 = 3600
usageSubscriberBuffer = 256
+
+ usageSupportRefreshPayload = `{"support_refresh":true}`
+ usageRefreshPayload = `{"refresh":true}`
)
type queueItem struct {
@@ -83,6 +86,10 @@ func SubscribeUsage() (<-chan []byte, func()) {
return global.subscribeUsage()
}
+func NotifyUsageRefresh() {
+ global.publishToSubscribers([]byte(usageRefreshPayload))
+}
+
func (q *queue) clear() {
q.mu.Lock()
@@ -137,6 +144,7 @@ func (q *queue) publishToSubscribers(payload []byte) bool {
func (q *queue) subscribeUsage() (<-chan []byte, func()) {
subscriber := make(chan []byte, usageSubscriberBuffer)
+ subscriber <- []byte(usageSupportRefreshPayload)
q.mu.Lock()
if q.subscribers == nil {
diff --git a/internal/redisqueue/queue_test.go b/internal/redisqueue/queue_test.go
index f40c8826660..1bc0fc30d4e 100644
--- a/internal/redisqueue/queue_test.go
+++ b/internal/redisqueue/queue_test.go
@@ -12,6 +12,9 @@ func TestEnqueueBroadcastsToUsageSubscribersAndSkipsQueue(t *testing.T) {
second, unsubscribeSecond := SubscribeUsage()
defer unsubscribeSecond()
+ requireUsageSubscriberPayload(t, first, usageSupportRefreshPayload)
+ requireUsageSubscriberPayload(t, second, usageSupportRefreshPayload)
+
Enqueue([]byte("usage-record"))
requireUsageSubscriberPayload(t, first, "usage-record")
@@ -37,6 +40,8 @@ func TestSetEnabledFalseClosesUsageSubscribers(t *testing.T) {
subscriber, unsubscribe := SubscribeUsage()
defer unsubscribe()
+ requireUsageSubscriberPayload(t, subscriber, usageSupportRefreshPayload)
+
SetEnabled(false)
select {
@@ -50,6 +55,24 @@ func TestSetEnabledFalseClosesUsageSubscribers(t *testing.T) {
})
}
+func TestNotifyUsageRefreshBroadcastsOnlyToUsageSubscribers(t *testing.T) {
+ withEnabledQueue(t, func() {
+ subscriber, unsubscribe := SubscribeUsage()
+ defer unsubscribe()
+
+ requireUsageSubscriberPayload(t, subscriber, usageSupportRefreshPayload)
+
+ NotifyUsageRefresh()
+ requireUsageSubscriberPayload(t, subscriber, usageRefreshPayload)
+
+ unsubscribe()
+ NotifyUsageRefresh()
+ if items := PopOldest(1); len(items) != 0 {
+ t.Fatalf("PopOldest() items = %q, want empty after refresh notification without subscribers", items)
+ }
+ })
+}
+
func requireUsageSubscriberPayload(t *testing.T, subscriber <-chan []byte, want string) {
t.Helper()
diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go
index 0a46660e8bd..be6738ce96b 100644
--- a/internal/watcher/clients.go
+++ b/internal/watcher/clients.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
@@ -134,6 +135,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
}
w.refreshAuthState(forceAuthRefresh)
+ redisqueue.NotifyUsageRefresh()
log.Infof("full client load complete - %d clients (%d auth files + %d Gemini API keys + %d Vertex API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
totalNewClients,
@@ -233,6 +235,7 @@ func (w *Watcher) addOrUpdateClient(path string) {
w.persistAuthAsync(fmt.Sprintf("Sync auth %s", filepath.Base(path)), path)
w.dispatchAuthUpdates(updates)
+ redisqueue.NotifyUsageRefresh()
}
func (w *Watcher) removeClient(path string) {
@@ -251,6 +254,7 @@ func (w *Watcher) removeClient(path string) {
w.persistAuthAsync(fmt.Sprintf("Remove auth %s", filepath.Base(path)), path)
w.dispatchAuthUpdates(updates)
+ redisqueue.NotifyUsageRefresh()
}
func (w *Watcher) computePerPathUpdatesLocked(oldByID, newByID map[string]*coreauth.Auth) []AuthUpdate {
diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go
index bb3b5577778..d93c2233594 100644
--- a/internal/watcher/watcher_test.go
+++ b/internal/watcher/watcher_test.go
@@ -15,6 +15,7 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
@@ -441,6 +442,34 @@ func TestRemoveClientRemovesHash(t *testing.T) {
}
}
+func TestAuthFileClientChangesNotifyUsageSubscribersToRefresh(t *testing.T) {
+ tmpDir := t.TempDir()
+ authFile := filepath.Join(tmpDir, "sample.json")
+ if err := os.WriteFile(authFile, []byte(`{"type":"demo","api_key":"k"}`), 0o644); err != nil {
+ t.Fatalf("failed to create auth file: %v", err)
+ }
+
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(true)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ subscriber, unsubscribe := redisqueue.SubscribeUsage()
+ defer unsubscribe()
+ requireWatcherUsagePayload(t, subscriber, `{"support_refresh":true}`)
+
+ w := &Watcher{
+ authDir: tmpDir,
+ lastAuthHashes: make(map[string]string),
+ }
+ w.SetConfig(&config.Config{AuthDir: tmpDir})
+
+ w.addOrUpdateClient(authFile)
+ requireWatcherUsagePayload(t, subscriber, `{"refresh":true}`)
+
+ w.removeClient(authFile)
+ requireWatcherUsagePayload(t, subscriber, `{"refresh":true}`)
+}
+
func TestAuthFileEventsDoNotInvokeSnapshotCoreAuths(t *testing.T) {
tmpDir := t.TempDir()
authFile := filepath.Join(tmpDir, "sample.json")
@@ -699,6 +728,25 @@ func TestReloadClientsHandlesNilConfig(t *testing.T) {
w.reloadClients(true, nil, false)
}
+func TestReloadClientsNotifiesUsageSubscribersToRefresh(t *testing.T) {
+ tmp := t.TempDir()
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(true)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ subscriber, unsubscribe := redisqueue.SubscribeUsage()
+ defer unsubscribe()
+ requireWatcherUsagePayload(t, subscriber, `{"support_refresh":true}`)
+
+ w := &Watcher{
+ authDir: tmp,
+ config: &config.Config{AuthDir: tmp},
+ }
+ w.reloadClients(false, nil, false)
+
+ requireWatcherUsagePayload(t, subscriber, `{"refresh":true}`)
+}
+
func TestReloadClientsFiltersProvidersWithNilCurrentAuths(t *testing.T) {
tmp := t.TempDir()
w := &Watcher{
@@ -711,6 +759,22 @@ func TestReloadClientsFiltersProvidersWithNilCurrentAuths(t *testing.T) {
}
}
+func requireWatcherUsagePayload(t *testing.T, subscriber <-chan []byte, want string) {
+ t.Helper()
+
+ select {
+ case got, ok := <-subscriber:
+ if !ok {
+ t.Fatalf("subscriber closed before receiving %q", want)
+ }
+ if string(got) != want {
+ t.Fatalf("subscriber payload = %q, want %q", string(got), want)
+ }
+ case <-time.After(time.Second):
+ t.Fatalf("timeout waiting for subscriber payload %q", want)
+ }
+}
+
func TestSetAuthUpdateQueueNilResetsDispatch(t *testing.T) {
w := &Watcher{}
queue := make(chan AuthUpdate, 1)
From bf04a24221a41a8e5d1213303c773e7cf82977c5 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Tue, 2 Jun 2026 08:50:32 +0800
Subject: [PATCH 100/248] feat(models): add support for
`grok-composer-2.5-fast` model
- Introduced `grok-composer-2.5-fast` as a new XAI model.
- Updated registry to include display name, description, and configuration details for the new model.
- Enabled support for the model in the Responses API.
---
internal/registry/models/models.json | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json
index 93e0376404d..f1e35fd67db 100644
--- a/internal/registry/models/models.json
+++ b/internal/registry/models/models.json
@@ -2299,6 +2299,25 @@
"high"
]
}
+ },
+ {
+ "id": "grok-composer-2.5-fast",
+ "object": "model",
+ "created": 1740960000,
+ "owned_by": "xai",
+ "type": "xai",
+ "display_name": "Composer 2.5 Fast",
+ "name": "grok-composer-2.5-fast",
+ "description": "xAI Composer 2.5 Fast model for the Responses API.",
+ "context_length": 131072,
+ "max_completion_tokens": 32768,
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high"
+ ]
+ }
}
]
}
From 87d813c56cf4957a71045c642837539355f32f31 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Tue, 2 Jun 2026 10:41:12 +0800
Subject: [PATCH 101/248] chore(models): remove legacy GPT 5.2 and GPT 5.3
Codex entries from registry
- Cleaned up outdated GPT 5.2 and GPT 5.3 Codex model configurations from `models.json`.
- Simplified registry by removing unused model references across all tiers (`codex-team`, `codex-plus`, `codex-pro`).
---
internal/registry/models/models.json | 141 ---------------------------
1 file changed, 141 deletions(-)
diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json
index f1e35fd67db..56739c52aac 100644
--- a/internal/registry/models/models.json
+++ b/internal/registry/models/models.json
@@ -1408,53 +1408,6 @@
}
],
"codex-team": [
- {
- "id": "gpt-5.2",
- "object": "model",
- "created": 1765440000,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.2",
- "version": "gpt-5.2",
- "description": "Stable version of GPT 5.2",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "none",
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
- {
- "id": "gpt-5.3-codex",
- "object": "model",
- "created": 1770307200,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.3 Codex",
- "version": "gpt-5.3",
- "description": "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
{
"id": "gpt-5.4",
"object": "model",
@@ -1549,53 +1502,6 @@
}
],
"codex-plus": [
- {
- "id": "gpt-5.2",
- "object": "model",
- "created": 1765440000,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.2",
- "version": "gpt-5.2",
- "description": "Stable version of GPT 5.2",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "none",
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
- {
- "id": "gpt-5.3-codex",
- "object": "model",
- "created": 1770307200,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.3 Codex",
- "version": "gpt-5.3",
- "description": "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
{
"id": "gpt-5.3-codex-spark",
"object": "model",
@@ -1713,53 +1619,6 @@
}
],
"codex-pro": [
- {
- "id": "gpt-5.2",
- "object": "model",
- "created": 1765440000,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.2",
- "version": "gpt-5.2",
- "description": "Stable version of GPT 5.2",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "none",
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
- {
- "id": "gpt-5.3-codex",
- "object": "model",
- "created": 1770307200,
- "owned_by": "openai",
- "type": "openai",
- "display_name": "GPT 5.3 Codex",
- "version": "gpt-5.3",
- "description": "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.",
- "context_length": 400000,
- "max_completion_tokens": 128000,
- "supported_parameters": [
- "tools"
- ],
- "thinking": {
- "levels": [
- "low",
- "medium",
- "high",
- "xhigh"
- ]
- }
- },
{
"id": "gpt-5.3-codex-spark",
"object": "model",
From 7cb466a8e628cfa008385ba99c84204b081118d5 Mon Sep 17 00:00:00 2001
From: Edward Becker
Date: Tue, 2 Jun 2026 01:11:03 -0400
Subject: [PATCH 102/248] docs: add Panopticon to "Who is with us?"
---
README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/README.md b/README.md
index 9c855bf4ba0..969d2282666 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,10 @@ Windows-focused, local-first desktop management platform for Codex CLI built on
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.
+### [Panopticon](https://github.com/eltmon/panopticon-cli)
+
+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.
+
> [!NOTE]
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
From c9dc6bd62803a5de98f70130991040a2c9fbaa5f Mon Sep 17 00:00:00 2001
From: sususu98
Date: Tue, 2 Jun 2026 13:43:07 +0800
Subject: [PATCH 103/248] Fix Home auth refresh retry handling
Parse Home refresh auth envelopes so refreshed access tokens are used instead of returning missing access token.
Stop retrying when Home dispatch returns an auth that already failed within the same request.
---
.../runtime/executor/helps/home_refresh.go | 44 ++++++++-
.../executor/helps/home_refresh_test.go | 80 ++++++++++++++++
sdk/cliproxy/auth/conductor.go | 31 +++++-
sdk/cliproxy/auth/home_retry_loop_test.go | 96 +++++++++++++++++++
4 files changed, 246 insertions(+), 5 deletions(-)
create mode 100644 sdk/cliproxy/auth/home_retry_loop_test.go
diff --git a/internal/runtime/executor/helps/home_refresh.go b/internal/runtime/executor/helps/home_refresh.go
index dc027040103..7c9719927c3 100644
--- a/internal/runtime/executor/helps/home_refresh.go
+++ b/internal/runtime/executor/helps/home_refresh.go
@@ -30,12 +30,26 @@ type homeErrorEnvelope struct {
Error *homeErrorDetail `json:"error"`
}
+type homeRefreshAuthEnvelope struct {
+ Auth cliproxyauth.Auth `json:"auth"`
+ AuthIndex string `json:"auth_index"`
+}
+
type homeErrorDetail struct {
Type string `json:"type"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
}
+type homeRefreshClient interface {
+ HeartbeatOK() bool
+ GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, error)
+}
+
+var currentHomeRefreshClient = func() homeRefreshClient {
+ return home.Current()
+}
+
// RefreshAuthViaHome replaces local refresh logic when home control plane integration is enabled.
// It returns (updatedAuth, true, nil) when home refresh succeeds; (nil, true, err) when home is
// enabled but refresh fails; and (nil, false, nil) when home is disabled.
@@ -50,7 +64,7 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya
return nil, true, homeStatusErr{code: http.StatusInternalServerError, msg: "home refresh: auth is nil"}
}
- client := home.Current()
+ client := currentHomeRefreshClient()
if client == nil || !client.HeartbeatOK() {
return nil, true, homeStatusErr{code: http.StatusServiceUnavailable, msg: "home control center unavailable"}
}
@@ -81,13 +95,35 @@ func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxya
return nil, true, homeStatusErr{code: statusFromHomeErrorCode(code), msg: msg}
}
- var updated cliproxyauth.Auth
- if errUnmarshal := json.Unmarshal(raw, &updated); errUnmarshal != nil {
+ updated, returnedIndex, errParse := parseHomeRefreshAuth(raw)
+ if errParse != nil {
return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: "home returned invalid auth payload"}
}
+ if returnedIndex != "" {
+ authIndex = returnedIndex
+ }
updated.Index = authIndex
updated.EnsureIndex()
- return &updated, true, nil
+ return updated, true, nil
+}
+
+func parseHomeRefreshAuth(raw []byte) (*cliproxyauth.Auth, string, error) {
+ var rawObject map[string]json.RawMessage
+ if errUnmarshal := json.Unmarshal(raw, &rawObject); errUnmarshal != nil {
+ return nil, "", errUnmarshal
+ }
+ if _, ok := rawObject["auth"]; ok {
+ var envelope homeRefreshAuthEnvelope
+ if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil {
+ return nil, "", errUnmarshal
+ }
+ return &envelope.Auth, strings.TrimSpace(envelope.AuthIndex), nil
+ }
+ var updated cliproxyauth.Auth
+ if errUnmarshal := json.Unmarshal(raw, &updated); errUnmarshal != nil {
+ return nil, "", errUnmarshal
+ }
+ return &updated, "", nil
}
func statusFromHomeErrorCode(code string) int {
diff --git a/internal/runtime/executor/helps/home_refresh_test.go b/internal/runtime/executor/helps/home_refresh_test.go
index c4507fdcc1f..e87c2b41568 100644
--- a/internal/runtime/executor/helps/home_refresh_test.go
+++ b/internal/runtime/executor/helps/home_refresh_test.go
@@ -1,8 +1,14 @@
package helps
import (
+ "context"
+ "encoding/json"
"net/http"
+ "sync/atomic"
"testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestStatusFromHomeErrorCodeMapsAuthenticationErrorToUnauthorized(t *testing.T) {
@@ -13,3 +19,77 @@ func TestStatusFromHomeErrorCodeMapsAuthenticationErrorToUnauthorized(t *testing
t.Fatalf("statusFromHomeErrorCode(unauthorized) = %d, want %d", got, http.StatusUnauthorized)
}
}
+
+type fakeHomeRefreshClient struct {
+ calls atomic.Int32
+ authIndex string
+ raw []byte
+}
+
+func (c *fakeHomeRefreshClient) HeartbeatOK() bool {
+ return true
+}
+
+func (c *fakeHomeRefreshClient) GetRefreshAuth(_ context.Context, authIndex string) ([]byte, error) {
+ c.calls.Add(1)
+ c.authIndex = authIndex
+ return c.raw, nil
+}
+
+func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) {
+ raw, errMarshal := json.Marshal(struct {
+ Auth cliproxyauth.Auth `json:"auth"`
+ AuthIndex string `json:"auth_index"`
+ }{
+ Auth: cliproxyauth.Auth{
+ ID: "home-auth-1",
+ Provider: "antigravity",
+ Metadata: map[string]any{
+ "access_token": "new-access-token",
+ },
+ },
+ AuthIndex: "home-index-1",
+ })
+ if errMarshal != nil {
+ t.Fatalf("marshal home envelope: %v", errMarshal)
+ }
+
+ client := &fakeHomeRefreshClient{raw: raw}
+ oldCurrentHomeRefreshClient := currentHomeRefreshClient
+ currentHomeRefreshClient = func() homeRefreshClient {
+ return client
+ }
+ t.Cleanup(func() {
+ currentHomeRefreshClient = oldCurrentHomeRefreshClient
+ })
+
+ cfg := &config.Config{Home: config.HomeConfig{Enabled: true}}
+ auth := &cliproxyauth.Auth{
+ ID: "home-auth-1",
+ Provider: "antigravity",
+ Index: "home-index-1",
+ Metadata: map[string]any{
+ "refresh_token": "refresh-token",
+ },
+ }
+
+ updated, handled, err := RefreshAuthViaHome(context.Background(), cfg, auth)
+ if err != nil {
+ t.Fatalf("RefreshAuthViaHome error: %v", err)
+ }
+ if !handled {
+ t.Fatal("RefreshAuthViaHome handled = false, want true")
+ }
+ if got := client.calls.Load(); got != 1 {
+ t.Fatalf("home refresh calls = %d, want 1", got)
+ }
+ if client.authIndex != "home-index-1" {
+ t.Fatalf("home refresh auth_index = %q, want home-index-1", client.authIndex)
+ }
+ if updated == nil {
+ t.Fatal("updated auth = nil")
+ }
+ if got := updated.Metadata["access_token"]; got != "new-access-token" {
+ t.Fatalf("updated access_token = %q, want new-access-token", got)
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index 33116fba8f5..c5c7e3f9497 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -3368,6 +3368,23 @@ func shouldReturnLastErrorOnPickFailure(homeMode bool, lastErr error, errPick er
return isHomeRequestRetryExceededError(errPick)
}
+func homeAuthAlreadyTried(tried map[string]struct{}, authID string) bool {
+ authID = strings.TrimSpace(authID)
+ if authID == "" || len(tried) == 0 {
+ return false
+ }
+ _, ok := tried[authID]
+ return ok
+}
+
+func repeatedHomeAuthError() *Error {
+ return &Error{
+ Code: homeRequestRetryExceededErrorCode,
+ Message: "home returned a previously tried auth",
+ HTTPStatus: http.StatusServiceUnavailable,
+ }
+}
+
type homeAuthDispatchResponse struct {
Model string `json:"model"`
Provider string `json:"provider"`
@@ -3376,6 +3393,15 @@ type homeAuthDispatchResponse struct {
Auth Auth `json:"auth"`
}
+type homeAuthDispatcher interface {
+ HeartbeatOK() bool
+ RPopAuth(ctx context.Context, requestedModel string, sessionID string, headers http.Header, count int) ([]byte, error)
+}
+
+var currentHomeDispatcher = func() homeAuthDispatcher {
+ return home.Current()
+}
+
func setHomeUserAPIKeyOnGinContext(ctx context.Context, apiKey string) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" || ctx == nil {
@@ -3575,7 +3601,7 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro
}
}
- client := home.Current()
+ client := currentHomeDispatcher()
if client == nil || !client.HeartbeatOK() {
return nil, nil, "", &Error{Code: "home_unavailable", Message: "home control center unavailable", HTTPStatus: http.StatusServiceUnavailable}
}
@@ -3630,6 +3656,9 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro
if strings.TrimSpace(auth.ID) == "" {
return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without id", HTTPStatus: http.StatusBadGateway}
}
+ if homeAuthAlreadyTried(tried, auth.ID) {
+ return nil, nil, "", repeatedHomeAuthError()
+ }
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
if providerKey == "" {
return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without provider", HTTPStatus: http.StatusBadGateway}
diff --git a/sdk/cliproxy/auth/home_retry_loop_test.go b/sdk/cliproxy/auth/home_retry_loop_test.go
new file mode 100644
index 00000000000..16f6e824bde
--- /dev/null
+++ b/sdk/cliproxy/auth/home_retry_loop_test.go
@@ -0,0 +1,96 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+)
+
+type repeatedHomeAuthDispatcher struct {
+ calls atomic.Int32
+}
+
+func (d *repeatedHomeAuthDispatcher) HeartbeatOK() bool {
+ return true
+}
+
+func (d *repeatedHomeAuthDispatcher) RPopAuth(context.Context, string, string, http.Header, int) ([]byte, error) {
+ d.calls.Add(1)
+ raw, _ := json.Marshal(homeAuthDispatchResponse{
+ Auth: Auth{
+ ID: "home-auth-1",
+ Provider: "home-loop-test",
+ Status: StatusActive,
+ Metadata: map[string]any{"email": "loop@example.com"},
+ },
+ })
+ return raw, nil
+}
+
+type unauthorizedHomeExecutor struct {
+ calls atomic.Int32
+}
+
+func (e *unauthorizedHomeExecutor) Identifier() string { return "home-loop-test" }
+
+func (e *unauthorizedHomeExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ e.calls.Add(1)
+ return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusUnauthorized, Message: "missing access token"}
+}
+
+func (e *unauthorizedHomeExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ e.calls.Add(1)
+ return nil, &Error{HTTPStatus: http.StatusUnauthorized, Message: "missing access token"}
+}
+
+func (e *unauthorizedHomeExecutor) Refresh(context.Context, *Auth) (*Auth, error) {
+ return nil, &Error{HTTPStatus: http.StatusUnauthorized, Message: "missing access token"}
+}
+
+func (e *unauthorizedHomeExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ e.calls.Add(1)
+ return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusUnauthorized, Message: "missing access token"}
+}
+
+func (e *unauthorizedHomeExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {
+ return nil, &Error{HTTPStatus: http.StatusUnauthorized, Message: "missing access token"}
+}
+
+func TestManagerExecuteHomeStopsWhenDispatchRepeatsTriedAuth(t *testing.T) {
+ dispatcher := &repeatedHomeAuthDispatcher{}
+ oldCurrentHomeDispatcher := currentHomeDispatcher
+ currentHomeDispatcher = func() homeAuthDispatcher {
+ return dispatcher
+ }
+ t.Cleanup(func() {
+ currentHomeDispatcher = oldCurrentHomeDispatcher
+ })
+
+ executor := &unauthorizedHomeExecutor{}
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ manager.RegisterExecutor(executor)
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ _, err := manager.Execute(ctx, []string{"home-loop-test"}, cliproxyexecutor.Request{Model: "gemini-3.5-flash-low"}, cliproxyexecutor.Options{})
+ if err == nil {
+ t.Fatal("Execute error = nil, want missing access token")
+ }
+ if statusCodeFromError(err) != http.StatusUnauthorized {
+ t.Fatalf("Execute error status = %d, want 401 (%v)", statusCodeFromError(err), err)
+ }
+ if got := executor.calls.Load(); got != 1 {
+ t.Fatalf("executor calls = %d, want 1", got)
+ }
+ if got := dispatcher.calls.Load(); got != 2 {
+ t.Fatalf("home dispatch calls = %d, want 2", got)
+ }
+}
From 603a08fc1aad4d4c4c00c6274602bbce7eff1eb7 Mon Sep 17 00:00:00 2001
From: sususu98
Date: Tue, 2 Jun 2026 00:23:14 +0800
Subject: [PATCH 104/248] feat(codex): cache reasoning replay items
---
.../cache/codex_reasoning_replay_cache.go | 253 ++++++
.../codex_reasoning_replay_cache_test.go | 73 ++
internal/cache/signature_cache.go | 1 +
internal/runtime/executor/codex_executor.go | 544 +++++++++++-
.../executor/codex_executor_cache_test.go | 98 ++-
...ex_executor_reasoning_replay_cache_test.go | 803 ++++++++++++++++++
.../codex_executor_stream_output_test.go | 7 +
.../executor/codex_websockets_executor.go | 90 +-
.../codex_websockets_executor_test.go | 105 ++-
9 files changed, 1916 insertions(+), 58 deletions(-)
create mode 100644 internal/cache/codex_reasoning_replay_cache.go
create mode 100644 internal/cache/codex_reasoning_replay_cache_test.go
create mode 100644 internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
diff --git a/internal/cache/codex_reasoning_replay_cache.go b/internal/cache/codex_reasoning_replay_cache.go
new file mode 100644
index 00000000000..820f7f1d185
--- /dev/null
+++ b/internal/cache/codex_reasoning_replay_cache.go
@@ -0,0 +1,253 @@
+package cache
+
+import (
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+const (
+ // CodexReasoningReplayCacheTTL limits how long encrypted reasoning replay
+ // items stay in process memory.
+ CodexReasoningReplayCacheTTL = 1 * time.Hour
+
+ // CodexReasoningReplayCacheMaxEntries bounds process memory for replay
+ // continuity. Oldest entries are evicted first.
+ CodexReasoningReplayCacheMaxEntries = 10240
+
+ // CodexReasoningReplayCacheEvictBatchSize leaves headroom after the cache
+ // reaches capacity so high write volume does not rescan the map every turn.
+ CodexReasoningReplayCacheEvictBatchSize = 128
+)
+
+type codexReasoningReplayEntry struct {
+ Items [][]byte
+ Timestamp time.Time
+}
+
+var (
+ codexReasoningReplayMu sync.Mutex
+ codexReasoningReplayEntries = make(map[string]codexReasoningReplayEntry)
+)
+
+// CacheCodexReasoningReplayItem stores a final GPT/Codex reasoning item for
+// stateless replay. The stored item is normalized to the minimal shape accepted
+// by Responses input replay.
+func CacheCodexReasoningReplayItem(modelName, sessionKey string, item []byte) bool {
+ return CacheCodexReasoningReplayItems(modelName, sessionKey, [][]byte{item})
+}
+
+// CacheCodexReasoningReplayItems stores the final GPT/Codex assistant output
+// items needed to replay a stateless next turn.
+func CacheCodexReasoningReplayItems(modelName, sessionKey string, items [][]byte) bool {
+ key := codexReasoningReplayCacheKey(modelName, sessionKey)
+ if key == "" {
+ return false
+ }
+ normalized, ok := normalizeCodexReasoningReplayItems(items)
+ if !ok {
+ return false
+ }
+
+ cacheCleanupOnce.Do(startCacheCleanup)
+ now := time.Now()
+ codexReasoningReplayMu.Lock()
+ defer codexReasoningReplayMu.Unlock()
+ codexReasoningReplayEntries[key] = codexReasoningReplayEntry{
+ Items: normalized,
+ Timestamp: now,
+ }
+ if len(codexReasoningReplayEntries) > CodexReasoningReplayCacheMaxEntries {
+ evictOldestCodexReasoningReplayEntries(CodexReasoningReplayCacheEvictBatchSize)
+ }
+ return true
+}
+
+// GetCodexReasoningReplayItem retrieves a normalized reasoning replay item.
+func GetCodexReasoningReplayItem(modelName, sessionKey string) ([]byte, bool) {
+ items, ok := GetCodexReasoningReplayItems(modelName, sessionKey)
+ if !ok || len(items) == 0 {
+ return nil, false
+ }
+ return items[0], true
+}
+
+// GetCodexReasoningReplayItems retrieves normalized assistant output items.
+func GetCodexReasoningReplayItems(modelName, sessionKey string) ([][]byte, bool) {
+ key := codexReasoningReplayCacheKey(modelName, sessionKey)
+ if key == "" {
+ return nil, false
+ }
+
+ cacheCleanupOnce.Do(startCacheCleanup)
+ now := time.Now()
+ codexReasoningReplayMu.Lock()
+ defer codexReasoningReplayMu.Unlock()
+ entry, ok := codexReasoningReplayEntries[key]
+ if !ok {
+ return nil, false
+ }
+ if now.Sub(entry.Timestamp) > CodexReasoningReplayCacheTTL {
+ delete(codexReasoningReplayEntries, key)
+ return nil, false
+ }
+ entry.Timestamp = now
+ codexReasoningReplayEntries[key] = entry
+ return cloneCodexReasoningReplayItems(entry.Items), true
+}
+
+// DeleteCodexReasoningReplayItem removes one replay item after upstream rejects
+// it or the caller otherwise knows it is stale.
+func DeleteCodexReasoningReplayItem(modelName, sessionKey string) {
+ key := codexReasoningReplayCacheKey(modelName, sessionKey)
+ if key == "" {
+ return
+ }
+ codexReasoningReplayMu.Lock()
+ delete(codexReasoningReplayEntries, key)
+ codexReasoningReplayMu.Unlock()
+}
+
+// ClearCodexReasoningReplayCache clears all Codex reasoning replay state.
+func ClearCodexReasoningReplayCache() {
+ codexReasoningReplayMu.Lock()
+ codexReasoningReplayEntries = make(map[string]codexReasoningReplayEntry)
+ codexReasoningReplayMu.Unlock()
+}
+
+func codexReasoningReplayCacheKey(modelName, sessionKey string) string {
+ modelName = strings.TrimSpace(modelName)
+ sessionKey = strings.TrimSpace(sessionKey)
+ if modelName == "" || sessionKey == "" {
+ return ""
+ }
+ // The session key is the continuity boundary. Keep this independent from
+ // the selected upstream Codex credential so auth failover can preserve replay.
+ return strings.Join([]string{"codex-reasoning-replay", modelName, sessionKey}, "\x00")
+}
+
+func normalizeCodexReasoningReplayItems(items [][]byte) ([][]byte, bool) {
+ normalized := make([][]byte, 0, len(items))
+ for _, item := range items {
+ normalizedItem, ok := normalizeCodexReasoningReplayItem(item)
+ if ok {
+ normalized = append(normalized, normalizedItem)
+ }
+ }
+ return normalized, len(normalized) > 0
+}
+
+func normalizeCodexReasoningReplayItem(item []byte) ([]byte, bool) {
+ itemResult := gjson.ParseBytes(item)
+ switch strings.TrimSpace(itemResult.Get("type").String()) {
+ case "reasoning":
+ return normalizeCodexReasoningReplayReasoningItem(itemResult)
+ case "function_call":
+ return normalizeCodexReasoningReplayFunctionCallItem(itemResult)
+ case "custom_tool_call":
+ return normalizeCodexReasoningReplayCustomToolCallItem(itemResult)
+ default:
+ return nil, false
+ }
+}
+
+func normalizeCodexReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte, bool) {
+ encryptedContentResult := itemResult.Get("encrypted_content")
+ if encryptedContentResult.Type != gjson.String {
+ return nil, false
+ }
+ encryptedContent := encryptedContentResult.String()
+ if encryptedContent != strings.TrimSpace(encryptedContent) {
+ return nil, false
+ }
+ if _, err := signature.InspectGPTReasoningSignature(encryptedContent); err != nil {
+ return nil, false
+ }
+
+ normalized := []byte(`{"type":"reasoning","summary":[],"content":null}`)
+ normalized, _ = sjson.SetBytes(normalized, "encrypted_content", encryptedContent)
+ return normalized, true
+}
+
+func normalizeCodexReasoningReplayFunctionCallItem(itemResult gjson.Result) ([]byte, bool) {
+ callID := strings.TrimSpace(itemResult.Get("call_id").String())
+ name := strings.TrimSpace(itemResult.Get("name").String())
+ arguments := itemResult.Get("arguments")
+ if callID == "" || name == "" || arguments.Type != gjson.String {
+ return nil, false
+ }
+
+ normalized := []byte(`{"type":"function_call"}`)
+ normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
+ normalized, _ = sjson.SetBytes(normalized, "name", name)
+ normalized, _ = sjson.SetBytes(normalized, "arguments", arguments.String())
+ return normalized, true
+}
+
+func normalizeCodexReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([]byte, bool) {
+ callID := strings.TrimSpace(itemResult.Get("call_id").String())
+ name := strings.TrimSpace(itemResult.Get("name").String())
+ input := itemResult.Get("input")
+ if callID == "" || name == "" || !input.Exists() {
+ return nil, false
+ }
+
+ normalized := []byte(`{"type":"custom_tool_call","status":"completed"}`)
+ if status := strings.TrimSpace(itemResult.Get("status").String()); status != "" {
+ normalized, _ = sjson.SetBytes(normalized, "status", status)
+ }
+ normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
+ normalized, _ = sjson.SetBytes(normalized, "name", name)
+ if input.Type == gjson.String {
+ normalized, _ = sjson.SetBytes(normalized, "input", input.String())
+ } else {
+ normalized, _ = sjson.SetRawBytes(normalized, "input", []byte(input.Raw))
+ }
+ return normalized, true
+}
+
+func cloneCodexReasoningReplayItems(items [][]byte) [][]byte {
+ cloned := make([][]byte, 0, len(items))
+ for _, item := range items {
+ cloned = append(cloned, append([]byte(nil), item...))
+ }
+ return cloned
+}
+
+func evictOldestCodexReasoningReplayEntries(count int) {
+ if count <= 0 || len(codexReasoningReplayEntries) == 0 {
+ return
+ }
+ type candidate struct {
+ key string
+ timestamp time.Time
+ }
+ candidates := make([]candidate, 0, len(codexReasoningReplayEntries))
+ for key, entry := range codexReasoningReplayEntries {
+ candidates = append(candidates, candidate{key: key, timestamp: entry.Timestamp})
+ }
+ sort.Slice(candidates, func(i, j int) bool {
+ return candidates[i].timestamp.Before(candidates[j].timestamp)
+ })
+ if count > len(candidates) {
+ count = len(candidates)
+ }
+ for i := 0; i < count; i++ {
+ delete(codexReasoningReplayEntries, candidates[i].key)
+ }
+}
+
+func purgeExpiredCodexReasoningReplayCache(now time.Time) {
+ codexReasoningReplayMu.Lock()
+ for key, entry := range codexReasoningReplayEntries {
+ if now.Sub(entry.Timestamp) > CodexReasoningReplayCacheTTL {
+ delete(codexReasoningReplayEntries, key)
+ }
+ }
+ codexReasoningReplayMu.Unlock()
+}
diff --git a/internal/cache/codex_reasoning_replay_cache_test.go b/internal/cache/codex_reasoning_replay_cache_test.go
new file mode 100644
index 00000000000..cc43ed414a7
--- /dev/null
+++ b/internal/cache/codex_reasoning_replay_cache_test.go
@@ -0,0 +1,73 @@
+package cache
+
+import (
+ "encoding/base64"
+ "fmt"
+ "testing"
+)
+
+func validCodexReasoningReplayEncryptedContentForTest(seed byte) string {
+ payload := make([]byte, 1+8+16+16+32)
+ payload[0] = 0x80
+ for i := 9; i < len(payload); i++ {
+ payload[i] = seed + byte(i)
+ }
+ return base64.RawURLEncoding.EncodeToString(payload)
+}
+
+func TestCodexReasoningReplayCacheRejectsInvalidItems(t *testing.T) {
+ ClearCodexReasoningReplayCache()
+ t.Cleanup(ClearCodexReasoningReplayCache)
+
+ if CacheCodexReasoningReplayItem("gpt-5.4", "session", []byte(`{"type":"reasoning","encrypted_content":"bad","summary":[]}`)) {
+ t.Fatal("invalid encrypted_content should not be cached")
+ }
+ if _, ok := GetCodexReasoningReplayItem("gpt-5.4", "session"); ok {
+ t.Fatal("invalid item was cached")
+ }
+}
+
+func TestCodexReasoningReplayCacheScopesByModelAndSession(t *testing.T) {
+ ClearCodexReasoningReplayCache()
+ t.Cleanup(ClearCodexReasoningReplayCache)
+
+ encryptedContent := validCodexReasoningReplayEncryptedContentForTest(7)
+ if !CacheCodexReasoningReplayItem("gpt-5.4", "session-a", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}`)) {
+ t.Fatal("valid item was not cached")
+ }
+
+ if _, ok := GetCodexReasoningReplayItem("gpt-5.5", "session-a"); ok {
+ t.Fatal("cache should not hit across models")
+ }
+ if _, ok := GetCodexReasoningReplayItem("gpt-5.4", "session-b"); ok {
+ t.Fatal("cache should not hit across sessions")
+ }
+
+ item, ok := GetCodexReasoningReplayItem("gpt-5.4", "session-a")
+ if !ok {
+ t.Fatal("cache miss for original model and session")
+ }
+ if string(item) != `{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}` {
+ t.Fatalf("normalized item = %s", string(item))
+ }
+}
+
+func TestCodexReasoningReplayCacheBatchEvictsWhenFull(t *testing.T) {
+ ClearCodexReasoningReplayCache()
+ t.Cleanup(ClearCodexReasoningReplayCache)
+
+ encryptedContent := validCodexReasoningReplayEncryptedContentForTest(9)
+ item := []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"` + encryptedContent + `"}`)
+ for i := 0; i <= CodexReasoningReplayCacheMaxEntries; i++ {
+ if !CacheCodexReasoningReplayItem("gpt-5.4", fmt.Sprintf("session-%d", i), item) {
+ t.Fatalf("cache insert %d failed", i)
+ }
+ }
+
+ codexReasoningReplayMu.Lock()
+ gotLen := len(codexReasoningReplayEntries)
+ codexReasoningReplayMu.Unlock()
+ if gotLen >= CodexReasoningReplayCacheMaxEntries {
+ t.Fatalf("cache entries = %d, want batch eviction below max %d", gotLen, CodexReasoningReplayCacheMaxEntries)
+ }
+}
diff --git a/internal/cache/signature_cache.go b/internal/cache/signature_cache.go
index fd2ccab7ca7..42020ae726e 100644
--- a/internal/cache/signature_cache.go
+++ b/internal/cache/signature_cache.go
@@ -94,6 +94,7 @@ func purgeExpiredCaches() {
}
return true
})
+ purgeExpiredCodexReasoningReplayCache(now)
}
// CacheSignature stores a thinking signature for a given model group and text.
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index d3c3925ed36..2b243db8a51 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -4,17 +4,22 @@ import (
"bufio"
"bytes"
"context"
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"io"
"net/http"
+ "regexp"
"sort"
"strings"
"time"
codexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
@@ -36,6 +41,7 @@ const (
)
var dataTag = []byte("data:")
+var codexClaudeCodeSessionPattern = regexp.MustCompile(`_session_([a-f0-9-]+)$`)
// Streamed Codex responses may emit response.output_item.done events while leaving
// response.completed.response.output empty. Keep the stream path aligned with the
@@ -101,6 +107,14 @@ func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]
}
func codexTerminalStreamContextLengthErr(eventData []byte) (statusErr, bool) {
+ streamErr, body, ok := codexTerminalStreamErr(eventData)
+ if !ok || !codexTerminalErrorIsContextLength(body) {
+ return statusErr{}, false
+ }
+ return streamErr, true
+}
+
+func codexTerminalStreamErr(eventData []byte) (statusErr, []byte, bool) {
eventType := gjson.GetBytes(eventData, "type").String()
var body []byte
switch eventType {
@@ -115,15 +129,23 @@ func codexTerminalStreamContextLengthErr(eventData []byte) (statusErr, bool) {
body = codexTerminalErrorBody(eventData, "error")
}
default:
- return statusErr{}, false
+ return statusErr{}, nil, false
}
if len(body) == 0 {
- return statusErr{}, false
+ return statusErr{}, nil, false
}
- if !codexTerminalErrorIsContextLength(body) {
- return statusErr{}, false
+ if !codexTerminalStreamErrShouldHandle(body) {
+ return statusErr{}, nil, false
}
- return newCodexStatusErr(http.StatusBadRequest, body), true
+ return newCodexStatusErr(http.StatusBadRequest, body), body, true
+}
+
+func codexTerminalStreamErrShouldHandle(body []byte) bool {
+ if codexTerminalErrorIsContextLength(body) {
+ return true
+ }
+ code, _, ok := codexStatusErrorClassification(http.StatusBadRequest, body)
+ return ok && code == "thinking_signature_invalid"
}
func codexTerminalErrorBody(eventData []byte, path string) []byte {
@@ -217,6 +239,482 @@ func translateCodexRequestPair(from, to sdktranslator.Format, model string, orig
return originalTranslated, body
}
+type codexReasoningReplayScope struct {
+ modelName string
+ sessionKey string
+}
+
+func (s codexReasoningReplayScope) valid() bool {
+ return strings.TrimSpace(s.modelName) != "" && strings.TrimSpace(s.sessionKey) != ""
+}
+
+func applyCodexReasoningReplayCache(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) ([]byte, codexReasoningReplayScope) {
+ scope := codexReasoningReplayScopeFromRequest(ctx, from, req, opts, body)
+ if !scope.valid() {
+ return body, scope
+ }
+ items, ok := internalcache.GetCodexReasoningReplayItems(scope.modelName, scope.sessionKey)
+ if !ok {
+ return body, scope
+ }
+ items = filterCodexReasoningReplayItemsForInput(body, items)
+ if len(items) == 0 {
+ return body, scope
+ }
+ updated, ok := insertCodexReasoningReplayItems(body, items)
+ if !ok {
+ return body, scope
+ }
+ return updated, scope
+}
+
+func codexReasoningReplayScopeFromRequest(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) codexReasoningReplayScope {
+ if !codexReasoningReplayEnabledForSource(from) {
+ return codexReasoningReplayScope{}
+ }
+ return codexReasoningReplayScope{
+ modelName: thinking.ParseSuffix(req.Model).ModelName,
+ sessionKey: codexReasoningReplaySessionKey(ctx, from, req, opts, body),
+ }
+}
+
+func codexReasoningReplayEnabledForSource(from sdktranslator.Format) bool {
+ return sourceFormatEqual(from, sdktranslator.FormatClaude)
+}
+
+func sourceFormatEqual(from, want sdktranslator.Format) bool {
+ return strings.EqualFold(strings.TrimSpace(from.String()), want.String())
+}
+
+func codexClaudeCodeReplaySessionKey(payload []byte) string {
+ sessionID := extractClaudeCodeSessionIDForCodexReplay(payload)
+ if sessionID == "" {
+ return ""
+ }
+ return "claude:" + sessionID
+}
+
+func codexClaudeCodePromptCacheStorageKey(req cliproxyexecutor.Request) string {
+ sessionID := extractClaudeCodeSessionIDForCodexReplay(req.Payload)
+ if sessionID == "" {
+ return ""
+ }
+ return fmt.Sprintf("%s-claude:%s", req.Model, sessionID)
+}
+
+func codexClaudeCodePromptCache(req cliproxyexecutor.Request) (helps.CodexCache, bool) {
+ key := codexClaudeCodePromptCacheStorageKey(req)
+ if key == "" {
+ return helps.CodexCache{}, false
+ }
+ if cache, ok := helps.GetCodexCache(key); ok {
+ return cache, true
+ }
+ cache := helps.CodexCache{
+ ID: uuid.New().String(),
+ Expire: time.Now().Add(1 * time.Hour),
+ }
+ helps.SetCodexCache(key, cache)
+ return cache, true
+}
+
+func extractClaudeCodeSessionIDForCodexReplay(payload []byte) string {
+ if len(payload) == 0 {
+ return ""
+ }
+ userID := gjson.GetBytes(payload, "metadata.user_id").String()
+ if userID == "" {
+ return ""
+ }
+ if matches := codexClaudeCodeSessionPattern.FindStringSubmatch(userID); len(matches) >= 2 {
+ return matches[1]
+ }
+ if len(userID) > 0 && userID[0] == '{' {
+ return gjson.Get(userID, "session_id").String()
+ }
+ return ""
+}
+
+func codexReasoningReplaySessionKey(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) string {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if value := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
+ return "execution:" + value
+ }
+ if value := metadataString(req.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
+ return "execution:" + value
+ }
+ if value := codexReasoningReplaySessionKeyFromPayload(body); value != "" {
+ return value
+ }
+ if value := codexReasoningReplaySessionKeyFromPayload(req.Payload); value != "" {
+ return value
+ }
+ if value := codexReasoningReplaySessionKeyFromHeaders(opts.Headers); value != "" {
+ return value
+ }
+ if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
+ if value := codexReasoningReplaySessionKeyFromHeaders(ginCtx.Request.Header); value != "" {
+ return value
+ }
+ }
+ if sourceFormatEqual(from, sdktranslator.FormatClaude) {
+ return codexClaudeCodeReplaySessionKey(req.Payload)
+ }
+ if sourceFormatEqual(from, sdktranslator.FormatOpenAI) {
+ if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" {
+ return "prompt-cache:" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
+ }
+ }
+ return ""
+}
+
+func metadataString(metadata map[string]any, key string) string {
+ if len(metadata) == 0 {
+ return ""
+ }
+ raw, ok := metadata[key]
+ if !ok || raw == nil {
+ return ""
+ }
+ switch v := raw.(type) {
+ case string:
+ return strings.TrimSpace(v)
+ case []byte:
+ return strings.TrimSpace(string(v))
+ default:
+ return ""
+ }
+}
+
+func codexReasoningReplaySessionKeyFromPayload(payload []byte) string {
+ if len(payload) == 0 {
+ return ""
+ }
+ if promptCacheKey := strings.TrimSpace(gjson.GetBytes(payload, "prompt_cache_key").String()); promptCacheKey != "" {
+ return "prompt-cache:" + promptCacheKey
+ }
+ if windowID := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-window-id").String()); windowID != "" {
+ return "window:" + windowID
+ }
+ if turnMetadata := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
+ return codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata)
+ }
+ return ""
+}
+
+func codexReasoningReplaySessionKeyFromHeaders(headers http.Header) string {
+ if headers == nil {
+ return ""
+ }
+ if turnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); turnMetadata != "" {
+ if key := codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata); key != "" {
+ return key
+ }
+ }
+ if windowID := strings.TrimSpace(headerValueCaseInsensitive(headers, "X-Codex-Window-Id")); windowID != "" {
+ return "window:" + windowID
+ }
+ for _, headerName := range []string{"Session_id", "session_id", "Session-Id"} {
+ if value := strings.TrimSpace(headerValueCaseInsensitive(headers, headerName)); value != "" {
+ return "session-id:" + value
+ }
+ }
+ if conversationID := strings.TrimSpace(headerValueCaseInsensitive(headers, "Conversation_id")); conversationID != "" {
+ return "conversation_id:" + conversationID
+ }
+ return ""
+}
+
+func codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata string) string {
+ if promptCacheKey := strings.TrimSpace(gjson.Get(turnMetadata, "prompt_cache_key").String()); promptCacheKey != "" {
+ return "prompt-cache:" + promptCacheKey
+ }
+ if windowID := strings.TrimSpace(gjson.Get(turnMetadata, "window_id").String()); windowID != "" {
+ return "window:" + windowID
+ }
+ return ""
+}
+
+func codexInputHasValidReasoningEncryptedContent(body []byte) bool {
+ input := gjson.GetBytes(body, "input")
+ if !input.IsArray() {
+ return false
+ }
+ for _, item := range input.Array() {
+ if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
+ continue
+ }
+ encryptedContent := item.Get("encrypted_content")
+ if encryptedContent.Type != gjson.String {
+ continue
+ }
+ if _, err := signature.InspectGPTReasoningSignature(encryptedContent.String()); err == nil {
+ return true
+ }
+ }
+ return false
+}
+
+func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]byte {
+ input := gjson.GetBytes(body, "input")
+ if !input.IsArray() {
+ return nil
+ }
+
+ hasInputReasoning := codexInputHasValidReasoningEncryptedContent(body)
+ existingCalls := make(map[string]bool)
+ for _, inputItem := range input.Array() {
+ for _, key := range codexReplayToolCallKeys(inputItem) {
+ existingCalls[key] = true
+ }
+ }
+
+ filtered := make([][]byte, 0, len(items))
+ for _, item := range items {
+ itemResult := gjson.ParseBytes(item)
+ switch strings.TrimSpace(itemResult.Get("type").String()) {
+ case "reasoning":
+ if hasInputReasoning {
+ continue
+ }
+ case "function_call", "custom_tool_call":
+ keys := codexReplayToolCallKeys(itemResult)
+ if len(keys) == 0 || codexReplayAnyToolCallKeyExists(existingCalls, keys) {
+ continue
+ }
+ for _, key := range keys {
+ existingCalls[key] = true
+ }
+ default:
+ continue
+ }
+ filtered = append(filtered, item)
+ }
+ return filtered
+}
+
+func insertCodexReasoningReplayItems(body []byte, replayItems [][]byte) ([]byte, bool) {
+ input := gjson.GetBytes(body, "input")
+ if !input.IsArray() || len(replayItems) == 0 {
+ return body, false
+ }
+ inputItems := input.Array()
+ insertIndex := codexReasoningReplayInsertIndex(inputItems, replayItems)
+ replayItems = codexAlignReasoningReplayToolCallIDs(inputItems, replayItems)
+ items := make([]string, 0, len(inputItems)+len(replayItems))
+ for i, inputItem := range inputItems {
+ if i == insertIndex {
+ for _, replayItem := range replayItems {
+ items = append(items, string(replayItem))
+ }
+ }
+ items = append(items, inputItem.Raw)
+ }
+ if insertIndex == len(inputItems) {
+ for _, replayItem := range replayItems {
+ items = append(items, string(replayItem))
+ }
+ }
+ updated, err := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(items, ",")+"]"))
+ if err != nil {
+ return body, false
+ }
+ return updated, true
+}
+
+func codexReasoningReplayInsertIndex(inputItems []gjson.Result, replayItems [][]byte) int {
+ replayCallIDs := make(map[string]bool)
+ for _, replayItem := range replayItems {
+ itemResult := gjson.ParseBytes(replayItem)
+ itemType := strings.TrimSpace(itemResult.Get("type").String())
+ if itemType != "function_call" && itemType != "custom_tool_call" {
+ continue
+ }
+ for _, callID := range codexReplayComparableCallIDs(itemResult.Get("call_id").String()) {
+ replayCallIDs[callID] = true
+ }
+ }
+ if len(replayCallIDs) > 0 {
+ for index, inputItem := range inputItems {
+ itemType := strings.TrimSpace(inputItem.Get("type").String())
+ if itemType != "function_call_output" && itemType != "custom_tool_call_output" {
+ continue
+ }
+ callID := strings.TrimSpace(inputItem.Get("call_id").String())
+ if callID == "" || replayCallIDs[callID] {
+ return index
+ }
+ }
+ }
+ for index := len(inputItems) - 1; index >= 0; index-- {
+ inputItem := inputItems[index]
+ if strings.TrimSpace(inputItem.Get("type").String()) == "message" && strings.TrimSpace(inputItem.Get("role").String()) == "assistant" {
+ return index
+ }
+ }
+ for index, inputItem := range inputItems {
+ if shouldInsertCodexReasoningReplayBefore(inputItem) {
+ return index
+ }
+ }
+ return len(inputItems)
+}
+
+func codexAlignReasoningReplayToolCallIDs(inputItems []gjson.Result, replayItems [][]byte) [][]byte {
+ outputCallIDs := codexReplayOutputCallIDs(inputItems)
+ if len(outputCallIDs) == 0 {
+ return replayItems
+ }
+
+ aligned := make([][]byte, 0, len(replayItems))
+ for _, replayItem := range replayItems {
+ itemResult := gjson.ParseBytes(replayItem)
+ itemType := strings.TrimSpace(itemResult.Get("type").String())
+ if itemType != "function_call" && itemType != "custom_tool_call" {
+ aligned = append(aligned, replayItem)
+ continue
+ }
+
+ callID := strings.TrimSpace(itemResult.Get("call_id").String())
+ outputCallID := ""
+ for _, candidate := range codexReplayComparableCallIDs(callID) {
+ if value := outputCallIDs[candidate]; value != "" {
+ outputCallID = value
+ break
+ }
+ }
+ if outputCallID == "" || outputCallID == callID {
+ aligned = append(aligned, replayItem)
+ continue
+ }
+
+ updated, err := sjson.SetBytes(replayItem, "call_id", outputCallID)
+ if err != nil {
+ aligned = append(aligned, replayItem)
+ continue
+ }
+ aligned = append(aligned, updated)
+ }
+ return aligned
+}
+
+func codexReplayOutputCallIDs(inputItems []gjson.Result) map[string]string {
+ outputCallIDs := make(map[string]string)
+ for _, inputItem := range inputItems {
+ itemType := strings.TrimSpace(inputItem.Get("type").String())
+ if itemType != "function_call_output" && itemType != "custom_tool_call_output" {
+ continue
+ }
+ callID := strings.TrimSpace(inputItem.Get("call_id").String())
+ if callID == "" {
+ continue
+ }
+ for _, candidate := range codexReplayComparableCallIDs(callID) {
+ outputCallIDs[candidate] = callID
+ }
+ }
+ return outputCallIDs
+}
+
+func shouldInsertCodexReasoningReplayBefore(item gjson.Result) bool {
+ if strings.TrimSpace(item.Get("type").String()) != "message" {
+ return true
+ }
+ switch strings.TrimSpace(item.Get("role").String()) {
+ case "developer", "system":
+ return false
+ default:
+ return true
+ }
+}
+
+func codexReplayToolCallKeys(item gjson.Result) []string {
+ itemType := strings.TrimSpace(item.Get("type").String())
+ if itemType != "function_call" && itemType != "custom_tool_call" {
+ return nil
+ }
+ callIDs := codexReplayComparableCallIDs(item.Get("call_id").String())
+ if len(callIDs) == 0 {
+ return nil
+ }
+ keys := make([]string, 0, len(callIDs))
+ for _, callID := range callIDs {
+ keys = append(keys, itemType+":"+callID)
+ }
+ return keys
+}
+
+func codexReplayAnyToolCallKeyExists(existing map[string]bool, keys []string) bool {
+ for _, key := range keys {
+ if existing[key] {
+ return true
+ }
+ }
+ return false
+}
+
+func codexReplayComparableCallIDs(callID string) []string {
+ callID = strings.TrimSpace(callID)
+ if callID == "" {
+ return nil
+ }
+
+ claudeVisibleCallID := shortenCodexReplayCallIDIfNeeded(util.SanitizeClaudeToolID(callID))
+ if claudeVisibleCallID == "" || claudeVisibleCallID == callID {
+ return []string{callID}
+ }
+ return []string{callID, claudeVisibleCallID}
+}
+
+func shortenCodexReplayCallIDIfNeeded(id string) string {
+ const limit = 64
+ if len(id) <= limit {
+ return id
+ }
+
+ sum := sha256.Sum256([]byte(id))
+ suffix := "_" + hex.EncodeToString(sum[:8])
+ prefixLen := limit - len(suffix)
+ if prefixLen <= 0 {
+ return suffix[len(suffix)-limit:]
+ }
+ return id[:prefixLen] + suffix
+}
+
+func cacheCodexReasoningReplayFromCompleted(scope codexReasoningReplayScope, completedData []byte) {
+ if !scope.valid() {
+ return
+ }
+ output := gjson.GetBytes(completedData, "response.output")
+ if !output.IsArray() {
+ return
+ }
+ items := make([][]byte, 0, len(output.Array()))
+ for _, item := range output.Array() {
+ switch strings.TrimSpace(item.Get("type").String()) {
+ case "reasoning", "function_call", "custom_tool_call":
+ items = append(items, []byte(item.Raw))
+ default:
+ continue
+ }
+ }
+ if !internalcache.CacheCodexReasoningReplayItems(scope.modelName, scope.sessionKey, items) {
+ internalcache.DeleteCodexReasoningReplayItem(scope.modelName, scope.sessionKey)
+ }
+}
+
+func clearCodexReasoningReplayOnInvalidSignature(scope codexReasoningReplayScope, statusCode int, body []byte) {
+ if !scope.valid() {
+ return
+ }
+ code, _, ok := codexStatusErrorClassification(statusCode, body)
+ if ok && code == "thinking_signature_invalid" {
+ internalcache.DeleteCodexReasoningReplayItem(scope.modelName, scope.sessionKey)
+ }
+}
+
// PrepareRequest injects Codex credentials into the outgoing HTTP request.
func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
@@ -295,6 +793,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
body = ensureImageGenerationTool(body, baseModel, auth)
}
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
+ body, replayScope := applyCodexReasoningReplayCache(ctx, from, req, opts, body)
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
@@ -338,6 +837,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
b = applyCodexIdentityConfuseResponsePayload(b, identityState)
+ clearCodexReasoningReplayOnInvalidSignature(replayScope, httpResp.StatusCode, b)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
err = newCodexStatusErr(httpResp.StatusCode, b)
@@ -362,7 +862,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
eventData := bytes.TrimSpace(line[5:])
eventType := gjson.GetBytes(eventData, "type").String()
- if streamErr, ok := codexTerminalStreamContextLengthErr(eventData); ok {
+ if streamErr, terminalBody, ok := codexTerminalStreamErr(eventData); ok {
+ clearCodexReasoningReplayOnInvalidSignature(replayScope, streamErr.StatusCode(), terminalBody)
err = streamErr
return resp, err
}
@@ -412,6 +913,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
completedData = completedDataPatched
}
+ cacheCodexReasoningReplayFromCompleted(replayScope, completedData)
var param any
clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState)
@@ -566,6 +1068,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
body = ensureImageGenerationTool(body, baseModel, auth)
}
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
+ body, replayScope := applyCodexReasoningReplayCache(ctx, from, req, opts, body)
reporter.SetTranslatedReasoningEffort(body, to.String())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
@@ -612,6 +1115,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
return nil, readErr
}
data = applyCodexIdentityConfuseResponsePayload(data, identityState)
+ clearCodexReasoningReplayOnInvalidSignature(replayScope, httpResp.StatusCode, data)
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
err = newCodexStatusErr(httpResp.StatusCode, data)
@@ -637,7 +1141,8 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
if bytes.HasPrefix(line, dataTag) {
data := bytes.TrimSpace(line[5:])
- if streamErr, ok := codexTerminalStreamContextLengthErr(data); ok {
+ if streamErr, terminalBody, ok := codexTerminalStreamErr(data); ok {
+ clearCodexReasoningReplayOnInvalidSignature(replayScope, streamErr.StatusCode(), terminalBody)
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
reporter.PublishFailure(ctx, streamErr)
select {
@@ -655,6 +1160,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
publishCodexImageToolUsage(ctx, reporter, body, data)
data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback)
+ cacheCodexReasoningReplayFromCompleted(replayScope, data)
translatedLine = append([]byte("data: "), data...)
}
}
@@ -895,25 +1401,16 @@ type codexIdentityReplacement struct {
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte) (*http.Request, []byte, codexIdentityConfuseState, error) {
var cache helps.CodexCache
- if from == "claude" {
- userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
- if userIDResult.Exists() {
- key := fmt.Sprintf("%s-%s", req.Model, userIDResult.String())
- var ok bool
- if cache, ok = helps.GetCodexCache(key); !ok {
- cache = helps.CodexCache{
- ID: uuid.New().String(),
- Expire: time.Now().Add(1 * time.Hour),
- }
- helps.SetCodexCache(key, cache)
- }
+ if sourceFormatEqual(from, sdktranslator.FormatClaude) {
+ if cached, ok := codexClaudeCodePromptCache(req); ok {
+ cache = cached
}
- } else if from == "openai-response" {
+ } else if sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse) {
promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
if promptCacheKey.Exists() {
cache.ID = promptCacheKey.String()
}
- } else if from == "openai" {
+ } else if sourceFormatEqual(from, sdktranslator.FormatOpenAI) {
if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" {
cache.ID = uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
}
@@ -978,10 +1475,7 @@ func applyCodexIdentityConfuseHeaders(headers http.Header, state *codexIdentityC
return
}
- setHeaderCasePreserved(headers, "Session-Id", state.promptCacheKey)
- if headerValueCaseInsensitive(headers, "session_id") != "" {
- setHeaderCasePreserved(headers, "session_id", state.promptCacheKey)
- }
+ setCodexSessionHeaderCasePreserved(headers, "Session_id", state.promptCacheKey)
if headerValueCaseInsensitive(headers, "Conversation_id") != "" {
setHeaderCasePreserved(headers, "Conversation_id", state.promptCacheKey)
}
diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go
index 3f7d412ba93..d33d7fc64fd 100644
--- a/internal/runtime/executor/codex_executor_cache_test.go
+++ b/internal/runtime/executor/codex_executor_cache_test.go
@@ -47,8 +47,11 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
if gotConversation := httpReq.Header.Get("Conversation_id"); gotConversation != "" {
t.Fatalf("Conversation_id = %q, want empty", gotConversation)
}
- if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedKey {
- t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey)
+ if gotSession := httpReq.Header["Session_id"]; len(gotSession) != 1 || gotSession[0] != expectedKey {
+ t.Fatalf("Session_id = %#v, want [%q]", gotSession, expectedKey)
+ }
+ if gotCanonicalSession := httpReq.Header.Get("Session-Id"); gotCanonicalSession != "" {
+ t.Fatalf("Session-Id = %q, want empty", gotCanonicalSession)
}
httpReq2, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON)
@@ -65,6 +68,88 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
}
}
+func TestCodexExecutorCacheHelper_ClaudeUsesClaudeCodeSessionID(t *testing.T) {
+ executor := &CodexExecutor{}
+ ctx := context.Background()
+ url := "https://example.com/responses"
+ rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)
+ firstReq := cliproxyexecutor.Request{
+ Model: "gpt-5.4-claude-cache-session",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-a\",\"account_uuid\":\"\",\"session_id\":\"cache-session-1\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"first"}]}]
+ }`),
+ }
+ secondReq := cliproxyexecutor.Request{
+ Model: "gpt-5.4-claude-cache-session",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-b\",\"account_uuid\":\"\",\"session_id\":\"cache-session-1\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]
+ }`),
+ }
+
+ firstHTTPReq, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("claude"), url, nil, firstReq, firstReq.Payload, rawJSON)
+ if err != nil {
+ t.Fatalf("cacheHelper first error: %v", err)
+ }
+ secondHTTPReq, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("claude"), url, nil, secondReq, secondReq.Payload, rawJSON)
+ if err != nil {
+ t.Fatalf("cacheHelper second error: %v", err)
+ }
+
+ firstBody, errRead := io.ReadAll(firstHTTPReq.Body)
+ if errRead != nil {
+ t.Fatalf("read first request body: %v", errRead)
+ }
+ secondBody, errRead := io.ReadAll(secondHTTPReq.Body)
+ if errRead != nil {
+ t.Fatalf("read second request body: %v", errRead)
+ }
+ firstKey := gjson.GetBytes(firstBody, "prompt_cache_key").String()
+ secondKey := gjson.GetBytes(secondBody, "prompt_cache_key").String()
+ if firstKey == "" {
+ t.Fatalf("first prompt_cache_key is empty; body=%s", string(firstBody))
+ }
+ if secondKey != firstKey {
+ t.Fatalf("same Claude Code session_id produced different prompt_cache_key: first=%q second=%q", firstKey, secondKey)
+ }
+ if gotSession := firstHTTPReq.Header["Session_id"]; len(gotSession) != 1 || gotSession[0] != firstKey {
+ t.Fatalf("first Session_id = %#v, want [%q]", gotSession, firstKey)
+ }
+ if gotSession := secondHTTPReq.Header["Session_id"]; len(gotSession) != 1 || gotSession[0] != firstKey {
+ t.Fatalf("second Session_id = %#v, want [%q]", gotSession, firstKey)
+ }
+}
+
+func TestCodexExecutorCacheHelper_ClaudeRejectsBareUserID(t *testing.T) {
+ executor := &CodexExecutor{}
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5.4-claude-cache-bare-user",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"same-user-across-chats"},"messages":[{"role":"user","content":[{"type":"text","text":"first"}]}]}`),
+ }
+
+ httpReq, _, _, err := executor.cacheHelper(context.Background(), sdktranslator.FromString("claude"), "https://example.com/responses", nil, req, req.Payload, []byte(`{"model":"gpt-5.4","stream":true}`))
+ if err != nil {
+ t.Fatalf("cacheHelper error: %v", err)
+ }
+
+ body, errRead := io.ReadAll(httpReq.Body)
+ if errRead != nil {
+ t.Fatalf("read request body: %v", errRead)
+ }
+ if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "" {
+ t.Fatalf("bare metadata.user_id must not create prompt_cache_key, got %q; body=%s", got, string(body))
+ }
+ if got := httpReq.Header["Session_id"]; len(got) != 0 {
+ t.Fatalf("bare metadata.user_id must not create Session_id, got %#v", got)
+ }
+ if got := httpReq.Header.Get("Session-Id"); got != "" {
+ t.Fatalf("bare metadata.user_id must not create Session-Id, got %q", got)
+ }
+}
+
func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing.T) {
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
@@ -114,13 +199,16 @@ func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing
if gotWindowID := gjson.GetBytes(body, "client_metadata.x-codex-window-id").String(); gotWindowID != expectedPromptCacheKey+":0" {
t.Fatalf("client_metadata.x-codex-window-id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0")
}
- for _, headerName := range []string{"Session-Id", "X-Client-Request-Id", "Thread-Id"} {
+ if gotHeader := httpReq.Header["Session_id"]; len(gotHeader) != 1 || gotHeader[0] != expectedPromptCacheKey {
+ t.Fatalf("Session_id = %#v, want [%q]", gotHeader, expectedPromptCacheKey)
+ }
+ for _, headerName := range []string{"X-Client-Request-Id", "Thread-Id"} {
if gotHeader := httpReq.Header.Get(headerName); gotHeader != expectedPromptCacheKey {
t.Fatalf("%s = %q, want %q", headerName, gotHeader, expectedPromptCacheKey)
}
}
- if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedPromptCacheKey {
- t.Fatalf("Session_id = %q, want %q", gotSession, expectedPromptCacheKey)
+ if gotCanonicalSession := httpReq.Header.Get("Session-Id"); gotCanonicalSession != "" {
+ t.Fatalf("Session-Id = %q, want empty", gotCanonicalSession)
}
if gotWindow := httpReq.Header.Get("X-Codex-Window-Id"); gotWindow != expectedPromptCacheKey+":0" {
t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindow, expectedPromptCacheKey+":0")
diff --git a/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go b/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
new file mode 100644
index 00000000000..a15007ed3bf
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
@@ -0,0 +1,803 @@
+package executor
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+func validCodexReasoningEncryptedContentForTestSeed(seed byte) string {
+ payload := make([]byte, 1+8+16+16+32)
+ payload[0] = 0x80
+ for i := 9; i < len(payload); i++ {
+ payload[i] = seed + byte(i)
+ }
+ return base64.RawURLEncoding.EncodeToString(payload)
+}
+
+func shortenedCodexReplayCallIDForTest(id string) string {
+ const limit = 64
+ if len(id) <= limit {
+ return id
+ }
+
+ sum := sha256.Sum256([]byte(id))
+ suffix := "_" + hex.EncodeToString(sum[:8])
+ prefixLen := limit - len(suffix)
+ if prefixLen <= 0 {
+ return suffix[len(suffix)-limit:]
+ }
+ return id[:prefixLen] + suffix
+}
+
+func TestCodexExecutorReasoningReplayCacheStoresFinalDoneAndInjectsNextClaudeRequest(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ addedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(1)
+ doneEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(2)
+ var bodies [][]byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ bodies = append(bodies, body)
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.added","item":{"id":"rs_added","type":"reasoning","status":"in_progress","summary":[],"encrypted_content":"` + addedEncryptedContent + `"},"output_index":0}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"rs_done","type":"reasoning","summary":[],"encrypted_content":"` + doneEncryptedContent + `"},"output_index":0}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: "auth-replay-1",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ }
+
+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-1\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("first Execute error: %v", err)
+ }
+
+ _, err = executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-1\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("second Execute error: %v", err)
+ }
+
+ if len(bodies) != 2 {
+ t.Fatalf("upstream request count = %d, want 2", len(bodies))
+ }
+ secondBody := bodies[1]
+ if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
+ t.Fatalf("input.0.type = %q, want reasoning; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
+ t.Fatalf("injected encrypted_content = %q, want final done %q; body=%s", got, doneEncryptedContent, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.1.role").String(); got != "user" {
+ t.Fatalf("input.1.role = %q, want user; body=%s", got, string(secondBody))
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheSharesSameSessionAcrossClientKeys(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ from := sdktranslator.FromString("claude")
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-only\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }
+ opts := cliproxyexecutor.Options{SourceFormat: from}
+ body := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`)
+ encryptedContent := validCodexReasoningEncryptedContentForTestSeed(11)
+
+ firstScope := codexReasoningReplayScopeFromRequest(codexReplaySessionOnlyContext("client-key-a"), from, req, opts, body)
+ if !firstScope.valid() {
+ t.Fatalf("first replay scope is invalid: %#v", firstScope)
+ }
+ cacheCodexReasoningReplayFromCompleted(firstScope, []byte(`{"response":{"output":[{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}]}}`))
+
+ secondBody, secondScope := applyCodexReasoningReplayCache(codexReplaySessionOnlyContext("client-key-b"), from, req, opts, body)
+ if secondScope != firstScope {
+ t.Fatalf("replay scope should ignore client API key for the same session: first=%#v second=%#v", firstScope, secondScope)
+ }
+ if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
+ t.Fatalf("input.0.type = %q, want same-session replay; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != encryptedContent {
+ t.Fatalf("injected encrypted_content = %q, want cached value", got)
+ }
+}
+
+func TestCodexExecutorReasoningReplaySessionKeyUsesClaudeCodeJSONSessionID(t *testing.T) {
+ from := sdktranslator.FromString("claude")
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-a\",\"account_uuid\":\"\",\"session_id\":\"session-json-1\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]
+ }`),
+ }
+ body := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`)
+
+ got := codexReasoningReplaySessionKey(context.Background(), from, req, cliproxyexecutor.Options{SourceFormat: from}, body)
+ if got != "claude:session-json-1" {
+ t.Fatalf("codexReasoningReplaySessionKey() = %q, want claude:session-json-1", got)
+ }
+}
+
+func TestCodexExecutorReasoningReplaySessionKeyRejectsBareClaudeUserID(t *testing.T) {
+ from := sdktranslator.FromString("claude")
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"same-user-across-chats"},"messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }
+ body := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`)
+
+ got := codexReasoningReplaySessionKey(context.Background(), from, req, cliproxyexecutor.Options{SourceFormat: from}, body)
+ if got != "" {
+ t.Fatalf("bare metadata.user_id must not become replay session key, got %q", got)
+ }
+}
+
+func TestCodexExecutorReasoningReplaySessionKeyCanonicalizesSessionHeaderAliases(t *testing.T) {
+ legacy := http.Header{"Session_id": []string{"session-alias"}}
+ lowercase := http.Header{"session_id": []string{"session-alias"}}
+ canonical := http.Header{"Session-Id": []string{"session-alias"}}
+
+ gotLegacy := codexReasoningReplaySessionKeyFromHeaders(legacy)
+ gotLowercase := codexReasoningReplaySessionKeyFromHeaders(lowercase)
+ gotCanonical := codexReasoningReplaySessionKeyFromHeaders(canonical)
+
+ if gotLegacy != gotLowercase || gotLowercase != gotCanonical {
+ t.Fatalf("session header aliases produced different keys: legacy=%q lowercase=%q canonical=%q", gotLegacy, gotLowercase, gotCanonical)
+ }
+ if gotCanonical != "session-id:session-alias" {
+ t.Fatalf("canonical session key = %q, want session-id:session-alias", gotCanonical)
+ }
+}
+
+func TestCodexExecutorReasoningReplaySessionKeyCanonicalizesWindowHeaderWithPayload(t *testing.T) {
+ payload := []byte(`{"client_metadata":{"x-codex-window-id":"window-1"}}`)
+ headers := http.Header{"X-Codex-Window-Id": []string{"window-1"}}
+
+ gotPayload := codexReasoningReplaySessionKeyFromPayload(payload)
+ gotHeader := codexReasoningReplaySessionKeyFromHeaders(headers)
+
+ if gotPayload != gotHeader {
+ t.Fatalf("window replay keys differ: payload=%q header=%q", gotPayload, gotHeader)
+ }
+ if gotHeader != "window:window-1" {
+ t.Fatalf("window replay key = %q, want window:window-1", gotHeader)
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheSharesSameSessionAcrossCodexAuths(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ encryptedContent := validCodexReasoningEncryptedContentForTestSeed(12)
+ var bodies [][]byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ bodies = append(bodies, body)
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"rs_done","type":"reasoning","summary":[],"encrypted_content":"` + encryptedContent + `"},"output_index":0}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ firstAuth := &cliproxyauth.Auth{
+ ID: "auth-replay-session-auth-a",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test-a",
+ },
+ }
+ secondAuth := &cliproxyauth.Auth{
+ ID: "auth-replay-session-auth-b",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test-b",
+ },
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ }
+
+ _, err := executor.Execute(context.Background(), firstAuth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-auth-switch\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("first Execute error: %v", err)
+ }
+
+ _, err = executor.Execute(context.Background(), secondAuth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-auth-switch\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("second Execute error: %v", err)
+ }
+
+ if len(bodies) != 2 {
+ t.Fatalf("upstream request count = %d, want 2", len(bodies))
+ }
+ secondBody := bodies[1]
+ if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
+ t.Fatalf("input.0.type = %q, want same-session replay across auths; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != encryptedContent {
+ t.Fatalf("injected encrypted_content = %q, want cached value", got)
+ }
+}
+
+func codexReplaySessionOnlyContext(apiKey string) context.Context {
+ recorder := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(recorder)
+ ginCtx.Set("userApiKey", apiKey)
+ ginCtx.Set("accessProvider", "config-inline")
+ ginCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
+ return context.WithValue(context.Background(), "gin", ginCtx)
+}
+
+func TestCodexExecutorReasoningReplayCacheDoesNotInjectNativeResponsesRequest(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(3)
+ internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "prompt-cache:native-session", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
+
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), &cliproxyauth.Auth{
+ ID: "auth-replay-native",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","prompt_cache_key":"native-session","input":[{"role":"user","content":"native"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ if got := gjson.GetBytes(gotBody, "input.0.type").String(); got == "reasoning" {
+ t.Fatalf("native Responses request should not receive cached reasoning; body=%s", string(gotBody))
+ }
+ if got := gjson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
+ t.Fatalf("input.0.role = %q, want user; body=%s", got, string(gotBody))
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheDoesNotStoreNativeResponsesRequest(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ nativeEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(4)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.ReadAll(r.Body)
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[{"id":"rs_native","type":"reasoning","summary":[],"encrypted_content":"` + nativeEncryptedContent + `"}]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), &cliproxyauth.Auth{
+ ID: "auth-replay-native-store",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","prompt_cache_key":"native-store","input":[{"role":"user","content":"native"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ if _, ok := internalcache.GetCodexReasoningReplayItem("gpt-5.4", "prompt-cache:native-store"); ok {
+ t.Fatal("native Responses request should not populate Codex reasoning replay cache")
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheDoesNotDuplicateClaudeClientReasoning(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(5)
+ clientEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(6)
+ internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-2", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
+
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), &cliproxyauth.Auth{
+ ID: "auth-replay-2",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-2\"}"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"client summary","signature":"` + clientEncryptedContent + `"},{"type":"text","text":"answer"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ if got := gjson.GetBytes(gotBody, "input.0.encrypted_content").String(); got != clientEncryptedContent {
+ t.Fatalf("client reasoning should be preserved, got %q want %q; body=%s", got, clientEncryptedContent, string(gotBody))
+ }
+ reasoningCount := 0
+ for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ if item.Get("type").String() == "reasoning" {
+ reasoningCount++
+ }
+ }
+ if reasoningCount != 1 {
+ t.Fatalf("reasoning item count = %d, want 1; body=%s", reasoningCount, string(gotBody))
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheInsertsReasoningBeforeAssistantOutputInClaudeHistory(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(7)
+ internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-history", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
+
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), &cliproxyauth.Auth{
+ ID: "auth-replay-history",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-history\"}"},
+ "messages":[
+ {"role":"user","content":[{"type":"text","text":"first"}]},
+ {"role":"assistant","content":[{"type":"text","text":"answer"}]},
+ {"role":"user","content":[{"type":"text","text":"next"}]}
+ ]
+ }`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ if got := gjson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
+ t.Fatalf("input.0.role = %q, want first user message; body=%s", got, string(gotBody))
+ }
+ if got := gjson.GetBytes(gotBody, "input.1.type").String(); got != "reasoning" {
+ t.Fatalf("input.1.type = %q, want cached reasoning before assistant output; body=%s", got, string(gotBody))
+ }
+ if got := gjson.GetBytes(gotBody, "input.1.encrypted_content").String(); got != cachedEncryptedContent {
+ t.Fatalf("input.1.encrypted_content = %q, want cached reasoning; body=%s", got, string(gotBody))
+ }
+ if got := gjson.GetBytes(gotBody, "input.2.role").String(); got != "assistant" {
+ t.Fatalf("input.2.role = %q, want assistant output after cached reasoning; body=%s", got, string(gotBody))
+ }
+ if got := gjson.GetBytes(gotBody, "input.3.role").String(); got != "user" {
+ t.Fatalf("input.3.role = %q, want final user message; body=%s", got, string(gotBody))
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheExecuteStreamStoresFinalDoneForClaude(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ addedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(7)
+ doneEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(8)
+ var bodies [][]byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ bodies = append(bodies, body)
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.added","item":{"id":"rs_added","type":"reasoning","status":"in_progress","summary":[],"encrypted_content":"` + addedEncryptedContent + `"},"output_index":0}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"rs_done","type":"reasoning","summary":[],"encrypted_content":"` + doneEncryptedContent + `"},"output_index":0}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: "auth-replay-stream",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }
+
+ streamResult, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"stream-session-1\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream error: %v", err)
+ }
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error: %v", chunk.Err)
+ }
+ }
+
+ _, err = executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"stream-session-1\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ if len(bodies) != 2 {
+ t.Fatalf("upstream request count = %d, want 2", len(bodies))
+ }
+ secondBody := bodies[1]
+ if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
+ t.Fatalf("stream cached encrypted_content = %q, want final done %q; body=%s", got, doneEncryptedContent, string(secondBody))
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheClearsOnNonStreamResponseFailedInvalidSignature(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(9)
+ internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-nonstream", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.ReadAll(r.Body)
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"message":"Invalid signature in thinking block","type":"invalid_request_error","code":"invalid_request_error"}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), &cliproxyauth.Auth{
+ ID: "auth-replay-invalid-nonstream",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-invalid-nonstream\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ })
+ if err == nil {
+ t.Fatal("expected invalid signature error")
+ }
+ if _, ok := internalcache.GetCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-nonstream"); ok {
+ t.Fatal("invalid signature response.failed should clear cached replay item")
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheClearsOnStreamResponseFailedInvalidSignature(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(10)
+ internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-stream", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.ReadAll(r.Body)
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"message":"Invalid signature in thinking block","type":"invalid_request_error","code":"invalid_request_error"}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ streamResult, err := executor.ExecuteStream(context.Background(), &cliproxyauth.Auth{
+ ID: "auth-replay-invalid-stream",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-invalid-stream\"}"},"messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream setup error: %v", err)
+ }
+
+ gotChunkErr := false
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ gotChunkErr = true
+ }
+ }
+ if !gotChunkErr {
+ t.Fatal("expected stream chunk error for invalid signature response.failed")
+ }
+ if _, ok := internalcache.GetCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-stream"); ok {
+ t.Fatal("invalid signature response.failed should clear cached replay item")
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheReplaysFunctionCallForClaudeToolResult(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ reasoningEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(8)
+ var bodies [][]byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ bodies = append(bodies, body)
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","summary":[],"encrypted_content":"` + reasoningEncryptedContent + `"},"output_index":0}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"weather\"}","status":"in_progress"},"output_index":1}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"weather\"}","status":"completed"},"output_index":1}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: "auth-replay-claude-tool",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ }
+
+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"claude-session-tool\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"call lookup"}]}],
+ "tools":[{"name":"lookup","input_schema":{"type":"object","properties":{"q":{"type":"string"}}}}]
+ }`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("first Execute error: %v", err)
+ }
+
+ _, err = executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"claude-session-tool\"}"},
+ "messages":[
+ {"role":"user","content":[{"type":"text","text":"call lookup"}]},
+ {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"sunny"}]}
+ ],
+ "tools":[{"name":"lookup","input_schema":{"type":"object","properties":{"q":{"type":"string"}}}}]
+ }`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("second Execute error: %v", err)
+ }
+
+ if len(bodies) != 2 {
+ t.Fatalf("upstream request count = %d, want 2", len(bodies))
+ }
+ secondBody := bodies[1]
+ if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
+ t.Fatalf("input.0.type = %q, want initial user message; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
+ t.Fatalf("input.1.type = %q, want cached reasoning; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
+ t.Fatalf("input.2.type = %q, want cached function_call; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.2.call_id").String(); got != "call_1" {
+ t.Fatalf("input.2.call_id = %q, want call_1; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
+ t.Fatalf("input.3.type = %q, want function_call_output after cached call; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.3.call_id").String(); got != "call_1" {
+ t.Fatalf("input.3.call_id = %q, want call_1; body=%s", got, string(secondBody))
+ }
+}
+
+func TestCodexExecutorReasoningReplayCacheMatchesShortenedClaudeToolResultCallID(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ longCallID := "call_" + strings.Repeat("a", 62)
+ shortCallID := shortenedCodexReplayCallIDForTest(longCallID)
+ if len(longCallID) <= 64 || len(shortCallID) > 64 || shortCallID == longCallID {
+ t.Fatalf("invalid test setup: long=%q short=%q", longCallID, shortCallID)
+ }
+
+ reasoningEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(13)
+ var bodies [][]byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Fatalf("read body: %v", errRead)
+ }
+ bodies = append(bodies, body)
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"rs_long","type":"reasoning","summary":[],"encrypted_content":"` + reasoningEncryptedContent + `"},"output_index":0}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"fc_long","type":"function_call","call_id":"` + longCallID + `","name":"lookup","arguments":"{\"q\":\"weather\"}","status":"completed"},"output_index":1}` + "\n"))
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"gpt-5.4","output":[]}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: "auth-replay-claude-short-tool",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("claude"),
+ Stream: false,
+ }
+
+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"claude-session-short-tool\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"call lookup"}]}],
+ "tools":[{"name":"lookup","input_schema":{"type":"object","properties":{"q":{"type":"string"}}}}]
+ }`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("first Execute error: %v", err)
+ }
+
+ _, err = executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"claude-session-short-tool\"}"},
+ "messages":[
+ {"role":"user","content":[{"type":"text","text":"call lookup"}]},
+ {"role":"user","content":[{"type":"tool_result","tool_use_id":"` + shortCallID + `","content":"sunny"}]}
+ ],
+ "tools":[{"name":"lookup","input_schema":{"type":"object","properties":{"q":{"type":"string"}}}}]
+ }`),
+ }, opts)
+ if err != nil {
+ t.Fatalf("second Execute error: %v", err)
+ }
+
+ if len(bodies) != 2 {
+ t.Fatalf("upstream request count = %d, want 2", len(bodies))
+ }
+ secondBody := bodies[1]
+ if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
+ t.Fatalf("input.0.type = %q, want initial user message; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
+ t.Fatalf("input.1.type = %q, want cached reasoning; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
+ t.Fatalf("input.2.type = %q, want cached function_call; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.2.call_id").String(); got != shortCallID {
+ t.Fatalf("input.2.call_id = %q, want shortened call_id %q; body=%s", got, shortCallID, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
+ t.Fatalf("input.3.type = %q, want function_call_output after cached call; body=%s", got, string(secondBody))
+ }
+ if got := gjson.GetBytes(secondBody, "input.3.call_id").String(); got != shortCallID {
+ t.Fatalf("input.3.call_id = %q, want shortened call_id %q; body=%s", got, shortCallID, string(secondBody))
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go
index 983f915bc55..46a227924b1 100644
--- a/internal/runtime/executor/codex_executor_stream_output_test.go
+++ b/internal/runtime/executor/codex_executor_stream_output_test.go
@@ -159,6 +159,13 @@ func TestCodexTerminalStreamContextLengthErrIgnoresOtherTerminalErrors(t *testin
}
}
+func TestCodexTerminalStreamErrIgnoresRateLimitTerminalErrors(t *testing.T) {
+ _, _, ok := codexTerminalStreamErr([]byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"Rate limit reached."}}`))
+ if ok {
+ t.Fatal("rate limit terminal error should not be handled by replay terminal error path")
+ }
+}
+
func statusCodeFromTestError(t *testing.T, err error) int {
t.Helper()
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index e1c9ce34412..8d68a251edc 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -835,21 +835,11 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
}
var cache helps.CodexCache
- if from == "claude" {
- userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
- if userIDResult.Exists() {
- key := fmt.Sprintf("%s-%s", req.Model, userIDResult.String())
- if cached, ok := helps.GetCodexCache(key); ok {
- cache = cached
- } else {
- cache = helps.CodexCache{
- ID: uuid.New().String(),
- Expire: time.Now().Add(1 * time.Hour),
- }
- helps.SetCodexCache(key, cache)
- }
+ if sourceFormatEqual(from, sdktranslator.FormatClaude) {
+ if cached, ok := codexClaudeCodePromptCache(req); ok {
+ cache = cached
}
- } else if from == "openai-response" {
+ } else if sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse) {
if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
cache.ID = promptCacheKey.String()
}
@@ -899,10 +889,11 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
betaHeader = codexResponsesWebsocketBetaHeaderValue
}
headers.Set("OpenAI-Beta", betaHeader)
+ sessionFallback := ""
if strings.Contains(headers.Get("User-Agent"), "Mac OS") {
- ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", uuid.NewString())
+ sessionFallback = uuid.NewString()
}
- ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", "")
+ ensureCodexWebsocketSessionHeader(headers, ginHeaders, sessionFallback)
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
headers.Set("Originator", originator)
} else if !isAPIKey {
@@ -927,6 +918,32 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
return headers
}
+func ensureCodexWebsocketSessionHeader(target http.Header, source http.Header, fallbackValue string) {
+ if target == nil {
+ return
+ }
+ sessionID := codexSessionHeaderValue(target)
+ if sessionID == "" {
+ sessionID = codexSessionHeaderValue(source)
+ }
+ if sessionID == "" {
+ sessionID = strings.TrimSpace(fallbackValue)
+ }
+ if sessionID != "" {
+ setHeaderCasePreserved(target, "session_id", sessionID)
+ }
+ deleteHeaderCaseInsensitive(target, "Session-Id")
+}
+
+func codexSessionHeaderValue(headers http.Header) string {
+ for _, key := range []string{"Session-Id", "Session_id", "session_id"} {
+ if value := strings.TrimSpace(headerValueCaseInsensitive(headers, key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
func codexAuthUsesAPIKey(auth *cliproxyauth.Auth) bool {
if auth == nil || auth.Attributes == nil {
return false
@@ -969,6 +986,47 @@ func setHeaderCasePreserved(headers http.Header, key string, value string) {
headers[key] = []string{value}
}
+func setCodexSessionHeaderCasePreserved(headers http.Header, fallbackKey string, value string) {
+ if headers == nil {
+ return
+ }
+ fallbackKey = strings.TrimSpace(fallbackKey)
+ value = strings.TrimSpace(value)
+ if fallbackKey == "" || value == "" {
+ return
+ }
+
+ selectedKey := ""
+ if _, ok := headers[fallbackKey]; ok && codexSessionHeaderKeyUsesUnderscore(fallbackKey) {
+ selectedKey = fallbackKey
+ } else {
+ for existingKey := range headers {
+ if codexSessionHeaderKeyUsesUnderscore(existingKey) {
+ selectedKey = existingKey
+ break
+ }
+ }
+ }
+ if selectedKey == "" {
+ selectedKey = fallbackKey
+ }
+ for existingKey := range headers {
+ if codexSessionHeaderKey(existingKey) && existingKey != selectedKey {
+ delete(headers, existingKey)
+ }
+ }
+ headers[selectedKey] = []string{value}
+}
+
+func codexSessionHeaderKey(key string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(key))
+ return normalized == "session_id" || normalized == "session-id"
+}
+
+func codexSessionHeaderKeyUsesUnderscore(key string) bool {
+ return strings.ToLower(strings.TrimSpace(key)) == "session_id"
+}
+
func headerValueCaseInsensitive(headers http.Header, key string) string {
key = strings.TrimSpace(key)
if headers == nil || key == "" {
diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go
index 5dbfbce9457..a3d3a552545 100644
--- a/internal/runtime/executor/codex_websockets_executor_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_test.go
@@ -197,7 +197,7 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
"Version": "0.115.0-alpha.27",
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
- "session_id": "legacy-session",
+ "session-id": "legacy-session",
})
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
@@ -217,11 +217,32 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
}
- if got := headerValueCaseInsensitive(headers, "session_id"); got != "legacy-session" {
- t.Fatalf("session_id = %s, want legacy-session", got)
+ if got := headers["session_id"]; len(got) != 1 || got[0] != "legacy-session" {
+ t.Fatalf("session_id = %#v, want [legacy-session]", got)
}
- if _, ok := headers["session_id"]; !ok {
- t.Fatalf("expected lowercase session_id header key, got %#v", headers)
+ if got := headers.Get("Session-Id"); got != "" {
+ t.Fatalf("Session-Id = %s, want empty", got)
+ }
+}
+
+func TestApplyCodexWebsocketHeadersCanonicalizesLegacyUnderscoreSessionHeader(t *testing.T) {
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Metadata: map[string]any{"email": "user@example.com"},
+ }
+ ctx := contextWithGinHeaders(map[string]string{
+ "Originator": "Codex Desktop",
+ "User-Agent": "codex_cli_rs/0.1.0",
+ "Session_id": "legacy-underscore-session",
+ })
+
+ headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
+
+ if got := headers["session_id"]; len(got) != 1 || got[0] != "legacy-underscore-session" {
+ t.Fatalf("session_id = %#v, want [legacy-underscore-session]", got)
+ }
+ if got := headers.Get("Session-Id"); got != "" {
+ t.Fatalf("Session-Id = %s, want empty", got)
}
}
@@ -361,22 +382,79 @@ func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) {
}
}
-func TestApplyCodexPromptCacheHeadersSetsLowercaseSessionAndLegacyConversation(t *testing.T) {
+func TestApplyCodexPromptCacheHeadersSetsSessionIDAndLegacyConversation(t *testing.T) {
req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)}
_, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`))
- if got := headerValueCaseInsensitive(headers, "session_id"); got != "cache-1" {
- t.Fatalf("session_id = %s, want cache-1", got)
+ if got := headers["session_id"]; len(got) != 1 || got[0] != "cache-1" {
+ t.Fatalf("session_id = %#v, want [cache-1]", got)
}
- if _, ok := headers["session_id"]; !ok {
- t.Fatalf("expected lowercase session_id key, got %#v", headers)
+ if got := headers.Get("Session-Id"); got != "" {
+ t.Fatalf("Session-Id = %s, want empty", got)
}
if got := headers.Get("Conversation_id"); got != "cache-1" {
t.Fatalf("Conversation_id = %s, want cache-1", got)
}
}
+func TestApplyCodexPromptCacheHeadersClaudeUsesClaudeCodeSessionID(t *testing.T) {
+ firstReq := cliproxyexecutor.Request{
+ Model: "gpt-5-codex-claude-ws-cache-session",
+ Payload: []byte(`{
+ "metadata":{"user_id":"{\"device_id\":\"device-a\",\"account_uuid\":\"\",\"session_id\":\"ws-cache-session-1\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"first"}]}]
+ }`),
+ }
+ secondReq := cliproxyexecutor.Request{
+ Model: "gpt-5-codex-claude-ws-cache-session",
+ Payload: []byte(`{
+ "metadata":{"user_id":"{\"device_id\":\"device-b\",\"account_uuid\":\"\",\"session_id\":\"ws-cache-session-1\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]
+ }`),
+ }
+
+ firstBody, firstHeaders := applyCodexPromptCacheHeaders("claude", firstReq, []byte(`{"model":"gpt-5-codex"}`))
+ secondBody, secondHeaders := applyCodexPromptCacheHeaders("claude", secondReq, []byte(`{"model":"gpt-5-codex"}`))
+
+ firstKey := gjson.GetBytes(firstBody, "prompt_cache_key").String()
+ secondKey := gjson.GetBytes(secondBody, "prompt_cache_key").String()
+ if firstKey == "" {
+ t.Fatalf("first prompt_cache_key is empty; body=%s", string(firstBody))
+ }
+ if secondKey != firstKey {
+ t.Fatalf("same Claude Code session_id produced different websocket prompt_cache_key: first=%q second=%q", firstKey, secondKey)
+ }
+ if got := firstHeaders["session_id"]; len(got) != 1 || got[0] != firstKey {
+ t.Fatalf("first session_id = %#v, want [%q]", got, firstKey)
+ }
+ if got := secondHeaders["session_id"]; len(got) != 1 || got[0] != firstKey {
+ t.Fatalf("second session_id = %#v, want [%q]", got, firstKey)
+ }
+}
+
+func TestApplyCodexPromptCacheHeadersClaudeRejectsBareUserID(t *testing.T) {
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5-codex-claude-ws-cache-bare-user",
+ Payload: []byte(`{"metadata":{"user_id":"same-user-across-chats"},"messages":[{"role":"user","content":[{"type":"text","text":"first"}]}]}`),
+ }
+
+ body, headers := applyCodexPromptCacheHeaders("claude", req, []byte(`{"model":"gpt-5-codex"}`))
+
+ if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "" {
+ t.Fatalf("bare metadata.user_id must not create websocket prompt_cache_key, got %q; body=%s", got, string(body))
+ }
+ if got := headers["session_id"]; len(got) != 0 {
+ t.Fatalf("bare metadata.user_id must not create websocket session_id, got %#v", got)
+ }
+ if got := headers.Get("Session-Id"); got != "" {
+ t.Fatalf("bare metadata.user_id must not create websocket Session-Id, got %q", got)
+ }
+ if got := headers.Get("Conversation_id"); got != "" {
+ t.Fatalf("bare metadata.user_id must not create websocket Conversation_id, got %q", got)
+ }
+}
+
func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testing.T) {
cfg := &config.Config{
Routing: config.RoutingConfig{SessionAffinity: true},
@@ -402,8 +480,11 @@ func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testin
if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
}
- if gotSession := headerValueCaseInsensitive(headers, "session_id"); gotSession != expectedPromptCacheKey {
- t.Fatalf("session_id = %q, want %q", gotSession, expectedPromptCacheKey)
+ if gotSession := headers["session_id"]; len(gotSession) != 1 || gotSession[0] != expectedPromptCacheKey {
+ t.Fatalf("session_id = %#v, want [%q]", gotSession, expectedPromptCacheKey)
+ }
+ if gotCanonicalSession := headers.Get("Session-Id"); gotCanonicalSession != "" {
+ t.Fatalf("Session-Id = %q, want empty", gotCanonicalSession)
}
if gotRequestID := headers.Get("X-Client-Request-Id"); gotRequestID != expectedPromptCacheKey {
t.Fatalf("X-Client-Request-Id = %q, want %q", gotRequestID, expectedPromptCacheKey)
From 68282c4aa7a854e7907946f50d0e27ceb4c2290e Mon Sep 17 00:00:00 2001
From: sususu98
Date: Tue, 2 Jun 2026 16:48:58 +0800
Subject: [PATCH 105/248] fix(translator): normalize message-level system roles
for Gemini
---
.../claude/antigravity_claude_request.go | 2 +
.../claude/antigravity_claude_request_test.go | 47 +++++++++++++++++++
.../claude/gemini-cli_claude_request.go | 2 +
.../claude/gemini-cli_claude_request_test.go | 46 ++++++++++++++++++
.../gemini/claude/gemini_claude_request.go | 2 +
.../claude/gemini_claude_request_test.go | 46 ++++++++++++++++++
6 files changed, 145 insertions(+)
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go
index fe2c8cde904..76bad5d602e 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request.go
@@ -308,6 +308,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
role := originalRole
if role == "assistant" {
role = "model"
+ } else if role == "system" {
+ role = "user"
}
clientContentJSON := []byte(`{"role":"","parts":[]}`)
clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "role", role)
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
index 017078d432d..d843dd9483e 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
@@ -133,6 +133,53 @@ func TestConvertClaudeRequestToAntigravity_StripsClaudeCodeAttribution(t *testin
}
}
+func TestConvertClaudeRequestToAntigravity_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
+ inputJSON := []byte(`{
+ "model": "gemini-3.5-flash",
+ "system": [{"type": "text", "text": "Top-level rules"}],
+ "messages": [
+ {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
+ {"role": "system", "content": "String mid-conversation rule"},
+ {"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
+ ]
+ }`)
+
+ output := ConvertClaudeRequestToAntigravity("gemini-3-flash-agent", inputJSON, false)
+ outputStr := string(output)
+
+ if systemContent := gjson.Get(outputStr, `request.contents.#(role=="system")`); systemContent.Exists() {
+ t.Fatalf("system role should not be emitted in request.contents: %s", systemContent.Raw)
+ }
+
+ contents := gjson.Get(outputStr, "request.contents").Array()
+ if len(contents) != 3 {
+ t.Fatalf("Expected the user and message-level system turns in request.contents, got %d: %s", len(contents), gjson.Get(outputStr, "request.contents").Raw)
+ }
+ if got := contents[0].Get("role").String(); got != "user" {
+ t.Fatalf("Expected first content role user, got %q", got)
+ }
+ if got := contents[1].Get("role").String(); got != "user" {
+ t.Fatalf("Expected message-level system content to be downgraded to user role, got %q", got)
+ }
+ if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" {
+ t.Fatalf("Unexpected string message-level system content text: %q", got)
+ }
+ if got := contents[2].Get("role").String(); got != "user" {
+ t.Fatalf("Expected array message-level system content to be downgraded to user role, got %q", got)
+ }
+ if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" {
+ t.Fatalf("Unexpected array message-level system content text: %q", got)
+ }
+
+ parts := gjson.Get(outputStr, "request.systemInstruction.parts").Array()
+ if len(parts) != 1 {
+ t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.Get(outputStr, "request.systemInstruction.parts").Raw)
+ }
+ if got := parts[0].Get("text").String(); got != "Top-level rules" {
+ t.Fatalf("Unexpected first system part: %q", got)
+ }
+}
+
func testNonAnthropicRawSignature(t *testing.T) string {
t.Helper()
diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go
index b21936a95c7..80e942118b9 100644
--- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go
+++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go
@@ -77,6 +77,8 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
role := roleResult.String()
if role == "assistant" {
role = "model"
+ } else if role == "system" {
+ role = "user"
}
contentJSON := []byte(`{"role":"","parts":[]}`)
diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go
index ff0cea657ec..50a491fd938 100644
--- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go
+++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go
@@ -61,3 +61,49 @@ func TestConvertClaudeRequestToCLI_StripsClaudeCodeAttribution(t *testing.T) {
t.Fatalf("Unexpected system part: %q", got)
}
}
+
+func TestConvertClaudeRequestToCLI_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
+ inputJSON := []byte(`{
+ "model": "gemini-3-flash-preview",
+ "system": [{"type": "text", "text": "Top-level rules"}],
+ "messages": [
+ {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
+ {"role": "system", "content": "String mid-conversation rule"},
+ {"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
+ ]
+ }`)
+
+ output := ConvertClaudeRequestToCLI("gemini-3-flash-preview", inputJSON, false)
+
+ if systemContent := gjson.GetBytes(output, `request.contents.#(role=="system")`); systemContent.Exists() {
+ t.Fatalf("system role should not be emitted in request.contents: %s", systemContent.Raw)
+ }
+
+ contents := gjson.GetBytes(output, "request.contents").Array()
+ if len(contents) != 3 {
+ t.Fatalf("Expected the user and message-level system turns in request.contents, got %d: %s", len(contents), gjson.GetBytes(output, "request.contents").Raw)
+ }
+ if got := contents[0].Get("role").String(); got != "user" {
+ t.Fatalf("Expected first content role user, got %q", got)
+ }
+ if got := contents[1].Get("role").String(); got != "user" {
+ t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got)
+ }
+ if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" {
+ t.Fatalf("Unexpected string message-level system content text: %q", got)
+ }
+ if got := contents[2].Get("role").String(); got != "user" {
+ t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got)
+ }
+ if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" {
+ t.Fatalf("Unexpected array message-level system content text: %q", got)
+ }
+
+ parts := gjson.GetBytes(output, "request.systemInstruction.parts").Array()
+ if len(parts) != 1 {
+ t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "request.systemInstruction.parts").Raw)
+ }
+ if got := parts[0].Get("text").String(); got != "Top-level rules" {
+ t.Fatalf("Unexpected first system part: %q", got)
+ }
+}
diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go
index 128dac6e088..3347eaec13c 100644
--- a/internal/translator/gemini/claude/gemini_claude_request.go
+++ b/internal/translator/gemini/claude/gemini_claude_request.go
@@ -71,6 +71,8 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
role := roleResult.String()
if role == "assistant" {
role = "model"
+ } else if role == "system" {
+ role = "user"
}
contentJSON := []byte(`{"role":"","parts":[]}`)
diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go
index 01bed5f17c6..81b06214ed0 100644
--- a/internal/translator/gemini/claude/gemini_claude_request_test.go
+++ b/internal/translator/gemini/claude/gemini_claude_request_test.go
@@ -107,6 +107,52 @@ func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T)
}
}
+func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
+ inputJSON := []byte(`{
+ "model": "gemini-3-flash-preview",
+ "system": [{"type": "text", "text": "Top-level rules"}],
+ "messages": [
+ {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
+ {"role": "system", "content": "String mid-conversation rule"},
+ {"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
+ ]
+ }`)
+
+ output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
+
+ if systemContent := gjson.GetBytes(output, `contents.#(role=="system")`); systemContent.Exists() {
+ t.Fatalf("system role should not be emitted in contents: %s", systemContent.Raw)
+ }
+
+ contents := gjson.GetBytes(output, "contents").Array()
+ if len(contents) != 3 {
+ t.Fatalf("Expected the user and message-level system turns in contents, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw)
+ }
+ if got := contents[0].Get("role").String(); got != "user" {
+ t.Fatalf("Expected first content role user, got %q", got)
+ }
+ if got := contents[1].Get("role").String(); got != "user" {
+ t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got)
+ }
+ if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" {
+ t.Fatalf("Unexpected string message-level system content text: %q", got)
+ }
+ if got := contents[2].Get("role").String(); got != "user" {
+ t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got)
+ }
+ if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" {
+ t.Fatalf("Unexpected array message-level system content text: %q", got)
+ }
+
+ parts := gjson.GetBytes(output, "system_instruction.parts").Array()
+ if len(parts) != 1 {
+ t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "system_instruction.parts").Raw)
+ }
+ if got := parts[0].Get("text").String(); got != "Top-level rules" {
+ t.Fatalf("Unexpected first system part: %q", got)
+ }
+}
+
func TestConvertClaudeRequestToGemini_SkipsEmptyTextParts(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet",
From 28c7f41cbadef853d55bcd885205980b4fa05c24 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Tue, 2 Jun 2026 19:42:51 +0800
Subject: [PATCH 106/248] docs(readme): update project descriptions and add
Panopticon link
- Updated links and descriptions for `CPA-Manager-Plus`, replacing outdated `CPA-Manager` references.
- Added `Panopticon`, a multi-agent orchestration tool, to the project list.
---
README.md | 6 +-----
README_CN.md | 10 +++++-----
README_JA.md | 10 +++++-----
3 files changed, 11 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 969d2282666..f684d5d638a 100644
--- a/README.md
+++ b/README.md
@@ -80,11 +80,7 @@ Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Prox
Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics.
-### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
-
-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.
-
-### [CPA-Manager](https://github.com/seakee/CPA-Manager)
+### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
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.
diff --git a/README_CN.md b/README_CN.md
index 1af6e1605d9..08d13044959 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -80,11 +80,7 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo
独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CLIProxyAPI 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。
-### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
-
-面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 Token 消耗并写入 SQLite,按账号和模型可视化每日及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。
-
-### [CPA-Manager](https://github.com/seakee/CPA-Manager)
+### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
面向 CLIProxyAPI 的完整管理中心,提供请求级监控和费用预估。CPA-Manager 可按账号、模型、渠道、延迟、状态和 token 用量追踪采集到的请求;支持可编辑模型价格与一键同步 LiteLLM 价格来估算费用;用 SQLite 持久化事件;并提供面向 Codex 账号池的批量巡检、配额识别、异常账号定位、清理建议与一键执行能力,适合多账号池的日常运维管理。
@@ -201,6 +197,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
原生 macOS SwiftUI 应用,用于监控 CLIProxyAPI 池中的 ChatGPT/Codex 账号额度。通过 Management API 展示账号可用状态、Plus 基准容量、5 小时与周额度进度条、套餐权重和恢复预测。
+### [Panopticon](https://github.com/eltmon/panopticon-cli)
+
+面向 AI 编程助手的多智能体编排工具。它将 CLIProxyAPI 作为本地 sidecar 运行,使其智能体可以通过 ChatGPT 订阅驱动 GPT 模型,并将 Claude Code 指向 Anthropic 兼容端点,无需 OpenAI API 密钥。
+
> [!NOTE]
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
diff --git a/README_JA.md b/README_JA.md
index a13ff13d11d..48b6cc6bdb2 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -78,11 +78,7 @@ v6.10.0以降、CLIProxyAPIおよび [CPAMC](https://github.com/router-for-me/Cl
CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLIProxyAPIデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。
-### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
-
-CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのToken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量を可視化し、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。
-
-### [CPA-Manager](https://github.com/seakee/CPA-Manager)
+### [CPA-Manager-Plus](https://github.com/seakee/CPA-Manager-Plus)
リクエスト単位の監視とコスト推定を備えたCLIProxyAPI向けのフル管理センターです。CPA-Managerは、収集したリクエストをアカウント、モデル、チャネル、レイテンシ、ステータス、Token使用量ごとに追跡し、編集可能なモデル価格とLiteLLM価格のワンクリック同期でコストを推定します。SQLiteでイベントを永続化し、Codexアカウントプール向けに一括検査、クォータ判定、異常アカウント検出、クリーンアップ提案、ワンクリック実行を提供し、日常的なマルチアカウント運用に適しています。
@@ -200,6 +196,10 @@ CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデス
CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視するmacOSネイティブSwiftUIアプリ。Management APIを通じて、アカウントの可用性、Plus基準の容量、5時間/週次クォータバー、プラン重み、復元予測を表示します。
+### [Panopticon](https://github.com/eltmon/panopticon-cli)
+
+AIコーディングアシスタント向けのマルチエージェントオーケストレーションツール。CLIProxyAPIをローカルsidecarとして実行することで、エージェントがChatGPTサブスクリプション経由でGPTモデルを利用できるようにし、Claude CodeをAnthropic互換エンドポイントへ向けるため、OpenAI APIキーは不要です。
+
> [!NOTE]
> CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
From 0e3c809ceb6f023815e123a6ec287a1034ddd25d Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Wed, 3 Jun 2026 06:28:51 +0800
Subject: [PATCH 107/248] fix(codex): handle non-empty reasoning and content
items, add test for trailing empty messages
Closes: #3683
---
.../chat-completions/codex_openai_response.go | 8 ++++++--
.../codex_openai_response_test.go | 19 +++++++++++++++++++
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
index 75b5b848b3f..d638eec0793 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
@@ -381,7 +381,9 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
summaryArray := summaryResult.Array()
for _, summaryItem := range summaryArray {
if summaryItem.Get("type").String() == "summary_text" {
- reasoningText = summaryItem.Get("text").String()
+ if text := summaryItem.Get("text").String(); text != "" {
+ reasoningText += text
+ }
break
}
}
@@ -392,7 +394,9 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
contentArray := contentResult.Array()
for _, contentItem := range contentArray {
if contentItem.Get("type").String() == "output_text" {
- contentText = contentItem.Get("text").String()
+ if text := contentItem.Get("text").String(); text != "" {
+ contentText += text
+ }
break
}
}
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
index a6bb486fdf6..3e31d178a07 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
@@ -149,3 +149,22 @@ func TestConvertCodexResponseToOpenAI_NonStreamImageGenerationCallAddsMessageIma
t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out))
}
}
+
+func TestConvertCodexResponseToOpenAI_NonStreamMultiMessageEmptyTrailingKeepsContent(t *testing.T) {
+ ctx := context.Background()
+ raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"model":"gpt-5.5","status":"completed","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15},"output":[` +
+ `{"type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}]},` +
+ `{"type":"message","content":[{"type":"output_text","text":"the real answer"}]},` +
+ `{"type":"reasoning","summary":[{"type":"summary_text","text":"thinking again"}]},` +
+ `{"type":"message","content":[{"type":"output_text","text":""}]}` +
+ `]}}`)
+ out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, raw, nil)
+
+ got := gjson.GetBytes(out, "choices.0.message.content")
+ if !got.Exists() || got.Type == gjson.Null {
+ t.Fatalf("content was dropped to null by trailing empty message; resp=%s", string(out))
+ }
+ if got.String() != "the real answer" {
+ t.Fatalf("expected content %q, got %q; resp=%s", "the real answer", got.String(), string(out))
+ }
+}
From 35ab084fc35c7a77fab82791a3ddc653f1ca0cf3 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Wed, 3 Jun 2026 06:58:26 +0800
Subject: [PATCH 108/248] refactor(runtime): enhance `NewUtlsHTTPClient` with
context-based RoundTripper
- Updated `NewUtlsHTTPClient` to support context-aware RoundTrippers for protected hosts (e.g., Cloudflare bypass).
- Replaced `anthropicHosts` with `utlsProtectedHosts` to generalize host handling logic.
- Added unit test to validate context-based RoundTripper behavior.
- Replaced `NewProxyAwareHTTPClient` with `NewUtlsHTTPClient` in relevant executors for improved TLS fingerprinting.
Closes: #3680
---
internal/runtime/executor/claude_executor.go | 8 ++--
internal/runtime/executor/codex_executor.go | 8 ++--
.../runtime/executor/helps/utls_client.go | 35 ++++++++-------
.../executor/helps/utls_client_test.go | 45 +++++++++++++++++++
4 files changed, 73 insertions(+), 23 deletions(-)
create mode 100644 internal/runtime/executor/helps/utls_client_test.go
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 5e95cb1dc8d..3766900e007 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -156,7 +156,7 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut
if err := e.PrepareRequest(httpReq, auth); err != nil {
return nil, err
}
- httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
return httpClient.Do(httpReq)
}
@@ -260,7 +260,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
AuthValue: authValue,
})
- httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
@@ -437,7 +437,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
AuthValue: authValue,
})
- httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
@@ -674,7 +674,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
AuthValue: authValue,
})
- httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
resp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index 2b243db8a51..399368125b8 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -744,7 +744,7 @@ func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth
if err := e.PrepareRequest(httpReq, auth); err != nil {
return nil, err
}
- httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
return httpClient.Do(httpReq)
}
@@ -821,7 +821,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
AuthType: authType,
AuthValue: authValue,
})
- httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
@@ -987,7 +987,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
AuthType: authType,
AuthValue: authValue,
})
- httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
@@ -1097,7 +1097,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
AuthValue: authValue,
})
- httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
+ httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go
index 3c17dc63cee..ad3315c6633 100644
--- a/internal/runtime/executor/helps/utls_client.go
+++ b/internal/runtime/executor/helps/utls_client.go
@@ -1,6 +1,7 @@
package helps
import (
+ "context"
"net"
"net/http"
"strings"
@@ -128,21 +129,23 @@ func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
return resp, nil
}
-// anthropicHosts contains the hosts that should use utls Chrome TLS fingerprint.
-var anthropicHosts = map[string]struct{}{
+// utlsProtectedHosts contains the hosts that should use utls Chrome TLS fingerprint
+// to bypass Cloudflare's TLS fingerprinting.
+var utlsProtectedHosts = map[string]struct{}{
"api.anthropic.com": {},
+ "chatgpt.com": {},
}
-// fallbackRoundTripper uses utls for Anthropic HTTPS hosts and falls back to
-// standard transport for all other requests (non-HTTPS or non-Anthropic hosts).
+// fallbackRoundTripper uses utls for protected HTTPS hosts and falls back to
+// standard transport for all other requests.
type fallbackRoundTripper struct {
- utls *utlsRoundTripper
+ utls http.RoundTripper
fallback http.RoundTripper
}
func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme == "https" {
- if _, ok := anthropicHosts[strings.ToLower(req.URL.Hostname())]; ok {
+ if _, ok := utlsProtectedHosts[strings.ToLower(req.URL.Hostname())]; ok {
return f.utls.RoundTrip(req)
}
}
@@ -150,9 +153,9 @@ func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
}
// NewUtlsHTTPClient creates an HTTP client using utls Chrome TLS fingerprint.
-// Use this for Claude API requests to match real Claude Code's TLS behavior.
+// Use this for provider requests that need a Chrome-like TLS fingerprint.
// Falls back to standard transport for non-HTTPS requests.
-func NewUtlsHTTPClient(cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
+func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
var proxyURL string
if auth != nil {
proxyURL = strings.TrimSpace(auth.ProxyURL)
@@ -161,18 +164,20 @@ func NewUtlsHTTPClient(cfg *config.Config, auth *cliproxyauth.Auth, timeout time
proxyURL = strings.TrimSpace(cfg.ProxyURL)
}
- utlsRT := newUtlsRoundTripper(proxyURL)
-
- var standardTransport http.RoundTripper = &http.Transport{
- DialContext: (&net.Dialer{
- Timeout: 30 * time.Second,
- KeepAlive: 30 * time.Second,
- }).DialContext,
+ var ctxRoundTripper http.RoundTripper
+ if ctx != nil {
+ ctxRoundTripper, _ = ctx.Value("cliproxy.roundtripper").(http.RoundTripper)
}
+
+ var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL)
+ var standardTransport http.RoundTripper = http.DefaultTransport
if proxyURL != "" {
if transport := buildProxyTransport(proxyURL); transport != nil {
standardTransport = transport
}
+ } else if ctxRoundTripper != nil {
+ utlsRT = ctxRoundTripper
+ standardTransport = ctxRoundTripper
}
client := &http.Client{
diff --git a/internal/runtime/executor/helps/utls_client_test.go b/internal/runtime/executor/helps/utls_client_test.go
new file mode 100644
index 00000000000..093ad4bef7c
--- /dev/null
+++ b/internal/runtime/executor/helps/utls_client_test.go
@@ -0,0 +1,45 @@
+package helps
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+type utlsClientRoundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f utlsClientRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+func TestNewUtlsHTTPClientUsesContextRoundTripperForProtectedHost(t *testing.T) {
+ t.Parallel()
+
+ called := false
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", utlsClientRoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ called = true
+ if req.URL.Hostname() != "chatgpt.com" {
+ t.Fatalf("hostname = %q, want chatgpt.com", req.URL.Hostname())
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("{}")),
+ Request: req,
+ }, nil
+ }))
+
+ client := NewUtlsHTTPClient(ctx, nil, nil, 0)
+ resp, err := client.Get("https://chatgpt.com/backend-api/codex/responses")
+ if err != nil {
+ t.Fatalf("client.Get returned error: %v", err)
+ }
+ if errClose := resp.Body.Close(); errClose != nil {
+ t.Fatalf("response body close returned error: %v", errClose)
+ }
+ if !called {
+ t.Fatal("expected context RoundTripper to handle protected host request")
+ }
+}
From 17af0891891563c8cb11eaf60e33a1b2b5a957f9 Mon Sep 17 00:00:00 2001
From: sususu98
Date: Wed, 3 Jun 2026 09:50:48 +0800
Subject: [PATCH 109/248] fix(codex): avoid replaying orphan tool calls
---
internal/runtime/executor/codex_executor.go | 24 ++++++++++
...ex_executor_reasoning_replay_cache_test.go | 48 +++++++++++++++++++
2 files changed, 72 insertions(+)
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index 399368125b8..73187963c72 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -465,7 +465,17 @@ func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]by
hasInputReasoning := codexInputHasValidReasoningEncryptedContent(body)
existingCalls := make(map[string]bool)
+ existingOutputs := make(map[string]bool)
for _, inputItem := range input.Array() {
+ itemType := strings.TrimSpace(inputItem.Get("type").String())
+ if itemType == "function_call_output" || itemType == "custom_tool_call_output" {
+ callID := strings.TrimSpace(inputItem.Get("call_id").String())
+ if callID != "" {
+ for _, candidate := range codexReplayComparableCallIDs(callID) {
+ existingOutputs[candidate] = true
+ }
+ }
+ }
for _, key := range codexReplayToolCallKeys(inputItem) {
existingCalls[key] = true
}
@@ -484,6 +494,20 @@ func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]by
if len(keys) == 0 || codexReplayAnyToolCallKeyExists(existingCalls, keys) {
continue
}
+ // Only inject if there is a matching output in the request
+ hasMatchingOutput := false
+ callID := strings.TrimSpace(itemResult.Get("call_id").String())
+ if callID != "" {
+ for _, candidate := range codexReplayComparableCallIDs(callID) {
+ if existingOutputs[candidate] {
+ hasMatchingOutput = true
+ break
+ }
+ }
+ }
+ if !hasMatchingOutput {
+ continue
+ }
for _, key := range keys {
existingCalls[key] = true
}
diff --git a/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go b/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
index a15007ed3bf..8c94b146b37 100644
--- a/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
+++ b/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
@@ -710,6 +710,54 @@ func TestCodexExecutorReasoningReplayCacheReplaysFunctionCallForClaudeToolResult
}
}
+func TestCodexExecutorReasoningReplayCacheDropsFunctionCallWithoutMatchingOutput(t *testing.T) {
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+
+ encryptedContent := validCodexReasoningEncryptedContentForTestSeed(14)
+ scope := codexReasoningReplayScope{
+ modelName: "gpt-5.4",
+ sessionKey: "claude:session-dropped-tool",
+ }
+ cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
+ `{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"},`+
+ `{"type":"function_call","call_id":"call_dropped","name":"TaskCreate","arguments":"{}"}`+
+ `]}}`))
+
+ body := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`)
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{
+ "model":"gpt-5.4",
+ "metadata":{"user_id":"{\"device_id\":\"device-test\",\"account_uuid\":\"\",\"session_id\":\"session-dropped-tool\"}"},
+ "messages":[{"role":"user","content":[{"type":"text","text":"next"}]}]
+ }`),
+ }
+
+ updated, replayScope := applyCodexReasoningReplayCache(
+ context.Background(),
+ sdktranslator.FromString("claude"),
+ req,
+ cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")},
+ body,
+ )
+ if replayScope != scope {
+ t.Fatalf("replay scope = %#v, want %#v", replayScope, scope)
+ }
+ if got := gjson.GetBytes(updated, "input.0.type").String(); got != "reasoning" {
+ t.Fatalf("input.0.type = %q, want reasoning; body=%s", got, string(updated))
+ }
+ if got := gjson.GetBytes(updated, "input.0.encrypted_content").String(); got != encryptedContent {
+ t.Fatalf("input.0.encrypted_content = %q, want cached reasoning; body=%s", got, string(updated))
+ }
+ if gjson.GetBytes(updated, `input.#(call_id=="call_dropped")`).Exists() {
+ t.Fatalf("cached function_call without matching output should not be replayed; body=%s", string(updated))
+ }
+ if got := gjson.GetBytes(updated, "input.1.role").String(); got != "user" {
+ t.Fatalf("input.1.role = %q, want user; body=%s", got, string(updated))
+ }
+}
+
func TestCodexExecutorReasoningReplayCacheMatchesShortenedClaudeToolResultCallID(t *testing.T) {
internalcache.ClearCodexReasoningReplayCache()
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
From 45f58d4f91b78be9c27eac737a22934ff9c392fa Mon Sep 17 00:00:00 2001
From: sususu98
Date: Wed, 3 Jun 2026 10:25:10 +0800
Subject: [PATCH 110/248] fix(auth): retry and backoff cloudflare challenge 403
errors
Introduce Cloudflare challenge detection for 403 errors in the Auth
Manager. Apply a progressive rate-limiting cooldown ladder using the
existing BackoffLevel field instead of a hard 30-minute credentials
suspension. This ensures challenged requests fall through to subsequent
credentials and recover exponentially.
Co-Authored-By: Claude Opus 4.8
---
sdk/cliproxy/auth/conductor.go | 71 +++++++++++++++++++
sdk/cliproxy/auth/conductor_overrides_test.go | 54 ++++++++++++++
2 files changed, 125 insertions(+)
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index c5c7e3f9497..76c2a7aeb4f 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -2351,6 +2351,30 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
state.NextRetryAfter = next
suspendReason = "model_not_supported"
shouldSuspendModel = true
+ } else if isCloudflareChallengeResultError(result.Error) {
+ var next time.Time
+ backoffLevel := state.Quota.BackoffLevel
+ if !disableCooling {
+ cooldown, nextLevel := nextQuotaCooldown(backoffLevel, disableCooling)
+ if cooldown < 10*time.Second {
+ cooldown = 10 * time.Second
+ }
+ if cooldown > 0 {
+ next = now.Add(cooldown)
+ }
+ backoffLevel = nextLevel
+ }
+ state.NextRetryAfter = next
+ state.StatusMessage = "cloudflare challenge"
+ if auth.LastError != nil {
+ auth.StatusMessage = "cloudflare challenge"
+ }
+ state.Quota = QuotaState{
+ Exceeded: true,
+ Reason: "cloudflare challenge",
+ NextRecoverAt: next,
+ BackoffLevel: backoffLevel,
+ }
} else {
switch statusCode {
case 401:
@@ -2750,6 +2774,27 @@ func isModelSupportResultError(err *Error) bool {
return isModelSupportErrorMessage(err.Message)
}
+func isCloudflareChallengeErrorMessage(message string) bool {
+ lower := strings.ToLower(strings.TrimSpace(message))
+ return strings.Contains(lower, "challenge-platform") ||
+ strings.Contains(lower, "cf-mitigated") ||
+ strings.Contains(lower, "challenge") ||
+ (strings.Contains(lower, "cloudflare") && strings.Contains(lower, " 0 {
+ next = now.Add(cooldown)
+ }
+ backoffLevel = nextLevel
+ }
+ auth.Quota = QuotaState{
+ Exceeded: true,
+ Reason: "cloudflare challenge",
+ NextRecoverAt: next,
+ BackoffLevel: backoffLevel,
+ }
+ auth.NextRetryAfter = next
+ return
+ }
switch statusCode {
case 401:
auth.StatusMessage = "unauthorized"
diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go
index 017602e3624..5acd331e1f5 100644
--- a/sdk/cliproxy/auth/conductor_overrides_test.go
+++ b/sdk/cliproxy/auth/conductor_overrides_test.go
@@ -570,6 +570,60 @@ func TestManager_MarkResult_RespectsAuthDisableCoolingOverride_On403(t *testing.
}
}
+func TestManager_MarkResult_CloudflareChallenge_On403(t *testing.T) {
+ prev := quotaCooldownDisabled.Load()
+ quotaCooldownDisabled.Store(false)
+ t.Cleanup(func() { quotaCooldownDisabled.Store(prev) })
+
+ m := NewManager(nil, nil, nil)
+
+ auth := &Auth{
+ ID: "auth-cf-403",
+ Provider: "claude",
+ }
+ if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ model := "test-model-cf-403"
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID,
+ Provider: "claude",
+ Model: model,
+ Success: false,
+ Error: &Error{HTTPStatus: http.StatusForbidden, Message: "cf-mitigated: challenge"},
+ })
+
+ updated, ok := m.GetByID(auth.ID)
+ if !ok || updated == nil {
+ t.Fatalf("expected auth to be present")
+ }
+ state := updated.ModelStates[model]
+ if state == nil {
+ t.Fatalf("expected model state to be present")
+ }
+ if state.NextRetryAfter.IsZero() {
+ t.Fatalf("expected NextRetryAfter to be non-zero for cloudflare challenge")
+ }
+ diff := time.Until(state.NextRetryAfter)
+ if diff < 5*time.Second || diff > 25*time.Second {
+ t.Fatalf("expected NextRetryAfter to be ~10 seconds, got %v", diff)
+ }
+ if state.StatusMessage != "cloudflare challenge" {
+ t.Fatalf("expected StatusMessage to be 'cloudflare challenge', got %s", state.StatusMessage)
+ }
+
+ // Because Cloudflare Challenge is treated as transient (no suspension),
+ // the model should NOT be suspended in the global registry, so count > 0.
+ if count := reg.GetModelCount(model); count <= 0 {
+ t.Fatalf("expected model count > 0 for cloudflare challenge transient cooldown, got %d", count)
+ }
+}
+
func TestManager_Execute_DisableCooling_DoesNotBlackoutAfter403(t *testing.T) {
prev := quotaCooldownDisabled.Load()
quotaCooldownDisabled.Store(false)
From 77061aad4ba9b4ebd8ccea20f421e3006129af2f Mon Sep 17 00:00:00 2001
From: sususu98
Date: Wed, 3 Jun 2026 10:35:39 +0800
Subject: [PATCH 111/248] refactor(auth): simplify and narrow cloudflare
challenge checks
---
sdk/cliproxy/auth/conductor.go | 43 ++++++++++++++--------------------
1 file changed, 18 insertions(+), 25 deletions(-)
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index 76c2a7aeb4f..2d355d48a6b 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -2352,18 +2352,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
suspendReason = "model_not_supported"
shouldSuspendModel = true
} else if isCloudflareChallengeResultError(result.Error) {
- var next time.Time
- backoffLevel := state.Quota.BackoffLevel
- if !disableCooling {
- cooldown, nextLevel := nextQuotaCooldown(backoffLevel, disableCooling)
- if cooldown < 10*time.Second {
- cooldown = 10 * time.Second
- }
- if cooldown > 0 {
- next = now.Add(cooldown)
- }
- backoffLevel = nextLevel
- }
+ next, backoffLevel := nextCloudflareCooldown(state.Quota.BackoffLevel, disableCooling, now)
state.NextRetryAfter = next
state.StatusMessage = "cloudflare challenge"
if auth.LastError != nil {
@@ -2778,7 +2767,7 @@ func isCloudflareChallengeErrorMessage(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
return strings.Contains(lower, "challenge-platform") ||
strings.Contains(lower, "cf-mitigated") ||
- strings.Contains(lower, "challenge") ||
+ strings.Contains(lower, "cloudflare challenge") ||
(strings.Contains(lower, "cloudflare") && strings.Contains(lower, " 0 {
+ next = now.Add(cooldown)
+ }
+ backoffLevel = nextLevel
+ }
+ return next, backoffLevel
+}
func isRequestScopedNotFoundMessage(message string) bool {
if message == "" {
return false
@@ -2868,18 +2872,7 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati
statusCode := statusCodeFromResult(resultErr)
if isCloudflareChallengeResultError(resultErr) {
auth.StatusMessage = "cloudflare challenge"
- var next time.Time
- backoffLevel := auth.Quota.BackoffLevel
- if !disableCooling {
- cooldown, nextLevel := nextQuotaCooldown(backoffLevel, disableCooling)
- if cooldown < 10*time.Second {
- cooldown = 10 * time.Second
- }
- if cooldown > 0 {
- next = now.Add(cooldown)
- }
- backoffLevel = nextLevel
- }
+ next, backoffLevel := nextCloudflareCooldown(auth.Quota.BackoffLevel, disableCooling, now)
auth.Quota = QuotaState{
Exceeded: true,
Reason: "cloudflare challenge",
From 55440f0a3907f9085edbe179de71877c9fde9369 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Wed, 3 Jun 2026 11:52:27 +0800
Subject: [PATCH 112/248] feat(auth): add runtime auth removal and unscheduling
logic
- Introduced `Manager.Remove` to delete runtime auth and unschedule associated tasks.
- Updated handler logic to directly remove auth instead of marking as disabled.
- Added tests to validate removal, unscheduling, and runtime state handling.
- Added a test to validate `skipPersist` behavior during registration.
- Enhanced `Remove` test to verify auto-refresh loop state before and after removal.
Closes: #3690
---
.../api/handlers/management/auth_files.go | 24 ++--
.../management/auth_files_delete_test.go | 46 ++++++++
sdk/cliproxy/auth/conductor.go | 99 ++++++++++++++--
sdk/cliproxy/auth/conductor_remove_test.go | 111 ++++++++++++++++++
sdk/cliproxy/auth/persist_policy_test.go | 7 ++
sdk/cliproxy/service.go | 18 ++-
sdk/cliproxy/service_stale_state_test.go | 33 +-----
7 files changed, 268 insertions(+), 70 deletions(-)
create mode 100644 sdk/cliproxy/auth/conductor_remove_test.go
diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go
index c32f41a71a9..b26bea75370 100644
--- a/internal/api/handlers/management/auth_files.go
+++ b/internal/api/handlers/management/auth_files.go
@@ -770,7 +770,7 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
return
}
deleted++
- h.disableAuth(ctx, full)
+ h.removeAuth(ctx, full)
}
}
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
@@ -976,9 +976,9 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string
return filepath.Base(name), http.StatusInternalServerError, errDeleteRecord
}
if targetID != "" {
- h.disableAuth(ctx, targetID)
+ h.removeAuth(ctx, targetID)
} else {
- h.disableAuth(ctx, targetPath)
+ h.removeAuth(ctx, targetPath)
}
return filepath.Base(name), http.StatusOK, nil
}
@@ -1558,7 +1558,7 @@ func syncAuthFileDisabledState(auth *coreauth.Auth) {
auth.StatusMessage = ""
}
-func (h *Handler) disableAuth(ctx context.Context, id string) {
+func (h *Handler) removeAuth(ctx context.Context, id string) {
if h == nil || h.authManager == nil {
return
}
@@ -1566,25 +1566,15 @@ func (h *Handler) disableAuth(ctx context.Context, id string) {
if id == "" {
return
}
- if auth, ok := h.authManager.GetByID(id); ok {
- auth.Disabled = true
- auth.Status = coreauth.StatusDisabled
- auth.StatusMessage = "removed via management API"
- auth.UpdatedAt = time.Now()
- _, _ = h.authManager.Update(ctx, auth)
+ if _, ok := h.authManager.GetByID(id); ok {
+ h.authManager.Remove(ctx, id)
return
}
authID := h.authIDForPath(id)
if authID == "" {
return
}
- if auth, ok := h.authManager.GetByID(authID); ok {
- auth.Disabled = true
- auth.Status = coreauth.StatusDisabled
- auth.StatusMessage = "removed via management API"
- auth.UpdatedAt = time.Now()
- _, _ = h.authManager.Update(ctx, auth)
- }
+ h.authManager.Remove(ctx, authID)
}
func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
diff --git a/internal/api/handlers/management/auth_files_delete_test.go b/internal/api/handlers/management/auth_files_delete_test.go
index a57c9993ada..b67f1f66c58 100644
--- a/internal/api/handlers/management/auth_files_delete_test.go
+++ b/internal/api/handlers/management/auth_files_delete_test.go
@@ -127,3 +127,49 @@ func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {
t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat)
}
}
+
+func TestDeleteAuthFile_RemovesRuntimeAuth(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ fileName := "runtime-remove-user.json"
+ filePath := filepath.Join(authDir, fileName)
+ if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex","email":"runtime@example.com"}`), 0o600); errWrite != nil {
+ t.Fatalf("failed to write auth file: %v", errWrite)
+ }
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ record := &coreauth.Auth{
+ ID: "runtime-remove-auth",
+ FileName: fileName,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": filePath,
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "email": "runtime@example.com",
+ },
+ }
+ if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
+ t.Fatalf("failed to register auth record: %v", errRegister)
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
+ h.tokenStore = &memoryAuthStore{}
+
+ deleteRec := httptest.NewRecorder()
+ deleteCtx, _ := gin.CreateTestContext(deleteRec)
+ deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
+ deleteCtx.Request = deleteReq
+ h.DeleteAuthFile(deleteCtx)
+
+ if deleteRec.Code != http.StatusOK {
+ t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
+ }
+ if _, ok := manager.GetByID(record.ID); ok {
+ t.Fatalf("expected runtime auth %q to be removed", record.ID)
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index 2d355d48a6b..8c8effcddbb 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -1164,18 +1164,21 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
return nil, nil
}
m.mu.Lock()
- if existing, ok := m.auths[auth.ID]; ok && existing != nil {
- if !auth.indexAssigned && auth.Index == "" {
- auth.Index = existing.Index
- auth.indexAssigned = existing.indexAssigned
- }
- auth.Success = existing.Success
- auth.Failed = existing.Failed
- auth.recentRequests = existing.recentRequests
- if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled {
- if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
- auth.ModelStates = existing.ModelStates
- }
+ existing, ok := m.auths[auth.ID]
+ if !ok || existing == nil {
+ m.mu.Unlock()
+ return nil, nil
+ }
+ if !auth.indexAssigned && auth.Index == "" {
+ auth.Index = existing.Index
+ auth.indexAssigned = existing.indexAssigned
+ }
+ auth.Success = existing.Success
+ auth.Failed = existing.Failed
+ auth.recentRequests = existing.recentRequests
+ if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled {
+ if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
+ auth.ModelStates = existing.ModelStates
}
}
auth.EnsureIndex()
@@ -1192,6 +1195,65 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
return auth.Clone(), nil
}
+// Remove deletes an auth from runtime state without persisting.
+// Disk and token-store deletion must be handled by the caller.
+func (m *Manager) Remove(ctx context.Context, id string) {
+ if m == nil {
+ return
+ }
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return
+ }
+ _ = ctx
+
+ m.mu.Lock()
+ existing := m.auths[id]
+ if existing == nil {
+ m.mu.Unlock()
+ return
+ }
+ provider := strings.TrimSpace(existing.Provider)
+ delete(m.auths, id)
+ if m.modelPoolOffsets != nil {
+ delete(m.modelPoolOffsets, id)
+ }
+ for sessionID, sessionAuths := range m.homeRuntimeAuths {
+ if sessionAuths == nil {
+ continue
+ }
+ delete(sessionAuths, id)
+ if len(sessionAuths) == 0 {
+ delete(m.homeRuntimeAuths, sessionID)
+ }
+ }
+ m.mu.Unlock()
+
+ m.rebuildAPIKeyModelAliasFromRuntimeConfig()
+ if m.scheduler != nil {
+ m.scheduler.removeAuth(id)
+ }
+ m.queueRefreshUnschedule(id)
+ m.invalidateSessionAffinity(id)
+
+ if provider != "" {
+ if exec, ok := m.Executor(provider); ok && exec != nil {
+ if closer, okCloser := exec.(ExecutionSessionCloser); okCloser {
+ closer.CloseExecutionSession(CloseAllExecutionSessionsID)
+ }
+ }
+ }
+}
+
+func (m *Manager) invalidateSessionAffinity(authID string) {
+ if m == nil || authID == "" {
+ return
+ }
+ if invalidator, ok := m.selector.(interface{ InvalidateAuth(string) }); ok && invalidator != nil {
+ invalidator.InvalidateAuth(authID)
+ }
+}
+
// Load resets manager state from the backing store.
func (m *Manager) Load(ctx context.Context) error {
m.mu.Lock()
@@ -4041,6 +4103,19 @@ func (m *Manager) queueRefreshReschedule(authID string) {
loop.queueReschedule(authID)
}
+func (m *Manager) queueRefreshUnschedule(authID string) {
+ if m == nil || authID == "" {
+ return
+ }
+ m.mu.RLock()
+ loop := m.refreshLoop
+ m.mu.RUnlock()
+ if loop == nil {
+ return
+ }
+ loop.remove(authID)
+}
+
func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
if a == nil {
return false
diff --git a/sdk/cliproxy/auth/conductor_remove_test.go b/sdk/cliproxy/auth/conductor_remove_test.go
new file mode 100644
index 00000000000..1ada1d74fea
--- /dev/null
+++ b/sdk/cliproxy/auth/conductor_remove_test.go
@@ -0,0 +1,111 @@
+package auth
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestManager_Remove_DeletesRuntimeAuth(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ ctx := context.Background()
+
+ auth := &Auth{
+ ID: "remove-runtime-auth",
+ Provider: "claude",
+ Status: StatusActive,
+ Metadata: map[string]any{"email": "x@example.com"},
+ }
+ if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ manager.Remove(ctx, auth.ID)
+
+ if _, ok := manager.GetByID(auth.ID); ok {
+ t.Fatalf("expected auth %q to be removed", auth.ID)
+ }
+}
+
+func TestManager_Update_MissingAuthIsNoOp(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ ctx := context.Background()
+
+ auth := &Auth{
+ ID: "missing-update-auth",
+ Provider: "claude",
+ Status: StatusActive,
+ }
+ if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ manager.Remove(ctx, auth.ID)
+
+ updated, errUpdate := manager.Update(ctx, &Auth{
+ ID: auth.ID,
+ Provider: "claude",
+ Status: StatusDisabled,
+ Disabled: true,
+ })
+ if errUpdate != nil {
+ t.Fatalf("update removed auth: %v", errUpdate)
+ }
+ if updated != nil {
+ t.Fatalf("expected update on removed auth to be no-op, got %#v", updated)
+ }
+ if _, ok := manager.GetByID(auth.ID); ok {
+ t.Fatalf("expected removed auth to stay absent after late update")
+ }
+}
+
+func TestManager_Remove_UnschedulesAutoRefresh(t *testing.T) {
+ ctx := context.Background()
+
+ manager := NewManager(nil, nil, nil)
+ loop := newAuthAutoRefreshLoop(manager, time.Second, 1)
+ manager.mu.Lock()
+ manager.refreshLoop = loop
+ manager.mu.Unlock()
+
+ lead := 10 * time.Minute
+ setRefreshLeadFactory(t, "provider-lead-expiry", func() *time.Duration {
+ d := lead
+ return &d
+ })
+
+ auth := &Auth{
+ ID: "remove-refresh-auth",
+ Provider: "provider-lead-expiry",
+ Metadata: map[string]any{
+ "email": "x@example.com",
+ "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
+ },
+ }
+ if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ now := time.Now()
+ if _, ok := nextRefreshCheckAt(now, auth, time.Second); !ok {
+ t.Fatalf("expected auth to be scheduled before removal")
+ }
+ loop.applyDirty(now)
+ loop.mu.Lock()
+ if _, ok := loop.index[auth.ID]; !ok {
+ loop.mu.Unlock()
+ t.Fatalf("expected auth %q to be present in auto-refresh index before removal", auth.ID)
+ }
+ loop.mu.Unlock()
+
+ manager.Remove(ctx, auth.ID)
+
+ if _, ok := manager.GetByID(auth.ID); ok {
+ t.Fatalf("expected auth to be removed")
+ }
+ loop.mu.Lock()
+ if _, ok := loop.index[auth.ID]; ok {
+ loop.mu.Unlock()
+ t.Fatalf("expected auth %q to be removed from auto-refresh index", auth.ID)
+ }
+ loop.mu.Unlock()
+}
diff --git a/sdk/cliproxy/auth/persist_policy_test.go b/sdk/cliproxy/auth/persist_policy_test.go
index f408c872dcc..6ec4aaf2f85 100644
--- a/sdk/cliproxy/auth/persist_policy_test.go
+++ b/sdk/cliproxy/auth/persist_policy_test.go
@@ -28,6 +28,13 @@ func TestWithSkipPersist_DisablesUpdatePersistence(t *testing.T) {
Metadata: map[string]any{"type": "antigravity"},
}
+ if _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil {
+ t.Fatalf("Register(skipPersist) returned error: %v", err)
+ }
+ if got := store.saveCount.Load(); got != 0 {
+ t.Fatalf("expected 0 Save calls, got %d", got)
+ }
+
if _, err := mgr.Update(context.Background(), auth); err != nil {
t.Fatalf("Update returned error: %v", err)
}
diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go
index 10c3d0dd938..ff30ad372e6 100644
--- a/sdk/cliproxy/service.go
+++ b/sdk/cliproxy/service.go
@@ -339,17 +339,15 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
if s.coreManager == nil {
return
}
- GlobalModelRegistry().UnregisterClient(id)
+ id = strings.TrimSpace(id)
+ var provider string
if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
- existing.Disabled = true
- existing.Status = coreauth.StatusDisabled
- if _, err := s.coreManager.Update(ctx, existing); err != nil {
- log.Errorf("failed to disable auth %s: %v", id, err)
- }
- if strings.EqualFold(strings.TrimSpace(existing.Provider), "codex") {
- executor.CloseCodexWebsocketSessionsForAuthID(existing.ID, "auth_removed")
- s.ensureExecutorsForAuth(existing)
- }
+ provider = strings.TrimSpace(existing.Provider)
+ }
+ GlobalModelRegistry().UnregisterClient(id)
+ s.coreManager.Remove(ctx, id)
+ if strings.EqualFold(provider, "codex") {
+ executor.CloseCodexWebsocketSessionsForAuthID(id, "auth_removed")
}
}
diff --git a/sdk/cliproxy/service_stale_state_test.go b/sdk/cliproxy/service_stale_state_test.go
index 53849eb3492..f5f72e7ec3c 100644
--- a/sdk/cliproxy/service_stale_state_test.go
+++ b/sdk/cliproxy/service_stale_state_test.go
@@ -40,37 +40,8 @@ func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeSt
service.applyCoreAuthRemoval(context.Background(), authID)
- disabled, ok := service.coreManager.GetByID(authID)
- if !ok || disabled == nil {
- t.Fatalf("expected disabled auth after removal")
- }
- if !disabled.Disabled || disabled.Status != coreauth.StatusDisabled {
- t.Fatalf("expected disabled auth after removal, got disabled=%v status=%v", disabled.Disabled, disabled.Status)
- }
- if disabled.LastRefreshedAt.IsZero() {
- t.Fatalf("expected disabled auth to still carry prior LastRefreshedAt for regression setup")
- }
- if disabled.NextRefreshAfter.IsZero() {
- t.Fatalf("expected disabled auth to still carry prior NextRefreshAfter for regression setup")
- }
-
- // Reconcile prunes unsupported model state during registration, so seed the
- // disabled snapshot explicitly before exercising delete -> re-add behavior.
- disabled.ModelStates = map[string]*coreauth.ModelState{
- modelID: {
- Quota: coreauth.QuotaState{BackoffLevel: 7},
- },
- }
- if _, err := service.coreManager.Update(context.Background(), disabled); err != nil {
- t.Fatalf("seed disabled auth stale ModelStates: %v", err)
- }
-
- disabled, ok = service.coreManager.GetByID(authID)
- if !ok || disabled == nil {
- t.Fatalf("expected disabled auth after stale state seeding")
- }
- if len(disabled.ModelStates) == 0 {
- t.Fatalf("expected disabled auth to carry seeded ModelStates for regression setup")
+ if _, ok := service.coreManager.GetByID(authID); ok {
+ t.Fatalf("expected auth %q to be removed from runtime state", authID)
}
service.applyCoreAuthAddOrUpdate(context.Background(), &coreauth.Auth{
From 1074507a2f767ef5e8b374db788176bf3e50dba9 Mon Sep 17 00:00:00 2001
From: Villoh
Date: Wed, 3 Jun 2026 18:34:56 +0200
Subject: [PATCH 113/248] docs: add Tunnel Agent to community projects
---
README.md | 4 ++++
README_CN.md | 4 ++++
README_JA.md | 4 ++++
3 files changed, 12 insertions(+)
diff --git a/README.md b/README.md
index f684d5d638a..3ef7e93b0f7 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,10 @@ Native macOS SwiftUI app for monitoring ChatGPT/Codex account quotas in CLIProxy
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.
+### [Tunnel Agent](https://github.com/Villoh/tunnel-agent)
+
+Windows desktop UI that manages CLIProxyAPI and Perplexity WebUI Scraper from a single interface, inspired by Quotio and VibeProxy. Connect OAuth providers (Claude, Gemini CLI, Codex, Kimi, Antigravity), custom API keys, and Perplexity session accounts, then point any coding agent at the local endpoint.
+
> [!NOTE]
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
diff --git a/README_CN.md b/README_CN.md
index 08d13044959..ae9d3b32346 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -201,6 +201,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
面向 AI 编程助手的多智能体编排工具。它将 CLIProxyAPI 作为本地 sidecar 运行,使其智能体可以通过 ChatGPT 订阅驱动 GPT 模型,并将 Claude Code 指向 Anthropic 兼容端点,无需 OpenAI API 密钥。
+### [Tunnel Agent](https://github.com/Villoh/tunnel-agent)
+
+Windows 桌面 UI,通过单一界面管理 CLIProxyAPI 和 Perplexity WebUI Scraper,灵感来自 Quotio 和 VibeProxy。连接 OAuth 提供商(Claude、Gemini CLI、Codex、Kimi、Antigravity)、自定义 API 密钥和 Perplexity 会话账号,然后将任意编程 Agent 指向本地端点。
+
> [!NOTE]
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
diff --git a/README_JA.md b/README_JA.md
index 48b6cc6bdb2..d292890a59c 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -200,6 +200,10 @@ CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視す
AIコーディングアシスタント向けのマルチエージェントオーケストレーションツール。CLIProxyAPIをローカルsidecarとして実行することで、エージェントがChatGPTサブスクリプション経由でGPTモデルを利用できるようにし、Claude CodeをAnthropic互換エンドポイントへ向けるため、OpenAI APIキーは不要です。
+### [Tunnel Agent](https://github.com/Villoh/tunnel-agent)
+
+CLIProxyAPIとPerplexity WebUI Scraperをひとつのインターフェースで管理するWindowsデスクトップUI。QuotioとVibeProxyにインスパイアされ、OAuthプロバイダー(Claude、Gemini CLI、Codex、Kimi、Antigravity)、カスタムAPIキー、Perplexityセッションアカウントを接続し、任意のコーディングエージェントをローカルエンドポイントに向けることができます。
+
> [!NOTE]
> CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
From 46a152a21b9f87d0d2a62fe8adf08fb5045f214e Mon Sep 17 00:00:00 2001
From: Mikel Villota <93930400+Villoh@users.noreply.github.com>
Date: Wed, 3 Jun 2026 18:48:40 +0200
Subject: [PATCH 114/248] =?UTF-8?q?docs:=20use=20=E6=99=BA=E8=83=BD?=
=?UTF-8?q?=E4=BD=93=20for=20Agent=20in=20Chinese=20translation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
README_CN.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README_CN.md b/README_CN.md
index ae9d3b32346..82ceeb9cd00 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -203,7 +203,7 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
### [Tunnel Agent](https://github.com/Villoh/tunnel-agent)
-Windows 桌面 UI,通过单一界面管理 CLIProxyAPI 和 Perplexity WebUI Scraper,灵感来自 Quotio 和 VibeProxy。连接 OAuth 提供商(Claude、Gemini CLI、Codex、Kimi、Antigravity)、自定义 API 密钥和 Perplexity 会话账号,然后将任意编程 Agent 指向本地端点。
+Windows 桌面 UI,通过单一界面管理 CLIProxyAPI 和 Perplexity WebUI Scraper,灵感来自 Quotio 和 VibeProxy。连接 OAuth 提供商(Claude、Gemini CLI、Codex、Kimi、Antigravity)、自定义 API 密钥和 Perplexity 会话账号,然后将任意编程智能体指向本地端点。
> [!NOTE]
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
From fd3094483084ae0c6913e56258d2baa5ba85223b Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 4 Jun 2026 00:53:43 +0800
Subject: [PATCH 115/248] feat(auth): add error event publishing and Redis
queue integration
- Introduced `publishErrorEvent` in `Manager` to publish error events to Redis.
- Implemented error event structure to capture authentication errors with detailed metadata.
- Added test cases for error event publishing, subscription, and Redis protocol handling.
- Enhanced error and usage queue handling with `SubscribeErrors` and `EnqueueError`.
Closes: #3701
---
internal/api/redis_queue_protocol.go | 54 ++++--
.../redis_queue_protocol_integration_test.go | 63 +++++++
internal/redisqueue/queue.go | 31 +++-
internal/redisqueue/queue_test.go | 45 +++++
sdk/cliproxy/auth/conductor.go | 1 +
sdk/cliproxy/auth/error_events.go | 159 +++++++++++++++++
sdk/cliproxy/auth/error_events_test.go | 165 ++++++++++++++++++
7 files changed, 501 insertions(+), 17 deletions(-)
create mode 100644 sdk/cliproxy/auth/error_events.go
create mode 100644 sdk/cliproxy/auth/error_events_test.go
diff --git a/internal/api/redis_queue_protocol.go b/internal/api/redis_queue_protocol.go
index 497d68efa75..4295cc75231 100644
--- a/internal/api/redis_queue_protocol.go
+++ b/internal/api/redis_queue_protocol.go
@@ -14,7 +14,10 @@ import (
log "github.com/sirupsen/logrus"
)
-const redisUsageChannel = "usage"
+const (
+ redisUsageChannel = "usage"
+ redisErrorsChannel = "errors"
+)
type redisSubscriptionCommand struct {
args []string
@@ -150,15 +153,15 @@ func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) {
}
continue
}
- if !strings.EqualFold(channel, redisUsageChannel) {
+ messages, unsubscribe, ok := subscribeRedisChannel(channel)
+ if !ok {
_ = writeRedisError(writer, fmt.Sprintf("ERR unsupported channel '%s'", channel))
if !flush() {
return
}
continue
}
- messages, unsubscribe := redisqueue.SubscribeUsage()
- if errWrite := writeRedisPubSubSubscribe(writer, redisUsageChannel, 1); errWrite != nil {
+ if errWrite := writeRedisPubSubSubscribe(writer, channel, 1); errWrite != nil {
unsubscribe()
log.Errorf("redis protocol subscribe response error: %v", errWrite)
return
@@ -167,7 +170,7 @@ func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) {
unsubscribe()
return
}
- s.streamRedisUsageSubscription(reader, writer, messages, unsubscribe)
+ s.streamRedisSubscription(reader, writer, channel, messages, unsubscribe)
return
case "LPOP", "RPOP":
count, hasCount, ok := parsePopCount(args)
@@ -185,7 +188,14 @@ func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) {
}
continue
}
- items := redisqueue.PopOldest(count)
+ items, ok := popRedisQueueItems(args[1], count)
+ if !ok {
+ _ = writeRedisError(writer, fmt.Sprintf("ERR unsupported channel '%s'", strings.TrimSpace(args[1])))
+ if !flush() {
+ return
+ }
+ continue
+ }
if hasCount {
_ = writeRedisArrayOfBulkStrings(writer, items)
if !flush() {
@@ -213,7 +223,29 @@ func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) {
}
}
-func (s *Server) streamRedisUsageSubscription(reader *bufio.Reader, writer *bufio.Writer, messages <-chan []byte, unsubscribe func()) {
+func subscribeRedisChannel(channel string) (<-chan []byte, func(), bool) {
+ switch strings.ToLower(strings.TrimSpace(channel)) {
+ case redisUsageChannel:
+ messages, unsubscribe := redisqueue.SubscribeUsage()
+ return messages, unsubscribe, true
+ case redisErrorsChannel:
+ messages, unsubscribe := redisqueue.SubscribeErrors()
+ return messages, unsubscribe, true
+ default:
+ return nil, nil, false
+ }
+}
+
+func popRedisQueueItems(channel string, count int) ([][]byte, bool) {
+ switch strings.ToLower(strings.TrimSpace(channel)) {
+ case redisUsageChannel:
+ return redisqueue.PopOldest(count), true
+ default:
+ return nil, false
+ }
+}
+
+func (s *Server) streamRedisSubscription(reader *bufio.Reader, writer *bufio.Writer, channel string, messages <-chan []byte, unsubscribe func()) {
if unsubscribe == nil {
return
}
@@ -231,7 +263,7 @@ func (s *Server) streamRedisUsageSubscription(reader *bufio.Reader, writer *bufi
if !ok {
return
}
- if errWrite := writeRedisPubSubMessage(writer, redisUsageChannel, msg); errWrite != nil {
+ if errWrite := writeRedisPubSubMessage(writer, channel, msg); errWrite != nil {
log.Errorf("redis protocol publish message error: %v", errWrite)
return
}
@@ -243,7 +275,7 @@ func (s *Server) streamRedisUsageSubscription(reader *bufio.Reader, writer *bufi
if !ok {
return
}
- keepOpen := handleRedisSubscriptionCommand(writer, command)
+ keepOpen := handleRedisSubscriptionCommand(writer, channel, command)
if errFlush := writer.Flush(); errFlush != nil {
log.Errorf("redis protocol flush error: %v", errFlush)
return
@@ -277,7 +309,7 @@ func readRedisSubscriptionCommands(reader *bufio.Reader, commands chan<- redisSu
}
}
-func handleRedisSubscriptionCommand(writer *bufio.Writer, command redisSubscriptionCommand) bool {
+func handleRedisSubscriptionCommand(writer *bufio.Writer, channel string, command redisSubscriptionCommand) bool {
if command.err != nil {
_ = writeRedisError(writer, "ERR "+command.err.Error())
return false
@@ -297,7 +329,7 @@ func handleRedisSubscriptionCommand(writer *bufio.Writer, command redisSubscript
_ = writeRedisPubSubPong(writer, payload)
return true
case "UNSUBSCRIBE":
- _ = writeRedisPubSubUnsubscribe(writer, redisUsageChannel, 0)
+ _ = writeRedisPubSubUnsubscribe(writer, channel, 0)
return false
case "QUIT":
_ = writeRedisSimpleString(writer, "OK")
diff --git a/internal/api/redis_queue_protocol_integration_test.go b/internal/api/redis_queue_protocol_integration_test.go
index 7d443f67f99..7904ca72809 100644
--- a/internal/api/redis_queue_protocol_integration_test.go
+++ b/internal/api/redis_queue_protocol_integration_test.go
@@ -359,6 +359,60 @@ func TestRedisProtocol_SUBSCRIBE_UsageSendsSupportRefresh(t *testing.T) {
}
}
+func TestRedisProtocol_SUBSCRIBE_ErrorsReceivesErrorEvents(t *testing.T) {
+ const managementPassword = "test-management-password"
+
+ t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ server := newTestServer(t)
+ if !server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be true")
+ }
+
+ addr, stop := startRedisMuxListener(t, server)
+ t.Cleanup(stop)
+
+ conn, errDial := net.DialTimeout("tcp", addr, time.Second)
+ if errDial != nil {
+ t.Fatalf("failed to dial redis listener: %v", errDial)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ reader := bufio.NewReader(conn)
+ _ = conn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil {
+ t.Fatalf("failed to write AUTH command: %v", errWrite)
+ }
+ if msg, errRead := readTestRESPSimpleString(reader); errRead != nil {
+ t.Fatalf("failed to read AUTH response: %v", errRead)
+ } else if msg != "OK" {
+ t.Fatalf("unexpected AUTH response: %q", msg)
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "SUBSCRIBE", "errors"); errWrite != nil {
+ t.Fatalf("failed to write SUBSCRIBE command: %v", errWrite)
+ }
+ channel, subscriptions, errSubscribe := readTestRESPPubSubSubscribe(reader)
+ if errSubscribe != nil {
+ t.Fatalf("failed to read subscribe response: %v", errSubscribe)
+ }
+ if channel != "errors" || subscriptions != 1 {
+ t.Fatalf("unexpected subscribe response channel=%q subscriptions=%d", channel, subscriptions)
+ }
+
+ redisqueue.EnqueueError([]byte(`{"auth_index":"auth-1","status_code":401}`))
+ channel, payload, errMessage := readTestRESPPubSubMessage(reader)
+ if errMessage != nil {
+ t.Fatalf("failed to read error message: %v", errMessage)
+ }
+ if channel != "errors" || string(payload) != `{"auth_index":"auth-1","status_code":401}` {
+ t.Fatalf("unexpected error message channel=%q payload=%q", channel, string(payload))
+ }
+}
+
func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) {
const managementPassword = "test-management-password"
@@ -450,4 +504,13 @@ func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) {
if len(emptyItems) != 0 {
t.Fatalf("expected empty array for empty queue with count, got %#v", emptyItems)
}
+
+ if errWrite := writeTestRESPCommand(conn, "RPOP", "errors", "2"); errWrite != nil {
+ t.Fatalf("failed to write RPOP errors count command: %v", errWrite)
+ }
+ if msg, errRead := readTestRESPError(reader); errRead != nil {
+ t.Fatalf("failed to read RPOP errors response: %v", errRead)
+ } else if msg != "ERR unsupported channel 'errors'" {
+ t.Fatalf("unexpected RPOP errors response: %q", msg)
+ }
}
diff --git a/internal/redisqueue/queue.go b/internal/redisqueue/queue.go
index 60aecdff823..85bd4a8fc33 100644
--- a/internal/redisqueue/queue.go
+++ b/internal/redisqueue/queue.go
@@ -10,6 +10,7 @@ const (
defaultRetentionSeconds int64 = 60
maxRetentionSeconds int64 = 3600
usageSubscriberBuffer = 256
+ errorSubscriberBuffer = 256
usageSupportRefreshPayload = `{"support_refresh":true}`
usageRefreshPayload = `{"refresh":true}`
@@ -32,6 +33,7 @@ var (
enabled atomic.Bool
retentionSeconds atomic.Int64
global queue
+ errorGlobal queue
)
func init() {
@@ -42,6 +44,7 @@ func SetEnabled(value bool) {
enabled.Store(value)
if !value {
global.clear()
+ errorGlobal.clear()
}
}
@@ -72,6 +75,16 @@ func Enqueue(payload []byte) {
global.enqueue(payload)
}
+func EnqueueError(payload []byte) {
+ if !Enabled() {
+ return
+ }
+ if len(payload) == 0 {
+ return
+ }
+ errorGlobal.publishToSubscribers(payload)
+}
+
func PopOldest(count int) [][]byte {
if !Enabled() {
return nil
@@ -83,7 +96,11 @@ func PopOldest(count int) [][]byte {
}
func SubscribeUsage() (<-chan []byte, func()) {
- return global.subscribeUsage()
+ return global.subscribe(usageSubscriberBuffer, []byte(usageSupportRefreshPayload))
+}
+
+func SubscribeErrors() (<-chan []byte, func()) {
+ return errorGlobal.subscribe(errorSubscriberBuffer, nil)
}
func NotifyUsageRefresh() {
@@ -142,9 +159,11 @@ func (q *queue) publishToSubscribers(payload []byte) bool {
return true
}
-func (q *queue) subscribeUsage() (<-chan []byte, func()) {
- subscriber := make(chan []byte, usageSubscriberBuffer)
- subscriber <- []byte(usageSupportRefreshPayload)
+func (q *queue) subscribe(buffer int, initialPayload []byte) (<-chan []byte, func()) {
+ subscriber := make(chan []byte, buffer)
+ if len(initialPayload) > 0 {
+ subscriber <- append([]byte(nil), initialPayload...)
+ }
q.mu.Lock()
if q.subscribers == nil {
@@ -158,13 +177,13 @@ func (q *queue) subscribeUsage() (<-chan []byte, func()) {
var once sync.Once
unsubscribe := func() {
once.Do(func() {
- q.unsubscribeUsage(id)
+ q.unsubscribe(id)
})
}
return subscriber, unsubscribe
}
-func (q *queue) unsubscribeUsage(id uint64) {
+func (q *queue) unsubscribe(id uint64) {
q.mu.Lock()
subscriber, ok := q.subscribers[id]
if ok {
diff --git a/internal/redisqueue/queue_test.go b/internal/redisqueue/queue_test.go
index 1bc0fc30d4e..d49a9bda3b4 100644
--- a/internal/redisqueue/queue_test.go
+++ b/internal/redisqueue/queue_test.go
@@ -39,6 +39,8 @@ func TestSetEnabledFalseClosesUsageSubscribers(t *testing.T) {
withEnabledQueue(t, func() {
subscriber, unsubscribe := SubscribeUsage()
defer unsubscribe()
+ errorSubscriber, unsubscribeErrors := SubscribeErrors()
+ defer unsubscribeErrors()
requireUsageSubscriberPayload(t, subscriber, usageSupportRefreshPayload)
@@ -52,6 +54,30 @@ func TestSetEnabledFalseClosesUsageSubscribers(t *testing.T) {
case <-time.After(time.Second):
t.Fatalf("timeout waiting for subscriber close")
}
+
+ select {
+ case _, ok := <-errorSubscriber:
+ if ok {
+ t.Fatalf("error subscriber channel remained open after SetEnabled(false)")
+ }
+ case <-time.After(time.Second):
+ t.Fatalf("timeout waiting for error subscriber close")
+ }
+ })
+}
+
+func TestEnqueueErrorBroadcastsToErrorSubscribersAndDiscardsWithoutSubscribers(t *testing.T) {
+ withEnabledQueue(t, func() {
+ subscriber, unsubscribe := SubscribeErrors()
+ defer unsubscribe()
+
+ EnqueueError([]byte("error-record"))
+ requireUsageSubscriberPayload(t, subscriber, "error-record")
+
+ unsubscribe()
+
+ EnqueueError([]byte("discarded-error"))
+ requireErrorQueueEmpty(t)
})
}
@@ -59,12 +85,20 @@ func TestNotifyUsageRefreshBroadcastsOnlyToUsageSubscribers(t *testing.T) {
withEnabledQueue(t, func() {
subscriber, unsubscribe := SubscribeUsage()
defer unsubscribe()
+ errorSubscriber, unsubscribeErrors := SubscribeErrors()
+ defer unsubscribeErrors()
requireUsageSubscriberPayload(t, subscriber, usageSupportRefreshPayload)
NotifyUsageRefresh()
requireUsageSubscriberPayload(t, subscriber, usageRefreshPayload)
+ select {
+ case got := <-errorSubscriber:
+ t.Fatalf("error subscriber received usage refresh payload %q", string(got))
+ default:
+ }
+
unsubscribe()
NotifyUsageRefresh()
if items := PopOldest(1); len(items) != 0 {
@@ -88,3 +122,14 @@ func requireUsageSubscriberPayload(t *testing.T, subscriber <-chan []byte, want
t.Fatalf("timeout waiting for subscriber payload %q", want)
}
}
+
+func requireErrorQueueEmpty(t *testing.T) {
+ t.Helper()
+
+ errorGlobal.mu.Lock()
+ defer errorGlobal.mu.Unlock()
+
+ if len(errorGlobal.items)-errorGlobal.head != 0 {
+ t.Fatalf("error queue retained %d item(s), want none", len(errorGlobal.items)-errorGlobal.head)
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index 8c8effcddbb..bd057308894 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -2523,6 +2523,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
}
m.hook.OnResult(ctx, result)
+ m.publishErrorEvent(result, authSnapshot)
}
func ensureModelState(auth *Auth, model string) *ModelState {
diff --git a/sdk/cliproxy/auth/error_events.go b/sdk/cliproxy/auth/error_events.go
new file mode 100644
index 00000000000..d9e650f003d
--- /dev/null
+++ b/sdk/cliproxy/auth/error_events.go
@@ -0,0 +1,159 @@
+package auth
+
+import (
+ "encoding/json"
+ "strings"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+)
+
+type errorEvent struct {
+ Timestamp time.Time `json:"timestamp"`
+ Provider string `json:"provider,omitempty"`
+ Model string `json:"model,omitempty"`
+ AuthID string `json:"auth_id,omitempty"`
+ AuthIndex string `json:"auth_index"`
+ StatusCode int `json:"status_code"`
+ Body string `json:"body"`
+ Code string `json:"code,omitempty"`
+ Retryable bool `json:"retryable,omitempty"`
+ AuthStatus errorEventAuthStatus `json:"auth_status"`
+}
+
+type errorEventAuthStatus struct {
+ Status Status `json:"status"`
+ StatusMessage string `json:"status_message,omitempty"`
+ Disabled bool `json:"disabled"`
+ Unavailable bool `json:"unavailable"`
+ NextRetryAfter *time.Time `json:"next_retry_after,omitempty"`
+ Quota *errorEventQuotaStatus `json:"quota,omitempty"`
+ Model *errorEventModelStatus `json:"model,omitempty"`
+}
+
+type errorEventQuotaStatus struct {
+ Exceeded bool `json:"exceeded"`
+ Reason string `json:"reason,omitempty"`
+ NextRecoverAt *time.Time `json:"next_recover_at,omitempty"`
+ BackoffLevel int `json:"backoff_level,omitempty"`
+}
+
+type errorEventModelStatus struct {
+ Name string `json:"name"`
+ Status Status `json:"status"`
+ StatusMessage string `json:"status_message,omitempty"`
+ Unavailable bool `json:"unavailable"`
+ NextRetryAfter *time.Time `json:"next_retry_after,omitempty"`
+ Quota *errorEventQuotaStatus `json:"quota,omitempty"`
+}
+
+func (m *Manager) publishErrorEvent(result Result, authSnapshot *Auth) {
+ if m == nil || result.Success || authSnapshot == nil || m.HomeEnabled() {
+ return
+ }
+ payload, ok := buildErrorEventPayload(result, authSnapshot)
+ if !ok {
+ return
+ }
+ redisqueue.EnqueueError(payload)
+}
+
+func buildErrorEventPayload(result Result, authSnapshot *Auth) ([]byte, bool) {
+ if authSnapshot == nil || result.Success {
+ return nil, false
+ }
+ authSnapshot.EnsureIndex()
+ event := errorEvent{
+ Timestamp: time.Now(),
+ Provider: strings.TrimSpace(result.Provider),
+ Model: strings.TrimSpace(result.Model),
+ AuthID: strings.TrimSpace(result.AuthID),
+ AuthIndex: strings.TrimSpace(authSnapshot.Index),
+ StatusCode: errorEventStatusCode(result.Error),
+ Body: errorEventBody(result.Error),
+ AuthStatus: buildErrorEventAuthStatus(result.Model, authSnapshot),
+ }
+ if result.Error != nil {
+ event.Code = strings.TrimSpace(result.Error.Code)
+ event.Retryable = result.Error.Retryable
+ }
+ payload, errMarshal := json.Marshal(event)
+ if errMarshal != nil {
+ return nil, false
+ }
+ return payload, true
+}
+
+func buildErrorEventAuthStatus(model string, authSnapshot *Auth) errorEventAuthStatus {
+ status := errorEventAuthStatus{
+ Status: authSnapshot.Status,
+ StatusMessage: strings.TrimSpace(authSnapshot.StatusMessage),
+ Disabled: authSnapshot.Disabled,
+ Unavailable: authSnapshot.Unavailable,
+ NextRetryAfter: timePtrIfSet(authSnapshot.NextRetryAfter),
+ Quota: errorEventQuotaStatusFrom(authSnapshot.Quota),
+ }
+ if modelState := errorEventModelStatusFrom(model, authSnapshot); modelState != nil {
+ status.Model = modelState
+ }
+ return status
+}
+
+func errorEventModelStatusFrom(model string, authSnapshot *Auth) *errorEventModelStatus {
+ model = strings.TrimSpace(model)
+ if model == "" || authSnapshot == nil || authSnapshot.ModelStates == nil {
+ return nil
+ }
+ state := authSnapshot.ModelStates[model]
+ if state == nil {
+ return nil
+ }
+ return &errorEventModelStatus{
+ Name: model,
+ Status: state.Status,
+ StatusMessage: strings.TrimSpace(state.StatusMessage),
+ Unavailable: state.Unavailable,
+ NextRetryAfter: timePtrIfSet(state.NextRetryAfter),
+ Quota: errorEventQuotaStatusFrom(state.Quota),
+ }
+}
+
+func errorEventQuotaStatusFrom(quota QuotaState) *errorEventQuotaStatus {
+ if !quota.Exceeded && strings.TrimSpace(quota.Reason) == "" && quota.NextRecoverAt.IsZero() && quota.BackoffLevel == 0 {
+ return nil
+ }
+ return &errorEventQuotaStatus{
+ Exceeded: quota.Exceeded,
+ Reason: strings.TrimSpace(quota.Reason),
+ NextRecoverAt: timePtrIfSet(quota.NextRecoverAt),
+ BackoffLevel: quota.BackoffLevel,
+ }
+}
+
+func errorEventStatusCode(err *Error) int {
+ if err != nil && err.HTTPStatus > 0 {
+ return err.HTTPStatus
+ }
+ return 500
+}
+
+func errorEventBody(err *Error) string {
+ if err == nil {
+ return "request failed"
+ }
+ if msg := strings.TrimSpace(err.Message); msg != "" {
+ return msg
+ }
+ if msg := strings.TrimSpace(err.Error()); msg != "" {
+ return msg
+ }
+ return "request failed"
+}
+
+func timePtrIfSet(value time.Time) *time.Time {
+ if value.IsZero() {
+ return nil
+ }
+ copyValue := value
+ return ©Value
+}
diff --git a/sdk/cliproxy/auth/error_events_test.go b/sdk/cliproxy/auth/error_events_test.go
new file mode 100644
index 00000000000..33afca879c9
--- /dev/null
+++ b/sdk/cliproxy/auth/error_events_test.go
@@ -0,0 +1,165 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+)
+
+func TestManagerMarkResultPublishesErrorEventAfterAuthStateUpdate(t *testing.T) {
+ withEnabledErrorQueue(t)
+ subscriber, unsubscribe := redisqueue.SubscribeErrors()
+ defer unsubscribe()
+
+ manager := NewManager(nil, nil, nil)
+ auth := &Auth{
+ ID: "auth-error-event",
+ Provider: "codex",
+ Metadata: map[string]any{
+ "type": "codex",
+ },
+ }
+ if _, errRegister := manager.Register(WithSkipPersist(context.Background()), auth); errRegister != nil {
+ t.Fatalf("Register returned error: %v", errRegister)
+ }
+
+ manager.MarkResult(context.Background(), Result{
+ AuthID: auth.ID,
+ Provider: "codex",
+ Model: "gpt-5",
+ Success: false,
+ Error: &Error{
+ Code: "rate_limit",
+ Message: `{"error":"quota"}`,
+ Retryable: true,
+ HTTPStatus: http.StatusTooManyRequests,
+ },
+ })
+
+ payload := requireErrorSubscriberPayload(t, subscriber)
+
+ var event struct {
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ AuthID string `json:"auth_id"`
+ AuthIndex string `json:"auth_index"`
+ StatusCode int `json:"status_code"`
+ Body string `json:"body"`
+ Code string `json:"code"`
+ Retryable bool `json:"retryable"`
+ AuthStatus struct {
+ Status Status `json:"status"`
+ StatusMessage string `json:"status_message"`
+ Unavailable bool `json:"unavailable"`
+ Quota *struct {
+ Exceeded bool `json:"exceeded"`
+ Reason string `json:"reason"`
+ } `json:"quota"`
+ Model *struct {
+ Name string `json:"name"`
+ Status Status `json:"status"`
+ Unavailable bool `json:"unavailable"`
+ Quota *struct {
+ Exceeded bool `json:"exceeded"`
+ Reason string `json:"reason"`
+ } `json:"quota"`
+ } `json:"model"`
+ } `json:"auth_status"`
+ }
+ if errUnmarshal := json.Unmarshal(payload, &event); errUnmarshal != nil {
+ t.Fatalf("unmarshal error event: %v body=%s", errUnmarshal, string(payload))
+ }
+ if event.Provider != "codex" || event.Model != "gpt-5" || event.AuthID != auth.ID {
+ t.Fatalf("unexpected event routing fields: %+v", event)
+ }
+ if event.AuthIndex == "" {
+ t.Fatalf("auth_index is empty in event: %s", string(payload))
+ }
+ if event.StatusCode != http.StatusTooManyRequests || event.Body != `{"error":"quota"}` {
+ t.Fatalf("unexpected error fields: status=%d body=%q", event.StatusCode, event.Body)
+ }
+ if event.Code != "rate_limit" || !event.Retryable {
+ t.Fatalf("unexpected error code fields: code=%q retryable=%t", event.Code, event.Retryable)
+ }
+ if event.AuthStatus.Status != StatusError || !event.AuthStatus.Unavailable {
+ t.Fatalf("unexpected auth status: %+v", event.AuthStatus)
+ }
+ if event.AuthStatus.Model == nil || event.AuthStatus.Model.Name != "gpt-5" || event.AuthStatus.Model.Status != StatusError || !event.AuthStatus.Model.Unavailable {
+ t.Fatalf("unexpected model status: %+v", event.AuthStatus.Model)
+ }
+ if event.AuthStatus.Quota == nil || !event.AuthStatus.Quota.Exceeded || event.AuthStatus.Quota.Reason != "quota" {
+ t.Fatalf("unexpected auth quota: %+v", event.AuthStatus.Quota)
+ }
+ if event.AuthStatus.Model.Quota == nil || !event.AuthStatus.Model.Quota.Exceeded || event.AuthStatus.Model.Quota.Reason != "quota" {
+ t.Fatalf("unexpected model quota: %+v", event.AuthStatus.Model.Quota)
+ }
+}
+
+func TestManagerMarkResultSkipsErrorEventInHomeMode(t *testing.T) {
+ withEnabledErrorQueue(t)
+ subscriber, unsubscribe := redisqueue.SubscribeErrors()
+ defer unsubscribe()
+
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ auth := &Auth{
+ ID: "home-auth-error-event",
+ Provider: "codex",
+ Metadata: map[string]any{
+ "type": "codex",
+ },
+ }
+ if _, errRegister := manager.Register(WithSkipPersist(context.Background()), auth); errRegister != nil {
+ t.Fatalf("Register returned error: %v", errRegister)
+ }
+
+ manager.MarkResult(context.Background(), Result{
+ AuthID: auth.ID,
+ Provider: "codex",
+ Model: "gpt-5",
+ Success: false,
+ Error: &Error{
+ Message: "unauthorized",
+ HTTPStatus: http.StatusUnauthorized,
+ },
+ })
+
+ select {
+ case got := <-subscriber:
+ t.Fatalf("received home-mode error event %q, want none", string(got))
+ default:
+ }
+}
+
+func withEnabledErrorQueue(t *testing.T) {
+ t.Helper()
+
+ prevQueueEnabled := redisqueue.Enabled()
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(true)
+
+ t.Cleanup(func() {
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(prevQueueEnabled)
+ })
+}
+
+func requireErrorSubscriberPayload(t *testing.T, subscriber <-chan []byte) []byte {
+ t.Helper()
+
+ select {
+ case got, ok := <-subscriber:
+ if !ok {
+ t.Fatalf("error subscriber closed before receiving payload")
+ }
+ return got
+ case <-time.After(time.Second):
+ t.Fatalf("timeout waiting for error subscriber payload")
+ return nil
+ }
+}
From 90d46e7749131cebac4165a42bce4d7777a276d5 Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Thu, 4 Jun 2026 12:58:50 +0800
Subject: [PATCH 116/248] docs: remove outdated Amp CLI and related tool
references
- Removed sections on Amp CLI integration and related management tools from all READMEs (`README.md`, `README_JA.md`, and `README_CN.md`).
- Updated project descriptions for relevance and focus.
---
README.md | 28 ----------------------------
README_CN.md | 27 ---------------------------
README_JA.md | 28 ----------------------------
3 files changed, 83 deletions(-)
diff --git a/README.md b/README.md
index 3ef7e93b0f7..14b09d5fc5b 100644
--- a/README.md
+++ b/README.md
@@ -84,26 +84,6 @@ Standalone persistence and visualization service for CLIProxyAPI, with periodic
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.
-## Amp CLI Support
-
-CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
-
-- Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
-- Management proxy for OAuth authentication and account features
-- Smart model fallback with automatic routing
-- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
-- Security-first design with localhost-only management endpoints
-
-When you need the request/response shape of a specific backend family, use the provider-specific paths instead of the merged `/v1/...` endpoints:
-
-- Use `/api/provider/{provider}/v1/messages` for messages-style backends.
-- Use `/api/provider/{provider}/v1beta/models/...` for model-scoped generate endpoints.
-- Use `/api/provider/{provider}/v1/chat/completions` for chat-completions backends.
-
-These routes help you select the protocol surface, but they do not by themselves guarantee a unique inference executor when the same client-visible model name is reused across multiple backends. Inference routing is still resolved from the request model/alias. For strict backend pinning, use unique aliases, prefixes, or otherwise avoid overlapping client-visible model names.
-
-**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
-
## SDK Docs
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
@@ -142,10 +122,6 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
Native macOS menu bar app that unifies Claude, Gemini, OpenAI, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
-### [CodMate](https://github.com/loocor/CodMate)
-
-Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, and Antigravity, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.
-
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.
@@ -193,10 +169,6 @@ Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a n
Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-account codex 5h/7d quota windows, plan-based sorting, status coloring, and multi-account summary analytics.
-### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
-
-Windows-focused, local-first desktop management platform for Codex CLI built on CLIProxyAPI, focused on simplifying local setup, account and runtime management, and providing a more complete Codex CLI experience for local users.
-
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
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.
diff --git a/README_CN.md b/README_CN.md
index 82ceeb9cd00..2da6d842402 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -84,25 +84,6 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo
面向 CLIProxyAPI 的完整管理中心,提供请求级监控和费用预估。CPA-Manager 可按账号、模型、渠道、延迟、状态和 token 用量追踪采集到的请求;支持可编辑模型价格与一键同步 LiteLLM 价格来估算费用;用 SQLite 持久化事件;并提供面向 Codex 账号池的批量巡检、配额识别、异常账号定位、清理建议与一键执行能力,适合多账号池的日常运维管理。
-## Amp CLI 支持
-
-CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
-
-- 提供商路由别名,兼容 Amp 的 API 路径模式(`/api/provider/{provider}/v1...`)
-- 管理代理,处理 OAuth 认证和账号功能
-- 智能模型回退与自动路由
-- 以安全为先的设计,管理端点仅限 localhost
-
-当你需要某一类后端的请求/响应协议形态时,优先使用 provider-specific 路径,而不是合并后的 `/v1/...` 端点:
-
-- 对于 messages 风格的后端,使用 `/api/provider/{provider}/v1/messages`。
-- 对于按模型路径暴露生成接口的后端,使用 `/api/provider/{provider}/v1beta/models/...`。
-- 对于 chat-completions 风格的后端,使用 `/api/provider/{provider}/v1/chat/completions`。
-
-这些路径有助于选择协议表面,但当多个后端复用同一个客户端可见模型名时,它们本身并不能保证唯一的推理执行器。实际的推理路由仍然根据请求里的 model/alias 解析。若要严格钉住某个后端,请使用唯一 alias、前缀,或避免让多个后端暴露相同的客户端模型名。
-
-**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
-
## SDK 文档
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
@@ -141,10 +122,6 @@ CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户
原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
-### [CodMate](https://github.com/loocor/CodMate)
-
-原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini 和 Antigravity 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。
-
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
原生 Windows CLIProxyAPI 分支,集成 TUI、系统托盘及多服务商 OAuth 认证,专为 AI 编程工具打造,无需 API 密钥。
@@ -189,10 +166,6 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
上手即用的面向 CLIProxyAPI 跨平台配额查询工具,支持按账号展示 codex 5h/7d 配额窗口、按计划排序、状态着色及多账号汇总分析。
-### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
-
-基于 CLIProxyAPI 的 Windows Codex CLI 本地优先桌面管理平台,聚焦简化本机配置、账号与运行状态管理,并为本地用户提供更完整的 Codex CLI 使用体验。
-
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
原生 macOS SwiftUI 应用,用于监控 CLIProxyAPI 池中的 ChatGPT/Codex 账号额度。通过 Management API 展示账号可用状态、Plus 基准容量、5 小时与周额度进度条、套餐权重和恢复预测。
diff --git a/README_JA.md b/README_JA.md
index d292890a59c..acea27af806 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -82,26 +82,6 @@ CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLI
リクエスト単位の監視とコスト推定を備えたCLIProxyAPI向けのフル管理センターです。CPA-Managerは、収集したリクエストをアカウント、モデル、チャネル、レイテンシ、ステータス、Token使用量ごとに追跡し、編集可能なモデル価格とLiteLLM価格のワンクリック同期でコストを推定します。SQLiteでイベントを永続化し、Codexアカウントプール向けに一括検査、クォータ判定、異常アカウント検出、クリーンアップ提案、ワンクリック実行を提供し、日常的なマルチアカウント運用に適しています。
-## Amp CLIサポート
-
-CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統合サポートを含んでおり、Google/ChatGPT/ClaudeのOAuthサブスクリプションをAmpのコーディングツールで使用できます:
-
-- Ampの APIパターン用のプロバイダールートエイリアス(`/api/provider/{provider}/v1...`)
-- OAuth認証およびアカウント機能用の管理プロキシ
-- 自動ルーティングによるスマートモデルフォールバック
-- 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)
-- localhostのみの管理エンドポイントによるセキュリティファーストの設計
-
-特定のバックエンド系統のリクエスト/レスポンス形状が必要な場合は、統合された `/v1/...` エンドポイントよりも provider-specific のパスを優先してください。
-
-- messages 系のバックエンドには `/api/provider/{provider}/v1/messages`
-- モデル単位の generate 系エンドポイントには `/api/provider/{provider}/v1beta/models/...`
-- chat-completions 系のバックエンドには `/api/provider/{provider}/v1/chat/completions`
-
-これらのパスはプロトコル面の選択には役立ちますが、同じクライアント向けモデル名が複数バックエンドで再利用されている場合、それだけで推論実行系が一意に固定されるわけではありません。実際の推論ルーティングは、引き続きリクエスト内の model/alias 解決に従います。厳密にバックエンドを固定したい場合は、一意な alias や prefix を使うか、クライアント向けモデル名の重複自体を避けてください。
-
-**→ [Amp CLI統合ガイドの完全版](https://help.router-for.me/agent-client/amp-cli.html)**
-
## SDKドキュメント
- 使い方:[docs/sdk-usage.md](docs/sdk-usage.md)
@@ -140,10 +120,6 @@ CLIProxyAPI OAuthを使用して複数のClaudeアカウントや代替モデル
Claude、Gemini、OpenAI、Antigravityのサブスクリプションを統合し、リアルタイムのクォータ追跡とスマート自動フェイルオーバーを備えたmacOSネイティブのメニューバーアプリ。Claude Code、OpenCode、Droidなどのコーディングツール向け - APIキー不要
-### [CodMate](https://github.com/loocor/CodMate)
-
-CLI AIセッション(Codex、Claude Code、Gemini CLI)を管理するmacOS SwiftUIネイティブアプリ。統合プロバイダー管理、Gitレビュー、プロジェクト整理、グローバル検索、ターミナル統合機能を搭載。CLIProxyAPIと統合し、Codex、Claude、Gemini、AntigravityのOAuth認証を提供。単一のプロキシエンドポイントを通じた組み込みおよびサードパーティプロバイダーの再ルーティングに対応 - OAuthプロバイダーではAPIキー不要
-
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
TUI、システムトレイ、マルチプロバイダーOAuthを備えたWindows向けCLIProxyAPIフォーク - AIコーディングツール用、APIキー不要
@@ -188,10 +164,6 @@ CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォー
CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォータ確認ツール。アカウントごとの codex 5h/7d クォータ表示、プラン別ソート、ステータス色分け、複数アカウントの集計分析に対応。
-### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
-
-CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデスクトップ管理プラットフォーム。ローカル設定、アカウント、実行状態の管理を簡素化し、ローカルユーザーにより包括的なCodex CLI体験を提供します。
-
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視するmacOSネイティブSwiftUIアプリ。Management APIを通じて、アカウントの可用性、Plus基準の容量、5時間/週次クォータバー、プラン重み、復元予測を表示します。
From 5753d1a0896fd5bb9ace47adb17b0174ceb79e4d Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Fri, 5 Jun 2026 01:47:43 +0800
Subject: [PATCH 117/248] feat(logging): enable file-backed request/response
sources for enhanced API logging
- Introduced support for file-backed logging of API requests and responses to handle large payloads efficiently.
- Refactored `attachWebsocketLogSources` to `attachRequestLogSources` for broader request and response handling.
- Added new methods for appending request/response data to file-backed sources and updated existing logging workflows for compatibility.
- Improved cleanup and merge logic for file-backed sources during request processing.
- Updated tests to cover newly introduced file-backed logging functionality.
---
config.example.yaml | 2 +-
internal/api/middleware/request_logging.go | 15 +-
.../api/middleware/request_logging_test.go | 4 +-
internal/api/middleware/response_writer.go | 110 +++++++++--
internal/api/server.go | 15 +-
internal/config/config.go | 2 +-
internal/logging/request_logger.go | 127 ++++++++++--
.../runtime/executor/helps/logging_helpers.go | 184 +++++++++++++-----
sdk/api/handlers/handlers.go | 6 +
9 files changed, 386 insertions(+), 79 deletions(-)
diff --git a/config.example.yaml b/config.example.yaml
index bb9307cc6bc..4b30dd887ed 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -49,7 +49,7 @@ pprof:
enable: false
addr: "127.0.0.1:8316"
-# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
+# When true, disable high-overhead request logging and HTTP middleware features to reduce per-request memory usage under high concurrency.
commercial-mode: false
# When true, write application logs to rotating files instead of stdout
diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go
index 561219c4f31..0ee849ae438 100644
--- a/internal/api/middleware/request_logging.go
+++ b/internal/api/middleware/request_logging.go
@@ -58,7 +58,7 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
wrapper.logOnErrorOnly = true
}
c.Writer = wrapper
- attachWebsocketLogSources(c, logger, loggerEnabled)
+ attachRequestLogSources(c, logger, loggerEnabled)
// Process the request
c.Next()
@@ -75,14 +75,23 @@ type fileBodySourceFactory interface {
NewFileBodySource(prefix string) (*logging.FileBodySource, error)
}
-func attachWebsocketLogSources(c *gin.Context, logger logging.RequestLogger, loggerEnabled bool) {
- if c == nil || !loggerEnabled || !isResponsesWebsocketUpgrade(c.Request) {
+func attachRequestLogSources(c *gin.Context, logger logging.RequestLogger, loggerEnabled bool) {
+ if c == nil || !loggerEnabled {
return
}
factory, ok := logger.(fileBodySourceFactory)
if !ok || factory == nil {
return
}
+ if source, errSource := factory.NewFileBodySource("api-request"); errSource == nil {
+ c.Set(logging.APIRequestSourceContextKey, source)
+ }
+ if source, errSource := factory.NewFileBodySource("api-response"); errSource == nil {
+ c.Set(logging.APIResponseSourceContextKey, source)
+ }
+ if !isResponsesWebsocketUpgrade(c.Request) {
+ return
+ }
if source, errSource := factory.NewFileBodySource("websocket-timeline"); errSource == nil {
c.Set(logging.WebsocketTimelineSourceContextKey, source)
}
diff --git a/internal/api/middleware/request_logging_test.go b/internal/api/middleware/request_logging_test.go
index c64b844a851..ed1be2e0924 100644
--- a/internal/api/middleware/request_logging_test.go
+++ b/internal/api/middleware/request_logging_test.go
@@ -144,7 +144,7 @@ func TestShouldCaptureRequestBody(t *testing.T) {
}
}
-func TestAttachWebsocketLogSourcesUsesLoggerLogsDir(t *testing.T) {
+func TestAttachRequestLogSourcesUsesLoggerLogsDir(t *testing.T) {
gin.SetMode(gin.TestMode)
logsDir := t.TempDir()
@@ -154,7 +154,7 @@ func TestAttachWebsocketLogSourcesUsesLoggerLogsDir(t *testing.T) {
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
c.Request.Header.Set("Upgrade", "websocket")
- attachWebsocketLogSources(c, logger, true)
+ attachRequestLogSources(c, logger, true)
defer cleanupFileBodySourcesFromContext(c)
for _, key := range []string{
diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go
index 5eabd08dca6..aedce47ca89 100644
--- a/internal/api/middleware/response_writer.go
+++ b/internal/api/middleware/response_writer.go
@@ -282,9 +282,11 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
hasAPIError := len(slicesAPIResponseError) > 0 || finalStatusCode >= http.StatusBadRequest
forceLog := w.logOnErrorOnly && hasAPIError && !w.logger.IsEnabled()
websocketTimelineSource := w.extractWebsocketTimelineSource(c)
+ apiRequestSource := w.extractAPIRequestSource(c)
+ apiResponseSource := w.extractAPIResponseSource(c)
apiWebsocketTimelineSource := w.extractAPIWebsocketTimelineSource(c)
if !w.logger.IsEnabled() && !forceLog {
- cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
+ cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource, apiWebsocketTimelineSource)
return nil
}
@@ -303,33 +305,63 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
// Write API Request and Response to the streaming log before closing
apiRequest := w.extractAPIRequest(c)
- if len(apiRequest) > 0 {
- _ = w.streamWriter.WriteAPIRequest(apiRequest)
- }
apiResponse := w.extractAPIResponse(c)
- if len(apiResponse) > 0 {
- _ = w.streamWriter.WriteAPIResponse(apiResponse)
+ if sourceWriter, ok := w.streamWriter.(interface {
+ WriteAPIRequestSource(*logging.FileBodySource) error
+ WriteAPIResponseSource(*logging.FileBodySource) error
+ }); ok {
+ if len(apiRequest) > 0 {
+ _ = w.streamWriter.WriteAPIRequest(apiRequest)
+ }
+ if apiRequestSource != nil && apiRequestSource.HasPayload() {
+ _ = sourceWriter.WriteAPIRequestSource(apiRequestSource)
+ }
+ if len(apiResponse) > 0 {
+ _ = w.streamWriter.WriteAPIResponse(apiResponse)
+ }
+ if apiResponseSource != nil && apiResponseSource.HasPayload() {
+ _ = sourceWriter.WriteAPIResponseSource(apiResponseSource)
+ }
+ } else {
+ var errMerge error
+ apiRequest, errMerge = mergeFileBodySource(apiRequest, apiRequestSource)
+ if errMerge != nil {
+ cleanupFileBodySources(websocketTimelineSource, apiResponseSource, apiWebsocketTimelineSource)
+ return errMerge
+ }
+ apiResponse, errMerge = mergeFileBodySource(apiResponse, apiResponseSource)
+ if errMerge != nil {
+ cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
+ return errMerge
+ }
+ if len(apiRequest) > 0 {
+ _ = w.streamWriter.WriteAPIRequest(apiRequest)
+ }
+ if len(apiResponse) > 0 {
+ _ = w.streamWriter.WriteAPIResponse(apiResponse)
+ }
}
apiWebsocketTimeline := w.extractAPIWebsocketTimeline(c)
var errMerge error
apiWebsocketTimeline, errMerge = mergeFileBodySource(apiWebsocketTimeline, apiWebsocketTimelineSource)
if errMerge != nil {
- cleanupFileBodySources(websocketTimelineSource)
+ cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource)
return errMerge
}
- cleanupFileBodySources(websocketTimelineSource)
if len(apiWebsocketTimeline) > 0 {
_ = w.streamWriter.WriteAPIWebsocketTimeline(apiWebsocketTimeline)
}
if err := w.streamWriter.Close(); err != nil {
w.streamWriter = nil
+ cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource)
return err
}
w.streamWriter = nil
+ cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource)
return nil
}
- return w.logRequest(w.extractRequestBody(c), finalStatusCode, w.cloneHeaders(), w.extractResponseBody(c), w.extractWebsocketTimeline(c), websocketTimelineSource, w.extractAPIRequest(c), w.extractAPIResponse(c), w.extractAPIWebsocketTimeline(c), apiWebsocketTimelineSource, w.extractAPIResponseTimestamp(c), slicesAPIResponseError, forceLog)
+ return w.logRequest(w.extractRequestBody(c), finalStatusCode, w.cloneHeaders(), w.extractResponseBody(c), w.extractWebsocketTimeline(c), websocketTimelineSource, w.extractAPIRequest(c), apiRequestSource, w.extractAPIResponse(c), apiResponseSource, w.extractAPIWebsocketTimeline(c), apiWebsocketTimelineSource, w.extractAPIResponseTimestamp(c), slicesAPIResponseError, forceLog)
}
func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {
@@ -369,6 +401,14 @@ func (w *ResponseWriterWrapper) extractAPIResponse(c *gin.Context) []byte {
return data
}
+func (w *ResponseWriterWrapper) extractAPIRequestSource(c *gin.Context) *logging.FileBodySource {
+ return extractFileBodySource(c, logging.APIRequestSourceContextKey)
+}
+
+func (w *ResponseWriterWrapper) extractAPIResponseSource(c *gin.Context) *logging.FileBodySource {
+ return extractFileBodySource(c, logging.APIResponseSourceContextKey)
+}
+
func (w *ResponseWriterWrapper) extractAPIWebsocketTimeline(c *gin.Context) []byte {
apiTimeline, isExist := c.Get("API_WEBSOCKET_TIMELINE")
if !isExist {
@@ -460,15 +500,53 @@ func extractBodyOverride(c *gin.Context, key string) []byte {
return nil
}
-func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, headers map[string][]string, body, websocketTimeline []byte, websocketTimelineSource *logging.FileBodySource, apiRequestBody, apiResponseBody, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *logging.FileBodySource, apiResponseTimestamp time.Time, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
+func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, headers map[string][]string, body, websocketTimeline []byte, websocketTimelineSource *logging.FileBodySource, apiRequestBody []byte, apiRequestSource *logging.FileBodySource, apiResponseBody []byte, apiResponseSource *logging.FileBodySource, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *logging.FileBodySource, apiResponseTimestamp time.Time, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
if w.requestInfo == nil {
- cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
+ cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource, apiWebsocketTimelineSource)
return nil
}
+ if loggerWithAllSources, ok := w.logger.(interface {
+ LogRequestWithOptionsAndAllSources(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, *logging.FileBodySource, []byte, *logging.FileBodySource, []byte, *logging.FileBodySource, []byte, *logging.FileBodySource, []*interfaces.ErrorMessage, bool, string, time.Time, time.Time) error
+ }); ok {
+ return loggerWithAllSources.LogRequestWithOptionsAndAllSources(
+ w.requestInfo.URL,
+ w.requestInfo.Method,
+ w.requestInfo.Headers,
+ requestBody,
+ statusCode,
+ headers,
+ body,
+ websocketTimeline,
+ websocketTimelineSource,
+ apiRequestBody,
+ apiRequestSource,
+ apiResponseBody,
+ apiResponseSource,
+ apiWebsocketTimeline,
+ apiWebsocketTimelineSource,
+ apiResponseErrors,
+ forceLog,
+ w.requestInfo.RequestID,
+ w.requestInfo.Timestamp,
+ apiResponseTimestamp,
+ )
+ }
+
if loggerWithSources, ok := w.logger.(interface {
LogRequestWithOptionsAndSources(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, *logging.FileBodySource, []byte, []byte, []byte, *logging.FileBodySource, []*interfaces.ErrorMessage, bool, string, time.Time, time.Time) error
}); ok {
+ var errMerge error
+ apiRequestBody, errMerge = mergeFileBodySource(apiRequestBody, apiRequestSource)
+ if errMerge != nil {
+ cleanupFileBodySources(websocketTimelineSource, apiResponseSource, apiWebsocketTimelineSource)
+ return errMerge
+ }
+ apiResponseBody, errMerge = mergeFileBodySource(apiResponseBody, apiResponseSource)
+ if errMerge != nil {
+ cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
+ return errMerge
+ }
return loggerWithSources.LogRequestWithOptionsAndSources(
w.requestInfo.URL,
w.requestInfo.Method,
@@ -493,6 +571,16 @@ func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, h
var errMerge error
websocketTimeline, errMerge = mergeFileBodySource(websocketTimeline, websocketTimelineSource)
+ if errMerge != nil {
+ cleanupFileBodySources(apiRequestSource, apiResponseSource, apiWebsocketTimelineSource)
+ return errMerge
+ }
+ apiRequestBody, errMerge = mergeFileBodySource(apiRequestBody, apiRequestSource)
+ if errMerge != nil {
+ cleanupFileBodySources(apiResponseSource, apiWebsocketTimelineSource)
+ return errMerge
+ }
+ apiResponseBody, errMerge = mergeFileBodySource(apiResponseBody, apiResponseSource)
if errMerge != nil {
cleanupFileBodySources(apiWebsocketTimelineSource)
return errMerge
diff --git a/internal/api/server.go b/internal/api/server.go
index 05bcd1cf7d8..e81ca67076a 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -72,6 +72,17 @@ func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.
return logger
}
+func effectiveSDKConfig(cfg *config.Config) *config.SDKConfig {
+ if cfg == nil {
+ return nil
+ }
+ sdkCfg := cfg.SDKConfig
+ if cfg.CommercialMode {
+ sdkCfg.RequestLog = false
+ }
+ return &sdkCfg
+}
+
// WithMiddleware appends additional Gin middleware during server construction.
func WithMiddleware(mw ...gin.HandlerFunc) ServerOption {
return func(cfg *serverOptionConfig) {
@@ -257,7 +268,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
// Create server instance
s := &Server{
engine: engine,
- handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
+ handlers: handlers.NewBaseAPIHandlers(effectiveSDKConfig(cfg), authManager),
cfg: cfg,
accessManager: accessManager,
requestLogger: requestLogger,
@@ -1453,7 +1464,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
// Save YAML snapshot for next comparison
s.oldConfigYaml, _ = yaml.Marshal(cfg)
- s.handlers.UpdateClients(&cfg.SDKConfig)
+ s.handlers.UpdateClients(effectiveSDKConfig(cfg))
if s.mgmt != nil {
s.mgmt.SetConfig(cfg)
diff --git a/internal/config/config.go b/internal/config/config.go
index 0e193938835..d0a5997306c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -52,7 +52,7 @@ type Config struct {
// Pprof config controls the optional pprof HTTP debug server.
Pprof PprofConfig `yaml:"pprof" json:"pprof"`
- // CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage.
+ // CommercialMode disables high-overhead request logging and HTTP middleware features to minimize per-request memory usage.
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
// LoggingToFile controls whether application logs are written to rotating files or stdout.
diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go
index e1c7a9cc4ad..9a21e7e0212 100644
--- a/internal/logging/request_logger.go
+++ b/internal/logging/request_logger.go
@@ -35,6 +35,9 @@ var requestLogID atomic.Uint64
const (
WebsocketTimelineSourceContextKey = "WEBSOCKET_TIMELINE_SOURCE"
+ APIRequestSourceContextKey = "API_REQUEST_SOURCE"
+ APIResponseSourceContextKey = "API_RESPONSE_SOURCE"
+ APIResponseCapturedContextKey = "API_RESPONSE_CAPTURED"
APIWebsocketTimelineSourceContextKey = "API_WEBSOCKET_TIMELINE_SOURCE"
)
@@ -140,6 +143,46 @@ func (s *FileBodySource) AppendPart(data []byte) error {
return writeErr
}
+// AppendBytes appends raw bytes to a single ordered part.
+func (s *FileBodySource) AppendBytes(data []byte) error {
+ if s == nil {
+ return fmt.Errorf("file body source is nil")
+ }
+ if len(data) == 0 {
+ return nil
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.cleaned {
+ return fmt.Errorf("file body source has been cleaned")
+ }
+ if errMkdir := os.MkdirAll(s.dir, 0755); errMkdir != nil {
+ return errMkdir
+ }
+
+ var file *os.File
+ var errOpen error
+ if len(s.paths) == 0 {
+ file, errOpen = os.CreateTemp(s.dir, "part-*.tmp")
+ if errOpen == nil {
+ s.paths = append(s.paths, file.Name())
+ }
+ } else {
+ file, errOpen = os.OpenFile(s.paths[len(s.paths)-1], os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ }
+ if errOpen != nil {
+ return errOpen
+ }
+
+ _, writeErr := file.Write(data)
+ if errClose := file.Close(); errClose != nil {
+ if writeErr == nil {
+ writeErr = errClose
+ }
+ }
+ return writeErr
+}
+
// HasPayload reports whether any detail parts were recorded.
func (s *FileBodySource) HasPayload() bool {
if s == nil {
@@ -520,20 +563,25 @@ func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[st
// LogRequestWithOptions logs a request with optional forced logging behavior.
// The force flag allows writing error logs even when regular request logging is disabled.
func (l *FileRequestLogger) LogRequestWithOptions(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
- return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, apiResponse, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
+ return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, nil, apiResponse, nil, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
}
func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
- return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, apiResponse, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
+ return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, nil, apiResponse, nil, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
}
// LogRequestWithOptionsAndSources logs a request with optional file-backed large sections.
func (l *FileRequestLogger) LogRequestWithOptionsAndSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
- return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, websocketTimelineSource, apiRequest, apiResponse, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
+ return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, websocketTimelineSource, apiRequest, nil, apiResponse, nil, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
}
-func (l *FileRequestLogger) logRequestWithSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
- defer cleanupFileBodySources(websocketTimelineSource, apiWebsocketTimelineSource)
+// LogRequestWithOptionsAndAllSources logs a request with optional file-backed request and response sections.
+func (l *FileRequestLogger) LogRequestWithOptionsAndAllSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest []byte, apiRequestSource *FileBodySource, apiResponse []byte, apiResponseSource *FileBodySource, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
+ return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, websocketTimelineSource, apiRequest, apiRequestSource, apiResponse, apiResponseSource, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
+}
+
+func (l *FileRequestLogger) logRequestWithSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest []byte, apiRequestSource *FileBodySource, apiResponse []byte, apiResponseSource *FileBodySource, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
+ defer cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource, apiWebsocketTimelineSource)
if !l.enabled && !force {
return nil
@@ -556,7 +604,9 @@ func (l *FileRequestLogger) logRequestWithSources(url, method string, requestHea
websocketTimeline,
websocketTimelineSource,
apiRequest,
+ apiRequestSource,
apiResponse,
+ apiResponseSource,
apiWebsocketTimeline,
apiWebsocketTimelineSource,
apiResponseErrors,
@@ -618,7 +668,9 @@ func (l *FileRequestLogger) logRequestWithSources(url, method string, requestHea
websocketTimeline,
websocketTimelineSource,
apiRequest,
+ apiRequestSource,
apiResponse,
+ apiResponseSource,
apiWebsocketTimeline,
apiWebsocketTimelineSource,
apiResponseErrors,
@@ -888,7 +940,9 @@ func (l *FileRequestLogger) writeNonStreamingLog(
websocketTimeline []byte,
websocketTimelineSource *FileBodySource,
apiRequest []byte,
+ apiRequestSource *FileBodySource,
apiResponse []byte,
+ apiResponseSource *FileBodySource,
apiWebsocketTimeline []byte,
apiWebsocketTimelineSource *FileBodySource,
apiResponseErrors []*interfaces.ErrorMessage,
@@ -904,7 +958,7 @@ func (l *FileRequestLogger) writeNonStreamingLog(
}
isWebsocketTranscript := hasSectionPayload(websocketTimeline) || hasFileBodySourcePayload(websocketTimelineSource)
downstreamTransport := inferDownstreamTransport(requestHeaders, websocketTimeline, websocketTimelineSource)
- upstreamTransport := inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors)
+ upstreamTransport := inferUpstreamTransport(apiRequest, apiRequestSource, apiResponse, apiResponseSource, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors)
if errWrite := writeRequestInfoWithBody(w, url, method, requestHeaders, requestBody, requestBodyPath, requestTimestamp, downstreamTransport, upstreamTransport, !isWebsocketTranscript); errWrite != nil {
return errWrite
}
@@ -914,13 +968,13 @@ func (l *FileRequestLogger) writeNonStreamingLog(
if errWrite := writeAPISectionWithSource(w, "=== API WEBSOCKET TIMELINE ===\n", "=== API WEBSOCKET TIMELINE", apiWebsocketTimeline, apiWebsocketTimelineSource, time.Time{}); errWrite != nil {
return errWrite
}
- if errWrite := writeAPISection(w, "=== API REQUEST ===\n", "=== API REQUEST", apiRequest, time.Time{}); errWrite != nil {
+ if errWrite := writePreformattedAPISectionWithSource(w, "=== API REQUEST ===\n", "=== API REQUEST", apiRequest, apiRequestSource, time.Time{}); errWrite != nil {
return errWrite
}
if errWrite := writeAPIErrorResponses(w, apiResponseErrors); errWrite != nil {
return errWrite
}
- if errWrite := writeAPISection(w, "=== API RESPONSE ===\n", "=== API RESPONSE", apiResponse, apiResponseTimestamp); errWrite != nil {
+ if errWrite := writePreformattedAPISectionWithSource(w, "=== API RESPONSE ===\n", "=== API RESPONSE", apiResponse, apiResponseSource, apiResponseTimestamp); errWrite != nil {
return errWrite
}
if isWebsocketTranscript {
@@ -1087,8 +1141,8 @@ func inferDownstreamTransport(headers map[string][]string, websocketTimeline []b
return "http"
}
-func inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, _ []*interfaces.ErrorMessage) string {
- hasHTTP := hasSectionPayload(apiRequest) || hasSectionPayload(apiResponse)
+func inferUpstreamTransport(apiRequest []byte, apiRequestSource *FileBodySource, apiResponse []byte, apiResponseSource *FileBodySource, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, _ []*interfaces.ErrorMessage) string {
+ hasHTTP := hasSectionPayload(apiRequest) || hasFileBodySourcePayload(apiRequestSource) || hasSectionPayload(apiResponse) || hasFileBodySourcePayload(apiResponseSource)
hasWS := hasSectionPayload(apiWebsocketTimeline) || hasFileBodySourcePayload(apiWebsocketTimelineSource)
switch {
case hasHTTP && hasWS:
@@ -1178,6 +1232,25 @@ func writeAPISectionWithSource(w io.Writer, sectionHeader string, sectionPrefix
return nil
}
+func writePreformattedAPISectionWithSource(w io.Writer, sectionHeader string, sectionPrefix string, payload []byte, source *FileBodySource, timestamp time.Time) error {
+ if !hasFileBodySourcePayload(source) {
+ return writeAPISection(w, sectionHeader, sectionPrefix, payload, timestamp)
+ }
+ if len(payload) > 0 {
+ if errWrite := writeAPISection(w, sectionHeader, sectionPrefix, payload, timestamp); errWrite != nil {
+ return errWrite
+ }
+ }
+ tracker := &trailingNewlineTrackingWriter{writer: w}
+ if errWrite := source.WriteTo(tracker); errWrite != nil {
+ return errWrite
+ }
+ if errWrite := writeSectionSpacing(w, tracker.trailingNewlines); errWrite != nil {
+ return errWrite
+ }
+ return nil
+}
+
func writeAPIErrorResponses(w io.Writer, apiResponseErrors []*interfaces.ErrorMessage) error {
for i := 0; i < len(apiResponseErrors); i++ {
if apiResponseErrors[i] == nil {
@@ -1288,7 +1361,7 @@ func (l *FileRequestLogger) formatLogContent(url, method string, headers map[str
var content strings.Builder
isWebsocketTranscript := hasSectionPayload(websocketTimeline)
downstreamTransport := inferDownstreamTransport(headers, websocketTimeline, nil)
- upstreamTransport := inferUpstreamTransport(apiRequest, apiResponse, apiWebsocketTimeline, nil, apiResponseErrors)
+ upstreamTransport := inferUpstreamTransport(apiRequest, nil, apiResponse, nil, apiWebsocketTimeline, nil, apiResponseErrors)
// Request info
content.WriteString(l.formatRequestInfo(url, method, headers, body, downstreamTransport, upstreamTransport, !isWebsocketTranscript))
@@ -1607,9 +1680,15 @@ type FileStreamingLogWriter struct {
// apiRequest stores the upstream API request data.
apiRequest []byte
+ // apiRequestSource stores file-backed upstream API request data.
+ apiRequestSource *FileBodySource
+
// apiResponse stores the upstream API response data.
apiResponse []byte
+ // apiResponseSource stores file-backed upstream API response data.
+ apiResponseSource *FileBodySource
+
// apiWebsocketTimeline stores the upstream websocket event timeline.
apiWebsocketTimeline []byte
@@ -1679,6 +1758,15 @@ func (w *FileStreamingLogWriter) WriteAPIRequest(apiRequest []byte) error {
return nil
}
+// WriteAPIRequestSource buffers a file-backed upstream API request for final writing.
+func (w *FileStreamingLogWriter) WriteAPIRequestSource(apiRequestSource *FileBodySource) error {
+ if apiRequestSource == nil || !apiRequestSource.HasPayload() {
+ return nil
+ }
+ w.apiRequestSource = apiRequestSource
+ return nil
+}
+
// WriteAPIResponse buffers the upstream API response details for later writing.
//
// Parameters:
@@ -1694,6 +1782,15 @@ func (w *FileStreamingLogWriter) WriteAPIResponse(apiResponse []byte) error {
return nil
}
+// WriteAPIResponseSource buffers a file-backed upstream API response for final writing.
+func (w *FileStreamingLogWriter) WriteAPIResponseSource(apiResponseSource *FileBodySource) error {
+ if apiResponseSource == nil || !apiResponseSource.HasPayload() {
+ return nil
+ }
+ w.apiResponseSource = apiResponseSource
+ return nil
+}
+
// WriteAPIWebsocketTimeline buffers the upstream websocket timeline for later writing.
//
// Parameters:
@@ -1799,16 +1896,16 @@ func (w *FileStreamingLogWriter) asyncWriter() {
}
func (w *FileStreamingLogWriter) writeFinalLog(logFile *os.File) error {
- if errWrite := writeRequestInfoWithBody(logFile, w.url, w.method, w.requestHeaders, nil, w.requestBodyPath, w.timestamp, "http", inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTimeline, nil, nil), true); errWrite != nil {
+ if errWrite := writeRequestInfoWithBody(logFile, w.url, w.method, w.requestHeaders, nil, w.requestBodyPath, w.timestamp, "http", inferUpstreamTransport(w.apiRequest, w.apiRequestSource, w.apiResponse, w.apiResponseSource, w.apiWebsocketTimeline, nil, nil), true); errWrite != nil {
return errWrite
}
if errWrite := writeAPISection(logFile, "=== API WEBSOCKET TIMELINE ===\n", "=== API WEBSOCKET TIMELINE", w.apiWebsocketTimeline, time.Time{}); errWrite != nil {
return errWrite
}
- if errWrite := writeAPISection(logFile, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest, time.Time{}); errWrite != nil {
+ if errWrite := writePreformattedAPISectionWithSource(logFile, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest, w.apiRequestSource, time.Time{}); errWrite != nil {
return errWrite
}
- if errWrite := writeAPISection(logFile, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse, w.apiResponseTimestamp); errWrite != nil {
+ if errWrite := writePreformattedAPISectionWithSource(logFile, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse, w.apiResponseSource, w.apiResponseTimestamp); errWrite != nil {
return errWrite
}
@@ -2040,7 +2137,7 @@ func (w *homeStreamingLogWriter) Close() error {
responsePayload := w.responseBody.Bytes()
var buf bytes.Buffer
- upstreamTransport := inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTime, nil, nil)
+ upstreamTransport := inferUpstreamTransport(w.apiRequest, nil, w.apiResponse, nil, w.apiWebsocketTime, nil, nil)
if errWrite := writeRequestInfoWithBody(&buf, w.url, w.method, w.requestHeaders, w.requestBody, "", w.timestamp, "http", upstreamTransport, true); errWrite != nil {
return errWrite
}
diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go
index c32230585bc..94837d2cf8b 100644
--- a/internal/runtime/executor/helps/logging_helpers.go
+++ b/internal/runtime/executor/helps/logging_helpers.go
@@ -44,6 +44,7 @@ type upstreamAttempt struct {
index int
request string
response *strings.Builder
+ responseSource *logging.FileBodySource
responseIntroWritten bool
statusWritten bool
headersWritten bool
@@ -53,9 +54,13 @@ type upstreamAttempt struct {
errorWritten bool
}
+func requestLogCaptureEnabled(cfg *config.Config) bool {
+ return cfg != nil && cfg.RequestLog && !cfg.CommercialMode
+}
+
// RecordAPIRequest stores the upstream request metadata in Gin context for request logging.
func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequestLog) {
- if cfg == nil || !cfg.RequestLog {
+ if !requestLogCaptureEnabled(cfg) {
return
}
ginCtx := ginContextFrom(ctx)
@@ -83,27 +88,57 @@ func RecordAPIRequest(ctx context.Context, cfg *config.Config, info UpstreamRequ
builder.WriteString("\nHeaders:\n")
writeHeaders(builder, info.Headers)
builder.WriteString("\nBody:\n")
- if len(info.Body) > 0 {
- builder.WriteString(string(info.Body))
+
+ requestText := ""
+ if source, ok := apiRequestSource(ginCtx); ok {
+ if errWrite := source.AppendBytes([]byte(builder.String())); errWrite == nil {
+ if len(info.Body) > 0 {
+ if errBody := source.AppendBytes(info.Body); errBody != nil {
+ log.WithError(errBody).Warn("failed to append api request body log part")
+ }
+ } else if errEmpty := source.AppendBytes([]byte("")); errEmpty != nil {
+ log.WithError(errEmpty).Warn("failed to append empty api request log part")
+ }
+ if errEnd := source.AppendBytes([]byte("\n\n")); errEnd != nil {
+ log.WithError(errEnd).Warn("failed to append api request log terminator")
+ }
+ } else {
+ log.WithError(errWrite).Warn("failed to append api request log part")
+ if len(info.Body) > 0 {
+ builder.WriteString(string(info.Body))
+ } else {
+ builder.WriteString("")
+ }
+ builder.WriteString("\n\n")
+ requestText = builder.String()
+ }
} else {
- builder.WriteString("")
+ if len(info.Body) > 0 {
+ builder.WriteString(string(info.Body))
+ } else {
+ builder.WriteString("")
+ }
+ builder.WriteString("\n\n")
+ requestText = builder.String()
}
- builder.WriteString("\n\n")
attempt := &upstreamAttempt{
- index: index,
- request: builder.String(),
- response: &strings.Builder{},
+ index: index,
+ request: requestText,
+ response: &strings.Builder{},
+ responseSource: apiResponseSourceOrNil(ginCtx),
}
attempts = append(attempts, attempt)
ginCtx.Set(apiAttemptsKey, attempts)
- updateAggregatedRequest(ginCtx, attempts)
+ if requestText != "" {
+ updateAggregatedRequest(ginCtx, attempts)
+ }
}
// RecordAPIResponseMetadata captures upstream response status/header information for the latest attempt.
func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status int, headers http.Header) {
logging.SetResponseHeaders(ctx, headers)
- if cfg == nil || !cfg.RequestLog {
+ if !requestLogCaptureEnabled(cfg) {
return
}
ginCtx := ginContextFrom(ctx)
@@ -111,25 +146,27 @@ func RecordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i
return
}
attempts, attempt := ensureAttempt(ginCtx)
- ensureResponseIntro(attempt)
+ ensureResponseIntro(ginCtx, attempt)
if status > 0 && !attempt.statusWritten {
- attempt.response.WriteString(fmt.Sprintf("Status: %d\n", status))
+ writeAttemptResponse(ginCtx, attempt, []byte(fmt.Sprintf("Status: %d\n", status)))
attempt.statusWritten = true
}
if !attempt.headersWritten {
- attempt.response.WriteString("Headers:\n")
- writeHeaders(attempt.response, headers)
+ builder := &strings.Builder{}
+ builder.WriteString("Headers:\n")
+ writeHeaders(builder, headers)
+ writeAttemptResponse(ginCtx, attempt, []byte(builder.String()))
attempt.headersWritten = true
- attempt.response.WriteString("\n")
+ writeAttemptResponse(ginCtx, attempt, []byte("\n"))
}
- updateAggregatedResponse(ginCtx, attempts)
+ updateAggregatedResponseIfMemoryBacked(ginCtx, attempts)
}
// RecordAPIResponseError adds an error entry for the latest attempt when no HTTP response is available.
func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error) {
- if cfg == nil || !cfg.RequestLog || err == nil {
+ if !requestLogCaptureEnabled(cfg) || err == nil {
return
}
ginCtx := ginContextFrom(ctx)
@@ -137,24 +174,24 @@ func RecordAPIResponseError(ctx context.Context, cfg *config.Config, err error)
return
}
attempts, attempt := ensureAttempt(ginCtx)
- ensureResponseIntro(attempt)
+ ensureResponseIntro(ginCtx, attempt)
if attempt.bodyStarted && !attempt.bodyHasContent {
// Ensure body does not stay empty marker if error arrives first.
attempt.bodyStarted = false
}
if attempt.errorWritten {
- attempt.response.WriteString("\n")
+ writeAttemptResponse(ginCtx, attempt, []byte("\n"))
}
- attempt.response.WriteString(fmt.Sprintf("Error: %s\n", err.Error()))
+ writeAttemptResponse(ginCtx, attempt, []byte(fmt.Sprintf("Error: %s\n", err.Error())))
attempt.errorWritten = true
- updateAggregatedResponse(ginCtx, attempts)
+ updateAggregatedResponseIfMemoryBacked(ginCtx, attempts)
}
// AppendAPIResponseChunk appends an upstream response chunk to Gin context for request logging.
func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) {
- if cfg == nil || !cfg.RequestLog {
+ if !requestLogCaptureEnabled(cfg) {
return
}
data := bytes.TrimSpace(chunk)
@@ -166,16 +203,18 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt
return
}
attempts, attempt := ensureAttempt(ginCtx)
- ensureResponseIntro(attempt)
+ ensureResponseIntro(ginCtx, attempt)
if !attempt.headersWritten {
- attempt.response.WriteString("Headers:\n")
- writeHeaders(attempt.response, nil)
+ builder := &strings.Builder{}
+ builder.WriteString("Headers:\n")
+ writeHeaders(builder, nil)
+ writeAttemptResponse(ginCtx, attempt, []byte(builder.String()))
attempt.headersWritten = true
- attempt.response.WriteString("\n")
+ writeAttemptResponse(ginCtx, attempt, []byte("\n"))
}
if !attempt.bodyStarted {
- attempt.response.WriteString("Body:\n")
+ writeAttemptResponse(ginCtx, attempt, []byte("Body:\n"))
attempt.bodyStarted = true
}
currentChunkIsSSEEvent := bytes.HasPrefix(data, []byte("event:"))
@@ -185,18 +224,18 @@ func AppendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt
if attempt.prevWasSSEEvent && currentChunkIsSSEData {
separator = "\n"
}
- attempt.response.WriteString(separator)
+ writeAttemptResponse(ginCtx, attempt, []byte(separator))
}
- attempt.response.WriteString(string(data))
+ writeAttemptResponse(ginCtx, attempt, data)
attempt.bodyHasContent = true
attempt.prevWasSSEEvent = currentChunkIsSSEEvent
- updateAggregatedResponse(ginCtx, attempts)
+ updateAggregatedResponseIfMemoryBacked(ginCtx, attempts)
}
// RecordAPIWebsocketRequest stores an upstream websocket request event in Gin context.
func RecordAPIWebsocketRequest(ctx context.Context, cfg *config.Config, info UpstreamRequestLog) {
- if cfg == nil || !cfg.RequestLog {
+ if !requestLogCaptureEnabled(cfg) {
return
}
ginCtx := ginContextFrom(ctx)
@@ -229,7 +268,7 @@ func RecordAPIWebsocketRequest(ctx context.Context, cfg *config.Config, info Ups
// RecordAPIWebsocketHandshake stores the upstream websocket handshake response metadata.
func RecordAPIWebsocketHandshake(ctx context.Context, cfg *config.Config, status int, headers http.Header) {
logging.SetResponseHeaders(ctx, headers)
- if cfg == nil || !cfg.RequestLog {
+ if !requestLogCaptureEnabled(cfg) {
return
}
ginCtx := ginContextFrom(ctx)
@@ -253,7 +292,7 @@ func RecordAPIWebsocketHandshake(ctx context.Context, cfg *config.Config, status
// RecordAPIWebsocketUpgradeRejection stores a rejected websocket upgrade as an HTTP attempt.
func RecordAPIWebsocketUpgradeRejection(ctx context.Context, cfg *config.Config, info UpstreamRequestLog, status int, headers http.Header, body []byte) {
logging.SetResponseHeaders(ctx, headers)
- if cfg == nil || !cfg.RequestLog {
+ if !requestLogCaptureEnabled(cfg) {
return
}
ginCtx := ginContextFrom(ctx)
@@ -287,7 +326,7 @@ func WebsocketUpgradeRequestURL(rawURL string) string {
// AppendAPIWebsocketResponse stores an upstream websocket response frame in Gin context.
func AppendAPIWebsocketResponse(ctx context.Context, cfg *config.Config, payload []byte) {
- if cfg == nil || !cfg.RequestLog {
+ if !requestLogCaptureEnabled(cfg) {
return
}
data := bytes.TrimSpace(payload)
@@ -311,7 +350,7 @@ func AppendAPIWebsocketResponse(ctx context.Context, cfg *config.Config, payload
// RecordAPIWebsocketError stores an upstream websocket error event in Gin context.
func RecordAPIWebsocketError(ctx context.Context, cfg *config.Config, stage string, err error) {
- if cfg == nil || !cfg.RequestLog || err == nil {
+ if !requestLogCaptureEnabled(cfg) || err == nil {
return
}
ginCtx := ginContextFrom(ctx)
@@ -352,27 +391,61 @@ func ensureAttempt(ginCtx *gin.Context) ([]*upstreamAttempt, *upstreamAttempt) {
attempts := getAttempts(ginCtx)
if len(attempts) == 0 {
attempt := &upstreamAttempt{
- index: 1,
- request: "=== API REQUEST 1 ===\n\n\n",
- response: &strings.Builder{},
+ index: 1,
+ response: &strings.Builder{},
+ responseSource: apiResponseSourceOrNil(ginCtx),
+ }
+ if source, ok := apiRequestSource(ginCtx); ok {
+ if errWrite := source.AppendBytes([]byte("=== API REQUEST 1 ===\n\n\n")); errWrite != nil {
+ log.WithError(errWrite).Warn("failed to append missing api request log part")
+ attempt.request = "=== API REQUEST 1 ===\n\n\n"
+ }
+ } else {
+ attempt.request = "=== API REQUEST 1 ===\n\n\n"
}
attempts = []*upstreamAttempt{attempt}
ginCtx.Set(apiAttemptsKey, attempts)
- updateAggregatedRequest(ginCtx, attempts)
+ if attempt.request != "" {
+ updateAggregatedRequest(ginCtx, attempts)
+ }
}
return attempts, attempts[len(attempts)-1]
}
-func ensureResponseIntro(attempt *upstreamAttempt) {
+func ensureResponseIntro(ginCtx *gin.Context, attempt *upstreamAttempt) {
if attempt == nil || attempt.response == nil || attempt.responseIntroWritten {
return
}
- attempt.response.WriteString(fmt.Sprintf("=== API RESPONSE %d ===\n", attempt.index))
- attempt.response.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
- attempt.response.WriteString("\n")
+ writeAttemptResponse(ginCtx, attempt, []byte(fmt.Sprintf("=== API RESPONSE %d ===\n", attempt.index)))
+ writeAttemptResponse(ginCtx, attempt, []byte(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))))
+ writeAttemptResponse(ginCtx, attempt, []byte("\n"))
attempt.responseIntroWritten = true
}
+func writeAttemptResponse(ginCtx *gin.Context, attempt *upstreamAttempt, payload []byte) {
+ if attempt == nil || len(payload) == 0 {
+ return
+ }
+ if attempt.responseSource == nil {
+ attempt.responseSource = apiResponseSourceOrNil(ginCtx)
+ }
+ if attempt.responseSource != nil {
+ if errWrite := attempt.responseSource.AppendBytes(payload); errWrite == nil {
+ if ginCtx != nil {
+ ginCtx.Set(logging.APIResponseCapturedContextKey, true)
+ }
+ return
+ } else {
+ log.WithError(errWrite).Warn("failed to append api response log part")
+ attempt.responseSource = nil
+ }
+ }
+ if attempt.response == nil {
+ attempt.response = &strings.Builder{}
+ }
+ attempt.response.Write(payload)
+}
+
func updateAggregatedRequest(ginCtx *gin.Context, attempts []*upstreamAttempt) {
if ginCtx == nil {
return
@@ -384,6 +457,13 @@ func updateAggregatedRequest(ginCtx *gin.Context, attempts []*upstreamAttempt) {
ginCtx.Set(apiRequestKey, []byte(builder.String()))
}
+func updateAggregatedResponseIfMemoryBacked(ginCtx *gin.Context, attempts []*upstreamAttempt) {
+ if apiResponseSourceOrNil(ginCtx) != nil {
+ return
+ }
+ updateAggregatedResponse(ginCtx, attempts)
+}
+
func updateAggregatedResponse(ginCtx *gin.Context, attempts []*upstreamAttempt) {
if ginCtx == nil {
return
@@ -408,6 +488,18 @@ func updateAggregatedResponse(ginCtx *gin.Context, attempts []*upstreamAttempt)
ginCtx.Set(apiResponseKey, []byte(builder.String()))
}
+func apiRequestSource(ginCtx *gin.Context) (*logging.FileBodySource, bool) {
+ return fileBodySourceFromGin(ginCtx, logging.APIRequestSourceContextKey)
+}
+
+func apiResponseSourceOrNil(ginCtx *gin.Context) *logging.FileBodySource {
+ source, ok := fileBodySourceFromGin(ginCtx, logging.APIResponseSourceContextKey)
+ if !ok {
+ return nil
+ }
+ return source
+}
+
func appendAPIWebsocketTimeline(ginCtx *gin.Context, chunk []byte) {
if ginCtx == nil {
return
@@ -440,10 +532,14 @@ func appendAPIWebsocketTimeline(ginCtx *gin.Context, chunk []byte) {
}
func apiWebsocketTimelineSource(ginCtx *gin.Context) (*logging.FileBodySource, bool) {
+ return fileBodySourceFromGin(ginCtx, logging.APIWebsocketTimelineSourceContextKey)
+}
+
+func fileBodySourceFromGin(ginCtx *gin.Context, key string) (*logging.FileBodySource, bool) {
if ginCtx == nil {
return nil, false
}
- value, exists := ginCtx.Get(logging.APIWebsocketTimelineSourceContextKey)
+ value, exists := ginCtx.Get(key)
if !exists {
return nil, false
}
diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go
index 55b4d6ab531..8b51d9eebc1 100644
--- a/sdk/api/handlers/handlers.go
+++ b/sdk/api/handlers/handlers.go
@@ -447,6 +447,12 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
logging.SetResponseStatus(cancelCtx, c.Writer.Status())
}
if h.Cfg.RequestLog && len(params) == 1 {
+ if captured, exists := c.Get(logging.APIResponseCapturedContextKey); exists {
+ if capturedBool, ok := captured.(bool); ok && capturedBool {
+ cancel()
+ return
+ }
+ }
if existing, exists := c.Get("API_RESPONSE"); exists {
if existingBytes, ok := existing.([]byte); ok && len(bytes.TrimSpace(existingBytes)) > 0 {
switch params[0].(type) {
From bc38b68902ac64738036a96c657561095dc12cf0 Mon Sep 17 00:00:00 2001
From: hkfires <10558748+hkfires@users.noreply.github.com>
Date: Sat, 6 Jun 2026 11:42:21 +0800
Subject: [PATCH 118/248] feat(safemode): implement example API key warning
server and related functionality
---
cmd/server/main.go | 21 +++
cmd/server/main_test.go | 89 ++++++++++
internal/cmd/run.go | 13 ++
internal/safemode/example_api_keys.go | 184 +++++++++++++++++++++
internal/safemode/example_api_keys_test.go | 91 ++++++++++
5 files changed, 398 insertions(+)
create mode 100644 cmd/server/main_test.go
create mode 100644 internal/safemode/example_api_keys.go
create mode 100644 internal/safemode/example_api_keys_test.go
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 4181faeca6b..95c646fdd68 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -27,6 +27,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/safemode"
"github.com/router-for-me/CLIProxyAPI/v7/internal/store"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
"github.com/router-for-me/CLIProxyAPI/v7/internal/tui"
@@ -51,6 +52,16 @@ func init() {
buildinfo.BuildDate = BuildDate
}
+func shouldStartExampleAPIKeyWarningServer(cfg *config.Config, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode bool) bool {
+ if cfg == nil || commandMode || homeMode || cloudConfigMissing {
+ return false
+ }
+ if tuiMode && !standalone {
+ return false
+ }
+ return safemode.HasExampleAPIKeys(cfg.APIKeys)
+}
+
// main is the entry point of the application.
// It parses command-line flags, loads configuration, and starts the appropriate
// service based on the provided flags (login, codex-login, or server mode).
@@ -512,6 +523,16 @@ func main() {
CallbackPort: oauthCallbackPort,
}
+ commandMode := vertexImport != "" || login || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || xaiLogin
+ cloudConfigMissing := isCloudDeploy && !configFileExists
+ homeMode := configLoadedFromHome || (cfg != nil && cfg.Home.Enabled)
+ if shouldStartExampleAPIKeyWarningServer(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode) {
+ matches := safemode.ExampleAPIKeys(cfg.APIKeys)
+ log.WithField("api_keys", strings.Join(matches, ",")).Error("unsafe example API key configured; starting warning-only server")
+ cmd.StartExampleAPIKeyWarningServer(cfg, configFilePath, matches)
+ return
+ }
+
// Register the shared token store once so all components use the same persistence backend.
if usePostgresStore {
sdkAuth.RegisterTokenStore(pgStoreInst)
diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go
new file mode 100644
index 00000000000..f5ec3b31846
--- /dev/null
+++ b/cmd/server/main_test.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+)
+
+func TestShouldStartExampleAPIKeyWarningServer(t *testing.T) {
+ cfgWithExampleKey := &config.Config{
+ SDKConfig: config.SDKConfig{
+ APIKeys: []string{"real-key", " your-api-key-1 "},
+ },
+ }
+ cfgWithRealKey := &config.Config{
+ SDKConfig: config.SDKConfig{
+ APIKeys: []string{"real-key"},
+ },
+ }
+
+ tests := []struct {
+ name string
+ cfg *config.Config
+ commandMode bool
+ tuiMode bool
+ standalone bool
+ cloudConfigMissing bool
+ homeMode bool
+ want bool
+ }{
+ {
+ name: "normal server with example key",
+ cfg: cfgWithExampleKey,
+ want: true,
+ },
+ {
+ name: "standalone tui with example key",
+ cfg: cfgWithExampleKey,
+ tuiMode: true,
+ standalone: true,
+ want: true,
+ },
+ {
+ name: "pure tui client is not blocked",
+ cfg: cfgWithExampleKey,
+ tuiMode: true,
+ standalone: false,
+ commandMode: false,
+ want: false,
+ },
+ {
+ name: "one-shot command is not blocked",
+ cfg: cfgWithExampleKey,
+ commandMode: true,
+ want: false,
+ },
+ {
+ name: "home mode is not blocked",
+ cfg: cfgWithExampleKey,
+ homeMode: true,
+ want: false,
+ },
+ {
+ name: "cloud standby without config is not blocked",
+ cfg: cfgWithExampleKey,
+ cloudConfigMissing: true,
+ want: false,
+ },
+ {
+ name: "normal server with real key",
+ cfg: cfgWithRealKey,
+ want: false,
+ },
+ {
+ name: "nil config",
+ cfg: nil,
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := shouldStartExampleAPIKeyWarningServer(tt.cfg, tt.commandMode, tt.tuiMode, tt.standalone, tt.cloudConfigMissing, tt.homeMode)
+ if got != tt.want {
+ t.Fatalf("shouldStartExampleAPIKeyWarningServer() = %t, want %t", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index 38f189b4a94..9d699bcfd3f 100644
--- a/internal/cmd/run.go
+++ b/internal/cmd/run.go
@@ -12,6 +12,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/safemode"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
log "github.com/sirupsen/logrus"
)
@@ -55,6 +56,18 @@ func StartService(cfg *config.Config, configPath string, localPassword string) {
}
}
+// StartExampleAPIKeyWarningServer starts a warning-only server for unsafe template API keys.
+func StartExampleAPIKeyWarningServer(cfg *config.Config, configPath string, keys []string) {
+ ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer cancel()
+
+ log.Errorf("normal API server disabled: example API key values are configured in %s", configPath)
+ log.Errorf("example API key warning page listening on: %s", safemode.WarningServerURL(cfg))
+ if err := safemode.StartExampleAPIKeyWarningServer(ctxSignal, cfg, configPath, keys); err != nil && !errors.Is(err, context.Canceled) {
+ log.Errorf("example API key warning server exited with error: %v", err)
+ }
+}
+
// StartServiceBackground starts the proxy service in a background goroutine
// and returns a cancel function for shutdown and a done channel.
func StartServiceBackground(cfg *config.Config, configPath string, localPassword string) (cancel func(), done <-chan struct{}) {
diff --git a/internal/safemode/example_api_keys.go b/internal/safemode/example_api_keys.go
new file mode 100644
index 00000000000..066c02d9654
--- /dev/null
+++ b/internal/safemode/example_api_keys.go
@@ -0,0 +1,184 @@
+package safemode
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "html"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+)
+
+var exampleAPIKeys = map[string]struct{}{
+ "your-api-key-1": {},
+ "your-api-key-2": {},
+ "your-api-key-3": {},
+}
+
+// ExampleAPIKeys returns configured top-level API keys that still use template values.
+func ExampleAPIKeys(keys []string) []string {
+ if len(keys) == 0 {
+ return nil
+ }
+
+ matches := make([]string, 0, len(keys))
+ seen := make(map[string]struct{}, len(exampleAPIKeys))
+ for _, key := range keys {
+ trimmed := strings.TrimSpace(key)
+ if _, ok := exampleAPIKeys[trimmed]; !ok {
+ continue
+ }
+ if _, exists := seen[trimmed]; exists {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ matches = append(matches, trimmed)
+ }
+ if len(matches) == 0 {
+ return nil
+ }
+ return matches
+}
+
+// HasExampleAPIKeys reports whether any configured top-level API key is a template value.
+func HasExampleAPIKeys(keys []string) bool {
+ return len(ExampleAPIKeys(keys)) > 0
+}
+
+// WarningServerURL returns a local-friendly URL for the warning-only server.
+func WarningServerURL(cfg *config.Config) string {
+ scheme := "http"
+ host := "127.0.0.1"
+ port := 0
+ if cfg != nil {
+ port = cfg.Port
+ if cfg.TLS.Enable {
+ scheme = "https"
+ }
+ if trimmed := strings.TrimSpace(cfg.Host); trimmed != "" {
+ host = trimmed
+ }
+ }
+ if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
+ host = "[" + host + "]"
+ }
+ return fmt.Sprintf("%s://%s:%d/", scheme, host, port)
+}
+
+// NewExampleAPIKeyWarningHandler serves a setup warning page and leaves all other routes unregistered.
+func NewExampleAPIKeyWarningHandler(configPath string, keys []string) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL == nil || r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ if r.Method == http.MethodHead {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ _, _ = fmt.Fprint(w, warningPageHTML(configPath, keys))
+ })
+ return mux
+}
+
+// StartExampleAPIKeyWarningServer starts the warning-only HTTP(S) server and blocks until it stops.
+func StartExampleAPIKeyWarningServer(ctx context.Context, cfg *config.Config, configPath string, keys []string) error {
+ if cfg == nil {
+ cfg = &config.Config{}
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ var tlsConfig *tls.Config
+ if cfg.TLS.Enable {
+ certPath := strings.TrimSpace(cfg.TLS.Cert)
+ keyPath := strings.TrimSpace(cfg.TLS.Key)
+ if certPath == "" || keyPath == "" {
+ return fmt.Errorf("failed to start HTTPS warning server: tls.cert or tls.key is empty")
+ }
+ certPair, errLoad := tls.LoadX509KeyPair(certPath, keyPath)
+ if errLoad != nil {
+ return fmt.Errorf("failed to start HTTPS warning server: %w", errLoad)
+ }
+ tlsConfig = &tls.Config{
+ Certificates: []tls.Certificate{certPair},
+ MinVersion: tls.VersionTLS12,
+ }
+ }
+
+ addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
+ listener, errListen := net.Listen("tcp", addr)
+ if errListen != nil {
+ return fmt.Errorf("failed to start warning server: %w", errListen)
+ }
+ if tlsConfig != nil {
+ listener = tls.NewListener(listener, tlsConfig)
+ }
+
+ server := &http.Server{
+ Addr: addr,
+ Handler: NewExampleAPIKeyWarningHandler(configPath, keys),
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- server.Serve(listener)
+ }()
+
+ select {
+ case errServe := <-errCh:
+ if errServe == nil || errServe == http.ErrServerClosed {
+ return nil
+ }
+ return errServe
+ case <-ctx.Done():
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ errShutdown := server.Shutdown(shutdownCtx)
+ errServe := <-errCh
+ if errShutdown != nil {
+ return errShutdown
+ }
+ if errServe != nil && errServe != http.ErrServerClosed {
+ return errServe
+ }
+ return ctx.Err()
+ }
+}
+
+func warningPageHTML(configPath string, keys []string) string {
+ var b strings.Builder
+ b.WriteString(`Example API key detectedExample API key detected
The normal API server was not started because the top-level api-keys configuration still contains template values.
`)
+ if len(keys) > 0 {
+ b.WriteString(`Replace these values before using the proxy:
`)
+ for _, key := range keys {
+ b.WriteString(``)
+ b.WriteString(html.EscapeString(key))
+ b.WriteString(` `)
+ }
+ b.WriteString(`
`)
+ }
+ if strings.TrimSpace(configPath) != "" {
+ b.WriteString(`Edit `)
+ b.WriteString(html.EscapeString(configPath))
+ b.WriteString(`, set strong random API keys, then restart CLIProxyAPI.
`)
+ } else {
+ b.WriteString(`Edit your config file, set strong random API keys, then restart CLIProxyAPI.
`)
+ }
+ b.WriteString(``)
+ return b.String()
+}
diff --git a/internal/safemode/example_api_keys_test.go b/internal/safemode/example_api_keys_test.go
new file mode 100644
index 00000000000..2aaf547182b
--- /dev/null
+++ b/internal/safemode/example_api_keys_test.go
@@ -0,0 +1,91 @@
+package safemode
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+)
+
+func TestExampleAPIKeysDetectsOnlyTemplateValues(t *testing.T) {
+ keys := []string{
+ " real-key ",
+ " your-api-key-1 ",
+ "your-api-key",
+ "change-me",
+ "your-api-key-2",
+ "your-api-key-2",
+ "your-api-key-3",
+ }
+
+ got := ExampleAPIKeys(keys)
+ want := []string{"your-api-key-1", "your-api-key-2", "your-api-key-3"}
+ if len(got) != len(want) {
+ t.Fatalf("ExampleAPIKeys() = %#v, want %#v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("ExampleAPIKeys()[%d] = %q, want %q (all: %#v)", i, got[i], want[i], got)
+ }
+ }
+}
+
+func TestExampleAPIKeysIgnoresSimilarValues(t *testing.T) {
+ keys := []string{"your-api-key", "change-me", "changeme", "your-api-key-4", "my-your-api-key-1"}
+ if got := ExampleAPIKeys(keys); len(got) != 0 {
+ t.Fatalf("ExampleAPIKeys() = %#v, want empty", got)
+ }
+ if HasExampleAPIKeys(keys) {
+ t.Fatal("HasExampleAPIKeys() = true, want false")
+ }
+}
+
+func TestExampleAPIKeyWarningHandler(t *testing.T) {
+ handler := NewExampleAPIKeyWarningHandler("C:\\config.yaml", []string{"your-api-key-1"})
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET / status = %d, want %d", w.Code, http.StatusOK)
+ }
+ body := w.Body.String()
+ for _, want := range []string{"Example API key detected", "your-api-key-1", "C:\\config.yaml"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("GET / body missing %q: %s", want, body)
+ }
+ }
+
+ req = httptest.NewRequest(http.MethodHead, "/", nil)
+ w = httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("HEAD / status = %d, want %d", w.Code, http.StatusOK)
+ }
+ if w.Body.Len() != 0 {
+ t.Fatalf("HEAD / body length = %d, want 0", w.Body.Len())
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ w = httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("GET /v1/models status = %d, want %d", w.Code, http.StatusNotFound)
+ }
+}
+
+func TestWarningServerURL(t *testing.T) {
+ cfg := &config.Config{Port: 8317}
+ if got := WarningServerURL(cfg); got != "http://127.0.0.1:8317/" {
+ t.Fatalf("WarningServerURL() = %q", got)
+ }
+
+ cfg.Host = "::1"
+ cfg.TLS.Enable = true
+ if got := WarningServerURL(cfg); got != "https://[::1]:8317/" {
+ t.Fatalf("WarningServerURL() = %q", got)
+ }
+}
From d625caddd9a66cff97e0cc281d827dbc4b46a7fe Mon Sep 17 00:00:00 2001
From: Luis Pater
Date: Sat, 6 Jun 2026 18:35:17 +0800
Subject: [PATCH 119/248] feat(pluginhost): add capabilities for command-line
flag handling and plugin execution
- Implemented command-line flag registration and execution for plugins with priority-based conflict resolution.
- Enabled plugin-owned command-line flag execution and persistence of plugin-auth data.
- Added new `Host` methods to support command-line capabilities, including flag normalization, validation, and execution state management.
- Introduced unit tests to ensure coverage for command-line plugin functionality, including auth data persistence.
- Updated configs to normalize plugins during initialization.
---
.gitignore | 1 +
cmd/server/main.go | 76 +-
config.example.yaml | 23 +
examples/plugin/README.md | 416 ++++
examples/plugin/README_CN.md | 416 ++++
examples/plugin/main.go | 420 ++++
.../api/handlers/management/auth_files.go | 139 +-
internal/api/handlers/management/handler.go | 18 +
.../api/handlers/management/oauth_sessions.go | 76 +-
internal/api/handlers/management/plugins.go | 459 ++++
.../api/handlers/management/plugins_test.go | 244 ++
internal/api/server.go | 115 +-
internal/api/server_test.go | 26 +
internal/cmd/run.go | 17 +
internal/config/config.go | 114 +-
internal/config/parse.go | 2 +
internal/config/plugin_config_test.go | 160 ++
internal/pluginhost/adapters.go | 1644 +++++++++++++
internal/pluginhost/adapters_test.go | 2182 +++++++++++++++++
internal/pluginhost/auth_provider.go | 495 ++++
internal/pluginhost/auth_provider_test.go | 317 +++
internal/pluginhost/command_line.go | 420 ++++
internal/pluginhost/command_line_test.go | 212 ++
internal/pluginhost/config.go | 156 ++
internal/pluginhost/config_test.go | 35 +
internal/pluginhost/host.go | 263 ++
internal/pluginhost/host_test.go | 250 ++
internal/pluginhost/http_bridge.go | 172 ++
internal/pluginhost/loader_plugin.go | 35 +
internal/pluginhost/loader_unsupported.go | 23 +
internal/pluginhost/management.go | 193 ++
internal/pluginhost/management_test.go | 156 ++
internal/pluginhost/platform.go | 126 +
internal/pluginhost/platform_test.go | 158 ++
internal/pluginhost/snapshot.go | 99 +
internal/pluginhost/test_helpers_test.go | 133 +
internal/registry/model_registry.go | 2 +-
internal/thinking/apply.go | 85 +-
internal/thinking/validate.go | 2 +-
internal/watcher/clients.go | 92 +-
internal/watcher/dispatcher.go | 75 +-
internal/watcher/events.go | 9 +-
internal/watcher/synthesizer/context.go | 10 +
internal/watcher/synthesizer/file.go | 27 +-
internal/watcher/watcher.go | 20 +-
internal/watcher/watcher_test.go | 4 +-
sdk/auth/filestore.go | 60 +-
sdk/cliproxy/auth/conductor.go | 16 +
sdk/cliproxy/auth/oauth_model_alias.go | 29 +-
sdk/cliproxy/auth/oauth_model_alias_test.go | 49 +
sdk/cliproxy/builder.go | 54 +-
sdk/cliproxy/service.go | 750 +++++-
sdk/cliproxy/service_excluded_models_test.go | 5 +-
.../service_oauth_model_alias_test.go | 42 +
sdk/cliproxy/service_plugin_executor_test.go | 59 +
sdk/cliproxy/types.go | 24 +
sdk/cliproxy/usage/manager.go | 28 +
sdk/cliproxy/watcher.go | 6 +
sdk/pluginapi/types.go | 876 +++++++
sdk/pluginapi/types_test.go | 152 ++
sdk/translator/helpers.go | 5 +
sdk/translator/plugin_hooks.go | 12 +
sdk/translator/registry.go | 119 +-
sdk/translator/registry_test.go | 204 ++
64 files changed, 12420 insertions(+), 187 deletions(-)
create mode 100644 examples/plugin/README.md
create mode 100644 examples/plugin/README_CN.md
create mode 100644 examples/plugin/main.go
create mode 100644 internal/api/handlers/management/plugins.go
create mode 100644 internal/api/handlers/management/plugins_test.go
create mode 100644 internal/config/plugin_config_test.go
create mode 100644 internal/pluginhost/adapters.go
create mode 100644 internal/pluginhost/adapters_test.go
create mode 100644 internal/pluginhost/auth_provider.go
create mode 100644 internal/pluginhost/auth_provider_test.go
create mode 100644 internal/pluginhost/command_line.go
create mode 100644 internal/pluginhost/command_line_test.go
create mode 100644 internal/pluginhost/config.go
create mode 100644 internal/pluginhost/config_test.go
create mode 100644 internal/pluginhost/host.go
create mode 100644 internal/pluginhost/host_test.go
create mode 100644 internal/pluginhost/http_bridge.go
create mode 100644 internal/pluginhost/loader_plugin.go
create mode 100644 internal/pluginhost/loader_unsupported.go
create mode 100644 internal/pluginhost/management.go
create mode 100644 internal/pluginhost/management_test.go
create mode 100644 internal/pluginhost/platform.go
create mode 100644 internal/pluginhost/platform_test.go
create mode 100644 internal/pluginhost/snapshot.go
create mode 100644 internal/pluginhost/test_helpers_test.go
create mode 100644 sdk/cliproxy/service_plugin_executor_test.go
create mode 100644 sdk/pluginapi/types.go
create mode 100644 sdk/pluginapi/types_test.go
create mode 100644 sdk/translator/plugin_hooks.go
diff --git a/.gitignore b/.gitignore
index 0ef1222973c..9f8bad4faac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ logs/*
conv/*
temp/*
refs/*
+plugins/*
# Storage backends
pgstore/*
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 4181faeca6b..ff7fb9e436f 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -25,6 +25,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/store"
@@ -126,6 +127,12 @@ func main() {
})
}
+ pluginHost := pluginhost.New()
+ if bootstrapCfg := loadPluginBootstrapConfig(pluginBootstrapConfigPath(os.Args[1:], DefaultConfigPath)); bootstrapCfg != nil {
+ pluginHost.ApplyConfig(context.Background(), bootstrapCfg)
+ pluginHost.RegisterCommandLineFlags(context.Background(), flag.CommandLine)
+ }
+
// Parse the command-line flags.
flag.Parse()
@@ -525,6 +532,15 @@ func main() {
// Register built-in access providers before constructing services.
configaccess.Register(&cfg.SDKConfig)
+ pluginHost.ApplyConfig(context.Background(), cfg)
+ if pluginHost.HasTriggeredCommandLineFlags() {
+ if exitCode, handled := pluginHost.ExecuteCommandLine(context.Background(), os.Args[0], os.Args[1:], configFilePath, flag.CommandLine); handled {
+ if exitCode != 0 {
+ os.Exit(exitCode)
+ }
+ return
+ }
+ }
// Handle different command modes based on the provided flags.
@@ -599,7 +615,7 @@ func main() {
password = localMgmtPassword
}
- cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password)
+ cancel, done := cmd.StartServiceBackgroundWithPluginHost(cfg, configFilePath, password, pluginHost)
client := tui.NewClient(cfg.Port, password)
ready := false
@@ -648,7 +664,63 @@ func main() {
} else if cfg.Home.Enabled {
log.Info("Home mode: remote model updates disabled")
}
- cmd.StartService(cfg, configFilePath, password)
+ cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost)
+ }
+ }
+}
+
+func pluginBootstrapConfigPath(args []string, defaultPath string) string {
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case arg == "--":
+ return defaultPluginBootstrapConfigPath(defaultPath)
+ case arg == "-config" || arg == "--config":
+ if i+1 < len(args) {
+ return args[i+1]
+ }
+ return defaultPluginBootstrapConfigPath(defaultPath)
+ case strings.HasPrefix(arg, "-config="):
+ return strings.TrimPrefix(arg, "-config=")
+ case strings.HasPrefix(arg, "--config="):
+ return strings.TrimPrefix(arg, "--config=")
}
}
+ return defaultPluginBootstrapConfigPath(defaultPath)
+}
+
+func defaultPluginBootstrapConfigPath(defaultPath string) string {
+ if strings.TrimSpace(defaultPath) != "" {
+ return defaultPath
+ }
+ wd, errGetwd := os.Getwd()
+ if errGetwd != nil {
+ return "config.yaml"
+ }
+ return filepath.Join(wd, "config.yaml")
+}
+
+func loadPluginBootstrapConfig(path string) *config.Config {
+ raw, errReadFile := os.ReadFile(path)
+ if errReadFile != nil {
+ if !errors.Is(errReadFile, os.ErrNotExist) {
+ log.Warnf("failed to read plugin bootstrap config: %v", errReadFile)
+ }
+ cfg := &config.Config{}
+ cfg.NormalizePluginsConfig()
+ return cfg
+ }
+ if len(strings.TrimSpace(string(raw))) == 0 {
+ cfg := &config.Config{}
+ cfg.NormalizePluginsConfig()
+ return cfg
+ }
+ cfg, errParseConfig := config.ParseConfigBytes(raw)
+ if errParseConfig != nil {
+ log.Warnf("failed to parse plugin bootstrap config: %v", errParseConfig)
+ cfg = &config.Config{}
+ cfg.NormalizePluginsConfig()
+ return cfg
+ }
+ return cfg
}
diff --git a/config.example.yaml b/config.example.yaml
index 4b30dd887ed..0070e9d3c28 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -49,6 +49,26 @@ pprof:
enable: false
addr: "127.0.0.1:8316"
+# Go dynamic plugins are trusted in-process code. They are disabled by default.
+# Build plugins with go build -buildmode=plugin for the target GOOS/GOARCH.
+# Plugin executors require a matching auth record with the same provider key.
+# If the same provider is configured as OpenAI-compatible, the native executor wins.
+# Plugin command-line flags and Management API routes are optional capabilities.
+# Existing native flags/routes and higher-priority plugin flags/routes cannot be replaced.
+# 插件列表 Management API 会读取插件 Metadata 中的 Logo 和 ConfigFields,用于管理端展示。
+# 单插件 enabled 只控制 plugins.configs..enabled,不会隐式修改全局 plugins.enabled。
+plugins:
+ enabled: false
+ dir: "plugins"
+ configs:
+ example:
+ enabled: true
+ priority: 1
+ config1: true
+ config2: "string"
+ config3: 3
+ mode: "safe" # enum example: safe, fast
+
# When true, disable high-overhead request logging and HTTP middleware features to reduce per-request memory usage under high concurrency.
commercial-mode: false
@@ -371,6 +391,9 @@ nonstream-keepalive-interval: 0
# xai:
# - name: "grok-4.3"
# alias: "grok-latest"
+# qoder: # plugin provider keys are supported for OAuth plugins
+# - name: "qmodel_latest"
+# alias: "qlatest"
# OAuth provider excluded models
# oauth-excluded-models:
diff --git a/examples/plugin/README.md b/examples/plugin/README.md
new file mode 100644
index 00000000000..e9c86fc31f6
--- /dev/null
+++ b/examples/plugin/README.md
@@ -0,0 +1,416 @@
+# Example Go Dynamic Plugin
+
+This directory is the reference skeleton for writing a provider plugin against the current `sdk/pluginapi` ABI. It is intentionally deterministic and small, but it demonstrates the host integration points that a real provider plugin needs: provider-owned auth parsing, model discovery, execution, HTTP bridging, request/response transforms, thinking config, usage observation, command-line flags, and diagnostic Management API routes.
+
+The example uses the provider key `plugin-example` and the plugin ID `example`.
+
+## What the sample implements
+
+`examples/plugin/main.go` exports the required Go plugin entrypoints:
+
+```go
+func Register(configYAML []byte) pluginapi.Plugin
+func Reconfigure(configYAML []byte) pluginapi.Plugin
+```
+
+`Register` is called the first time the host loads the `.so` file. `Reconfigure` is called on config hot reload for a plugin that has already been opened and is still enabled. Both functions must return a `pluginapi.Plugin` value with valid metadata and at least one capability.
+
+Required metadata fields:
+
+- `Metadata.Name`
+- `Metadata.Version`
+- `Metadata.Author`
+- `Metadata.GitHubRepository`
+
+The sample declares these capabilities:
+
+| Capability | Interface | What this sample shows |
+| --- | --- | --- |
+| Static and per-auth models | `ModelProvider` | Returns `plugin-example-model` for both static registration and auth-bound discovery. |
+| Auth parsing and refresh | `AuthProvider` | Parses auth JSON whose `type` is `plugin-example`, exposes non-interactive login methods, and returns refreshed storage unchanged. |
+| Frontend auth | `FrontendAuthProvider` | Accepts inbound requests only when `X-Plugin-Example: allow` is present. |
+| Provider execution | `ProviderExecutor` | Implements non-streaming execution, streaming execution, token counting, and raw HTTP passthrough. |
+| Executor model scope | `ExecutorModelScope` | Uses `pluginapi.ExecutorModelScopeBoth` so the executor can serve static models and OAuth/auth-bound models. |
+| Request conversion | `RequestTranslator`, `RequestNormalizer` | Shows where canonical and provider-specific request payload transforms live. |
+| Response conversion | `ResponseTranslator`, `ResponseBeforeTranslator`, `ResponseAfterTranslator` | Shows the response transform hooks before and after native translation. |
+| Thinking config | `ThinkingApplier` | Receives canonical thinking config and writes provider-specific payload fields. |
+| Usage observation | `UsagePlugin` | Counts completed usage records in memory for diagnostics. |
+| Command-line flags | `CommandLinePlugin` | Adds plugin-owned CLI flags and receives all parsed flag values at execution time. |
+| Management API | `ManagementAPI` | Adds exact diagnostic routes under `/v0/management/`. |
+
+`ModelRegistrar` is still present in `sdk/pluginapi` for simple model-only plugins. New provider plugins should normally prefer `ModelProvider`, because it supports both static model metadata and per-auth model discovery through the same provider-native path.
+
+## Platform and ABI rules
+
+CLIProxyAPI loads standard Go plugins built with:
+
+```bash
+go build -buildmode=plugin
+```
+
+The Go standard `plugin` package is supported on Linux, FreeBSD, and macOS. On unsupported platforms, plugin loading is disabled and the service continues with native logic.
+
+Go plugin ABI compatibility is strict. Build the plugin for the target service binary with the same:
+
+- `GOOS` and `GOARCH`
+- CPU feature target, when you use CPU-specific directories
+- Go toolchain version
+- build tags and CGO settings
+- module path
+- shared dependency versions
+
+If any of these differ, `plugin.Open` can fail or the loaded symbols can have incompatible types.
+
+## Build and install
+
+Build from the repository root:
+
+```bash
+mkdir -p plugins/$(go env GOOS)/$(go env GOARCH)
+go build -buildmode=plugin -o plugins/$(go env GOOS)/$(go env GOARCH)/example.so ./examples/plugin
+```
+
+The plugin ID is the `.so` file basename without the final `.so` suffix. `example.so` maps to `plugins.configs.example`.
+
+Plugin IDs must match this shape:
+
+```text
+[A-Za-z0-9][A-Za-z0-9._-]{0,127}
+```
+
+The host searches these directories in order and keeps the first `.so` found for each plugin ID:
+
+```text
+plugins//-/*.so
+plugins///*.so
+plugins/*.so
+```
+
+For `amd64`, `` is selected from CPU capabilities as `v4`, `v3`, `v2`, or `v1`. CPU-specific builds therefore belong under paths such as `plugins/linux/amd64-v3/`.
+
+Replacing an already opened `.so` file requires a process restart. Go plugins cannot be unloaded from the current process.
+
+## Configure the host
+
+Dynamic plugins are disabled by default. Enable them in `config.yaml`:
+
+```yaml
+plugins:
+ enabled: true
+ dir: "plugins"
+ configs:
+ example:
+ enabled: true
+ priority: 1
+ config1: true
+ config2: "string"
+ config3: 3
+```
+
+Configuration rules:
+
+- `plugins.enabled=false` skips all plugin loading and execution.
+- `plugins.dir` defaults to `plugins` when omitted or empty.
+- `plugins.configs.` is the per-plugin YAML subtree passed to `Register` or `Reconfigure`.
+- `enabled` defaults to `true` for a configured plugin instance.
+- `priority` defaults to `0`.
+- The host injects normalized `enabled` and `priority` into the YAML bytes passed to the plugin when they are missing.
+- Higher `priority` plugins run before lower `priority` plugins. Equal priorities are ordered by plugin ID.
+
+Hot reload updates the runtime plugin snapshot. Already opened plugin binaries stay in memory, but disabled plugins are removed from the active capability set. If a loaded plugin remains enabled, the host calls `Reconfigure(configYAML)` instead of `Register(configYAML)`.
+
+## 插件 metadata、Logo 和配置字段
+
+插件通过 `pluginapi.Metadata` 向宿主管理接口提供展示信息:
+
+```go
+type Metadata struct {
+ Name string
+ Version string
+ Author string
+ GitHubRepository string
+ Logo string
+ ConfigFields []ConfigField
+}
+```
+
+`Logo` 是给管理端展示的字符串。宿主只透传该值,不校验它是 URL、data URI、文件路径或其他格式。
+
+`ConfigFields` 描述 `plugins.configs.` 下的插件自定义配置字段。它只用于管理端展示和生成配置表单,宿主不会用它校验插件配置。字段结构如下:
+
+```go
+type ConfigField struct {
+ Name string
+ Type ConfigFieldType
+ EnumValues []string
+ Description string
+}
+```
+
+支持的 `ConfigFieldType` 值包括 `string`、`number`、`integer`、`boolean`、`enum`、`array` 和 `object`。当类型是 `enum` 时,`EnumValues` 应列出所有可选值。
+
+## Add auth material
+
+Executor-backed plugin models need a matching auth record so the scheduler can select the provider. The auth `type` must match the provider returned by `ModelProvider`, `AuthProvider.Identifier`, and `ProviderExecutor.Identifier`.
+
+For this sample:
+
+```json
+{
+ "type": "plugin-example",
+ "api_key": "plugin-or-upstream-secret"
+}
+```
+
+Place the file under the configured auth directory, for example:
+
+```text
+auths/plugin-example.json
+```
+
+Do not configure `base_url`, `compat_name`, or an `openai-compatibility` entry for the same provider unless you intentionally want the native OpenAI-compatible executor to own that provider. Native executors always win over plugin executors.
+
+Auth provider behavior in this sample:
+
+- `ParseAuth` accepts JSON offered by the host auth loader and returns `pluginapi.AuthData`.
+- `StartLogin` and `PollLogin` are present but return non-interactive errors in this sample.
+- `RefreshAuth` returns the current auth data unchanged.
+- A real plugin can return `AuthData` from command-line execution or login polling; the host persists it through the normal auth store.
+
+## Model registration and executor scope
+
+The current provider-native model path is `ModelProvider`:
+
+- `StaticModels` returns provider models that are available without inspecting a specific auth record.
+- `ModelsForAuth` returns models discovered for one selected auth record and can return an `AuthUpdate` when discovery refreshes persisted provider state.
+
+The host applies normal model processing after plugin discovery: aliases, excluded models, prefixes, registry reconciliation, and scheduler rules.
+
+`ExecutorModelScope` controls which model-registration paths are allowed when `Capabilities.Executor` is present:
+
+| Scope | Meaning |
+| --- | --- |
+| `pluginapi.ExecutorModelScopeBoth` | The executor supports both static models and auth-bound OAuth-style models. This is the default when the scope is empty or invalid. |
+| `pluginapi.ExecutorModelScopeStatic` | The executor supports only non-OAuth static models. `ModelsForAuth` is skipped for executor-backed registration. |
+| `pluginapi.ExecutorModelScopeOAuth` | The executor supports only auth-bound models. Static executor model clients are not registered. |
+
+Use the narrowest scope that matches the provider. This avoids exposing models through the wrong registration path.
+
+## Execution flow
+
+A plugin executor runs only when:
+
+- global plugins are enabled,
+- the specific plugin is enabled,
+- the plugin has not been panic-fused,
+- the selected auth provider matches the executor provider,
+- no native executor owns the same provider or selected model,
+- and no higher-priority plugin has already claimed the same provider/model.
+
+`ProviderExecutor` receives a `pluginapi.ExecutorRequest` with:
+
+- `Model`: the host-resolved model identifier after alias handling,
+- `Format`: the target provider format,
+- `SourceFormat`: the original client format,
+- `OriginalRequest`: the raw client payload,
+- `Payload`: the translated provider payload,
+- `StorageJSON`, `AuthMetadata`, and `AuthAttributes`: selected auth state,
+- `HTTPClient`: the host HTTP bridge.
+
+Executor upstream HTTP calls must use `req.HTTPClient.Do` or `req.HTTPClient.DoStream`. Do not build a separate proxy-aware client inside the plugin. The host bridge preserves host transport policy and lets `request-log` capture the outbound upstream request and the raw upstream response before plugin-side translation.
+
+The sample methods are intentionally deterministic:
+
+- `Execute` returns one OpenAI-shaped JSON response.
+- `ExecuteStream` emits one stream chunk and closes the channel.
+- `CountTokens` returns zero token counts.
+- `HttpRequest` forwards raw HTTP through the host bridge.
+
+For real providers, use `req.Model` for provider routing and model rewriting decisions. Do not assume every protocol payload has a trustworthy top-level `model` field.
+
+## Translators, normalizers, and thinking
+
+Native logic is authoritative. Plugin transforms fill gaps instead of replacing built-in provider support.
+
+Request and response behavior:
+
+- Request normalizers run from higher priority to lower priority and are chained.
+- Response normalizers before and after translation follow the same priority ordering.
+- Request translators and response translators run only when no native translator exists for the format pair.
+- Only the highest-priority plugin translator is selected for a missing translation path.
+
+Thinking behavior:
+
+- The host parses, normalizes, and validates thinking config centrally.
+- `ThinkingApplier` receives canonical `pluginapi.ThinkingConfig`.
+- A plugin thinking applier only applies provider keys that are not owned by native thinking providers.
+- When a plugin is disabled, removed from the active snapshot, or panic-fused, its thinking applier is removed.
+
+The sample writes these provider-specific fields into the payload:
+
+```json
+{
+ "plugin_example_thinking": {
+ "mode": "budget",
+ "budget": 1024,
+ "level": ""
+ }
+}
+```
+
+## Command-line flags
+
+The sample declares two plugin-owned flags:
+
+```bash
+./cli-proxy-api -config config.yaml -plugin-example-command
+./cli-proxy-api -config config.yaml -plugin-example-command -plugin-example-message "custom message"
+```
+
+Plugin command-line flags are registered before normal flag parsing so they appear in `-help`.
+
+Rules:
+
+- Supported flag types are `bool`, `string`, `int`, `int64`, `float64`, and `duration`.
+- Flag names cannot start with `-`, contain whitespace, contain `=`, or be `help` / `h`.
+- Native flags cannot be replaced.
+- Higher-priority plugin flags cannot be replaced by lower-priority plugins.
+- When any plugin-owned flag is provided, the host passes every argument, every visible parsed flag, and the triggered plugin-owned flags to `ExecuteCommandLine`.
+- If final config disables global plugins or this plugin, the flag can still be parsed but plugin execution is skipped.
+- If `ExecuteCommandLine` returns `Auths`, the host persists them through the configured auth store and appends saved paths to stdout.
+
+## Management API routes
+
+宿主提供原生插件管理接口:
+
+```text
+GET /v0/management/plugins
+PATCH /v0/management/plugins/{pluginID}/enabled
+PUT /v0/management/plugins/{pluginID}/config
+PATCH /v0/management/plugins/{pluginID}/config
+```
+
+`GET /v0/management/plugins` 会按宿主当前扫描规则列出插件目录中的 `.so` 文件,也会列出只存在于 `plugins.configs` 中的配置项。已成功注册的插件会返回 `logo`、`config_fields` 和 `supports_oauth`。
+
+如果插件注册的 Management API 路由是 `GET` 方法,并且 `ManagementRoute.Menu` 不为空,`GET /v0/management/plugins` 会在该插件条目的 `menus` 数组中返回 `path`、`menu` 和 `description`。`Menu` 用作管理端菜单名称,`Description` 用作菜单说明。
+
+`PATCH /v0/management/plugins/{pluginID}/enabled` 只更新 `plugins.configs..enabled`,不会隐式修改全局 `plugins.enabled`。因此当 `plugins.enabled=false` 时,单插件可以显示为启用,但实际运行时仍不会加载插件能力。
+
+`PUT /v0/management/plugins/{pluginID}/config` 会替换整个插件配置子树。`PATCH /v0/management/plugins/{pluginID}/config` 会做浅层合并;请求中的 `null` 会删除对应字段。
+
+The sample routes are:
+
+```text
+GET /v0/management/plugins/example/status
+GET /v0/management/plugins/example/capabilities
+```
+
+Management API route rules:
+
+- Routes are exact method/path matches under `/v0/management/`.
+- A plugin may return relative paths such as `/plugins/example/status`; the host resolves them under `/v0/management`.
+- Paths cannot contain whitespace, `:`, or `*`.
+- Native Management API routes cannot be replaced.
+- Higher-priority plugin routes cannot be replaced by lower-priority plugins.
+- Routes require the normal Management API authentication.
+- Routes are unavailable when Home mode or Management API availability disables local Management routes.
+- The route table is rebuilt on config reload.
+
+## Frontend authentication
+
+The sample `FrontendAuthProvider` accepts a request only when this header is present:
+
+```text
+X-Plugin-Example: allow
+```
+
+The registered frontend provider key is namespaced by the host as:
+
+```text
+plugin::
+```
+
+For this sample, the provider identifier is `plugin-example`, so downstream auth metadata is kept separate from native frontend auth providers.
+
+## Usage plugin
+
+`UsagePlugin.HandleUsage` receives completed usage records after request execution. The sample increments an in-memory counter that is visible through the diagnostic Management API status route.
+
+Usage records include provider, executor type, model, alias, selected auth, source, requested reasoning effort, service tier, latency, TTFT, failure details, token counters, and selected response headers.
+
+Keep this hook lightweight. Usage dispatch is part of the request accounting path, and the host will recover from panics by fusing the plugin.
+
+## Priority, native precedence, and panic fuse
+
+The plugin system is additive:
+
+- Native providers, executors, translators, thinking appliers, flags, and Management routes have priority over plugins.
+- Plugins fill provider gaps and add plugin-owned surfaces.
+- Higher-priority plugins are considered before lower-priority plugins.
+- Plugin executors do not override native executors.
+- Plugin Management routes and command-line flags do not override native routes or flags.
+
+Every lifecycle and capability call is protected by panic recovery. If a plugin panics during `Register`, `Reconfigure`, or any capability method, the host marks that plugin fused for the current process lifetime. A fused plugin is no longer called, even if config reload enables it again. Restart the service to clear the fused state.
+
+Go plugins are trusted in-process code, not a sandbox. Panic recovery cannot prevent a plugin from calling `os.Exit`, mutating shared process state, starting background work, or leaking secrets. Treat plugin binaries as code with the same trust level as the service binary.
+
+## Extending this sample
+
+When turning this sample into a real provider plugin:
+
+1. Keep `package main` and the exported `Register` / `Reconfigure` functions.
+2. Rename metadata, provider keys, model IDs, command-line flags, and Management paths consistently.
+3. Build the `.so` filename to match the desired plugin ID.
+4. Choose the narrowest `ExecutorModelScope`.
+5. Use `HostHTTPClient` for all upstream provider calls.
+6. Return `AuthData` instead of writing directly to auth storage when the host is already managing login or command-line persistence.
+7. Keep provider-specific payload rewriting inside the plugin boundary.
+8. Avoid logging secrets, tokens, raw auth JSON, or signed request headers.
+9. Keep background goroutines tied to context or explicit lifecycle state, because Go plugins cannot be unloaded.
+10. Add plugin-local tests and build the plugin with the same toolchain as the service.
+
+## Verification
+
+Compile the sample plugin:
+
+```bash
+go build -buildmode=plugin -o /tmp/cliproxy-example-plugin.so ./examples/plugin && rm -f /tmp/cliproxy-example-plugin.so
+```
+
+Check Markdown whitespace after editing docs:
+
+```bash
+git diff --check -- examples/plugin/README.md examples/plugin/README_CN.md
+```
+
+If you changed Go code as part of a plugin implementation, also run the repository-required server compile:
+
+```bash
+go build -o test-output ./cmd/server && rm test-output
+```
+
+## Troubleshooting
+
+`plugin.Open` fails with a type or version error:
+
+Build the plugin with the same Go version, module path, build tags, and dependency versions as the service binary.
+
+The plugin is not loaded:
+
+Confirm `plugins.enabled=true`, the `.so` file is under the selected plugin directory, the plugin ID is valid, and the per-plugin config is not disabled.
+
+The plugin loads but no capability is active:
+
+Confirm `Register` or `Reconfigure` returns valid metadata and at least one non-nil capability.
+
+The executor is not used:
+
+Confirm a matching auth record exists, the auth `type` matches the provider key, the executor scope allows the desired model path, and no native executor owns the provider or model.
+
+The command-line flag appears but does nothing:
+
+Confirm the final loaded config still enables global plugins and this plugin. CLI flags are registered before final config dispatch, but execution is checked against the final active plugin snapshot.
+
+The Management route returns 404:
+
+Confirm local Management API routes are available, the route path is exact, the plugin is enabled, and no native or higher-priority route claimed the same method/path.
diff --git a/examples/plugin/README_CN.md b/examples/plugin/README_CN.md
new file mode 100644
index 00000000000..aaaabbe19d8
--- /dev/null
+++ b/examples/plugin/README_CN.md
@@ -0,0 +1,416 @@
+# Go 动态插件示例
+
+这个目录是基于当前 `sdk/pluginapi` ABI 编写 provider 插件的参考骨架。它保持确定性和小规模实现,但覆盖真实 provider 插件通常需要接入的宿主能力:provider 自有 auth 解析、模型发现、执行器、HTTP bridge、请求/响应转换、thinking 配置、usage 观察、命令行参数和诊断 Management API 路由。
+
+示例使用 provider key `plugin-example`,插件 ID 为 `example`。
+
+## 示例实现内容
+
+`examples/plugin/main.go` 导出了 Go 插件必须提供的入口函数:
+
+```go
+func Register(configYAML []byte) pluginapi.Plugin
+func Reconfigure(configYAML []byte) pluginapi.Plugin
+```
+
+宿主第一次加载 `.so` 文件时调用 `Register`。如果插件已经打开并且仍处于启用状态,配置热重载时调用 `Reconfigure`。两个函数都必须返回包含有效 metadata 且至少带有一个能力的 `pluginapi.Plugin`。
+
+必须填写的 metadata 字段:
+
+- `Metadata.Name`
+- `Metadata.Version`
+- `Metadata.Author`
+- `Metadata.GitHubRepository`
+
+这个示例声明了以下能力:
+
+| 能力 | 接口 | 示例展示内容 |
+| --- | --- | --- |
+| 静态模型和按 auth 发现模型 | `ModelProvider` | 为静态注册和 auth 绑定发现都返回 `plugin-example-model`。 |
+| Auth 解析和刷新 | `AuthProvider` | 解析 `type` 为 `plugin-example` 的 auth JSON,暴露非交互式登录方法,并原样返回刷新后的存储数据。 |
+| 前端鉴权 | `FrontendAuthProvider` | 仅当请求包含 `X-Plugin-Example: allow` 时接受前端请求。 |
+| Provider 执行器 | `ProviderExecutor` | 实现非流式执行、流式执行、token 统计和原始 HTTP 透传。 |
+| 执行器模型范围 | `ExecutorModelScope` | 使用 `pluginapi.ExecutorModelScopeBoth`,表示执行器同时支持静态模型和 OAuth/auth 绑定模型。 |
+| 请求转换 | `RequestTranslator`, `RequestNormalizer` | 展示 canonical 请求和 provider 专属请求 payload 的转换位置。 |
+| 响应转换 | `ResponseTranslator`, `ResponseBeforeTranslator`, `ResponseAfterTranslator` | 展示原生翻译前后的响应转换 hook。 |
+| Thinking 配置 | `ThinkingApplier` | 接收 canonical thinking 配置,并写入 provider 专属 payload 字段。 |
+| Usage 观察 | `UsagePlugin` | 在内存中统计已完成 usage record,供诊断接口展示。 |
+| 命令行参数 | `CommandLinePlugin` | 添加插件自有 CLI 参数,并在执行时接收全部解析后的 flag 值。 |
+| Management API | `ManagementAPI` | 在 `/v0/management/` 下添加精确匹配的诊断路由。 |
+
+`sdk/pluginapi` 中仍保留 `ModelRegistrar`,用于简单的纯模型插件。新的 provider 插件通常应优先使用 `ModelProvider`,因为它通过同一条 provider-native 路径同时支持静态模型元数据和按 auth 发现模型。
+
+## 平台和 ABI 规则
+
+CLIProxyAPI 加载使用以下命令构建的标准 Go 插件:
+
+```bash
+go build -buildmode=plugin
+```
+
+Go 标准库 `plugin` 包支持 Linux、FreeBSD 和 macOS。在不支持的平台上,插件加载会被禁用,服务会继续使用原生逻辑运行。
+
+Go plugin ABI 兼容性非常严格。请使用与目标服务二进制一致的环境构建插件:
+
+- `GOOS` 和 `GOARCH`
+- 使用 CPU 专属目录时的 CPU feature target
+- Go 工具链版本
+- build tags 和 CGO 设置
+- module path
+- 共享依赖版本
+
+如果这些条件不一致,`plugin.Open` 可能失败,或者加载出的符号类型不兼容。
+
+## 构建和安装
+
+在仓库根目录构建:
+
+```bash
+mkdir -p plugins/$(go env GOOS)/$(go env GOARCH)
+go build -buildmode=plugin -o plugins/$(go env GOOS)/$(go env GOARCH)/example.so ./examples/plugin
+```
+
+插件 ID 来自 `.so` 文件名去掉最后的 `.so` 后缀。`example.so` 对应 `plugins.configs.example`。
+
+插件 ID 必须符合以下格式:
+
+```text
+[A-Za-z0-9][A-Za-z0-9._-]{0,127}
+```
+
+宿主按以下顺序搜索目录,并对每个插件 ID 保留第一个发现的 `.so`:
+
+```text
+plugins//