feat: byok-observability for aibridge#239
Conversation
9bf93dd to
c5dd566
Compare
71c69d3 to
30d306a
Compare
c5dd566 to
d6e95ea
Compare
7d0c198 to
b1c6f3f
Compare
d6e95ea to
b6a01e9
Compare
e1e15df to
b6a01e9
Compare
a610090 to
d3e5994
Compare
provider/copilot.go
Outdated
| return nil, UnknownRoute | ||
| } | ||
|
|
||
| interceptor.SetCredential(intercept.CredentialKindSubscription, utils.MaskSecret(key)) |
There was a problem hiding this comment.
CredentialKindSubscription makes sense for Copilot?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
I think subscription is widely used term, e.g.:
- https://docs.litellm.ai/docs/tutorials/claude_code_max_subscription
- https://docs.litellm.ai/docs/providers/chatgpt
- I heavily use it documentation PR: docs: add byok docs for aibridge coder#23922
- Copilot also uses subscription term, see: https://github.com/settings/copilot/features
- You currently have an active Copilot Pro subscription.
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 |
There was a problem hiding this comment.
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-Idheader - Check is it
JWT TokenorAPI KEY - Rely on URL (openai vs chatgpt)
See more details here: #227 (comment)
Alternatively we can introduce new enum value, smth like: CredentialKindPersonal
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I think there are few problems with that:
- I think headers are implementation details, auditors don't really know/care what headers are used?
- Different headers are used by different providers. E.g. OpenAI doesn't use X-API-Key.
- I think we want to differentiate between
Personal API KeyandChatGPT Subscriptionfor OpenAI provider?
There was a problem hiding this comment.
@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).
d3e5994 to
ec6ae45
Compare
provider/copilot.go
Outdated
| return nil, UnknownRoute | ||
| } | ||
|
|
||
| interceptor.SetCredential(intercept.CredentialKindSubscription, utils.MaskSecret(key)) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 ..
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Maybe good option is to have two fields:
- secret_hint (short version)
- secret_length
|
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)) |
There was a problem hiding this comment.
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 ..
utils/mask_test.go
Outdated
| {"long_api_key", "sk-ant-api03-abcdefgh", "sk-a*************efgh"}, | ||
| {"unicode", "hélloworld🌍!", "hé********🌍!"}, | ||
| {"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_******************************efgh"}, | ||
| {"short", "short", "...(5)..."}, |
There was a problem hiding this comment.
This is pointless, TBH. If we're masking the full string then that defeats the original purpose of being able to identify them.
There was a problem hiding this comment.
@dannykopping Logic didn't change? Previously we also masked strings if their length <= 10.
Previously it just looked like: ***** instead of ...(5)...
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I reverted this change.
Co-authored-by: Susana Ferreira <susana@coder.com>
provider/anthropic_test.go
Outdated
|
|
||
| cred := interceptor.Credential() | ||
| assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch") | ||
| assert.NotEmpty(t, cred.Hint, "credential hint should not be empty") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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.
utils/mask_test.go
Outdated
| {"long_api_key", "sk-ant-api03-abcdefgh", "sk-a*************efgh"}, | ||
| {"unicode", "hélloworld🌍!", "hé********🌍!"}, | ||
| {"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_******************************efgh"}, | ||
| {"short", "short", "...(5)..."}, |
There was a problem hiding this comment.
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.
I extended logger with credential info in |
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
CreateInterceptornow captures two new fields on the interceptor:credential_kind:centralized,personal_api_key, orsubscriptioncredential_hint: masked credential for audit (e.g.sk-a...(13)...efgh)Changes
Interceptorinterface withSetCredential,CredentialKind, andCredentialHint, backed by an embeddableCredentialFieldshelperCreateInterceptor:centralized(admin key) orpersonal(Bearer token)centralized,personal_api_key(X-Api-Key), orsubscription(Bearer token)subscriptionMaskSecretto embed hidden character count (e.g.sk-a...(13)...efghinstead ofsk-a*************efgh)Relates to coder/coder#23808