fix(ops): classify local policy denials outside SLA
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
00eb3abbe1
commit
c782c2d9c3
@ -53,6 +53,8 @@ const (
|
||||
opsCodeUserNotFound = "USER_NOT_FOUND"
|
||||
opsCodeAPIKeyQuotaExhausted = "API_KEY_QUOTA_EXHAUSTED"
|
||||
opsCodeAPIKeyQueryDeprecated = "api_key_in_query_deprecated"
|
||||
opsCodeGroupDeleted = "GROUP_DELETED"
|
||||
opsCodeGroupDisabled = "GROUP_DISABLED"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -1012,6 +1014,8 @@ func parseOpsErrorResponse(body []byte) parsedOpsError {
|
||||
var code string
|
||||
if v, ok := errObj["code"]; ok {
|
||||
switch n := v.(type) {
|
||||
case string:
|
||||
code = strings.TrimSpace(n)
|
||||
case float64:
|
||||
code = strconvItoa(int(n))
|
||||
case int:
|
||||
@ -1188,14 +1192,19 @@ func isOpsClientAuthError(code string, msg string) bool {
|
||||
opsCodeAPIKeyExpired,
|
||||
opsCodeAPIKeyDisabled,
|
||||
opsCodeUserNotFound,
|
||||
opsCodeUserInactive:
|
||||
opsCodeUserInactive,
|
||||
opsCodeGroupDeleted,
|
||||
opsCodeGroupDisabled:
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "invalid api key") ||
|
||||
strings.Contains(msg, "api key is required") ||
|
||||
strings.Contains(msg, "api key is disabled") ||
|
||||
strings.Contains(msg, "user associated with api key not found") ||
|
||||
strings.Contains(msg, "user account is not active")
|
||||
strings.Contains(msg, "user account is not active") ||
|
||||
strings.Contains(msg, "api key 所属分组已删除") ||
|
||||
strings.Contains(msg, "api key 所属分组已停用") ||
|
||||
strings.Contains(msg, "api key is not assigned to any group")
|
||||
}
|
||||
|
||||
func isOpsLocalBusinessLimitError(code string, msg string) bool {
|
||||
@ -1211,6 +1220,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
|
||||
return strings.Contains(msg, "api key in query parameter is deprecated") ||
|
||||
strings.Contains(msg, "query parameter api_key is deprecated") ||
|
||||
strings.Contains(msg, "no active subscription found for this group") ||
|
||||
strings.Contains(msg, "subscription is invalid or expired") ||
|
||||
strings.Contains(msg, opsErrInsufficientBalance) ||
|
||||
strings.Contains(msg, "insufficient account balance") ||
|
||||
strings.Contains(msg, "api key group platform is not gemini") ||
|
||||
@ -1221,7 +1231,22 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
|
||||
strings.Contains(msg, "daily usage limit exceeded") ||
|
||||
strings.Contains(msg, "weekly usage limit exceeded") ||
|
||||
strings.Contains(msg, "monthly usage limit exceeded") ||
|
||||
strings.Contains(msg, "requests-per-minute limit exceeded")
|
||||
strings.Contains(msg, "usage quota exhausted for this platform") ||
|
||||
strings.Contains(msg, "requests-per-minute limit exceeded") ||
|
||||
strings.Contains(msg, "too many pending requests") ||
|
||||
strings.Contains(msg, "concurrency limit exceeded") ||
|
||||
strings.Contains(msg, "image generation concurrency limit exceeded") ||
|
||||
strings.Contains(msg, "this group is restricted to claude code clients") ||
|
||||
strings.Contains(msg, "this group does not allow /v1/messages dispatch") ||
|
||||
strings.Contains(msg, "image generation is not enabled for this group") ||
|
||||
strings.Contains(msg, "token counting is not supported for this platform") ||
|
||||
strings.Contains(msg, "images api is not supported for this platform") ||
|
||||
(strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) ||
|
||||
(strings.Contains(msg, "beta feature ") && strings.Contains(msg, " is not allowed")) ||
|
||||
(strings.Contains(msg, "openai service_tier=") && strings.Contains(msg, " is not allowed for model")) ||
|
||||
strings.Contains(msg, "this account only allows codex official clients") ||
|
||||
strings.Contains(msg, "openai wsv1 is temporarily unsupported") ||
|
||||
strings.Contains(msg, "openai codex passthrough requires a non-empty instructions field")
|
||||
}
|
||||
|
||||
func hasOpsUpstreamErrorContext(c *gin.Context) bool {
|
||||
|
||||
@ -288,6 +288,34 @@ func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) {
|
||||
code: "USER_INACTIVE",
|
||||
status: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "deleted local API key group",
|
||||
errType: "api_error",
|
||||
message: "API Key 所属分组已删除",
|
||||
code: "GROUP_DELETED",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "disabled local API key group",
|
||||
errType: "api_error",
|
||||
message: "API Key 所属分组已停用",
|
||||
code: "GROUP_DISABLED",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "google deleted API key group message without semantic code",
|
||||
errType: "api_error",
|
||||
message: "API Key 所属分组已删除",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "anthropic unassigned API key group",
|
||||
errType: "permission_error",
|
||||
message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "google invalid API key",
|
||||
errType: "api_error",
|
||||
@ -389,6 +417,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "gateway subscription invalid cache recheck",
|
||||
errType: "billing_error",
|
||||
message: "subscription is invalid or expired",
|
||||
code: "billing_error",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "billing_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "google insufficient account balance",
|
||||
errType: "api_error",
|
||||
@ -443,6 +480,132 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "user platform daily quota exhausted",
|
||||
errType: "api_error",
|
||||
message: "Daily usage quota exhausted for this platform.",
|
||||
code: "rate_limit_exceeded",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "local pending queue limit",
|
||||
errType: "rate_limit_error",
|
||||
message: "Too many pending requests, please retry later",
|
||||
code: "",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantErrType: "rate_limit_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "local concurrency limit",
|
||||
errType: "rate_limit_error",
|
||||
message: "Concurrency limit exceeded for user, please retry later",
|
||||
code: "",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantErrType: "rate_limit_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "group claude code only feature gate",
|
||||
errType: "permission_error",
|
||||
message: "This group is restricted to Claude Code clients (/v1/messages only)",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "group image generation feature gate",
|
||||
errType: "permission_error",
|
||||
message: "Image generation is not enabled for this group",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "route token counting platform unsupported",
|
||||
errType: "not_found_error",
|
||||
message: "Token counting is not supported for this platform",
|
||||
code: "",
|
||||
status: http.StatusNotFound,
|
||||
wantErrType: "not_found_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "route images API platform unsupported",
|
||||
errType: "not_found_error",
|
||||
message: "Images API is not supported for this platform",
|
||||
code: "",
|
||||
status: http.StatusNotFound,
|
||||
wantErrType: "not_found_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "antigravity model whitelist feature gate",
|
||||
errType: "permission_error",
|
||||
message: "model claude-3-5-sonnet not in whitelist",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "google antigravity model whitelist feature gate",
|
||||
errType: "api_error",
|
||||
message: "model gemini-2.5-pro not in whitelist",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "claude beta policy block",
|
||||
errType: "invalid_request_error",
|
||||
message: "beta feature interleaved-thinking-2025-05-14 is not allowed",
|
||||
code: "",
|
||||
status: http.StatusBadRequest,
|
||||
wantErrType: "invalid_request_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "openai fast policy block",
|
||||
errType: "permission_error",
|
||||
message: "openai service_tier=priority is not allowed for model gpt-5.5",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "api_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "codex official client policy block",
|
||||
errType: "forbidden_error",
|
||||
message: "This account only allows Codex official clients",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "forbidden_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "openai wsv1 unsupported feature gate",
|
||||
errType: "invalid_request_error",
|
||||
message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
|
||||
code: "",
|
||||
status: http.StatusBadRequest,
|
||||
wantErrType: "invalid_request_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "openai passthrough instructions policy block",
|
||||
errType: "forbidden_error",
|
||||
message: "OpenAI codex passthrough requires a non-empty instructions field",
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "forbidden_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@ -479,6 +642,22 @@ func TestClassifyOpsIPRestrictionAccessDeniedExcludedFromSLA(t *testing.T) {
|
||||
require.Equal(t, "client_request", errorSource)
|
||||
}
|
||||
|
||||
func TestClassifyOpsClientBusinessLimitedMarkerExcludesCustomPolicyDenialFromSLA(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
|
||||
errType := normalizeOpsErrorType("invalid_request_error", "")
|
||||
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "custom admin policy message", "", http.StatusBadRequest)
|
||||
|
||||
require.Equal(t, "invalid_request_error", errType)
|
||||
require.Equal(t, "auth", phase)
|
||||
require.True(t, isBusinessLimited)
|
||||
require.Equal(t, "client", errorOwner)
|
||||
require.Equal(t, "client_request", errorSource)
|
||||
}
|
||||
|
||||
func TestClassifyOpsOtherErrorsStillCountForSLA(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
@ -583,6 +762,78 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
|
||||
code: "API_KEY_QUOTA_EXHAUSTED",
|
||||
status: http.StatusTooManyRequests,
|
||||
},
|
||||
{
|
||||
name: "provider deleted group shaped error",
|
||||
message: "API Key 所属分组已删除",
|
||||
code: "GROUP_DELETED",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider unassigned group shaped error",
|
||||
message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider local quota shaped error",
|
||||
message: "Daily usage quota exhausted for this platform.",
|
||||
code: "rate_limit_exceeded",
|
||||
status: http.StatusTooManyRequests,
|
||||
},
|
||||
{
|
||||
name: "provider feature gate shaped error",
|
||||
message: "Image generation is not enabled for this group",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider token counting unsupported shaped error",
|
||||
message: "Token counting is not supported for this platform",
|
||||
code: "404",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "provider image API unsupported shaped error",
|
||||
message: "Images API is not supported for this platform",
|
||||
code: "404",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "provider antigravity whitelist shaped error",
|
||||
message: "model claude-3-5-sonnet not in whitelist",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider beta policy shaped error",
|
||||
message: "beta feature interleaved-thinking-2025-05-14 is not allowed",
|
||||
code: "400",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "provider openai fast policy shaped error",
|
||||
message: "openai service_tier=priority is not allowed for model gpt-5.5",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider codex client policy shaped error",
|
||||
message: "This account only allows Codex official clients",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider wsv1 unsupported shaped error",
|
||||
message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
|
||||
code: "400",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "provider passthrough instructions shaped error",
|
||||
message: "OpenAI codex passthrough requires a non-empty instructions field",
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@ -628,6 +879,14 @@ func TestClassifyOpsUpstreamNoAvailableTextStillCountsForSLA(t *testing.T) {
|
||||
require.Equal(t, "upstream_http", errorSource)
|
||||
}
|
||||
|
||||
func TestParseOpsErrorResponsePreservesNestedStringCode(t *testing.T) {
|
||||
parsed := parseOpsErrorResponse([]byte(`{"error":{"type":"permission_error","code":"GROUP_DELETED","message":"API Key 所属分组已删除"}}`))
|
||||
|
||||
require.Equal(t, "permission_error", parsed.ErrorType)
|
||||
require.Equal(t, "GROUP_DELETED", parsed.Code)
|
||||
require.Equal(t, "API Key 所属分组已删除", parsed.Message)
|
||||
}
|
||||
|
||||
func TestSetOpsEndpointContext_SetsContextKeys(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user