mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
Merge branch 'main' into cascade-disable-models
This commit is contained in:
@@ -57,6 +57,14 @@ func NewCopilotProvider(cfg config.Copilot) provider.Provider {
|
||||
return provider.NewCopilot(cfg)
|
||||
}
|
||||
|
||||
// NewDisabledProviderStub returns a Provider that reports Enabled() ==
|
||||
// false and has no-op implementations for all other methods. Use this
|
||||
// instead of constructing a concrete provider for disabled rows so that
|
||||
// adding a new provider type does not require updating a switch here.
|
||||
func NewDisabledProviderStub(name, providerType string) provider.Provider {
|
||||
return provider.NewDisabledStub(name, providerType)
|
||||
}
|
||||
|
||||
func NewMetrics(reg prometheus.Registerer) *metrics.Metrics {
|
||||
return metrics.NewMetrics(reg)
|
||||
}
|
||||
|
||||
+52
-9
@@ -20,6 +20,7 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/aibridge/circuitbreaker"
|
||||
aibcontext "github.com/coder/coder/v2/aibridge/context"
|
||||
"github.com/coder/coder/v2/aibridge/intercept"
|
||||
"github.com/coder/coder/v2/aibridge/mcp"
|
||||
"github.com/coder/coder/v2/aibridge/metrics"
|
||||
"github.com/coder/coder/v2/aibridge/provider"
|
||||
@@ -30,6 +31,11 @@ import (
|
||||
const (
|
||||
// The duration after which an async recording will be aborted.
|
||||
recordingTimeout = time.Second * 5
|
||||
|
||||
// ErrorCodeProviderDisabled is the code written in the response
|
||||
// body when a request targets a configured-but-disabled provider.
|
||||
// Paired with HTTP 503.
|
||||
ErrorCodeProviderDisabled = "provider_disabled"
|
||||
)
|
||||
|
||||
// RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs;
|
||||
@@ -96,6 +102,14 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
|
||||
mux := http.NewServeMux()
|
||||
|
||||
for _, prov := range providers {
|
||||
// Disabled providers serve a 503 sentinel on every path under
|
||||
// "/<name>/". Bound to the bare name (not RoutePrefix) so paths
|
||||
// outside the provider's normal "/v1" subtree are also caught.
|
||||
if !prov.Enabled() {
|
||||
prefix := fmt.Sprintf("/%s/", prov.Name())
|
||||
mux.HandleFunc(prefix, disabledProviderHandler(prov.Name(), logger))
|
||||
continue
|
||||
}
|
||||
// Create per-provider circuit breaker if configured
|
||||
cfg := prov.CircuitBreakerConfig()
|
||||
providerName := prov.Name()
|
||||
@@ -170,6 +184,20 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
|
||||
}, nil
|
||||
}
|
||||
|
||||
// disabledProviderHandler returns 503 with a body containing
|
||||
// [ErrorCodeProviderDisabled] and the provider name for every request
|
||||
// targeting name.
|
||||
func disabledProviderHandler(name string, logger slog.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Debug(r.Context(), "refusing request for disabled ai provider",
|
||||
slog.F("provider", name),
|
||||
slog.F("path", r.URL.Path),
|
||||
slog.F("method", r.Method),
|
||||
)
|
||||
http.Error(w, fmt.Sprintf("%s: AI provider %q is disabled", ErrorCodeProviderDisabled, name), http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
// newInterceptionProcessor returns an [http.HandlerFunc] which is capable of creating a new interceptor and processing a given request
|
||||
// using [Provider] p, recording all usage events using [Recorder] rec.
|
||||
// If cbs is non-nil, circuit breaker protection is applied per endpoint/model tuple.
|
||||
@@ -248,11 +276,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC
|
||||
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")
|
||||
// Log BYOK credentials. Centralized credentials are set by
|
||||
// the key failover loop.
|
||||
credLogFields := []slog.Field{}
|
||||
if cred.Kind == intercept.CredentialKindBYOK {
|
||||
credLogFields = append(credLogFields,
|
||||
slog.F("credential_hint", cred.Hint),
|
||||
slog.F("credential_length", cred.Length),
|
||||
)
|
||||
}
|
||||
log.Debug(ctx, "interception started", credLogFields...)
|
||||
if m != nil {
|
||||
m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Add(1)
|
||||
defer func() {
|
||||
@@ -261,22 +296,30 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC
|
||||
}
|
||||
|
||||
// Process request with circuit breaker protection if configured
|
||||
if err := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error {
|
||||
execErr := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error {
|
||||
return interceptor.ProcessRequest(rw, r)
|
||||
}); err != nil {
|
||||
})
|
||||
// For centralized, the hint now reflects the last attempted
|
||||
// key from the failover loop.
|
||||
credHint := interceptor.Credential().Hint
|
||||
credLen := interceptor.Credential().Length
|
||||
if execErr != nil {
|
||||
if m != nil {
|
||||
m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusFailed, route, r.Method, actor.ID, string(client)).Add(1)
|
||||
}
|
||||
span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", err))
|
||||
log.Warn(ctx, "interception failed", slog.Error(err))
|
||||
span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", execErr))
|
||||
log.Warn(ctx, "interception failed", slog.Error(execErr), slog.F("credential_hint", credHint), slog.F("credential_length", credLen))
|
||||
} else {
|
||||
if m != nil {
|
||||
m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusCompleted, route, r.Method, actor.ID, string(client)).Add(1)
|
||||
}
|
||||
log.Debug(ctx, "interception ended")
|
||||
log.Debug(ctx, "interception ended", slog.F("credential_hint", credHint), slog.F("credential_length", credLen))
|
||||
}
|
||||
|
||||
_ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ID: interceptor.ID().String()})
|
||||
_ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{
|
||||
ID: interceptor.ID().String(),
|
||||
CredentialHint: credHint,
|
||||
})
|
||||
|
||||
// Ensure all recording have completed before completing request.
|
||||
asyncRecorder.Wait()
|
||||
|
||||
@@ -205,3 +205,58 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisabledProviderHandler asserts that requests to a disabled
|
||||
// provider return a 503 with an ErrorCodeProviderDisabled body and
|
||||
// that a sibling enabled provider keeps routing normally.
|
||||
func TestDisabledProviderHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("upstream-reached"))
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
enabled := aibridge.NewOpenAIProvider(config.OpenAI{Name: "enabled-openai", BaseURL: upstream.URL})
|
||||
disabled := aibridge.NewDisabledProviderStub("disabled-openai", "openai")
|
||||
bridge, err := aibridge.NewRequestBridge(
|
||||
t.Context(),
|
||||
[]provider.Provider{enabled, disabled},
|
||||
nil, nil, logger, nil, bridgeTestTracer,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "Bridged", path: "/disabled-openai/v1/chat/completions"},
|
||||
{name: "Passthrough", path: "/disabled-openai/v1/models"},
|
||||
{name: "Unknown", path: "/disabled-openai/anything/else"},
|
||||
} {
|
||||
t.Run("DisabledProviderReturnsSentinel/"+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
bridge.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
|
||||
assert.Contains(t, resp.Body.String(), aibridge.ErrorCodeProviderDisabled)
|
||||
assert.Contains(t, resp.Body.String(), "disabled-openai")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("EnabledProviderUnaffected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/enabled-openai/v1/models", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
bridge.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
assert.Equal(t, "upstream-reached", resp.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -291,15 +291,16 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc
|
||||
// 401/403. Errors that aren't key-specific don't trigger
|
||||
// failover and are returned to the caller.
|
||||
func (i *BlockingInterception) newChatCompletionWithKeyFailover(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) (*openai.ChatCompletion, error) {
|
||||
// TODO(ssncferreira): update the interception's credential
|
||||
// hint with the actually-used key (the successful key on
|
||||
// success, the last tried key on failure) in the upstack PR.
|
||||
walker := i.cfg.KeyPool.Walker()
|
||||
for {
|
||||
key, keyPoolErr := walker.Next()
|
||||
if keyPoolErr != nil {
|
||||
return nil, keyPoolErr
|
||||
}
|
||||
// Record the key in use so the hint reflects the last attempted key.
|
||||
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
|
||||
i.logger.Debug(ctx, "using centralized api key",
|
||||
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
|
||||
|
||||
requestOpts := append([]option.RequestOption{}, opts...)
|
||||
requestOpts = append(requestOpts,
|
||||
|
||||
@@ -72,31 +72,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
expectedRetryAfter string
|
||||
// Expected key states after the request, by index in keys.
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: last
|
||||
// attempted key for centralized, user key from initial request for BYOK.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 1 valid key returning 200.
|
||||
// Then: 1 request, 200 response, key remains valid.
|
||||
name: "single_valid_key",
|
||||
keys: []string{"k0"},
|
||||
keys: []string{"k0-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
|
||||
name: "failover_after_429",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -104,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_401",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -120,15 +125,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_403",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -136,25 +142,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
|
||||
// Then: 3 requests, 429 response with smallest Retry-After,
|
||||
// all keys temporary.
|
||||
name: "all_keys_rate_limited",
|
||||
keys: []string{"k0", "k1", "k2"},
|
||||
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "3"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k2": {
|
||||
"k2-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "10"},
|
||||
body: rateLimitBody,
|
||||
@@ -168,15 +175,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; both return 401.
|
||||
// Then: 2 requests, 502 api_error response, both keys permanent.
|
||||
name: "all_keys_unauthorized",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusBadGateway,
|
||||
@@ -184,14 +192,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStatePermanent,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 500.
|
||||
// Then: 1 request, 500 response, both keys remain valid.
|
||||
name: "server_error_no_failover",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
@@ -199,6 +208,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: BYOK with a single key returning 429.
|
||||
@@ -219,9 +229,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
body: rateLimitBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedCredentialHint: utils.MaskSecret("user-byok"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -252,6 +263,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
|
||||
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
|
||||
var pool *keypool.Pool
|
||||
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
|
||||
if len(tc.keys) > 0 {
|
||||
var err error
|
||||
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
|
||||
@@ -259,6 +271,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
cfg.KeyPool = pool
|
||||
} else if tc.byokKey != "" {
|
||||
cfg.Key = tc.byokKey
|
||||
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
|
||||
}
|
||||
|
||||
interceptor := NewBlockingInterceptor(
|
||||
@@ -269,7 +282,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
http.Header{},
|
||||
"Authorization",
|
||||
otel.Tracer("blocking_test"),
|
||||
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
|
||||
credInfo,
|
||||
)
|
||||
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
|
||||
|
||||
@@ -288,6 +301,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
if pool != nil {
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
}
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -309,6 +323,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
expectedSeenKeys []string
|
||||
expectedStatusCode int
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: hint of the
|
||||
// last attempted key across all agentic-loop iterations.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 2 keys; both upstream calls succeed on key-0.
|
||||
@@ -319,12 +336,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, body: textCompleteBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedSeenKeys: []string{"k0", "k0"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then 429s
|
||||
@@ -342,12 +360,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, body: textCompleteBody},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then both
|
||||
@@ -369,12 +388,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -409,7 +429,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
|
||||
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.OpenAI{
|
||||
@@ -459,6 +479,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
defer seenKeysMu.Unlock()
|
||||
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re
|
||||
break
|
||||
}
|
||||
currentKey = key
|
||||
// Record the key in use so the hint reflects the last attempted key.
|
||||
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
|
||||
logger.Debug(ctx, "using centralized api key",
|
||||
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
|
||||
|
||||
opts = append(opts,
|
||||
option.WithAPIKey(key.Value()),
|
||||
// Disable SDK retries because the failover
|
||||
|
||||
@@ -144,36 +144,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
expectedRetryAfter string
|
||||
// Expected key states after the request, by index in keys.
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: last
|
||||
// attempted key for centralized, user key from initial request for BYOK.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 1 valid key returning a successful stream.
|
||||
// Then: 1 request, 200 response, key remains valid.
|
||||
name: "single_valid_key",
|
||||
keys: []string{"k0"},
|
||||
keys: []string{"k0-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
|
||||
// streams successfully.
|
||||
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
|
||||
name: "failover_after_429",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -185,16 +189,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
|
||||
// streams successfully.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_401",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -206,15 +211,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_403",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1": {
|
||||
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -226,6 +232,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 3 keys; all return 429 pre-stream with
|
||||
@@ -233,19 +240,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
// Then: 3 requests, 429 response with smallest
|
||||
// Retry-After, all keys temporary.
|
||||
name: "all_keys_rate_limited",
|
||||
keys: []string{"k0", "k1", "k2"},
|
||||
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "3"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k2": {
|
||||
"k2-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "10"},
|
||||
body: rateLimitBody,
|
||||
@@ -259,15 +266,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; both return 401 pre-stream.
|
||||
// Then: 2 requests, 502 api_error response, both keys permanent.
|
||||
name: "all_keys_unauthorized",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusBadGateway,
|
||||
@@ -275,14 +283,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStatePermanent,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 500 pre-stream.
|
||||
// Then: 1 request, 500 response, both keys remain valid.
|
||||
name: "server_error_no_failover",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
@@ -290,6 +299,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: BYOK with a single key returning 429.
|
||||
@@ -310,9 +320,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
body: rateLimitBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedCredentialHint: utils.MaskSecret("user-byok"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -342,6 +353,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
|
||||
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
|
||||
var pool *keypool.Pool
|
||||
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
|
||||
if len(tc.keys) > 0 {
|
||||
var err error
|
||||
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
|
||||
@@ -349,6 +361,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
cfg.KeyPool = pool
|
||||
} else if tc.byokKey != "" {
|
||||
cfg.Key = tc.byokKey
|
||||
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
|
||||
}
|
||||
|
||||
interceptor := NewStreamingInterceptor(
|
||||
@@ -359,7 +372,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
http.Header{},
|
||||
"Authorization",
|
||||
otel.Tracer("streaming_test"),
|
||||
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
|
||||
credInfo,
|
||||
)
|
||||
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
|
||||
|
||||
@@ -378,6 +391,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
if pool != nil {
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
}
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -435,6 +449,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
// error (e.g. all keys exhausted).
|
||||
expectedErr bool
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: hint of the
|
||||
// last attempted key across all agentic-loop iterations.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 2 keys; both upstream calls succeed on key-0.
|
||||
@@ -445,13 +462,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedSeenKeys: []string{"k0", "k0"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
|
||||
expectedBodyContains: "done",
|
||||
expectErrorAsSSEEvent: false,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then 429s
|
||||
@@ -469,13 +487,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedBodyContains: "done",
|
||||
expectErrorAsSSEEvent: false,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then both
|
||||
@@ -497,7 +516,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedBodyContains: "all configured keys are rate-limited",
|
||||
expectErrorAsSSEEvent: true,
|
||||
expectedErr: true,
|
||||
@@ -505,6 +524,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -538,7 +558,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
|
||||
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.OpenAI{
|
||||
@@ -596,6 +616,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
defer seenKeysMu.Unlock()
|
||||
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,15 +367,16 @@ func (i *BlockingInterception) newMessageWithKey(ctx context.Context, svc anthro
|
||||
// Errors that aren't key-specific don't trigger failover and
|
||||
// are returned to the caller.
|
||||
func (i *BlockingInterception) newMessageWithKeyFailover(ctx context.Context, svc anthropic.MessageService) (*anthropic.Message, error) {
|
||||
// TODO(ssncferreira): update the interception's credential
|
||||
// hint with the actually-used key (the successful key on
|
||||
// success, the last tried key on failure) in the upstack PR.
|
||||
walker := i.cfg.KeyPool.Walker()
|
||||
for {
|
||||
key, keyPoolErr := walker.Next()
|
||||
if keyPoolErr != nil {
|
||||
return nil, keyPoolErr
|
||||
}
|
||||
// Record the key in use so the hint reflects the last attempted key.
|
||||
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
|
||||
i.logger.Debug(ctx, "using centralized api key",
|
||||
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
|
||||
|
||||
msg, err := i.newMessageWithKey(ctx, svc,
|
||||
option.WithAPIKey(key.Value()),
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/coder/coder/v2/aibridge/internal/testutil"
|
||||
"github.com/coder/coder/v2/aibridge/keypool"
|
||||
"github.com/coder/coder/v2/aibridge/mcp"
|
||||
"github.com/coder/coder/v2/aibridge/utils"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
@@ -54,31 +55,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
expectedRetryAfter string
|
||||
// Expected key states after the request, by index in keys.
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: last
|
||||
// attempted key for centralized, user key from initial request for BYOK.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 1 valid key returning 200.
|
||||
// Then: 1 request, 200 response, key remains valid.
|
||||
name: "single_valid_key",
|
||||
keys: []string{"k0"},
|
||||
keys: []string{"k0-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
|
||||
name: "failover_after_429",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -86,15 +91,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_401",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -102,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_403",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -118,25 +125,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
|
||||
// Then: 3 requests, 429 response with smallest Retry-After,
|
||||
// all keys temporary.
|
||||
name: "all_keys_rate_limited",
|
||||
keys: []string{"k0", "k1", "k2"},
|
||||
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "3"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k2": {
|
||||
"k2-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "10"},
|
||||
body: rateLimitBody,
|
||||
@@ -150,15 +158,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; both return 401.
|
||||
// Then: 2 requests, 502 api_error response, both keys permanent.
|
||||
name: "all_keys_unauthorized",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusBadGateway,
|
||||
@@ -166,14 +175,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStatePermanent,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 500.
|
||||
// Then: 1 request, 500 response, both keys remain valid.
|
||||
name: "server_error_no_failover",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
@@ -181,6 +191,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: BYOK with a single key returning 429.
|
||||
@@ -201,9 +212,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
body: rateLimitBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedCredentialHint: utils.MaskSecret("user-byok"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -234,6 +246,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
|
||||
cfg := config.Anthropic{BaseURL: upstream.URL + "/"}
|
||||
var pool *keypool.Pool
|
||||
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
|
||||
if len(tc.keys) > 0 {
|
||||
var err error
|
||||
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
|
||||
@@ -241,6 +254,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
cfg.KeyPool = pool
|
||||
} else if tc.byokKey != "" {
|
||||
cfg.Key = tc.byokKey
|
||||
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
|
||||
}
|
||||
|
||||
payload, err := NewRequestPayload([]byte(requestBody))
|
||||
@@ -255,7 +269,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
http.Header{},
|
||||
"X-Api-Key",
|
||||
otel.Tracer("blocking_test"),
|
||||
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
|
||||
credInfo,
|
||||
)
|
||||
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
|
||||
|
||||
@@ -271,6 +285,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
|
||||
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
|
||||
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
if pool != nil {
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
}
|
||||
@@ -296,6 +311,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
expectedStatusCode int
|
||||
expectedRetryAfter string
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: hint of the
|
||||
// last attempted key across all agentic-loop iterations.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 2 keys; both upstream calls succeed on key-0.
|
||||
@@ -306,12 +324,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedSeenKeys: []string{"k0", "k0"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then 429s
|
||||
@@ -329,12 +348,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then both
|
||||
@@ -356,13 +376,14 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "3",
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -397,7 +418,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
|
||||
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.Anthropic{
|
||||
@@ -447,6 +468,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
|
||||
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
|
||||
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
|
||||
seenKeysMu.Lock()
|
||||
defer seenKeysMu.Unlock()
|
||||
|
||||
@@ -195,6 +195,11 @@ newStream:
|
||||
break
|
||||
}
|
||||
currentKey = key
|
||||
// Record the key in use so the hint reflects the last attempted key.
|
||||
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
|
||||
logger.Debug(ctx, "using centralized api key",
|
||||
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
|
||||
|
||||
streamOpts = append(streamOpts,
|
||||
option.WithAPIKey(key.Value()),
|
||||
// Disable SDK retries because the failover
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/coder/coder/v2/aibridge/internal/testutil"
|
||||
"github.com/coder/coder/v2/aibridge/keypool"
|
||||
"github.com/coder/coder/v2/aibridge/mcp"
|
||||
"github.com/coder/coder/v2/aibridge/utils"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
@@ -60,36 +61,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
expectedRetryAfter string
|
||||
// Expected key states after the request, by index in keys.
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: last
|
||||
// attempted key for centralized, user key from initial request for BYOK.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 1 valid key returning a successful stream.
|
||||
// Then: 1 request, 200 response, key remains valid.
|
||||
name: "single_valid_key",
|
||||
keys: []string{"k0"},
|
||||
keys: []string{"k0-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
|
||||
// streams successfully.
|
||||
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
|
||||
name: "failover_after_429",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -101,16 +106,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
|
||||
// streams successfully.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_401",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -122,15 +128,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_403",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1": {
|
||||
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -142,6 +149,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 3 keys; all return 429 pre-stream with
|
||||
@@ -149,19 +157,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
// Then: 3 requests, 429 response with smallest
|
||||
// Retry-After, all keys temporary.
|
||||
name: "all_keys_rate_limited",
|
||||
keys: []string{"k0", "k1", "k2"},
|
||||
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "3"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k2": {
|
||||
"k2-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "10"},
|
||||
body: rateLimitBody,
|
||||
@@ -175,15 +183,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; both return 401 pre-stream.
|
||||
// Then: 2 requests, 502 api_error response, both keys permanent.
|
||||
name: "all_keys_unauthorized",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusBadGateway,
|
||||
@@ -191,14 +200,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStatePermanent,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 500 pre-stream.
|
||||
// Then: 1 request, 500 response, both keys remain valid.
|
||||
name: "server_error_no_failover",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
@@ -206,6 +216,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: BYOK with a single key returning 429.
|
||||
@@ -226,9 +237,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
body: rateLimitBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRetryAfter: "5",
|
||||
expectedCredentialHint: utils.MaskSecret("user-byok"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -258,6 +270,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
|
||||
cfg := config.Anthropic{BaseURL: upstream.URL + "/"}
|
||||
var pool *keypool.Pool
|
||||
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
|
||||
if len(tc.keys) > 0 {
|
||||
var err error
|
||||
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
|
||||
@@ -265,6 +278,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
cfg.KeyPool = pool
|
||||
} else if tc.byokKey != "" {
|
||||
cfg.Key = tc.byokKey
|
||||
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
|
||||
}
|
||||
|
||||
payload, err := NewRequestPayload([]byte(requestBody))
|
||||
@@ -279,7 +293,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
http.Header{},
|
||||
"X-Api-Key",
|
||||
otel.Tracer("streaming_test"),
|
||||
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
|
||||
credInfo,
|
||||
)
|
||||
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
|
||||
|
||||
@@ -301,6 +315,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
|
||||
if pool != nil {
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
}
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -387,6 +402,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
// error (e.g. all keys exhausted).
|
||||
expectedErr bool
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: hint of the
|
||||
// last attempted key across all agentic-loop iterations.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 2 keys; both upstream calls succeed on key-0.
|
||||
@@ -397,13 +415,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedSeenKeys: []string{"k0", "k0"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
|
||||
expectedBodyContains: "done",
|
||||
expectErrorAsSSEEvent: false,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then 429s
|
||||
@@ -421,13 +440,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedBodyContains: "done",
|
||||
expectErrorAsSSEEvent: false,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then both
|
||||
@@ -453,7 +473,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedBodyContains: "all configured keys are rate-limited",
|
||||
expectErrorAsSSEEvent: true,
|
||||
expectedErr: true,
|
||||
@@ -461,6 +481,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -494,7 +515,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
|
||||
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.Anthropic{
|
||||
@@ -553,6 +574,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
|
||||
defer seenKeysMu.Unlock()
|
||||
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,15 +171,16 @@ func (i *BlockingResponsesInterceptor) newResponseWithKey(ctx context.Context, s
|
||||
// Errors that aren't key-specific don't trigger failover and
|
||||
// are returned to the caller.
|
||||
func (i *BlockingResponsesInterceptor) newResponseWithKeyFailover(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (*responses.Response, error) {
|
||||
// TODO(ssncferreira): update the interception's credential
|
||||
// hint with the actually-used key (the successful key on
|
||||
// success, the last tried key on failure) in the upstack PR.
|
||||
walker := i.cfg.KeyPool.Walker()
|
||||
for {
|
||||
key, keyPoolErr := walker.Next()
|
||||
if keyPoolErr != nil {
|
||||
return nil, keyPoolErr
|
||||
}
|
||||
// Record the key in use so the hint reflects the last attempted key.
|
||||
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
|
||||
i.logger.Debug(ctx, "using centralized api key",
|
||||
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
|
||||
|
||||
requestOpts := append([]option.RequestOption{}, opts...)
|
||||
requestOpts = append(requestOpts,
|
||||
|
||||
@@ -58,31 +58,35 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
expectedRetryAfter string
|
||||
// Expected key states after the request, by index in keys.
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: last
|
||||
// attempted key for centralized, user key from initial request for BYOK.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 1 valid key returning 200.
|
||||
// Then: 1 request, 200 response, key remains valid.
|
||||
name: "single_valid_key",
|
||||
keys: []string{"k0"},
|
||||
keys: []string{"k0-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
|
||||
name: "failover_after_429",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -90,15 +94,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_401",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -106,15 +111,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_403",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusOK, body: successBody},
|
||||
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
@@ -122,25 +128,26 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
|
||||
// Then: 3 requests, 429 response with smallest Retry-After,
|
||||
// all keys temporary.
|
||||
name: "all_keys_rate_limited",
|
||||
keys: []string{"k0", "k1", "k2"},
|
||||
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "3"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k2": {
|
||||
"k2-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "10"},
|
||||
body: rateLimitBody,
|
||||
@@ -154,15 +161,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; both return 401.
|
||||
// Then: 2 requests, 502 api_error response, both keys permanent.
|
||||
name: "all_keys_unauthorized",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusBadGateway,
|
||||
@@ -170,14 +178,15 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStatePermanent,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 500.
|
||||
// Then: 1 request, 500 response, both keys remain valid.
|
||||
name: "server_error_no_failover",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
@@ -185,6 +194,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: BYOK with a single key returning 429.
|
||||
@@ -204,8 +214,9 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
body: rateLimitBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedCredentialHint: utils.MaskSecret("user-byok"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -235,6 +246,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
|
||||
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
|
||||
var pool *keypool.Pool
|
||||
if len(tc.keys) > 0 {
|
||||
var err error
|
||||
@@ -243,6 +255,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
cfg.KeyPool = pool
|
||||
} else if tc.byokKey != "" {
|
||||
cfg.Key = tc.byokKey
|
||||
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
|
||||
}
|
||||
|
||||
payload, err := NewRequestPayload([]byte(requestBody))
|
||||
@@ -256,7 +269,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
http.Header{},
|
||||
"Authorization",
|
||||
otel.Tracer("blocking_test"),
|
||||
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
|
||||
credInfo,
|
||||
)
|
||||
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
|
||||
|
||||
@@ -272,6 +285,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
|
||||
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
|
||||
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
if pool != nil {
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
}
|
||||
@@ -296,6 +310,9 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
expectedSeenKeys []string
|
||||
expectedStatusCode int
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: hint of the
|
||||
// last attempted key across all agentic-loop iterations.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 2 keys; both upstream calls succeed on key-0.
|
||||
@@ -306,12 +323,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, body: textCompleteBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedSeenKeys: []string{"k0", "k0"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then 429s
|
||||
@@ -329,12 +347,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, body: textCompleteBody},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then both
|
||||
@@ -356,12 +375,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -396,7 +416,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
|
||||
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.OpenAI{
|
||||
@@ -444,6 +464,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
|
||||
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
|
||||
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
|
||||
seenKeysMu.Lock()
|
||||
defer seenKeysMu.Unlock()
|
||||
|
||||
@@ -144,6 +144,11 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r
|
||||
return xerrors.Errorf("key pool exhausted: %w", keyPoolErr)
|
||||
}
|
||||
currentKey = key
|
||||
// Record the key in use so the hint reflects the last attempted key.
|
||||
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
|
||||
i.logger.Debug(ctx, "using centralized api key",
|
||||
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
|
||||
|
||||
opts = append(opts,
|
||||
option.WithAPIKey(key.Value()),
|
||||
// Disable SDK retries because the failover
|
||||
|
||||
@@ -51,36 +51,40 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
expectedRetryAfter string
|
||||
// Expected key states after the request, by index in keys.
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: last
|
||||
// attempted key for centralized, user key from initial request for BYOK.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 1 valid key returning a successful stream.
|
||||
// Then: 1 request, 200 response, key remains valid.
|
||||
name: "single_valid_key",
|
||||
keys: []string{"k0"},
|
||||
keys: []string{"k0-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
|
||||
// streams successfully.
|
||||
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
|
||||
name: "failover_after_429",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -92,16 +96,17 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
|
||||
// streams successfully.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_401",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -113,15 +118,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
|
||||
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
|
||||
name: "failover_after_403",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1": {
|
||||
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusOK,
|
||||
headers: map[string]string{"Content-Type": "text/event-stream"},
|
||||
body: streamingSuccessBody,
|
||||
@@ -133,6 +139,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 3 keys; all return 429 pre-stream with
|
||||
@@ -140,19 +147,19 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
// Then: 3 requests, 429 response with smallest
|
||||
// Retry-After, all keys temporary.
|
||||
name: "all_keys_rate_limited",
|
||||
keys: []string{"k0", "k1", "k2"},
|
||||
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {
|
||||
"k0-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "5"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k1": {
|
||||
"k1-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "3"},
|
||||
body: rateLimitBody,
|
||||
},
|
||||
"k2": {
|
||||
"k2-long-key": {
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
headers: map[string]string{"Retry-After": "10"},
|
||||
body: rateLimitBody,
|
||||
@@ -166,15 +173,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; both return 401 pre-stream.
|
||||
// Then: 2 requests, 502 api_error response, both keys permanent.
|
||||
name: "all_keys_unauthorized",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedStatusCode: http.StatusBadGateway,
|
||||
@@ -182,14 +190,15 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStatePermanent,
|
||||
keypool.KeyStatePermanent,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 returns 500 pre-stream.
|
||||
// Then: 1 request, 500 response, both keys remain valid.
|
||||
name: "server_error_no_failover",
|
||||
keys: []string{"k0", "k1"},
|
||||
keys: []string{"k0-long-key", "k1-long-key"},
|
||||
responses: map[string]upstreamResponse{
|
||||
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
@@ -197,6 +206,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: BYOK with a single key returning 429.
|
||||
@@ -216,8 +226,9 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
body: rateLimitBody,
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedRequestCount: 1,
|
||||
expectedStatusCode: http.StatusTooManyRequests,
|
||||
expectedCredentialHint: utils.MaskSecret("user-byok"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -246,6 +257,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
|
||||
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
|
||||
var pool *keypool.Pool
|
||||
if len(tc.keys) > 0 {
|
||||
var err error
|
||||
@@ -254,6 +266,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
cfg.KeyPool = pool
|
||||
} else if tc.byokKey != "" {
|
||||
cfg.Key = tc.byokKey
|
||||
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
|
||||
}
|
||||
|
||||
payload, err := NewRequestPayload([]byte(streamingRequestBody))
|
||||
@@ -267,7 +280,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
http.Header{},
|
||||
"Authorization",
|
||||
otel.Tracer("streaming_test"),
|
||||
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
|
||||
credInfo,
|
||||
)
|
||||
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
|
||||
|
||||
@@ -283,6 +296,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
|
||||
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
|
||||
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
if pool != nil {
|
||||
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
|
||||
}
|
||||
@@ -339,6 +353,9 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
// error (e.g. all keys exhausted).
|
||||
expectedErr bool
|
||||
expectedKeyStates []keypool.KeyState
|
||||
// Expected credential hint after ProcessRequest: hint of the
|
||||
// last attempted key across all agentic-loop iterations.
|
||||
expectedCredentialHint string
|
||||
}{
|
||||
{
|
||||
// Given: 2 keys; both upstream calls succeed on key-0.
|
||||
@@ -349,12 +366,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
|
||||
},
|
||||
expectedRequestCount: 2,
|
||||
expectedSeenKeys: []string{"k0", "k0"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
|
||||
expectedBodyContains: "done",
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateValid,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then 429s
|
||||
@@ -372,12 +390,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedBodyContains: "done",
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateValid,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
{
|
||||
// Given: 2 keys; key-0 succeeds initially, then both
|
||||
@@ -399,13 +418,14 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expectedRequestCount: 3,
|
||||
expectedSeenKeys: []string{"k0", "k0", "k1"},
|
||||
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
|
||||
expectedBodyContains: "all configured keys are rate-limited",
|
||||
expectedErr: true,
|
||||
expectedKeyStates: []keypool.KeyState{
|
||||
keypool.KeyStateTemporary,
|
||||
keypool.KeyStateTemporary,
|
||||
},
|
||||
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -439,7 +459,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
|
||||
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.OpenAI{
|
||||
@@ -489,6 +509,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, tc.expectedBodyContains, "response body")
|
||||
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
|
||||
|
||||
seenKeysMu.Lock()
|
||||
defer seenKeysMu.Unlock()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type MockProvider struct {
|
||||
NameStr string
|
||||
URL string
|
||||
Disabled bool
|
||||
Bridged []string
|
||||
Passthrough []string
|
||||
InterceptorFunc func(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (intercept.Interceptor, error)
|
||||
@@ -22,6 +23,7 @@ type MockProvider struct {
|
||||
|
||||
func (m *MockProvider) Type() string { return m.NameStr }
|
||||
func (m *MockProvider) Name() string { return m.NameStr }
|
||||
func (m *MockProvider) Enabled() bool { return !m.Disabled }
|
||||
func (m *MockProvider) BaseURL() string { return m.URL }
|
||||
func (m *MockProvider) RoutePrefix() string { return fmt.Sprintf("/%s", m.NameStr) }
|
||||
func (m *MockProvider) BridgedRoutes() []string { return m.Bridged }
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/aibridge/utils"
|
||||
)
|
||||
|
||||
// MarkKeyOnStatus marks key based on a key-specific HTTP
|
||||
@@ -32,7 +31,7 @@ func MarkKeyOnStatus(
|
||||
if key.MarkTemporary(cooldown) {
|
||||
logger.Info(ctx, "key marked temporary",
|
||||
slog.F("provider", providerName),
|
||||
slog.F("api_key_hint", utils.MaskSecret(key.Value())),
|
||||
slog.F("api_key_hint", key.Hint()),
|
||||
slog.F("status", statusCode),
|
||||
slog.F("cooldown", cooldown))
|
||||
}
|
||||
@@ -41,7 +40,7 @@ func MarkKeyOnStatus(
|
||||
if key.MarkPermanent() {
|
||||
logger.Warn(ctx, "key marked permanent",
|
||||
slog.F("provider", providerName),
|
||||
slog.F("api_key_hint", utils.MaskSecret(key.Value())),
|
||||
slog.F("api_key_hint", key.Hint()),
|
||||
slog.F("status", statusCode))
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/aibridge/utils"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
@@ -116,6 +117,12 @@ func (k *Key) Value() string {
|
||||
return k.value
|
||||
}
|
||||
|
||||
// Hint returns a masked, identifiable fragment of the key, suitable
|
||||
// for logs and persisted records.
|
||||
func (k *Key) Hint() string {
|
||||
return utils.MaskSecret(k.value)
|
||||
}
|
||||
|
||||
// State returns the current state of the key, derived from its
|
||||
// permanent flag and cooldown deadline.
|
||||
func (k *Key) State() KeyState {
|
||||
|
||||
@@ -95,6 +95,8 @@ func (p *Anthropic) Name() string {
|
||||
return p.cfg.Name
|
||||
}
|
||||
|
||||
func (*Anthropic) Enabled() bool { return true }
|
||||
|
||||
func (p *Anthropic) RoutePrefix() string {
|
||||
return fmt.Sprintf("/%s", p.Name())
|
||||
}
|
||||
@@ -168,15 +170,10 @@ func (p *Anthropic) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tr
|
||||
authHeaderName = "Authorization"
|
||||
credKind = intercept.CredentialKindBYOK
|
||||
credSecret = token
|
||||
} else if cfg.KeyPool != nil {
|
||||
// Centralized: use the first key as a placeholder hint.
|
||||
// TODO(ssncferreira): record the actually-used key in
|
||||
// the interception record to reflect failover.
|
||||
if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil {
|
||||
credSecret = key.Value()
|
||||
}
|
||||
}
|
||||
|
||||
// Centralized leaves credSecret empty: the hint is set by the
|
||||
// failover loop on each key attempt and persisted at
|
||||
// end-of-interception.
|
||||
cred := intercept.NewCredentialInfo(credKind, credSecret)
|
||||
|
||||
var interceptor intercept.Interceptor
|
||||
|
||||
@@ -257,7 +257,9 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) {
|
||||
setHeaders: map[string]string{},
|
||||
wantXApiKey: "test-key",
|
||||
wantCredentialKind: intercept.CredentialKindCentralized,
|
||||
wantCredentialHint: "t...y",
|
||||
// Centralized hint is empty at CreateInterceptor; set
|
||||
// by the key failover loop during ProcessRequest.
|
||||
wantCredentialHint: "",
|
||||
},
|
||||
{
|
||||
name: "Messages_BYOK_BearerToken_And_APIKey",
|
||||
|
||||
@@ -78,6 +78,8 @@ func (p *Copilot) Name() string {
|
||||
return p.cfg.Name
|
||||
}
|
||||
|
||||
func (*Copilot) Enabled() bool { return true }
|
||||
|
||||
func (p *Copilot) BaseURL() string {
|
||||
return p.cfg.BaseURL
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/aibridge/config"
|
||||
"github.com/coder/coder/v2/aibridge/intercept"
|
||||
"github.com/coder/coder/v2/aibridge/keypool"
|
||||
)
|
||||
|
||||
// DisabledStub is a Provider placeholder for a configured-but-disabled
|
||||
// provider. Only Name and Enabled return meaningful values; all other
|
||||
// methods return empty/nil so the stub never influences routing.
|
||||
type DisabledStub struct {
|
||||
name string
|
||||
providerType string
|
||||
}
|
||||
|
||||
// NewDisabledStub returns a Provider stub that reports Enabled() == false.
|
||||
// The type string is preserved so callers can distinguish provider families.
|
||||
func NewDisabledStub(name, providerType string) *DisabledStub {
|
||||
return &DisabledStub{name: name, providerType: providerType}
|
||||
}
|
||||
|
||||
func (d *DisabledStub) Type() string { return d.providerType }
|
||||
func (d *DisabledStub) Name() string { return d.name }
|
||||
func (*DisabledStub) Enabled() bool { return false }
|
||||
func (*DisabledStub) BaseURL() string { return "" }
|
||||
func (d *DisabledStub) RoutePrefix() string {
|
||||
return fmt.Sprintf("/%s", d.name)
|
||||
}
|
||||
func (*DisabledStub) BridgedRoutes() []string { return nil }
|
||||
func (*DisabledStub) PassthroughRoutes() []string { return nil }
|
||||
func (*DisabledStub) AuthHeader() string { return "" }
|
||||
func (*DisabledStub) KeyFailoverConfig(_ slog.Logger) keypool.KeyFailoverConfig {
|
||||
return keypool.KeyFailoverConfig{}
|
||||
}
|
||||
func (*DisabledStub) CircuitBreakerConfig() *config.CircuitBreaker { return nil }
|
||||
func (*DisabledStub) APIDumpDir() string { return "" }
|
||||
func (*DisabledStub) CreateInterceptor(_ http.ResponseWriter, _ *http.Request, _ trace.Tracer) (intercept.Interceptor, error) {
|
||||
//nolint:nilnil // disabled providers never reach the interceptor.
|
||||
return nil, nil
|
||||
}
|
||||
@@ -84,6 +84,8 @@ func (p *OpenAI) Name() string {
|
||||
return p.cfg.Name
|
||||
}
|
||||
|
||||
func (*OpenAI) Enabled() bool { return true }
|
||||
|
||||
func (p *OpenAI) RoutePrefix() string {
|
||||
// Route prefix includes version to match default OpenAI base URL.
|
||||
// More detailed explanation: https://github.com/coder/aibridge/pull/174#discussion_r2782320152
|
||||
@@ -141,14 +143,10 @@ func (p *OpenAI) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trace
|
||||
cfg.KeyPool = nil
|
||||
credKind = intercept.CredentialKindBYOK
|
||||
credSecret = token
|
||||
} else if cfg.KeyPool != nil {
|
||||
// Centralized: use the first key as a placeholder hint.
|
||||
// TODO(ssncferreira): record the actually-used key in
|
||||
// the interception record to reflect failover.
|
||||
if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil {
|
||||
credSecret = key.Value()
|
||||
}
|
||||
}
|
||||
// Centralized leaves credSecret empty: the hint is set by the
|
||||
// failover loop on each key attempt and persisted at
|
||||
// end-of-interception.
|
||||
cred := intercept.NewCredentialInfo(credKind, credSecret)
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix())
|
||||
|
||||
@@ -229,7 +229,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) {
|
||||
setHeaders: map[string]string{},
|
||||
wantAuthorization: "Bearer centralized-key",
|
||||
wantCredentialKind: intercept.CredentialKindCentralized,
|
||||
wantCredentialHint: "ce...ey",
|
||||
// Centralized hint is empty at CreateInterceptor; set
|
||||
// by the key failover loop during ProcessRequest.
|
||||
wantCredentialHint: "",
|
||||
},
|
||||
{
|
||||
name: "Responses_BYOK",
|
||||
@@ -249,7 +251,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) {
|
||||
setHeaders: map[string]string{},
|
||||
wantAuthorization: "Bearer centralized-key",
|
||||
wantCredentialKind: intercept.CredentialKindCentralized,
|
||||
wantCredentialHint: "ce...ey",
|
||||
// Centralized hint is empty at CreateInterceptor; set
|
||||
// by the key failover loop during ProcessRequest.
|
||||
wantCredentialHint: "",
|
||||
},
|
||||
// X-Api-Key should not appear in production since clients use Authorization,
|
||||
// but ensure it is stripped if it does arrive.
|
||||
|
||||
@@ -53,6 +53,8 @@ type Provider interface {
|
||||
// Name returns the provider instance name.
|
||||
// Defaults to Type() when not explicitly configured.
|
||||
Name() string
|
||||
// Enabled reports whether the provider should serve requests.
|
||||
Enabled() bool
|
||||
// BaseURL defines the base URL endpoint for this provider's API.
|
||||
BaseURL() string
|
||||
|
||||
|
||||
@@ -39,13 +39,20 @@ type InterceptionRecord struct {
|
||||
Client string
|
||||
UserAgent string
|
||||
CorrelatingToolCallID *string
|
||||
CredentialKind string
|
||||
CredentialHint string
|
||||
// CredentialKind is always set: either BYOK or centralized.
|
||||
CredentialKind string
|
||||
// CredentialHint is only set for BYOK, where the key is known
|
||||
// from the request. Centralized uses key failover, so the hint
|
||||
// can only be determined at end-of-interception.
|
||||
CredentialHint string
|
||||
}
|
||||
|
||||
type InterceptionRecordEnded struct {
|
||||
ID string
|
||||
EndedAt time.Time
|
||||
// CredentialHint is the hint observed at end-of-interception.
|
||||
// Only applied to the DB row for centralized; ignored for BYOK.
|
||||
CredentialHint string
|
||||
}
|
||||
|
||||
type TokenUsageRecord struct {
|
||||
|
||||
+37
-12
@@ -4,6 +4,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
@@ -101,10 +102,18 @@ func (r *poolDBReloader) Reload(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildProviders loads every ai_providers row (including disabled)
|
||||
// and returns the active provider list plus per-row outcomes. Per-row
|
||||
// build errors are logged and excluded from providers but recorded in
|
||||
// outcomes; only DB query failures propagate.
|
||||
// BuildProviders loads all ai_providers rows (enabled and disabled),
|
||||
// attaches keys to enabled rows, and constructs the equivalent
|
||||
// [aibridge.Provider] instances. The database is the single source of
|
||||
// truth for runtime provider configuration.
|
||||
//
|
||||
// Disabled rows produce a Provider stub with Enabled() == false so the
|
||||
// bridge can answer requests targeting them with a 503 sentinel.
|
||||
//
|
||||
// Per-provider construction errors are logged and the offending row is
|
||||
// excluded from the returned snapshot; only a failure of the DB query
|
||||
// itself is propagated. This keeps a single misconfigured row from
|
||||
// taking the whole daemon down.
|
||||
func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) {
|
||||
//nolint:gocritic // AsAIBridged has a minimal permission set for this purpose.
|
||||
authCtx := dbauthz.AsAIBridged(ctx)
|
||||
@@ -160,12 +169,9 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
|
||||
Name: row.Name,
|
||||
Type: string(row.Type),
|
||||
}
|
||||
if !row.Enabled {
|
||||
outcome.Status = aibridged.ProviderStatusDisabled
|
||||
outcomes = append(outcomes, outcome)
|
||||
continue
|
||||
if row.Enabled {
|
||||
enabledCount++
|
||||
}
|
||||
enabledCount++
|
||||
prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg)
|
||||
if err != nil {
|
||||
outcome.Status = aibridged.ProviderStatusError
|
||||
@@ -179,13 +185,17 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
|
||||
)
|
||||
continue
|
||||
}
|
||||
outcome.Status = aibridged.ProviderStatusEnabled
|
||||
if row.Enabled {
|
||||
outcome.Status = aibridged.ProviderStatusEnabled
|
||||
} else {
|
||||
outcome.Status = aibridged.ProviderStatusDisabled
|
||||
}
|
||||
outcomes = append(outcomes, outcome)
|
||||
providers = append(providers, prov)
|
||||
}
|
||||
|
||||
if enabledCount > 0 && len(providers) == 0 {
|
||||
logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers")
|
||||
if enabledCount > 0 && !slices.ContainsFunc(providers, func(p aibridge.Provider) bool { return p.Enabled() }) {
|
||||
logger.Warn(ctx, "all enabled ai providers failed to build; only disabled providers remain")
|
||||
}
|
||||
|
||||
return providers, outcomes, nil
|
||||
@@ -193,11 +203,18 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
|
||||
|
||||
// buildAIProviderFromRow decodes the settings blob and constructs the
|
||||
// appropriate [aibridge.Provider] for a single ai_providers row.
|
||||
// Disabled rows return a Provider stub carrying only Name and
|
||||
// Disabled: true; settings decode, key loading, and credential checks
|
||||
// are skipped because the provider will never call upstream.
|
||||
func buildAIProviderFromRow(
|
||||
row database.AIProvider,
|
||||
keys []database.AIProviderKey,
|
||||
cfg codersdk.AIBridgeConfig,
|
||||
) (aibridge.Provider, error) {
|
||||
if !row.Enabled {
|
||||
return disabledProviderFromRow(row)
|
||||
}
|
||||
|
||||
settings, err := db2sdk.AIProviderSettings(row.Settings)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("decode settings: %w", err)
|
||||
@@ -287,6 +304,14 @@ func buildAIProviderFromRow(
|
||||
}
|
||||
}
|
||||
|
||||
// disabledProviderFromRow builds a Provider stub for a disabled row.
|
||||
// Using provider.DisabledStub rather than a concrete provider avoids
|
||||
// duplicating the row.Type switch and ensures that a new AiProviderType
|
||||
// value is automatically handled without requiring a matching case here.
|
||||
func disabledProviderFromRow(row database.AIProvider) (aibridge.Provider, error) {
|
||||
return aibridge.NewDisabledProviderStub(row.Name, string(row.Type)), nil
|
||||
}
|
||||
|
||||
// buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check
|
||||
// len(keys) > 0 first; keypool.New rejects empty input.
|
||||
func buildAIProviderKeyPool(keys []database.AIProviderKey) (*keypool.Pool, error) {
|
||||
|
||||
@@ -393,25 +393,60 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) {
|
||||
|
||||
t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
Name: "openai-off",
|
||||
BaseUrl: "https://api.openai.com/",
|
||||
}, func(p *database.InsertAIProviderParams) {
|
||||
p.Enabled = false
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
row database.AIProvider
|
||||
}{
|
||||
{
|
||||
name: "OpenAI",
|
||||
row: database.AIProvider{
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
Name: "openai-off",
|
||||
BaseUrl: "https://api.openai.com/",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Anthropic and Bedrock have stricter credential checks
|
||||
// than the OpenAI family; the disabled short-circuit
|
||||
// must reach them too. No keys, no bedrock settings.
|
||||
name: "Anthropic",
|
||||
row: database.AIProvider{
|
||||
Type: database.AiProviderTypeAnthropic,
|
||||
Name: "anthropic-off",
|
||||
BaseUrl: "https://api.anthropic.com/",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Bedrock",
|
||||
row: database.AIProvider{
|
||||
Type: database.AiProviderTypeBedrock,
|
||||
Name: "bedrock-off",
|
||||
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, providers, "disabled providers must not be in the active snapshot")
|
||||
require.Len(t, outcomes, 1)
|
||||
assert.Equal(t, "openai-off", outcomes[0].Name)
|
||||
assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status)
|
||||
assert.NoError(t, outcomes[0].Err)
|
||||
dbgen.AIProvider(t, db, tc.row, func(p *database.InsertAIProviderParams) {
|
||||
p.Enabled = false
|
||||
})
|
||||
|
||||
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers, 1, "disabled providers stay in the snapshot so the bridge can serve a 503 sentinel")
|
||||
assert.Equal(t, tc.row.Name, providers[0].Name())
|
||||
assert.False(t, providers[0].Enabled())
|
||||
require.Len(t, outcomes, 1)
|
||||
assert.Equal(t, tc.row.Name, outcomes[0].Name)
|
||||
assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status)
|
||||
assert.NoError(t, outcomes[0].Err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -588,6 +588,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "OpenAI",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
Name: "openai",
|
||||
BaseUrl: "https://api.openai.com/",
|
||||
@@ -597,6 +598,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "Anthropic",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeAnthropic,
|
||||
Name: "anthropic",
|
||||
BaseUrl: "https://api.anthropic.com/",
|
||||
@@ -606,6 +608,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "Copilot",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeCopilot,
|
||||
Name: "copilot",
|
||||
BaseUrl: "https://api.githubcopilot.com/",
|
||||
@@ -615,6 +618,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "Azure",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeAzure,
|
||||
Name: "azure",
|
||||
BaseUrl: "https://example.openai.azure.com/",
|
||||
@@ -624,6 +628,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "Google",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeGoogle,
|
||||
Name: "google",
|
||||
BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
@@ -633,6 +638,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "OpenAICompat",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeOpenaiCompat,
|
||||
Name: "openai-compat",
|
||||
BaseUrl: "https://compat.example.com/v1/",
|
||||
@@ -642,6 +648,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "OpenRouter",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeOpenrouter,
|
||||
Name: "openrouter",
|
||||
BaseUrl: "https://openrouter.ai/api/v1/",
|
||||
@@ -651,6 +658,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "Vercel",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeVercel,
|
||||
Name: "vercel",
|
||||
BaseUrl: "https://api.v0.dev/v1/",
|
||||
@@ -660,6 +668,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
{
|
||||
name: "Bedrock",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeBedrock,
|
||||
Name: "bedrock",
|
||||
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
@@ -694,6 +703,7 @@ func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := buildAIProviderFromRow(database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeBedrock,
|
||||
Name: "bedrock-no-settings",
|
||||
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
|
||||
+26
-1
@@ -245,8 +245,15 @@ func Test_TaskSend(t *testing.T) {
|
||||
pauseTask(setupCtx, t, setup.userClient, setup.task)
|
||||
resumeTask(setupCtx, t, setup.userClient, setup.task)
|
||||
|
||||
// Set up mock clock and traps before starting the command.
|
||||
// Without a mock clock the poll can race with the stop build
|
||||
// and see a transient 'unknown' status instead of 'paused'.
|
||||
mClock := quartz.NewMock(t)
|
||||
tickTrap := mClock.Trap().NewTicker("task_send", "poll")
|
||||
resetTrap := mClock.Trap().TickerReset("task_send", "poll")
|
||||
|
||||
// When: We attempt to send input to the initializing task.
|
||||
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
|
||||
inv, root := clitest.NewWithClock(t, mClock, "task", "send", setup.task.Name, "some task input")
|
||||
clitest.SetupConfig(t, setup.userClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
@@ -259,11 +266,29 @@ func Test_TaskSend(t *testing.T) {
|
||||
// of waitForTaskIdle.
|
||||
pty.ExpectMatchContext(ctx, "Waiting for task to become idle")
|
||||
|
||||
// Wait for ticker creation and release it.
|
||||
tickCall := tickTrap.MustWait(ctx)
|
||||
tickCall.MustRelease(ctx)
|
||||
tickTrap.Close()
|
||||
|
||||
// Fire the immediate first poll (time.Nanosecond initial interval).
|
||||
// This poll sees 'initializing' because no agent is connected.
|
||||
mClock.Advance(time.Nanosecond).MustWait(ctx)
|
||||
|
||||
// Wait for Reset (confirms first poll completed).
|
||||
resetCall := resetTrap.MustWait(ctx)
|
||||
resetCall.MustRelease(ctx)
|
||||
resetTrap.Close()
|
||||
|
||||
// Pause the task while waitForTaskIdle is polling. Since
|
||||
// no agent is connected, the task stays initializing until
|
||||
// we pause it, at which point the status becomes paused.
|
||||
pauseTask(ctx, t, setup.userClient, setup.task)
|
||||
|
||||
// Fire second poll at the regular 5s interval. The stop
|
||||
// build has completed, so the poll sees 'paused'.
|
||||
mClock.Advance(5 * time.Second).MustWait(ctx)
|
||||
|
||||
// Then: The command should fail because the task was paused.
|
||||
err := w.Wait()
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -116,10 +116,21 @@ func SeedAIProvidersFromEnv(
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode existing settings for %q: %w", dp.Name, err)
|
||||
}
|
||||
// Load existing bearer keys so the canonical hash
|
||||
// includes credentials for comparison.
|
||||
existingKeyRows, err := tx.GetAIProviderKeysByProviderID(sysCtx, existing.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("load existing keys for %q: %w", dp.Name, err)
|
||||
}
|
||||
existingKeys := make([]string, 0, len(existingKeyRows))
|
||||
for _, k := range existingKeyRows {
|
||||
existingKeys = append(existingKeys, k.APIKey)
|
||||
}
|
||||
existingDP := desiredAIProvider{
|
||||
Type: existing.Type,
|
||||
BaseURL: existing.BaseUrl,
|
||||
Bedrock: existingSettings.Bedrock,
|
||||
Keys: existingKeys,
|
||||
}
|
||||
existingHash := computeProviderHash(existingDP.canonical())
|
||||
if existingHash == dp.Hash {
|
||||
@@ -196,18 +207,15 @@ func SeedAIProvidersFromEnv(
|
||||
// canonicalAIProvider is the shape we hash to detect drift between the
|
||||
// configured environment and the row stored in the database. The fields
|
||||
// we hash are exactly the operator-controllable inputs that affect
|
||||
// runtime behavior. Credentials are intentionally NOT part of the hash
|
||||
// so operators can rotate them via the API without forcing a server
|
||||
// restart. This applies to both bearer API keys (stored in
|
||||
// ai_provider_keys) and to Bedrock access key/secret pairs (stored in
|
||||
// the settings blob because Bedrock authenticates via settings rather
|
||||
// than a bearer token).
|
||||
// runtime behavior, including credentials.
|
||||
//
|
||||
// Model and SmallFastModel are excluded: they're tunables, and their
|
||||
// serpent defaults shift across releases.
|
||||
type canonicalAIProvider struct {
|
||||
Type string `json:"type"`
|
||||
BaseURL string `json:"base_url"`
|
||||
BedrockRegion string `json:"bedrock_region"`
|
||||
KeysHash string `json:"keys_hash"`
|
||||
}
|
||||
|
||||
// desiredAIProvider is a normalized provider description sourced from
|
||||
@@ -235,9 +243,39 @@ func (d desiredAIProvider) canonical() canonicalAIProvider {
|
||||
if d.Bedrock != nil {
|
||||
c.BedrockRegion = d.Bedrock.Region
|
||||
}
|
||||
c.KeysHash = computeKeysHash(d.Keys, d.Bedrock)
|
||||
return c
|
||||
}
|
||||
|
||||
// computeKeysHash produces a deterministic hash over the bearer API
|
||||
// keys and, for Bedrock providers, the access key and secret.
|
||||
func computeKeysHash(bearerKeys []string, bedrock *codersdk.AIProviderBedrockSettings) string {
|
||||
// Collect all credential material in a deterministic order.
|
||||
// Bearer keys are sorted so reordering in env vars does not
|
||||
// trigger a false-positive drift.
|
||||
sorted := make([]string, len(bearerKeys))
|
||||
copy(sorted, bearerKeys)
|
||||
slices.Sort(sorted)
|
||||
|
||||
h := sha256.New()
|
||||
for _, k := range sorted {
|
||||
_, _ = h.Write([]byte(k))
|
||||
// Separator so "ab"+"c" != "a"+"bc".
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
if bedrock != nil {
|
||||
if bedrock.AccessKey != nil {
|
||||
_, _ = h.Write([]byte(*bedrock.AccessKey))
|
||||
}
|
||||
_, _ = h.Write([]byte{0})
|
||||
if bedrock.AccessKeySecret != nil {
|
||||
_, _ = h.Write([]byte(*bedrock.AccessKeySecret))
|
||||
}
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func computeProviderHash(c canonicalAIProvider) string {
|
||||
// json.Marshal is deterministic for structs because field order is
|
||||
// fixed by the struct definition.
|
||||
|
||||
@@ -91,21 +91,23 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
|
||||
// Changing the API key alone does NOT count as drift: keys
|
||||
// live in a separate table and operators rotate them via the
|
||||
// API. Only changes to non-credential provider-level fields
|
||||
// (base_url, type, Bedrock region/model) trip the drift check.
|
||||
// Changing the API key counts as drift: keys are included
|
||||
// in the canonical hash so operators notice when env-var
|
||||
// credential changes are ignored by an existing provider.
|
||||
cfg.LegacyOpenAI.Key = serpent.String("sk-rotated")
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
|
||||
// Changing the base URL is real drift.
|
||||
cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2")
|
||||
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "differs from the current environment configuration")
|
||||
|
||||
// Changing the base URL is also real drift.
|
||||
cfg.LegacyOpenAI.Key = serpent.String("sk-original")
|
||||
cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2")
|
||||
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "differs from the current environment configuration")
|
||||
})
|
||||
|
||||
t.Run("BedrockCredentialRotationIsNotDrift", func(t *testing.T) {
|
||||
t.Run("BedrockCredentialChangeIsDrift", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -120,17 +122,20 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
|
||||
// Rotating the Bedrock access key and secret in env must NOT
|
||||
// trip the drift check: they're credentials, equivalent to
|
||||
// bearer API keys, and operators rotate them via the API.
|
||||
// Rotating the Bedrock access key in env trips the drift
|
||||
// check so operators know the change did not take effect.
|
||||
cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-rotated")
|
||||
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-rotated")
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "differs from the current environment configuration")
|
||||
|
||||
// Changing the Bedrock region (a non-credential field) is
|
||||
// real drift.
|
||||
// also real drift.
|
||||
cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-original")
|
||||
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-original")
|
||||
cfg.LegacyBedrock.Region = serpent.String("us-west-2")
|
||||
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
|
||||
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "differs from the current environment configuration")
|
||||
})
|
||||
@@ -293,6 +298,57 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
|
||||
require.Equal(t, "sk-ant-1", anKeys[0].APIKey)
|
||||
})
|
||||
|
||||
t.Run("IndexedProvidersKeyDriftWithMultipleKeysAndProviders", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
cfg := codersdk.AIBridgeConfig{
|
||||
Providers: []codersdk.AIProviderConfig{
|
||||
{
|
||||
Type: "openai",
|
||||
Name: "primary-openai",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Keys: []string{"sk-openai-1", "sk-openai-2"},
|
||||
},
|
||||
{
|
||||
Type: "anthropic",
|
||||
Name: "primary-anthropic",
|
||||
BaseURL: "https://api.anthropic.com/",
|
||||
Keys: []string{"sk-ant-1", "sk-ant-2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
|
||||
// Reordering keys must not count as drift. The canonical hash
|
||||
// sorts keys before hashing, so equivalent key sets remain
|
||||
// stable across restarts.
|
||||
cfg.Providers[0].Keys = []string{"sk-openai-2", "sk-openai-1"}
|
||||
cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-1"}
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
|
||||
// Changing one key on one provider must block startup even
|
||||
// when multiple providers are configured.
|
||||
cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-rotated"}
|
||||
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "differs from the current environment configuration")
|
||||
require.Contains(t, err.Error(), `"primary-anthropic"`)
|
||||
|
||||
oa, err := db.GetAIProviderByName(ctx, "primary-openai")
|
||||
require.NoError(t, err)
|
||||
oaKeys, err := db.GetAIProviderKeysByProviderID(ctx, oa.ID)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []string{"sk-openai-1", "sk-openai-2"}, []string{oaKeys[0].APIKey, oaKeys[1].APIKey})
|
||||
|
||||
an, err := db.GetAIProviderByName(ctx, "primary-anthropic")
|
||||
require.NoError(t, err)
|
||||
anKeys, err := db.GetAIProviderKeysByProviderID(ctx, an.ID)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []string{"sk-ant-1", "sk-ant-2"}, []string{anKeys[0].APIKey, anKeys[1].APIKey})
|
||||
})
|
||||
|
||||
t.Run("BedrockIndexedProviderHasNoKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
@@ -424,7 +480,7 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
|
||||
require.Empty(t, all, "expected no active rows after soft-delete + re-seed")
|
||||
})
|
||||
|
||||
t.Run("ExistingKeysArePreserved", func(t *testing.T) {
|
||||
t.Run("ExistingKeysBlockOnDrift", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -440,15 +496,17 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
|
||||
row, err := db.GetAIProviderByName(ctx, "openai")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Operator rotates the env key. The seed must not duplicate
|
||||
// keys on a row that already exists; the new key is only
|
||||
// installed via the API/CRUD layer in this flow.
|
||||
// Operator rotates the env key. The seed now blocks startup
|
||||
// because the keys differ, alerting the operator.
|
||||
cfg.LegacyOpenAI.Key = serpent.String("sk-rotated")
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "differs from the current environment configuration")
|
||||
|
||||
// The original key is still in the database.
|
||||
keys, err := db.GetAIProviderKeysByProviderID(ctx, row.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1, "env reseed must not duplicate keys on existing rows")
|
||||
require.Len(t, keys, 1)
|
||||
require.Equal(t, "sk-original", keys[0].APIKey)
|
||||
})
|
||||
|
||||
@@ -482,6 +540,40 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
|
||||
require.Len(t, all, 1, "duplicate indexed entries with matching hash must produce a single row")
|
||||
})
|
||||
|
||||
t.Run("IndexedDuplicateNameMatchingHashDedupesReorderedKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Key order should not affect the canonical hash. Reordered
|
||||
// duplicates under the same name should still dedupe.
|
||||
cfg := codersdk.AIBridgeConfig{
|
||||
Providers: []codersdk.AIProviderConfig{
|
||||
{
|
||||
Type: "openai",
|
||||
Name: "shared",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Keys: []string{"sk-1", "sk-2"},
|
||||
},
|
||||
{
|
||||
Type: "openai",
|
||||
Name: "shared",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Keys: []string{"sk-2", "sk-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
|
||||
|
||||
all, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all, 1)
|
||||
keys, err := db.GetAIProviderKeysByProviderID(ctx, all[0].ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 2)
|
||||
require.ElementsMatch(t, []string{"sk-1", "sk-2"}, []string{keys[0].APIKey, keys[1].APIKey})
|
||||
})
|
||||
|
||||
t.Run("IndexedDuplicateNameMismatchingHashFails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
@@ -30,7 +30,9 @@ const (
|
||||
type Pooler interface {
|
||||
Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpBootstrapper MCPProxyBuilder) (http.Handler, error)
|
||||
// ReplaceProviders swaps the providers used to construct future
|
||||
// RequestBridge instances and clears the cache.
|
||||
// RequestBridge instances and clears the cache. Disabled providers
|
||||
// must be included; the bridge serves a 503 sentinel on their
|
||||
// routes.
|
||||
ReplaceProviders(providers []aibridge.Provider)
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
@@ -53,7 +55,8 @@ var _ Pooler = &CachedBridgePool{}
|
||||
|
||||
type CachedBridgePool struct {
|
||||
cache *ristretto.Cache[string, *aibridge.RequestBridge]
|
||||
// providers is the live provider set used by new RequestBridge instances.
|
||||
// providers is the live provider set used by new RequestBridge
|
||||
// instances. Includes disabled providers.
|
||||
providers atomic.Pointer[[]aibridge.Provider]
|
||||
providerVersion atomic.Int64
|
||||
logger slog.Logger
|
||||
|
||||
@@ -216,8 +216,9 @@ type RecordInterceptionEndedRequest struct {
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID.
|
||||
EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID.
|
||||
EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"`
|
||||
CredentialHint string `protobuf:"bytes,3,opt,name=credential_hint,json=credentialHint,proto3" json:"credential_hint,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RecordInterceptionEndedRequest) Reset() {
|
||||
@@ -266,6 +267,13 @@ func (x *RecordInterceptionEndedRequest) GetEndedAt() *timestamppb.Timestamp {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *RecordInterceptionEndedRequest) GetCredentialHint() string {
|
||||
if x != nil {
|
||||
return x.CredentialHint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type RecordInterceptionEndedResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -1295,249 +1303,252 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{
|
||||
0x42, 0x14, 0x0a, 0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73,
|
||||
0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x22, 0x67, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e,
|
||||
0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f,
|
||||
0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
|
||||
0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22, 0x21, 0x0a,
|
||||
0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f,
|
||||
0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c,
|
||||
0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12,
|
||||
0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
|
||||
0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f,
|
||||
0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
|
||||
0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x22, 0x90, 0x01, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49,
|
||||
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64,
|
||||
0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
|
||||
0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x12, 0x27,
|
||||
0x0a, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x6e,
|
||||
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74,
|
||||
0x69, 0x61, 0x6c, 0x48, 0x69, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64,
|
||||
0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52,
|
||||
0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39,
|
||||
0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09,
|
||||
0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63,
|
||||
0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f,
|
||||
0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68,
|
||||
0x65, 0x52, 0x65, 0x61, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
|
||||
0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f,
|
||||
0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01,
|
||||
0x28, 0x03, 0x52, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e,
|
||||
0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74,
|
||||
0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
|
||||
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05,
|
||||
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f,
|
||||
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e,
|
||||
0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63,
|
||||
0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
|
||||
0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15,
|
||||
0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
|
||||
0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a,
|
||||
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72,
|
||||
0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63,
|
||||
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12,
|
||||
0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e,
|
||||
0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74,
|
||||
0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48,
|
||||
0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b,
|
||||
0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08,
|
||||
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61,
|
||||
0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
|
||||
0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
|
||||
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
|
||||
0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
|
||||
0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
|
||||
0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61,
|
||||
0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x49,
|
||||
0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61,
|
||||
0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x63, 0x61,
|
||||
0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f,
|
||||
0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
|
||||
0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22,
|
||||
0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88,
|
||||
0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18,
|
||||
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08,
|
||||
0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08,
|
||||
0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f,
|
||||
0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01,
|
||||
0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61,
|
||||
0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61,
|
||||
0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
|
||||
0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
|
||||
0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18,
|
||||
0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c,
|
||||
0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51,
|
||||
0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
|
||||
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
|
||||
0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||
0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
|
||||
0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c,
|
||||
0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
|
||||
0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54,
|
||||
0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c,
|
||||
0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
|
||||
0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65,
|
||||
0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e,
|
||||
0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20,
|
||||
0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a,
|
||||
0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61,
|
||||
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f,
|
||||
0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||
0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63,
|
||||
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12,
|
||||
0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64,
|
||||
0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73,
|
||||
0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64,
|
||||
0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61,
|
||||
0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
|
||||
0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
|
||||
0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a,
|
||||
0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
|
||||
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
|
||||
0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
|
||||
0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
|
||||
0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74,
|
||||
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04,
|
||||
0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67,
|
||||
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65,
|
||||
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49,
|
||||
0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76,
|
||||
0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09,
|
||||
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04,
|
||||
0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c,
|
||||
0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74,
|
||||
0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74,
|
||||
0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f,
|
||||
0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88,
|
||||
0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08,
|
||||
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
|
||||
0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
|
||||
0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65,
|
||||
0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63,
|
||||
0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f,
|
||||
0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61,
|
||||
0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79,
|
||||
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52,
|
||||
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f,
|
||||
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69,
|
||||
0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22,
|
||||
0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61,
|
||||
0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52,
|
||||
0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68,
|
||||
0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74,
|
||||
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
|
||||
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f,
|
||||
0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
|
||||
0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66,
|
||||
0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61,
|
||||
0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18,
|
||||
0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43,
|
||||
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65,
|
||||
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f,
|
||||
0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
|
||||
0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74,
|
||||
0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
|
||||
0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65,
|
||||
0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d,
|
||||
0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a,
|
||||
0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63,
|
||||
0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31,
|
||||
0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e,
|
||||
0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d,
|
||||
0x63, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64,
|
||||
0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76,
|
||||
0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61,
|
||||
0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03,
|
||||
0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
|
||||
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74,
|
||||
0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
|
||||
0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
|
||||
0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45,
|
||||
0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f,
|
||||
0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
|
||||
0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74,
|
||||
0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e,
|
||||
0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b,
|
||||
0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49,
|
||||
0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12,
|
||||
0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
|
||||
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49,
|
||||
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
|
||||
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
|
||||
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65,
|
||||
0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a,
|
||||
0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67,
|
||||
0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d,
|
||||
0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67,
|
||||
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61,
|
||||
0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65,
|
||||
0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c,
|
||||
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55,
|
||||
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67,
|
||||
0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
|
||||
0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43,
|
||||
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47,
|
||||
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65,
|
||||
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49,
|
||||
0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d,
|
||||
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65,
|
||||
0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e,
|
||||
0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d,
|
||||
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
|
||||
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
|
||||
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
|
||||
0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d,
|
||||
0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
|
||||
0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47,
|
||||
0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
|
||||
0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
|
||||
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65,
|
||||
0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74,
|
||||
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50,
|
||||
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19,
|
||||
0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63,
|
||||
0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
|
||||
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61,
|
||||
0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22,
|
||||
0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e,
|
||||
0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c,
|
||||
0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12,
|
||||
0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67,
|
||||
0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65,
|
||||
0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43,
|
||||
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f,
|
||||
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64,
|
||||
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, 0x63, 0x70, 0x53, 0x65, 0x72, 0x76,
|
||||
0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25,
|
||||
0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65,
|
||||
0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
|
||||
0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
|
||||
0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75,
|
||||
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
|
||||
0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30,
|
||||
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
|
||||
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64,
|
||||
0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
|
||||
0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74,
|
||||
0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73,
|
||||
0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63,
|
||||
0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72,
|
||||
0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11,
|
||||
0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a,
|
||||
0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14,
|
||||
0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75,
|
||||
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
|
||||
0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75,
|
||||
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61,
|
||||
0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65,
|
||||
0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65,
|
||||
0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65,
|
||||
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a,
|
||||
0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e,
|
||||
0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55,
|
||||
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55,
|
||||
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67,
|
||||
0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f,
|
||||
0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
|
||||
0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c,
|
||||
0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64,
|
||||
0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75,
|
||||
0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
|
||||
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65,
|
||||
0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
|
||||
0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42,
|
||||
0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74,
|
||||
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50,
|
||||
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32,
|
||||
0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a,
|
||||
0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
|
||||
0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62,
|
||||
0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
|
||||
0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69,
|
||||
0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -58,6 +58,7 @@ message RecordInterceptionResponse {}
|
||||
message RecordInterceptionEndedRequest {
|
||||
string id = 1; // UUID.
|
||||
google.protobuf.Timestamp ended_at = 2;
|
||||
string credential_hint = 3;
|
||||
}
|
||||
|
||||
message RecordInterceptionEndedResponse {}
|
||||
|
||||
@@ -17,9 +17,9 @@ const (
|
||||
)
|
||||
|
||||
// ProviderOutcome classifies one ai_providers row, including disabled
|
||||
// and errored rows the pool excludes. Err is populated only when
|
||||
// Status == ProviderStatusError; the build error is already logged at
|
||||
// the call site.
|
||||
// rows (which the pool keeps as 503 stubs) and errored rows (which the
|
||||
// pool excludes). Err is populated only when Status == ProviderStatusError;
|
||||
// the build error is already logged at the call site.
|
||||
type ProviderOutcome struct {
|
||||
Name string
|
||||
Type string
|
||||
|
||||
@@ -45,8 +45,9 @@ func (t *recorderTranslation) RecordInterception(ctx context.Context, req *aibri
|
||||
|
||||
func (t *recorderTranslation) RecordInterceptionEnded(ctx context.Context, req *aibridge.InterceptionRecordEnded) error {
|
||||
_, err := t.client.RecordInterceptionEnded(ctx, &proto.RecordInterceptionEndedRequest{
|
||||
Id: req.ID,
|
||||
EndedAt: timestamppb.New(req.EndedAt),
|
||||
Id: req.ID,
|
||||
EndedAt: timestamppb.New(req.EndedAt),
|
||||
CredentialHint: req.CredentialHint,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -222,8 +222,9 @@ func (s *Server) RecordInterceptionEnded(ctx context.Context, in *proto.RecordIn
|
||||
}
|
||||
|
||||
_, err = s.store.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: intcID,
|
||||
EndedAt: in.EndedAt.AsTime(),
|
||||
ID: intcID,
|
||||
EndedAt: in.EndedAt.AsTime(),
|
||||
CredentialHint: in.CredentialHint,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("end interception: %w", err)
|
||||
|
||||
@@ -944,23 +944,26 @@ func TestRecordInterceptionEnded(t *testing.T) {
|
||||
{
|
||||
name: "ok",
|
||||
request: &proto.RecordInterceptionEndedRequest{
|
||||
Id: uuid.UUID{1}.String(),
|
||||
EndedAt: timestamppb.Now(),
|
||||
Id: uuid.UUID{1}.String(),
|
||||
EndedAt: timestamppb.Now(),
|
||||
CredentialHint: "sk-a...efgh",
|
||||
},
|
||||
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) {
|
||||
interceptionID, err := uuid.Parse(req.GetId())
|
||||
assert.NoError(t, err, "parse interception UUID")
|
||||
|
||||
db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: interceptionID,
|
||||
EndedAt: req.EndedAt.AsTime(),
|
||||
ID: interceptionID,
|
||||
EndedAt: req.EndedAt.AsTime(),
|
||||
CredentialHint: req.CredentialHint,
|
||||
}).Return(database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
InitiatorID: uuid.UUID{2},
|
||||
Provider: "prov",
|
||||
Model: "mod",
|
||||
StartedAt: time.Now(),
|
||||
EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true},
|
||||
ID: interceptionID,
|
||||
InitiatorID: uuid.UUID{2},
|
||||
Provider: "prov",
|
||||
Model: "mod",
|
||||
StartedAt: time.Now(),
|
||||
EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true},
|
||||
CredentialHint: req.CredentialHint,
|
||||
}, nil)
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+159
-2
@@ -9171,6 +9171,110 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/ai/budget": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get user AI budget override",
|
||||
"operationId": "get-user-ai-budget-override",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Upsert user AI budget override",
|
||||
"operationId": "upsert-user-ai-budget-override",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Upsert user AI budget override request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Delete user AI budget override",
|
||||
"operationId": "delete-user-ai-budget-override",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/appearance": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -15199,6 +15303,10 @@ const docTemplate = `{
|
||||
"audit_log:*",
|
||||
"audit_log:create",
|
||||
"audit_log:read",
|
||||
"boundary_log:*",
|
||||
"boundary_log:create",
|
||||
"boundary_log:delete",
|
||||
"boundary_log:read",
|
||||
"boundary_usage:*",
|
||||
"boundary_usage:delete",
|
||||
"boundary_usage:read",
|
||||
@@ -15425,6 +15533,10 @@ const docTemplate = `{
|
||||
"APIKeyScopeAuditLogAll",
|
||||
"APIKeyScopeAuditLogCreate",
|
||||
"APIKeyScopeAuditLogRead",
|
||||
"APIKeyScopeBoundaryLogAll",
|
||||
"APIKeyScopeBoundaryLogCreate",
|
||||
"APIKeyScopeBoundaryLogDelete",
|
||||
"APIKeyScopeBoundaryLogRead",
|
||||
"APIKeyScopeBoundaryUsageAll",
|
||||
"APIKeyScopeBoundaryUsageDelete",
|
||||
"APIKeyScopeBoundaryUsageRead",
|
||||
@@ -16490,7 +16602,8 @@ const docTemplate = `{
|
||||
"auth",
|
||||
"config",
|
||||
"usage_limit",
|
||||
"missing_key"
|
||||
"missing_key",
|
||||
"provider_disabled"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatErrorKindGeneric",
|
||||
@@ -16501,7 +16614,8 @@ const docTemplate = `{
|
||||
"ChatErrorKindAuth",
|
||||
"ChatErrorKindConfig",
|
||||
"ChatErrorKindUsageLimit",
|
||||
"ChatErrorKindMissingKey"
|
||||
"ChatErrorKindMissingKey",
|
||||
"ChatErrorKindProviderDisabled"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
@@ -22223,6 +22337,7 @@ const docTemplate = `{
|
||||
"assign_org_role",
|
||||
"assign_role",
|
||||
"audit_log",
|
||||
"boundary_log",
|
||||
"boundary_usage",
|
||||
"chat",
|
||||
"connection_log",
|
||||
@@ -22273,6 +22388,7 @@ const docTemplate = `{
|
||||
"ResourceAssignOrgRole",
|
||||
"ResourceAssignRole",
|
||||
"ResourceAuditLog",
|
||||
"ResourceBoundaryLog",
|
||||
"ResourceBoundaryUsage",
|
||||
"ResourceChat",
|
||||
"ResourceConnectionLog",
|
||||
@@ -24651,6 +24767,23 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpsertUserAIBudgetOverrideRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"group_id"
|
||||
],
|
||||
"properties": {
|
||||
"group_id": {
|
||||
"description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.",
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"spend_limit_micros": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpsertWorkspaceAgentPortShareRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24805,6 +24938,30 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserAIBudgetOverride": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"group_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"spend_limit_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserActivity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+145
-2
@@ -8132,6 +8132,98 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/ai/budget": {
|
||||
"get": {
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get user AI budget override",
|
||||
"operationId": "get-user-ai-budget-override",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Upsert user AI budget override",
|
||||
"operationId": "upsert-user-ai-budget-override",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Upsert user AI budget override request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Delete user AI budget override",
|
||||
"operationId": "delete-user-ai-budget-override",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/appearance": {
|
||||
"get": {
|
||||
"produces": ["application/json"],
|
||||
@@ -13595,6 +13687,10 @@
|
||||
"audit_log:*",
|
||||
"audit_log:create",
|
||||
"audit_log:read",
|
||||
"boundary_log:*",
|
||||
"boundary_log:create",
|
||||
"boundary_log:delete",
|
||||
"boundary_log:read",
|
||||
"boundary_usage:*",
|
||||
"boundary_usage:delete",
|
||||
"boundary_usage:read",
|
||||
@@ -13821,6 +13917,10 @@
|
||||
"APIKeyScopeAuditLogAll",
|
||||
"APIKeyScopeAuditLogCreate",
|
||||
"APIKeyScopeAuditLogRead",
|
||||
"APIKeyScopeBoundaryLogAll",
|
||||
"APIKeyScopeBoundaryLogCreate",
|
||||
"APIKeyScopeBoundaryLogDelete",
|
||||
"APIKeyScopeBoundaryLogRead",
|
||||
"APIKeyScopeBoundaryUsageAll",
|
||||
"APIKeyScopeBoundaryUsageDelete",
|
||||
"APIKeyScopeBoundaryUsageRead",
|
||||
@@ -14840,7 +14940,8 @@
|
||||
"auth",
|
||||
"config",
|
||||
"usage_limit",
|
||||
"missing_key"
|
||||
"missing_key",
|
||||
"provider_disabled"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatErrorKindGeneric",
|
||||
@@ -14851,7 +14952,8 @@
|
||||
"ChatErrorKindAuth",
|
||||
"ChatErrorKindConfig",
|
||||
"ChatErrorKindUsageLimit",
|
||||
"ChatErrorKindMissingKey"
|
||||
"ChatErrorKindMissingKey",
|
||||
"ChatErrorKindProviderDisabled"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
@@ -20366,6 +20468,7 @@
|
||||
"assign_org_role",
|
||||
"assign_role",
|
||||
"audit_log",
|
||||
"boundary_log",
|
||||
"boundary_usage",
|
||||
"chat",
|
||||
"connection_log",
|
||||
@@ -20416,6 +20519,7 @@
|
||||
"ResourceAssignOrgRole",
|
||||
"ResourceAssignRole",
|
||||
"ResourceAuditLog",
|
||||
"ResourceBoundaryLog",
|
||||
"ResourceBoundaryUsage",
|
||||
"ResourceChat",
|
||||
"ResourceConnectionLog",
|
||||
@@ -22684,6 +22788,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpsertUserAIBudgetOverrideRequest": {
|
||||
"type": "object",
|
||||
"required": ["group_id"],
|
||||
"properties": {
|
||||
"group_id": {
|
||||
"description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.",
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"spend_limit_micros": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpsertWorkspaceAgentPortShareRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -22817,6 +22936,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserAIBudgetOverride": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"group_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"spend_limit_micros": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserActivity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -422,6 +422,23 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
Isolation: sql.LevelRepeatableRead,
|
||||
TxIdentifier: "lifecycle",
|
||||
})
|
||||
// A concurrent build (e.g. from the API or another lifecycle
|
||||
// executor) may have already inserted a build with the same
|
||||
// number. This is a benign race; the other actor's build
|
||||
// will take effect. Clear the error so downstream checks
|
||||
// (audit, notification, stats) treat this as a no-op.
|
||||
if database.IsUniqueViolation(err, database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey) {
|
||||
log.Info(e.ctx, "skipping workspace: concurrent build already inserted", slog.Error(err))
|
||||
err = nil
|
||||
// Reset notification flags set before builder.Build.
|
||||
// The build was rolled back, so this executor did not
|
||||
// perform the transition. The concurrent actor handles
|
||||
// both the build and any notifications. Without these
|
||||
// resets, downstream code would send duplicate or
|
||||
// incorrect notifications.
|
||||
didAutoUpdate = false
|
||||
shouldNotifyTaskPause = false
|
||||
}
|
||||
if auditLog != nil {
|
||||
// If the transition didn't succeed then updating the workspace
|
||||
// to indicate dormant didn't either.
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
@@ -160,6 +162,92 @@ func TestMultipleLifecycleExecutors(t *testing.T) {
|
||||
assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID])
|
||||
}
|
||||
|
||||
// uniqueViolationStore wraps a database.Store and injects a unique violation
|
||||
// error from InsertWorkspaceBuild after a configurable number of successful
|
||||
// calls. This simulates a concurrent build race (e.g. an API-driven start
|
||||
// racing with the lifecycle executor autostart).
|
||||
type uniqueViolationStore struct {
|
||||
database.Store
|
||||
insertCount *atomic.Int32 // pointer: shared across InTx copies
|
||||
failAfterN int32
|
||||
}
|
||||
|
||||
func newUniqueViolationStore(db database.Store, failAfterN int32) *uniqueViolationStore {
|
||||
return &uniqueViolationStore{
|
||||
Store: db,
|
||||
insertCount: &atomic.Int32{},
|
||||
failAfterN: failAfterN,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uniqueViolationStore) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
|
||||
return s.Store.InTx(func(tx database.Store) error {
|
||||
return fn(&uniqueViolationStore{
|
||||
Store: tx,
|
||||
insertCount: s.insertCount, // shared pointer
|
||||
failAfterN: s.failAfterN,
|
||||
})
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (s *uniqueViolationStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
|
||||
n := s.insertCount.Add(1)
|
||||
if n > s.failAfterN {
|
||||
return &pq.Error{
|
||||
Code: pq.ErrorCode("23505"),
|
||||
Constraint: string(database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey),
|
||||
Message: `duplicate key value violates unique constraint "workspace_builds_workspace_id_build_number_key"`,
|
||||
}
|
||||
}
|
||||
return s.Store.InsertWorkspaceBuild(ctx, arg)
|
||||
}
|
||||
|
||||
func TestExecutorBuildNumberRaceIsHandled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The lifecycle executor must handle a unique-violation from
|
||||
// InsertWorkspaceBuild gracefully. This error occurs when a concurrent
|
||||
// actor (API handler, another executor, prebuilds reconciler) inserts a
|
||||
// build with the same number before the executor's INSERT lands.
|
||||
//
|
||||
// We inject the error via a store wrapper. The first two
|
||||
// InsertWorkspaceBuild calls succeed (setup builds), then the third
|
||||
// (the lifecycle executor's autostart build) gets a unique violation.
|
||||
|
||||
realDB, ps := dbtestutil.NewDB(t)
|
||||
wrappedDB := newUniqueViolationStore(realDB, 2) // Allow builds 1 (start) and 2 (stop); fail build 3 (autostart)
|
||||
|
||||
var (
|
||||
sched, _ = cron.Weekly("CRON_TZ=UTC 0 * * * *")
|
||||
tickCh = make(chan time.Time)
|
||||
statsCh = make(chan autobuild.Stats)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildTicker: tickCh,
|
||||
AutobuildStats: statsCh,
|
||||
Database: wrappedDB,
|
||||
Pubsub: ps,
|
||||
})
|
||||
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.AutostartSchedule = ptr.Ref(sched.String())
|
||||
})
|
||||
)
|
||||
|
||||
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
|
||||
|
||||
p, err := coderdtest.GetProvisionerForTags(realDB, time.Now(), workspace.OrganizationID, nil)
|
||||
require.NoError(t, err)
|
||||
next := sched.Next(workspace.LatestBuild.CreatedAt)
|
||||
coderdtest.UpdateProvisionerLastSeenAt(t, realDB, p.ID, next)
|
||||
|
||||
tickCh <- next
|
||||
stats := <-statsCh
|
||||
|
||||
// The lifecycle executor should treat the unique violation as a benign
|
||||
// race, not as a hard error.
|
||||
assert.Empty(t, stats.Errors, "lifecycle executor should not report unique-violation as error")
|
||||
}
|
||||
|
||||
func TestExecutorAutostartTemplateUpdated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+1
@@ -44,6 +44,7 @@ const (
|
||||
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
|
||||
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
|
||||
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
|
||||
CheckUserAiBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
|
||||
CheckUserAiProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
|
||||
CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills
|
||||
CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills
|
||||
|
||||
@@ -1509,6 +1509,16 @@ func GroupAIBudget(b database.GroupAiBudget) codersdk.GroupAIBudget {
|
||||
}
|
||||
}
|
||||
|
||||
func UserAIBudgetOverride(o database.UserAiBudgetOverride) codersdk.UserAIBudgetOverride {
|
||||
return codersdk.UserAIBudgetOverride{
|
||||
UserID: o.UserID,
|
||||
GroupID: o.GroupID,
|
||||
SpendLimitMicros: o.SpendLimitMicros,
|
||||
CreatedAt: o.CreatedAt,
|
||||
UpdatedAt: o.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
|
||||
var presets []codersdk.InvalidatedPreset
|
||||
for _, p := range invalidatedPresets {
|
||||
|
||||
@@ -651,6 +651,8 @@ var (
|
||||
rbac.ResourceAibridgeInterception.Type: {policy.ActionDelete},
|
||||
// Chat auto-archive sets archived=true on inactive chats.
|
||||
rbac.ResourceChat.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
// Purge old boundary logs past the retention period.
|
||||
rbac.ResourceBoundaryLog.Type: {policy.ActionDelete},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
@@ -2191,9 +2193,8 @@ func (q *querier) DeleteOldAuditLogs(ctx context.Context, arg database.DeleteOld
|
||||
return q.db.DeleteOldAuditLogs(ctx, arg)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceSystem with dedicated boundary_log resource type.
|
||||
func (q *querier) DeleteOldBoundaryLogs(ctx context.Context, arg database.DeleteOldBoundaryLogsParams) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceBoundaryLog); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.DeleteOldBoundaryLogs(ctx, arg)
|
||||
@@ -2322,6 +2323,32 @@ func (q *querier) DeleteTask(ctx context.Context, arg database.DeleteTaskParams)
|
||||
return q.db.DeleteTask(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
// Removing a user's AI budget override affects both the user (clearing
|
||||
// their per-user spend cap) and the group it was attributed to.
|
||||
u, err := q.db.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
// Fetch the existing override to learn which group it attributes spend to,
|
||||
// so we can authorize the caller against that group as well.
|
||||
userOverride, err := q.db.GetUserAIBudgetOverride(ctx, userID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
g, err := q.db.GetGroupByID(ctx, userOverride.GroupID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
return q.db.DeleteUserAIBudgetOverride(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error {
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
@@ -2780,17 +2807,15 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI
|
||||
return q.db.GetAuthorizationUserRoles(ctx, userID)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (database.BoundaryLog, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
|
||||
return database.BoundaryLog{}, err
|
||||
}
|
||||
return q.db.GetBoundaryLogByID(ctx, id)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
|
||||
return database.BoundarySession{}, err
|
||||
}
|
||||
return q.db.GetBoundarySessionByID(ctx, id)
|
||||
@@ -4537,6 +4562,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
|
||||
return q.db.GetUnexpiredLicenses(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
if _, err := q.GetUserByID(ctx, userID); err != nil { // AuthZ check
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
return q.db.GetUserAIBudgetOverride(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) {
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
@@ -5468,14 +5500,29 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo
|
||||
return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) {
|
||||
return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundaryLog)(ctx, arg)
|
||||
func (q *querier) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) {
|
||||
session, err := q.db.GetBoundarySessionByID(ctx, arg.SessionID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get boundary session for owner: %w", err)
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(session.OwnerID.UUID.String())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.InsertBoundaryLogs(ctx, arg)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) InsertBoundarySession(ctx context.Context, arg database.InsertBoundarySessionParams) (database.BoundarySession, error) {
|
||||
return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundarySession)(ctx, arg)
|
||||
row, err := q.db.GetWorkspaceAgentAndWorkspaceByID(ctx, arg.WorkspaceAgentID)
|
||||
if err != nil {
|
||||
return database.BoundarySession{}, xerrors.Errorf("get workspace for boundary session owner: %w", err)
|
||||
}
|
||||
arg.OwnerID = uuid.NullUUID{UUID: row.WorkspaceTable.OwnerID, Valid: true}
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(arg.OwnerID.UUID.String())); err != nil {
|
||||
return database.BoundarySession{}, err
|
||||
}
|
||||
return q.db.InsertBoundarySession(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) {
|
||||
@@ -6191,9 +6238,8 @@ func (q *querier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context,
|
||||
return q.db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, interceptionIDs)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.ListBoundaryLogsBySessionID(ctx, arg)
|
||||
@@ -8329,6 +8375,26 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error {
|
||||
return q.db.UpsertTemplateUsageStats(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) {
|
||||
// Setting a user's AI budget override affects both the user (their
|
||||
// per-user spend cap) and the group (spend attribution).
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
g, err := q.db.GetGroupByID(ctx, arg.GroupID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
return q.db.UpsertUserAIBudgetOverride(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) {
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
|
||||
@@ -440,35 +440,55 @@ func (s *MethodTestSuite) TestAuditLogs() {
|
||||
}))
|
||||
}
|
||||
|
||||
// TODO (PR #24810): These RBAC assertions use placeholder resource types.
|
||||
// They will be updated when the dedicated boundary_log resource type is added.
|
||||
func (s *MethodTestSuite) TestBoundaryLogs() {
|
||||
s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.InsertBoundarySessionParams{}
|
||||
dbm.EXPECT().InsertBoundarySession(gomock.Any(), arg).Return(database.BoundarySession{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate)
|
||||
s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
aww := testutil.Fake(s.T(), faker, database.GetWorkspaceAgentAndWorkspaceByIDRow{})
|
||||
arg := database.InsertBoundarySessionParams{
|
||||
WorkspaceAgentID: aww.WorkspaceAgent.ID,
|
||||
}
|
||||
dbm.EXPECT().GetWorkspaceAgentAndWorkspaceByID(gomock.Any(), aww.WorkspaceAgent.ID).Return(aww, nil).AnyTimes()
|
||||
expectedArg := database.InsertBoundarySessionParams{
|
||||
WorkspaceAgentID: aww.WorkspaceAgent.ID,
|
||||
OwnerID: uuid.NullUUID{UUID: aww.WorkspaceTable.OwnerID, Valid: true},
|
||||
}
|
||||
dbm.EXPECT().InsertBoundarySession(gomock.Any(), expectedArg).Return(database.BoundarySession{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(
|
||||
rbac.ResourceBoundaryLog.WithOwner(aww.WorkspaceTable.OwnerID.String()), policy.ActionCreate,
|
||||
)
|
||||
}))
|
||||
s.Run("GetBoundarySessionByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), uuid.Nil).Return(database.BoundarySession{}, nil).AnyTimes()
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead)
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead)
|
||||
}))
|
||||
s.Run("InsertBoundaryLog", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.InsertBoundaryLogParams{}
|
||||
dbm.EXPECT().InsertBoundaryLog(gomock.Any(), arg).Return(database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate)
|
||||
s.Run("InsertBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
ownerID := uuid.New()
|
||||
sessionID := uuid.New()
|
||||
session := database.BoundarySession{
|
||||
ID: sessionID,
|
||||
OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true},
|
||||
}
|
||||
arg := database.InsertBoundaryLogsParams{
|
||||
SessionID: sessionID,
|
||||
ID: []uuid.UUID{uuid.New(), uuid.New()},
|
||||
}
|
||||
dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), sessionID).Return(session, nil).AnyTimes()
|
||||
dbm.EXPECT().InsertBoundaryLogs(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()), policy.ActionCreate,
|
||||
)
|
||||
}))
|
||||
s.Run("GetBoundaryLogByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetBoundaryLogByID(gomock.Any(), uuid.Nil).Return(database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead)
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead)
|
||||
}))
|
||||
s.Run("ListBoundaryLogsBySessionID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.ListBoundaryLogsBySessionIDParams{}
|
||||
dbm.EXPECT().ListBoundaryLogsBySessionID(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionRead)
|
||||
check.Args(arg).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead)
|
||||
}))
|
||||
s.Run("DeleteOldBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().DeleteOldBoundaryLogs(gomock.Any(), database.DeleteOldBoundaryLogsParams{}).Return(int64(0), nil).AnyTimes()
|
||||
check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete)
|
||||
check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceBoundaryLog, policy.ActionDelete)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6455,6 +6475,36 @@ func (s *MethodTestSuite) TestAIBridge() {
|
||||
check.Args(g.ID).Asserts(g, policy.ActionUpdate).Returns(b)
|
||||
}))
|
||||
|
||||
s.Run("GetUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID})
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
|
||||
dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes()
|
||||
check.Args(user.ID).Asserts(user, policy.ActionRead).Returns(override)
|
||||
}))
|
||||
|
||||
s.Run("UpsertUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
group := testutil.Fake(s.T(), faker, database.Group{})
|
||||
override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID})
|
||||
arg := database.UpsertUserAIBudgetOverrideParams{UserID: user.ID, GroupID: group.ID, SpendLimitMicros: override.SpendLimitMicros}
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
|
||||
dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes()
|
||||
dbm.EXPECT().UpsertUserAIBudgetOverride(gomock.Any(), arg).Return(override, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override)
|
||||
}))
|
||||
|
||||
s.Run("DeleteUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
group := testutil.Fake(s.T(), faker, database.Group{})
|
||||
override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID})
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
|
||||
dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes()
|
||||
dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes()
|
||||
dbm.EXPECT().DeleteUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes()
|
||||
check.Args(user.ID).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override)
|
||||
}))
|
||||
|
||||
s.Run("GetAIProviderByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
provider := testutil.Fake(s.T(), faker, database.AIProvider{})
|
||||
dbm.EXPECT().GetAIProviderByID(gomock.Any(), provider.ID).Return(provider, nil).AnyTimes()
|
||||
|
||||
@@ -458,6 +458,7 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess
|
||||
session, err := db.InsertBoundarySession(genCtx, database.InsertBoundarySessionParams{
|
||||
ID: takeFirst(seed.ID, uuid.New()),
|
||||
WorkspaceAgentID: takeFirst(seed.WorkspaceAgentID, uuid.New()),
|
||||
OwnerID: takeFirst(seed.OwnerID, uuid.NullUUID{UUID: uuid.New(), Valid: true}),
|
||||
ConfinedProcessName: takeFirst(seed.ConfinedProcessName, "claude-code"),
|
||||
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
|
||||
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
|
||||
@@ -466,20 +467,52 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess
|
||||
return session
|
||||
}
|
||||
|
||||
func BoundaryLog(t testing.TB, db database.Store, seed database.BoundaryLog) database.BoundaryLog {
|
||||
log, err := db.InsertBoundaryLog(genCtx, database.InsertBoundaryLogParams{
|
||||
ID: takeFirst(seed.ID, uuid.New()),
|
||||
SessionID: seed.SessionID,
|
||||
SequenceNumber: takeFirst(seed.SequenceNumber, 0),
|
||||
CapturedAt: takeFirst(seed.CapturedAt, dbtime.Now()),
|
||||
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
|
||||
Proto: takeFirst(seed.Proto, "http"),
|
||||
Method: takeFirst(seed.Method, "GET"),
|
||||
Detail: takeFirst(seed.Detail, "https://example.com"),
|
||||
MatchedRule: seed.MatchedRule,
|
||||
func BoundaryLogs(t testing.TB, db database.Store, seed []database.BoundaryLog) []database.BoundaryLog {
|
||||
ids := make([]uuid.UUID, 0, len(seed))
|
||||
sessionID := seed[0].SessionID
|
||||
sequenceNumbers := make([]int32, 0, len(seed))
|
||||
capturedAt := make([]time.Time, 0, len(seed))
|
||||
createdAt := make([]time.Time, 0, len(seed))
|
||||
protos := make([]string, 0, len(seed))
|
||||
method := make([]string, 0, len(seed))
|
||||
detail := make([]string, 0, len(seed))
|
||||
matchedRule := make([]string, 0, len(seed))
|
||||
for _, log := range seed {
|
||||
log = takeFirstBoundaryLog(log)
|
||||
ids = append(ids, log.ID)
|
||||
sequenceNumbers = append(sequenceNumbers, log.SequenceNumber)
|
||||
capturedAt = append(capturedAt, log.CapturedAt)
|
||||
createdAt = append(createdAt, log.CreatedAt)
|
||||
protos = append(protos, log.Proto)
|
||||
method = append(method, log.Method)
|
||||
detail = append(detail, log.Detail)
|
||||
matchedRule = append(matchedRule, log.MatchedRule.String)
|
||||
}
|
||||
logs, err := db.InsertBoundaryLogs(genCtx, database.InsertBoundaryLogsParams{
|
||||
ID: ids,
|
||||
SessionID: sessionID,
|
||||
SequenceNumber: sequenceNumbers,
|
||||
CapturedAt: capturedAt,
|
||||
CreatedAt: createdAt,
|
||||
Proto: protos,
|
||||
Method: method,
|
||||
Detail: detail,
|
||||
MatchedRule: matchedRule,
|
||||
})
|
||||
require.NoError(t, err, "insert boundary log")
|
||||
return log
|
||||
require.NoError(t, err, "insert boundary logs")
|
||||
return logs
|
||||
}
|
||||
|
||||
func takeFirstBoundaryLog(seed database.BoundaryLog) database.BoundaryLog {
|
||||
seed.ID = takeFirst(seed.ID, uuid.New())
|
||||
seed.SessionID = takeFirst(seed.SessionID, uuid.New())
|
||||
seed.SequenceNumber = takeFirst(seed.SequenceNumber, 0)
|
||||
seed.CapturedAt = takeFirst(seed.CapturedAt, dbtime.Now())
|
||||
seed.CreatedAt = takeFirst(seed.CreatedAt, dbtime.Now())
|
||||
seed.Proto = takeFirst(seed.Proto, "http")
|
||||
seed.Method = takeFirst(seed.Method, "GET")
|
||||
seed.Detail = takeFirst(seed.Detail, "https://example.com")
|
||||
return seed
|
||||
}
|
||||
|
||||
func Template(t testing.TB, db database.Store, seed database.Template) database.Template {
|
||||
@@ -1969,8 +2002,9 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
|
||||
})
|
||||
if endedAt != nil {
|
||||
interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: interception.ID,
|
||||
EndedAt: *endedAt,
|
||||
ID: interception.ID,
|
||||
EndedAt: *endedAt,
|
||||
CredentialHint: takeFirst(seed.CredentialHint, ""),
|
||||
})
|
||||
require.NoError(t, err, "insert aibridge interception")
|
||||
}
|
||||
|
||||
+28
-4
@@ -793,6 +793,14 @@ func (m queryMetricsStore) DeleteTask(ctx context.Context, arg database.DeleteTa
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.DeleteUserAIBudgetOverride(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("DeleteUserAIBudgetOverride").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserAIBudgetOverride").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteUserAIProviderKey(ctx, arg)
|
||||
@@ -2905,6 +2913,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserAIBudgetOverride(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("GetUserAIBudgetOverride").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAIBudgetOverride").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserAIProviderKeyByProviderID(ctx, arg)
|
||||
@@ -3745,11 +3761,11 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) {
|
||||
func (m queryMetricsStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertBoundaryLog(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertBoundaryLog").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLog").Inc()
|
||||
r0, r1 := m.s.InsertBoundaryLogs(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertBoundaryLogs").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLogs").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
@@ -6049,6 +6065,14 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpsertUserAIBudgetOverride(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpsertUserAIBudgetOverride").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserAIBudgetOverride").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpsertUserAIProviderKey(ctx, arg)
|
||||
|
||||
Generated
+52
-7
@@ -1349,6 +1349,21 @@ func (mr *MockStoreMockRecorder) DeleteTask(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockStore)(nil).DeleteTask), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteUserAIBudgetOverride mocks base method.
|
||||
func (m *MockStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteUserAIBudgetOverride", ctx, userID)
|
||||
ret0, _ := ret[0].(database.UserAiBudgetOverride)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteUserAIBudgetOverride indicates an expected call of DeleteUserAIBudgetOverride.
|
||||
func (mr *MockStoreMockRecorder) DeleteUserAIBudgetOverride(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).DeleteUserAIBudgetOverride), ctx, userID)
|
||||
}
|
||||
|
||||
// DeleteUserAIProviderKey mocks base method.
|
||||
func (m *MockStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -5445,6 +5460,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx)
|
||||
}
|
||||
|
||||
// GetUserAIBudgetOverride mocks base method.
|
||||
func (m *MockStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserAIBudgetOverride", ctx, userID)
|
||||
ret0, _ := ret[0].(database.UserAiBudgetOverride)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserAIBudgetOverride indicates an expected call of GetUserAIBudgetOverride.
|
||||
func (mr *MockStoreMockRecorder) GetUserAIBudgetOverride(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).GetUserAIBudgetOverride), ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserAIProviderKeyByProviderID mocks base method.
|
||||
func (m *MockStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -7034,19 +7064,19 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertBoundaryLog mocks base method.
|
||||
func (m *MockStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) {
|
||||
// InsertBoundaryLogs mocks base method.
|
||||
func (m *MockStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertBoundaryLog", ctx, arg)
|
||||
ret0, _ := ret[0].(database.BoundaryLog)
|
||||
ret := m.ctrl.Call(m, "InsertBoundaryLogs", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.BoundaryLog)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertBoundaryLog indicates an expected call of InsertBoundaryLog.
|
||||
func (mr *MockStoreMockRecorder) InsertBoundaryLog(ctx, arg any) *gomock.Call {
|
||||
// InsertBoundaryLogs indicates an expected call of InsertBoundaryLogs.
|
||||
func (mr *MockStoreMockRecorder) InsertBoundaryLogs(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLog", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLog), ctx, arg)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLogs", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLogs), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertBoundarySession mocks base method.
|
||||
@@ -11344,6 +11374,21 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx)
|
||||
}
|
||||
|
||||
// UpsertUserAIBudgetOverride mocks base method.
|
||||
func (m *MockStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertUserAIBudgetOverride", ctx, arg)
|
||||
ret0, _ := ret[0].(database.UserAiBudgetOverride)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpsertUserAIBudgetOverride indicates an expected call of UpsertUserAIBudgetOverride.
|
||||
func (mr *MockStoreMockRecorder) UpsertUserAIBudgetOverride(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).UpsertUserAIBudgetOverride), ctx, arg)
|
||||
}
|
||||
|
||||
// UpsertUserAIProviderKey mocks base method.
|
||||
func (m *MockStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+74
-2
@@ -249,7 +249,11 @@ CREATE TYPE api_key_scope AS ENUM (
|
||||
'user_skill:read',
|
||||
'user_skill:update',
|
||||
'user_skill:delete',
|
||||
'user_skill:*'
|
||||
'user_skill:*',
|
||||
'boundary_log:*',
|
||||
'boundary_log:create',
|
||||
'boundary_log:delete',
|
||||
'boundary_log:read'
|
||||
);
|
||||
|
||||
CREATE TYPE app_sharing_level AS ENUM (
|
||||
@@ -837,6 +841,42 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.group_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.organization_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM group_members_expanded
|
||||
WHERE user_id = NEW.user_id AND group_id = NEW.group_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -1485,7 +1525,8 @@ CREATE TABLE boundary_sessions (
|
||||
workspace_agent_id uuid NOT NULL,
|
||||
confined_process_name text NOT NULL,
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
owner_id uuid
|
||||
);
|
||||
|
||||
COMMENT ON TABLE boundary_sessions IS 'Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent.';
|
||||
@@ -1500,6 +1541,8 @@ COMMENT ON COLUMN boundary_sessions.started_at IS 'Time when the first log for t
|
||||
|
||||
COMMENT ON COLUMN boundary_sessions.updated_at IS 'Time when the session was last updated.';
|
||||
|
||||
COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.';
|
||||
|
||||
CREATE TABLE boundary_usage_stats (
|
||||
replica_id uuid NOT NULL,
|
||||
unique_workspaces_count bigint DEFAULT 0 NOT NULL,
|
||||
@@ -3130,6 +3173,17 @@ COMMENT ON TABLE usage_events_daily IS 'usage_events_daily is a daily rollup of
|
||||
|
||||
COMMENT ON COLUMN usage_events_daily.day IS 'The date of the summed usage events, always in UTC.';
|
||||
|
||||
CREATE TABLE user_ai_budget_overrides (
|
||||
user_id uuid NOT NULL,
|
||||
group_id uuid NOT NULL,
|
||||
spend_limit_micros bigint NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT user_ai_budget_overrides_spend_limit_micros_check CHECK ((spend_limit_micros >= 0))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.';
|
||||
|
||||
CREATE TABLE user_ai_provider_keys (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
@@ -3979,6 +4033,9 @@ ALTER TABLE ONLY usage_events_daily
|
||||
ALTER TABLE ONLY usage_events
|
||||
ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY user_ai_budget_overrides
|
||||
ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id);
|
||||
|
||||
ALTER TABLE ONLY user_ai_provider_keys
|
||||
ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -4460,6 +4517,12 @@ CREATE TRIGGER trigger_delete_group_members_on_org_member_delete BEFORE DELETE O
|
||||
|
||||
CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_provider_app_tokens FOR EACH ROW EXECUTE FUNCTION delete_deleted_oauth2_provider_app_token_api_key();
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete BEFORE DELETE ON group_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete();
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete BEFORE DELETE ON organization_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete();
|
||||
|
||||
CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership BEFORE INSERT OR UPDATE ON user_ai_budget_overrides FOR EACH ROW EXECUTE FUNCTION enforce_user_ai_budget_override_membership();
|
||||
|
||||
CREATE TRIGGER trigger_insert_apikeys BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION insert_apikey_fail_if_user_deleted();
|
||||
|
||||
CREATE TRIGGER trigger_insert_organization_system_roles AFTER INSERT ON organizations FOR EACH ROW EXECUTE FUNCTION insert_organization_system_roles();
|
||||
@@ -4509,6 +4572,9 @@ ALTER TABLE ONLY api_keys
|
||||
ALTER TABLE ONLY boundary_logs
|
||||
ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY boundary_sessions
|
||||
ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY boundary_sessions
|
||||
ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id);
|
||||
|
||||
@@ -4782,6 +4848,12 @@ ALTER TABLE ONLY templates
|
||||
ALTER TABLE ONLY templates
|
||||
ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY user_ai_budget_overrides
|
||||
ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY user_ai_budget_overrides
|
||||
ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY user_ai_provider_keys
|
||||
ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
+3
@@ -13,6 +13,7 @@ const (
|
||||
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
|
||||
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyBoundaryLogsSessionID ForeignKeyConstraint = "boundary_logs_session_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE;
|
||||
ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id);
|
||||
ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
@@ -104,6 +105,8 @@ const (
|
||||
ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE;
|
||||
ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiBudgetOverridesGroupID ForeignKeyConstraint = "user_ai_budget_overrides_group_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiBudgetOverridesUserID ForeignKeyConstraint = "user_ai_budget_overrides_user_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiProviderKeysAiProviderID ForeignKeyConstraint = "user_ai_provider_keys_ai_provider_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiProviderKeysAPIKeyKeyID ForeignKeyConstraint = "user_ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
|
||||
ForeignKeyUserAiProviderKeysUserID ForeignKeyConstraint = "user_ai_provider_keys_user_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op for boundary_log scopes: keep enum values to avoid dependency churn.
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add boundary_log scopes for RBAC.
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:*';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:create';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:delete';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:read';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE boundary_sessions DROP CONSTRAINT IF EXISTS boundary_sessions_owner_id_fkey;
|
||||
ALTER TABLE boundary_sessions DROP COLUMN IF EXISTS owner_id;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Add owner_id to boundary_sessions to avoid expensive JOINs when
|
||||
-- deriving the workspace owner for RBAC checks during log insertion.
|
||||
ALTER TABLE boundary_sessions ADD COLUMN owner_id uuid;
|
||||
|
||||
COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.';
|
||||
|
||||
-- Backfill owner_id from the workspace agent -> workspace -> owner chain.
|
||||
-- Soft-deleted agents and workspaces are included so that their audit
|
||||
-- data is preserved.
|
||||
UPDATE boundary_sessions bs
|
||||
SET owner_id = w.owner_id
|
||||
FROM workspace_agents wa
|
||||
JOIN workspace_resources wr ON wa.resource_id = wr.id
|
||||
JOIN provisioner_jobs pj ON wr.job_id = pj.id
|
||||
JOIN workspace_builds wb ON pj.id = wb.job_id
|
||||
JOIN workspaces w ON wb.workspace_id = w.id
|
||||
WHERE wa.id = bs.workspace_agent_id
|
||||
AND pj.type = 'workspace_build';
|
||||
|
||||
-- Delete any sessions that could not be backfilled (orphaned data
|
||||
-- with no resolvable workspace agent or workspace build chain).
|
||||
DELETE FROM boundary_sessions WHERE owner_id IS NULL;
|
||||
|
||||
-- Add FK constraint. SET NULL preserves audit data when a user is
|
||||
-- hard-deleted; the session and its logs survive with a NULL owner.
|
||||
ALTER TABLE boundary_sessions
|
||||
ADD CONSTRAINT boundary_sessions_owner_id_fkey
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_org_member_delete ON organization_members;
|
||||
DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_org_member_delete;
|
||||
DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_group_member_delete ON group_members;
|
||||
DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_group_member_delete;
|
||||
DROP TRIGGER IF EXISTS trigger_enforce_user_ai_budget_override_membership ON user_ai_budget_overrides;
|
||||
DROP FUNCTION IF EXISTS enforce_user_ai_budget_override_membership;
|
||||
DROP TABLE IF EXISTS user_ai_budget_overrides CASCADE;
|
||||
@@ -0,0 +1,76 @@
|
||||
CREATE TABLE user_ai_budget_overrides (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Spend limit applied to the user, in micro-units (1 unit = 1,000,000).
|
||||
spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
-- The membership invariant (user must be a member of the attributed
|
||||
-- group, including when that group is "Everyone") would naturally be
|
||||
-- a composite FK to group_members_expanded, but PostgreSQL does not
|
||||
-- allow FKs to views. It's enforced instead by a write-time trigger
|
||||
-- on this table and removal-time triggers on the underlying
|
||||
-- membership tables.
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.';
|
||||
|
||||
-- Write-time membership check. Reads from group_members_expanded so
|
||||
-- the "Everyone" group (whose membership lives in organization_members)
|
||||
-- is correctly handled. Raises check_violation with a constraint name
|
||||
-- so callers can match it via database.IsCheckViolation in Go.
|
||||
CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM group_members_expanded
|
||||
WHERE user_id = NEW.user_id AND group_id = NEW.group_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership
|
||||
BEFORE INSERT OR UPDATE ON user_ai_budget_overrides
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE enforce_user_ai_budget_override_membership();
|
||||
|
||||
-- When a user is removed from a regular group (any group except
|
||||
-- "Everyone"), delete any override attributed to that group.
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.group_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete
|
||||
BEFORE DELETE ON group_members
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_group_member_delete();
|
||||
|
||||
-- When a user is removed from an organization, delete any override
|
||||
-- attributed to that organization's "Everyone" group (which has
|
||||
-- id == organization_id).
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.organization_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete
|
||||
BEFORE DELETE ON organization_members
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_org_member_delete();
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
-- Re-insert boundary session and log fixture data after migration 000511
|
||||
-- deletes orphaned rows (the original fixture's workspace_agent links to a
|
||||
-- template_version_import job, not a workspace_build, so the backfill
|
||||
-- cannot resolve the owner).
|
||||
|
||||
INSERT INTO boundary_sessions (
|
||||
id,
|
||||
workspace_agent_id,
|
||||
confined_process_name,
|
||||
started_at,
|
||||
updated_at,
|
||||
owner_id
|
||||
) VALUES (
|
||||
'a1b2c3d4-e5f6-4890-abcd-ef1234567890',
|
||||
'45e89705-e09d-4850-bcec-f9a937f5d78d',
|
||||
'claude-code',
|
||||
'2026-04-01 10:00:00+00',
|
||||
'2026-04-01 10:00:00+00',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307'
|
||||
);
|
||||
|
||||
INSERT INTO boundary_logs (
|
||||
id,
|
||||
session_id,
|
||||
sequence_number,
|
||||
captured_at,
|
||||
created_at,
|
||||
proto,
|
||||
method,
|
||||
detail,
|
||||
matched_rule
|
||||
) VALUES (
|
||||
'b2c3d4e5-f6a7-4901-bcde-f12345678901',
|
||||
'a1b2c3d4-e5f6-4890-abcd-ef1234567890',
|
||||
0,
|
||||
'2026-04-01 10:00:01+00',
|
||||
'2026-04-01 10:00:00+00',
|
||||
'http',
|
||||
'GET',
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
'domain=api.anthropic.com'
|
||||
);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
-- Seed a group_members row so the override below references a real
|
||||
-- membership.
|
||||
INSERT INTO group_members (
|
||||
user_id,
|
||||
group_id
|
||||
) VALUES
|
||||
('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO user_ai_budget_overrides (
|
||||
user_id,
|
||||
group_id,
|
||||
spend_limit_micros
|
||||
) VALUES
|
||||
('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 500000000);
|
||||
@@ -1003,3 +1003,10 @@ type UpsertConnectionLogParams struct {
|
||||
func (r GetLatestWorkspaceBuildWithStatusByWorkspaceIDRow) RBACObject() rbac.Object {
|
||||
return r.WorkspaceTable.RBACObject()
|
||||
}
|
||||
|
||||
func (s BoundarySession) RBACObject() rbac.Object {
|
||||
if s.OwnerID.Valid {
|
||||
return rbac.ResourceBoundaryLog.WithOwner(s.OwnerID.UUID.String())
|
||||
}
|
||||
return rbac.ResourceBoundaryLog
|
||||
}
|
||||
|
||||
Generated
+24
-1
@@ -320,6 +320,10 @@ const (
|
||||
ApiKeyScopeUserSkillUpdate APIKeyScope = "user_skill:update"
|
||||
ApiKeyScopeUserSkillDelete APIKeyScope = "user_skill:delete"
|
||||
ApiKeyScopeUserSkill APIKeyScope = "user_skill:*"
|
||||
ApiKeyScopeBoundaryLog APIKeyScope = "boundary_log:*"
|
||||
ApiKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create"
|
||||
ApiKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete"
|
||||
ApiKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read"
|
||||
)
|
||||
|
||||
func (e *APIKeyScope) Scan(src interface{}) error {
|
||||
@@ -580,7 +584,11 @@ func (e APIKeyScope) Valid() bool {
|
||||
ApiKeyScopeUserSkillRead,
|
||||
ApiKeyScopeUserSkillUpdate,
|
||||
ApiKeyScopeUserSkillDelete,
|
||||
ApiKeyScopeUserSkill:
|
||||
ApiKeyScopeUserSkill,
|
||||
ApiKeyScopeBoundaryLog,
|
||||
ApiKeyScopeBoundaryLogCreate,
|
||||
ApiKeyScopeBoundaryLogDelete,
|
||||
ApiKeyScopeBoundaryLogRead:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -810,6 +818,10 @@ func AllAPIKeyScopeValues() []APIKeyScope {
|
||||
ApiKeyScopeUserSkillUpdate,
|
||||
ApiKeyScopeUserSkillDelete,
|
||||
ApiKeyScopeUserSkill,
|
||||
ApiKeyScopeBoundaryLog,
|
||||
ApiKeyScopeBoundaryLogCreate,
|
||||
ApiKeyScopeBoundaryLogDelete,
|
||||
ApiKeyScopeBoundaryLogRead,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4543,6 +4555,8 @@ type BoundarySession struct {
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
// Time when the session was last updated.
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// The ID of the user who owns the workspace. NULL if the user has been deleted.
|
||||
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
|
||||
}
|
||||
|
||||
// Per-replica boundary usage statistics for telemetry aggregation.
|
||||
@@ -5716,6 +5730,15 @@ type User struct {
|
||||
ChatSpendLimitMicros sql.NullInt64 `db:"chat_spend_limit_micros" json:"chat_spend_limit_micros"`
|
||||
}
|
||||
|
||||
// Per-user AI spend override that supersedes group budget resolution.
|
||||
type UserAiBudgetOverride struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
GroupID uuid.UUID `db:"group_id" json:"group_id"`
|
||||
SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// User-owned API keys associated with AI providers. These keys are used only when BYOK is enabled.
|
||||
type UserAiProviderKey struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
|
||||
Generated
+4
-1
@@ -198,6 +198,7 @@ type sqlcQuerier interface {
|
||||
DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error)
|
||||
DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error)
|
||||
DeleteTask(ctx context.Context, arg DeleteTaskParams) (uuid.UUID, error)
|
||||
DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error)
|
||||
DeleteUserAIProviderKey(ctx context.Context, arg DeleteUserAIProviderKeyParams) error
|
||||
DeleteUserAIProviderKeysByProviderID(ctx context.Context, aiProviderID uuid.UUID) error
|
||||
DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error
|
||||
@@ -738,6 +739,7 @@ type sqlcQuerier interface {
|
||||
// inclusive.
|
||||
GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error)
|
||||
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
|
||||
GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error)
|
||||
GetUserAIProviderKeyByProviderID(ctx context.Context, arg GetUserAIProviderKeyByProviderIDParams) (UserAiProviderKey, error)
|
||||
// GetUserAIProviderKeys is used by dbcrypt key rotation. Request paths should use
|
||||
// user-scoped lookups instead of this bulk accessor.
|
||||
@@ -920,7 +922,7 @@ type sqlcQuerier interface {
|
||||
// every member of the org.
|
||||
InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error)
|
||||
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)
|
||||
InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error)
|
||||
InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error)
|
||||
InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error)
|
||||
InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error)
|
||||
// updated_at is the retention clock used by DeleteOldChatDebugRuns.
|
||||
@@ -1407,6 +1409,7 @@ type sqlcQuerier interface {
|
||||
// used to store the data, and the minutes are summed for each user and template
|
||||
// combination. The result is stored in the template_usage_stats table.
|
||||
UpsertTemplateUsageStats(ctx context.Context) error
|
||||
UpsertUserAIBudgetOverride(ctx context.Context, arg UpsertUserAIBudgetOverrideParams) (UserAiBudgetOverride, error)
|
||||
// UpsertUserAIProviderKey preserves the original id and created_at when the
|
||||
// user/provider pair already exists. On conflict, callers provide id and
|
||||
// created_at for the insert path only.
|
||||
|
||||
@@ -9921,8 +9921,9 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
got, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: uuid.New(),
|
||||
EndedAt: time.Now(),
|
||||
ID: uuid.New(),
|
||||
EndedAt: time.Now(),
|
||||
CredentialHint: "sk-a...efgh",
|
||||
})
|
||||
require.ErrorContains(t, err, "no rows in result set")
|
||||
require.EqualValues(t, database.AIBridgeInterception{}, got)
|
||||
@@ -9957,18 +9958,21 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) {
|
||||
endedAt := time.Now()
|
||||
// Mark first interception as done
|
||||
updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: intc0.ID,
|
||||
EndedAt: endedAt,
|
||||
ID: intc0.ID,
|
||||
EndedAt: endedAt,
|
||||
CredentialHint: "sk-a...efgh",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, updated.ID, intc0.ID)
|
||||
require.True(t, updated.EndedAt.Valid)
|
||||
require.WithinDuration(t, endedAt, updated.EndedAt.Time, 5*time.Second)
|
||||
require.Equal(t, "sk-a...efgh", updated.CredentialHint)
|
||||
|
||||
// Updating first interception again should fail
|
||||
updated, err = db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: intc0.ID,
|
||||
EndedAt: endedAt.Add(time.Hour),
|
||||
ID: intc0.ID,
|
||||
EndedAt: endedAt.Add(time.Hour),
|
||||
CredentialHint: "sk-a...efgh",
|
||||
})
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
@@ -9979,6 +9983,52 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) {
|
||||
require.False(t, got.EndedAt.Valid)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CentralizedHintUpdated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.New(),
|
||||
InitiatorID: user.ID,
|
||||
Metadata: json.RawMessage("{}"),
|
||||
CredentialKind: database.CredentialKindCentralized,
|
||||
CredentialHint: "",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: intc.ID,
|
||||
EndedAt: time.Now(),
|
||||
CredentialHint: "sk-a...efgh",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sk-a...efgh", updated.CredentialHint)
|
||||
})
|
||||
|
||||
t.Run("BYOKHintPreserved", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.New(),
|
||||
InitiatorID: user.ID,
|
||||
Metadata: json.RawMessage("{}"),
|
||||
CredentialKind: database.CredentialKindByok,
|
||||
CredentialHint: "sk-u...byok",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: intc.ID,
|
||||
EndedAt: time.Now(),
|
||||
CredentialHint: "sk-a...efgh",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sk-u...byok", updated.CredentialHint)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteExpiredAPIKeys(t *testing.T) {
|
||||
|
||||
Generated
+154
-58
@@ -2389,20 +2389,28 @@ func (q *sqlQuerier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Contex
|
||||
|
||||
const updateAIBridgeInterceptionEnded = `-- name: UpdateAIBridgeInterceptionEnded :one
|
||||
UPDATE aibridge_interceptions
|
||||
SET ended_at = $1::timestamptz
|
||||
SET ended_at = $1::timestamptz,
|
||||
-- BYOK records its hint at the start of the interception.
|
||||
-- Centralized uses key failover, so its hint is only known
|
||||
-- at end-of-interception.
|
||||
credential_hint = CASE
|
||||
WHEN credential_kind = 'centralized' THEN $2::text
|
||||
ELSE credential_hint
|
||||
END
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
id = $3::uuid
|
||||
AND ended_at IS NULL
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name, credential_kind, credential_hint
|
||||
`
|
||||
|
||||
type UpdateAIBridgeInterceptionEndedParams struct {
|
||||
EndedAt time.Time `db:"ended_at" json:"ended_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
EndedAt time.Time `db:"ended_at" json:"ended_at"`
|
||||
CredentialHint string `db:"credential_hint" json:"credential_hint"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.ID)
|
||||
row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.CredentialHint, arg.ID)
|
||||
var i AIBridgeInterception
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -2441,6 +2449,23 @@ func (q *sqlQuerier) DeleteGroupAIBudget(ctx context.Context, groupID uuid.UUID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteUserAIBudgetOverride = `-- name: DeleteUserAIBudgetOverride :one
|
||||
DELETE FROM user_ai_budget_overrides WHERE user_id = $1 RETURNING user_id, group_id, spend_limit_micros, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) {
|
||||
row := q.db.QueryRowContext(ctx, deleteUserAIBudgetOverride, userID)
|
||||
var i UserAiBudgetOverride
|
||||
err := row.Scan(
|
||||
&i.UserID,
|
||||
&i.GroupID,
|
||||
&i.SpendLimitMicros,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAIModelPriceByProviderModel = `-- name: GetAIModelPriceByProviderModel :one
|
||||
SELECT provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at
|
||||
FROM ai_model_prices
|
||||
@@ -2486,6 +2511,25 @@ func (q *sqlQuerier) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (G
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserAIBudgetOverride = `-- name: GetUserAIBudgetOverride :one
|
||||
SELECT user_id, group_id, spend_limit_micros, created_at, updated_at
|
||||
FROM user_ai_budget_overrides
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserAIBudgetOverride, userID)
|
||||
var i UserAiBudgetOverride
|
||||
err := row.Scan(
|
||||
&i.UserID,
|
||||
&i.GroupID,
|
||||
&i.SpendLimitMicros,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertAIModelPrices = `-- name: UpsertAIModelPrices :exec
|
||||
INSERT INTO ai_model_prices (
|
||||
provider, model, input_price, output_price, cache_read_price, cache_write_price
|
||||
@@ -2540,6 +2584,35 @@ func (q *sqlQuerier) UpsertGroupAIBudget(ctx context.Context, arg UpsertGroupAIB
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertUserAIBudgetOverride = `-- name: UpsertUserAIBudgetOverride :one
|
||||
INSERT INTO user_ai_budget_overrides (user_id, group_id, spend_limit_micros)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
group_id = EXCLUDED.group_id,
|
||||
spend_limit_micros = EXCLUDED.spend_limit_micros,
|
||||
updated_at = NOW()
|
||||
RETURNING user_id, group_id, spend_limit_micros, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertUserAIBudgetOverrideParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
GroupID uuid.UUID `db:"group_id" json:"group_id"`
|
||||
SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpsertUserAIBudgetOverride(ctx context.Context, arg UpsertUserAIBudgetOverrideParams) (UserAiBudgetOverride, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertUserAIBudgetOverride, arg.UserID, arg.GroupID, arg.SpendLimitMicros)
|
||||
var i UserAiBudgetOverride
|
||||
err := row.Scan(
|
||||
&i.UserID,
|
||||
&i.GroupID,
|
||||
&i.SpendLimitMicros,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one
|
||||
SELECT
|
||||
COUNT(*)
|
||||
@@ -3627,7 +3700,7 @@ func (q *sqlQuerier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (Boun
|
||||
}
|
||||
|
||||
const getBoundarySessionByID = `-- name: GetBoundarySessionByID :one
|
||||
SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at FROM boundary_sessions WHERE id = $1
|
||||
SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at, owner_id FROM boundary_sessions WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (BoundarySession, error) {
|
||||
@@ -3639,11 +3712,12 @@ func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (
|
||||
&i.ConfinedProcessName,
|
||||
&i.StartedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OwnerID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertBoundaryLog = `-- name: InsertBoundaryLog :one
|
||||
const insertBoundaryLogs = `-- name: InsertBoundaryLogs :many
|
||||
INSERT INTO boundary_logs (
|
||||
id,
|
||||
session_id,
|
||||
@@ -3654,62 +3728,80 @@ INSERT INTO boundary_logs (
|
||||
method,
|
||||
detail,
|
||||
matched_rule
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
) RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule
|
||||
)
|
||||
SELECT
|
||||
unnest($1 :: uuid[]),
|
||||
$2 :: uuid,
|
||||
unnest($3 :: int[]),
|
||||
unnest($4 :: timestamptz[]),
|
||||
unnest($5 :: timestamptz[]),
|
||||
unnest($6 :: text[]),
|
||||
unnest($7 :: text[]),
|
||||
unnest($8 :: text[]),
|
||||
unnest($9 :: text[])
|
||||
RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule
|
||||
`
|
||||
|
||||
type InsertBoundaryLogParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
SessionID uuid.UUID `db:"session_id" json:"session_id"`
|
||||
SequenceNumber int32 `db:"sequence_number" json:"sequence_number"`
|
||||
CapturedAt time.Time `db:"captured_at" json:"captured_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
Proto string `db:"proto" json:"proto"`
|
||||
Method string `db:"method" json:"method"`
|
||||
Detail string `db:"detail" json:"detail"`
|
||||
MatchedRule sql.NullString `db:"matched_rule" json:"matched_rule"`
|
||||
type InsertBoundaryLogsParams struct {
|
||||
ID []uuid.UUID `db:"id" json:"id"`
|
||||
SessionID uuid.UUID `db:"session_id" json:"session_id"`
|
||||
SequenceNumber []int32 `db:"sequence_number" json:"sequence_number"`
|
||||
CapturedAt []time.Time `db:"captured_at" json:"captured_at"`
|
||||
CreatedAt []time.Time `db:"created_at" json:"created_at"`
|
||||
Proto []string `db:"proto" json:"proto"`
|
||||
Method []string `db:"method" json:"method"`
|
||||
Detail []string `db:"detail" json:"detail"`
|
||||
MatchedRule []string `db:"matched_rule" json:"matched_rule"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertBoundaryLog,
|
||||
arg.ID,
|
||||
func (q *sqlQuerier) InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error) {
|
||||
rows, err := q.db.QueryContext(ctx, insertBoundaryLogs,
|
||||
pq.Array(arg.ID),
|
||||
arg.SessionID,
|
||||
arg.SequenceNumber,
|
||||
arg.CapturedAt,
|
||||
arg.CreatedAt,
|
||||
arg.Proto,
|
||||
arg.Method,
|
||||
arg.Detail,
|
||||
arg.MatchedRule,
|
||||
pq.Array(arg.SequenceNumber),
|
||||
pq.Array(arg.CapturedAt),
|
||||
pq.Array(arg.CreatedAt),
|
||||
pq.Array(arg.Proto),
|
||||
pq.Array(arg.Method),
|
||||
pq.Array(arg.Detail),
|
||||
pq.Array(arg.MatchedRule),
|
||||
)
|
||||
var i BoundaryLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.SequenceNumber,
|
||||
&i.CapturedAt,
|
||||
&i.CreatedAt,
|
||||
&i.Proto,
|
||||
&i.Method,
|
||||
&i.Detail,
|
||||
&i.MatchedRule,
|
||||
)
|
||||
return i, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []BoundaryLog
|
||||
for rows.Next() {
|
||||
var i BoundaryLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.SequenceNumber,
|
||||
&i.CapturedAt,
|
||||
&i.CreatedAt,
|
||||
&i.Proto,
|
||||
&i.Method,
|
||||
&i.Detail,
|
||||
&i.MatchedRule,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertBoundarySession = `-- name: InsertBoundarySession :one
|
||||
INSERT INTO boundary_sessions (
|
||||
id,
|
||||
workspace_agent_id,
|
||||
owner_id,
|
||||
confined_process_name,
|
||||
started_at,
|
||||
updated_at
|
||||
@@ -3718,22 +3810,25 @@ INSERT INTO boundary_sessions (
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5
|
||||
) RETURNING id, workspace_agent_id, confined_process_name, started_at, updated_at
|
||||
$5,
|
||||
$6
|
||||
) RETURNING id, workspace_agent_id, confined_process_name, started_at, updated_at, owner_id
|
||||
`
|
||||
|
||||
type InsertBoundarySessionParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
|
||||
ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
|
||||
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
|
||||
ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertBoundarySession,
|
||||
arg.ID,
|
||||
arg.WorkspaceAgentID,
|
||||
arg.OwnerID,
|
||||
arg.ConfinedProcessName,
|
||||
arg.StartedAt,
|
||||
arg.UpdatedAt,
|
||||
@@ -3745,6 +3840,7 @@ func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBounda
|
||||
&i.ConfinedProcessName,
|
||||
&i.StartedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OwnerID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,14 @@ RETURNING *;
|
||||
|
||||
-- name: UpdateAIBridgeInterceptionEnded :one
|
||||
UPDATE aibridge_interceptions
|
||||
SET ended_at = @ended_at::timestamptz
|
||||
SET ended_at = @ended_at::timestamptz,
|
||||
-- BYOK records its hint at the start of the interception.
|
||||
-- Centralized uses key failover, so its hint is only known
|
||||
-- at end-of-interception.
|
||||
credential_hint = CASE
|
||||
WHEN credential_kind = 'centralized' THEN @credential_hint::text
|
||||
ELSE credential_hint
|
||||
END
|
||||
WHERE
|
||||
id = @id::uuid
|
||||
AND ended_at IS NULL
|
||||
|
||||
@@ -40,3 +40,20 @@ RETURNING *;
|
||||
|
||||
-- name: DeleteGroupAIBudget :one
|
||||
DELETE FROM group_ai_budgets WHERE group_id = @group_id RETURNING *;
|
||||
|
||||
-- name: GetUserAIBudgetOverride :one
|
||||
SELECT *
|
||||
FROM user_ai_budget_overrides
|
||||
WHERE user_id = @user_id;
|
||||
|
||||
-- name: UpsertUserAIBudgetOverride :one
|
||||
INSERT INTO user_ai_budget_overrides (user_id, group_id, spend_limit_micros)
|
||||
VALUES (@user_id, @group_id, @spend_limit_micros)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
group_id = EXCLUDED.group_id,
|
||||
spend_limit_micros = EXCLUDED.spend_limit_micros,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteUserAIBudgetOverride :one
|
||||
DELETE FROM user_ai_budget_overrides WHERE user_id = @user_id RETURNING *;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
INSERT INTO boundary_sessions (
|
||||
id,
|
||||
workspace_agent_id,
|
||||
owner_id,
|
||||
confined_process_name,
|
||||
started_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@workspace_agent_id,
|
||||
@owner_id,
|
||||
@confined_process_name,
|
||||
@started_at,
|
||||
@updated_at
|
||||
@@ -16,7 +18,7 @@ INSERT INTO boundary_sessions (
|
||||
-- name: GetBoundarySessionByID :one
|
||||
SELECT * FROM boundary_sessions WHERE id = @id;
|
||||
|
||||
-- name: InsertBoundaryLog :one
|
||||
-- name: InsertBoundaryLogs :many
|
||||
INSERT INTO boundary_logs (
|
||||
id,
|
||||
session_id,
|
||||
@@ -27,17 +29,18 @@ INSERT INTO boundary_logs (
|
||||
method,
|
||||
detail,
|
||||
matched_rule
|
||||
) VALUES (
|
||||
@id,
|
||||
@session_id,
|
||||
@sequence_number,
|
||||
@captured_at,
|
||||
@created_at,
|
||||
@proto,
|
||||
@method,
|
||||
@detail,
|
||||
@matched_rule
|
||||
) RETURNING *;
|
||||
)
|
||||
SELECT
|
||||
unnest(@id :: uuid[]),
|
||||
@session_id :: uuid,
|
||||
unnest(@sequence_number :: int[]),
|
||||
unnest(@captured_at :: timestamptz[]),
|
||||
unnest(@created_at :: timestamptz[]),
|
||||
unnest(@proto :: text[]),
|
||||
unnest(@method :: text[]),
|
||||
unnest(@detail :: text[]),
|
||||
unnest(@matched_rule :: text[])
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetBoundaryLogByID :one
|
||||
SELECT * FROM boundary_logs WHERE id = @id;
|
||||
|
||||
Generated
+1
@@ -97,6 +97,7 @@ const (
|
||||
UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id);
|
||||
UniqueUsageEventsDailyPkey UniqueConstraint = "usage_events_daily_pkey" // ALTER TABLE ONLY usage_events_daily ADD CONSTRAINT usage_events_daily_pkey PRIMARY KEY (day, event_type);
|
||||
UniqueUsageEventsPkey UniqueConstraint = "usage_events_pkey" // ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id);
|
||||
UniqueUserAiBudgetOverridesPkey UniqueConstraint = "user_ai_budget_overrides_pkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id);
|
||||
UniqueUserAiProviderKeysPkey UniqueConstraint = "user_ai_provider_keys_pkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id);
|
||||
UniqueUserAiProviderKeysUserIDAiProviderIDKey UniqueConstraint = "user_ai_provider_keys_user_id_ai_provider_id_key" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_ai_provider_id_key UNIQUE (user_id, ai_provider_id);
|
||||
UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key);
|
||||
|
||||
@@ -89,6 +89,15 @@ var (
|
||||
Type: "audit_log",
|
||||
}
|
||||
|
||||
// ResourceBoundaryLog
|
||||
// Valid Actions
|
||||
// - "ActionCreate" :: create boundary log records
|
||||
// - "ActionDelete" :: delete boundary logs
|
||||
// - "ActionRead" :: read boundary logs and session metadata
|
||||
ResourceBoundaryLog = Object{
|
||||
Type: "boundary_log",
|
||||
}
|
||||
|
||||
// ResourceBoundaryUsage
|
||||
// Valid Actions
|
||||
// - "ActionDelete" :: delete boundary usage statistics
|
||||
@@ -478,6 +487,7 @@ func AllResources() []Objecter {
|
||||
ResourceAssignOrgRole,
|
||||
ResourceAssignRole,
|
||||
ResourceAuditLog,
|
||||
ResourceBoundaryLog,
|
||||
ResourceBoundaryUsage,
|
||||
ResourceChat,
|
||||
ResourceConnectionLog,
|
||||
|
||||
@@ -422,6 +422,13 @@ var RBACPermissions = map[string]PermissionDefinition{
|
||||
ActionRead: "read AI seat state",
|
||||
},
|
||||
},
|
||||
"boundary_log": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionCreate: "create boundary log records",
|
||||
ActionRead: "read boundary logs and session metadata",
|
||||
ActionDelete: "delete boundary logs",
|
||||
},
|
||||
},
|
||||
"boundary_usage": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionRead: "read boundary usage statistics",
|
||||
|
||||
+15
-3
@@ -303,7 +303,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec.
|
||||
// Owners can inspect and delete personal skills for operability and
|
||||
// abuse handling, but cannot create or edit user-authored instructions.
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat),
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat),
|
||||
// This adds back in the Workspace permissions.
|
||||
Permissions(map[string][]policy.Action{
|
||||
ResourceWorkspace.Type: ownerWorkspaceActions,
|
||||
@@ -313,6 +313,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Explicitly setting PrebuiltWorkspace permissions for clarity.
|
||||
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
|
||||
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
|
||||
// Owners can read all boundary logs. Delete is reserved for
|
||||
// DBPurge only. Create is user-scoped (inherited from member).
|
||||
ResourceBoundaryLog.Type: {policy.ActionRead},
|
||||
})...,
|
||||
),
|
||||
User: []Permission{},
|
||||
@@ -332,7 +335,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
denyPermissions...,
|
||||
),
|
||||
User: append(
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat),
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat),
|
||||
Permissions(map[string][]policy.Action{
|
||||
// Users cannot do create/update/delete on themselves, but they
|
||||
// can read their own details.
|
||||
@@ -342,6 +345,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Members can create and update AI Bridge interceptions but
|
||||
// cannot read them back.
|
||||
ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate},
|
||||
// Workspace agents create boundary logs under their owner's
|
||||
// identity. Create is user-scoped so agents can only write
|
||||
// logs owned by their workspace owner.
|
||||
// Read: owners and auditors. Delete: DBPurge only.
|
||||
ResourceBoundaryLog.Type: {policy.ActionCreate},
|
||||
})...,
|
||||
),
|
||||
ByOrgID: map[string]OrgPermissions{},
|
||||
@@ -366,6 +374,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
ResourceDeploymentConfig.Type: {policy.ActionRead},
|
||||
// Allow auditors to query AI Bridge interceptions.
|
||||
ResourceAibridgeInterception.Type: {policy.ActionRead},
|
||||
// Allow auditors to read boundary logs.
|
||||
ResourceBoundaryLog.Type: {policy.ActionRead},
|
||||
}),
|
||||
User: []Permission{},
|
||||
ByOrgID: map[string]OrgPermissions{},
|
||||
@@ -465,7 +475,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
// Org admins should not have workspace exec perms.
|
||||
organizationID.String(): {
|
||||
Org: append(
|
||||
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceAiSeat),
|
||||
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat),
|
||||
Permissions(map[string][]policy.Action{
|
||||
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
|
||||
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
|
||||
@@ -1052,6 +1062,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
|
||||
ResourcePrebuiltWorkspace,
|
||||
ResourceUser,
|
||||
ResourceOrganizationMember,
|
||||
ResourceBoundaryLog,
|
||||
ResourceAibridgeInterception,
|
||||
// Chat access requires the agents-access role.
|
||||
ResourceChat,
|
||||
@@ -1137,6 +1148,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
|
||||
ResourcePrebuiltWorkspace,
|
||||
ResourceUser,
|
||||
ResourceOrganizationMember,
|
||||
ResourceBoundaryLog,
|
||||
ResourceAibridgeInterception,
|
||||
// Chat access requires the agents-access role.
|
||||
ResourceChat,
|
||||
|
||||
@@ -1229,6 +1229,75 @@ func TestRolePermissions(t *testing.T) {
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Boundary logs: members can create logs they own (user-scoped).
|
||||
// memberMe and agentsAccessUser have ID == currentUser, so they
|
||||
// match the resource owner. Other subjects have different IDs.
|
||||
Name: "BoundaryLogCreate",
|
||||
Actions: []policy.Action{policy.ActionCreate},
|
||||
Resource: rbac.ResourceBoundaryLog.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {memberMe, agentsAccessUser},
|
||||
false: {
|
||||
owner,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor, auditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Cross-user isolation: no subject can create boundary logs
|
||||
// owned by a different user. The resource owner is a random
|
||||
// UUID that does not match any test subject's ID.
|
||||
Name: "BoundaryLogCreateOther",
|
||||
Actions: []policy.Action{policy.ActionCreate},
|
||||
Resource: rbac.ResourceBoundaryLog.WithOwner(uuid.New().String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {},
|
||||
false: {
|
||||
owner, memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor, auditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Boundary logs: only DBPurge can delete. No human role
|
||||
// has delete; DBPurge is a system subject outside this matrix.
|
||||
Name: "BoundaryLogDelete",
|
||||
Actions: []policy.Action{policy.ActionDelete},
|
||||
Resource: rbac.ResourceBoundaryLog,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {},
|
||||
false: {
|
||||
owner, memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor, auditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Boundary logs: owner and auditor get read.
|
||||
Name: "BoundaryLogRead",
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceBoundaryLog,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, auditor},
|
||||
false: {
|
||||
memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
userAdmin, orgUserAdmin, otherOrgUserAdmin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ChatUsageCRU",
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
|
||||
@@ -1471,3 +1540,121 @@ func TestChangeSet(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceAgentScopeBoundaryLog verifies that a real workspace agent
|
||||
// scope (not ScopeAll) can create boundary logs for its own owner but
|
||||
// cannot create them for other users, and cannot read or delete them.
|
||||
func TestWorkspaceAgentScopeBoundaryLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
|
||||
|
||||
ownerID := uuid.New()
|
||||
otherOwnerID := uuid.New()
|
||||
workspaceID := uuid.New()
|
||||
templateID := uuid.New()
|
||||
versionID := uuid.New()
|
||||
|
||||
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: templateID,
|
||||
VersionID: versionID,
|
||||
})
|
||||
|
||||
memberRole, err := rbac.RoleByName(rbac.RoleMember())
|
||||
require.NoError(t, err)
|
||||
|
||||
agent := rbac.Subject{
|
||||
ID: ownerID.String(),
|
||||
Roles: rbac.Roles{memberRole},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
// Agent can create boundary logs for its own owner.
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()))
|
||||
require.NoError(t, err, "agent should create boundary logs for own owner")
|
||||
|
||||
// Agent cannot create boundary logs for a different owner.
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String()))
|
||||
require.Error(t, err, "agent must not create boundary logs for other owner")
|
||||
|
||||
// Agent cannot read boundary logs (even its own owner's).
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionRead,
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()))
|
||||
require.Error(t, err, "agent must not read boundary logs")
|
||||
|
||||
// Agent cannot delete boundary logs (even its own owner's).
|
||||
err = auth.Authorize(context.Background(), agent, policy.ActionDelete,
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()))
|
||||
require.Error(t, err, "agent must not delete boundary logs")
|
||||
|
||||
// When the workspace owner is a site admin, the agent scope
|
||||
// wildcard for boundary_log combined with the owner role's site-level
|
||||
// read grant means the agent CAN read all boundary logs. This is an
|
||||
// accepted consequence of the wildcard scope needed for creation.
|
||||
ownerRole, err := rbac.RoleByName(rbac.RoleOwner())
|
||||
require.NoError(t, err)
|
||||
|
||||
adminAgent := rbac.Subject{
|
||||
ID: ownerID.String(),
|
||||
Roles: rbac.Roles{memberRole, ownerRole},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
// Admin-owned agent CAN read boundary logs due to site-level owner
|
||||
// role + wildcard scope.
|
||||
err = auth.Authorize(context.Background(), adminAgent, policy.ActionRead,
|
||||
rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String()))
|
||||
require.NoError(t, err, "admin agent inherits site-level read via owner role")
|
||||
|
||||
// Admin-owned agent still cannot create boundary logs for another owner
|
||||
// because member-level create is user-scoped (subject.id must match owner).
|
||||
err = auth.Authorize(context.Background(), adminAgent, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String()))
|
||||
require.Error(t, err, "admin agent must not create boundary logs for other owner")
|
||||
}
|
||||
|
||||
// TestDBPurgeBoundaryLogDelete verifies that the DBPurge system subject
|
||||
// can delete boundary logs but cannot create or read them.
|
||||
func TestDBPurgeBoundaryLogDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
|
||||
|
||||
// Build the DBPurge subject the same way dbauthz does.
|
||||
dbPurge := rbac.Subject{
|
||||
Type: rbac.SubjectTypeDBPurge,
|
||||
FriendlyName: "DB Purge",
|
||||
ID: uuid.Nil.String(),
|
||||
Roles: rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleIdentifier{Name: "dbpurge"},
|
||||
DisplayName: "DB Purge Daemon",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceBoundaryLog.Type: {policy.ActionDelete},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
},
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
// DBPurge can delete boundary logs.
|
||||
err := auth.Authorize(context.Background(), dbPurge, policy.ActionDelete,
|
||||
rbac.ResourceBoundaryLog)
|
||||
require.NoError(t, err, "DBPurge should delete boundary logs")
|
||||
|
||||
// DBPurge cannot create boundary logs.
|
||||
err = auth.Authorize(context.Background(), dbPurge, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(uuid.New().String()))
|
||||
require.Error(t, err, "DBPurge must not create boundary logs")
|
||||
|
||||
// DBPurge cannot read boundary logs.
|
||||
err = auth.Authorize(context.Background(), dbPurge, policy.ActionRead,
|
||||
rbac.ResourceBoundaryLog)
|
||||
require.Error(t, err, "DBPurge must not read boundary logs")
|
||||
}
|
||||
|
||||
@@ -65,6 +65,11 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope {
|
||||
{Type: ResourceTemplate.Type, ID: params.TemplateID.String()},
|
||||
{Type: ResourceTemplate.Type, ID: params.VersionID.String()},
|
||||
{Type: ResourceUser.Type, ID: params.OwnerID.String()},
|
||||
// No pre-existing ID for new records; wildcard is required.
|
||||
// Owner-scoped create (user-level) limits agents to their own
|
||||
// logs. Adding site-level actions to the member role would
|
||||
// bypass this and grant deployment-wide access.
|
||||
{Type: ResourceBoundaryLog.Type, ID: policy.WildcardSymbol},
|
||||
}, extraAllowList...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ const (
|
||||
ScopeAssignRoleUnassign ScopeName = "assign_role:unassign"
|
||||
ScopeAuditLogCreate ScopeName = "audit_log:create"
|
||||
ScopeAuditLogRead ScopeName = "audit_log:read"
|
||||
ScopeBoundaryLogCreate ScopeName = "boundary_log:create"
|
||||
ScopeBoundaryLogDelete ScopeName = "boundary_log:delete"
|
||||
ScopeBoundaryLogRead ScopeName = "boundary_log:read"
|
||||
ScopeBoundaryUsageDelete ScopeName = "boundary_usage:delete"
|
||||
ScopeBoundaryUsageRead ScopeName = "boundary_usage:read"
|
||||
ScopeBoundaryUsageUpdate ScopeName = "boundary_usage:update"
|
||||
@@ -210,6 +213,9 @@ func (e ScopeName) Valid() bool {
|
||||
ScopeAssignRoleUnassign,
|
||||
ScopeAuditLogCreate,
|
||||
ScopeAuditLogRead,
|
||||
ScopeBoundaryLogCreate,
|
||||
ScopeBoundaryLogDelete,
|
||||
ScopeBoundaryLogRead,
|
||||
ScopeBoundaryUsageDelete,
|
||||
ScopeBoundaryUsageRead,
|
||||
ScopeBoundaryUsageUpdate,
|
||||
@@ -388,6 +394,9 @@ func AllScopeNameValues() []ScopeName {
|
||||
ScopeAssignRoleUnassign,
|
||||
ScopeAuditLogCreate,
|
||||
ScopeAuditLogRead,
|
||||
ScopeBoundaryLogCreate,
|
||||
ScopeBoundaryLogDelete,
|
||||
ScopeBoundaryLogRead,
|
||||
ScopeBoundaryUsageDelete,
|
||||
ScopeBoundaryUsageRead,
|
||||
ScopeBoundaryUsageUpdate,
|
||||
|
||||
@@ -195,6 +195,7 @@ func Classify(err error) ClassifiedError {
|
||||
}
|
||||
|
||||
retryableHTTP2StreamReset, hasHTTP2StreamReset := classifyHTTP2StreamReset(err)
|
||||
providerDisabledMatch := containsAny(lower, providerDisabledPatterns...)
|
||||
deadline := errors.Is(err, context.DeadlineExceeded) || strings.Contains(lower, "context deadline exceeded")
|
||||
overloadedMatch := statusCode == 529 || containsAny(lower, overloadedPatterns...)
|
||||
usageLimitMatch := containsAny(lower, usageLimitPatterns...)
|
||||
@@ -221,6 +222,8 @@ func Classify(err error) ClassifiedError {
|
||||
// over whatever HTTP status code the provider happened to use.
|
||||
// Strong auth still stays above config because bad credentials are
|
||||
// the root cause when both signals appear.
|
||||
// Provider-disabled must precede timeout because disabled providers
|
||||
// return 503, which matches the timeout rule.
|
||||
rules := []struct {
|
||||
match bool
|
||||
kind codersdk.ChatErrorKind
|
||||
@@ -251,6 +254,11 @@ func Classify(err error) ClassifiedError {
|
||||
kind: codersdk.ChatErrorKindRateLimit,
|
||||
retryable: true,
|
||||
},
|
||||
{
|
||||
match: providerDisabledMatch,
|
||||
kind: codersdk.ChatErrorKindProviderDisabled,
|
||||
retryable: false,
|
||||
},
|
||||
{
|
||||
match: timeoutMatch && !configMatch,
|
||||
kind: codersdk.ChatErrorKindTimeout,
|
||||
|
||||
@@ -2,6 +2,7 @@ package chaterror_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -218,6 +219,85 @@ func TestClassify(t *testing.T) {
|
||||
StatusCode: 0,
|
||||
},
|
||||
},
|
||||
// The next cases model the error that fantasy produces
|
||||
// when aibridge's disabledProviderHandler returns a 503
|
||||
// plain-text sentinel. Fantasy sets Title from the HTTP
|
||||
// status text and Message from the response body (including
|
||||
// the trailing newline written by http.Error).
|
||||
{
|
||||
name: "ProviderDisabled503ClassifiesAsProviderDisabled",
|
||||
err: &fantasy.ProviderError{
|
||||
Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable),
|
||||
Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "openai"),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The OpenAI provider has been disabled. Contact your Coder administrator.",
|
||||
Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "openai"),
|
||||
Kind: codersdk.ChatErrorKindProviderDisabled,
|
||||
Provider: "openai",
|
||||
Retryable: false,
|
||||
StatusCode: 503,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ProviderDisabled503UnknownProvider",
|
||||
err: &fantasy.ProviderError{
|
||||
Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable),
|
||||
Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The AI provider has been disabled. Contact your Coder administrator.",
|
||||
Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"),
|
||||
Kind: codersdk.ChatErrorKindProviderDisabled,
|
||||
Provider: "",
|
||||
Retryable: false,
|
||||
StatusCode: 503,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ProviderDisabledPlainErrorString",
|
||||
err: xerrors.New(fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "anthropic")),
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The Anthropic provider has been disabled. Contact your Coder administrator.",
|
||||
Kind: codersdk.ChatErrorKindProviderDisabled,
|
||||
Provider: "anthropic",
|
||||
Retryable: false,
|
||||
StatusCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ProviderDisabledBeatsTimeout503",
|
||||
err: &fantasy.ProviderError{
|
||||
Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable),
|
||||
Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "google"),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The Google provider has been disabled. Contact your Coder administrator.",
|
||||
Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "google"),
|
||||
Kind: codersdk.ChatErrorKindProviderDisabled,
|
||||
Provider: "google",
|
||||
Retryable: false,
|
||||
StatusCode: 503,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Generic503StillClassifiesAsTimeout",
|
||||
err: &fantasy.ProviderError{
|
||||
Message: "service unavailable",
|
||||
StatusCode: 503,
|
||||
},
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "The AI provider is temporarily unavailable.",
|
||||
Detail: "service unavailable",
|
||||
Kind: codersdk.ChatErrorKindTimeout,
|
||||
Provider: "",
|
||||
Retryable: true,
|
||||
StatusCode: 503,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -363,6 +443,7 @@ func TestClassify_PatternCoverage(t *testing.T) {
|
||||
{name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false},
|
||||
{name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
|
||||
{name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true},
|
||||
{name: "ProviderDisabledLiteral", err: "provider_disabled", wantKind: codersdk.ChatErrorKindProviderDisabled, wantRetry: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
stringutil "github.com/coder/coder/v2/coderd/util/strings"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -16,60 +17,58 @@ func terminalMessage(classified ClassifiedError) string {
|
||||
subject := providerSubject(classified.Provider)
|
||||
switch classified.Kind {
|
||||
case codersdk.ChatErrorKindOverloaded:
|
||||
return fmt.Sprintf("%s is temporarily overloaded.", subject)
|
||||
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject))
|
||||
|
||||
case codersdk.ChatErrorKindRateLimit:
|
||||
return fmt.Sprintf("%s is rate limiting requests.", subject)
|
||||
return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject))
|
||||
|
||||
case codersdk.ChatErrorKindTimeout:
|
||||
if !classified.Retryable && classified.StatusCode == 0 {
|
||||
return "The request timed out before it completed."
|
||||
}
|
||||
return fmt.Sprintf("%s is temporarily unavailable.", subject)
|
||||
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject))
|
||||
|
||||
case codersdk.ChatErrorKindStartupTimeout:
|
||||
return fmt.Sprintf(
|
||||
return stringutil.Capitalize(fmt.Sprintf(
|
||||
"%s did not start responding in time.", subject,
|
||||
)
|
||||
))
|
||||
|
||||
case codersdk.ChatErrorKindUsageLimit:
|
||||
displayName := providerDisplayName(classified.Provider)
|
||||
if displayName == "" {
|
||||
displayName = "the AI provider"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
return stringutil.Capitalize(fmt.Sprintf(
|
||||
"The usage quota for %s has been exceeded."+
|
||||
" Check the billing and quota settings for the provider account.",
|
||||
displayName,
|
||||
)
|
||||
subject,
|
||||
))
|
||||
|
||||
case codersdk.ChatErrorKindAuth:
|
||||
displayName := providerDisplayName(classified.Provider)
|
||||
if displayName == "" {
|
||||
displayName = "the AI provider"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Authentication with %s failed."+
|
||||
" Check the API key and permissions.",
|
||||
displayName,
|
||||
subject,
|
||||
)
|
||||
|
||||
case codersdk.ChatErrorKindConfig:
|
||||
return fmt.Sprintf(
|
||||
return stringutil.Capitalize(fmt.Sprintf(
|
||||
"%s rejected the model configuration."+
|
||||
" Check the selected model and provider settings.",
|
||||
subject,
|
||||
)
|
||||
))
|
||||
|
||||
case codersdk.ChatErrorKindMissingKey:
|
||||
return "This conversation was started with an API key that is no longer available." +
|
||||
" Send your message again to continue."
|
||||
|
||||
case codersdk.ChatErrorKindProviderDisabled:
|
||||
displayName := providerDisplayName(classified.Provider)
|
||||
return fmt.Sprintf(
|
||||
"The %s provider has been disabled."+
|
||||
" Contact your Coder administrator.",
|
||||
displayName,
|
||||
)
|
||||
default:
|
||||
if !classified.Retryable && classified.StatusCode == 0 {
|
||||
return "The chat request failed unexpectedly."
|
||||
}
|
||||
return fmt.Sprintf("%s returned an unexpected error.", subject)
|
||||
return stringutil.Capitalize(fmt.Sprintf("%s returned an unexpected error.", subject))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,41 +84,43 @@ func retryMessage(classified ClassifiedError) string {
|
||||
subject := providerSubject(classified.Provider)
|
||||
switch classified.Kind {
|
||||
case codersdk.ChatErrorKindOverloaded:
|
||||
return fmt.Sprintf("%s is temporarily overloaded.", subject)
|
||||
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject))
|
||||
case codersdk.ChatErrorKindRateLimit:
|
||||
return fmt.Sprintf("%s is rate limiting requests.", subject)
|
||||
return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject))
|
||||
case codersdk.ChatErrorKindTimeout:
|
||||
return fmt.Sprintf("%s is temporarily unavailable.", subject)
|
||||
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject))
|
||||
case codersdk.ChatErrorKindStartupTimeout:
|
||||
return fmt.Sprintf(
|
||||
return stringutil.Capitalize(fmt.Sprintf(
|
||||
"%s did not start responding in time.", subject,
|
||||
)
|
||||
))
|
||||
case codersdk.ChatErrorKindAuth:
|
||||
displayName := providerDisplayName(classified.Provider)
|
||||
if displayName == "" {
|
||||
displayName = "the AI provider"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Authentication with %s failed.", displayName,
|
||||
"Authentication with %s failed.", subject,
|
||||
)
|
||||
case codersdk.ChatErrorKindConfig:
|
||||
return fmt.Sprintf(
|
||||
return stringutil.Capitalize(fmt.Sprintf(
|
||||
"%s rejected the model configuration.", subject,
|
||||
)
|
||||
))
|
||||
case codersdk.ChatErrorKindMissingKey:
|
||||
return "The API key for this conversation is no longer available."
|
||||
default:
|
||||
case codersdk.ChatErrorKindProviderDisabled:
|
||||
displayName := providerDisplayName(classified.Provider)
|
||||
return fmt.Sprintf(
|
||||
"%s returned an unexpected error.", subject,
|
||||
"The %s provider has been disabled by an administrator.",
|
||||
displayName,
|
||||
)
|
||||
default:
|
||||
return stringutil.Capitalize(fmt.Sprintf(
|
||||
"%s returned an unexpected error.", subject,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func providerSubject(provider string) string {
|
||||
if displayName := providerDisplayName(provider); displayName != "" {
|
||||
if displayName := providerDisplayName(provider); displayName != "AI" && displayName != "" {
|
||||
return displayName
|
||||
}
|
||||
return "The AI provider"
|
||||
return "the AI provider"
|
||||
}
|
||||
|
||||
func providerDisplayName(provider string) string {
|
||||
@@ -141,7 +142,7 @@ func providerDisplayName(provider string) string {
|
||||
case "vercel":
|
||||
return "Vercel AI Gateway"
|
||||
default:
|
||||
return ""
|
||||
return "AI"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/coder/coder/v2/aibridge"
|
||||
)
|
||||
|
||||
type providerHint struct {
|
||||
@@ -83,6 +85,7 @@ var (
|
||||
}
|
||||
genericRetryablePatterns = []string{"server error", "internal server error"}
|
||||
interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"}
|
||||
providerDisabledPatterns = []string{aibridge.ErrorCodeProviderDisabled}
|
||||
)
|
||||
|
||||
func extractStatusCode(lower string) int {
|
||||
|
||||
@@ -435,3 +435,71 @@ func (c *Client) DeleteGroupAIBudget(ctx context.Context, group uuid.UUID) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserAIBudgetOverride struct {
|
||||
UserID uuid.UUID `json:"user_id" format:"uuid"`
|
||||
GroupID uuid.UUID `json:"group_id" format:"uuid"`
|
||||
SpendLimitMicros int64 `json:"spend_limit_micros"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
}
|
||||
|
||||
type UpsertUserAIBudgetOverrideRequest struct {
|
||||
// GroupID is the group the user's spend is attributed to. The user must
|
||||
// be a member of this group.
|
||||
GroupID uuid.UUID `json:"group_id" format:"uuid" validate:"required"`
|
||||
SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"`
|
||||
}
|
||||
|
||||
// UserAIBudgetOverride returns the AI spend budget override configured for the given user.
|
||||
func (c *Client) UserAIBudgetOverride(ctx context.Context, user uuid.UUID) (UserAIBudgetOverride, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return UserAIBudgetOverride{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return UserAIBudgetOverride{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp UserAIBudgetOverride
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// UpsertUserAIBudgetOverride creates or updates the AI spend budget override for the given user.
|
||||
func (c *Client) UpsertUserAIBudgetOverride(ctx context.Context, user uuid.UUID, req UpsertUserAIBudgetOverrideRequest) (UserAIBudgetOverride, error) {
|
||||
res, err := c.Request(ctx, http.MethodPut,
|
||||
fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()),
|
||||
req,
|
||||
)
|
||||
if err != nil {
|
||||
return UserAIBudgetOverride{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return UserAIBudgetOverride{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp UserAIBudgetOverride
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// DeleteUserAIBudgetOverride removes the AI spend budget override for the given user.
|
||||
func (c *Client) DeleteUserAIBudgetOverride(ctx context.Context, user uuid.UUID) error {
|
||||
res, err := c.Request(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ const (
|
||||
APIKeyScopeAuditLogAll APIKeyScope = "audit_log:*"
|
||||
APIKeyScopeAuditLogCreate APIKeyScope = "audit_log:create"
|
||||
APIKeyScopeAuditLogRead APIKeyScope = "audit_log:read"
|
||||
APIKeyScopeBoundaryLogAll APIKeyScope = "boundary_log:*"
|
||||
APIKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create"
|
||||
APIKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete"
|
||||
APIKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read"
|
||||
APIKeyScopeBoundaryUsageAll APIKeyScope = "boundary_usage:*"
|
||||
APIKeyScopeBoundaryUsageDelete APIKeyScope = "boundary_usage:delete"
|
||||
APIKeyScopeBoundaryUsageRead APIKeyScope = "boundary_usage:read"
|
||||
|
||||
+11
-9
@@ -1525,15 +1525,16 @@ type ChatStreamStatus struct {
|
||||
type ChatErrorKind string
|
||||
|
||||
const (
|
||||
ChatErrorKindGeneric ChatErrorKind = "generic"
|
||||
ChatErrorKindOverloaded ChatErrorKind = "overloaded"
|
||||
ChatErrorKindRateLimit ChatErrorKind = "rate_limit"
|
||||
ChatErrorKindTimeout ChatErrorKind = "timeout"
|
||||
ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout"
|
||||
ChatErrorKindAuth ChatErrorKind = "auth"
|
||||
ChatErrorKindConfig ChatErrorKind = "config"
|
||||
ChatErrorKindUsageLimit ChatErrorKind = "usage_limit"
|
||||
ChatErrorKindMissingKey ChatErrorKind = "missing_key"
|
||||
ChatErrorKindGeneric ChatErrorKind = "generic"
|
||||
ChatErrorKindOverloaded ChatErrorKind = "overloaded"
|
||||
ChatErrorKindRateLimit ChatErrorKind = "rate_limit"
|
||||
ChatErrorKindTimeout ChatErrorKind = "timeout"
|
||||
ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout"
|
||||
ChatErrorKindAuth ChatErrorKind = "auth"
|
||||
ChatErrorKindConfig ChatErrorKind = "config"
|
||||
ChatErrorKindUsageLimit ChatErrorKind = "usage_limit"
|
||||
ChatErrorKindMissingKey ChatErrorKind = "missing_key"
|
||||
ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled"
|
||||
)
|
||||
|
||||
// AllChatErrorKinds contains every ChatErrorKind value.
|
||||
@@ -1548,6 +1549,7 @@ var AllChatErrorKinds = []ChatErrorKind{
|
||||
ChatErrorKindConfig,
|
||||
ChatErrorKindUsageLimit,
|
||||
ChatErrorKindMissingKey,
|
||||
ChatErrorKindProviderDisabled,
|
||||
}
|
||||
|
||||
// ChatError represents a terminal chat error in persisted chat state or the
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
ResourceAssignOrgRole RBACResource = "assign_org_role"
|
||||
ResourceAssignRole RBACResource = "assign_role"
|
||||
ResourceAuditLog RBACResource = "audit_log"
|
||||
ResourceBoundaryLog RBACResource = "boundary_log"
|
||||
ResourceBoundaryUsage RBACResource = "boundary_usage"
|
||||
ResourceChat RBACResource = "chat"
|
||||
ResourceConnectionLog RBACResource = "connection_log"
|
||||
@@ -89,6 +90,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
|
||||
ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate},
|
||||
ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign},
|
||||
ResourceAuditLog: {ActionCreate, ActionRead},
|
||||
ResourceBoundaryLog: {ActionCreate, ActionDelete, ActionRead},
|
||||
ResourceBoundaryUsage: {ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionShare, ActionUpdate},
|
||||
ResourceConnectionLog: {ActionRead, ActionUpdate},
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# API Tokens of deleted users not invalidated
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Coder identified an issue in
|
||||
[https://github.com/coder/coder](https://github.com/coder/coder) where API
|
||||
tokens belonging to a deleted user were not invalidated. A deleted user in
|
||||
possession of a valid and non-expired API token is still able to use the above
|
||||
token with their full suite of capabilities.
|
||||
|
||||
## Impact: HIGH
|
||||
|
||||
If exploited, an attacker could perform any action that the deleted user was
|
||||
authorized to perform.
|
||||
|
||||
## Exploitability: HIGH
|
||||
|
||||
The CLI writes the API key to `~/.coderv2/session` by default, so any deleted
|
||||
user who previously logged in via the Coder CLI has the potential to exploit
|
||||
this. Note that there is a time window for exploitation; API tokens have a
|
||||
maximum lifetime after which they are no longer valid.
|
||||
|
||||
The issue only affects users who were active (not suspended) at the time they
|
||||
were deleted. Users who were first suspended and later deleted cannot exploit
|
||||
this issue.
|
||||
|
||||
## Affected Versions
|
||||
|
||||
All versions of Coder between v0.8.15 and v0.22.2 (inclusive) are affected.
|
||||
|
||||
All customers are advised to upgrade to
|
||||
[v0.23.0](https://github.com/coder/coder/releases/tag/v0.23.0) as soon as
|
||||
possible.
|
||||
|
||||
## Details
|
||||
|
||||
Coder incorrectly failed to invalidate API keys belonging to a user when they
|
||||
were deleted. When authenticating a user via their API key, Coder incorrectly
|
||||
failed to check whether the API key corresponds to a deleted user.
|
||||
|
||||
## Indications of Compromise
|
||||
|
||||
> [!TIP]
|
||||
> Automated remediation steps in the upgrade purge all affected API keys.
|
||||
> Either perform the following query before upgrade or run it on a backup of
|
||||
> your database from before the upgrade.
|
||||
|
||||
Execute the following SQL query:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
users.email,
|
||||
users.updated_at,
|
||||
api_keys.id,
|
||||
api_keys.last_used
|
||||
FROM
|
||||
users
|
||||
LEFT JOIN
|
||||
api_keys
|
||||
ON
|
||||
api_keys.user_id = users.id
|
||||
WHERE
|
||||
users.deleted
|
||||
AND
|
||||
api_keys.last_used > users.updated_at
|
||||
;
|
||||
```
|
||||
|
||||
If the output is similar to the below, then you are not affected:
|
||||
|
||||
```sql
|
||||
-----
|
||||
(0 rows)
|
||||
```
|
||||
|
||||
Otherwise, the following information will be reported:
|
||||
|
||||
- User email
|
||||
- Time the user was last modified (i.e. deleted)
|
||||
- User API key ID
|
||||
- Time the affected API key was last used
|
||||
|
||||
> [!TIP]
|
||||
> If your license includes the
|
||||
> [Audit Logs](https://coder.com/docs/admin/audit-logs#filtering-logs) feature,
|
||||
> you can then query all actions performed by the above users by using the
|
||||
> filter `email:$USER_EMAIL`.
|
||||
@@ -11,17 +11,6 @@ For other security tips, visit our guide to
|
||||
> If you discover a vulnerability in Coder, please do not hesitate to report it
|
||||
> to us by following the [security policy](https://github.com/coder/coder/blob/main/SECURITY.md).
|
||||
|
||||
From time to time, Coder employees or other community members may discover
|
||||
vulnerabilities in the product.
|
||||
|
||||
If a vulnerability requires an immediate upgrade to mitigate a potential
|
||||
security risk, we will add it to the below table.
|
||||
|
||||
Click on the description links to view more details about each specific
|
||||
vulnerability.
|
||||
|
||||
---
|
||||
|
||||
| Description | Severity | Fix | Vulnerable Versions |
|
||||
|-----------------------------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------------------------|---------------------|
|
||||
| [API tokens of deleted users not invalidated](https://github.com/coder/coder/blob/main/docs/admin/security/0001_user_apikeys_invalidation.md) | HIGH | [v0.23.0](https://github.com/coder/coder/releases/tag/v0.23.0) | v0.8.25 - v0.22.2 |
|
||||
Security advisories are published on the
|
||||
[GitHub Security Advisories](https://github.com/coder/coder/security/advisories)
|
||||
page.
|
||||
|
||||
Generated
+7
-7
@@ -292,13 +292,13 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|---------------|---------------------------------------------------------------------------------------------------------------------|
|
||||
| `client_type` | `api`, `ui` |
|
||||
| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
|
||||
| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
|
||||
| `plan_mode` | `plan` |
|
||||
| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
|
||||
| Property | Value(s) |
|
||||
|---------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `client_type` | `api`, `ui` |
|
||||
| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
|
||||
| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
|
||||
| `plan_mode` | `plan` |
|
||||
| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
||||
Generated
+119
@@ -3418,6 +3418,125 @@ curl -X POST http://coder-server:8080/api/v2/templates/{template}/prebuilds/inva
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get user AI budget override
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/users/{user}/ai/budget \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/v2/users/{user}/ai/budget`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------|----------|--------------------------|
|
||||
| `user` | path | string | true | User ID, username, or me |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
|
||||
"spend_limit_micros": 0,
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAIBudgetOverride](schemas.md#codersdkuseraibudgetoverride) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Upsert user AI budget override
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X PUT http://coder-server:8080/api/v2/users/{user}/ai/budget \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`PUT /api/v2/users/{user}/ai/budget`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
|
||||
"spend_limit_micros": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|----------------------------------------------------------------------------------------------------|----------|----------------------------------------|
|
||||
| `user` | path | string | true | User ID, username, or me |
|
||||
| `body` | body | [codersdk.UpsertUserAIBudgetOverrideRequest](schemas.md#codersdkupsertuseraibudgetoverriderequest) | true | Upsert user AI budget override request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
|
||||
"spend_limit_micros": 0,
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAIBudgetOverride](schemas.md#codersdkuseraibudgetoverride) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Delete user AI budget override
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X DELETE http://coder-server:8080/api/v2/users/{user}/ai/budget \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`DELETE /api/v2/users/{user}/ai/budget`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------|----------|--------------------------|
|
||||
| `user` | path | string | true | User ID, username, or me |
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|-----------------------------------------------------------------|-------------|--------|
|
||||
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get user quiet hours schedule
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+20
-20
@@ -193,10 +193,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -326,10 +326,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -459,10 +459,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -554,10 +554,10 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -960,9 +960,9 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| Property | Value(s) |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` |
|
||||
| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
Generated
+47
-9
File diff suppressed because one or more lines are too long
Generated
+5
-5
@@ -865,11 +865,11 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| `login_type` | `github`, `oidc`, `password`, `token` |
|
||||
| `scope` | `all`, `application_connect` |
|
||||
| Property | Value(s) |
|
||||
|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` |
|
||||
| `login_type` | `github`, `oidc`, `password`, `token` |
|
||||
| `scope` | `all`, `application_connect` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ const (
|
||||
// reference a valid resource in the expected scope.
|
||||
var errInvalidCursor = xerrors.New("invalid pagination cursor")
|
||||
|
||||
// This name is raised by a trigger function with USING CONSTRAINT.
|
||||
// It is not a table CHECK constraint, so dbgen does not emit it in
|
||||
// check_constraint.go.
|
||||
const userAIBudgetOverridesMustBeGroupMemberConstraint database.CheckConstraint = "user_ai_budget_overrides_must_be_group_member"
|
||||
|
||||
// aibridgeHandler handles all aibridged-related endpoints.
|
||||
func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) {
|
||||
// Build the overload protection middleware chain for the aibridged handler.
|
||||
@@ -821,3 +826,116 @@ func (api *API) deleteGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Get user AI budget override
|
||||
// @ID get-user-ai-budget-override
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param user path string true "User ID, username, or me"
|
||||
// @Success 200 {object} codersdk.UserAIBudgetOverride
|
||||
// @Router /api/v2/users/{user}/ai/budget [get]
|
||||
func (api *API) userAIBudgetOverride(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
override, err := api.Database.GetUserAIBudgetOverride(ctx, user.ID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "get user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override))
|
||||
}
|
||||
|
||||
// @Summary Upsert user AI budget override
|
||||
// @ID upsert-user-ai-budget-override
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param user path string true "User ID, username, or me"
|
||||
// @Param request body codersdk.UpsertUserAIBudgetOverrideRequest true "Upsert user AI budget override request"
|
||||
// @Success 200 {object} codersdk.UserAIBudgetOverride
|
||||
// @Router /api/v2/users/{user}/ai/budget [put]
|
||||
func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
var req codersdk.UpsertUserAIBudgetOverrideRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the group first so a missing or forbidden group_id returns
|
||||
// 404, distinct from the 400 "not a member" case handled below.
|
||||
if _, err := api.Database.GetGroupByID(ctx, req.GroupID); err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
api.Logger.Error(ctx, "get group for user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
override, err := api.Database.UpsertUserAIBudgetOverride(ctx, database.UpsertUserAIBudgetOverrideParams{
|
||||
UserID: user.ID,
|
||||
GroupID: req.GroupID,
|
||||
SpendLimitMicros: req.SpendLimitMicros,
|
||||
})
|
||||
// A trigger enforces that the user must be a member of the attributed
|
||||
// group; it raises check_violation with this constraint name. Map
|
||||
// the violation to a structured 400.
|
||||
if database.IsCheckViolation(err, userAIBudgetOverridesMustBeGroupMemberConstraint) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "User is not a member of the referenced group.",
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "group_id",
|
||||
Detail: "user must be a member of this group",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "upsert user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override))
|
||||
}
|
||||
|
||||
// @Summary Delete user AI budget override
|
||||
// @ID delete-user-ai-budget-override
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Enterprise
|
||||
// @Param user path string true "User ID, username, or me"
|
||||
// @Success 204
|
||||
// @Router /api/v2/users/{user}/ai/budget [delete]
|
||||
func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
_, err := api.Database.DeleteUserAIBudgetOverride(ctx, user.ID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "delete user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -211,6 +211,18 @@ func TestAIBridgeProviderHotReload(t *testing.T) {
|
||||
"expected provider %q to stop routing", providerName)
|
||||
}
|
||||
|
||||
// requireDisabledSentinel polls until the provider name yields a
|
||||
// 503 with the provider_disabled body, indicating the disabled
|
||||
// handler is wired up for the row.
|
||||
requireDisabledSentinel := func(t *testing.T, providerName string) {
|
||||
t.Helper()
|
||||
require.Eventuallyf(t, func() bool {
|
||||
status, _ := sendRequest(providerName)
|
||||
return status == http.StatusServiceUnavailable
|
||||
}, testutil.WaitShort, testutil.IntervalFast,
|
||||
"expected provider %q to serve the disabled sentinel", providerName)
|
||||
}
|
||||
|
||||
// 1. Create: provider points at upstream A.
|
||||
created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
@@ -233,14 +245,14 @@ func TestAIBridgeProviderHotReload(t *testing.T) {
|
||||
requireRoutesTo(t, "primary", upstreamB)
|
||||
requireProviderStatus(t, "primary", "enabled")
|
||||
|
||||
// 3. Disable: the provider drops out of the snapshot, requests
|
||||
// stop reaching any upstream. The metric flips to "disabled".
|
||||
// 3. Disable: requests stop reaching upstream and the bridge
|
||||
// answers with the 503 sentinel. The metric flips to "disabled".
|
||||
disabled := false
|
||||
_, err = client.UpdateAIProvider(ctx, "primary", codersdk.UpdateAIProviderRequest{
|
||||
Enabled: &disabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
requireRoutingGone(t, "primary")
|
||||
requireDisabledSentinel(t, "primary")
|
||||
requireProviderStatus(t, "primary", "disabled")
|
||||
|
||||
// 4. Re-enable: routing comes back at the most recent BaseURL.
|
||||
|
||||
@@ -2871,6 +2871,447 @@ func TestGroupAIBudget(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserAIBudgetOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Upsert/CreatesAndUpdates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// First upsert creates the override.
|
||||
newOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, targetUser.ID, newOverride.UserID)
|
||||
require.Equal(t, group.ID, newOverride.GroupID)
|
||||
require.EqualValues(t, 500_000_000, newOverride.SpendLimitMicros)
|
||||
|
||||
// Second upsert updates the existing override.
|
||||
updatedOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 1_000_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1_000_000_000, updatedOverride.SpendLimitMicros)
|
||||
|
||||
// GET returns the latest value.
|
||||
currentOverride, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1_000_000_000, currentOverride.SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("Upsert/ReassignsGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, groupA := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// First upsert: attribute spend to groupA.
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: groupA.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create groupB in the same org and add the target user.
|
||||
groupB, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
|
||||
Name: "reassign-test-group-b",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = adminClient.PatchGroup(ctx, groupB.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reassign the override's attribution to groupB.
|
||||
updated, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: groupB.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, groupB.ID, updated.GroupID, "upsert should change attributed group")
|
||||
|
||||
// GET reflects the new group.
|
||||
got, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, groupB.ID, got.GroupID, "GET should reflect new group")
|
||||
})
|
||||
|
||||
t.Run("Upsert/EveryoneGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// The Everyone group has id == organization_id, and the target user
|
||||
// is implicitly a member via organization_members rather than
|
||||
// group_members. The membership trigger queries
|
||||
// group_members_expanded (a UNION of both tables), so this case
|
||||
// exercises the organization_members branch.
|
||||
everyoneGroupID := targetUser.OrganizationIDs[0]
|
||||
|
||||
override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: everyoneGroupID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err, "should be able to attribute override to Everyone group")
|
||||
require.Equal(t, targetUser.ID, override.UserID)
|
||||
require.Equal(t, everyoneGroupID, override.GroupID)
|
||||
require.EqualValues(t, 500_000_000, override.SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("Upsert/AcceptsZeroSpendLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// 0 is a valid value: it blocks all spend for the user.
|
||||
override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, override.SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("Upsert/RejectsNegativeSpend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: -1,
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Upsert/RejectsUnknownGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// A group_id that doesn't exist (or that the caller can't see)
|
||||
// is rejected by the visibility check before the membership check.
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: uuid.New(),
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Upsert/RejectsNonMemberGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create a second group the target is NOT a member of.
|
||||
outsiderGroup, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
|
||||
Name: "outsider-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: outsiderGroup.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Get/AbsentReturns404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Get/UnknownUserReturns404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, _, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UserAIBudgetOverride(ctx, uuid.New())
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Delete/RoundTrip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID))
|
||||
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Delete/AbsentReturns404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
err := adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserAIBudgetOverrideRoleAccess verifies the authz matrix for the roles
|
||||
// expected to interact with user budget overrides:
|
||||
//
|
||||
// - Owner / UserAdmin: full CRUD.
|
||||
// - OrgAdmin / OrgUserAdmin: read-only. Writes require ActionUpdate on the
|
||||
// User resource (site-scoped), which neither role has.
|
||||
//
|
||||
//nolint:tparallel // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race.
|
||||
func TestUserAIBudgetOverrideRoleAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
|
||||
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{DeploymentValues: dv},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
codersdk.FeatureAIBridge: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
userAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
orgUserAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgUserAdmin(owner.OrganizationID))
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
group, err := userAdminClient.CreateGroup(setupCtx, owner.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "role-access-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
Name string
|
||||
Client *codersdk.Client
|
||||
CanWrite bool
|
||||
}{
|
||||
{Name: "Owner", Client: ownerClient, CanWrite: true},
|
||||
{Name: "UserAdmin", Client: userAdminClient, CanWrite: true},
|
||||
{Name: "OrgAdmin", Client: orgAdminClient, CanWrite: false},
|
||||
{Name: "OrgUserAdmin", Client: orgUserAdminClient, CanWrite: false},
|
||||
}
|
||||
|
||||
//nolint:paralleltest // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race.
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Each case gets a fresh target user.
|
||||
_, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
_, err := userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
upsertReq := codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
}
|
||||
|
||||
if tc.CanWrite {
|
||||
// Full CRUD lifecycle.
|
||||
override, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq)
|
||||
require.NoError(t, err, "PUT")
|
||||
require.Equal(t, group.ID, override.GroupID)
|
||||
|
||||
got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "GET")
|
||||
require.EqualValues(t, 500_000_000, got.SpendLimitMicros)
|
||||
|
||||
err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "DELETE")
|
||||
} else {
|
||||
// PUT rejected.
|
||||
_, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "PUT")
|
||||
|
||||
// Seed a row via UserAdmin so we can verify read access still works.
|
||||
_, err = userAdminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
// GET still works (all roles have ActionRead on User).
|
||||
got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "GET")
|
||||
require.EqualValues(t, 500_000_000, got.SpendLimitMicros)
|
||||
|
||||
// DELETE rejected.
|
||||
err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "DELETE")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserAIBudgetOverrideDeletedOnMembershipRemoval verifies that a per-user
|
||||
// override is deleted automatically when the user loses membership in the
|
||||
// attributed group. Two paths are exercised:
|
||||
//
|
||||
// - RegularGroup: membership stored in group_members; removed via
|
||||
// PatchGroup with RemoveUsers.
|
||||
// - EveryoneGroup: membership stored in organization_members; removed
|
||||
// via DeleteOrganizationMember.
|
||||
func TestUserAIBudgetOverrideDeletedOnMembershipRemoval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
|
||||
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{DeploymentValues: dv},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
codersdk.FeatureAIBridge: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
|
||||
// "Regular group" means any group except "Everyone".
|
||||
t.Run("RegularGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "cascade-regular-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err, "set override")
|
||||
|
||||
// Sanity-check the override exists.
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "override should exist before removal")
|
||||
|
||||
_, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
RemoveUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err, "remove user from group")
|
||||
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(),
|
||||
"override should be deleted after user is removed from the attributed group")
|
||||
})
|
||||
|
||||
t.Run("EveryoneGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
// The Everyone group has id == organization_id.
|
||||
everyoneGroupID := owner.OrganizationID
|
||||
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: everyoneGroupID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err, "set override")
|
||||
|
||||
// Sanity-check the override exists.
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "override should exist before removal")
|
||||
|
||||
err = adminClient.DeleteOrganizationMember(ctx, owner.OrganizationID, targetUser.ID.String())
|
||||
require.NoError(t, err, "remove user from organization")
|
||||
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(),
|
||||
"override should be deleted after user is removed from the organization")
|
||||
})
|
||||
}
|
||||
|
||||
// setupUserAIBudgetOverrideTest returns an Admin client, a target user, and a
|
||||
// group the target user is a member of.
|
||||
func setupUserAIBudgetOverrideTest(t *testing.T) (adminClient *codersdk.Client, targetUser codersdk.User, group codersdk.Group) {
|
||||
t.Helper()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
|
||||
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{DeploymentValues: dv},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
codersdk.FeatureAIBridge: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
adminClient, _ = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
_, targetUser = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
g, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "override-test-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
g, err = adminClient.PatchGroup(ctx, g.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return adminClient, targetUser, g
|
||||
}
|
||||
|
||||
// setupGroupAIBudgetTest returns an Admin client along with a newly created group inside it.
|
||||
func setupGroupAIBudgetTest(t *testing.T) (adminClient *codersdk.Client, group codersdk.Group) {
|
||||
t.Helper()
|
||||
|
||||
@@ -596,6 +596,17 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Get("/", api.userQuietHoursSchedule)
|
||||
r.Put("/", api.putUserQuietHoursSchedule)
|
||||
})
|
||||
r.Route("/users/{user}/ai/budget", func(r chi.Router) {
|
||||
// AI cost controls are a paid feature (AI Governance add-on).
|
||||
r.Use(
|
||||
api.RequireFeatureMW(codersdk.FeatureAIBridge),
|
||||
apiKeyMiddleware,
|
||||
httpmw.ExtractUserParam(options.Database),
|
||||
)
|
||||
r.Get("/", api.userAIBudgetOverride)
|
||||
r.Put("/", api.upsertUserAIBudgetOverride)
|
||||
r.Delete("/", api.deleteUserAIBudgetOverride)
|
||||
})
|
||||
r.Route("/prebuilds", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
|
||||
@@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202
|
||||
|
||||
// There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here:
|
||||
// https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main
|
||||
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f
|
||||
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399
|
||||
|
||||
// This is replaced to include
|
||||
// 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25
|
||||
|
||||
@@ -350,8 +350,8 @@ github.com/coder/serpent v0.15.0 h1:jobR7DnPsxzEMD0cRiailwlY+4v6HAPS/8emIgBpaIU=
|
||||
github.com/coder/serpent v0.15.0/go.mod h1:7OIvFBYMd+OqarMy5einBl8AtRr8LliopVU7pyrwucY=
|
||||
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw=
|
||||
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ=
|
||||
github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f h1:gYivllu5CHhvRr4SM93zSQDj9cG2V+Pc0URTFy3fF/Y=
|
||||
github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f/go.mod h1:WTWP5ZNODDXHwWlQ1Jc2MFhqxu93pUs7lIy28Fd5a5E=
|
||||
github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399 h1:4IhFSmu0DSfWrvmHCb8aXDjWqSEYoIDA1L7Ar82Dm84=
|
||||
github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399/go.mod h1:IatCC3hlq/ncu6DjZ+GJ/hNjSf5TmO+Xtc6B20k0q/c=
|
||||
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0=
|
||||
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI=
|
||||
github.com/coder/terraform-provider-coder/v2 v2.18.0 h1:b60ixwf7pVPuiL0GkHZf+1mVj94/HZhCNpsfjAK34mI=
|
||||
|
||||
@@ -50,6 +50,11 @@ export const RBACResourceActions: Partial<
|
||||
create: "create new audit log entries",
|
||||
read: "read audit logs",
|
||||
},
|
||||
boundary_log: {
|
||||
create: "create boundary log records",
|
||||
delete: "delete boundary logs",
|
||||
read: "read boundary logs and session metadata",
|
||||
},
|
||||
boundary_usage: {
|
||||
delete: "delete boundary usage statistics",
|
||||
read: "read boundary usage statistics",
|
||||
|
||||
Generated
+31
@@ -554,6 +554,10 @@ export type APIKeyScope =
|
||||
| "audit_log:*"
|
||||
| "audit_log:create"
|
||||
| "audit_log:read"
|
||||
| "boundary_log:*"
|
||||
| "boundary_log:create"
|
||||
| "boundary_log:delete"
|
||||
| "boundary_log:read"
|
||||
| "boundary_usage:*"
|
||||
| "boundary_usage:delete"
|
||||
| "boundary_usage:read"
|
||||
@@ -780,6 +784,10 @@ export const APIKeyScopes: APIKeyScope[] = [
|
||||
"audit_log:*",
|
||||
"audit_log:create",
|
||||
"audit_log:read",
|
||||
"boundary_log:*",
|
||||
"boundary_log:create",
|
||||
"boundary_log:delete",
|
||||
"boundary_log:read",
|
||||
"boundary_usage:*",
|
||||
"boundary_usage:delete",
|
||||
"boundary_usage:read",
|
||||
@@ -1961,6 +1969,7 @@ export type ChatErrorKind =
|
||||
| "generic"
|
||||
| "missing_key"
|
||||
| "overloaded"
|
||||
| "provider_disabled"
|
||||
| "rate_limit"
|
||||
| "startup_timeout"
|
||||
| "timeout"
|
||||
@@ -1972,6 +1981,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [
|
||||
"generic",
|
||||
"missing_key",
|
||||
"overloaded",
|
||||
"provider_disabled",
|
||||
"rate_limit",
|
||||
"startup_timeout",
|
||||
"timeout",
|
||||
@@ -6870,6 +6880,7 @@ export type RBACResource =
|
||||
| "assign_org_role"
|
||||
| "assign_role"
|
||||
| "audit_log"
|
||||
| "boundary_log"
|
||||
| "boundary_usage"
|
||||
| "chat"
|
||||
| "connection_log"
|
||||
@@ -6920,6 +6931,7 @@ export const RBACResources: RBACResource[] = [
|
||||
"assign_org_role",
|
||||
"assign_role",
|
||||
"audit_log",
|
||||
"boundary_log",
|
||||
"boundary_usage",
|
||||
"chat",
|
||||
"connection_log",
|
||||
@@ -9148,6 +9160,16 @@ export interface UpsertGroupAIBudgetRequest {
|
||||
readonly spend_limit_micros: number;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface UpsertUserAIBudgetOverrideRequest {
|
||||
/**
|
||||
* GroupID is the group the user's spend is attributed to. The user must
|
||||
* be a member of this group.
|
||||
*/
|
||||
readonly group_id: string;
|
||||
readonly spend_limit_micros: number;
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagentportshare.go
|
||||
export interface UpsertWorkspaceAgentPortShareRequest {
|
||||
readonly agent_name: string;
|
||||
@@ -9192,6 +9214,15 @@ export interface User extends ReducedUser {
|
||||
readonly has_ai_seat: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface UserAIBudgetOverride {
|
||||
readonly user_id: string;
|
||||
readonly group_id: string;
|
||||
readonly spend_limit_micros: number;
|
||||
readonly created_at: string;
|
||||
readonly updated_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UserAIProviderKeyConfig is a provider summary from the current user's
|
||||
|
||||
@@ -1163,9 +1163,11 @@ const AgentChatPage: FC = () => {
|
||||
store.setStreamError(reason);
|
||||
setChatErrorReason(agentId, reason);
|
||||
} else if (isApiError(error)) {
|
||||
const detail = error.response?.data?.detail?.trim() || undefined;
|
||||
const reason: ChatDetailError = {
|
||||
kind: "generic",
|
||||
message: error.message || "An unexpected error occurred.",
|
||||
message: getErrorMessage(error, "An unexpected error occurred."),
|
||||
...(detail ? { detail } : {}),
|
||||
};
|
||||
store.setStreamError(reason);
|
||||
setChatErrorReason(agentId, reason);
|
||||
|
||||
@@ -157,11 +157,17 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => {
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
{status.phase === "failed" && status.detail && (
|
||||
<span className="mt-1 block text-content-secondary">
|
||||
{status.detail}
|
||||
</span>
|
||||
)}
|
||||
{status.phase === "failed" &&
|
||||
status.detail &&
|
||||
(status.kind === "generic" ? (
|
||||
<code className="mt-1 block whitespace-pre-wrap text-xs text-content-secondary font-mono bg-surface-secondary rounded-md">
|
||||
{status.detail}
|
||||
</code>
|
||||
) : (
|
||||
<span className="mt-1 block text-content-secondary">
|
||||
{status.detail}
|
||||
</span>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user