diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f637d12..f234e69 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -172,6 +172,10 @@ HTTP/public smoke tests. **Validation/outcome:** Relevant llm tests plus focused public execution/smoke cases establish both failure locations and unchanged genuine malformed behavior. +Completion: successful-response body read failures retain their redacted +transport cause, including errors returned with bytes. The llm suite and focused +public execution and smoke classification tests pass on Go 1.27.3. + ## Stage 6: Mark Built-In Diagnostic Preparation Failure **Catalog:** 9. **Files:** generation evidence/coordinator and smoke orchestration. diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index 58daa19..9be82c7 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -370,40 +370,69 @@ func tokenCount(value *int) int { return *value } +type responseErrorReader struct { + reader io.Reader + err error +} + +func (r *responseErrorReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if err != nil && !errors.Is(err, io.EOF) { + r.err = err + } + return n, err +} + func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) { + observed := &responseErrorReader{reader: body} limited := &io.LimitedReader{ - R: body, + R: observed, N: maxOpenAIChatResponseBytes + 1, } decoder := json.NewDecoder(limited) var response openAIChatResponse if err := decoder.Decode(&response); err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return openAIChatResponse{}, &requestFailedError{cause: err} + if errors.Is(observed.err, context.Canceled) || errors.Is(observed.err, context.DeadlineExceeded) { + return openAIChatResponse{}, &requestFailedError{cause: observed.err} } if limited.N == 0 { return openAIChatResponse{}, openAIChatResponseTooLargeError() } + if observed.err != nil { + return openAIChatResponse{}, &requestFailedError{cause: observed.err} + } return openAIChatResponse{}, fmt.Errorf("%w: failed to decode response", ErrMalformedResponse) } + if errors.Is(observed.err, context.Canceled) || errors.Is(observed.err, context.DeadlineExceeded) { + return openAIChatResponse{}, &requestFailedError{cause: observed.err} + } if limited.N == 0 { return openAIChatResponse{}, openAIChatResponseTooLargeError() } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return openAIChatResponse{}, &requestFailedError{cause: err} + if errors.Is(observed.err, context.Canceled) || errors.Is(observed.err, context.DeadlineExceeded) { + return openAIChatResponse{}, &requestFailedError{cause: observed.err} } if limited.N == 0 { return openAIChatResponse{}, openAIChatResponseTooLargeError() } + if observed.err != nil { + return openAIChatResponse{}, &requestFailedError{cause: observed.err} + } return openAIChatResponse{}, fmt.Errorf("%w: response contains trailing data", ErrMalformedResponse) } + if errors.Is(observed.err, context.Canceled) || errors.Is(observed.err, context.DeadlineExceeded) { + return openAIChatResponse{}, &requestFailedError{cause: observed.err} + } if limited.N == 0 { return openAIChatResponse{}, openAIChatResponseTooLargeError() } + if observed.err != nil { + return openAIChatResponse{}, &requestFailedError{cause: observed.err} + } return response, nil } diff --git a/internal/llm/response_read_error_test.go b/internal/llm/response_read_error_test.go new file mode 100644 index 0000000..eedecb7 --- /dev/null +++ b/internal/llm/response_read_error_test.go @@ -0,0 +1,48 @@ +package llm + +import ( + "errors" + "io" + "strings" + "testing" +) + +type responseBytesAndError struct { + data []byte + err error +} + +func (r *responseBytesAndError) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, r.err + } + n := copy(p, r.data) + r.data = r.data[n:] + if len(r.data) == 0 { + return n, r.err + } + return n, nil +} + +func TestSuccessfulResponseReadFailuresRetainCause(t *testing.T) { + cause := errors.New("private reader diagnostic") + for _, test := range []struct { + name, prefix string + together bool + }{ + {"partial", `{"choices":[`, false}, + {"complete then failure", successfulProviderResponse, false}, + {"bytes and error together", successfulProviderResponse, true}, + } { + t.Run(test.name, func(t *testing.T) { + var reader io.Reader = io.MultiReader(strings.NewReader(test.prefix), failingReader{err: cause}) + if test.together { + reader = &responseBytesAndError{data: []byte(test.prefix), err: cause} + } + _, err := decodeOpenAIChatResponse(reader) + if !errors.Is(err, ErrTransport) || !errors.Is(err, cause) || errors.Is(err, ErrMalformedResponse) || strings.Contains(err.Error(), cause.Error()) { + t.Fatalf("read failure = %v", err) + } + }) + } +} diff --git a/response_read_contract_test.go b/response_read_contract_test.go new file mode 100644 index 0000000..a918bb8 --- /dev/null +++ b/response_read_contract_test.go @@ -0,0 +1,36 @@ +package promptkit_test + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "go.maximumdirect.net/promptkit" +) + +type failingResponseReader struct{ cause error } + +func (r failingResponseReader) Read([]byte) (int, error) { return 0, r.cause } + +func TestSuccessfulResponseReadFailurePublicClassification(t *testing.T) { + cause := errors.New("private response read marker") + cfg := simpleConfig("message") + cfg.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(io.MultiReader(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`), failingResponseReader{cause}))}, nil + })} + engine, err := promptkit.NewEngine(cfg) + if err != nil { + t.Fatal(err) + } + _, err = engine.Run(context.Background(), simpleGenerationRequest()) + if !errors.Is(err, promptkit.ErrTransport) || !errors.Is(err, cause) || errors.Is(err, promptkit.ErrMalformedResponse) || strings.Contains(err.Error(), cause.Error()) { + t.Fatalf("generation error = %v", err) + } + report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, ProfileID: "profile"}) + if err != nil || report == nil || !report.Completed || report.Failure != promptkit.ExecutionFailureTransport { + t.Fatalf("smoke report = %+v, %v", report, err) + } +}