From ec6ae45db7550048eb23d14425c4e9350d992b58 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 30 Mar 2026 22:37:48 +0000 Subject: [PATCH 01/12] feat: byok-observability for aibridge --- bridge.go | 2 ++ intercept/chatcompletions/base.go | 2 ++ intercept/credential.go | 29 +++++++++++++++++++++++++++++ intercept/interceptor.go | 9 +++++++++ intercept/messages/base.go | 2 ++ intercept/responses/base.go | 2 ++ provider/anthropic.go | 7 +++++++ provider/copilot.go | 1 + provider/openai.go | 3 +++ recorder/types.go | 2 ++ utils/mask.go | 11 ++++++----- utils/mask_test.go | 14 ++++++++------ 12 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 intercept/credential.go diff --git a/bridge.go b/bridge.go index 4d79fba5..7454b1be 100644 --- a/bridge.go +++ b/bridge.go @@ -228,6 +228,8 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC Client: string(client), ClientSessionID: sessionID, CorrelatingToolCallID: interceptor.CorrelatingToolCallID(), + CredentialKind: interceptor.CredentialKind(), + CredentialHint: interceptor.CredentialHint(), }); err != nil { span.SetStatus(codes.Error, fmt.Sprintf("failed to record interception: %v", err)) logger.Warn(ctx, "failed to record interception", slog.Error(err)) diff --git a/intercept/chatcompletions/base.go b/intercept/chatcompletions/base.go index 9c784e9f..5c5d9157 100644 --- a/intercept/chatcompletions/base.go +++ b/intercept/chatcompletions/base.go @@ -40,6 +40,8 @@ type interceptionBase struct { recorder recorder.Recorder mcpProxy mcp.ServerProxier + + intercept.CredentialFields } func (i *interceptionBase) newCompletionsService() openai.ChatCompletionService { diff --git a/intercept/credential.go b/intercept/credential.go new file mode 100644 index 00000000..d8846ed2 --- /dev/null +++ b/intercept/credential.go @@ -0,0 +1,29 @@ +package intercept + +// Credential kind constants for interception recording. +const ( + CredentialKindCentralized = "centralized" + CredentialKindPersonalAPIKey = "personal_api_key" + CredentialKindSubscription = "subscription" +) + +// CredentialFields is an embeddable helper that implements the +// SetCredential, CredentialKind, and CredentialHint methods of the +// Interceptor interface. +type CredentialFields struct { + Kind string + Hint string +} + +func (c *CredentialFields) SetCredential(kind, hint string) { + c.Kind = kind + c.Hint = hint +} + +func (c *CredentialFields) CredentialKind() string { + return c.Kind +} + +func (c *CredentialFields) CredentialHint() string { + return c.Hint +} diff --git a/intercept/interceptor.go b/intercept/interceptor.go index cbd29d62..3928bbdc 100644 --- a/intercept/interceptor.go +++ b/intercept/interceptor.go @@ -25,6 +25,15 @@ type Interceptor interface { Streaming() bool // TraceAttributes returns tracing attributes for this [Interceptor] TraceAttributes(*http.Request) []attribute.KeyValue + // SetCredential sets the credential kind and hint for this + // interception. Called by the provider after construction. + SetCredential(kind, hint string) + // CredentialKind returns how the request was authenticated: + // centralized, personal_api_key, or subscription. + CredentialKind() string + // CredentialHint returns a masked credential identifier for audit + // purposes (e.g. sk-a...abc1). + CredentialHint() string // CorrelatingToolCallID returns the ID of a tool call result submitted // in the request, if present. This is used to correlate the current // interception back to the previous interception that issued those tool diff --git a/intercept/messages/base.go b/intercept/messages/base.go index ccbd91ba..04e51b87 100644 --- a/intercept/messages/base.go +++ b/intercept/messages/base.go @@ -79,6 +79,8 @@ type interceptionBase struct { recorder recorder.Recorder mcpProxy mcp.ServerProxier + + intercept.CredentialFields } func (i *interceptionBase) ID() uuid.UUID { diff --git a/intercept/responses/base.go b/intercept/responses/base.go index 1dc0e8d6..bc9756ed 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -49,6 +49,8 @@ type responsesInterceptionBase struct { logger slog.Logger tracer trace.Tracer + + intercept.CredentialFields } func (i *responsesInterceptionBase) newResponsesService() responses.ResponseService { diff --git a/provider/anthropic.go b/provider/anthropic.go index 2800220c..0bb52fd6 100644 --- a/provider/anthropic.go +++ b/provider/anthropic.go @@ -130,14 +130,20 @@ func (p *Anthropic) CreateInterceptor(w http.ResponseWriter, r *http.Request, tr // set BYOKBearerToken and clear the centralized key. // When both are present, X-Api-Key takes priority to match // claude-code behavior. + credKind := intercept.CredentialKindCentralized + credHint := utils.MaskSecret(cfg.Key) authHeaderName := p.AuthHeader() if apiKey := r.Header.Get("X-Api-Key"); apiKey != "" { cfg.Key = apiKey authHeaderName = "X-Api-Key" + credKind = intercept.CredentialKindPersonalAPIKey + credHint = utils.MaskSecret(apiKey) } else if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" { cfg.BYOKBearerToken = token cfg.Key = "" authHeaderName = "Authorization" + credKind = intercept.CredentialKindSubscription + credHint = utils.MaskSecret(token) } var interceptor intercept.Interceptor @@ -146,6 +152,7 @@ func (p *Anthropic) CreateInterceptor(w http.ResponseWriter, r *http.Request, tr } else { interceptor = messages.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, p.bedrockCfg, r.Header, authHeaderName, tracer) } + interceptor.SetCredential(credKind, credHint) span.SetAttributes(interceptor.TraceAttributes(r)...) return interceptor, nil } diff --git a/provider/copilot.go b/provider/copilot.go index eeeb74d4..85c77305 100644 --- a/provider/copilot.go +++ b/provider/copilot.go @@ -182,6 +182,7 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac return nil, UnknownRoute } + interceptor.SetCredential(intercept.CredentialKindSubscription, utils.MaskSecret(key)) span.SetAttributes(interceptor.TraceAttributes(r)...) return interceptor, nil } diff --git a/provider/openai.go b/provider/openai.go index 1cd85912..362bad5d 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -113,8 +113,10 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace // // In BYOK mode the user's credential is in Authorization. Replace // the centralized key with it so it is forwarded upstream. + credKind := intercept.CredentialKindCentralized if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" { cfg.Key = token + credKind = intercept.CredentialKindPersonalAPIKey } path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) @@ -150,6 +152,7 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace span.SetStatus(codes.Error, "unknown route: "+r.URL.Path) return nil, UnknownRoute } + interceptor.SetCredential(credKind, utils.MaskSecret(cfg.Key)) span.SetAttributes(interceptor.TraceAttributes(r)...) return interceptor, nil } diff --git a/recorder/types.go b/recorder/types.go index 9983dbd9..cd541eeb 100644 --- a/recorder/types.go +++ b/recorder/types.go @@ -39,6 +39,8 @@ type InterceptionRecord struct { Client string UserAgent string CorrelatingToolCallID *string + CredentialKind string + CredentialHint string } type InterceptionRecordEnded struct { diff --git a/utils/mask.go b/utils/mask.go index 108aae2c..4ed60c53 100644 --- a/utils/mask.go +++ b/utils/mask.go @@ -1,10 +1,11 @@ package utils -import "strings" +import "fmt" // MaskSecret masks the middle of a secret string, revealing a small -// prefix and suffix for identification. The number of characters -// revealed scales with string length. +// prefix and suffix for identification. The number of hidden characters +// is embedded in the masked portion (e.g. "sk-a...(21)...efgh"). +// The number of characters revealed scales with string length. func MaskSecret(s string) string { if s == "" { return "" @@ -15,13 +16,13 @@ func MaskSecret(s string) string { // If we'd reveal everything or more, mask it all. if reveal*2 >= len(runes) { - return strings.Repeat("*", len(runes)) + return fmt.Sprintf("...(%d)...", len(runes)) } prefix := string(runes[:reveal]) suffix := string(runes[len(runes)-reveal:]) masked := len(runes) - reveal*2 - return prefix + strings.Repeat("*", masked) + suffix + return prefix + fmt.Sprintf("...(%d)...", masked) + suffix } // revealLength returns the number of runes to show at each end. diff --git a/utils/mask_test.go b/utils/mask_test.go index d481fd02..5db8fc71 100644 --- a/utils/mask_test.go +++ b/utils/mask_test.go @@ -1,6 +1,7 @@ package utils_test import ( + "strings" "testing" "github.com/coder/aibridge/utils" @@ -16,12 +17,13 @@ func TestMaskSecret(t *testing.T) { expected string }{ {"empty", "", ""}, - {"short", "short", "*****"}, - {"short_9_chars", "veryshort", "*********"}, - {"medium_15_chars", "thisisquitelong", "th***********ng"}, - {"long_api_key", "sk-ant-api03-abcdefgh", "sk-a*************efgh"}, - {"unicode", "hélloworld🌍!", "hé********🌍!"}, - {"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_******************************efgh"}, + {"short", "short", "...(5)..."}, + {"short_9_chars", "veryshort", "...(9)..."}, + {"medium_15_chars", "thisisquitelong", "th...(11)...ng"}, + {"long_api_key", "sk-ant-api03-abcdefgh", "sk-a...(13)...efgh"}, + {"unicode", "hélloworld🌍!", "hé...(8)...🌍!"}, + {"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_...(30)...efgh"}, + {"jwt_300_chars", "eyJh" + strings.Repeat("a", 292) + "Xk8s", "eyJh...(292)...Xk8s"}, } for _, tc := range tests { From 8d7a6b693035eea7d6353eca403b4c2bde3a851e Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 2 Apr 2026 19:25:01 +0000 Subject: [PATCH 02/12] refactor: re-org credential configuring --- bridge.go | 4 ++-- intercept/chatcompletions/base.go | 11 +++++++---- intercept/chatcompletions/blocking.go | 2 ++ intercept/chatcompletions/streaming.go | 2 ++ intercept/chatcompletions/streaming_test.go | 3 ++- intercept/credential.go | 17 +---------------- intercept/interceptor.go | 11 ++--------- intercept/messages/base.go | 11 +++++++---- intercept/messages/blocking.go | 2 ++ intercept/messages/streaming.go | 2 ++ intercept/responses/base.go | 11 +++++++---- intercept/responses/blocking.go | 2 ++ intercept/responses/streaming.go | 2 ++ provider/anthropic.go | 7 ++++--- provider/copilot.go | 11 ++++++----- provider/openai.go | 10 +++++----- 16 files changed, 55 insertions(+), 53 deletions(-) diff --git a/bridge.go b/bridge.go index 7454b1be..2b74a46c 100644 --- a/bridge.go +++ b/bridge.go @@ -228,8 +228,8 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC Client: string(client), ClientSessionID: sessionID, CorrelatingToolCallID: interceptor.CorrelatingToolCallID(), - CredentialKind: interceptor.CredentialKind(), - CredentialHint: interceptor.CredentialHint(), + CredentialKind: interceptor.Credential().Kind, + CredentialHint: interceptor.Credential().Hint, }); err != nil { span.SetStatus(codes.Error, fmt.Sprintf("failed to record interception: %v", err)) logger.Warn(ctx, "failed to record interception", slog.Error(err)) diff --git a/intercept/chatcompletions/base.go b/intercept/chatcompletions/base.go index 5c5d9157..439002b7 100644 --- a/intercept/chatcompletions/base.go +++ b/intercept/chatcompletions/base.go @@ -38,10 +38,9 @@ type interceptionBase struct { logger slog.Logger tracer trace.Tracer - recorder recorder.Recorder - mcpProxy mcp.ServerProxier - - intercept.CredentialFields + recorder recorder.Recorder + mcpProxy mcp.ServerProxier + credential intercept.CredentialFields } func (i *interceptionBase) newCompletionsService() openai.ChatCompletionService { @@ -76,6 +75,10 @@ func (i *interceptionBase) ID() uuid.UUID { return i.id } +func (i *interceptionBase) Credential() intercept.CredentialFields { + return i.credential +} + func (i *interceptionBase) Setup(logger slog.Logger, recorder recorder.Recorder, mcpProxy mcp.ServerProxier) { i.logger = logger i.recorder = recorder diff --git a/intercept/chatcompletions/blocking.go b/intercept/chatcompletions/blocking.go index f289ac9c..786ae12d 100644 --- a/intercept/chatcompletions/blocking.go +++ b/intercept/chatcompletions/blocking.go @@ -36,6 +36,7 @@ func NewBlockingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, + cred intercept.CredentialFields, ) *BlockingInterception { return &BlockingInterception{interceptionBase: interceptionBase{ id: id, @@ -45,6 +46,7 @@ func NewBlockingInterceptor( clientHeaders: clientHeaders, authHeaderName: authHeaderName, tracer: tracer, + credential: cred, }} } diff --git a/intercept/chatcompletions/streaming.go b/intercept/chatcompletions/streaming.go index c2d2a396..dc62fa9d 100644 --- a/intercept/chatcompletions/streaming.go +++ b/intercept/chatcompletions/streaming.go @@ -41,6 +41,7 @@ func NewStreamingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, + cred intercept.CredentialFields, ) *StreamingInterception { return &StreamingInterception{interceptionBase: interceptionBase{ id: id, @@ -50,6 +51,7 @@ func NewStreamingInterceptor( clientHeaders: clientHeaders, authHeaderName: authHeaderName, tracer: tracer, + credential: cred, }} } diff --git a/intercept/chatcompletions/streaming_test.go b/intercept/chatcompletions/streaming_test.go index 54c47336..a75d8072 100644 --- a/intercept/chatcompletions/streaming_test.go +++ b/intercept/chatcompletions/streaming_test.go @@ -9,6 +9,7 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/aibridge/config" + "github.com/coder/aibridge/intercept" "github.com/coder/aibridge/internal/testutil" "github.com/google/uuid" "github.com/openai/openai-go/v3" @@ -86,7 +87,7 @@ func TestStreamingInterception_RelaysUpstreamErrorToClient(t *testing.T) { httpReq := httptest.NewRequest(http.MethodPost, "/chat/completions", nil) tracer := otel.Tracer("test") - interceptor := NewStreamingInterceptor(uuid.New(), req, config.ProviderOpenAI, cfg, httpReq.Header, "Authorization", tracer) + interceptor := NewStreamingInterceptor(uuid.New(), req, config.ProviderOpenAI, cfg, httpReq.Header, "Authorization", tracer, intercept.CredentialFields{}) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) interceptor.Setup(logger, &testutil.MockRecorder{}, nil) diff --git a/intercept/credential.go b/intercept/credential.go index d8846ed2..838d0c1e 100644 --- a/intercept/credential.go +++ b/intercept/credential.go @@ -7,23 +7,8 @@ const ( CredentialKindSubscription = "subscription" ) -// CredentialFields is an embeddable helper that implements the -// SetCredential, CredentialKind, and CredentialHint methods of the -// Interceptor interface. +// CredentialFields holds credential metadata for an interception. type CredentialFields struct { Kind string Hint string } - -func (c *CredentialFields) SetCredential(kind, hint string) { - c.Kind = kind - c.Hint = hint -} - -func (c *CredentialFields) CredentialKind() string { - return c.Kind -} - -func (c *CredentialFields) CredentialHint() string { - return c.Hint -} diff --git a/intercept/interceptor.go b/intercept/interceptor.go index 3928bbdc..02078370 100644 --- a/intercept/interceptor.go +++ b/intercept/interceptor.go @@ -25,15 +25,8 @@ type Interceptor interface { Streaming() bool // TraceAttributes returns tracing attributes for this [Interceptor] TraceAttributes(*http.Request) []attribute.KeyValue - // SetCredential sets the credential kind and hint for this - // interception. Called by the provider after construction. - SetCredential(kind, hint string) - // CredentialKind returns how the request was authenticated: - // centralized, personal_api_key, or subscription. - CredentialKind() string - // CredentialHint returns a masked credential identifier for audit - // purposes (e.g. sk-a...abc1). - CredentialHint() string + // Credential returns the credential metadata for this interception. + Credential() CredentialFields // CorrelatingToolCallID returns the ID of a tool call result submitted // in the request, if present. This is used to correlate the current // interception back to the previous interception that issued those tool diff --git a/intercept/messages/base.go b/intercept/messages/base.go index 04e51b87..3108d4aa 100644 --- a/intercept/messages/base.go +++ b/intercept/messages/base.go @@ -77,16 +77,19 @@ type interceptionBase struct { tracer trace.Tracer logger slog.Logger - recorder recorder.Recorder - mcpProxy mcp.ServerProxier - - intercept.CredentialFields + recorder recorder.Recorder + mcpProxy mcp.ServerProxier + credential intercept.CredentialFields } func (i *interceptionBase) ID() uuid.UUID { return i.id } +func (i *interceptionBase) Credential() intercept.CredentialFields { + return i.credential +} + func (i *interceptionBase) Setup(logger slog.Logger, recorder recorder.Recorder, mcpProxy mcp.ServerProxier) { i.logger = logger i.recorder = recorder diff --git a/intercept/messages/blocking.go b/intercept/messages/blocking.go index da5526ad..37894e1b 100644 --- a/intercept/messages/blocking.go +++ b/intercept/messages/blocking.go @@ -37,6 +37,7 @@ func NewBlockingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, + cred intercept.CredentialFields, ) *BlockingInterception { return &BlockingInterception{interceptionBase: interceptionBase{ id: id, @@ -47,6 +48,7 @@ func NewBlockingInterceptor( clientHeaders: clientHeaders, authHeaderName: authHeaderName, tracer: tracer, + credential: cred, }} } diff --git a/intercept/messages/streaming.go b/intercept/messages/streaming.go index 7fed361d..49071ed7 100644 --- a/intercept/messages/streaming.go +++ b/intercept/messages/streaming.go @@ -43,6 +43,7 @@ func NewStreamingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, + cred intercept.CredentialFields, ) *StreamingInterception { return &StreamingInterception{interceptionBase: interceptionBase{ id: id, @@ -53,6 +54,7 @@ func NewStreamingInterceptor( clientHeaders: clientHeaders, authHeaderName: authHeaderName, tracer: tracer, + credential: cred, }} } diff --git a/intercept/responses/base.go b/intercept/responses/base.go index bc9756ed..b38e94b2 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -47,10 +47,9 @@ type responsesInterceptionBase struct { recorder recorder.Recorder mcpProxy mcp.ServerProxier - logger slog.Logger - tracer trace.Tracer - - intercept.CredentialFields + logger slog.Logger + tracer trace.Tracer + credential intercept.CredentialFields } func (i *responsesInterceptionBase) newResponsesService() responses.ResponseService { @@ -85,6 +84,10 @@ func (i *responsesInterceptionBase) ID() uuid.UUID { return i.id } +func (i *responsesInterceptionBase) Credential() intercept.CredentialFields { + return i.credential +} + func (i *responsesInterceptionBase) Setup(logger slog.Logger, recorder recorder.Recorder, mcpProxy mcp.ServerProxier) { i.logger = logger.With(slog.F("model", i.Model())) i.recorder = recorder diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index d64adf9f..4981a7e0 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -33,6 +33,7 @@ func NewBlockingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, + cred intercept.CredentialFields, ) *BlockingResponsesInterceptor { return &BlockingResponsesInterceptor{ responsesInterceptionBase: responsesInterceptionBase{ @@ -43,6 +44,7 @@ func NewBlockingInterceptor( clientHeaders: clientHeaders, authHeaderName: authHeaderName, tracer: tracer, + credential: cred, }, } } diff --git a/intercept/responses/streaming.go b/intercept/responses/streaming.go index 359f82e8..15aec381 100644 --- a/intercept/responses/streaming.go +++ b/intercept/responses/streaming.go @@ -40,6 +40,7 @@ func NewStreamingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, + cred intercept.CredentialFields, ) *StreamingResponsesInterceptor { return &StreamingResponsesInterceptor{ responsesInterceptionBase: responsesInterceptionBase{ @@ -50,6 +51,7 @@ func NewStreamingInterceptor( clientHeaders: clientHeaders, authHeaderName: authHeaderName, tracer: tracer, + credential: cred, }, } } diff --git a/provider/anthropic.go b/provider/anthropic.go index 0bb52fd6..daa42ff8 100644 --- a/provider/anthropic.go +++ b/provider/anthropic.go @@ -146,13 +146,14 @@ func (p *Anthropic) CreateInterceptor(w http.ResponseWriter, r *http.Request, tr credHint = utils.MaskSecret(token) } + cred := intercept.CredentialFields{Kind: credKind, Hint: credHint} + var interceptor intercept.Interceptor if reqPayload.Stream() { - interceptor = messages.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, p.bedrockCfg, r.Header, authHeaderName, tracer) + interceptor = messages.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, p.bedrockCfg, r.Header, authHeaderName, tracer, cred) } else { - interceptor = messages.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, p.bedrockCfg, r.Header, authHeaderName, tracer) + interceptor = messages.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, p.bedrockCfg, r.Header, authHeaderName, tracer, cred) } - interceptor.SetCredential(credKind, credHint) span.SetAttributes(interceptor.TraceAttributes(r)...) return interceptor, nil } diff --git a/provider/copilot.go b/provider/copilot.go index 85c77305..52b6ee70 100644 --- a/provider/copilot.go +++ b/provider/copilot.go @@ -145,6 +145,8 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac ExtraHeaders: extractCopilotHeaders(r), } + cred := intercept.CredentialFields{Kind: intercept.CredentialKindSubscription, Hint: utils.MaskSecret(key)} + var interceptor intercept.Interceptor path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) @@ -156,9 +158,9 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac } if req.Stream { - interceptor = chatcompletions.NewStreamingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = chatcompletions.NewStreamingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } else { - interceptor = chatcompletions.NewBlockingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = chatcompletions.NewBlockingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } case routeCopilotResponses: @@ -172,9 +174,9 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac } if reqPayload.Stream() { - interceptor = responses.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = responses.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } else { - interceptor = responses.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = responses.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } default: @@ -182,7 +184,6 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac return nil, UnknownRoute } - interceptor.SetCredential(intercept.CredentialKindSubscription, utils.MaskSecret(key)) span.SetAttributes(interceptor.TraceAttributes(r)...) return interceptor, nil } diff --git a/provider/openai.go b/provider/openai.go index 362bad5d..c50c9cf6 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -118,6 +118,7 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace cfg.Key = token credKind = intercept.CredentialKindPersonalAPIKey } + cred := intercept.CredentialFields{Kind: credKind, Hint: utils.MaskSecret(cfg.Key)} path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) switch path { @@ -128,9 +129,9 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace } if req.Stream { - interceptor = chatcompletions.NewStreamingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = chatcompletions.NewStreamingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } else { - interceptor = chatcompletions.NewBlockingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = chatcompletions.NewBlockingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } case routeResponses: @@ -143,16 +144,15 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace return nil, fmt.Errorf("unmarshal request body: %w", err) } if reqPayload.Stream() { - interceptor = responses.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = responses.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } else { - interceptor = responses.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer) + interceptor = responses.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) } default: span.SetStatus(codes.Error, "unknown route: "+r.URL.Path) return nil, UnknownRoute } - interceptor.SetCredential(credKind, utils.MaskSecret(cfg.Key)) span.SetAttributes(interceptor.TraceAttributes(r)...) return interceptor, nil } From be0295df45d398d948a3245686cf486fe1036852 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 2 Apr 2026 15:39:14 -0400 Subject: [PATCH 03/12] Update intercept/credential.go Co-authored-by: Susana Ferreira --- intercept/credential.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/intercept/credential.go b/intercept/credential.go index 838d0c1e..4b082fc5 100644 --- a/intercept/credential.go +++ b/intercept/credential.go @@ -3,8 +3,8 @@ package intercept // Credential kind constants for interception recording. const ( CredentialKindCentralized = "centralized" - CredentialKindPersonalAPIKey = "personal_api_key" - CredentialKindSubscription = "subscription" + CredentialKindPersonalAPIKey = "byok_api_key" + CredentialKindSubscription = "byok_subscription" ) // CredentialFields holds credential metadata for an interception. From 08c98cb8beec2561d09ffaa94a0b244000961ba5 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 2 Apr 2026 19:55:48 +0000 Subject: [PATCH 04/12] refactor: minor refactor --- intercept/chatcompletions/base.go | 4 ++-- intercept/chatcompletions/blocking.go | 2 +- intercept/chatcompletions/streaming.go | 2 +- intercept/chatcompletions/streaming_test.go | 2 +- intercept/credential.go | 4 ++-- intercept/interceptor.go | 2 +- intercept/messages/base.go | 4 ++-- intercept/messages/blocking.go | 2 +- intercept/messages/streaming.go | 2 +- intercept/responses/base.go | 4 ++-- intercept/responses/blocking.go | 2 +- intercept/responses/streaming.go | 2 +- provider/anthropic.go | 2 +- provider/copilot.go | 2 +- provider/openai.go | 2 +- 15 files changed, 19 insertions(+), 19 deletions(-) diff --git a/intercept/chatcompletions/base.go b/intercept/chatcompletions/base.go index 439002b7..75691136 100644 --- a/intercept/chatcompletions/base.go +++ b/intercept/chatcompletions/base.go @@ -40,7 +40,7 @@ type interceptionBase struct { recorder recorder.Recorder mcpProxy mcp.ServerProxier - credential intercept.CredentialFields + credential intercept.CredentialInfo } func (i *interceptionBase) newCompletionsService() openai.ChatCompletionService { @@ -75,7 +75,7 @@ func (i *interceptionBase) ID() uuid.UUID { return i.id } -func (i *interceptionBase) Credential() intercept.CredentialFields { +func (i *interceptionBase) Credential() intercept.CredentialInfo { return i.credential } diff --git a/intercept/chatcompletions/blocking.go b/intercept/chatcompletions/blocking.go index 786ae12d..532addd3 100644 --- a/intercept/chatcompletions/blocking.go +++ b/intercept/chatcompletions/blocking.go @@ -36,7 +36,7 @@ func NewBlockingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, - cred intercept.CredentialFields, + cred intercept.CredentialInfo, ) *BlockingInterception { return &BlockingInterception{interceptionBase: interceptionBase{ id: id, diff --git a/intercept/chatcompletions/streaming.go b/intercept/chatcompletions/streaming.go index dc62fa9d..d7a5485d 100644 --- a/intercept/chatcompletions/streaming.go +++ b/intercept/chatcompletions/streaming.go @@ -41,7 +41,7 @@ func NewStreamingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, - cred intercept.CredentialFields, + cred intercept.CredentialInfo, ) *StreamingInterception { return &StreamingInterception{interceptionBase: interceptionBase{ id: id, diff --git a/intercept/chatcompletions/streaming_test.go b/intercept/chatcompletions/streaming_test.go index a75d8072..ee27f431 100644 --- a/intercept/chatcompletions/streaming_test.go +++ b/intercept/chatcompletions/streaming_test.go @@ -87,7 +87,7 @@ func TestStreamingInterception_RelaysUpstreamErrorToClient(t *testing.T) { httpReq := httptest.NewRequest(http.MethodPost, "/chat/completions", nil) tracer := otel.Tracer("test") - interceptor := NewStreamingInterceptor(uuid.New(), req, config.ProviderOpenAI, cfg, httpReq.Header, "Authorization", tracer, intercept.CredentialFields{}) + interceptor := NewStreamingInterceptor(uuid.New(), req, config.ProviderOpenAI, cfg, httpReq.Header, "Authorization", tracer, intercept.CredentialInfo{}) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) interceptor.Setup(logger, &testutil.MockRecorder{}, nil) diff --git a/intercept/credential.go b/intercept/credential.go index 4b082fc5..934ef8fd 100644 --- a/intercept/credential.go +++ b/intercept/credential.go @@ -7,8 +7,8 @@ const ( CredentialKindSubscription = "byok_subscription" ) -// CredentialFields holds credential metadata for an interception. -type CredentialFields struct { +// CredentialInfo holds credential metadata for an interception. +type CredentialInfo struct { Kind string Hint string } diff --git a/intercept/interceptor.go b/intercept/interceptor.go index 02078370..8b954286 100644 --- a/intercept/interceptor.go +++ b/intercept/interceptor.go @@ -26,7 +26,7 @@ type Interceptor interface { // TraceAttributes returns tracing attributes for this [Interceptor] TraceAttributes(*http.Request) []attribute.KeyValue // Credential returns the credential metadata for this interception. - Credential() CredentialFields + Credential() CredentialInfo // CorrelatingToolCallID returns the ID of a tool call result submitted // in the request, if present. This is used to correlate the current // interception back to the previous interception that issued those tool diff --git a/intercept/messages/base.go b/intercept/messages/base.go index 3108d4aa..a1458b07 100644 --- a/intercept/messages/base.go +++ b/intercept/messages/base.go @@ -79,14 +79,14 @@ type interceptionBase struct { recorder recorder.Recorder mcpProxy mcp.ServerProxier - credential intercept.CredentialFields + credential intercept.CredentialInfo } func (i *interceptionBase) ID() uuid.UUID { return i.id } -func (i *interceptionBase) Credential() intercept.CredentialFields { +func (i *interceptionBase) Credential() intercept.CredentialInfo { return i.credential } diff --git a/intercept/messages/blocking.go b/intercept/messages/blocking.go index 37894e1b..f83f2187 100644 --- a/intercept/messages/blocking.go +++ b/intercept/messages/blocking.go @@ -37,7 +37,7 @@ func NewBlockingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, - cred intercept.CredentialFields, + cred intercept.CredentialInfo, ) *BlockingInterception { return &BlockingInterception{interceptionBase: interceptionBase{ id: id, diff --git a/intercept/messages/streaming.go b/intercept/messages/streaming.go index 49071ed7..760313ec 100644 --- a/intercept/messages/streaming.go +++ b/intercept/messages/streaming.go @@ -43,7 +43,7 @@ func NewStreamingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, - cred intercept.CredentialFields, + cred intercept.CredentialInfo, ) *StreamingInterception { return &StreamingInterception{interceptionBase: interceptionBase{ id: id, diff --git a/intercept/responses/base.go b/intercept/responses/base.go index b38e94b2..9949009c 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -49,7 +49,7 @@ type responsesInterceptionBase struct { logger slog.Logger tracer trace.Tracer - credential intercept.CredentialFields + credential intercept.CredentialInfo } func (i *responsesInterceptionBase) newResponsesService() responses.ResponseService { @@ -84,7 +84,7 @@ func (i *responsesInterceptionBase) ID() uuid.UUID { return i.id } -func (i *responsesInterceptionBase) Credential() intercept.CredentialFields { +func (i *responsesInterceptionBase) Credential() intercept.CredentialInfo { return i.credential } diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index 4981a7e0..9d263dec 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -33,7 +33,7 @@ func NewBlockingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, - cred intercept.CredentialFields, + cred intercept.CredentialInfo, ) *BlockingResponsesInterceptor { return &BlockingResponsesInterceptor{ responsesInterceptionBase: responsesInterceptionBase{ diff --git a/intercept/responses/streaming.go b/intercept/responses/streaming.go index 15aec381..0c692f83 100644 --- a/intercept/responses/streaming.go +++ b/intercept/responses/streaming.go @@ -40,7 +40,7 @@ func NewStreamingInterceptor( clientHeaders http.Header, authHeaderName string, tracer trace.Tracer, - cred intercept.CredentialFields, + cred intercept.CredentialInfo, ) *StreamingResponsesInterceptor { return &StreamingResponsesInterceptor{ responsesInterceptionBase: responsesInterceptionBase{ diff --git a/provider/anthropic.go b/provider/anthropic.go index daa42ff8..b34a1224 100644 --- a/provider/anthropic.go +++ b/provider/anthropic.go @@ -146,7 +146,7 @@ func (p *Anthropic) CreateInterceptor(w http.ResponseWriter, r *http.Request, tr credHint = utils.MaskSecret(token) } - cred := intercept.CredentialFields{Kind: credKind, Hint: credHint} + cred := intercept.CredentialInfo{Kind: credKind, Hint: credHint} var interceptor intercept.Interceptor if reqPayload.Stream() { diff --git a/provider/copilot.go b/provider/copilot.go index 52b6ee70..2b83da23 100644 --- a/provider/copilot.go +++ b/provider/copilot.go @@ -145,7 +145,7 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac ExtraHeaders: extractCopilotHeaders(r), } - cred := intercept.CredentialFields{Kind: intercept.CredentialKindSubscription, Hint: utils.MaskSecret(key)} + cred := intercept.CredentialInfo{Kind: intercept.CredentialKindSubscription, Hint: utils.MaskSecret(key)} var interceptor intercept.Interceptor diff --git a/provider/openai.go b/provider/openai.go index c50c9cf6..e231358e 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -118,7 +118,7 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace cfg.Key = token credKind = intercept.CredentialKindPersonalAPIKey } - cred := intercept.CredentialFields{Kind: credKind, Hint: utils.MaskSecret(cfg.Key)} + cred := intercept.CredentialInfo{Kind: credKind, Hint: utils.MaskSecret(cfg.Key)} path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) switch path { From 11709dba6b93e06a52281163aad90a4e25cd5232 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 2 Apr 2026 20:11:54 +0000 Subject: [PATCH 05/12] refactor: make secret is always called --- intercept/credential.go | 12 ++++++++++++ provider/anthropic.go | 8 ++++---- provider/copilot.go | 2 +- provider/openai.go | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/intercept/credential.go b/intercept/credential.go index 934ef8fd..a2291a8b 100644 --- a/intercept/credential.go +++ b/intercept/credential.go @@ -1,5 +1,7 @@ package intercept +import "github.com/coder/aibridge/utils" + // Credential kind constants for interception recording. const ( CredentialKindCentralized = "centralized" @@ -12,3 +14,13 @@ type CredentialInfo struct { Kind string Hint string } + +// NewCredentialInfo creates a CredentialInfo from a raw credential. +// The credential is automatically masked before storage so that the +// original secret is never retained. +func NewCredentialInfo(kind, credential string) CredentialInfo { + return CredentialInfo{ + Kind: kind, + Hint: utils.MaskSecret(credential), + } +} diff --git a/provider/anthropic.go b/provider/anthropic.go index b34a1224..a4153d6b 100644 --- a/provider/anthropic.go +++ b/provider/anthropic.go @@ -131,22 +131,22 @@ func (p *Anthropic) CreateInterceptor(w http.ResponseWriter, r *http.Request, tr // When both are present, X-Api-Key takes priority to match // claude-code behavior. credKind := intercept.CredentialKindCentralized - credHint := utils.MaskSecret(cfg.Key) + credSecret := cfg.Key authHeaderName := p.AuthHeader() if apiKey := r.Header.Get("X-Api-Key"); apiKey != "" { cfg.Key = apiKey authHeaderName = "X-Api-Key" credKind = intercept.CredentialKindPersonalAPIKey - credHint = utils.MaskSecret(apiKey) + credSecret = apiKey } else if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" { cfg.BYOKBearerToken = token cfg.Key = "" authHeaderName = "Authorization" credKind = intercept.CredentialKindSubscription - credHint = utils.MaskSecret(token) + credSecret = token } - cred := intercept.CredentialInfo{Kind: credKind, Hint: credHint} + cred := intercept.NewCredentialInfo(credKind, credSecret) var interceptor intercept.Interceptor if reqPayload.Stream() { diff --git a/provider/copilot.go b/provider/copilot.go index 2b83da23..a9675f1d 100644 --- a/provider/copilot.go +++ b/provider/copilot.go @@ -145,7 +145,7 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac ExtraHeaders: extractCopilotHeaders(r), } - cred := intercept.CredentialInfo{Kind: intercept.CredentialKindSubscription, Hint: utils.MaskSecret(key)} + cred := intercept.NewCredentialInfo(intercept.CredentialKindSubscription, key) var interceptor intercept.Interceptor diff --git a/provider/openai.go b/provider/openai.go index e231358e..03528912 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -118,7 +118,7 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace cfg.Key = token credKind = intercept.CredentialKindPersonalAPIKey } - cred := intercept.CredentialInfo{Kind: credKind, Hint: utils.MaskSecret(cfg.Key)} + cred := intercept.NewCredentialInfo(credKind, cfg.Key) path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) switch path { From 74503bd74cb61455bb2c5a5edfec38f55fdb3908 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 2 Apr 2026 20:20:06 +0000 Subject: [PATCH 06/12] refactor: add CredentialKind custom type --- bridge.go | 2 +- intercept/credential.go | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bridge.go b/bridge.go index 2b74a46c..9ab3a164 100644 --- a/bridge.go +++ b/bridge.go @@ -228,7 +228,7 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC Client: string(client), ClientSessionID: sessionID, CorrelatingToolCallID: interceptor.CorrelatingToolCallID(), - CredentialKind: interceptor.Credential().Kind, + CredentialKind: string(interceptor.Credential().Kind), CredentialHint: interceptor.Credential().Hint, }); err != nil { span.SetStatus(codes.Error, fmt.Sprintf("failed to record interception: %v", err)) diff --git a/intercept/credential.go b/intercept/credential.go index a2291a8b..1e7252a3 100644 --- a/intercept/credential.go +++ b/intercept/credential.go @@ -2,23 +2,26 @@ package intercept import "github.com/coder/aibridge/utils" +// CredentialKind identifies how a request was authenticated. +type CredentialKind string + // Credential kind constants for interception recording. const ( - CredentialKindCentralized = "centralized" - CredentialKindPersonalAPIKey = "byok_api_key" - CredentialKindSubscription = "byok_subscription" + CredentialKindCentralized CredentialKind = "centralized" + CredentialKindPersonalAPIKey CredentialKind = "byok_api_key" + CredentialKindSubscription CredentialKind = "byok_subscription" ) // CredentialInfo holds credential metadata for an interception. type CredentialInfo struct { - Kind string + Kind CredentialKind Hint string } // NewCredentialInfo creates a CredentialInfo from a raw credential. // The credential is automatically masked before storage so that the // original secret is never retained. -func NewCredentialInfo(kind, credential string) CredentialInfo { +func NewCredentialInfo(kind CredentialKind, credential string) CredentialInfo { return CredentialInfo{ Kind: kind, Hint: utils.MaskSecret(credential), From a2e538f52765c3c94f53fbb8badf4e755832d0c5 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 2 Apr 2026 20:47:37 +0000 Subject: [PATCH 07/12] test: enhance tests --- provider/anthropic_test.go | 38 ++++++++++------ provider/openai_test.go | 88 ++++++++++++++++++++++---------------- 2 files changed, 74 insertions(+), 52 deletions(-) diff --git a/provider/anthropic_test.go b/provider/anthropic_test.go index 3269c33d..e3347d2b 100644 --- a/provider/anthropic_test.go +++ b/provider/anthropic_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/aibridge/config" + "github.com/coder/aibridge/intercept" "github.com/coder/aibridge/internal/testutil" ) @@ -163,25 +164,29 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { t.Parallel() tests := []struct { - name string - setHeaders map[string]string - wantXApiKey string - wantAuthorization string + name string + setHeaders map[string]string + wantXApiKey string + wantAuthorization string + wantCredentialKind intercept.CredentialKind }{ { - name: "Messages_BYOK_BearerToken", - setHeaders: map[string]string{"Authorization": "Bearer user-access-token"}, - wantAuthorization: "Bearer user-access-token", + name: "Messages_BYOK_BearerToken", + setHeaders: map[string]string{"Authorization": "Bearer user-access-token"}, + wantAuthorization: "Bearer user-access-token", + wantCredentialKind: intercept.CredentialKindSubscription, }, { - name: "Messages_BYOK_APIKey", - setHeaders: map[string]string{"X-Api-Key": "user-api-key"}, - wantXApiKey: "user-api-key", + name: "Messages_BYOK_APIKey", + setHeaders: map[string]string{"X-Api-Key": "user-api-key"}, + wantXApiKey: "user-api-key", + wantCredentialKind: intercept.CredentialKindPersonalAPIKey, }, { - name: "Messages_Centralized_UsesCentralizedKey", - setHeaders: map[string]string{}, - wantXApiKey: "test-key", + name: "Messages_Centralized_UsesCentralizedKey", + setHeaders: map[string]string{}, + wantXApiKey: "test-key", + wantCredentialKind: intercept.CredentialKindCentralized, }, { name: "Messages_BYOK_BearerToken_And_APIKey", @@ -189,7 +194,8 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { "Authorization": "Bearer user-access-token", "X-Api-Key": "user-api-key", }, - wantXApiKey: "user-api-key", + wantXApiKey: "user-api-key", + wantCredentialKind: intercept.CredentialKindPersonalAPIKey, }, } @@ -223,6 +229,10 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { require.NoError(t, err) require.NotNil(t, interceptor) + cred := interceptor.Credential() + assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch") + assert.NotEmpty(t, cred.Hint, "credential hint should not be empty") + logger := slog.Make() interceptor.Setup(logger, &testutil.MockRecorder{}, nil) diff --git a/provider/openai_test.go b/provider/openai_test.go index dcdd2831..c586810a 100644 --- a/provider/openai_test.go +++ b/provider/openai_test.go @@ -11,6 +11,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/aibridge/config" + "github.com/coder/aibridge/intercept" "github.com/coder/aibridge/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -197,68 +198,75 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { t.Parallel() tests := []struct { - name string - route string - requestBody string - responseBody string - setHeaders map[string]string - wantAuthorization string + name string + route string + requestBody string + responseBody string + setHeaders map[string]string + wantAuthorization string + wantCredentialKind intercept.CredentialKind }{ { - name: "ChatCompletions_BYOK", - route: routeChatCompletions, - requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, - responseBody: chatCompletionResponse, - setHeaders: map[string]string{"Authorization": "Bearer user-token"}, - wantAuthorization: "Bearer user-token", + name: "ChatCompletions_BYOK", + route: routeChatCompletions, + requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, + responseBody: chatCompletionResponse, + setHeaders: map[string]string{"Authorization": "Bearer user-token"}, + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindPersonalAPIKey, }, { - name: "ChatCompletions_Centralized", - route: routeChatCompletions, - requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, - responseBody: chatCompletionResponse, - setHeaders: map[string]string{}, - wantAuthorization: "Bearer centralized-key", + name: "ChatCompletions_Centralized", + route: routeChatCompletions, + requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, + responseBody: chatCompletionResponse, + setHeaders: map[string]string{}, + wantAuthorization: "Bearer centralized-key", + wantCredentialKind: intercept.CredentialKindCentralized, }, { - name: "Responses_BYOK", - route: routeResponses, - requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, - responseBody: responsesAPIResponse, - setHeaders: map[string]string{"Authorization": "Bearer user-token"}, - wantAuthorization: "Bearer user-token", + name: "Responses_BYOK", + route: routeResponses, + requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, + responseBody: responsesAPIResponse, + setHeaders: map[string]string{"Authorization": "Bearer user-token"}, + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindPersonalAPIKey, }, { - name: "Responses_Centralized", - route: routeResponses, - requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, - responseBody: responsesAPIResponse, - setHeaders: map[string]string{}, - wantAuthorization: "Bearer centralized-key", + name: "Responses_Centralized", + route: routeResponses, + requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, + responseBody: responsesAPIResponse, + setHeaders: map[string]string{}, + wantAuthorization: "Bearer centralized-key", + wantCredentialKind: intercept.CredentialKindCentralized, }, // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. { - name: "ChatCompletions_BYOK_XApiKeyStripped", - route: routeChatCompletions, - requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, + name: "ChatCompletions_BYOK_XApiKeyStripped", + route: routeChatCompletions, + requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, responseBody: chatCompletionResponse, setHeaders: map[string]string{ "Authorization": "Bearer user-token", "X-Api-Key": "some-key", }, - wantAuthorization: "Bearer user-token", + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindPersonalAPIKey, }, { - name: "Responses_BYOK_XApiKeyStripped", - route: routeResponses, - requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, + name: "Responses_BYOK_XApiKeyStripped", + route: routeResponses, + requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, responseBody: responsesAPIResponse, setHeaders: map[string]string{ "Authorization": "Bearer user-token", "X-Api-Key": "some-key", }, - wantAuthorization: "Bearer user-token", + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindPersonalAPIKey, }, } @@ -292,6 +300,10 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { require.NoError(t, err) require.NotNil(t, interceptor) + cred := interceptor.Credential() + assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch") + assert.NotEmpty(t, cred.Hint, "credential hint should not be empty") + logger := slog.Make() interceptor.Setup(logger, &testutil.MockRecorder{}, nil) From c963324ad63c35d51fe882cfbad6c5d62f4f25d5 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 3 Apr 2026 14:05:22 +0000 Subject: [PATCH 08/12] test: enhance tests --- provider/anthropic_test.go | 7 ++++++- provider/openai_test.go | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/provider/anthropic_test.go b/provider/anthropic_test.go index e3347d2b..289cbf95 100644 --- a/provider/anthropic_test.go +++ b/provider/anthropic_test.go @@ -169,24 +169,28 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { wantXApiKey string wantAuthorization string wantCredentialKind intercept.CredentialKind + wantCredentialHint string }{ { name: "Messages_BYOK_BearerToken", setHeaders: map[string]string{"Authorization": "Bearer user-access-token"}, wantAuthorization: "Bearer user-access-token", wantCredentialKind: intercept.CredentialKindSubscription, + wantCredentialHint: "us...(13)...en", }, { name: "Messages_BYOK_APIKey", setHeaders: map[string]string{"X-Api-Key": "user-api-key"}, wantXApiKey: "user-api-key", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, + wantCredentialHint: "us...(8)...ey", }, { name: "Messages_Centralized_UsesCentralizedKey", setHeaders: map[string]string{}, wantXApiKey: "test-key", wantCredentialKind: intercept.CredentialKindCentralized, + wantCredentialHint: "...(8)...", }, { name: "Messages_BYOK_BearerToken_And_APIKey", @@ -196,6 +200,7 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { }, wantXApiKey: "user-api-key", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, + wantCredentialHint: "us...(8)...ey", }, } @@ -231,7 +236,7 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { cred := interceptor.Credential() assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch") - assert.NotEmpty(t, cred.Hint, "credential hint should not be empty") + assert.Equal(t, tc.wantCredentialHint, cred.Hint, "credential hint mismatch") logger := slog.Make() interceptor.Setup(logger, &testutil.MockRecorder{}, nil) diff --git a/provider/openai_test.go b/provider/openai_test.go index c586810a..c9eef53e 100644 --- a/provider/openai_test.go +++ b/provider/openai_test.go @@ -205,6 +205,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders map[string]string wantAuthorization string wantCredentialKind intercept.CredentialKind + wantCredentialHint string }{ { name: "ChatCompletions_BYOK", @@ -214,6 +215,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{"Authorization": "Bearer user-token"}, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, + wantCredentialHint: "us...(6)...en", }, { name: "ChatCompletions_Centralized", @@ -223,6 +225,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, + wantCredentialHint: "ce...(11)...ey", }, { name: "Responses_BYOK", @@ -232,6 +235,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{"Authorization": "Bearer user-token"}, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, + wantCredentialHint: "us...(6)...en", }, { name: "Responses_Centralized", @@ -241,6 +245,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, + wantCredentialHint: "ce...(11)...ey", }, // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. @@ -255,6 +260,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { }, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, + wantCredentialHint: "us...(6)...en", }, { name: "Responses_BYOK_XApiKeyStripped", @@ -267,6 +273,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { }, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, + wantCredentialHint: "us...(6)...en", }, } @@ -302,7 +309,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { cred := interceptor.Credential() assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch") - assert.NotEmpty(t, cred.Hint, "credential hint should not be empty") + assert.Equal(t, tc.wantCredentialHint, cred.Hint, "credential hint mismatch") logger := slog.Make() interceptor.Setup(logger, &testutil.MockRecorder{}, nil) From 3f63615154a828d01393e075739fcacf502932f1 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 3 Apr 2026 14:30:13 +0000 Subject: [PATCH 09/12] ci: make fmt --- provider/openai_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/provider/openai_test.go b/provider/openai_test.go index c9eef53e..ca15bc1f 100644 --- a/provider/openai_test.go +++ b/provider/openai_test.go @@ -250,9 +250,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. { - name: "ChatCompletions_BYOK_XApiKeyStripped", - route: routeChatCompletions, - requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, + name: "ChatCompletions_BYOK_XApiKeyStripped", + route: routeChatCompletions, + requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, responseBody: chatCompletionResponse, setHeaders: map[string]string{ "Authorization": "Bearer user-token", @@ -263,9 +263,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { wantCredentialHint: "us...(6)...en", }, { - name: "Responses_BYOK_XApiKeyStripped", - route: routeResponses, - requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, + name: "Responses_BYOK_XApiKeyStripped", + route: routeResponses, + requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, responseBody: responsesAPIResponse, setHeaders: map[string]string{ "Authorization": "Bearer user-token", From d6594a282b1cfd0159dbfb18c8a3f93040d4c354 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 3 Apr 2026 15:14:33 +0000 Subject: [PATCH 10/12] fix: revert MaskSecret changes --- provider/anthropic_test.go | 8 ++++---- provider/openai_test.go | 12 ++++++------ utils/mask.go | 11 +++++------ utils/mask_test.go | 14 ++++++-------- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/provider/anthropic_test.go b/provider/anthropic_test.go index 289cbf95..1ff9b16c 100644 --- a/provider/anthropic_test.go +++ b/provider/anthropic_test.go @@ -176,21 +176,21 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { setHeaders: map[string]string{"Authorization": "Bearer user-access-token"}, wantAuthorization: "Bearer user-access-token", wantCredentialKind: intercept.CredentialKindSubscription, - wantCredentialHint: "us...(13)...en", + wantCredentialHint: "us*************en", }, { name: "Messages_BYOK_APIKey", setHeaders: map[string]string{"X-Api-Key": "user-api-key"}, wantXApiKey: "user-api-key", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, - wantCredentialHint: "us...(8)...ey", + wantCredentialHint: "us********ey", }, { name: "Messages_Centralized_UsesCentralizedKey", setHeaders: map[string]string{}, wantXApiKey: "test-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "...(8)...", + wantCredentialHint: "********", }, { name: "Messages_BYOK_BearerToken_And_APIKey", @@ -200,7 +200,7 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { }, wantXApiKey: "user-api-key", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, - wantCredentialHint: "us...(8)...ey", + wantCredentialHint: "us********ey", }, } diff --git a/provider/openai_test.go b/provider/openai_test.go index ca15bc1f..d06079da 100644 --- a/provider/openai_test.go +++ b/provider/openai_test.go @@ -215,7 +215,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{"Authorization": "Bearer user-token"}, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, - wantCredentialHint: "us...(6)...en", + wantCredentialHint: "us******en", }, { name: "ChatCompletions_Centralized", @@ -225,7 +225,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...(11)...ey", + wantCredentialHint: "ce***********ey", }, { name: "Responses_BYOK", @@ -235,7 +235,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{"Authorization": "Bearer user-token"}, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, - wantCredentialHint: "us...(6)...en", + wantCredentialHint: "us******en", }, { name: "Responses_Centralized", @@ -245,7 +245,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...(11)...ey", + wantCredentialHint: "ce***********ey", }, // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. @@ -260,7 +260,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { }, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, - wantCredentialHint: "us...(6)...en", + wantCredentialHint: "us******en", }, { name: "Responses_BYOK_XApiKeyStripped", @@ -273,7 +273,7 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { }, wantAuthorization: "Bearer user-token", wantCredentialKind: intercept.CredentialKindPersonalAPIKey, - wantCredentialHint: "us...(6)...en", + wantCredentialHint: "us******en", }, } diff --git a/utils/mask.go b/utils/mask.go index 4ed60c53..108aae2c 100644 --- a/utils/mask.go +++ b/utils/mask.go @@ -1,11 +1,10 @@ package utils -import "fmt" +import "strings" // MaskSecret masks the middle of a secret string, revealing a small -// prefix and suffix for identification. The number of hidden characters -// is embedded in the masked portion (e.g. "sk-a...(21)...efgh"). -// The number of characters revealed scales with string length. +// prefix and suffix for identification. The number of characters +// revealed scales with string length. func MaskSecret(s string) string { if s == "" { return "" @@ -16,13 +15,13 @@ func MaskSecret(s string) string { // If we'd reveal everything or more, mask it all. if reveal*2 >= len(runes) { - return fmt.Sprintf("...(%d)...", len(runes)) + return strings.Repeat("*", len(runes)) } prefix := string(runes[:reveal]) suffix := string(runes[len(runes)-reveal:]) masked := len(runes) - reveal*2 - return prefix + fmt.Sprintf("...(%d)...", masked) + suffix + return prefix + strings.Repeat("*", masked) + suffix } // revealLength returns the number of runes to show at each end. diff --git a/utils/mask_test.go b/utils/mask_test.go index 5db8fc71..d481fd02 100644 --- a/utils/mask_test.go +++ b/utils/mask_test.go @@ -1,7 +1,6 @@ package utils_test import ( - "strings" "testing" "github.com/coder/aibridge/utils" @@ -17,13 +16,12 @@ func TestMaskSecret(t *testing.T) { expected string }{ {"empty", "", ""}, - {"short", "short", "...(5)..."}, - {"short_9_chars", "veryshort", "...(9)..."}, - {"medium_15_chars", "thisisquitelong", "th...(11)...ng"}, - {"long_api_key", "sk-ant-api03-abcdefgh", "sk-a...(13)...efgh"}, - {"unicode", "hélloworld🌍!", "hé...(8)...🌍!"}, - {"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_...(30)...efgh"}, - {"jwt_300_chars", "eyJh" + strings.Repeat("a", 292) + "Xk8s", "eyJh...(292)...Xk8s"}, + {"short", "short", "*****"}, + {"short_9_chars", "veryshort", "*********"}, + {"medium_15_chars", "thisisquitelong", "th***********ng"}, + {"long_api_key", "sk-ant-api03-abcdefgh", "sk-a*************efgh"}, + {"unicode", "hélloworld🌍!", "hé********🌍!"}, + {"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_******************************efgh"}, } for _, tc := range tests { From 43ee7279dd21d62e2561ffdfc2074a8080ce2eae Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 3 Apr 2026 19:32:07 +0000 Subject: [PATCH 11/12] fix: add small comment to enum --- intercept/credential.go | 1 + 1 file changed, 1 insertion(+) diff --git a/intercept/credential.go b/intercept/credential.go index 1e7252a3..e652c21d 100644 --- a/intercept/credential.go +++ b/intercept/credential.go @@ -3,6 +3,7 @@ package intercept import "github.com/coder/aibridge/utils" // CredentialKind identifies how a request was authenticated. +// Keep in sync with the credential_kind enum in coderd's database. type CredentialKind string // Credential kind constants for interception recording. From 9faf167ec58986e98c1863d9d717c26c148ddc79 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 3 Apr 2026 20:33:27 +0000 Subject: [PATCH 12/12] feat: log credential info on interception errors --- bridge.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bridge.go b/bridge.go index 9ab3a164..21f4b6cd 100644 --- a/bridge.go +++ b/bridge.go @@ -217,6 +217,7 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC asyncRecorder.WithClient(string(client)) interceptor.Setup(logger, asyncRecorder, mcpProxy) + cred := interceptor.Credential() if err := rec.RecordInterception(ctx, &recorder.InterceptionRecord{ ID: interceptor.ID().String(), InitiatorID: actor.ID, @@ -228,8 +229,8 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC Client: string(client), ClientSessionID: sessionID, CorrelatingToolCallID: interceptor.CorrelatingToolCallID(), - CredentialKind: string(interceptor.Credential().Kind), - CredentialHint: interceptor.Credential().Hint, + CredentialKind: string(cred.Kind), + CredentialHint: cred.Hint, }); err != nil { span.SetStatus(codes.Error, fmt.Sprintf("failed to record interception: %v", err)) logger.Warn(ctx, "failed to record interception", slog.Error(err)) @@ -244,6 +245,8 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC slog.F("interception_id", interceptor.ID()), slog.F("user_agent", r.UserAgent()), slog.F("streaming", interceptor.Streaming()), + slog.F("credential_kind", string(cred.Kind)), + slog.F("credential_hint", cred.Hint), ) log.Debug(ctx, "interception started")