From 64d1cffd8951c2b18b064baec9c2c2a506ec2581 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 25 Aug 2026 09:42:28 +0000 Subject: [PATCH] Preserve explicit empty model responses --- docs/roadmap/implementation.md | 2 + internal/llm/openai_compatible_client.go | 10 ++-- internal/llm/openai_compatible_client_test.go | 51 +++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d575bca..4f5ef77 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -230,6 +230,8 @@ Stage 2 is complete when content presence is distinguishable from content emptiness and all malformed successful-envelope cases retain their prior error identity. +**Status:** Complete. + ## Stage 3: Build Full-Context, Prompt-Safe Corrective Requests ### Objective diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index 3247a09..ba0c760 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -179,12 +179,12 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse) } content := wireResp.Choices[0].Message.Content - if content == "" { - return nil, fmt.Errorf("%w: first choice has empty message content", ErrMalformedResponse) + if content == nil { + return nil, fmt.Errorf("%w: first choice has missing message content", ErrMalformedResponse) } return &domain.GenerateResponse{ - Content: content, + Content: *content, Usage: domain.TokenUsage{ PromptTokens: wireResp.Usage.PromptTokens, CompletionTokens: wireResp.Usage.CompletionTokens, @@ -379,8 +379,8 @@ type openAICacheControl struct { } type openAIChatResponseMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content *string `json:"content"` } type openAIChatResponse struct { diff --git a/internal/llm/openai_compatible_client_test.go b/internal/llm/openai_compatible_client_test.go index 0877e01..f76c7da 100644 --- a/internal/llm/openai_compatible_client_test.go +++ b/internal/llm/openai_compatible_client_test.go @@ -765,6 +765,7 @@ func TestOpenAICompatibleClientResponseFraming(t *testing.T) { run func(*testing.T) }{ {name: "usage mapping", run: checkCacheUsageMapping}, + {name: "content presence", run: checkContentPresence}, {name: "common response failures", run: checkCommonResponseFailures}, {name: "successful response byte boundary", run: checkSuccessfulResponseByteBoundary}, {name: "continuing oversized response", run: checkContinuingOversizedResponse}, @@ -776,6 +777,53 @@ func TestOpenAICompatibleClientResponseFraming(t *testing.T) { } } +func checkContentPresence(t *testing.T) { + tests := []struct { + name string + content string + }{ + {name: "explicit empty string", content: ""}, + {name: "whitespace string", content: " \n\t "}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider := newRecordingProvider(t) + provider.respond(http.StatusOK, `{ + "choices": [{"message": {"content": `+strconv.Quote(tc.content)+`}}], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + "prompt_tokens_details": {"cached_tokens": 4}, + "cache_write_tokens": 5 + } +}`) + client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"}) + + response, err := client.Generate(context.Background(), ordinaryGenerateRequest()) + if err != nil { + t.Fatalf("generate: %v", err) + } + if response == nil { + t.Fatal("expected response") + } + if response.Content != tc.content { + t.Fatalf("content = %q, want %q", response.Content, tc.content) + } + if response.Usage != (domain.TokenUsage{ + PromptTokens: 10, + CompletionTokens: 20, + TotalTokens: 30, + CachedTokens: 4, + CacheWriteTokens: 5, + }) { + t.Fatalf("usage = %+v", response.Usage) + } + }) + } +} + func checkCacheUsageMapping(t *testing.T) { provider := newRecordingProvider(t) provider.respond(http.StatusOK, `{ @@ -1129,6 +1177,9 @@ func checkCommonResponseFailures(t *testing.T) { }, {name: "invalid JSON", statusCode: http.StatusOK, body: `{not valid json`, wantErr: ErrMalformedResponse}, {name: "missing choices", statusCode: http.StatusOK, body: `{"choices": []}`, wantErr: ErrMalformedResponse}, + {name: "missing content", statusCode: http.StatusOK, body: `{"choices": [{"message": {}}]}`, wantErr: ErrMalformedResponse}, + {name: "null content", statusCode: http.StatusOK, body: `{"choices": [{"message": {"content": null}}]}`, wantErr: ErrMalformedResponse}, + {name: "non-string content", statusCode: http.StatusOK, body: `{"choices": [{"message": {"content": 1}}]}`, wantErr: ErrMalformedResponse}, } for _, tc := range tests {