Skip to content

Commit a1bd278

Browse files
committed
fix: 兼容最新 Codex 用量接口
1 parent 2635d37 commit a1bd278

5 files changed

Lines changed: 223 additions & 20 deletions

File tree

backend/internal/providers/codex/adapter.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
const (
1313
// Codex ChatGPT backend gates some models (e.g. gpt-5.5) behind minimum Codex versions.
1414
// Use a recent Codex CLI version string to avoid unnecessary model gating.
15-
codexClientVersion = "0.125.0"
15+
codexClientVersion = "0.142.5"
1616
codexOriginator = "codex_cli_rs"
1717
codexUserAgent = "codex_cli_rs/" + codexClientVersion + " (ai-gate)"
1818
)
@@ -62,15 +62,12 @@ func (a *Adapter) BuildResponsesEndpointRequest(ctx context.Context, credential
6262
}
6363

6464
func (a *Adapter) BuildUsageRequest(ctx context.Context, credential string, accountID string) (*http.Request, error) {
65-
base, err := url.Parse(a.baseURL)
65+
usageURL, err := a.buildUsageURL()
6666
if err != nil {
6767
return nil, err
6868
}
69-
base.Path = "/backend-api/wham/usage"
70-
base.RawQuery = ""
71-
base.Fragment = ""
7269

73-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil)
70+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageURL, nil)
7471
if err != nil {
7572
return nil, err
7673
}
@@ -82,6 +79,37 @@ func (a *Adapter) BuildUsageRequest(ctx context.Context, credential string, acco
8279
return req, nil
8380
}
8481

