From c52de4283f0ac80d7bba42e21791ed646e0aab30 Mon Sep 17 00:00:00 2001 From: warelik Date: Tue, 25 Aug 2026 08:08:19 -0400 Subject: [PATCH] fix(auth): rotate on empty OpenAI completion Empty chat.completions 200 was treated as success. Detect semantic emptiness and retry the next auth. First slice of #4881. --- sdk/cliproxy/auth/conductor_execution.go | 7 + sdk/cliproxy/auth/empty_completion.go | 602 ++++++++++++++++++ .../auth/empty_completion_openai_test.go | 245 +++++++ 3 files changed, 854 insertions(+) create mode 100644 sdk/cliproxy/auth/empty_completion.go create mode 100644 sdk/cliproxy/auth/empty_completion_openai_test.go diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index c7dcebe2459..bc3c6eed6ee 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -453,6 +453,13 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req } continue } + if isEmptyCompletionPayload(resp.Payload) { + lastErr = m.markEmptyCompletion(execCtx, &result) + if homeMode { + homeAuthCount++ + } + continue + } m.MarkResult(execCtx, result) attemptAliasResult := resolveAttemptAliasResult(routing, auth, routeModel, upstreamModel, aliasResult) rewriteForceMappedResponse(&resp, attemptAliasResult) diff --git a/sdk/cliproxy/auth/empty_completion.go b/sdk/cliproxy/auth/empty_completion.go new file mode 100644 index 00000000000..fac5adde5df --- /dev/null +++ b/sdk/cliproxy/auth/empty_completion.go @@ -0,0 +1,602 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "io" + "math" + "net/http" + "strings" +) + +// tokenCount is a tolerant usage count that accepts any valid JSON number +// (integer, decimal, or exponent) and treats every other JSON value (null, +// string, object, array, or malformed) as unset, absorbing it without failing +// the enclosing frame. positive reports whether the count is a finite number +// greater than zero, the only property the empty-completion logic needs. +type tokenCount json.Number + +func (t *tokenCount) UnmarshalJSON(b []byte) error { + var n json.Number + if err := json.Unmarshal(b, &n); err != nil { + *t = "" + return nil + } + *t = tokenCount(n) + return nil +} + +// positive reports whether c is a finite JSON number greater than zero. +func (c tokenCount) positive() bool { + n := json.Number(c) + if n == "" { + return false + } + f, err := n.Float64() + if err != nil { + return false + } + return !math.IsNaN(f) && !math.IsInf(f, 0) && f > 0 +} + +// addUsage folds a positive usage count into the accumulator's token total. +// Exact integer counts are summed; fractional, huge, or otherwise non-integer +// positive values still count as output evidence so the >0 check holds. +func (a *emptyCompletionAccum) addUsage(c tokenCount) { + if !c.positive() { + return + } + if n, err := json.Number(c).Int64(); err == nil && n > 0 { + a.completionTokens += int(n) + } else { + a.completionTokens = max(a.completionTokens, 1) + } +} + +// errEmptyCompletion indicates the upstream returned a terminal but empty +// completion (no content, no tool calls, zero completion tokens). It is +// retriable so the conductor marks the auth as failed, cools it down, and +// rotates to the next auth/model. +var errEmptyCompletion = &Error{ + Code: "empty_completion", + Message: "upstream returned an empty completion", + Retryable: true, + HTTPStatus: http.StatusServiceUnavailable, +} + +// openAIChunk is the minimal OpenAI-style SSE/JSON shape used to detect empty +// completions. +type openAIChunk struct { + Choices []struct { + Index *int `json:"index"` + Text string `json:"text"` + Delta struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + Refusal *string `json:"refusal"` + ToolCalls []json.RawMessage `json:"tool_calls"` + FunctionCall json.RawMessage `json:"function_call"` + Audio json.RawMessage `json:"audio"` + Images []json.RawMessage `json:"images"` + } `json:"delta"` + Message struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + Refusal *string `json:"refusal"` + ToolCalls []json.RawMessage `json:"tool_calls"` + FunctionCall json.RawMessage `json:"function_call"` + Audio json.RawMessage `json:"audio"` + Images []json.RawMessage `json:"images"` + } `json:"message"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Usage *struct { + CompletionTokens *tokenCount `json:"completion_tokens"` + } `json:"usage"` +} + +// nonEmptyJSONPayload reports whether raw holds a payload beyond an empty +// null, empty string, empty object, or empty array. +func nonEmptyJSONPayload(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return false + } + var val any + if err := json.Unmarshal(trimmed, &val); err != nil { + return false + } + switch v := val.(type) { + case nil: + return false + case string: + return strings.TrimSpace(v) != "" + case map[string]any: + return len(v) > 0 + case []any: + return len(v) > 0 + default: + return true + } +} + +func hasMeaningfulJSONArguments(args string) bool { + trimmed := strings.TrimSpace(args) + if trimmed == "" || trimmed == "null" { + return false + } + var val any + if err := json.Unmarshal([]byte(trimmed), &val); err == nil { + switch v := val.(type) { + case nil: + return false + case string: + return strings.TrimSpace(v) != "" + case map[string]any: + return len(v) > 0 + case []any: + return len(v) > 0 + default: + return true + } + } + return true +} + +func nonEmptyAudioPayload(raw json.RawMessage) bool { + var value any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return false + } + if err := decoder.Decode(new(any)); err != io.EOF { + return false + } + return nonEmptyAudioValue(value) +} + +func nonEmptyAudioValue(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + return strings.TrimSpace(typed) != "" + case bool: + return typed + case json.Number: + number, err := typed.Float64() + return err == nil && !math.IsNaN(number) && !math.IsInf(number, 0) && number != 0 + case []any: + for _, item := range typed { + if nonEmptyAudioValue(item) { + return true + } + } + case map[string]any: + for _, item := range typed { + if nonEmptyAudioValue(item) { + return true + } + } + } + return false +} + +// nonEmptyFunctionCall reports whether a legacy OpenAI function_call object +// carries a non-empty name and/or non-empty arguments. +func nonEmptyFunctionCall(raw json.RawMessage) bool { + var fc struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } + if err := json.Unmarshal(raw, &fc); err != nil { + return false + } + return strings.TrimSpace(fc.Name) != "" || hasMeaningfulJSONArguments(fc.Arguments) +} + +func hasMeaningfulImages(rawImages []json.RawMessage) bool { + for _, raw := range rawImages { + if nonEmptyJSONPayload(raw) { + return true + } + } + return false +} + +func hasMeaningfulToolCalls(rawCalls []json.RawMessage) bool { + for _, raw := range rawCalls { + if isMeaningfulToolCall(raw) { + return true + } + } + return false +} + +func isMeaningfulToolCall(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return false + } + var call struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + Name string `json:"name"` + Arguments string `json:"arguments"` + Custom json.RawMessage `json:"custom"` + } + if err := json.Unmarshal(trimmed, &call); err != nil { + var m map[string]any + if err := json.Unmarshal(trimmed, &m); err == nil && len(m) > 0 { + for _, v := range m { + if v != nil && v != "" { + return true + } + } + } + return false + } + if strings.TrimSpace(call.Function.Name) != "" || hasMeaningfulJSONArguments(call.Function.Arguments) { + return true + } + if strings.TrimSpace(call.Name) != "" || hasMeaningfulJSONArguments(call.Arguments) { + return true + } + if nonEmptyJSONPayload(call.Custom) { + return true + } + return false +} + +// emptyCompletionAccum accumulates the properties relevant to deciding whether +// an OpenAI-style completion is empty. Later slices add Claude/Gemini/Responses +// evaluators onto the same accum; unused format flags are omitted here. +type emptyCompletionAccum struct { + expectedChoices int + recognized bool + sawUnknownData bool + terminal bool + hasContent bool + hasToolCalls bool + completionTokens int + sawUsage bool + blocked bool + sawMetadataOnly bool + sawMessageData bool + openAITerminal bool + openAIChoicesSeen map[int]bool + openAIChoicesFinished map[int]bool +} + +func (a *emptyCompletionAccum) evalJSON(data []byte) bool { + values, err := decodeJSONValues(data) + if err != nil { + return false + } + recognized := false + for _, v := range values { + if a.evalOpenAI(v) { + recognized = true + } else { + a.sawUnknownData = true + } + } + return recognized +} + +// decodeJSONValues decodes every top-level JSON value in payload with the +// stdlib decoder until io.EOF, supporting pretty JSON, NDJSON, whitespace +// separated, and directly concatenated values. It requires at least one value +// and a clean EOF; malformed or trailing garbage returns an error. +func decodeJSONValues(payload []byte) ([]json.RawMessage, error) { + dec := json.NewDecoder(bytes.NewReader(payload)) + var values []json.RawMessage + for { + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + if err == io.EOF { + break + } + return nil, err + } + values = append(values, raw) + } + if len(values) == 0 { + return nil, io.EOF + } + return values, nil +} + +func (a *emptyCompletionAccum) evalOpenAI(data []byte) bool { + // Recognize the OpenAI shape by the presence of a "choices" key, even when + // the array is empty (e.g. {"choices":[]}). Such prefixes must still be + // judged at stream close instead of being forwarded immediately. + if !hasJSONKey(data, "choices") { + return false + } + a.recognized = true + a.sawMessageData = true + var chunk openAIChunk + if err := json.Unmarshal(data, &chunk); err != nil { + // A recognized choices-bearing payload whose shape does not decode + // (for example message.content as an array of content parts) carries + // forward-compatible output we cannot inspect. Treat it as unknown + // data so it passes through instead of being misjudged as an empty + // completion. + a.sawUnknownData = true + return true + } + if chunk.Usage != nil && chunk.Usage.CompletionTokens != nil { + a.sawUsage = true + a.addUsage(*chunk.Usage.CompletionTokens) + } + if a.openAIChoicesSeen == nil { + a.openAIChoicesSeen = make(map[int]bool) + a.openAIChoicesFinished = make(map[int]bool) + } + for i, ch := range chunk.Choices { + idx := i + if ch.Index != nil { + idx = *ch.Index + } + a.openAIChoicesSeen[idx] = true + if ch.FinishReason != nil { + reason := strings.TrimSpace(*ch.FinishReason) + if strings.EqualFold(reason, "stop") || strings.EqualFold(reason, "tool_calls") || strings.EqualFold(reason, "function_call") { + a.openAIChoicesFinished[idx] = true + a.terminal = true + } else if reason != "" { + // content_filter, length, and other non-stop terminal reasons + // are not empty completions: the client must see the reason + // rather than a silent auth rotation. + a.blocked = true + a.terminal = true + } + } + content := ch.Text + ch.Delta.Content + ch.Message.Content + ch.Delta.ReasoningContent + ch.Message.ReasoningContent + ch.Delta.Reasoning + ch.Message.Reasoning + if strings.TrimSpace(content) != "" { + a.hasContent = true + } + if (ch.Delta.Refusal != nil && strings.TrimSpace(*ch.Delta.Refusal) != "") || + (ch.Message.Refusal != nil && strings.TrimSpace(*ch.Message.Refusal) != "") { + a.hasContent = true + } + if hasMeaningfulToolCalls(ch.Delta.ToolCalls) || hasMeaningfulToolCalls(ch.Message.ToolCalls) { + a.hasToolCalls = true + } + if nonEmptyFunctionCall(ch.Delta.FunctionCall) || nonEmptyFunctionCall(ch.Message.FunctionCall) { + a.hasToolCalls = true + } + if nonEmptyAudioPayload(ch.Delta.Audio) || nonEmptyAudioPayload(ch.Message.Audio) { + a.hasContent = true + } + if hasMeaningfulImages(ch.Delta.Images) || hasMeaningfulImages(ch.Message.Images) { + a.hasContent = true + } + } + expected := a.expectedChoices + if expected <= 0 { + expected = 1 + } + targetChoices := expected + if len(a.openAIChoicesSeen) > targetChoices { + targetChoices = len(a.openAIChoicesSeen) + } + if len(a.openAIChoicesFinished) >= targetChoices && len(a.openAIChoicesFinished) >= len(a.openAIChoicesSeen) && !a.blocked { + a.openAITerminal = true + } else { + a.openAITerminal = false + } + if len(chunk.Choices) == 0 && chunk.Usage != nil { + // A completed non-streaming payload with zero choices + // ({"choices":[], "usage":...}) never enters the loop above, so + // terminal would never be set and the payload would be accepted as a + // successful response. With usage present the response is complete, so + // the empty judgment can run. (Streamed zero-choices chunks without + // usage are mid-stream signals and must not mark terminal here.) + a.terminal = true + } + return true +} + +// hasJSONKey reports whether the given JSON object contains name as a top-level +// key. It returns false for non-object or malformed input. +func hasJSONKey(data []byte, name string) bool { + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return false + } + _, ok := probe[name] + return ok +} + +// empty reports whether the accumulated stream is an empty completion. +func (a *emptyCompletionAccum) empty() bool { + if a.sawUnknownData || a.blocked || a.hasContent || a.hasToolCalls || (a.sawUsage && a.completionTokens > 0) { + return false + } + if a.recognized && a.terminal { + return true + } + if a.recognized { + return true + } + if a.sawMetadataOnly && !a.sawMessageData { + return true + } + return false +} + +// isEmptyCompletionPayload reports whether a payload (aggregated SSE chunks or +// a single non-stream JSON response) represents an empty completion. +func isEmptyCompletionPayload(payload []byte) bool { + trimmed := bytes.TrimSpace(payload) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + // A zero-length or whitespace-only body on an HTTP success is the + // canonical empty completion: without this, Execute and plugin + // executors returned it as a successful response and never rotated + // credentials. A literal JSON null is equally empty. + return true + } + + var jsonAcc emptyCompletionAccum + if jsonAcc.evalJSON(trimmed) { + var probe struct { + Choices json.RawMessage `json:"choices"` + } + if json.Unmarshal(trimmed, &probe) == nil && probe.Choices != nil { + jsonAcc.terminal = true + } + return jsonAcc.empty() + } + + var acc emptyCompletionAccum + + if isSSEPayload(trimmed) { + acc.evalSSE(trimmed) + return acc.empty() + } + + acc.evalJSON(trimmed) + // A complete non-SSE OpenAI chat completion body is terminal by + // construction: zero-choice payloads such as {"choices":[]} or + // {"choices":[],"usage":null} never enter the per-choice terminal paths, + // so without this they would be accepted as successful responses instead + // of being judged as empty completions. Other recognized shapes (for + // example Claude messages) keep their per-shape terminal rules. + var probe struct { + Choices json.RawMessage `json:"choices"` + } + if json.Unmarshal(trimmed, &probe) == nil && probe.Choices != nil { + acc.terminal = true + } + return acc.empty() +} + +func isSSEPayload(trimmed []byte) bool { + for _, line := range bytes.Split(trimmed, []byte("\n")) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + if isSSEPrefix(line) { + return true + } + } + return false +} + +func parseSSEDataLine(line []byte) []byte { + data := bytes.TrimPrefix(line, []byte("data:")) + if len(data) > 0 && data[0] == ' ' { + data = data[1:] + } + return data +} + +func isSSEPrefix(b []byte) bool { + return bytes.HasPrefix(b, []byte("data:")) || + bytes.HasPrefix(b, []byte("event:")) || + bytes.HasPrefix(b, []byte("id:")) || + bytes.HasPrefix(b, []byte("retry:")) || + bytes.HasPrefix(b, []byte(":")) || + bytes.Equal(b, []byte("data")) || + bytes.Equal(b, []byte("event")) || + bytes.Equal(b, []byte("id")) || + bytes.Equal(b, []byte("retry")) +} + +func (a *emptyCompletionAccum) evalSSE(payload []byte) { + var dataLines [][]byte + flush := func() { + if len(dataLines) == 0 { + return + } + data := bytes.Join(dataLines, []byte("\n")) + dataLines = dataLines[:0] + if bytes.Equal(data, []byte("[DONE]")) { + a.recognized = true + a.terminal = true + a.sawMessageData = true + return + } + if len(data) == 0 { + a.sawMetadataOnly = true + return + } + if !a.evalJSON(data) { + a.sawUnknownData = true + } + } + + processSingle := func(line []byte) { + if bytes.HasPrefix(line, []byte("event:")) { + event := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("event:"))) + if bytes.Equal(event, []byte("message_stop")) { + a.recognized = true + a.terminal = true + a.sawMessageData = true + } else { + a.sawMetadataOnly = true + } + return + } + if bytes.Equal(line, []byte("event")) { + a.sawMetadataOnly = true + return + } + if bytes.HasPrefix(line, []byte("id:")) || bytes.HasPrefix(line, []byte("retry:")) || bytes.HasPrefix(line, []byte(":")) { + a.sawMetadataOnly = true + return + } + if bytes.Equal(line, []byte("id")) || bytes.Equal(line, []byte("retry")) { + a.sawMetadataOnly = true + return + } + switch { + case bytes.HasPrefix(line, []byte("data:")): + dataLines = append(dataLines, parseSSEDataLine(line)) + case bytes.Equal(line, []byte("data")): + dataLines = append(dataLines, []byte("")) + case bytes.HasPrefix(line, []byte("{")), bytes.HasPrefix(line, []byte("[")): + // Some executors translate upstream SSE into the client format and + // emit raw JSON payloads without SSE framing (the HTTP handler adds + // the data: prefix later). Treat bare JSON lines as chunk data. + dataLines = append(dataLines, line) + default: + a.sawUnknownData = true + } + } + + processLine := func(line []byte) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + flush() + return + } + processSingle(line) + } + + for _, line := range bytes.Split(payload, []byte("\n")) { + processLine(line) + } + flush() +} + +// markEmptyCompletion records a failed retriable empty-completion result and +// returns the error to propagate. The mixed duty execution path rotates on an +// empty completion; the home (credits) path reports it via reportHomeResult. +func (m *Manager) markEmptyCompletion(ctx context.Context, result *Result) error { + result.Success = false + result.Error = errEmptyCompletion + m.MarkResult(ctx, *result) + return errEmptyCompletion +} diff --git a/sdk/cliproxy/auth/empty_completion_openai_test.go b/sdk/cliproxy/auth/empty_completion_openai_test.go new file mode 100644 index 00000000000..c48d7839c61 --- /dev/null +++ b/sdk/cliproxy/auth/empty_completion_openai_test.go @@ -0,0 +1,245 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +// openaiEmptyTestExecutor returns an empty OpenAI chat-completion payload from +// the first auth Execute picks and a real completion from every later auth. +type openaiEmptyTestExecutor struct { + emptyPayload []byte + contentPayload []byte + firstExecute string + executeCalls map[string]int +} + +func (*openaiEmptyTestExecutor) Identifier() string { return "claude" } + +func (e *openaiEmptyTestExecutor) Execute(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + if e.executeCalls == nil { + e.executeCalls = map[string]int{} + } + e.executeCalls[auth.ID]++ + if e.firstExecute == "" { + e.firstExecute = auth.ID + } + if e.firstExecute == auth.ID { + empty := e.emptyPayload + if len(empty) == 0 { + empty = []byte(`{"choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{"completion_tokens":0}}`) + } + return cliproxyexecutor.Response{Payload: empty}, nil + } + content := e.contentPayload + if len(content) == 0 { + content = []byte(`{"choices":[{"message":{"content":"real"},"finish_reason":"stop"}]}`) + } + return cliproxyexecutor.Response{Payload: content}, nil +} + +func (*openaiEmptyTestExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{Payload: []byte("ok")}, nil +} + +func (*openaiEmptyTestExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + return nil, errors.New("stream not in this slice") +} + +func (*openaiEmptyTestExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) { + return auth, nil +} + +func (*openaiEmptyTestExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) { + return nil, errors.New("not implemented") +} + +func newOpenAIEmptyTestManager(t *testing.T, executor *openaiEmptyTestExecutor) (*Manager, []string, string, *resultCaptureHook) { + t.Helper() + model := "empty-completion-model-" + uuid.NewString() + capture := &resultCaptureHook{} + manager := NewManager(nil, nil, capture) + manager.SetRetryConfig(0, 0, 0) + manager.RegisterExecutor(executor) + + var ids []string + for i := 0; i < 2; i++ { + auth := &Auth{ + ID: "empty-completion-auth-" + uuid.NewString(), + Provider: "claude", + Attributes: map[string]string{"auth_kind": "oauth"}, + Metadata: map[string]any{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "request_retry": float64(0), + }, + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}}) + t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID) }) + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("Register(%s) error = %v", auth.ID, errRegister) + } + ids = append(ids, auth.ID) + } + return manager, ids, model, capture +} + +func assertOpenAIEmptyRotates(t *testing.T, ids []string, emptyFirst, gotPayload, wantSubstr string, capture *resultCaptureHook) { + t.Helper() + if emptyFirst == "" { + t.Fatal("executor never executed any auth") + } + if !strings.Contains(gotPayload, wantSubstr) { + t.Fatalf("payload = %q, want %q from the non-empty auth", gotPayload, wantSubstr) + } + other := ids[0] + if emptyFirst == ids[0] { + other = ids[1] + } + var emptyRecorded bool + var emptySucceeded bool + var otherSucceeded bool + for _, r := range capture.Results() { + if r.AuthID == emptyFirst && !r.Success { + emptyRecorded = true + } + if r.AuthID == emptyFirst && r.Success { + emptySucceeded = true + } + if r.AuthID == other && r.Success { + otherSucceeded = true + } + } + if emptySucceeded { + t.Fatalf("empty auth %q was recorded as success; results=%v", emptyFirst, capture.Results()) + } + if !emptyRecorded { + t.Fatalf("empty auth %q was not recorded as a failure; results=%v", emptyFirst, capture.Results()) + } + if !otherSucceeded { + t.Fatalf("content auth %q was not recorded as success; results=%v", other, capture.Results()) + } +} + +func TestExecuteEmptyCompletionRotatesAuth(t *testing.T) { + executor := &openaiEmptyTestExecutor{} + manager, ids, model, capture := newOpenAIEmptyTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + assertOpenAIEmptyRotates(t, ids, executor.firstExecute, string(resp.Payload), "real", capture) +} + +func TestExecuteEmptyChoicesRotatesAuth(t *testing.T) { + executor := &openaiEmptyTestExecutor{ + emptyPayload: []byte(`{"choices":[],"usage":{"completion_tokens":0}}`), + } + manager, ids, model, capture := newOpenAIEmptyTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + assertOpenAIEmptyRotates(t, ids, executor.firstExecute, string(resp.Payload), "real", capture) +} + +func TestExecuteEmptyBodyRotatesAuth(t *testing.T) { + executor := &openaiEmptyTestExecutor{emptyPayload: []byte(" ")} + manager, ids, model, capture := newOpenAIEmptyTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + assertOpenAIEmptyRotates(t, ids, executor.firstExecute, string(resp.Payload), "real", capture) +} + +func TestExecuteNonEmptyOpenAINotRotated(t *testing.T) { + executor := &openaiEmptyTestExecutor{ + emptyPayload: []byte(`{"choices":[{"message":{"content":"hello from first"},"finish_reason":"stop"}]}`), + } + manager, _, model, capture := newOpenAIEmptyTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(string(resp.Payload), "hello from first") { + t.Fatalf("payload = %q, want first-auth content (no rotation)", resp.Payload) + } + if executor.firstExecute == "" { + t.Fatal("executor never executed any auth") + } + results := capture.Results() + if len(results) != 1 || !results[0].Success || results[0].AuthID != executor.firstExecute { + t.Fatalf("first auth should succeed without rotation, results=%v", results) + } +} + +func TestExecuteMeaningfulToolCallNotRotated(t *testing.T) { + executor := &openaiEmptyTestExecutor{ + emptyPayload: []byte(`{"choices":[{"message":{"tool_calls":[{"id":"x","function":{"name":"lookup","arguments":"{\"q\":\"1\"}"}}]},"finish_reason":"tool_calls"}]}`), + } + manager, _, model, capture := newOpenAIEmptyTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(string(resp.Payload), "lookup") { + t.Fatalf("payload = %q, want tool call from first auth", resp.Payload) + } + results := capture.Results() + if len(results) != 1 || !results[0].Success || results[0].AuthID != executor.firstExecute { + t.Fatalf("meaningful tool call must not rotate, results=%v", results) + } +} + +func TestIsEmptyCompletionPayloadOpenAI(t *testing.T) { + cases := []struct { + name string + payload []byte + expected bool + }{ + {name: "whitespace body", payload: []byte(" "), expected: true}, + {name: "json null", payload: []byte("null"), expected: true}, + {name: "empty choices", payload: []byte(`{"choices":[]}`), expected: true}, + {name: "empty content stop", payload: []byte(`{"choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{"completion_tokens":0}}`), expected: true}, + {name: "skeleton tool_calls id only", payload: []byte(`{"choices":[{"message":{"tool_calls":[{"id":"x"}]},"finish_reason":"tool_calls"}]}`), expected: true}, + {name: "real content", payload: []byte(`{"choices":[{"message":{"content":"hello"},"finish_reason":"stop"}]}`), expected: false}, + {name: "nonzero tokens", payload: []byte(`{"choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{"completion_tokens":5}}`), expected: false}, + {name: "named tool call", payload: []byte(`{"choices":[{"message":{"tool_calls":[{"id":"x","function":{"name":"lookup"}}]},"finish_reason":"tool_calls"}]}`), expected: false}, + {name: "openai sse empty", payload: []byte("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"), expected: true}, + {name: "openai sse then content", payload: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"), expected: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := isEmptyCompletionPayload(tc.payload) + if got != tc.expected { + t.Fatalf("isEmptyCompletionPayload(%q) = %v, want %v", tc.payload, got, tc.expected) + } + }) + } +} + +func TestExecuteSkeletonToolCallRotatesAuth(t *testing.T) { + executor := &openaiEmptyTestExecutor{ + emptyPayload: []byte(`{"choices":[{"message":{"tool_calls":[{"id":"x"}]},"finish_reason":"tool_calls"}]}`), + } + manager, ids, model, capture := newOpenAIEmptyTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + assertOpenAIEmptyRotates(t, ids, executor.firstExecute, string(resp.Payload), "real", capture) +}