Skip to content

Commit c4773fa

Browse files
committed
fix: handle account errors and gpt55 response limit
1 parent 685caf2 commit c4773fa

14 files changed

Lines changed: 234 additions & 11 deletions

admin/batch_test_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ func TestShouldMarkBatchTestAccountError(t *testing.T) {
1818
body: []byte(`{"error":{"code":"unsupported_country_region_territory"}}`),
1919
want: true,
2020
},
21+
{
22+
name: "payment required deactivated workspace is account scoped",
23+
statusCode: http.StatusPaymentRequired,
24+
body: []byte(`{"detail":{"code":"deactivated_workspace"}}`),
25+
want: true,
26+
},
2127
{
2228
name: "invalid grant bad request is account scoped",
2329
statusCode: http.StatusBadRequest,

admin/handler.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ type accountResponse struct {
287287
Email string `json:"email"`
288288
PlanType string `json:"plan_type"`
289289
Status string `json:"status"`
290+
ErrorMessage string `json:"error_message,omitempty"`
290291
ATOnly bool `json:"at_only"`
291292
HealthTier string `json:"health_tier"`
292293
SchedulerScore float64 `json:"scheduler_score"`
@@ -388,6 +389,7 @@ func (h *Handler) ListAccounts(c *gin.Context) {
388389
Email: row.GetCredential("email"),
389390
PlanType: row.GetCredential("plan_type"),
390391
Status: row.Status,
392+
ErrorMessage: row.ErrorMessage,
391393
ATOnly: row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
392394
ProxyURL: row.ProxyURL,
393395
Enabled: row.Enabled,
@@ -468,6 +470,9 @@ func (h *Handler) ListAccounts(c *gin.Context) {
468470
}
469471
// 使用运行时状态(优先于 DB 状态)
470472
resp.Status = acc.RuntimeStatus()
473+
acc.Mu().RLock()
474+
resp.ErrorMessage = acc.ErrorMsg
475+
acc.Mu().RUnlock()
471476
} else if row.CooldownUntil.Valid && row.CooldownUntil.Time.After(time.Now()) {
472477
resp.CooldownReason = row.CooldownReason
473478
resp.CooldownUntil = row.CooldownUntil.Time.Format(time.RFC3339)

admin/test_connection.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,9 @@ func (h *Handler) BatchTest(c *gin.Context) {
467467

468468
func shouldMarkBatchTestAccountError(statusCode int, body []byte) bool {
469469
msg := strings.ToLower(string(body))
470+
if statusCode == http.StatusPaymentRequired && proxy.IsDeactivatedWorkspaceError(body) {
471+
return true
472+
}
470473
if statusCode == http.StatusForbidden {
471474
return true
472475
}

api/openapi.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ components:
339339
max_output_tokens:
340340
type: integer
341341
minimum: 1
342-
maximum: 65536
342+
maximum: 128000
343+
description: Maximum output tokens. gpt-5.5 supports up to 128000; other models may be capped lower.
343344
temperature:
344345
type: number
345346
minimum: 0

api/validation.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import (
1414
"github.com/tidwall/gjson"
1515
)
1616

17+
const (
18+
defaultResponsesMaxOutputTokens = 65536
19+
gpt55ResponsesMaxOutputTokens = 128000
20+
)
21+
1722
// ValidationRule represents a validation rule function
1823
type ValidationRule func(value gjson.Result, path string) *ValidationError
1924

@@ -419,13 +424,30 @@ func ChatCompletionValidationRules() map[string][]ValidationRule {
419424
}
420425
}
421426

427+
// ResponsesMaxOutputTokensForModel returns the downstream validation cap for
428+
// max_output_tokens. Most Codex models still use the legacy 64k output cap,
429+
// while gpt-5.5 clients may legitimately request up to 128k.
430+
func ResponsesMaxOutputTokensForModel(model string) int {
431+
switch strings.ToLower(strings.TrimSpace(model)) {
432+
case "gpt-5.5":
433+
return gpt55ResponsesMaxOutputTokens
434+
default:
435+
return defaultResponsesMaxOutputTokens
436+
}
437+
}
438+
422439
// ResponsesAPIValidationRules returns validation rules for responses API request
423440
// Note: input can be either a string or an array of items (validated separately)
424441
func ResponsesAPIValidationRules() map[string][]ValidationRule {
442+
return ResponsesAPIValidationRulesForModel("")
443+
}
444+
445+
func ResponsesAPIValidationRulesForModel(model string) map[string][]ValidationRule {
446+
maxOutputTokens := ResponsesMaxOutputTokensForModel(model)
425447
return map[string][]ValidationRule{
426448
"model": {Required(), TypeString(), MaxLength(64)},
427449
// input validation is handled separately to support both string and array formats
428-
"max_output_tokens": {TypeNumber(), MinValue(1), MaxValue(65536)},
450+
"max_output_tokens": {TypeNumber(), MinValue(1), MaxValue(float64(maxOutputTokens))},
429451
"temperature": {TypeNumber(), Range(0, 2)},
430452
"top_p": {TypeNumber(), Range(0, 1)},
431453
"stream": {TypeBoolean()},
@@ -451,7 +473,7 @@ func ValidateChatCompletionsRequest(body []byte, supportedModels []string) *Vali
451473

452474
// ValidateResponsesAPIRequest validates a responses API request with model validation
453475
func ValidateResponsesAPIRequest(body []byte, supportedModels []string) *ValidationResult {
454-
rules := ResponsesAPIValidationRules()
476+
rules := ResponsesAPIValidationRulesForModel(gjson.GetBytes(body, "model").String())
455477
rules["model"] = append(rules["model"], ModelValidator(supportedModels))
456478
validator := NewValidator(body)
457479
return validator.ValidateRequest(rules)

api/validation_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,39 @@ func TestValidateResponsesAPIRequestAllowsCompactionInputType(t *testing.T) {
7575
}
7676
}
7777

78+
func TestValidateResponsesAPIRequestUsesModelAwareMaxOutputTokens(t *testing.T) {
79+
tests := []struct {
80+
name string
81+
body []byte
82+
valid bool
83+
}{
84+
{
85+
name: "gpt-5.5 allows 128k output tokens",
86+
body: []byte(`{"model":"gpt-5.5","input":"hello","max_output_tokens":128000}`),
87+
valid: true,
88+
},
89+
{
90+
name: "gpt-5.5 rejects above 128k output tokens",
91+
body: []byte(`{"model":"gpt-5.5","input":"hello","max_output_tokens":128001}`),
92+
valid: false,
93+
},
94+
{
95+
name: "other models keep 64k output cap",
96+
body: []byte(`{"model":"gpt-5.4","input":"hello","max_output_tokens":65537}`),
97+
valid: false,
98+
},
99+
}
100+
101+
for _, test := range tests {
102+
t.Run(test.name, func(t *testing.T) {
103+
result := ValidateResponsesAPIRequest(test.body, []string{"gpt-5.5", "gpt-5.4"})
104+
if result.Valid != test.valid {
105+
t.Fatalf("Valid = %v, want %v; errors=%#v", result.Valid, test.valid, result.Errors)
106+
}
107+
})
108+
}
109+
}
110+
78111
func TestValidateResponsesAPIRequestRejectsUnknownInputType(t *testing.T) {
79112
result := ValidateResponsesAPIRequest(
80113
[]byte(`{"model":"gpt-5.4","input":[{"type":"unknown_call","call_id":"call_1"}]}`),

auth/fast_scheduler_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ func TestFastSchedulerSkipsDispatchPausedAccount(t *testing.T) {
5757
}
5858
}
5959

60+
func TestFastSchedulerSkipsErrorAccount(t *testing.T) {
61+
errored := newFastSchedulerTestAccount(1, HealthTierHealthy, 120, 2)
62+
errored.Status = StatusError
63+
fallback := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 2)
64+
65+
scheduler := NewFastScheduler(2)
66+
scheduler.Rebuild([]*Account{errored, fallback})
67+
68+
got := scheduler.Acquire()
69+
if got == nil {
70+
t.Fatal("Acquire() returned nil")
71+
}
72+
defer scheduler.Release(got)
73+
74+
if got.DBID != fallback.DBID {
75+
t.Fatalf("Acquire() picked dbID=%d, want %d", got.DBID, fallback.DBID)
76+
}
77+
}
78+
6079
func TestFastSchedulerRespectsConcurrencyLimit(t *testing.T) {
6180
acc := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 1)
6281

auth/session_affinity_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ func TestNextForSessionWithFilterFallsBackWhenBoundAccountRejected(t *testing.T)
9393
}
9494
}
9595

96+
func TestNextForSessionFallsBackWhenBoundAccountIsError(t *testing.T) {
97+
store := &Store{
98+
accounts: []*Account{
99+
{DBID: 1, AccessToken: "tok-1"},
100+
{DBID: 2, AccessToken: "tok-2", Status: StatusError, ErrorMsg: "deactivated_workspace"},
101+
},
102+
maxConcurrency: 2,
103+
}
104+
store.bindSessionAffinity("session-1", store.accounts[1], "http://proxy-2")
105+
106+
acc, proxyURL := store.NextForSession("session-1", 0, nil)
107+
if acc == nil {
108+
t.Fatal("expected fallback account")
109+
}
110+
if acc.DBID != 1 {
111+
t.Fatalf("account DBID = %d, want %d", acc.DBID, 1)
112+
}
113+
if proxyURL != "" {
114+
t.Fatalf("proxyURL = %q, want empty fallback proxy", proxyURL)
115+
}
116+
if store.accounts[1].IsAvailable() {
117+
t.Fatal("error account should not be available for scheduling")
118+
}
119+
}
120+
96121
func TestWaitForSessionAvailableReturnsBoundAccount(t *testing.T) {
97122
store := &Store{
98123
accounts: []*Account{

frontend/src/pages/Accounts.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,11 @@ export default function Accounts() {
12711271
{account.cooldown_until && (account.status === 'rate_limited' || account.status === 'error') && (
12721272
<CooldownTimer until={account.cooldown_until} />
12731273
)}
1274+
{account.status === 'error' && account.error_message && (
1275+
<div className="max-w-[180px] truncate text-[11px] leading-tight text-red-500" title={account.error_message}>
1276+
{account.error_message}
1277+
</div>
1278+
)}
12741279
{(account.model_cooldowns?.length ?? 0) > 0 && (
12751280
<div className="text-[11px] leading-tight text-amber-600">
12761281
model {account.model_cooldowns?.[0]?.model}

frontend/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface AccountRow {
2828
email: string
2929
plan_type: string
3030
status: AccountStatus
31+
error_message?: string
3132
at_only?: boolean
3233
health_tier?: string
3334
scheduler_score?: number

0 commit comments

Comments
 (0)