Skip to content

feat: byok-observability for aibridge#239

Open
evgeniy-scherbina wants to merge 12 commits intomainfrom
yevhenii/byok-observability
Open

feat: byok-observability for aibridge#239
evgeniy-scherbina wants to merge 12 commits intomainfrom
yevhenii/byok-observability

Conversation

@evgeniy-scherbina
Copy link
Copy Markdown
Contributor

@evgeniy-scherbina evgeniy-scherbina commented Mar 30, 2026

Summary

Records credential metadata on each AI Bridge interception, enabling admins to see how requests were authenticated and identify which credential was used.

Each provider's CreateInterceptor now captures two new fields on the interceptor:

  • credential_kind: centralized, personal_api_key, or subscription
  • credential_hint: masked credential for audit (e.g. sk-a...(13)...efgh)

Changes

  • Extended Interceptor interface with SetCredential, CredentialKind, and CredentialHint, backed by an embeddable CredentialFields helper
  • Each provider sets credential metadata in CreateInterceptor:
    • OpenAI: centralized (admin key) or personal (Bearer token)
    • Anthropic: centralized, personal_api_key (X-Api-Key), or subscription (Bearer token)
    • Copilot: always subscription
  • Improved MaskSecret to embed hidden character count (e.g. sk-a...(13)...efgh instead of sk-a*************efgh)

Relates to coder/coder#23808

@evgeniy-scherbina evgeniy-scherbina force-pushed the yevhenii/byok-observability branch from 9bf93dd to c5dd566 Compare March 30, 2026 22:37
@evgeniy-scherbina evgeniy-scherbina changed the base branch from yevhenii/aibridge-chatgpt-provider to ssncf/feat-validate-provider-names March 30, 2026 22:38
@ssncferreira ssncferreira force-pushed the ssncf/feat-validate-provider-names branch 2 times, most recently from 71c69d3 to 30d306a Compare March 31, 2026 10:48
@evgeniy-scherbina evgeniy-scherbina force-pushed the yevhenii/byok-observability branch from c5dd566 to d6e95ea Compare March 31, 2026 14:00
@ssncferreira ssncferreira force-pushed the ssncf/feat-validate-provider-names branch from 7d0c198 to b1c6f3f Compare March 31, 2026 14:36
@evgeniy-scherbina evgeniy-scherbina force-pushed the yevhenii/byok-observability branch from d6e95ea to b6a01e9 Compare March 31, 2026 16:47
@evgeniy-scherbina evgeniy-scherbina changed the base branch from ssncf/feat-validate-provider-names to main March 31, 2026 16:47
@evgeniy-scherbina evgeniy-scherbina force-pushed the yevhenii/byok-observability branch from e1e15df to b6a01e9 Compare March 31, 2026 17:38
@evgeniy-scherbina evgeniy-scherbina force-pushed the yevhenii/byok-observability branch from a610090 to d3e5994 Compare March 31, 2026 19:20
return nil, UnknownRoute
}

interceptor.SetCredential(intercept.CredentialKindSubscription, utils.MaskSecret(key))
Copy link
Copy Markdown
Contributor Author

@evgeniy-scherbina evgeniy-scherbina Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CredentialKindSubscription makes sense for Copilot?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was also thinking the same 🤔 I think subscription is not a really generic term and doesn't quite apply to copilot. Since for the header X-API-Key we use the term api_key, why not something like auth_token?

Copy link
Copy Markdown
Contributor Author

@evgeniy-scherbina evgeniy-scherbina Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think subscription is widely used term, e.g.:

Now I started to think it's probably fine to use this term?


auth_token sounds good, but I think not everyone can link auth_token to Claude Pro/Max or ChatGPT Pro/Plus subscriptions. So it maybe a bit confusing for auditors?


I wouldn't rely on headers, X-API-Key is used by Anthropic. But OpenAI uses Authorization for everything.

credKind := intercept.CredentialKindCentralized
if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" {
cfg.Key = token
credKind = intercept.CredentialKindPersonalAPIKey
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit tricky, it can be either CredentialKindPersonalAPIKey or CredentialKindSubscription.
We can rely on some hacks to determine it:

  • Presence of Chatgpt-Account-Id header
  • Check is it JWT Token or API KEY
  • Rely on URL (openai vs chatgpt)

See more details here: #227 (comment)

Alternatively we can introduce new enum value, smth like: CredentialKindPersonal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make the modes header specific? If the Authorization header is used use mode auth_token, otherweise, if the X-API-Key header is used, use mode api_key? This would mean that for openai (default) and openai chatgpt both modes would be auth_token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are few problems with that:

  1. I think headers are implementation details, auditors don't really know/care what headers are used?
  2. Different headers are used by different providers. E.g. OpenAI doesn't use X-API-Key.
  3. I think we want to differentiate between Personal API Key and ChatGPT Subscription for OpenAI provider?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dannykopping The question is do we want to differentiate between Personal API Key and ChatGPT Subscription for OpenAI.

If the answer is yes - I think we should rely on Chatgpt-Account-Id header. That's what I recommend.
if the answer is no - we can either introduce new CredentialKindPersonal type which encompasses both:

  • CredentialKindPersonalAPIKey
  • CredentialKindSubscription

or go with Susana's approach (though I think it's less informative/obvious for users).

return nil, UnknownRoute
}

