Preserve explicit empty model responses
This commit is contained in:
@@ -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
|
emptiness and all malformed successful-envelope cases retain their prior error
|
||||||
identity.
|
identity.
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
## Stage 3: Build Full-Context, Prompt-Safe Corrective Requests
|
## Stage 3: Build Full-Context, Prompt-Safe Corrective Requests
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|||||||
@@ -179,12 +179,12 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
|||||||
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
||||||
}
|
}
|
||||||
content := wireResp.Choices[0].Message.Content
|
content := wireResp.Choices[0].Message.Content
|
||||||
if content == "" {
|
if content == nil {
|
||||||
return nil, fmt.Errorf("%w: first choice has empty message content", ErrMalformedResponse)
|
return nil, fmt.Errorf("%w: first choice has missing message content", ErrMalformedResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &domain.GenerateResponse{
|
return &domain.GenerateResponse{
|
||||||
Content: content,
|
Content: *content,
|
||||||
Usage: domain.TokenUsage{
|
Usage: domain.TokenUsage{
|
||||||
PromptTokens: wireResp.Usage.PromptTokens,
|
PromptTokens: wireResp.Usage.PromptTokens,
|
||||||
CompletionTokens: wireResp.Usage.CompletionTokens,
|
CompletionTokens: wireResp.Usage.CompletionTokens,
|
||||||
@@ -379,8 +379,8 @@ type openAICacheControl struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openAIChatResponseMessage struct {
|
type openAIChatResponseMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content *string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type openAIChatResponse struct {
|
type openAIChatResponse struct {
|
||||||
|
|||||||
@@ -765,6 +765,7 @@ func TestOpenAICompatibleClientResponseFraming(t *testing.T) {
|
|||||||
run func(*testing.T)
|
run func(*testing.T)
|
||||||
}{
|
}{
|
||||||
{name: "usage mapping", run: checkCacheUsageMapping},
|
{name: "usage mapping", run: checkCacheUsageMapping},
|
||||||
|
{name: "content presence", run: checkContentPresence},
|
||||||
{name: "common response failures", run: checkCommonResponseFailures},
|
{name: "common response failures", run: checkCommonResponseFailures},
|
||||||
{name: "successful response byte boundary", run: checkSuccessfulResponseByteBoundary},
|
{name: "successful response byte boundary", run: checkSuccessfulResponseByteBoundary},
|
||||||
{name: "continuing oversized response", run: checkContinuingOversizedResponse},
|
{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) {
|
func checkCacheUsageMapping(t *testing.T) {
|
||||||
provider := newRecordingProvider(t)
|
provider := newRecordingProvider(t)
|
||||||
provider.respond(http.StatusOK, `{
|
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: "invalid JSON", statusCode: http.StatusOK, body: `{not valid json`, wantErr: ErrMalformedResponse},
|
||||||
{name: "missing choices", statusCode: http.StatusOK, body: `{"choices": []}`, 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 {
|
for _, tc := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user