82+
func (a *Adapter) buildUsageURL() (string, error) {
83+
base, err := url.Parse(a.baseURL)
84+
if err != nil {
85+
return "", err
86+
}
87+
base.RawQuery = ""
88+
base.Fragment = ""
89+
90+
if isChatGPTUsageBase(base) {
91+
base.Path = chatGPTBackendPath(base.Path) + "/wham/usage"
92+
return base.String(), nil
93+
}
94+
95+
base.Path = strings.TrimRight(base.Path, "/") + "/api/codex/usage"
96+
return base.String(), nil
97+
}
98+
99+
func isChatGPTUsageBase(base *url.URL) bool {
100+
host := strings.ToLower(base.Hostname())
101+
return host == "chatgpt.com" ||
102+
host == "chat.openai.com" ||
103+
strings.Contains(base.Path, "/backend-api")
104+
}
105+
106+
func chatGPTBackendPath(path string) string {
107+
if idx := strings.Index(path, "/backend-api"); idx >= 0 {
108+
return path[:idx] + "/backend-api"
109+
}
110+
return "/backend-api"
111+
}
112+
85113
func strconvTimeID() string {
86114
return time.Now().UTC().Format("20060102T150405.000000000")
87115
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package codex
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestAdapterBuildUsageRequestUsesLatestClientVersionAndChatGPTUsagePath(t *testing.T) {
9+
t.Parallel()
10+
11+
req, err := NewAdapter("https://chatgpt.com/backend-api/codex").BuildUsageRequest(
12+
context.Background(),
13+
"token-1",
14+
"acct-1",
15+
)
16+
if err != nil {
17+
t.Fatalf("BuildUsageRequest returned error: %v", err)
18+
}
19+
if req.URL.String() != "https://chatgpt.com/backend-api/wham/usage" {
20+
t.Fatalf("url = %q, want ChatGPT WHAM usage endpoint", req.URL.String())
21+
}
22+
if got := req.Header.Get("User-Agent"); got != "codex_cli_rs/0.142.5 (ai-gate)" {
23+
t.Fatalf("User-Agent = %q, want latest Codex client version", got)
24+
}
25+
}
26+
27+
func TestAdapterBuildUsageRequestUsesCodexAPIPathForNonChatGPTBase(t *testing.T) {
28+
t.Parallel()
29+
30+
req, err := NewAdapter("https://codex.example.test/root").BuildUsageRequest(
31+
context.Background(),
32+
"token-1",
33+
"acct-1",
34+
)
35+
if err != nil {
36+
t.Fatalf("BuildUsageRequest returned error: %v", err)
37+
}
38+
if req.URL.String() != "https://codex.example.test/root/api/codex/usage" {
39+
t.Fatalf("url = %q, want Codex API usage endpoint", req.URL.String())
40+
}
41+
}

backend/internal/usagedrv/builtin/builtin_test.go

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http/httptest"
99
"strings"
1010
"testing"
11+
"time"
1112

1213
"github.com/gcssloop/codex-router/backend/internal/accountdrv"
1314
"github.com/gcssloop/codex-router/backend/internal/accounts"
@@ -33,7 +34,7 @@ func TestOpenAIOfficialDriverFetchParsesUsageLimits(t *testing.T) {
3334
driver := builtin.NewOpenAIOfficialDriver(http.DefaultClient)
3435
result, err := driver.Fetch(context.Background(), accounts.Account{
3536
ProviderType: accounts.ProviderOpenAIOfficial,
36-
BaseURL: server.URL,
37+
BaseURL: server.URL + "/backend-api/codex",
3738
}, accountdrv.ResolvedCredential{
3839
AccessToken: "at-token",
3940
Metadata: map[string]any{"account_id": "acct-1"},
@@ -67,6 +68,96 @@ func TestOpenAIOfficialDriverFetchParsesUsageLimits(t *testing.T) {
6768
}
6869
}
6970

71+
func TestOpenAIOfficialDriverFetchParsesCurrentCodexRateLimits(t *testing.T) {
72+
t.Parallel()
73+
74+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
if r.URL.Path != "/backend-api/wham/usage" {
76+
http.NotFound(w, r)
77+
return
78+
}
79+
w.Header().Set("Content-Type", "application/json")
80+
_, _ = w.Write([]byte(`{
81+
"plan_type":"pro",
82+
"rate_limit":{
83+
"allowed":true,
84+
"limit_reached":false,
85+
"primary_window":{
86+
"used_percent":52,
87+
"limit_window_seconds":18000,
88+
"reset_after_seconds":3600,
89+
"reset_at":1767205800
90+
},
91+
"secondary_window":{
92+
"used_percent":51,
93+
"limit_window_seconds":604800,
94+
"reset_after_seconds":86400,
95+
"reset_at":1783350300
96+
}
97+
},
98+
"credits":{"has_credits":true,"unlimited":false,"balance":"0"},
99+
"additional_rate_limits":[
100+
{
101+
"limit_name":"codex_other",
102+
"metered_feature":"codex_other",
103+
"rate_limit":{
104+
"allowed":true,
105+
"limit_reached":false,
106+
"primary_window":{
107+
"used_percent":88,
108+
"limit_window_seconds":1800,
109+
"reset_after_seconds":600,
110+
"reset_at":1735693200
111+
}
112+
}
113+
}
114+
],
115+
"rate_limit_reached_type":{"type":"workspace_member_usage_limit_reached"},
116+
"rate_limit_reset_credits":{"available_count":1}
117+
}`))
118+
}))
119+
defer server.Close()
120+
121+
driver := builtin.NewOpenAIOfficialDriver(http.DefaultClient)
122+
result, err := driver.Fetch(context.Background(), accounts.Account{
123+
ProviderType: accounts.ProviderOpenAIOfficial,
124+
BaseURL: server.URL + "/backend-api/codex",
125+
}, accountdrv.ResolvedCredential{
126+
AccessToken: "at-token",
127+
Metadata: map[string]any{"account_id": "acct-1"},
128+
})
129+
if err != nil {
130+
t.Fatalf("Fetch returned error: %v", err)
131+
}
132+
if result.Limits.PrimaryUsedPercent == nil || *result.Limits.PrimaryUsedPercent != 52 {
133+
t.Fatalf("PrimaryUsedPercent = %#v, want 52", result.Limits.PrimaryUsedPercent)
134+
}
135+
if result.Limits.SecondaryUsedPercent == nil || *result.Limits.SecondaryUsedPercent != 51 {
136+
t.Fatalf("SecondaryUsedPercent = %#v, want 51", result.Limits.SecondaryUsedPercent)
137+
}
138+
if result.Limits.RPMRemaining == nil || *result.Limits.RPMRemaining != 48 {
139+
t.Fatalf("RPMRemaining = %#v, want 48", result.Limits.RPMRemaining)
140+
}
141+
if result.Limits.TPMRemaining == nil || *result.Limits.TPMRemaining != 49 {
142+
t.Fatalf("TPMRemaining = %#v, want 49", result.Limits.TPMRemaining)
143+
}
144+
wantPrimaryReset := time.Unix(1767205800, 0).UTC()
145+
if result.Limits.PrimaryResetsAt == nil || !result.Limits.PrimaryResetsAt.Equal(wantPrimaryReset) {
146+
t.Fatalf("PrimaryResetsAt = %#v, want %s", result.Limits.PrimaryResetsAt, wantPrimaryReset)
147+
}
148+
wantSecondaryReset := time.Unix(1783350300, 0).UTC()
149+
if result.Limits.SecondaryResetsAt == nil || !result.Limits.SecondaryResetsAt.Equal(wantSecondaryReset) {
150+
t.Fatalf("SecondaryResetsAt = %#v, want %s", result.Limits.SecondaryResetsAt, wantSecondaryReset)
151+
}
152+
if got, ok := result.Meta["plan_type"].(string); !ok || got != "pro" {
153+
t.Fatalf("meta.plan_type = %#v, want pro", result.Meta["plan_type"])
154+
}
155+
reachedType, ok := result.Meta["rate_limit_reached_type"].(map[string]any)
156+
if !ok || reachedType["type"] != "workspace_member_usage_limit_reached" {
157+
t.Fatalf("meta.rate_limit_reached_type = %#v, want workspace_member_usage_limit_reached", result.Meta["rate_limit_reached_type"])
158+
}
159+
}
160+
70161
func TestOpenAIOfficialDriverFetchClassifiesErrors(t *testing.T) {
71162
t.Parallel()
72163

backend/internal/usagedrv/builtin/openai_official.go

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,18 @@ func parseOfficialRawUsage(raw []byte) (usagedrv.RawUsageResult, bool) {
124124
return usagedrv.RawUsageResult{}, false
125125
}
126126

127-
rateLimit, _ := payload["rate_limit"].(map[string]any)
128-
primary, _ := rateLimit["primary_window"].(map[string]any)
129-
secondary, _ := rateLimit["secondary_window"].(map[string]any)
130-
credits, _ := payload["credits"].(map[string]any)
127+
rateLimit := asMap(payload["rate_limit"])
128+
credits := asMap(payload["credits"])
129+
if rateLimits := asMap(payload["rate_limits"]); len(rateLimits) > 0 {
130+
if len(rateLimit) == 0 {
131+
rateLimit = rateLimits
132+
}
133+
if len(credits) == 0 {
134+
credits = asMap(rateLimits["credits"])
135+
}
136+
}
137+
primary := firstMap(rateLimit["primary_window"], rateLimit["primary"])
138+
secondary := firstMap(rateLimit["secondary_window"], rateLimit["secondary"])
131139

132140
primaryUsed := asFloat(primary["used_percent"])
133141
secondaryUsed := asFloat(secondary["used_percent"])
@@ -143,8 +151,17 @@ func parseOfficialRawUsage(raw []byte) (usagedrv.RawUsageResult, bool) {
143151

144152
primaryRemaining := maxFloat(100-primaryUsed, 0)
145153
secondaryRemaining := maxFloat(100-secondaryUsed, 0)
146-
primaryResetsAt := unixSecondsPtr(int64(asFloat(primary["reset_at"])))
147-
secondaryResetsAt := unixSecondsPtr(int64(asFloat(secondary["reset_at"])))
154+
primaryResetsAt := unixSecondsPtr(int64(firstFloat(primary["reset_at"], primary["resets_at"])))
155+
secondaryResetsAt := unixSecondsPtr(int64(firstFloat(secondary["reset_at"], secondary["resets_at"])))
156+
meta := map[string]any{
157+
"allowed": allowed,
158+
"limit_reached": limitReached,
159+
"has_credits": hasCredits,
160+
"unlimited": unlimited,
161+
}
162+
copyMetaValue(meta, payload, "plan_type")
163+
copyMetaValue(meta, payload, "rate_limit_reached_type")
164+
copyMetaValue(meta, payload, "rate_limit_reset_credits")
148165

149166
return usagedrv.RawUsageResult{
150167
Source: "remote",
@@ -159,12 +176,7 @@ func parseOfficialRawUsage(raw []byte) (usagedrv.RawUsageResult, bool) {
159176
PrimaryResetsAt: primaryResetsAt,
160177
SecondaryResetsAt: secondaryResetsAt,
161178
},
162-
Meta: map[string]any{
163-
"allowed": allowed,
164-
"limit_reached": limitReached,
165-
"has_credits": hasCredits,
166-
"unlimited": unlimited,
167-
},
179+
Meta: meta,
168180
}, true
169181
}
170182

@@ -199,6 +211,37 @@ func unixSecondsPtr(seconds int64) *time.Time {
199211
return &value
200212
}
201213

214+
func asMap(value any) map[string]any {
215+
if typed, ok := value.(map[string]any); ok {
216+
return typed
217+
}
218+
return nil
219+
}
220+
221+
func firstMap(values ...any) map[string]any {
222+
for _, value := range values {
223+
if typed := asMap(value); len(typed) > 0 {
224+
return typed
225+
}
226+
}
227+
return nil
228+
}
229+
230+
func firstFloat(values ...any) float64 {
231+
for _, value := range values {
232+
if parsed := asFloat(value); parsed != 0 {
233+
return parsed
234+
}
235+
}
236+
return 0
237+
}
238+
239+
func copyMetaValue(meta map[string]any, payload map[string]any, key string) {
240+
if value, ok := payload[key]; ok {
241+
meta[key] = value
242+
}
243+
}
244+
202245
func asFloat(value any) float64 {
203246
switch typed := value.(type) {
204247
case float64:

references/openai-codex

Submodule openai-codex updated 3456 files

0 commit comments

Comments
 (0)