interceptor.SetCredential(intercept.CredentialKindSubscription, utils.MaskSecret(key))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was also thinking the same 🤔 I think subscription is not a really generic term and doesn't quite apply to copilot. Since for the header X-API-Key we use the term api_key, why not something like auth_token?

credKind := intercept.CredentialKindCentralized
if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" {
cfg.Key = token
credKind = intercept.CredentialKindPersonalAPIKey
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make the modes header specific? If the Authorization header is used use mode auth_token, otherweise, if the X-API-Key header is used, use mode api_key? This would mean that for openai (default) and openai chatgpt both modes would be auth_token.

//
// In BYOK mode the user's credential is in Authorization. Replace
// the centralized key with it so it is forwarded upstream.
credKind := intercept.CredentialKindCentralized
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't setting credHint missing here? Also, we should probably check if cfg.key is empty (chatgpt case), and error if the credential key is empty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't setting credHint missing here?

Actually - no, it's later used like this:

interceptor.SetCredential(credKind, utils.MaskSecret(cfg.Key))

It's easier for openai case, because authz header is always used.


if cfg.key is empty (chatgpt case), and error if the credential key is empty

Yeah, I remember about this. I can add in this PR.

Copy link
Copy Markdown
Contributor Author

@evgeniy-scherbina evgeniy-scherbina Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ssncferreira

if cfg.key is empty (chatgpt case), and error if the credential key is empty

We already know that claude (maybe other clients) send HEAD requests without authentication, maybe we don't need it?

Also I think it's not specific to chatgpt case.

utils/mask.go Outdated
// 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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the benefit of doing this? This way we are explicitly saying the length of the key...which I think could be problematic if this is someway leaked?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also adds confusion because the ... prefix and suffix don't actually count towards the length.
If we don't want to leak the length (which could be used by attackers which intercept these masked secrets to know how many chars to spoof to recreate the secret) then I'd suggest we just go with a fixed number of * chars, and not use ..

Copy link
Copy Markdown
Contributor Author

@evgeniy-scherbina evgeniy-scherbina Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's related to this discussion: #216 (comment)

I remember you wanted "Preserve the length" of the secret, and I'm fine with it.
But the issue with current approach is tokens can be very long, let's say 300 chars.
Do we really want to log and potentially store in Postgres 300 * chars? Feels like a waste of space?
Feels like current approach is most ineffective in storing length of the secret as you can imagine?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear > clever.

This current approach is not clear. ...(len)... leaves itself open to different interpretations.
Since this change isn't the focus of the PR, I'd suggest reverting it for now and opening an issue to address the size concerns.

I'd like to show the secret length, because it's diagnostically useful, but it has to be done in a way that is obvious.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I reverted it.

I’m fine with any format as long as it’s efficient, especially if we plan to store it in Postgres.

Another potential issue is the UI. If we decide to display a secret hint there, as Anthropic and OpenAI do, we’ll probably want to show a compact version anyway. That creates a slightly awkward implementation: we would log and store the long version of a hint, then shorten it at the API or frontend layer before displaying it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe good option is to have two fields:

  • secret_hint (short version)
  • secret_length

@ssncferreira
Copy link
Copy Markdown
Contributor

I think we are missing adding the credential info to the logs, especially for error cases like 401/403/429

utils/mask.go Outdated
// 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))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also adds confusion because the ... prefix and suffix don't actually count towards the length.
If we don't want to leak the length (which could be used by attackers which intercept these masked secrets to know how many chars to spoof to recreate the secret) then I'd suggest we just go with a fixed number of * chars, and not use ..

{"long_api_key", "sk-ant-api03-abcdefgh", "sk-a*************efgh"},
{"unicode", "hélloworld🌍!", "hé********🌍!"},
{"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_******************************efgh"},
{"short", "short", "...(5)..."},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pointless, TBH. If we're masking the full string then that defeats the original purpose of being able to identify them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dannykopping Logic didn't change? Previously we also masked strings if their length <= 10.
Previously it just looked like: ***** instead of ...(5)...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must've missed that in the previous review.
Related to https://github.com/coder/aibridge/pull/239/changes#r3031158013, we can address this in a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reverted this change.


cred := interceptor.Credential()
assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch")
assert.NotEmpty(t, cred.Hint, "credential hint should not be empty")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a loose assertion here; NotEmpty doesn't tell us much.
Since our masking is deterministic, you could add the expected hint as the masked value and assert on that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, done

utils/mask.go Outdated
// 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))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear > clever.

This current approach is not clear. ...(len)... leaves itself open to different interpretations.
Since this change isn't the focus of the PR, I'd suggest reverting it for now and opening an issue to address the size concerns.

I'd like to show the secret length, because it's diagnostically useful, but it has to be done in a way that is obvious.

{"long_api_key", "sk-ant-api03-abcdefgh", "sk-a*************efgh"},
{"unicode", "hélloworld🌍!", "hé********🌍!"},
{"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_******************************efgh"},
{"short", "short", "...(5)..."},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must've missed that in the previous review.
Related to https://github.com/coder/aibridge/pull/239/changes#r3031158013, we can address this in a follow-up.

@evgeniy-scherbina
Copy link
Copy Markdown
Contributor Author

evgeniy-scherbina commented Apr 3, 2026

@ssncferreira

I think we are missing adding the credential info to the logs, especially for error cases like 401/403/429

I extended logger with credential info in newInterceptionProcessor where we call interceptor.ProcessRequest(rw, r).
It should capture all cases you mentioned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants