Files
coder/aibridge/intercept/responses/base.go
T
Michael Suchacz c97f4da3ac chore: sync fantasy fork with upstream v0.40.0 and openai-go with v3.50.0 (#27981)
Our fantasy fork had drifted far behind upstream charmbracelet/fantasy
(base v0.31.0 vs current v0.40.0). This PR updates the pinned forks
after reconciling which fork hacks upstream has fixed and which we still
need, and adapts this repo to the new APIs.

## Fork updates

- `charm.land/fantasy` ->
[coder/fantasy#51](https://github.com/coder/fantasy/pull/51) (merged):
`coder_2_33` synced with upstream v0.40.0, pinned at the merge commit
`bb10946892ef`.
- `github.com/openai/openai-go/v3` ->
[coder/openai-go#10](https://github.com/coder/openai-go/pull/10)
(merged): `coder/pinned` rebased from v3.16.0 onto upstream v3.50.0
(required by upstream fantasy), pinned at the merge commit
`92b5addb22d2`.
- `coder/anthropic-sdk-go` pin unchanged; the fantasy fork now tracks
the same revision this repo ships.

## Hack reconciliation summary

Dropped from our fantasy diff (upstream now has equivalents, often
stricter): truncated-stream fail-closed detection, Anthropic EffortXHigh
/ computer use / thinking effort / thinking display, replay fidelity for
signed reasoning and web_search errors, PDF and text documents with
sanitized filename titles, refusal finish-reason mapping (upstream also
maps Bedrock `content_filtered`/`guardrail_intervened`), gpt-5.5/5.6
Responses routing, the Go 1.25 downgrade, and the openai-go SSE decoder
and appendCompact patches.

Still fork-only and preserved: OpenAI computer use, OpenAI Responses
replay continuity validation, Anthropic pre-4.6 budget-thinking
conversion plus explicit thinking disable for effort none, Anthropic
RefusalMetadata parsing, Bedrock cross-region inference profile region
mirroring, and openai-go deferred body serialization with the
WithJSONSet fix.

Picked up new upstream features: stream transport retry with in-band SSE
error classification, Bedrock expired-credential refresh, per-message
cache markers for OpenAI-compatible models, tool panic recovery, extra
usage fields in provider metadata, and ClientMetadata on tool results.

## Changes in this repo

- `aibridge/intercept/responses`: `ResponseOutputItemUnion.Arguments`
became a union type in openai-go v3.50; read function-call arguments via
`.OfString` (plus test literal updates).
- `coderd/x/chatd/chatdebug`: register the new fantasy `Call.Headers`,
`ObjectCall.Headers`, and `ToolResultPart.ClientMetadata` fields in the
normalization coverage map (all skipped).
- `aibridge/internal/integrationtest`: make the RST test listener drain
the request before resetting the connection. The new SDK's write path
exposed the previous 1-byte-read race as sporadic `use of closed network
connection` failures; the fix holds over 40 consecutive runs.
- `go.mod`: rewrite the fork provenance comments to describe the
post-sync state.

## Validation

- `go build ./...` and `go vet ./...` clean (vet findings identical to
base).
- Fresh (`-count=1`) runs of `./coderd/x/chatd/...`, `./aibridge/...`,
`./coderd/aibridged/...`, `./coderd/database/db2sdk/`: 37 packages pass.
- `TestClientAndConnectionError` stress-tested 40x clean.
- Both fork PRs have green CI.

> Mux acted on Mike's behalf to create this PR.
2026-08-11 11:20:05 +02:00

498 lines
16 KiB
Go

package responses
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
"github.com/openai/openai-go/v3/shared/constant"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
aibcontext "github.com/coder/coder/v2/aibridge/context"
"github.com/coder/coder/v2/aibridge/intercept"
"github.com/coder/coder/v2/aibridge/intercept/apidump"
"github.com/coder/coder/v2/aibridge/keypool"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/recorder"
"github.com/coder/coder/v2/aibridge/tracing"
"github.com/coder/quartz"
)
const (
requestTimeout = time.Second * 600
)
type responsesInterceptionBase struct {
id uuid.UUID
reqPayload RequestPayload
cfg intercept.Config
cred intercept.Credential
// clientHeaders are the original HTTP headers from the client request.
clientHeaders http.Header
logger slog.Logger
tracer trace.Tracer
recorder recorder.Recorder
mcpProxy mcp.ServerProxier
}
// newResponsesService builds the SDK service used for upstream calls.
func (i *responsesInterceptionBase) newResponsesService(ctx context.Context) responses.ResponseService {
var opts []option.RequestOption
// Only BYOK sets its credential here. Centralized keys are injected
// per-attempt in the failover loop.
if byok, ok := intercept.AsBYOK(i.cred); ok {
i.logger.Debug(ctx, "using byok auth",
slog.F("auth_header", byok.Header), slog.F("key_hint", byok.Hint()),
)
opts = append(opts, option.WithAPIKey(byok.Secret))
}
opts = append(opts, option.WithBaseURL(i.cfg.BaseURL))
// Forward client headers to upstream. This middleware runs after the SDK
// has built the request, and replaces the outgoing headers with the sanitized
// client headers plus provider auth.
if i.clientHeaders != nil {
opts = append(opts, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
req.Header = intercept.BuildUpstreamHeaders(req.Header, i.clientHeaders, i.cred.AuthHeader())
return next(req)
}))
}
// Add API dump middleware if configured
if mw := apidump.NewBridgeMiddleware(i.cfg.APIDumpDir, i.cfg.ProviderName, i.Model(), i.id, i.logger, quartz.NewReal()); mw != nil {
opts = append(opts, option.WithMiddleware(mw))
}
return responses.NewResponseService(opts...)
}
func (i *responsesInterceptionBase) ID() uuid.UUID {
return i.id
}
func (i *responsesInterceptionBase) Credential() intercept.Credential {
return i.cred
}
func (i *responsesInterceptionBase) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) {
i.logger = logger.With(slog.F("model", i.Model()))
i.recorder = rec
i.mcpProxy = mcpProxy
}
func (i *responsesInterceptionBase) Model() string {
return i.reqPayload.model()
}
func (i *responsesInterceptionBase) CorrelatingToolCallID() *string {
return i.reqPayload.correlatingToolCallID()
}
func (i *responsesInterceptionBase) baseTraceAttributes(r *http.Request, streaming bool) []attribute.KeyValue {
return []attribute.KeyValue{
attribute.String(tracing.RequestPath, r.URL.Path),
attribute.String(tracing.InterceptionID, i.id.String()),
attribute.String(tracing.InitiatorID, aibcontext.ActorIDFromContext(r.Context())),
attribute.String(tracing.Provider, i.cfg.ProviderName),
attribute.String(tracing.Model, i.Model()),
attribute.Bool(tracing.Streaming, streaming),
}
}
func (i *responsesInterceptionBase) validateRequest(ctx context.Context, w http.ResponseWriter) error {
if i.reqPayload.background() {
err := xerrors.New("background requests are currently not supported by AI Gateway")
i.sendCustomErr(ctx, w, http.StatusNotImplemented, err)
return err
}
return nil
}
// writeUpstreamError marshals and writes a given error.
func (i *responsesInterceptionBase) writeUpstreamError(w http.ResponseWriter, oaiErr *intercept.ResponseError) {
if oaiErr == nil {
return
}
w.Header().Set("Content-Type", "application/json")
// Set Retry-After when a cooldown is configured.
if oaiErr.RetryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(oaiErr.RetryAfter.Seconds()))))
}
w.WriteHeader(oaiErr.StatusCode)
out, err := json.Marshal(oaiErr)
if err != nil {
i.logger.Warn(context.Background(), "failed to marshal upstream error", slog.Error(err), slog.F("error_payload", fmt.Sprintf("%+v", oaiErr)))
// Response has to match expected format.
_, _ = w.Write([]byte(`{
"error": {
"type": "error",
"message":"error marshaling upstream error",
"code": "server_error"
}
}`))
} else {
_, _ = w.Write(out)
}
}
// For centralized requests, markKeyOnError extracts an OpenAI
// SDK error from err and marks the key based on its status
// code. Returns true if the status was a key-specific failover
// trigger so callers can retry with the next key.
func (i *responsesInterceptionBase) markKeyOnError(ctx context.Context, key *keypool.Key, err error) bool {
cp, ok := intercept.AsCentralizedPool(i.cred)
if !ok {
return false
}
var apiErr *openai.Error
if !errors.As(err, &apiErr) {
return false
}
return cp.Pool.MarkKeyOnStatus(
ctx, key, apiErr.Response, i.logger,
)
}
// sendCustomErr sends custom responses.Error error to the client
// it should only be called before any data is sent back to the client
func (i *responsesInterceptionBase) sendCustomErr(ctx context.Context, w http.ResponseWriter, code int, err error) {
// Same JSON shape as responses.Error but using a plain struct because
// responses.Error embeds *http.Request whose GetBody func field
// is not JSON-marshalable (SA1026).
respErr := struct {
Code string `json:"code"`
Message string `json:"message"`
}{
Code: strconv.Itoa(code),
Message: err.Error(),
}
if b, err := json.Marshal(respErr); err != nil {
i.logger.Warn(ctx, "failed to marshal custom error: ", slog.Error(err))
} else {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if _, err := w.Write(b); err != nil {
i.logger.Warn(ctx, "failed to send custom error: ", slog.Error(err))
}
}
}
func (i *responsesInterceptionBase) requestOptions(respCopy *responseCopier) []option.RequestOption {
opts := []option.RequestOption{
// Sends original payload to solve json re-encoding issues
// eg. Codex CLI produces requests without ID set in reasoning items: https://platform.openai.com/docs/api-reference/responses/create#responses_create-input-input_item_list-item-reasoning-id
// when re-encoded, ID field is set to empty string which results
// in bad request while not sending ID field at all somehow works.
option.WithRequestBody("application/json", []byte(i.reqPayload)),
// copyMiddleware copies body of original response body to the buffer in responseCopier,
// also reference to headers and status code is kept responseCopier.
// responseCopier is used by interceptors to forward response as it was received,
// eliminating any possibility of JSON re-encoding issues.
option.WithMiddleware(respCopy.copyMiddleware),
}
if !i.reqPayload.Stream() {
opts = append(opts, option.WithRequestTimeout(requestTimeout))
}
return opts
}
func (i *responsesInterceptionBase) recordUserPrompt(ctx context.Context, responseID string, prompt string) {
if responseID == "" {
i.logger.Warn(ctx, "got empty response ID, skipping prompt recording")
return
}
promptUsage := &recorder.PromptUsageRecord{
InterceptionID: i.ID().String(),
MsgID: responseID,
Prompt: prompt,
}
if err := i.recorder.RecordPromptUsage(ctx, promptUsage); err != nil {
i.logger.Warn(ctx, "failed to record prompt usage", slog.Error(err))
}
}
func (i *responsesInterceptionBase) recordModelThoughts(ctx context.Context, response *responses.Response) {
for _, t := range i.extractModelThoughts(response) {
_ = i.recorder.RecordModelThought(ctx, &recorder.ModelThoughtRecord{
InterceptionID: i.ID().String(),
Content: t.Content,
Metadata: t.Metadata,
})
}
}
func (i *responsesInterceptionBase) recordNonInjectedToolUsage(ctx context.Context, response *responses.Response) {
if response == nil {
i.logger.Warn(ctx, "got empty response, skipping tool usage recording")
return
}
for _, item := range response.Output {
var args recorder.ToolArgs
// Whitelist the output item types that represent tool calls. Every
// other output type (message, reasoning, *_output, etc.) is skipped.
// Only function_call and custom_tool_call carry arguments we parse;
// the remaining built-in tool calls are recorded for visibility but
// have no uniform argument representation.
switch item.Type {
case string(constant.ValueOf[constant.FunctionCall]()):
// Arguments is a union since openai-go v3.50; function_call
// arguments are always the JSON string variant.
args = i.parseFunctionCallJSONArgs(ctx, item.Arguments.OfString)
case string(constant.ValueOf[constant.CustomToolCall]()):
args = item.Input
case string(constant.ValueOf[constant.WebSearchCall]()),
// computer_call has no SDK constant; only computer_call_output does.
"computer_call",
string(constant.ValueOf[constant.LocalShellCall]()),
string(constant.ValueOf[constant.ShellCall]()),
string(constant.ValueOf[constant.ApplyPatchCall]()),
string(constant.ValueOf[constant.CodeInterpreterCall]()),
string(constant.ValueOf[constant.McpCall]()),
string(constant.ValueOf[constant.FileSearchCall]()),
string(constant.ValueOf[constant.ImageGenerationCall]()):
// Built-in tool calls carry no uniform argument payload.
default:
continue
}
// Built-in tools usually have no name, so fall back to the type.
toolName := item.Name
if toolName == "" {
toolName = item.Type
}
if err := i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{
InterceptionID: i.ID().String(),
MsgID: response.ID,
// ItemID is always present; ToolCallID (call_id) is empty for
// hosted tools that the provider executes internally.
ItemID: item.ID,
ToolCallID: item.CallID,
Tool: toolName,
Args: args,
Injected: false,
}); err != nil {
i.logger.Warn(ctx, "failed to record tool usage", slog.Error(err), slog.F("tool", toolName))
}
}
}
func (i *responsesInterceptionBase) parseFunctionCallJSONArgs(ctx context.Context, raw string) recorder.ToolArgs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return trimmed
}
var args recorder.ToolArgs
if err := json.Unmarshal([]byte(trimmed), &args); err != nil {
i.logger.Warn(ctx, "failed to unmarshal tool args", slog.Error(err))
return trimmed
}
return args
}
func (i *responsesInterceptionBase) recordTokenUsage(ctx context.Context, response *responses.Response) {
if response == nil {
i.logger.Warn(ctx, "got empty response, skipping token usage recording")
return
}
usage := response.Usage
// Keeping logic consistent with chat completions
// Input *includes* the cached tokens, so we subtract them here to reflect actual input token usage.
inputNonCacheTokens := max(0, usage.InputTokens-usage.InputTokensDetails.CachedTokens)
if err := i.recorder.RecordTokenUsage(ctx, &recorder.TokenUsageRecord{
InterceptionID: i.ID().String(),
MsgID: response.ID,
Input: inputNonCacheTokens,
Output: usage.OutputTokens,
CacheReadInputTokens: usage.InputTokensDetails.CachedTokens,
ExtraTokenTypes: map[string]int64{
"output_reasoning": usage.OutputTokensDetails.ReasoningTokens,
"total_tokens": usage.TotalTokens,
},
}); err != nil {
i.logger.Warn(ctx, "failed to record token usage", slog.Error(err))
}
}
// extractModelThoughts extracts model thoughts from response output items.
// It captures both reasoning summary items and commentary messages (message
// output items with "phase": "commentary") as model thoughts.
func (*responsesInterceptionBase) extractModelThoughts(response *responses.Response) []*recorder.ModelThoughtRecord {
if response == nil {
return nil
}
var thoughts []*recorder.ModelThoughtRecord
for _, item := range response.Output {
switch item.Type {
case string(constant.ValueOf[constant.Reasoning]()):
reasoning := item.AsReasoning()
for _, summary := range reasoning.Summary {
if summary.Text == "" {
continue
}
thoughts = append(thoughts, &recorder.ModelThoughtRecord{
Content: summary.Text,
Metadata: recorder.Metadata{"source": recorder.ThoughtSourceReasoningSummary},
})
}
case string(constant.ValueOf[constant.Message]()):
// The API sometimes returns commentary messages instead of reasoning
// summaries. These are assistant message output items with "phase": "commentary".
// The SDK doesn't expose a Phase field, so we extract it from raw JSON.
// TODO: revisit when the OpenAI SDK adds a proper Phase field.
raw := item.RawJSON()
if gjson.Get(raw, "role").String() != string(constant.ValueOf[constant.Assistant]()) ||
gjson.Get(raw, "phase").String() != "commentary" {
continue
}
msg := item.AsMessage()
for _, part := range msg.Content {
if part.Type != string(constant.ValueOf[constant.OutputText]()) {
continue
}
if part.Text == "" {
continue
}
thoughts = append(thoughts, &recorder.ModelThoughtRecord{
Content: part.Text,
Metadata: recorder.Metadata{"source": recorder.ThoughtSourceCommentary},
})
}
}
}
return thoughts
}
func (i *responsesInterceptionBase) hasInjectableTools() bool {
return i.mcpProxy != nil && len(i.mcpProxy.ListTools()) > 0
}
// responseCopier helper struct to send original response to the client
type responseCopier struct {
buff deltaBuffer
responseStatus int
responseHeaders http.Header
// responseBody keeps reference to original ReadCloser.
// TeeReader in copyMiddleware copies read bytes from
// response body (read by SDK) to the buffer. In case
// SDK doesns't read everything readAll method reads from
// this closer to makes sure whole response body is in the buffer.
responseBody io.ReadCloser
// responseReceived flag is used to determine if AI Gateway needs to write custom error:
// - If responseReceived is true, the upstream response is forwarded as-is.
// - If responseReceived is false, no response was returned and there is nothing to forward (eg. connection/client error). Custom error will be returned.
responseReceived atomic.Bool
}
func (r *responseCopier) copyMiddleware(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
resp, err := next(req)
if err != nil || resp == nil {
return resp, err
}
r.responseReceived.Store(true)
r.responseStatus = resp.StatusCode
r.responseHeaders = resp.Header
resp.Body = io.NopCloser(io.TeeReader(resp.Body, &r.buff))
r.responseBody = resp.Body
return resp, nil
}
// readAll reads all data from resp.Body returned by so TeeReader
// so it appends all read data to the buffer and returns buffer contents.
func (r *responseCopier) readAll() ([]byte, error) {
if r.responseBody == nil {
return []byte{}, nil
}
_, err := io.ReadAll(r.responseBody)
return r.buff.readDelta(), err
}
// forwardResp writes whole response as received to ResponseWriter
func (r *responseCopier) forwardResp(w http.ResponseWriter) error {
// no response was received, nothing to forward
if !r.responseReceived.Load() {
return nil
}
w.Header().Set("Content-Type", r.responseHeaders.Get("Content-Type"))
// Preserve the upstream retry-after header so clients can honor it on
// rate-limited or unavailable responses.
if retryAfter := r.responseHeaders.Get("Retry-After"); retryAfter != "" {
w.Header().Set("Retry-After", retryAfter)
}
w.WriteHeader(r.responseStatus)
b, err := r.readAll()
if err != nil {
return xerrors.Errorf("failed to read response body: %w", err)
}
if _, err := w.Write(b); err != nil {
return xerrors.Errorf("failed to write response body: %w", err)
}
return nil
}
// deltaBuffer is a thread safe byte buffer
// supports reading incremental data (added after last read)
type deltaBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (d *deltaBuffer) Write(p []byte) (int, error) {
d.mu.Lock()
defer d.mu.Unlock()
return d.buf.Write(p)
}
// readDelta returns only the bytes appended
// after the last readDelta call.
func (d *deltaBuffer) readDelta() []byte {
d.mu.Lock()
defer d.mu.Unlock()
b := bytes.Clone(d.buf.Bytes())
d.buf.Reset()
return b
}