Files
coder/aibridge/intercept/responses/blocking.go
T
Susana Ferreira 19aa9f5616 refactor: separate aibridge provider and interceptor configs (#26092)
## Description

Separates the aibridge provider configuration from the per-request configuration an interceptor actually needs, and introduces a single `Credential` type that each provider resolves per request. Previously a provider handed its full config to the interceptor (including fields the interceptor didn't use) while other request data was passed as loose arguments, and authentication was spread across config fields and arguments.

## Changes

- Add `intercept.Config`: the per-request, provider-agnostic configuration an interceptor needs (`ProviderName`, `BaseURL`, `APIDumpDir`, `SendActorHeaders`).
- Introduce a single `Credential` interface (`BYOK` and `Centralized`) that each provider resolves per request in `resolveCredential`, and have interceptors route on the credential kind.
- Fail fast with `ErrNoCredential` when a request is neither BYOK nor backed by a centralized key pool.
- Remove unused provider config fields (`Key`, `BYOKBearerToken`, `ExtraHeaders`).

Closes: coder/aibridge#266
Closes: https://linear.app/codercom/issue/AIGOV-221/refactor-separate-provider-and-interceptor-configs

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
2026-06-19 08:48:03 +01:00

208 lines
6.9 KiB
Go

package responses
import (
"context"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
"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/keypool"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/recorder"
"github.com/coder/coder/v2/aibridge/tracing"
)
type BlockingResponsesInterceptor struct {
responsesInterceptionBase
}
func NewBlockingInterceptor(
id uuid.UUID,
reqPayload RequestPayload,
cfg intercept.Config,
cred intercept.Credential,
clientHeaders http.Header,
tracer trace.Tracer,
) *BlockingResponsesInterceptor {
return &BlockingResponsesInterceptor{
responsesInterceptionBase: responsesInterceptionBase{
id: id,
reqPayload: reqPayload,
cfg: cfg,
cred: cred,
clientHeaders: clientHeaders,
tracer: tracer,
},
}
}
func (i *BlockingResponsesInterceptor) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) {
i.responsesInterceptionBase.Setup(logger.Named("blocking"), rec, mcpProxy)
}
func (*BlockingResponsesInterceptor) Streaming() bool {
return false
}
func (i *BlockingResponsesInterceptor) TraceAttributes(r *http.Request) []attribute.KeyValue {
return i.responsesInterceptionBase.baseTraceAttributes(r, false)
}
func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r *http.Request) (outErr error) {
ctx, span := i.tracer.Start(r.Context(), "Intercept.ProcessRequest", trace.WithAttributes(tracing.InterceptionAttributesFromContext(r.Context())...))
defer tracing.EndSpanErr(span, &outErr)
if err := i.validateRequest(ctx, w); err != nil {
return err
}
i.injectTools()
var (
response *responses.Response
upstreamErr error
respCopy responseCopier
firstResponseID string
)
prompt, promptFound, err := i.reqPayload.lastUserPrompt(ctx, i.logger)
if err != nil {
i.logger.Warn(ctx, "failed to get user prompt", slog.Error(err))
}
shouldLoop := true
// Sum the key attempts across all iterations and record once when the
// interception completes.
var totalKeyAttempts int
if cp, ok := intercept.AsCentralizedPool(i.cred); ok {
defer func() {
cp.Pool.RecordAttempts(totalKeyAttempts)
}()
}
for shouldLoop {
srv := i.newResponsesService(ctx)
respCopy = responseCopier{}
opts := i.requestOptions(&respCopy)
opts = append(opts, option.WithRequestTimeout(time.Second*600))
// TODO(ssncferreira): inject actor headers directly in the client-header
// middleware instead of using SDK options.
if actor := aibcontext.ActorFromContext(r.Context()); actor != nil && i.cfg.SendActorHeaders {
opts = append(opts, intercept.ActorHeadersAsOpenAIOpts(actor)...)
}
var keyAttempts int
response, keyAttempts, upstreamErr = i.newResponse(ctx, srv, opts)
totalKeyAttempts += keyAttempts
// The failover loop may return a keypool exhaustion
// error. Render it here.
if upstreamErr != nil {
var keyPoolErr *keypool.Error
if errors.As(upstreamErr, &keyPoolErr) {
i.writeUpstreamError(w, intercept.ResponseErrorFromKeyPool(keyPoolErr))
return xerrors.Errorf("key pool exhausted: %w", upstreamErr)
}
}
if upstreamErr != nil || response == nil {
break
}
if firstResponseID == "" {
firstResponseID = response.ID
}
i.recordTokenUsage(ctx, response)
i.recordModelThoughts(ctx, response)
// Check if there any injected tools to invoke.
pending := i.getPendingInjectedToolCalls(response)
shouldLoop, err = i.handleInnerAgenticLoop(ctx, pending, response)
if err != nil {
i.sendCustomErr(ctx, w, http.StatusInternalServerError, err)
shouldLoop = false
}
}
if promptFound {
i.recordUserPrompt(ctx, firstResponseID, prompt)
}
i.recordNonInjectedToolUsage(ctx, response)
if upstreamErr != nil && !respCopy.responseReceived.Load() {
// no response received from upstream, return custom error
i.sendCustomErr(ctx, w, http.StatusInternalServerError, upstreamErr)
return xerrors.Errorf("failed to connect to upstream: %w", upstreamErr)
}
err = respCopy.forwardResp(w)
return errors.Join(upstreamErr, err)
}
// newResponse routes by credential type, returning the upstream response, the
// number of key attempts made for this call, and any error. A centralized key
// pool fails over across keys, while BYOK authenticates with a single, fixed
// credential baked into srv, so it makes one attempt.
func (i *BlockingResponsesInterceptor) newResponse(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (*responses.Response, int, error) {
if cp, ok := intercept.AsCentralizedPool(i.cred); ok {
return i.newResponseWithKeyFailover(ctx, srv, cp, opts)
}
response, err := i.newResponseWithKey(intercept.WithCredentialInfo(ctx, i.cred), srv, opts)
return response, 0, err
}
// newResponseWithKey performs a single upstream call.
func (i *BlockingResponsesInterceptor) newResponseWithKey(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (_ *responses.Response, outErr error) {
_, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...))
defer tracing.EndSpanErr(span, &outErr)
// The body is overridden by option.WithRequestBody(reqPayload) in requestOptions
return srv.New(ctx, responses.ResponseNewParams{}, opts...)
}
// newResponseWithKeyFailover walks the centralized key pool, trying each key
// until one succeeds or the pool is exhausted. Keys are marked temporary on
// 429 and permanent on 401/403. Errors that aren't key-specific don't trigger
// failover and are returned to the caller. It returns the upstream response,
// the number of key attempts made for this call, and any error.
func (i *BlockingResponsesInterceptor) newResponseWithKeyFailover(ctx context.Context, srv responses.ResponseService, cp *intercept.CentralizedPool, opts []option.RequestOption) (*responses.Response, int, error) {
walker := cp.Pool.Walker()
for {
key, keyPoolErr := cp.NextKey(walker)
if keyPoolErr != nil {
return nil, walker.Attempts(), keyPoolErr
}
ctx = intercept.WithCredentialInfo(ctx, i.cred)
i.logger.Debug(ctx, "using centralized api key")
requestOpts := append([]option.RequestOption{}, opts...)
requestOpts = append(requestOpts,
option.WithAPIKey(key.Value()),
// Disable SDK retries because the failover loop
// handles retries via key rotation.
option.WithMaxRetries(0),
)
response, err := i.newResponseWithKey(ctx, srv, requestOpts)
// Key-specific failure: try the next key.
if i.markKeyOnError(ctx, key, err) {
continue
}
// Either success (response, nil) or a non-key error
// (nil, err): nothing to retry, return as-is.
return response, walker.Attempts(), err
}
}