From fc99cd8353410846db51dd1a1d61bb251b7ae9f6 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Wed, 4 Feb 2026 15:34:47 +0000 Subject: [PATCH 1/3] fix: preserve the stream property for chat/completions calls --- go.mod | 2 +- go.sum | 4 ++-- intercept/chatcompletions/streaming.go | 9 ++++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index c60c802..3753dc5 100644 --- a/go.mod +++ b/go.mod @@ -94,4 +94,4 @@ require ( replace github.com/anthropics/anthropic-sdk-go v1.13.0 => github.com/dannykopping/anthropic-sdk-go v0.0.0-20251230111224-88a4315810bd // https://github.com/openai/openai-go/pull/602 -replace github.com/openai/openai-go/v3 => github.com/SasSwart/openai-go/v3 v3.0.0-20260202093810-72af3b857f95 +replace github.com/openai/openai-go/v3 => github.com/SasSwart/openai-go/v3 v3.0.0-20260204134041-fb987b42a728 diff --git a/go.sum b/go.sum index cb23c75..2f78024 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,8 @@ cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0Fv cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI= cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= -github.com/SasSwart/openai-go/v3 v3.0.0-20260202093810-72af3b857f95 h1:HVJp3FanNaeFAlwg0/lkdkSnwFemHnwwjXBM8KRj540= -github.com/SasSwart/openai-go/v3 v3.0.0-20260202093810-72af3b857f95/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/SasSwart/openai-go/v3 v3.0.0-20260204134041-fb987b42a728 h1:FOjd3xOH+arcrtz1e5P6WZ/VtRD5KQHHRg4kc4BZers= +github.com/SasSwart/openai-go/v3 v3.0.0-20260204134041-fb987b42a728/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= diff --git a/intercept/chatcompletions/streaming.go b/intercept/chatcompletions/streaming.go index 3e48713..748ae0a 100644 --- a/intercept/chatcompletions/streaming.go +++ b/intercept/chatcompletions/streaming.go @@ -125,6 +125,13 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re opts = append(opts, intercept.ActorHeadersAsOpenAIOpts(actor)...) } + body, err := json.Marshal(i.req.ChatCompletionNewParams) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + opts = append(opts, option.WithRequestBody("application/json", body)) + opts = append(opts, option.WithJSONSet("stream", true)) + stream = i.newStream(streamCtx, svc, opts) processor := newStreamProcessor(streamCtx, i.logger.Named("stream-processor"), i.getInjectedToolByName) @@ -380,7 +387,7 @@ func (i *StreamingInterception) newStream(ctx context.Context, svc openai.ChatCo _, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) defer span.End() - return svc.NewStreaming(ctx, i.req.ChatCompletionNewParams, opts...) + return svc.NewStreaming(ctx, openai.ChatCompletionNewParams{}, opts...) } type streamProcessor struct { From d43b58353cfbff69c780aee6ad358c87f23ea729 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Wed, 4 Feb 2026 15:52:05 +0000 Subject: [PATCH 2/3] test: add request body validation to mock server --- bridge_integration_test.go | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/bridge_integration_test.go b/bridge_integration_test.go index 64adbb7..8f61692 100644 --- a/bridge_integration_test.go +++ b/bridge_integration_test.go @@ -2076,6 +2076,35 @@ func newMockServer(ctx context.Context, t *testing.T, files archiveFileMap, requ defer r.Body.Close() require.NoError(t, err) + // Validate request body based on endpoint. + var validationErr error + if strings.Contains(r.URL.Path, "/chat/completions") { + validationErr = validateOpenAIChatCompletionRequest(body) + } else if strings.Contains(r.URL.Path, "/responses") { + validationErr = validateOpenAIResponsesRequest(body) + } else if strings.Contains(r.URL.Path, "/messages") { + validationErr = validateAnthropicMessagesRequest(body) + } + + // If validation failed, return error response + if validationErr != nil { + // Return HTTP error response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + errResp := map[string]any{ + "error": map[string]any{ + "message": fmt.Sprintf("Request #%d validation failed: %v", ms.callCount.Load(), validationErr), + "type": "invalid_request_error", + }, + } + json.NewEncoder(w).Encode(errResp) + + // Mark test as failed with detailed message + t.Errorf("Request #%d validation failed: %v\n\nRequest body:\n%s", + ms.callCount.Load(), validationErr, string(body)) + return + } + type msg struct { Stream bool `json:"stream"` } @@ -2135,6 +2164,72 @@ func newMockServer(ctx context.Context, t *testing.T, files archiveFileMap, requ return ms } +// validateOpenAIChatCompletionRequest validates that an OpenAI chat completion request +// has all required fields. Returns an error if validation fails. +func validateOpenAIChatCompletionRequest(body []byte) error { + var req openai.ChatCompletionNewParams + if err := json.Unmarshal(body, &req); err != nil { + return fmt.Errorf("request should unmarshal into ChatCompletionNewParams: %w", err) + } + + // Collect all validation errors + var errs []string + if req.Model == "" { + errs = append(errs, "model field is required but empty") + } + if len(req.Messages) == 0 { + errs = append(errs, "messages field is required but empty") + } + + if len(errs) > 0 { + return fmt.Errorf("validation failed: %s", strings.Join(errs, "; ")) + } + return nil +} + +// validateOpenAIResponsesRequest validates that an OpenAI responses request +// has all required fields. Returns an error if validation fails. +func validateOpenAIResponsesRequest(body []byte) error { + var reqBody map[string]any + if err := json.Unmarshal(body, &reqBody); err != nil { + return fmt.Errorf("request should be valid JSON: %w", err) + } + + // Verify required fields for OpenAI responses + // Note: Using map here since there's no specific SDK type for responses endpoint + model, ok := reqBody["model"] + if !ok || model == "" { + return fmt.Errorf("model field is required but missing or empty") + } + return nil +} + +// validateAnthropicMessagesRequest validates that an Anthropic messages request +// has all required fields. Returns an error if validation fails. +func validateAnthropicMessagesRequest(body []byte) error { + var req anthropic.MessageNewParams + if err := json.Unmarshal(body, &req); err != nil { + return fmt.Errorf("request should unmarshal into MessageNewParams: %w", err) + } + + // Collect all validation errors + var errs []string + if req.Model == "" { + errs = append(errs, "model field is required but empty") + } + if len(req.Messages) == 0 { + errs = append(errs, "messages field is required but empty") + } + if req.MaxTokens == 0 { + errs = append(errs, "max_tokens field is required but zero") + } + + if len(errs) > 0 { + return fmt.Errorf("validation failed: %s", strings.Join(errs, "; ")) + } + return nil +} + const mockToolName = "coder_list_workspaces" // callAccumulator tracks all tool invocations by name and each instance's arguments. From a787d862e9bbe8a40ee973c9ccda4383799ecc35 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 5 Feb 2026 08:03:58 +0000 Subject: [PATCH 3/3] document aibridge stream mashalling behaviour for chat completions --- intercept/chatcompletions/streaming.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/intercept/chatcompletions/streaming.go b/intercept/chatcompletions/streaming.go index 748ae0a..fb18f82 100644 --- a/intercept/chatcompletions/streaming.go +++ b/intercept/chatcompletions/streaming.go @@ -125,6 +125,9 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re opts = append(opts, intercept.ActorHeadersAsOpenAIOpts(actor)...) } + // We take control of request body here and pass it to the SDK as a raw byte slice. + // This is because the SDK's serialization applies hidden request options that result in + // unexpected, breaking behaviour. See https://github.com/coder/aibridge/pull/164 body, err := json.Marshal(i.req.ChatCompletionNewParams) if err != nil { return fmt.Errorf("marshal request body: %w", err)