diff --git a/bridge.go b/bridge.go index 4d79fba5..3b6a964d 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,6 +229,8 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC Client: string(client), ClientSessionID: sessionID, CorrelatingToolCallID: interceptor.CorrelatingToolCallID(), + 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)) @@ -242,6 +245,9 @@ 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), + slog.F("credential_length", cred.Length), ) log.Debug(ctx, "interception started") diff --git a/intercept/chatcompletions/base.go b/intercept/chatcompletions/base.go index 9c784e9f..75691136 100644 --- a/intercept/chatcompletions/base.go +++ b/intercept/chatcompletions/base.go @@ -38,8 +38,9 @@ type interceptionBase struct { logger slog.Logger tracer trace.Tracer - recorder recorder.Recorder - mcpProxy mcp.ServerProxier + recorder recorder.Recorder + mcpProxy mcp.ServerProxier + credential intercept.CredentialInfo } func (i *interceptionBase) newCompletionsService() openai.ChatCompletionService { @@ -74,6 +75,10 @@ func (i *interceptionBase) ID() uuid.UUID { return i.id } +func (i *interceptionBase) Credential() intercept.CredentialInfo { + 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..532addd3 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.CredentialInfo, ) *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..d7a5485d 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.CredentialInfo, ) *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..ee27f431 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.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 new file mode 100644 index 00000000..c008beba --- /dev/null +++ b/intercept/credential.go @@ -0,0 +1,31 @@ +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. +const ( + CredentialKindCentralized CredentialKind = "centralized" + CredentialKindBYOK CredentialKind = "byok" +) + +// CredentialInfo holds credential metadata for an interception. +type CredentialInfo struct { + Kind CredentialKind + Hint string + Length int +} + +// 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 CredentialKind, credential string) CredentialInfo { + return CredentialInfo{ + Kind: kind, + Hint: utils.MaskSecret(credential), + Length: len(credential), + } +} diff --git a/intercept/interceptor.go b/intercept/interceptor.go index cbd29d62..8b954286 100644 --- a/intercept/interceptor.go +++ b/intercept/interceptor.go @@ -25,6 +25,8 @@ type Interceptor interface { Streaming() bool // TraceAttributes returns tracing attributes for this [Interceptor] TraceAttributes(*http.Request) []attribute.KeyValue + // Credential returns the credential metadata for this interception. + 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 ccbd91ba..a1458b07 100644 --- a/intercept/messages/base.go +++ b/intercept/messages/base.go @@ -77,14 +77,19 @@ type interceptionBase struct { tracer trace.Tracer logger slog.Logger - recorder recorder.Recorder - mcpProxy mcp.ServerProxier + recorder recorder.Recorder + mcpProxy mcp.ServerProxier + credential intercept.CredentialInfo } func (i *interceptionBase) ID() uuid.UUID { return i.id } +func (i *interceptionBase) Credential() intercept.CredentialInfo { + 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..f83f2187 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.CredentialInfo, ) *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..760313ec 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.CredentialInfo, ) *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 1dc0e8d6..9949009c 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -47,8 +47,9 @@ type responsesInterceptionBase struct { recorder recorder.Recorder mcpProxy mcp.ServerProxier - logger slog.Logger - tracer trace.Tracer + logger slog.Logger + tracer trace.Tracer + credential intercept.CredentialInfo } func (i *responsesInterceptionBase) newResponsesService() responses.ResponseService { @@ -83,6 +84,10 @@ func (i *responsesInterceptionBase) ID() uuid.UUID { return i.id } +func (i *responsesInterceptionBase) Credential() intercept.CredentialInfo { + 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..9d263dec 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.CredentialInfo, ) *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..0c692f83 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.CredentialInfo, ) *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 2800220c..44870c63 100644 --- a/provider/anthropic.go +++ b/provider/anthropic.go @@ -130,21 +130,29 @@ 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 + credSecret := cfg.Key authHeaderName := p.AuthHeader() if apiKey := r.Header.Get("X-Api-Key"); apiKey != "" { cfg.Key = apiKey authHeaderName = "X-Api-Key" + credKind = intercept.CredentialKindBYOK + credSecret = apiKey } else if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" { cfg.BYOKBearerToken = token cfg.Key = "" authHeaderName = "Authorization" + credKind = intercept.CredentialKindBYOK + credSecret = token } + cred := intercept.NewCredentialInfo(credKind, credSecret) + 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) } span.SetAttributes(interceptor.TraceAttributes(r)...) return interceptor, nil diff --git a/provider/anthropic_test.go b/provider/anthropic_test.go index 3269c33d..bc240a46 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,33 @@ 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 + wantCredentialHint string }{ { - 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.CredentialKindBYOK, + wantCredentialHint: "us...en", }, { - 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.CredentialKindBYOK, + wantCredentialHint: "us...ey", }, { - name: "Messages_Centralized_UsesCentralizedKey", - setHeaders: map[string]string{}, - wantXApiKey: "test-key", + name: "Messages_Centralized", + setHeaders: map[string]string{}, + wantXApiKey: "test-key", + wantCredentialKind: intercept.CredentialKindCentralized, + wantCredentialHint: "***", }, { name: "Messages_BYOK_BearerToken_And_APIKey", @@ -189,7 +198,9 @@ 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.CredentialKindBYOK, + wantCredentialHint: "us...ey", }, } @@ -223,6 +234,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.Equal(t, tc.wantCredentialHint, cred.Hint, "credential hint mismatch") + logger := slog.Make() interceptor.Setup(logger, &testutil.MockRecorder{}, nil) diff --git a/provider/copilot.go b/provider/copilot.go index eeeb74d4..a7df6e8b 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.NewCredentialInfo(intercept.CredentialKindBYOK, 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: diff --git a/provider/openai.go b/provider/openai.go index 1cd85912..a8e86216 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -113,9 +113,12 @@ 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.CredentialKindBYOK } + cred := intercept.NewCredentialInfo(credKind, cfg.Key) path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) switch path { @@ -126,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: @@ -141,9 +144,9 @@ 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: diff --git a/provider/openai_test.go b/provider/openai_test.go index dcdd2831..0c715cc8 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,44 +198,54 @@ 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 + wantCredentialHint string }{ { - 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.CredentialKindBYOK, + wantCredentialHint: "us...en", }, { - 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, + wantCredentialHint: "ce...ey", }, { - 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.CredentialKindBYOK, + wantCredentialHint: "us...en", }, { - 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, + wantCredentialHint: "ce...ey", }, // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. @@ -247,7 +258,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { "Authorization": "Bearer user-token", "X-Api-Key": "some-key", }, - wantAuthorization: "Bearer user-token", + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...en", }, { name: "Responses_BYOK_XApiKeyStripped", @@ -258,7 +271,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { "Authorization": "Bearer user-token", "X-Api-Key": "some-key", }, - wantAuthorization: "Bearer user-token", + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...en", }, } @@ -292,6 +307,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.Equal(t, tc.wantCredentialHint, cred.Hint, "credential hint mismatch") + logger := slog.Make() interceptor.Setup(logger, &testutil.MockRecorder{}, 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..4c249c93 100644 --- a/utils/mask.go +++ b/utils/mask.go @@ -1,7 +1,5 @@ package utils -import "strings" - // 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. @@ -13,15 +11,14 @@ func MaskSecret(s string) string { runes := []rune(s) reveal := revealLength(len(runes)) - // If we'd reveal everything or more, mask it all. - if reveal*2 >= len(runes) { - return strings.Repeat("*", len(runes)) + // If there's nothing safe to reveal, mask it all. + if reveal == 0 || reveal*2 >= len(runes) { + return "***" } prefix := string(runes[:reveal]) suffix := string(runes[len(runes)-reveal:]) - masked := len(runes) - reveal*2 - return prefix + strings.Repeat("*", masked) + suffix + return prefix + "..." + 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..f71b8cf3 100644 --- a/utils/mask_test.go +++ b/utils/mask_test.go @@ -16,12 +16,12 @@ 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", "***"}, + {"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 {