diff --git a/core/go.mod b/core/go.mod index ebc00685d0..9d7a62041c 100644 --- a/core/go.mod +++ b/core/go.mod @@ -26,7 +26,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/valyala/fasthttp v1.68.0 go.starlark.net v0.0.0-20260102030733-3fee463870c9 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/text v0.35.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/core/go.sum b/core/go.sum index 8a1162b393..1b9dd85bea 100644 --- a/core/go.sum +++ b/core/go.sum @@ -164,8 +164,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index f50eed4210..b58cdabe65 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -180,7 +180,9 @@ const ( BifrostContextKeyGovernanceTeamName BifrostContextKey = "bifrost-governance-team-name" // string (to store the team name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceCustomerID BifrostContextKey = "bifrost-governance-customer-id" // string (to store the customer ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceCustomerName BifrostContextKey = "bifrost-governance-customer-name" // string (to store the customer name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceUserID BifrostContextKey = "bifrost-governance-user-id" // string (to store the user ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceUserID BifrostContextKey = "bifrost-governance-user-id" // string (to store the user ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceBusinessUnitID BifrostContextKey = "bifrost-governance-business-unit-id" // string (to store the business unit ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceBusinessUnitName BifrostContextKey = "bifrost-governance-business-unit-name" // string (to store the business unit name (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceRoutingRuleID BifrostContextKey = "bifrost-governance-routing-rule-id" // string (to store the routing rule ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceRoutingRuleName BifrostContextKey = "bifrost-governance-routing-rule-name" // string (to store the routing rule name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceIncludeOnlyKeys BifrostContextKey = "bf-governance-include-only-keys" // []string (to store the include-only key IDs for provider config routing (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 9ac8b740cc..5bc7d64e7b 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -134124,61 +134124,133 @@ } } }, - "/api/session/login": { - "post": { - "operationId": "login", - "summary": "Login", - "description": "Authenticates a user and returns a session token.\nSets a cookie with the session token for subsequent requests.\n", + "/api/users": { + "get": { + "operationId": "listUsers", + "summary": "List users", + "description": "Returns a paginated list of users with optional search.", "tags": [ - "Session" + "Users" ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "Login request", - "required": [ - "username", - "password" - ], - "properties": { - "username": { - "type": "string" - }, - "password": { - "type": "string" - } - } - } + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number (1-based)", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of users per page (max 100)", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "search", + "in": "query", + "description": "Search by name or email", + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Login successful", + "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", - "description": "Login response", "properties": { - "message": { - "type": "string", - "example": "Login successful" + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique user identifier" + }, + "name": { + "type": "string", + "description": "User's display name" + }, + "email": { + "type": "string", + "format": "email", + "description": "User's email address" + }, + "role_id": { + "type": "integer", + "nullable": true, + "description": "ID of the assigned RBAC role" + }, + "role": { + "type": "object", + "nullable": true, + "description": "RBAC role details", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "is_system_role": { + "type": "boolean" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } }, - "token": { - "type": "string", - "description": "Session token" + "total": { + "type": "integer", + "description": "Total number of users matching the query" + }, + "page": { + "type": "integer", + "description": "Current page number" + }, + "limit": { + "type": "integer", + "description": "Number of users per page" + }, + "total_pages": { + "type": "integer", + "description": "Total number of pages" + }, + "has_more": { + "type": "boolean", + "description": "Whether more pages are available" } } } } } }, - "400": { - "description": "Bad request", + "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { @@ -134260,9 +134332,110 @@ } } } + } + } + }, + "post": { + "operationId": "createUser", + "summary": "Create user", + "description": "Manually creates a new user in the organization.", + "tags": [ + "Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string", + "description": "User's display name" + }, + "email": { + "type": "string", + "format": "email", + "description": "User's email address (must be unique)" + }, + "role_id": { + "type": "integer", + "description": "Optional RBAC role ID to assign" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique user identifier" + }, + "name": { + "type": "string", + "description": "User's display name" + }, + "email": { + "type": "string", + "format": "email", + "description": "User's email address" + }, + "role_id": { + "type": "integer", + "nullable": true, + "description": "ID of the assigned RBAC role" + }, + "role": { + "type": "object", + "nullable": true, + "description": "RBAC role details", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "is_system_role": { + "type": "boolean" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } }, - "401": { - "description": "Invalid credentials", + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -134345,13 +134518,13 @@ } } }, - "403": { - "description": "Authentication is not enabled", + "409": { + "description": "User with this email already exists", "content": { "application/json": { "schema": { "type": "object", - "description": "Error response from Bifrost", + "description": "Error response", "properties": { "event_id": { "type": "string" @@ -134516,39 +134689,49 @@ } } }, - "/api/session/logout": { - "post": { - "operationId": "logout", - "summary": "Logout", - "description": "Logs out the current user and invalidates the session token.", + "/api/users/{id}": { + "delete": { + "operationId": "deleteUser", + "summary": "Delete user", + "description": "Permanently removes a user from the organization. This cascades to delete the user's governance settings (budget/rate limits), team memberships, access profiles, and OIDC sessions. Cannot delete yourself.\n", "tags": [ - "Session" + "Users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "User ID", + "schema": { + "type": "string" + } + } ], "responses": { "200": { - "description": "Logout successful", + "description": "User deleted successfully", "content": { "application/json": { "schema": { "type": "object", - "description": "Logout response", + "description": "Simple message response", "properties": { "message": { - "type": "string", - "example": "Logout successful" + "type": "string" } } } } } }, - "403": { - "description": "Authentication is not enabled", + "400": { + "description": "Bad request (e.g. cannot delete yourself)", "content": { "application/json": { "schema": { "type": "object", - "description": "Error response from Bifrost", + "description": "Error response", "properties": { "event_id": { "type": "string" @@ -134625,45 +134808,3809 @@ } } } - } - } - } - }, - "/api/session/is-auth-enabled": { - "get": { - "operationId": "isAuthEnabled", - "summary": "Check if authentication is enabled", - "description": "Returns whether authentication is enabled and if the current token is valid.", - "tags": [ - "Session" - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "Auth enabled status response", - "properties": { - "is_auth_enabled": { - "type": "boolean" - }, - "has_valid_token": { - "type": "boolean" - } - } - } - } - } }, - "500": { - "description": "Internal server error", + "404": { + "description": "User not found", "content": { "application/json": { "schema": { "type": "object", - "description": "Error response from Bifrost", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/users/me/permissions": { + "get": { + "operationId": "getCurrentUserPermissions", + "summary": "Get current user permissions", + "description": "Returns the RBAC permissions for the authenticated user. When SCIM is not enabled, returns full permissions for all resources. Otherwise returns the permissions associated with the user's assigned role.\n", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permissions": { + "type": "object", + "description": "Map of resource names to their permitted operations. When SCIM is disabled, returns full permissions for all resources.\n", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized (user not authenticated)", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/users/{id}/role": { + "put": { + "operationId": "assignUserRole", + "summary": "Assign role to user", + "description": "Assigns an RBAC role to a user. This also auto-assigns the default access profile for the new role and reloads the RBAC permission cache.\n", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "User ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "role_id" + ], + "properties": { + "role_id": { + "type": "integer", + "description": "ID of the RBAC role to assign" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Role assigned successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Simple message response", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "User or role not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/users/{id}/teams": { + "get": { + "operationId": "getUserTeams", + "summary": "Get user's teams", + "description": "Returns the list of teams a user belongs to, including the membership source.", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "User ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Team ID" + }, + "name": { + "type": "string", + "description": "Team name" + }, + "source": { + "type": "string", + "description": "How the user was added to this team (e.g. \"manual\", \"scim_sync\")" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "updateUserTeams", + "summary": "Update user's team assignments", + "description": "Replaces the user's manual team assignments. Synced team memberships (from SCIM providers) are preserved and cannot be removed via this endpoint.\n", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "User ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "team_ids" + ], + "properties": { + "team_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of team IDs to assign (replaces existing manual assignments; synced memberships are preserved)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Teams updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Simple message response", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad request (e.g. team not found)", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/teams": { + "get": { + "operationId": "listTeams", + "summary": "List teams", + "description": "Returns a paginated list of teams with optional search.", + "tags": [ + "Teams" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number (1-based)", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of teams per page (max 100)", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "search", + "in": "query", + "description": "Search by team name", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Team ID (derived from name)" + }, + "name": { + "type": "string", + "description": "Team name" + }, + "member_count": { + "type": "integer", + "description": "Number of members in the team" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "createTeam", + "summary": "Create team", + "description": "Creates a new team. The team ID is derived from the name.", + "tags": [ + "Teams" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Team name (must be unique)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Team created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "Team with this name already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/teams/{id}": { + "get": { + "operationId": "getTeam", + "summary": "Get team", + "description": "Returns details of a specific team including member count.", + "tags": [ + "Teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Team ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Team ID (derived from name)" + }, + "name": { + "type": "string", + "description": "Team name" + }, + "member_count": { + "type": "integer", + "description": "Number of members in the team" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "updateTeam", + "summary": "Update team", + "description": "Updates a team. Note that renaming teams is not allowed.", + "tags": [ + "Teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Team ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Team name (must be unique)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Team updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad request (e.g. renaming not allowed)", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteTeam", + "summary": "Delete team", + "description": "Permanently removes a team.", + "tags": [ + "Teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Team ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Team deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Simple message response", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/teams/{id}/members": { + "get": { + "operationId": "getTeamMembers", + "summary": "List team members", + "description": "Returns all members of a team with their user details and membership source.", + "tags": [ + "Teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Team ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + }, + "user_email": { + "type": "string" + }, + "source": { + "type": "string", + "description": "How the member was added (e.g. \"manual\", \"scim_sync\")" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "addTeamMember", + "summary": "Add team member", + "description": "Adds a user to a team. Both the team and user must exist.", + "tags": [ + "Teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Team ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "description": "ID of the user to add to the team" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Member added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Simple message response", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Team or user not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "409": { + "description": "User is already a member of this team", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/teams/{id}/members/{userId}": { + "delete": { + "operationId": "removeTeamMember", + "summary": "Remove team member", + "description": "Removes a user from a team.", + "tags": [ + "Teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Team ID", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "User ID to remove", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Member removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Simple message response", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Membership not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/session/login": { + "post": { + "operationId": "login", + "summary": "Login", + "description": "Authenticates a user and returns a session token.\nSets a cookie with the session token for subsequent requests.\n", + "tags": [ + "Session" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Login request", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Login response", + "properties": { + "message": { + "type": "string", + "example": "Login successful" + }, + "token": { + "type": "string", + "description": "Session token" + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Invalid credentials", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Authentication is not enabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/session/logout": { + "post": { + "operationId": "logout", + "summary": "Logout", + "description": "Logs out the current user and invalidates the session token.", + "tags": [ + "Session" + ], + "responses": { + "200": { + "description": "Logout successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Logout response", + "properties": { + "message": { + "type": "string", + "example": "Logout successful" + } + } + } + } + } + }, + "403": { + "description": "Authentication is not enabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", + "properties": { + "event_id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "is_bifrost_error": { + "type": "boolean" + }, + "status_code": { + "type": "integer" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "param": { + "type": "string" + }, + "event_id": { + "type": "string" + } + } + }, + "extra_fields": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "AI model provider identifier", + "enum": [ + "openai", + "azure", + "anthropic", + "bedrock", + "cohere", + "vertex", + "vllm", + "mistral", + "ollama", + "groq", + "sgl", + "parasail", + "perplexity", + "replicate", + "cerebras", + "gemini", + "openrouter", + "elevenlabs", + "huggingface", + "nebius", + "xai", + "runway" + ] + }, + "model_requested": { + "type": "string" + }, + "request_type": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/api/session/is-auth-enabled": { + "get": { + "operationId": "isAuthEnabled", + "summary": "Check if authentication is enabled", + "description": "Returns whether authentication is enabled and if the current token is valid.", + "tags": [ + "Session" + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Auth enabled status response", + "properties": { + "is_auth_enabled": { + "type": "boolean" + }, + "has_valid_token": { + "type": "boolean" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Error response from Bifrost", "properties": { "event_id": { "type": "string" @@ -138008,6 +141955,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -138027,12 +141985,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -138165,12 +142117,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -138253,12 +142199,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -138283,18 +142223,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -138370,6 +142298,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -138722,6 +142660,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -138741,12 +142690,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -138879,12 +142822,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -138967,12 +142904,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -138997,18 +142928,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -139084,6 +143003,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -139159,6 +143088,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -139178,12 +143118,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -139316,12 +143250,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -139404,12 +143332,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -139434,18 +143356,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -139521,6 +143431,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -139963,6 +143883,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -139982,12 +143913,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -140120,12 +144045,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -140208,12 +144127,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -140238,18 +144151,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -140325,6 +144226,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -140680,6 +144591,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -140699,12 +144621,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -140837,12 +144753,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -140925,12 +144835,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -140955,18 +144859,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -141042,6 +144934,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -141117,6 +145019,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -141136,12 +145049,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -141274,12 +145181,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -141362,12 +145263,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -141392,18 +145287,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -141479,6 +145362,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -141835,6 +145728,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -141854,12 +145758,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -141992,12 +145890,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -142080,12 +145972,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -142110,18 +145996,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -142197,6 +146071,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -142537,6 +146421,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -142556,12 +146451,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -142694,12 +146583,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -142782,12 +146665,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -142812,18 +146689,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -142899,6 +146764,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -146379,7 +150254,8 @@ "enum": [ "none", "headers", - "oauth" + "oauth", + "per_user_oauth" ], "description": "Authentication type for the MCP connection" }, @@ -146626,7 +150502,8 @@ "enum": [ "none", "headers", - "oauth" + "oauth", + "per_user_oauth" ], "description": "Authentication type for the MCP connection" }, @@ -146755,7 +150632,8 @@ "enum": [ "none", "headers", - "oauth" + "oauth", + "per_user_oauth" ], "description": "Authentication type for the MCP connection" }, @@ -146884,7 +150762,8 @@ "enum": [ "none", "headers", - "oauth" + "oauth", + "per_user_oauth" ], "description": "Authentication type for the MCP connection" }, @@ -147282,7 +151161,8 @@ "enum": [ "none", "headers", - "oauth" + "oauth", + "per_user_oauth" ], "description": "Authentication type for the MCP connection" }, @@ -175715,6 +179595,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -175734,12 +179625,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -175872,12 +179757,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -175960,12 +179839,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -175990,18 +179863,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -176077,6 +179938,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -202456,6 +206327,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -202475,12 +206357,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -202613,12 +206489,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -202701,12 +206571,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -202731,18 +206595,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -202818,6 +206670,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -202891,6 +206753,17 @@ "type": "number", "description": "Weight for load balancing" }, + "aliases": { + "type": "object", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Model alias mappings — maps a user-facing model name to a provider-specific identifier (deployment name, inference profile ID, fine-tuned model ID, etc.)" + }, "azure_key_config": { "type": "object", "description": "Azure-specific key configuration", @@ -202910,12 +206783,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "api_version": { "type": "object", "description": "Environment variable configuration", @@ -203048,12 +206915,6 @@ "type": "boolean" } } - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } } } }, @@ -203136,12 +206997,6 @@ } } }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "batch_s3_config": { "type": "object", "properties": { @@ -203166,18 +207021,6 @@ } } }, - "replicate_key_config": { - "type": "object", - "description": "Replicate-specific key configuration", - "properties": { - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, "vllm_key_config": { "type": "object", "description": "VLLM-specific key configuration", @@ -203253,6 +207096,16 @@ "url" ] }, + "replicate_key_config": { + "type": "object", + "description": "Replicate-specific key configuration", + "properties": { + "use_deployments_endpoint": { + "type": "boolean", + "description": "Whether to use the deployments endpoint instead of the models endpoint" + } + } + }, "enabled": { "type": "boolean", "description": "Whether the key is active (defaults to true)" @@ -203689,7 +207542,8 @@ "enum": [ "none", "headers", - "oauth" + "oauth", + "per_user_oauth" ], "description": "Authentication type for the MCP connection" }, @@ -203850,7 +207704,8 @@ "enum": [ "none", "headers", - "oauth" + "oauth", + "per_user_oauth" ], "description": "Authentication type for the MCP connection" }, diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 8a816a3cc5..0e17c36c92 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -553,6 +553,22 @@ paths: $ref: './paths/management/users.yaml#/users' /api/users/{id}: $ref: './paths/management/users.yaml#/users-by-id' + /api/users/me/permissions: + $ref: './paths/management/users.yaml#/users-me-permissions' + /api/users/{id}/role: + $ref: './paths/management/users.yaml#/users-role' + /api/users/{id}/teams: + $ref: './paths/management/users.yaml#/users-teams' + + # Teams + /api/teams: + $ref: './paths/management/users.yaml#/teams' + /api/teams/{id}: + $ref: './paths/management/users.yaml#/teams-by-id' + /api/teams/{id}/members: + $ref: './paths/management/users.yaml#/team-members' + /api/teams/{id}/members/{userId}: + $ref: './paths/management/users.yaml#/team-member-by-id' # Session /api/session/login: diff --git a/docs/openapi/paths/management/users.yaml b/docs/openapi/paths/management/users.yaml index 5c6df756e6..5de0e36fdd 100644 --- a/docs/openapi/paths/management/users.yaml +++ b/docs/openapi/paths/management/users.yaml @@ -104,3 +104,431 @@ users-by-id: $ref: '../../schemas/management/common.yaml#/ErrorResponse' '500': $ref: '../../openapi.yaml#/components/responses/InternalError' + +users-me-permissions: + get: + operationId: getCurrentUserPermissions + summary: Get current user permissions + description: > + Returns the RBAC permissions for the authenticated user. When SCIM is not + enabled, returns full permissions for all resources. Otherwise returns the + permissions associated with the user's assigned role. + tags: + - Users + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/PermissionsResponse' + '401': + description: Unauthorized (user not authenticated) + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + +users-role: + put: + operationId: assignUserRole + summary: Assign role to user + description: > + Assigns an RBAC role to a user. This also auto-assigns the default + access profile for the new role and reloads the RBAC permission cache. + tags: + - Users + parameters: + - name: id + in: path + required: true + description: User ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/AssignUserRoleRequest' + responses: + '200': + description: Role assigned successfully + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/MessageResponse' + '400': + $ref: '../../openapi.yaml#/components/responses/BadRequest' + '404': + description: User or role not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + +users-teams: + get: + operationId: getUserTeams + summary: Get user's teams + description: Returns the list of teams a user belongs to, including the membership source. + tags: + - Users + parameters: + - name: id + in: path + required: true + description: User ID + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/UserTeamsResponse' + '400': + $ref: '../../openapi.yaml#/components/responses/BadRequest' + '404': + description: User not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + + put: + operationId: updateUserTeams + summary: Update user's team assignments + description: > + Replaces the user's manual team assignments. Synced team memberships + (from SCIM providers) are preserved and cannot be removed via this endpoint. + tags: + - Users + parameters: + - name: id + in: path + required: true + description: User ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/UpdateUserTeamsRequest' + responses: + '200': + description: Teams updated successfully + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/MessageResponse' + '400': + description: Bad request (e.g. team not found) + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + +# ---- Teams ---- + +teams: + get: + operationId: listTeams + summary: List teams + description: Returns a paginated list of teams with optional search. + tags: + - Teams + parameters: + - name: page + in: query + description: Page number (1-based) + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + description: Number of teams per page (max 100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: search + in: query + description: Search by team name + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/ListTeamsResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + + post: + operationId: createTeam + summary: Create team + description: Creates a new team. The team ID is derived from the name. + tags: + - Teams + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/CreateTeamRequest' + responses: + '200': + description: Team created successfully + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/CreateTeamResponse' + '400': + $ref: '../../openapi.yaml#/components/responses/BadRequest' + '409': + description: Team with this name already exists + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + +teams-by-id: + get: + operationId: getTeam + summary: Get team + description: Returns details of a specific team including member count. + tags: + - Teams + parameters: + - name: id + in: path + required: true + description: Team ID + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/TeamObject' + '404': + description: Team not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + + put: + operationId: updateTeam + summary: Update team + description: Updates a team. Note that renaming teams is not allowed. + tags: + - Teams + parameters: + - name: id + in: path + required: true + description: Team ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/UpdateTeamRequest' + responses: + '200': + description: Team updated successfully + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/CreateTeamResponse' + '400': + description: Bad request (e.g. renaming not allowed) + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '404': + description: Team not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + + delete: + operationId: deleteTeam + summary: Delete team + description: Permanently removes a team. + tags: + - Teams + parameters: + - name: id + in: path + required: true + description: Team ID + schema: + type: string + responses: + '200': + description: Team deleted successfully + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/MessageResponse' + '404': + description: Team not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + +# ---- Team Members ---- + +team-members: + get: + operationId: getTeamMembers + summary: List team members + description: Returns all members of a team with their user details and membership source. + tags: + - Teams + parameters: + - name: id + in: path + required: true + description: Team ID + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/TeamMembersResponse' + '400': + $ref: '../../openapi.yaml#/components/responses/BadRequest' + '404': + description: Team not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + + post: + operationId: addTeamMember + summary: Add team member + description: Adds a user to a team. Both the team and user must exist. + tags: + - Teams + parameters: + - name: id + in: path + required: true + description: Team ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/AddTeamMemberRequest' + responses: + '200': + description: Member added successfully + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/MessageResponse' + '404': + description: Team or user not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '409': + description: User is already a member of this team + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + +team-member-by-id: + delete: + operationId: removeTeamMember + summary: Remove team member + description: Removes a user from a team. + tags: + - Teams + parameters: + - name: id + in: path + required: true + description: Team ID + schema: + type: string + - name: userId + in: path + required: true + description: User ID to remove + schema: + type: string + responses: + '200': + description: Member removed successfully + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/MessageResponse' + '404': + description: Membership not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' diff --git a/docs/openapi/schemas/management/users.yaml b/docs/openapi/schemas/management/users.yaml index e57c550ad5..46db148f3e 100644 --- a/docs/openapi/schemas/management/users.yaml +++ b/docs/openapi/schemas/management/users.yaml @@ -80,3 +80,160 @@ ListUsersResponse: has_more: type: boolean description: Whether more pages are available + +# ---- User Permissions ---- + +PermissionsResponse: + type: object + properties: + permissions: + type: object + description: > + Map of resource names to their permitted operations. + When SCIM is disabled, returns full permissions for all resources. + additionalProperties: + type: object + additionalProperties: + type: boolean + +# ---- User Role ---- + +AssignUserRoleRequest: + type: object + required: + - role_id + properties: + role_id: + type: integer + description: ID of the RBAC role to assign + +# ---- User Teams ---- + +UserTeamEntry: + type: object + properties: + id: + type: string + description: Team ID + name: + type: string + description: Team name + source: + type: string + description: How the user was added to this team (e.g. "manual", "scim_sync") + +UserTeamsResponse: + type: object + properties: + teams: + type: array + items: + $ref: '#/UserTeamEntry' + +UpdateUserTeamsRequest: + type: object + required: + - team_ids + properties: + team_ids: + type: array + items: + type: string + description: List of team IDs to assign (replaces existing manual assignments; synced memberships are preserved) + +# ---- Teams ---- + +TeamObject: + type: object + properties: + id: + type: string + description: Team ID (derived from name) + name: + type: string + description: Team name + member_count: + type: integer + description: Number of members in the team + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + +CreateTeamRequest: + type: object + required: + - name + properties: + name: + type: string + description: Team name (must be unique) + +UpdateTeamRequest: + type: object + properties: + description: + type: string + description: Updated team description + +CreateTeamResponse: + type: object + properties: + id: + type: string + name: + type: string + +ListTeamsResponse: + type: object + properties: + teams: + type: array + items: + $ref: '#/TeamObject' + total: + type: integer + page: + type: integer + limit: + type: integer + total_pages: + type: integer + description: Total number of pages + has_more: + type: boolean + description: Whether more pages are available + +# ---- Team Members ---- + +TeamMemberObject: + type: object + properties: + user_id: + type: string + user_name: + type: string + user_email: + type: string + source: + type: string + description: How the member was added (e.g. "manual", "scim_sync") + +TeamMembersResponse: + type: object + properties: + members: + type: array + items: + $ref: '#/TeamMemberObject' + +AddTeamMemberRequest: + type: object + required: + - user_id + properties: + user_id: + type: string + description: ID of the user to add to the team diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index bc1c4bfbd2..ad09cf640d 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -3344,6 +3344,7 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo var modelConfigs []tables.TableModelConfig var providers []tables.TableProvider var routingRules []tables.TableRoutingRule + var pricingOverrides []tables.TablePricingOverride var governanceConfigs []tables.TableGovernanceConfig if err := s.db.WithContext(ctx). @@ -3375,12 +3376,15 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo if err := s.loadRoutingRulesOrdered(ctx, &routingRules); err != nil { return nil, err } + if err := s.db.WithContext(ctx).Find(&pricingOverrides).Error; err != nil { + return nil, err + } // Fetching governance config for username and password if err := s.db.WithContext(ctx).Find(&governanceConfigs).Error; err != nil { return nil, err } // Check if any config is present - if len(virtualKeys) == 0 && len(teams) == 0 && len(customers) == 0 && len(budgets) == 0 && len(rateLimits) == 0 && len(modelConfigs) == 0 && len(providers) == 0 && len(governanceConfigs) == 0 && len(routingRules) == 0 { + if len(virtualKeys) == 0 && len(teams) == 0 && len(customers) == 0 && len(budgets) == 0 && len(rateLimits) == 0 && len(modelConfigs) == 0 && len(providers) == 0 && len(governanceConfigs) == 0 && len(routingRules) == 0 && len(pricingOverrides) == 0 { return nil, nil } var authConfig *AuthConfig @@ -3412,15 +3416,16 @@ func (s *RDBConfigStore) GetGovernanceConfig(ctx context.Context) (*GovernanceCo } } return &GovernanceConfig{ - VirtualKeys: virtualKeys, - Teams: teams, - Customers: customers, - Budgets: budgets, - RateLimits: rateLimits, - ModelConfigs: modelConfigs, - Providers: providers, - RoutingRules: routingRules, - AuthConfig: authConfig, + VirtualKeys: virtualKeys, + Teams: teams, + Customers: customers, + Budgets: budgets, + RateLimits: rateLimits, + ModelConfigs: modelConfigs, + Providers: providers, + RoutingRules: routingRules, + PricingOverrides: pricingOverrides, + AuthConfig: authConfig, }, nil } diff --git a/framework/configstore/tables/virtualkey.go b/framework/configstore/tables/virtualkey.go index 8c70d2e4bf..fb603202eb 100644 --- a/framework/configstore/tables/virtualkey.go +++ b/framework/configstore/tables/virtualkey.go @@ -30,12 +30,12 @@ type TableVirtualKeyProviderConfig struct { Weight *float64 `json:"weight"` AllowedModels schemas.WhiteList `gorm:"type:text;serializer:json" json:"allowed_models"` // ["*"] allows all models; empty denies all (deny-by-default) AllowAllKeys bool `gorm:"default:false" json:"allow_all_keys"` // True means all keys allowed; false with empty Keys means no keys allowed (deny-by-default) - RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` + RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` // Relationships RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` - Budgets []TableBudget `gorm:"foreignKey:ProviderConfigID;constraint:OnDelete:CASCADE" json:"budgets,omitempty"` // Multiple budgets with different reset intervals - Keys []TableKey `gorm:"many2many:governance_virtual_key_provider_config_keys;constraint:OnDelete:CASCADE" json:"keys"` // Empty means all keys allowed for this provider + Budgets []TableBudget `gorm:"foreignKey:ProviderConfigID;constraint:OnDelete:CASCADE" json:"budgets,omitempty"` // Multiple budgets with different reset intervals + Keys []TableKey `gorm:"many2many:governance_virtual_key_provider_config_keys;constraint:OnDelete:CASCADE" json:"keys"` // Empty means all keys allowed for this provider } // TableName sets the table name for each model diff --git a/framework/go.mod b/framework/go.mod index 8744833042..19521ad141 100644 --- a/framework/go.mod +++ b/framework/go.mod @@ -123,7 +123,7 @@ require ( go.mongodb.org/mongo-driver v1.17.6 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect diff --git a/framework/go.sum b/framework/go.sum index 9dd2ada774..fc7876e180 100644 --- a/framework/go.sum +++ b/framework/go.sum @@ -285,8 +285,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/framework/logstore/matviews.go b/framework/logstore/matviews.go index 0ccc4a8f28..02126a4e38 100644 --- a/framework/logstore/matviews.go +++ b/framework/logstore/matviews.go @@ -5,6 +5,7 @@ import ( "fmt" "sort" "strings" + "sync/atomic" "time" "github.com/maximhq/bifrost/core/schemas" @@ -30,6 +31,10 @@ SELECT selected_key_id, COALESCE(virtual_key_id, '') AS virtual_key_id, COALESCE(routing_rule_id, '') AS routing_rule_id, + COALESCE(user_id, '') AS user_id, + COALESCE(team_id, '') AS team_id, + COALESCE(customer_id, '') AS customer_id, + COALESCE(business_unit_id, '') AS business_unit_id, COUNT(*) AS count, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count, @@ -44,13 +49,13 @@ SELECT COALESCE(SUM(cost), 0) AS total_cost FROM logs WHERE status IN ('success', 'error') -GROUP BY 1, 2, 3, 4, 5, 6, 7, 8 +GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ` // mvLogsHourlyUniqueIdx is required for REFRESH MATERIALIZED VIEW CONCURRENTLY. const mvLogsHourlyUniqueIdx = ` CREATE UNIQUE INDEX IF NOT EXISTS mv_logs_hourly_uniq -ON mv_logs_hourly (hour, provider, model, status, object_type, selected_key_id, virtual_key_id, routing_rule_id) +ON mv_logs_hourly (hour, provider, model, status, object_type, selected_key_id, virtual_key_id, routing_rule_id, user_id, team_id, customer_id, business_unit_id) ` // mvLogsFilterdataDDL creates a materialized view of distinct filter values @@ -67,7 +72,14 @@ SELECT DISTINCT COALESCE(virtual_key_name, '') AS virtual_key_name, COALESCE(routing_rule_id, '') AS routing_rule_id, COALESCE(routing_rule_name, '') AS routing_rule_name, - COALESCE(routing_engines_used, '') AS routing_engines_used + COALESCE(routing_engines_used, '') AS routing_engines_used, + COALESCE(user_id, '') AS user_id, + COALESCE(team_id, '') AS team_id, + COALESCE(team_name, '') AS team_name, + COALESCE(customer_id, '') AS customer_id, + COALESCE(customer_name, '') AS customer_name, + COALESCE(business_unit_id, '') AS business_unit_id, + COALESCE(business_unit_name, '') AS business_unit_name FROM logs WHERE timestamp >= NOW() - INTERVAL '60 days' AND model IS NOT NULL AND model != '' @@ -77,7 +89,7 @@ WHERE timestamp >= NOW() - INTERVAL '60 days' // Includes both ID and name columns so renamed keys don't cause duplicate violations. const mvLogsFilterdataUniqueIdx = ` CREATE UNIQUE INDEX IF NOT EXISTS mv_logs_filterdata_uniq -ON mv_logs_filterdata (model, provider, selected_key_id, selected_key_name, virtual_key_id, virtual_key_name, routing_rule_id, routing_rule_name, routing_engines_used) +ON mv_logs_filterdata (model, provider, selected_key_id, selected_key_name, virtual_key_id, virtual_key_name, routing_rule_id, routing_rule_name, routing_engines_used, user_id, team_id, team_name, customer_id, customer_name, business_unit_id, business_unit_name) ` // --------------------------------------------------------------------------- @@ -138,8 +150,10 @@ func refreshMatViews(ctx context.Context, db *gorm.DB) error { } // startMatViewRefresher launches a background goroutine that periodically -// refreshes materialized views. Returns a stop function for graceful shutdown. -func startMatViewRefresher(ctx context.Context, db *gorm.DB, interval time.Duration, logger schemas.Logger) func() { +// refreshes materialized views. If readyFlag is provided and not yet true, +// it will be set to true on the first successful refresh (recovery path when +// the initial refresh failed). Returns a stop function for graceful shutdown. +func startMatViewRefresher(ctx context.Context, db *gorm.DB, interval time.Duration, logger schemas.Logger, readyFlag *atomic.Bool) func() { stopCh := make(chan struct{}) go func() { ticker := time.NewTicker(interval) @@ -149,6 +163,9 @@ func startMatViewRefresher(ctx context.Context, db *gorm.DB, interval time.Durat case <-ticker.C: if err := refreshMatViews(ctx, db); err != nil { logger.Warn(fmt.Sprintf("logstore: matview refresh failed: %s", err)) + } else if readyFlag != nil && !readyFlag.Load() { + logger.Info("logstore: materialized views are ready (recovered)") + readyFlag.Store(true) } case <-ctx.Done(): return @@ -160,12 +177,11 @@ func startMatViewRefresher(ctx context.Context, db *gorm.DB, interval time.Durat return func() { close(stopCh) } } -// canUseMatView returns true if the given filters can be served from +// canUseMatViewFilters returns true if the given filters can be served from // mv_logs_hourly. Per-row filters (content search, metadata, numeric ranges) // require the raw logs table. -func canUseMatView(f SearchFilters) bool { - return f.ParentRequestID == "" && - f.ContentSearch == "" && +func canUseMatViewFilters(f SearchFilters) bool { + return f.ContentSearch == "" && len(f.MetadataFilters) == 0 && len(f.RoutingEngineUsed) == 0 && f.MinLatency == nil && f.MaxLatency == nil && @@ -174,6 +190,15 @@ func canUseMatView(f SearchFilters) bool { !f.MissingCostOnly } +// canUseMatView checks both that materialized views are ready (created and +// populated) and that the given filters are eligible for the matview path. +// This prevents queries from hitting non-existent views during the startup +// window between migration (which drops old views) and ensureMatViews (which +// recreates them asynchronously). +func (s *RDBLogStore) canUseMatView(f SearchFilters) bool { + return s.matViewsReady.Load() && canUseMatViewFilters(f) +} + // --------------------------------------------------------------------------- // Mat-view filter helpers // --------------------------------------------------------------------------- @@ -207,6 +232,18 @@ func applyMatViewFilters(q *gorm.DB, f SearchFilters) *gorm.DB { if len(f.RoutingRuleIDs) > 0 { q = q.Where("routing_rule_id IN ?", f.RoutingRuleIDs) } + if len(f.TeamIDs) > 0 { + q = q.Where("team_id IN ?", f.TeamIDs) + } + if len(f.CustomerIDs) > 0 { + q = q.Where("customer_id IN ?", f.CustomerIDs) + } + if len(f.UserIDs) > 0 { + q = q.Where("user_id IN ?", f.UserIDs) + } + if len(f.BusinessUnitIDs) > 0 { + q = q.Where("business_unit_id IN ?", f.BusinessUnitIDs) + } return q } @@ -701,6 +738,200 @@ func (s *RDBLogStore) getProviderLatencyHistogramFromMatView(ctx context.Context return &ProviderLatencyHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Providers: providers}, nil } +// --------------------------------------------------------------------------- +// Generic dimension histogram queries (cost, tokens, latency grouped by any dimension) +// --------------------------------------------------------------------------- + +// getDimensionCostHistogramFromMatView returns time-bucketed cost data grouped by +// the specified dimension column from mv_logs_hourly. +func (s *RDBLogStore) getDimensionCostHistogramFromMatView(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionCostHistogramResult, error) { + dimCol := string(dimension) + var results []struct { + BucketTimestamp int64 `gorm:"column:bucket_timestamp"` + DimValue string `gorm:"column:dim_value"` + Cost float64 `gorm:"column:cost"` + } + q := s.db.WithContext(ctx).Table("mv_logs_hourly") + q = applyMatViewFilters(q, filters) + if err := q.Select(fmt.Sprintf(` + CAST(FLOOR(EXTRACT(EPOCH FROM hour) / %d) * %d AS BIGINT) AS bucket_timestamp, + %s AS dim_value, + SUM(total_cost) AS cost + `, bucketSizeSeconds, bucketSizeSeconds, dimCol)). + Group(fmt.Sprintf("bucket_timestamp, %s", dimCol)). + Order("bucket_timestamp ASC"). + Find(&results).Error; err != nil { + return nil, err + } + + type bucketAgg struct { + totalCost float64 + byDimension map[string]float64 + } + grouped := make(map[int64]*bucketAgg) + dimSet := make(map[string]struct{}) + for _, r := range results { + a, ok := grouped[r.BucketTimestamp] + if !ok { + a = &bucketAgg{byDimension: make(map[string]float64)} + grouped[r.BucketTimestamp] = a + } + a.totalCost += r.Cost + a.byDimension[r.DimValue] += r.Cost + dimSet[r.DimValue] = struct{}{} + } + + allTimestamps := generateBucketTimestamps(filters.StartTime, filters.EndTime, bucketSizeSeconds) + buckets := make([]DimensionCostHistogramBucket, 0, len(allTimestamps)) + for _, ts := range allTimestamps { + b := DimensionCostHistogramBucket{Timestamp: time.Unix(ts, 0).UTC(), ByDimension: make(map[string]float64)} + if a, ok := grouped[ts]; ok { + b.TotalCost = a.totalCost + b.ByDimension = a.byDimension + } + buckets = append(buckets, b) + } + + dimValues := sortedStringKeys(dimSet) + return &DimensionCostHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil +} + +// getDimensionTokenHistogramFromMatView returns time-bucketed token usage grouped by +// the specified dimension column from mv_logs_hourly. +func (s *RDBLogStore) getDimensionTokenHistogramFromMatView(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionTokenHistogramResult, error) { + dimCol := string(dimension) + var results []struct { + BucketTimestamp int64 `gorm:"column:bucket_timestamp"` + DimValue string `gorm:"column:dim_value"` + PromptTokens int64 `gorm:"column:prompt_tokens"` + CompletionTokens int64 `gorm:"column:completion_tokens"` + TotalTokens int64 `gorm:"column:total_tkns"` + } + q := s.db.WithContext(ctx).Table("mv_logs_hourly") + q = applyMatViewFilters(q, filters) + if err := q.Select(fmt.Sprintf(` + CAST(FLOOR(EXTRACT(EPOCH FROM hour) / %d) * %d AS BIGINT) AS bucket_timestamp, + %s AS dim_value, + SUM(total_prompt_tokens) AS prompt_tokens, + SUM(total_completion_tokens) AS completion_tokens, + SUM(total_tokens) AS total_tkns + `, bucketSizeSeconds, bucketSizeSeconds, dimCol)). + Group(fmt.Sprintf("bucket_timestamp, %s", dimCol)). + Order("bucket_timestamp ASC"). + Find(&results).Error; err != nil { + return nil, err + } + + type dimAgg struct { + prompt, completion, total int64 + } + type bucketAgg struct { + byDimension map[string]*dimAgg + } + grouped := make(map[int64]*bucketAgg) + dimSet := make(map[string]struct{}) + for _, r := range results { + a, ok := grouped[r.BucketTimestamp] + if !ok { + a = &bucketAgg{byDimension: make(map[string]*dimAgg)} + grouped[r.BucketTimestamp] = a + } + da, ok := a.byDimension[r.DimValue] + if !ok { + da = &dimAgg{} + a.byDimension[r.DimValue] = da + } + da.prompt += r.PromptTokens + da.completion += r.CompletionTokens + da.total += r.TotalTokens + dimSet[r.DimValue] = struct{}{} + } + + allTimestamps := generateBucketTimestamps(filters.StartTime, filters.EndTime, bucketSizeSeconds) + buckets := make([]DimensionTokenHistogramBucket, 0, len(allTimestamps)) + for _, ts := range allTimestamps { + b := DimensionTokenHistogramBucket{Timestamp: time.Unix(ts, 0).UTC(), ByDimension: make(map[string]DimensionTokenStats)} + if a, ok := grouped[ts]; ok { + for dim, da := range a.byDimension { + b.ByDimension[dim] = DimensionTokenStats{ + PromptTokens: da.prompt, + CompletionTokens: da.completion, + TotalTokens: da.total, + } + } + } + buckets = append(buckets, b) + } + + dimValues := sortedStringKeys(dimSet) + return &DimensionTokenHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil +} + +// getDimensionLatencyHistogramFromMatView returns time-bucketed latency percentiles +// grouped by the specified dimension column from mv_logs_hourly. +func (s *RDBLogStore) getDimensionLatencyHistogramFromMatView(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionLatencyHistogramResult, error) { + dimCol := string(dimension) + var results []struct { + BucketTimestamp int64 `gorm:"column:bucket_timestamp"` + DimValue string `gorm:"column:dim_value"` + AvgLatency float64 `gorm:"column:avg_lat"` + P90Latency float64 `gorm:"column:p90_lat"` + P95Latency float64 `gorm:"column:p95_lat"` + P99Latency float64 `gorm:"column:p99_lat"` + TotalRequests int64 `gorm:"column:total_requests"` + } + q := s.db.WithContext(ctx).Table("mv_logs_hourly") + q = applyMatViewFilters(q, filters) + if err := q.Select(fmt.Sprintf(` + CAST(FLOOR(EXTRACT(EPOCH FROM hour) / %d) * %d AS BIGINT) AS bucket_timestamp, + %s AS dim_value, + CASE WHEN SUM(count) > 0 THEN SUM(avg_latency * count) / SUM(count) ELSE 0 END AS avg_lat, + CASE WHEN SUM(count) > 0 THEN SUM(p90_latency * count) / SUM(count) ELSE 0 END AS p90_lat, + CASE WHEN SUM(count) > 0 THEN SUM(p95_latency * count) / SUM(count) ELSE 0 END AS p95_lat, + CASE WHEN SUM(count) > 0 THEN SUM(p99_latency * count) / SUM(count) ELSE 0 END AS p99_lat, + SUM(count) AS total_requests + `, bucketSizeSeconds, bucketSizeSeconds, dimCol)). + Group(fmt.Sprintf("bucket_timestamp, %s", dimCol)). + Order("bucket_timestamp ASC"). + Find(&results).Error; err != nil { + return nil, err + } + + type bucketAgg struct { + byDimension map[string]DimensionLatencyStats + } + grouped := make(map[int64]*bucketAgg) + dimSet := make(map[string]struct{}) + for _, r := range results { + a, ok := grouped[r.BucketTimestamp] + if !ok { + a = &bucketAgg{byDimension: make(map[string]DimensionLatencyStats)} + grouped[r.BucketTimestamp] = a + } + a.byDimension[r.DimValue] = DimensionLatencyStats{ + AvgLatency: r.AvgLatency, + P90Latency: r.P90Latency, + P95Latency: r.P95Latency, + P99Latency: r.P99Latency, + TotalRequests: r.TotalRequests, + } + dimSet[r.DimValue] = struct{}{} + } + + allTimestamps := generateBucketTimestamps(filters.StartTime, filters.EndTime, bucketSizeSeconds) + buckets := make([]DimensionLatencyHistogramBucket, 0, len(allTimestamps)) + for _, ts := range allTimestamps { + b := DimensionLatencyHistogramBucket{Timestamp: time.Unix(ts, 0).UTC(), ByDimension: make(map[string]DimensionLatencyStats)} + if a, ok := grouped[ts]; ok { + b.ByDimension = a.byDimension + } + buckets = append(buckets, b) + } + + dimValues := sortedStringKeys(dimSet) + return &DimensionLatencyHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil +} + // getModelRankingsFromMatView returns models ranked by usage with trend // comparison to the previous period of equal duration from mv_logs_hourly. func (s *RDBLogStore) getModelRankingsFromMatView(ctx context.Context, filters SearchFilters) (*ModelRankingResult, error) { @@ -796,6 +1027,85 @@ func (s *RDBLogStore) getModelRankingsFromMatView(ctx context.Context, filters S return &ModelRankingResult{Rankings: rankings}, nil } +// getUserRankingsFromMatView returns users ranked by usage with trend +// comparison to the previous period of equal duration from mv_logs_hourly. +func (s *RDBLogStore) getUserRankingsFromMatView(ctx context.Context, filters SearchFilters) (*UserRankingResult, error) { + var results []struct { + UserID string `gorm:"column:user_id"` + Total int64 `gorm:"column:total"` + TotalTokens int64 `gorm:"column:total_tkns"` + TotalCost float64 `gorm:"column:total_cost"` + } + q := s.db.WithContext(ctx).Table("mv_logs_hourly") + q = applyMatViewFilters(q, filters) + q = q.Where("user_id != ''") + if err := q.Select(` + user_id, + SUM(count) AS total, + SUM(total_tokens) AS total_tkns, + SUM(total_cost) AS total_cost + `).Group("user_id"). + Order("total DESC"). + Find(&results).Error; err != nil { + return nil, err + } + + // Previous period for trend (same duration, ending just before current start) + type prevRow struct { + UserID string `gorm:"column:user_id"` + Total int64 `gorm:"column:total"` + TotalTokens int64 `gorm:"column:total_tkns"` + TotalCost float64 `gorm:"column:total_cost"` + } + var prevResults []prevRow + if filters.StartTime != nil && filters.EndTime != nil { + duration := filters.EndTime.Sub(*filters.StartTime) + prevStart := filters.StartTime.Add(-duration) + prevEnd := filters.StartTime.Add(-time.Nanosecond) + prevFilters := filters + prevFilters.StartTime = &prevStart + prevFilters.EndTime = &prevEnd + pq := s.db.WithContext(ctx).Table("mv_logs_hourly") + pq = applyMatViewFilters(pq, prevFilters) + pq = pq.Where("user_id != ''") + if err := pq.Select(` + user_id, + SUM(count) AS total, + SUM(total_tokens) AS total_tkns, + SUM(total_cost) AS total_cost + `).Group("user_id").Find(&prevResults).Error; err != nil { + return nil, fmt.Errorf("failed to get previous period user rankings: %w", err) + } + } + + prevMap := make(map[string]int, len(prevResults)) + for i, r := range prevResults { + prevMap[r.UserID] = i + } + + rankings := make([]UserRankingWithTrend, 0, len(results)) + for _, r := range results { + entry := UserRankingEntry{ + UserID: r.UserID, + TotalRequests: r.Total, + TotalTokens: r.TotalTokens, + TotalCost: r.TotalCost, + } + urt := UserRankingWithTrend{UserRankingEntry: entry} + if idx, ok := prevMap[r.UserID]; ok { + prev := prevResults[idx] + urt.Trend = UserRankingTrend{ + HasPreviousPeriod: true, + RequestsTrend: trendPct(float64(r.Total), float64(prev.Total)), + TokensTrend: trendPct(float64(r.TotalTokens), float64(prev.TotalTokens)), + CostTrend: trendPct(r.TotalCost, prev.TotalCost), + } + } + rankings = append(rankings, urt) + } + return &UserRankingResult{Rankings: rankings}, nil +} + // --------------------------------------------------------------------------- // Filterdata from mat view // --------------------------------------------------------------------------- diff --git a/framework/logstore/migrations.go b/framework/logstore/migrations.go index 244f844f9a..0082191d35 100644 --- a/framework/logstore/migrations.go +++ b/framework/logstore/migrations.go @@ -215,6 +215,12 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddAliasColumn(ctx, db); err != nil { return err } + if err := migrationAddGovernanceContextColumns(ctx, db); err != nil { + return err + } + if err := migrationRecreateMatViewsWithGovernanceColumns(ctx, db); err != nil { + return err + } return nil } @@ -2039,6 +2045,26 @@ var performanceIndexes = []performanceIndexDef{ sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_alias ON logs(alias)", }, { + table: "logs", + name: "idx_logs_team_id", + sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_team_id ON logs(team_id)", + }, + { + table: "logs", + name: "idx_logs_customer_id", + sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_customer_id ON logs(customer_id)", + }, + { + table: "logs", + name: "idx_logs_user_id", + sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_user_id ON logs(user_id)", + }, + { + table: "logs", + name: "idx_logs_business_unit_id", + sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_business_unit_id ON logs(business_unit_id)", + }, + { table: "logs", name: "idx_logs_parent_request_id", sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_parent_request_id ON logs(parent_request_id) WHERE parent_request_id IS NOT NULL", @@ -2227,3 +2253,78 @@ func migrationAddAliasColumn(ctx context.Context, db *gorm.DB) error { } return nil } + +// migrationAddGovernanceContextColumns adds user_id, team_id, team_name, customer_id, customer_name, +// business_unit_id, business_unit_name columns to the logs table. +func migrationAddGovernanceContextColumns(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + + columns := []string{"user_id", "team_id", "team_name", "customer_id", "customer_name", "business_unit_id", "business_unit_name"} + + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_governance_context_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if !mig.HasColumn(&Log{}, col) { + if err := mig.AddColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if mig.HasColumn(&Log{}, col) { + if err := mig.DropColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding governance context columns: %s", err.Error()) + } + return nil +} + +// migrationRecreateMatViewsWithGovernanceColumns drops and recreates materialized views +// so they include the new governance context columns (user_id, team_id, customer_id, business_unit_id). +// The views are recreated by ensureMatViews on startup, so we just need to drop the old ones. +func migrationRecreateMatViewsWithGovernanceColumns(ctx context.Context, db *gorm.DB) error { + // Materialized views are PostgreSQL-only; skip on other dialects + if db.Dialector.Name() != "postgres" { + return nil + } + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_recreate_matviews_with_governance_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + for _, view := range []string{"mv_logs_hourly", "mv_logs_filterdata"} { + if err := tx.Exec("DROP MATERIALIZED VIEW IF EXISTS " + view + " CASCADE").Error; err != nil { + return fmt.Errorf("failed to drop %s: %w", view, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + // No rollback needed — ensureMatViews will recreate on next startup + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while recreating matviews with governance columns: %s", err.Error()) + } + return nil +} diff --git a/framework/logstore/postgres.go b/framework/logstore/postgres.go index 187f190a7a..df78b1735d 100644 --- a/framework/logstore/postgres.go +++ b/framework/logstore/postgres.go @@ -144,8 +144,11 @@ func newPostgresLogStore(ctx context.Context, config *PostgresConfig, logger sch logger.Warn(fmt.Sprintf("logstore: initial matview refresh failed: %s", err)) } else { logger.Info("logstore: materialized views are ready") + // Signal that matviews are ready for query use. Until this point, + // canUseMatView() returns false so all queries use raw tables. + d.matViewsReady.Store(true) } - startMatViewRefresher(context.Background(), db, 30*time.Second, logger) + startMatViewRefresher(context.Background(), db, 30*time.Second, logger, &d.matViewsReady) }() return d, nil diff --git a/framework/logstore/rdb.go b/framework/logstore/rdb.go index a14aa61580..cd82e67b72 100644 --- a/framework/logstore/rdb.go +++ b/framework/logstore/rdb.go @@ -10,6 +10,7 @@ import ( "sort" "strconv" "strings" + "sync/atomic" "time" "github.com/bytedance/sonic" @@ -46,8 +47,9 @@ const ( // RDBLogStore represents a log store that uses a SQLite database. type RDBLogStore struct { - db *gorm.DB - logger schemas.Logger + db *gorm.DB + logger schemas.Logger + matViewsReady atomic.Bool } // generateBucketTimestamps generates all bucket timestamps for a time range. @@ -101,6 +103,18 @@ func (s *RDBLogStore) applyFilters(baseQuery *gorm.DB, filters SearchFilters) *g if len(filters.RoutingRuleIDs) > 0 { baseQuery = baseQuery.Where("routing_rule_id IN ?", filters.RoutingRuleIDs) } + if len(filters.TeamIDs) > 0 { + baseQuery = baseQuery.Where("team_id IN ?", filters.TeamIDs) + } + if len(filters.CustomerIDs) > 0 { + baseQuery = baseQuery.Where("customer_id IN ?", filters.CustomerIDs) + } + if len(filters.UserIDs) > 0 { + baseQuery = baseQuery.Where("user_id IN ?", filters.UserIDs) + } + if len(filters.BusinessUnitIDs) > 0 { + baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs) + } if len(filters.RoutingEngineUsed) > 0 { // Query routing engines (comma-separated values) - find logs containing ANY of the specified engines dialect := s.db.Dialector.Name() @@ -401,7 +415,7 @@ func (s *RDBLogStore) SearchLogs(ctx context.Context, filters SearchFilters, pag g, gCtx := errgroup.WithContext(ctx) g.Go(func() error { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) { + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { var err error totalCount, err = s.getCountFromMatView(gCtx, filters) return err @@ -616,6 +630,8 @@ func (s *RDBLogStore) listSelectColumns() string { "selected_key_id", "selected_key_name", "virtual_key_id", "virtual_key_name", "routing_engines_used", "routing_rule_id", "routing_rule_name", + "user_id", "team_id", "team_name", "customer_id", "customer_name", + "business_unit_id", "business_unit_name", "speech_input", "transcription_input", "image_generation_input", "video_generation_input", "latency", "token_usage", "cost", "status", "error_details", "stream", "content_summary", "metadata", @@ -657,7 +673,7 @@ func (s *RDBLogStore) listSelectColumns() string { // GetStats calculates statistics for logs matching the given filters. func (s *RDBLogStore) GetStats(ctx context.Context, filters SearchFilters) (*SearchStats, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) { + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { return s.getStatsFromMatView(ctx, filters) } baseQuery := s.db.WithContext(ctx).Model(&Log{}) @@ -717,12 +733,12 @@ func (s *RDBLogStore) GetStats(ctx context.Context, filters SearchFilters) (*Sea // GetHistogram returns time-bucketed request counts for the given filters. func (s *RDBLogStore) GetHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*HistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 // Default to 1 hour } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } // Determine database type for SQL syntax dialect := s.db.Dialector.Name() @@ -843,12 +859,12 @@ func (s *RDBLogStore) GetHistogram(ctx context.Context, filters SearchFilters, b // GetTokenHistogram returns time-bucketed token usage for the given filters. func (s *RDBLogStore) GetTokenHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*TokenHistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 // Default to 1 hour } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } dialect := s.db.Dialector.Name() @@ -969,12 +985,12 @@ func (s *RDBLogStore) GetTokenHistogram(ctx context.Context, filters SearchFilte // GetCostHistogram returns time-bucketed cost data with model breakdown for the given filters. func (s *RDBLogStore) GetCostHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*CostHistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getCostHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 // Default to 1 hour } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getCostHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } dialect := s.db.Dialector.Name() @@ -1091,12 +1107,12 @@ func (s *RDBLogStore) GetCostHistogram(ctx context.Context, filters SearchFilter // GetModelHistogram returns time-bucketed model usage with success/error breakdown for the given filters. func (s *RDBLogStore) GetModelHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*ModelHistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getModelHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 // Default to 1 hour } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getModelHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } dialect := s.db.Dialector.Name() @@ -1246,12 +1262,12 @@ func computePercentile(sorted []float64, p float64) float64 { // PostgreSQL uses database-level percentile_cont aggregation (returns 1 row per bucket). // MySQL and SQLite fall back to Go-based percentile computation (loads individual latency values). func (s *RDBLogStore) GetLatencyHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*LatencyHistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getLatencyHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getLatencyHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } dialect := s.db.Dialector.Name() @@ -1460,7 +1476,7 @@ func (s *RDBLogStore) buildLatencyHistogramResult(computedBuckets map[int64]Late // GetModelRankings returns models ranked by usage with trend comparison to the previous period. func (s *RDBLogStore) GetModelRankings(ctx context.Context, filters SearchFilters) (*ModelRankingResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) { + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { return s.getModelRankingsFromMatView(ctx, filters) } selectClause := ` @@ -1598,6 +1614,115 @@ func (s *RDBLogStore) GetModelRankings(ctx context.Context, filters SearchFilter return &ModelRankingResult{Rankings: rankings}, nil } +// GetUserRankings returns users ranked by usage with trend comparison to the previous period. +func (s *RDBLogStore) GetUserRankings(ctx context.Context, filters SearchFilters) (*UserRankingResult, error) { + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { + return s.getUserRankingsFromMatView(ctx, filters) + } + selectClause := ` + user_id, + COUNT(*) as total_requests, + SUM(total_tokens) as total_tokens, + COALESCE(SUM(cost), 0) as total_cost + ` + + // Query current period + currentQuery := s.db.WithContext(ctx).Model(&Log{}) + currentQuery = s.applyFilters(currentQuery, filters) + currentQuery = currentQuery.Where("status IN ?", []string{"success", "error"}) + currentQuery = currentQuery.Where("user_id IS NOT NULL AND user_id != ''") + + var currentResults []struct { + UserID string `gorm:"column:user_id"` + TotalRequests int64 `gorm:"column:total_requests"` + TotalTokens sql.NullInt64 `gorm:"column:total_tokens"` + TotalCost sql.NullFloat64 `gorm:"column:total_cost"` + } + + if err := currentQuery. + Select(selectClause). + Group("user_id"). + Order("total_requests DESC"). + Limit(defaultMaxRankingsLimit). + Find(¤tResults).Error; err != nil { + return nil, fmt.Errorf("failed to get user rankings: %w", err) + } + + // Query previous period for trend comparison + prevMap := make(map[string]UserRankingEntry) + if filters.StartTime != nil && filters.EndTime != nil { + duration := filters.EndTime.Sub(*filters.StartTime) + prevStart := filters.StartTime.Add(-duration) + prevEnd := filters.StartTime.Add(-time.Nanosecond) + + prevFilters := filters + prevFilters.StartTime = &prevStart + prevFilters.EndTime = &prevEnd + + prevQuery := s.db.WithContext(ctx).Model(&Log{}) + prevQuery = s.applyFilters(prevQuery, prevFilters) + prevQuery = prevQuery.Where("status IN ?", []string{"success", "error"}) + prevQuery = prevQuery.Where("user_id IS NOT NULL AND user_id != ''") + + if len(currentResults) > 0 { + userIDs := make([]string, len(currentResults)) + for i, r := range currentResults { + userIDs[i] = r.UserID + } + prevQuery = prevQuery.Where("user_id IN ?", userIDs) + } + + var prevResults []struct { + UserID string `gorm:"column:user_id"` + TotalRequests int64 `gorm:"column:total_requests"` + TotalTokens sql.NullInt64 `gorm:"column:total_tokens"` + TotalCost sql.NullFloat64 `gorm:"column:total_cost"` + } + + if err := prevQuery. + Select(selectClause). + Group("user_id"). + Find(&prevResults).Error; err != nil { + return nil, fmt.Errorf("failed to get previous period user rankings: %w", err) + } + + for _, r := range prevResults { + prevMap[r.UserID] = UserRankingEntry{ + UserID: r.UserID, + TotalRequests: r.TotalRequests, + TotalTokens: r.TotalTokens.Int64, + TotalCost: r.TotalCost.Float64, + } + } + } + + // Build results with trends + rankings := make([]UserRankingWithTrend, len(currentResults)) + for i, r := range currentResults { + entry := UserRankingEntry{ + UserID: r.UserID, + TotalRequests: r.TotalRequests, + TotalTokens: r.TotalTokens.Int64, + TotalCost: r.TotalCost.Float64, + } + + var trend UserRankingTrend + if prev, ok := prevMap[r.UserID]; ok && prev.TotalRequests > 0 { + trend.HasPreviousPeriod = true + trend.RequestsTrend = pctChange(float64(prev.TotalRequests), float64(r.TotalRequests)) + trend.TokensTrend = pctChange(float64(prev.TotalTokens), float64(r.TotalTokens.Int64)) + trend.CostTrend = pctChange(prev.TotalCost, r.TotalCost.Float64) + } + + rankings[i] = UserRankingWithTrend{ + UserRankingEntry: entry, + Trend: trend, + } + } + + return &UserRankingResult{Rankings: rankings}, nil +} + // pctChange computes the percentage change from old to new. func pctChange(old, new float64) float64 { if old == 0 { @@ -1608,12 +1733,12 @@ func pctChange(old, new float64) float64 { // GetProviderCostHistogram returns time-bucketed cost data with provider breakdown for the given filters. func (s *RDBLogStore) GetProviderCostHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*ProviderCostHistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getProviderCostHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getProviderCostHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } dialect := s.db.Dialector.Name() @@ -1719,12 +1844,12 @@ func (s *RDBLogStore) GetProviderCostHistogram(ctx context.Context, filters Sear // GetProviderTokenHistogram returns time-bucketed token usage with provider breakdown for the given filters. func (s *RDBLogStore) GetProviderTokenHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*ProviderTokenHistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getProviderTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getProviderTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } dialect := s.db.Dialector.Name() @@ -1846,12 +1971,12 @@ func (s *RDBLogStore) GetProviderTokenHistogram(ctx context.Context, filters Sea // PostgreSQL uses database-level percentile_cont aggregation. // MySQL and SQLite fall back to Go-based percentile computation. func (s *RDBLogStore) GetProviderLatencyHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*ProviderLatencyHistogramResult, error) { - if s.db.Dialector.Name() == "postgres" && canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getProviderLatencyHistogramFromMatView(ctx, filters, bucketSizeSeconds) - } if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getProviderLatencyHistogramFromMatView(ctx, filters, bucketSizeSeconds) + } dialect := s.db.Dialector.Name() @@ -2116,6 +2241,317 @@ func (s *RDBLogStore) buildProviderLatencyHistogramResult(computedBuckets map[in }, nil } +// --------------------------------------------------------------------------- +// Generic dimension histogram methods +// --------------------------------------------------------------------------- + +// GetDimensionCostHistogram returns time-bucketed cost data grouped by the specified dimension. +// Uses the mv_logs_hourly materialized view on PostgreSQL when eligible; falls back to raw queries otherwise. +func (s *RDBLogStore) GetDimensionCostHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionCostHistogramResult, error) { + if !ValidHistogramDimensions[dimension] { + return nil, fmt.Errorf("invalid histogram dimension: %s", dimension) + } + if bucketSizeSeconds <= 0 { + bucketSizeSeconds = 3600 + } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getDimensionCostHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) + } + dimCol := string(dimension) + dialect := s.db.Dialector.Name() + baseQuery := s.db.WithContext(ctx).Model(&Log{}) + baseQuery = s.applyFilters(baseQuery, filters) + baseQuery = baseQuery.Where("status IN ?", []string{"success", "error"}) + baseQuery = baseQuery.Where("cost IS NOT NULL AND cost > 0") + + var bucketExpr string + switch dialect { + case "sqlite": + bucketExpr = fmt.Sprintf("CAST((CAST(strftime('%%s', timestamp) AS INTEGER) / %d) * %d AS INTEGER)", bucketSizeSeconds, bucketSizeSeconds) + default: + bucketExpr = fmt.Sprintf("CAST(FLOOR(EXTRACT(EPOCH FROM timestamp) / %d) * %d AS BIGINT)", bucketSizeSeconds, bucketSizeSeconds) + } + + var results []struct { + BucketTimestamp int64 `gorm:"column:bucket_timestamp"` + DimValue string `gorm:"column:dim_value"` + Cost float64 `gorm:"column:cost"` + } + if err := baseQuery.Select(fmt.Sprintf(` + %s AS bucket_timestamp, + COALESCE(%s, '') AS dim_value, + SUM(cost) AS cost + `, bucketExpr, dimCol)). + Group(fmt.Sprintf("bucket_timestamp, %s", dimCol)). + Order("bucket_timestamp ASC"). + Find(&results).Error; err != nil { + return nil, err + } + + type bucketAgg struct { + totalCost float64 + byDimension map[string]float64 + } + grouped := make(map[int64]*bucketAgg) + dimSet := make(map[string]struct{}) + for _, r := range results { + a, ok := grouped[r.BucketTimestamp] + if !ok { + a = &bucketAgg{byDimension: make(map[string]float64)} + grouped[r.BucketTimestamp] = a + } + a.totalCost += r.Cost + a.byDimension[r.DimValue] += r.Cost + dimSet[r.DimValue] = struct{}{} + } + + dimValues := sortedStringKeys(dimSet) + allTimestamps := generateBucketTimestamps(filters.StartTime, filters.EndTime, bucketSizeSeconds) + + // If no time range specified, build buckets directly from query results + if len(allTimestamps) == 0 { + keys := make([]int64, 0, len(grouped)) + for ts := range grouped { + keys = append(keys, ts) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + buckets := make([]DimensionCostHistogramBucket, 0, len(keys)) + for _, ts := range keys { + a := grouped[ts] + buckets = append(buckets, DimensionCostHistogramBucket{ + Timestamp: time.Unix(ts, 0).UTC(), + TotalCost: a.totalCost, + ByDimension: a.byDimension, + }) + } + return &DimensionCostHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil + } + + buckets := make([]DimensionCostHistogramBucket, 0, len(allTimestamps)) + for _, ts := range allTimestamps { + b := DimensionCostHistogramBucket{Timestamp: time.Unix(ts, 0).UTC(), ByDimension: make(map[string]float64)} + if a, ok := grouped[ts]; ok { + b.TotalCost = a.totalCost + b.ByDimension = a.byDimension + } + buckets = append(buckets, b) + } + + return &DimensionCostHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil +} + +// GetDimensionTokenHistogram returns time-bucketed token usage grouped by the specified dimension. +// Uses the mv_logs_hourly materialized view on PostgreSQL when eligible; falls back to raw queries otherwise. +func (s *RDBLogStore) GetDimensionTokenHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionTokenHistogramResult, error) { + if !ValidHistogramDimensions[dimension] { + return nil, fmt.Errorf("invalid histogram dimension: %s", dimension) + } + if bucketSizeSeconds <= 0 { + bucketSizeSeconds = 3600 + } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getDimensionTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) + } + dimCol := string(dimension) + dialect := s.db.Dialector.Name() + baseQuery := s.db.WithContext(ctx).Model(&Log{}) + baseQuery = s.applyFilters(baseQuery, filters) + baseQuery = baseQuery.Where("status IN ?", []string{"success", "error"}) + + var bucketExpr string + switch dialect { + case "sqlite": + bucketExpr = fmt.Sprintf("CAST((CAST(strftime('%%s', timestamp) AS INTEGER) / %d) * %d AS INTEGER)", bucketSizeSeconds, bucketSizeSeconds) + default: + bucketExpr = fmt.Sprintf("CAST(FLOOR(EXTRACT(EPOCH FROM timestamp) / %d) * %d AS BIGINT)", bucketSizeSeconds, bucketSizeSeconds) + } + + var results []struct { + BucketTimestamp int64 `gorm:"column:bucket_timestamp"` + DimValue string `gorm:"column:dim_value"` + PromptTokens int64 `gorm:"column:prompt_tokens"` + CompletionTokens int64 `gorm:"column:completion_tokens"` + TotalTokens int64 `gorm:"column:total_tkns"` + } + if err := baseQuery.Select(fmt.Sprintf(` + %s AS bucket_timestamp, + COALESCE(%s, '') AS dim_value, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tkns + `, bucketExpr, dimCol)). + Group(fmt.Sprintf("bucket_timestamp, %s", dimCol)). + Order("bucket_timestamp ASC"). + Find(&results).Error; err != nil { + return nil, err + } + + type dimAgg struct { + prompt, completion, total int64 + } + type bucketAgg struct { + byDimension map[string]*dimAgg + } + grouped := make(map[int64]*bucketAgg) + dimSet := make(map[string]struct{}) + for _, r := range results { + a, ok := grouped[r.BucketTimestamp] + if !ok { + a = &bucketAgg{byDimension: make(map[string]*dimAgg)} + grouped[r.BucketTimestamp] = a + } + da, ok := a.byDimension[r.DimValue] + if !ok { + da = &dimAgg{} + a.byDimension[r.DimValue] = da + } + da.prompt += r.PromptTokens + da.completion += r.CompletionTokens + da.total += r.TotalTokens + dimSet[r.DimValue] = struct{}{} + } + + dimValues := sortedStringKeys(dimSet) + allTimestamps := generateBucketTimestamps(filters.StartTime, filters.EndTime, bucketSizeSeconds) + + // If no time range specified, build buckets directly from query results + if len(allTimestamps) == 0 { + keys := make([]int64, 0, len(grouped)) + for ts := range grouped { + keys = append(keys, ts) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + buckets := make([]DimensionTokenHistogramBucket, 0, len(keys)) + for _, ts := range keys { + a := grouped[ts] + b := DimensionTokenHistogramBucket{Timestamp: time.Unix(ts, 0).UTC(), ByDimension: make(map[string]DimensionTokenStats)} + for dim, da := range a.byDimension { + b.ByDimension[dim] = DimensionTokenStats{ + PromptTokens: da.prompt, + CompletionTokens: da.completion, + TotalTokens: da.total, + } + } + buckets = append(buckets, b) + } + return &DimensionTokenHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil + } + + buckets := make([]DimensionTokenHistogramBucket, 0, len(allTimestamps)) + for _, ts := range allTimestamps { + b := DimensionTokenHistogramBucket{Timestamp: time.Unix(ts, 0).UTC(), ByDimension: make(map[string]DimensionTokenStats)} + if a, ok := grouped[ts]; ok { + for dim, da := range a.byDimension { + b.ByDimension[dim] = DimensionTokenStats{ + PromptTokens: da.prompt, + CompletionTokens: da.completion, + TotalTokens: da.total, + } + } + } + buckets = append(buckets, b) + } + + return &DimensionTokenHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil +} + +// GetDimensionLatencyHistogram returns time-bucketed latency percentiles grouped by the specified dimension. +// Uses the mv_logs_hourly materialized view on PostgreSQL when eligible; falls back to raw queries otherwise. +// The fallback path computes AVG latency only (no percentiles) since percentile_cont is Postgres-specific. +func (s *RDBLogStore) GetDimensionLatencyHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionLatencyHistogramResult, error) { + if !ValidHistogramDimensions[dimension] { + return nil, fmt.Errorf("invalid histogram dimension: %s", dimension) + } + if bucketSizeSeconds <= 0 { + bucketSizeSeconds = 3600 + } + if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getDimensionLatencyHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) + } + dimCol := string(dimension) + dialect := s.db.Dialector.Name() + baseQuery := s.db.WithContext(ctx).Model(&Log{}) + baseQuery = s.applyFilters(baseQuery, filters) + baseQuery = baseQuery.Where("status IN ?", []string{"success", "error"}) + baseQuery = baseQuery.Where("latency IS NOT NULL") + + var bucketExpr string + switch dialect { + case "sqlite": + bucketExpr = fmt.Sprintf("CAST((CAST(strftime('%%s', timestamp) AS INTEGER) / %d) * %d AS INTEGER)", bucketSizeSeconds, bucketSizeSeconds) + default: + bucketExpr = fmt.Sprintf("CAST(FLOOR(EXTRACT(EPOCH FROM timestamp) / %d) * %d AS BIGINT)", bucketSizeSeconds, bucketSizeSeconds) + } + + var results []struct { + BucketTimestamp int64 `gorm:"column:bucket_timestamp"` + DimValue string `gorm:"column:dim_value"` + AvgLatency float64 `gorm:"column:avg_lat"` + TotalRequests int64 `gorm:"column:total_requests"` + } + if err := baseQuery.Select(fmt.Sprintf(` + %s AS bucket_timestamp, + COALESCE(%s, '') AS dim_value, + COALESCE(AVG(latency), 0) AS avg_lat, + COUNT(*) AS total_requests + `, bucketExpr, dimCol)). + Group(fmt.Sprintf("bucket_timestamp, %s", dimCol)). + Order("bucket_timestamp ASC"). + Find(&results).Error; err != nil { + return nil, err + } + + type bucketAgg struct { + byDimension map[string]DimensionLatencyStats + } + grouped := make(map[int64]*bucketAgg) + dimSet := make(map[string]struct{}) + for _, r := range results { + a, ok := grouped[r.BucketTimestamp] + if !ok { + a = &bucketAgg{byDimension: make(map[string]DimensionLatencyStats)} + grouped[r.BucketTimestamp] = a + } + a.byDimension[r.DimValue] = DimensionLatencyStats{ + AvgLatency: r.AvgLatency, + TotalRequests: r.TotalRequests, + } + dimSet[r.DimValue] = struct{}{} + } + + dimValues := sortedStringKeys(dimSet) + allTimestamps := generateBucketTimestamps(filters.StartTime, filters.EndTime, bucketSizeSeconds) + + // If no time range specified, build buckets directly from query results + if len(allTimestamps) == 0 { + keys := make([]int64, 0, len(grouped)) + for ts := range grouped { + keys = append(keys, ts) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + buckets := make([]DimensionLatencyHistogramBucket, 0, len(keys)) + for _, ts := range keys { + a := grouped[ts] + buckets = append(buckets, DimensionLatencyHistogramBucket{ + Timestamp: time.Unix(ts, 0).UTC(), + ByDimension: a.byDimension, + }) + } + return &DimensionLatencyHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil + } + + buckets := make([]DimensionLatencyHistogramBucket, 0, len(allTimestamps)) + for _, ts := range allTimestamps { + b := DimensionLatencyHistogramBucket{Timestamp: time.Unix(ts, 0).UTC(), ByDimension: make(map[string]DimensionLatencyStats)} + if a, ok := grouped[ts]; ok { + b.ByDimension = a.byDimension + } + buckets = append(buckets, b) + } + + return &DimensionLatencyHistogramResult{Buckets: buckets, BucketSizeSeconds: bucketSizeSeconds, Dimension: dimension, DimensionValues: dimValues}, nil +} + // HasLogs checks if there are any logs in the database. func (s *RDBLogStore) HasLogs(ctx context.Context) (bool, error) { var log Log @@ -2179,7 +2615,7 @@ func (s *RDBLogStore) Flush(ctx context.Context, since time.Time) error { // GetDistinctModels returns all unique non-empty model values using SELECT DISTINCT. // Scoped to recent data to avoid full table scans. func (s *RDBLogStore) GetDistinctModels(ctx context.Context) ([]string, error) { - if s.db.Dialector.Name() == "postgres" { + if s.db.Dialector.Name() == "postgres" && s.matViewsReady.Load() { return s.getDistinctModelsFromMatView(ctx) } cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays) @@ -2210,18 +2646,25 @@ func (s *RDBLogStore) GetDistinctAliases(ctx context.Context) ([]string, error) // allowedKeyPairColumns is a whitelist of column names that can be used in GetDistinctKeyPairs // to prevent SQL injection from interpolated column names. var allowedKeyPairColumns = map[string]struct{}{ - "selected_key_id": {}, - "selected_key_name": {}, - "virtual_key_id": {}, - "virtual_key_name": {}, - "routing_rule_id": {}, - "routing_rule_name": {}, + "selected_key_id": {}, + "selected_key_name": {}, + "virtual_key_id": {}, + "virtual_key_name": {}, + "routing_rule_id": {}, + "routing_rule_name": {}, + "team_id": {}, + "team_name": {}, + "customer_id": {}, + "customer_name": {}, + "user_id": {}, + "business_unit_id": {}, + "business_unit_name": {}, } // GetDistinctKeyPairs returns unique non-empty ID-Name pairs for the given columns using SELECT DISTINCT. // idCol and nameCol must be valid column names (e.g., "selected_key_id", "selected_key_name"). func (s *RDBLogStore) GetDistinctKeyPairs(ctx context.Context, idCol, nameCol string) ([]KeyPairResult, error) { - if s.db.Dialector.Name() == "postgres" { + if s.db.Dialector.Name() == "postgres" && s.matViewsReady.Load() { return s.getDistinctKeyPairsFromMatView(ctx, idCol, nameCol) } if _, ok := allowedKeyPairColumns[idCol]; !ok { @@ -2246,7 +2689,7 @@ func (s *RDBLogStore) GetDistinctKeyPairs(ctx context.Context, idCol, nameCol st // GetDistinctRoutingEngines returns all unique routing engine values from the comma-separated column. // Scoped to recent data to avoid full table scans. func (s *RDBLogStore) GetDistinctRoutingEngines(ctx context.Context) ([]string, error) { - if s.db.Dialector.Name() == "postgres" { + if s.db.Dialector.Name() == "postgres" && s.matViewsReady.Load() { return s.getDistinctRoutingEnginesFromMatView(ctx) } cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays) diff --git a/framework/logstore/store.go b/framework/logstore/store.go index 3d6aedf711..f209672442 100644 --- a/framework/logstore/store.go +++ b/framework/logstore/store.go @@ -42,6 +42,13 @@ type LogStore interface { GetProviderTokenHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*ProviderTokenHistogramResult, error) GetProviderLatencyHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64) (*ProviderLatencyHistogramResult, error) GetModelRankings(ctx context.Context, filters SearchFilters) (*ModelRankingResult, error) + GetUserRankings(ctx context.Context, filters SearchFilters) (*UserRankingResult, error) + // GetDimensionCostHistogram returns time-bucketed cost data grouped by the specified dimension (e.g., team_id, customer_id). + GetDimensionCostHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionCostHistogramResult, error) + // GetDimensionTokenHistogram returns time-bucketed token usage grouped by the specified dimension. + GetDimensionTokenHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionTokenHistogramResult, error) + // GetDimensionLatencyHistogram returns time-bucketed latency percentiles grouped by the specified dimension. + GetDimensionLatencyHistogram(ctx context.Context, filters SearchFilters, bucketSizeSeconds int64, dimension HistogramDimension) (*DimensionLatencyHistogramResult, error) Update(ctx context.Context, id string, entry any) error BulkUpdateCost(ctx context.Context, updates map[string]float64) error Flush(ctx context.Context, since time.Time) error diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go index c37d51d6f2..40bd235840 100644 --- a/framework/logstore/tables.go +++ b/framework/logstore/tables.go @@ -38,6 +38,10 @@ type SearchFilters struct { SelectedKeyIDs []string `json:"selected_key_ids,omitempty"` VirtualKeyIDs []string `json:"virtual_key_ids,omitempty"` RoutingRuleIDs []string `json:"routing_rule_ids,omitempty"` + TeamIDs []string `json:"team_ids,omitempty"` + CustomerIDs []string `json:"customer_ids,omitempty"` + UserIDs []string `json:"user_ids,omitempty"` + BusinessUnitIDs []string `json:"business_unit_ids,omitempty"` RoutingEngineUsed []string `json:"routing_engine_used,omitempty"` // For filtering by routing engine (routing-rule, governance, loadbalancing) StartTime *time.Time `json:"start_time,omitempty"` EndTime *time.Time `json:"end_time,omitempty"` @@ -115,6 +119,13 @@ type Log struct { RoutingEnginesUsedStr *string `gorm:"type:varchar(255);column:routing_engines_used" json:"-"` // Comma-separated routing engines RoutingRuleID *string `gorm:"type:varchar(255);index:idx_logs_routing_rule_id" json:"routing_rule_id"` RoutingRuleName *string `gorm:"type:varchar(255)" json:"routing_rule_name"` + UserID *string `gorm:"type:varchar(255);index:idx_logs_user_id" json:"user_id"` + TeamID *string `gorm:"type:varchar(255);index:idx_logs_team_id" json:"team_id"` + TeamName *string `gorm:"type:varchar(255)" json:"team_name"` + CustomerID *string `gorm:"type:varchar(255);index:idx_logs_customer_id" json:"customer_id"` + CustomerName *string `gorm:"type:varchar(255)" json:"customer_name"` + BusinessUnitID *string `gorm:"type:varchar(255);index:idx_logs_business_unit_id" json:"business_unit_id"` + BusinessUnitName *string `gorm:"type:varchar(255)" json:"business_unit_name"` InputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ChatMessage ResponsesInputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ResponsesMessage OutputMessage string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ChatMessage @@ -1218,6 +1229,87 @@ type ProviderLatencyHistogramResult struct { Providers []string `json:"providers"` } +// HistogramDimension represents a column that can be used as a grouping dimension in histograms +type HistogramDimension string + +const ( + DimensionProvider HistogramDimension = "provider" + DimensionTeam HistogramDimension = "team_id" + DimensionCustomer HistogramDimension = "customer_id" + DimensionUser HistogramDimension = "user_id" + DimensionBusinessUnit HistogramDimension = "business_unit_id" +) + +// ValidHistogramDimensions is the set of allowed dimension values +var ValidHistogramDimensions = map[HistogramDimension]bool{ + DimensionProvider: true, + DimensionTeam: true, + DimensionCustomer: true, + DimensionUser: true, + DimensionBusinessUnit: true, +} + +// Dimension-level histogram types (generic version of Provider histograms) + +// DimensionCostHistogramBucket represents a single time bucket for dimension-grouped cost data +type DimensionCostHistogramBucket struct { + Timestamp time.Time `json:"timestamp"` + TotalCost float64 `json:"total_cost"` + ByDimension map[string]float64 `json:"by_dimension"` +} + +// DimensionCostHistogramResult represents the dimension cost histogram query result +type DimensionCostHistogramResult struct { + Buckets []DimensionCostHistogramBucket `json:"buckets"` + BucketSizeSeconds int64 `json:"bucket_size_seconds"` + Dimension HistogramDimension `json:"dimension"` + DimensionValues []string `json:"dimension_values"` +} + +// DimensionTokenStats represents token statistics for a single dimension value +type DimensionTokenStats struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +// DimensionTokenHistogramBucket represents a single time bucket for dimension-grouped token data +type DimensionTokenHistogramBucket struct { + Timestamp time.Time `json:"timestamp"` + ByDimension map[string]DimensionTokenStats `json:"by_dimension"` +} + +// DimensionTokenHistogramResult represents the dimension token histogram query result +type DimensionTokenHistogramResult struct { + Buckets []DimensionTokenHistogramBucket `json:"buckets"` + BucketSizeSeconds int64 `json:"bucket_size_seconds"` + Dimension HistogramDimension `json:"dimension"` + DimensionValues []string `json:"dimension_values"` +} + +// DimensionLatencyStats represents latency statistics for a single dimension value +type DimensionLatencyStats struct { + AvgLatency float64 `json:"avg_latency"` + P90Latency float64 `json:"p90_latency"` + P95Latency float64 `json:"p95_latency"` + P99Latency float64 `json:"p99_latency"` + TotalRequests int64 `json:"total_requests"` +} + +// DimensionLatencyHistogramBucket represents a single time bucket for dimension-grouped latency data +type DimensionLatencyHistogramBucket struct { + Timestamp time.Time `json:"timestamp"` + ByDimension map[string]DimensionLatencyStats `json:"by_dimension"` +} + +// DimensionLatencyHistogramResult represents the dimension latency histogram query result +type DimensionLatencyHistogramResult struct { + Buckets []DimensionLatencyHistogramBucket `json:"buckets"` + BucketSizeSeconds int64 `json:"bucket_size_seconds"` + Dimension HistogramDimension `json:"dimension"` + DimensionValues []string `json:"dimension_values"` +} + // MCPHistogramBucket represents a single time bucket for MCP tool call volume type MCPHistogramBucket struct { Timestamp time.Time `json:"timestamp"` @@ -1287,3 +1379,30 @@ type ModelRankingWithTrend struct { type ModelRankingResult struct { Rankings []ModelRankingWithTrend `json:"rankings"` } + +// UserRankingEntry represents a single user's usage statistics. +type UserRankingEntry struct { + UserID string `json:"user_id"` + TotalRequests int64 `json:"total_requests"` + TotalTokens int64 `json:"total_tokens"` + TotalCost float64 `json:"total_cost"` +} + +// UserRankingTrend represents the percentage change compared to the previous period. +type UserRankingTrend struct { + HasPreviousPeriod bool `json:"has_previous_period"` + RequestsTrend float64 `json:"requests_trend"` + TokensTrend float64 `json:"tokens_trend"` + CostTrend float64 `json:"cost_trend"` +} + +// UserRankingWithTrend combines ranking entry with trend data. +type UserRankingWithTrend struct { + UserRankingEntry + Trend UserRankingTrend `json:"trend"` +} + +// UserRankingResult is the response for the user rankings endpoint. +type UserRankingResult struct { + Rankings []UserRankingWithTrend `json:"rankings"` +} diff --git a/plugins/governance/go.mod b/plugins/governance/go.mod index 9004bcdd25..2ca7b1203c 100644 --- a/plugins/governance/go.mod +++ b/plugins/governance/go.mod @@ -124,7 +124,7 @@ require ( golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/plugins/governance/go.sum b/plugins/governance/go.sum index a91037a522..79d0de8dc8 100644 --- a/plugins/governance/go.sum +++ b/plugins/governance/go.sum @@ -298,8 +298,7 @@ golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0c golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/governance/main.go b/plugins/governance/main.go index d925e9ee03..de02673d6b 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -336,7 +336,7 @@ func (p *GovernancePlugin) GetName() string { func (p *GovernancePlugin) UpdateEnforceAuthOnInference(enforceAuthOnInference bool) { p.cfgMutex.Lock() defer p.cfgMutex.Unlock() - p.isVkMandatory = bifrost.Ptr(enforceAuthOnInference) + p.isVkMandatory = new(enforceAuthOnInference) } // HTTPTransportPreHook intercepts requests before they are processed (governance decision point) @@ -404,6 +404,22 @@ func (p *GovernancePlugin) HTTPTransportPreHook(ctx *schemas.BifrostContext, req } } + // Attaching team and customer based on the virtual key + if virtualKey != nil { + if virtualKey.TeamID != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamID, *virtualKey.TeamID) + } + if virtualKey.Team != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamName, virtualKey.Team.Name) + } + if virtualKey.CustomerID != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerID, *virtualKey.CustomerID) + } + if virtualKey.Customer != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerName, virtualKey.Customer.Name) + } + } + //1. Apply routing rules only if we have rules or matched decision var routingDecision *RoutingDecision if hasRoutingRules { @@ -487,6 +503,22 @@ func (p *GovernancePlugin) governLargePayload(ctx *schemas.BifrostContext, req * virtualKey = vk } + // Attaching team and customer based on the virtual key + if virtualKey != nil { + if virtualKey.TeamID != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamID, *virtualKey.TeamID) + } + if virtualKey.Team != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamName, virtualKey.Team.Name) + } + if virtualKey.CustomerID != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerID, *virtualKey.CustomerID) + } + if virtualKey.Customer != nil { + ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerName, virtualKey.Customer.Name) + } + } + // Apply routing rules (read-only: decisions still affect downstream evaluation) if hasRoutingRules { var err error diff --git a/plugins/governance/store.go b/plugins/governance/store.go index 49086dc9e4..4775c71bd4 100644 --- a/plugins/governance/store.go +++ b/plugins/governance/store.go @@ -385,7 +385,7 @@ func (gs *LocalGovernanceStore) GetGovernanceData() *GovernanceData { return true // continue iteration }) routingRules := make(map[string]*configstoreTables.TableRoutingRule) - gs.routingRules.Range(func(key, value interface{}) bool { + gs.routingRules.Range(func(key, value any) bool { rules, ok := value.([]*configstoreTables.TableRoutingRule) if !ok || rules == nil { return true // continue @@ -399,7 +399,7 @@ func (gs *LocalGovernanceStore) GetGovernanceData() *GovernanceData { return true // continue iteration }) var modelConfigsList []*configstoreTables.TableModelConfig - gs.modelConfigs.Range(func(key, value interface{}) bool { + gs.modelConfigs.Range(func(key, value any) bool { mc, ok := value.(*configstoreTables.TableModelConfig) if !ok || mc == nil { return true // continue diff --git a/plugins/jsonparser/go.mod b/plugins/jsonparser/go.mod index 4443e017a7..03ec3c0071 100644 --- a/plugins/jsonparser/go.mod +++ b/plugins/jsonparser/go.mod @@ -63,7 +63,7 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/plugins/jsonparser/go.sum b/plugins/jsonparser/go.sum index b82db7ee19..ae12a1c88d 100644 --- a/plugins/jsonparser/go.sum +++ b/plugins/jsonparser/go.sum @@ -165,8 +165,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/litellmcompat/go.mod b/plugins/litellmcompat/go.mod index c8ea0d96fa..54e59aa032 100644 --- a/plugins/litellmcompat/go.mod +++ b/plugins/litellmcompat/go.mod @@ -117,7 +117,7 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/plugins/litellmcompat/go.sum b/plugins/litellmcompat/go.sum index 29346f3bdb..d6d1cf3c4f 100644 --- a/plugins/litellmcompat/go.sum +++ b/plugins/litellmcompat/go.sum @@ -287,8 +287,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/logging/go.mod b/plugins/logging/go.mod index 53cc9e98a0..c5d1948fa3 100644 --- a/plugins/logging/go.mod +++ b/plugins/logging/go.mod @@ -117,7 +117,7 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/plugins/logging/go.sum b/plugins/logging/go.sum index 29346f3bdb..d6d1cf3c4f 100644 --- a/plugins/logging/go.sum +++ b/plugins/logging/go.sum @@ -287,8 +287,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/logging/main.go b/plugins/logging/main.go index 83bc98f54c..2d01f1b61d 100644 --- a/plugins/logging/main.go +++ b/plugins/logging/main.go @@ -204,7 +204,7 @@ type InitialLogData struct { Object string InputHistory []schemas.ChatMessage ResponsesInputHistory []schemas.ResponsesMessage - Params interface{} + Params any SpeechInput *schemas.SpeechInput TranscriptionInput *schemas.TranscriptionInput ImageGenerationInput *schemas.ImageGenerationInput @@ -213,7 +213,7 @@ type InitialLogData struct { VideoGenerationInput *schemas.VideoGenerationInput Tools []schemas.ChatTool RoutingEngineUsed []string - Metadata map[string]interface{} + Metadata map[string]any PassthroughRequestBody string // Raw body for passthrough requests (UTF-8) } @@ -281,12 +281,12 @@ func Init(ctx context.Context, config *Config, logger schemas.Logger, logsStore writeQueue: make(chan *writeQueueEntry, writeQueueCapacity), deferredUsageSem: make(chan struct{}, maxDeferredUsageConcurrency), logMsgPool: sync.Pool{ - New: func() interface{} { + New: func() any { return &LogMessage{} }, }, updateDataPool: sync.Pool{ - New: func() interface{} { + New: func() any { return &UpdateLogData{} }, }, @@ -676,6 +676,13 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas. virtualKeyName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceVirtualKeyName) routingRuleID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceRoutingRuleID) routingRuleName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceRoutingRuleName) + teamID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceTeamID) + teamName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceTeamName) + customerID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceCustomerID) + customerName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceCustomerName) + userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceUserID) + businessUnitID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceBusinessUnitID) + businessUnitName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceBusinessUnitName) numberOfRetries := bifrost.GetIntFromContext(ctx, schemas.BifrostContextKeyNumberOfRetries) requestType, _, originalModelRequested, resolvedModelUsed := bifrost.GetResponseFields(result, bifrostErr) @@ -750,7 +757,7 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas. if result != nil { latency = result.GetExtraFields().Latency } - applyOutputFieldsToEntry(entry, selectedKeyID, selectedKeyName, virtualKeyID, virtualKeyName, routingRuleID, routingRuleName, numberOfRetries, latency) + applyOutputFieldsToEntry(entry, selectedKeyID, selectedKeyName, virtualKeyID, virtualKeyName, routingRuleID, routingRuleName, teamID, teamName, customerID, customerName, userID, businessUnitID, businessUnitName, numberOfRetries, latency) entry.MetadataParsed = pending.InitialData.Metadata entry.MetadataParsed = mergeRealtimeMetadata(entry.MetadataParsed, ctx) entry.RoutingEngineLogs = routingEngineLogs diff --git a/plugins/logging/operations.go b/plugins/logging/operations.go index 1383cba265..cf65beb9c1 100644 --- a/plugins/logging/operations.go +++ b/plugins/logging/operations.go @@ -1206,6 +1206,68 @@ func (p *LoggerPlugin) GetAvailableRoutingRules(ctx context.Context) []KeyPair { return keyPairResultsToKeyPairs(results) } +// GetAvailableTeams returns all unique team ID-Name pairs from logs. +// Uses DISTINCT to avoid loading all rows when only unique values are needed. +func (p *LoggerPlugin) GetAvailableTeams(ctx context.Context) []KeyPair { + results, err := p.store.GetDistinctKeyPairs(ctx, "team_id", "team_name") + if err != nil { + p.logger.Error("failed to get available teams: %v", err) + return []KeyPair{} + } + return keyPairResultsToKeyPairs(results) +} + +// GetAvailableCustomers returns all unique customer ID-Name pairs from logs. +// Uses DISTINCT to avoid loading all rows when only unique values are needed. +func (p *LoggerPlugin) GetAvailableCustomers(ctx context.Context) []KeyPair { + results, err := p.store.GetDistinctKeyPairs(ctx, "customer_id", "customer_name") + if err != nil { + p.logger.Error("failed to get available customers: %v", err) + return []KeyPair{} + } + return keyPairResultsToKeyPairs(results) +} + +// GetAvailableUsers returns all unique user IDs from logs. +// Both ID and Name are set to user_id since users don't have a separate name column. +func (p *LoggerPlugin) GetAvailableUsers(ctx context.Context) []KeyPair { + results, err := p.store.GetDistinctKeyPairs(ctx, "user_id", "user_id") + if err != nil { + p.logger.Error("failed to get available users: %v", err) + return []KeyPair{} + } + return keyPairResultsToKeyPairs(results) +} + +// GetAvailableBusinessUnits returns all unique business unit ID-Name pairs from logs. +// Uses DISTINCT to avoid loading all rows when only unique values are needed. +func (p *LoggerPlugin) GetAvailableBusinessUnits(ctx context.Context) []KeyPair { + results, err := p.store.GetDistinctKeyPairs(ctx, "business_unit_id", "business_unit_name") + if err != nil { + p.logger.Error("failed to get available business units: %v", err) + return []KeyPair{} + } + return keyPairResultsToKeyPairs(results) +} + +// GetDimensionCostHistogram returns time-bucketed cost data grouped by the specified dimension. +// Delegates to the underlying log store which uses materialized views on PostgreSQL for performance. +func (p *LoggerPlugin) GetDimensionCostHistogram(ctx context.Context, filters logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionCostHistogramResult, error) { + return p.store.GetDimensionCostHistogram(ctx, filters, bucketSizeSeconds, dimension) +} + +// GetDimensionTokenHistogram returns time-bucketed token usage grouped by the specified dimension. +// Delegates to the underlying log store which uses materialized views on PostgreSQL for performance. +func (p *LoggerPlugin) GetDimensionTokenHistogram(ctx context.Context, filters logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionTokenHistogramResult, error) { + return p.store.GetDimensionTokenHistogram(ctx, filters, bucketSizeSeconds, dimension) +} + +// GetDimensionLatencyHistogram returns time-bucketed latency percentiles grouped by the specified dimension. +// Delegates to the underlying log store which uses materialized views on PostgreSQL for performance. +func (p *LoggerPlugin) GetDimensionLatencyHistogram(ctx context.Context, filters logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionLatencyHistogramResult, error) { + return p.store.GetDimensionLatencyHistogram(ctx, filters, bucketSizeSeconds, dimension) +} + // GetAvailableRoutingEngines returns all unique routing engine types used in logs. // Uses DISTINCT to avoid loading all rows when only unique values are needed. func (p *LoggerPlugin) GetAvailableRoutingEngines(ctx context.Context) []string { diff --git a/plugins/logging/utils.go b/plugins/logging/utils.go index 81ea1b0f3f..4d1abbbde5 100644 --- a/plugins/logging/utils.go +++ b/plugins/logging/utils.go @@ -85,9 +85,30 @@ type LogManager interface { // GetAvailableRoutingEngines returns all unique routing engine types from logs GetAvailableRoutingEngines(ctx context.Context) []string + // GetAvailableTeams returns all unique team ID-Name pairs from logs + GetAvailableTeams(ctx context.Context) []KeyPair + + // GetAvailableCustomers returns all unique customer ID-Name pairs from logs + GetAvailableCustomers(ctx context.Context) []KeyPair + + // GetAvailableUsers returns all unique user IDs from logs + GetAvailableUsers(ctx context.Context) []KeyPair + + // GetAvailableBusinessUnits returns all unique business unit ID-Name pairs from logs + GetAvailableBusinessUnits(ctx context.Context) []KeyPair + // GetAvailableMetadataKeys returns distinct metadata keys and their values from recent logs GetAvailableMetadataKeys(ctx context.Context) (map[string][]string, error) + // GetDimensionCostHistogram returns time-bucketed cost data grouped by the specified dimension + GetDimensionCostHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionCostHistogramResult, error) + + // GetDimensionTokenHistogram returns time-bucketed token usage grouped by the specified dimension + GetDimensionTokenHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionTokenHistogramResult, error) + + // GetDimensionLatencyHistogram returns time-bucketed latency percentiles grouped by the specified dimension + GetDimensionLatencyHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionLatencyHistogramResult, error) + // DeleteLog deletes a log entry by its ID DeleteLog(ctx context.Context, id string) error @@ -263,6 +284,50 @@ func (p *PluginLogManager) GetAvailableRoutingEngines(ctx context.Context) []str return p.plugin.GetAvailableRoutingEngines(ctx) } +// GetAvailableTeams returns all unique team ID-Name pairs from logs. +func (p *PluginLogManager) GetAvailableTeams(ctx context.Context) []KeyPair { + return p.plugin.GetAvailableTeams(ctx) +} + +// GetAvailableCustomers returns all unique customer ID-Name pairs from logs. +func (p *PluginLogManager) GetAvailableCustomers(ctx context.Context) []KeyPair { + return p.plugin.GetAvailableCustomers(ctx) +} + +// GetAvailableUsers returns all unique user IDs from logs. +func (p *PluginLogManager) GetAvailableUsers(ctx context.Context) []KeyPair { + return p.plugin.GetAvailableUsers(ctx) +} + +// GetAvailableBusinessUnits returns all unique business unit ID-Name pairs from logs. +func (p *PluginLogManager) GetAvailableBusinessUnits(ctx context.Context) []KeyPair { + return p.plugin.GetAvailableBusinessUnits(ctx) +} + +// GetDimensionCostHistogram returns time-bucketed cost data grouped by the specified dimension. +func (p *PluginLogManager) GetDimensionCostHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionCostHistogramResult, error) { + if filters == nil { + return nil, fmt.Errorf("filters cannot be nil") + } + return p.plugin.GetDimensionCostHistogram(ctx, *filters, bucketSizeSeconds, dimension) +} + +// GetDimensionTokenHistogram returns time-bucketed token usage grouped by the specified dimension. +func (p *PluginLogManager) GetDimensionTokenHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionTokenHistogramResult, error) { + if filters == nil { + return nil, fmt.Errorf("filters cannot be nil") + } + return p.plugin.GetDimensionTokenHistogram(ctx, *filters, bucketSizeSeconds, dimension) +} + +// GetDimensionLatencyHistogram returns time-bucketed latency percentiles grouped by the specified dimension. +func (p *PluginLogManager) GetDimensionLatencyHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionLatencyHistogramResult, error) { + if filters == nil { + return nil, fmt.Errorf("filters cannot be nil") + } + return p.plugin.GetDimensionLatencyHistogram(ctx, *filters, bucketSizeSeconds, dimension) +} + func (p *PluginLogManager) GetAvailableMetadataKeys(ctx context.Context) (map[string][]string, error) { if p.plugin == nil || p.plugin.store == nil { return map[string][]string{}, nil diff --git a/plugins/logging/writer.go b/plugins/logging/writer.go index 24f4125601..61afcdb792 100644 --- a/plugins/logging/writer.go +++ b/plugins/logging/writer.go @@ -261,7 +261,7 @@ func estimateLogEntrySize(log *logstore.Log) int { len(log.PassthroughRequestBody) + len(log.PassthroughResponseBody) + len(log.ContentSummary) + - len(log.CacheDebug) + + len(log.CacheDebug) + len(log.RoutingEngineLogs) // Baseline for fixed-width columns and struct overhead return n + 512 @@ -351,6 +351,10 @@ func applyOutputFieldsToEntry( selectedKeyID, selectedKeyName string, virtualKeyID, virtualKeyName string, routingRuleID, routingRuleName string, + teamID, teamName string, + customerID, customerName string, + userID string, + businessUnitID, businessUnitName string, numberOfRetries int, latency int64, ) { @@ -368,6 +372,27 @@ func applyOutputFieldsToEntry( if routingRuleName != "" { entry.RoutingRuleName = &routingRuleName } + if teamID != "" { + entry.TeamID = &teamID + } + if teamName != "" { + entry.TeamName = &teamName + } + if customerID != "" { + entry.CustomerID = &customerID + } + if customerName != "" { + entry.CustomerName = &customerName + } + if userID != "" { + entry.UserID = &userID + } + if businessUnitID != "" { + entry.BusinessUnitID = &businessUnitID + } + if businessUnitName != "" { + entry.BusinessUnitName = &businessUnitName + } if numberOfRetries != 0 { entry.NumberOfRetries = numberOfRetries } diff --git a/plugins/maxim/go.mod b/plugins/maxim/go.mod index fdd305e198..a5b71e5b68 100644 --- a/plugins/maxim/go.mod +++ b/plugins/maxim/go.mod @@ -5,7 +5,7 @@ go 1.26.1 require ( github.com/maximhq/bifrost/core v1.5.0 github.com/maximhq/bifrost/framework v1.3.0 - github.com/maximhq/maxim-go v0.2.0 + github.com/maximhq/maxim-go v0.2.1 ) require github.com/google/uuid v1.6.0 @@ -119,7 +119,7 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/plugins/maxim/go.sum b/plugins/maxim/go.sum index 47880f12e2..9c4b9dafd4 100644 --- a/plugins/maxim/go.sum +++ b/plugins/maxim/go.sum @@ -197,8 +197,7 @@ github.com/maximhq/bifrost/core v1.5.0 h1:COg/4ssyANLeYt3VbfoU2FdgEDLcpSPpqEnvl5 github.com/maximhq/bifrost/core v1.5.0/go.mod h1:A+AHUm/jf2lWFz5RNSxcJD/ozPlFJIVK9riMM1nyjt8= github.com/maximhq/bifrost/framework v1.3.0 h1:TRUKCM39qgJw0MrvfFPhY6UEdcgTGlxZ0zrT02ScaXw= github.com/maximhq/bifrost/framework v1.3.0/go.mod h1:mDCR8IRMaHFffTJxyaYf9/7grG8knskluachivWjRAA= -github.com/maximhq/maxim-go v0.2.0 h1:3SNpna+Z9bDcUBqPLV/pfaZaxTEtsyix7Rn1KtwoEp4= -github.com/maximhq/maxim-go v0.2.0/go.mod h1:RvESsFEUWSJmIypHtV+vHN2EWjp+HoqWGStEvB/cPBU= +github.com/maximhq/maxim-go v0.2.1 h1:hCp8dQ4HsyyNC+y5HCUuY/HFD0sOnGkjL5MdYCHkgEQ= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -289,8 +288,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/mocker/go.mod b/plugins/mocker/go.mod index 8ad5cb2943..f0aade684f 100644 --- a/plugins/mocker/go.mod +++ b/plugins/mocker/go.mod @@ -66,7 +66,7 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/plugins/mocker/go.sum b/plugins/mocker/go.sum index c3f55d72c9..836246f5a4 100644 --- a/plugins/mocker/go.sum +++ b/plugins/mocker/go.sum @@ -167,8 +167,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/otel/go.mod b/plugins/otel/go.mod index 2730c76812..952ffb20e4 100644 --- a/plugins/otel/go.mod +++ b/plugins/otel/go.mod @@ -120,7 +120,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect diff --git a/plugins/otel/go.sum b/plugins/otel/go.sum index f0bdde3dde..789d0c75f8 100644 --- a/plugins/otel/go.sum +++ b/plugins/otel/go.sum @@ -297,8 +297,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/semanticcache/go.mod b/plugins/semanticcache/go.mod index 560cb62d8c..704ce8ea79 100644 --- a/plugins/semanticcache/go.mod +++ b/plugins/semanticcache/go.mod @@ -119,7 +119,7 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/plugins/semanticcache/go.sum b/plugins/semanticcache/go.sum index 98b91ffbb4..a11c1de7ff 100644 --- a/plugins/semanticcache/go.sum +++ b/plugins/semanticcache/go.sum @@ -291,8 +291,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/plugins/telemetry/go.mod b/plugins/telemetry/go.mod index 87b141d8c8..a06bf45ece 100644 --- a/plugins/telemetry/go.mod +++ b/plugins/telemetry/go.mod @@ -124,7 +124,7 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/plugins/telemetry/go.sum b/plugins/telemetry/go.sum index ca7d971da8..b34fc4246f 100644 --- a/plugins/telemetry/go.sum +++ b/plugins/telemetry/go.sum @@ -303,8 +303,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/transports/bifrost-http/handlers/logging.go b/transports/bifrost-http/handlers/logging.go index 3e57c66329..dc33e67d10 100644 --- a/transports/bifrost-http/handlers/logging.go +++ b/transports/bifrost-http/handlers/logging.go @@ -80,6 +80,9 @@ func (h *LoggingHandler) RegisterRoutes(r *router.Router, middlewares ...schemas r.GET("/api/logs/histogram/cost/by-provider", lib.ChainMiddlewares(h.getLogsProviderCostHistogram, middlewares...)) r.GET("/api/logs/histogram/tokens/by-provider", lib.ChainMiddlewares(h.getLogsProviderTokenHistogram, middlewares...)) r.GET("/api/logs/histogram/latency/by-provider", lib.ChainMiddlewares(h.getLogsProviderLatencyHistogram, middlewares...)) + r.GET("/api/logs/histogram/cost/by-dimension", lib.ChainMiddlewares(h.getLogsDimensionCostHistogram, middlewares...)) + r.GET("/api/logs/histogram/tokens/by-dimension", lib.ChainMiddlewares(h.getLogsDimensionTokenHistogram, middlewares...)) + r.GET("/api/logs/histogram/latency/by-dimension", lib.ChainMiddlewares(h.getLogsDimensionLatencyHistogram, middlewares...)) r.GET("/api/logs/dropped", lib.ChainMiddlewares(h.getDroppedRequests, middlewares...)) r.GET("/api/logs/filterdata", lib.ChainMiddlewares(h.getAvailableFilterData, middlewares...)) r.GET("/api/logs/rankings", lib.ChainMiddlewares(h.getModelRankings, middlewares...)) @@ -250,6 +253,18 @@ func (h *LoggingHandler) getLogs(ctx *fasthttp.RequestCtx) { if routingRuleIDs := string(ctx.QueryArgs().Peek("routing_rule_ids")); routingRuleIDs != "" { filters.RoutingRuleIDs = parseCommaSeparated(routingRuleIDs) } + if teamIDs := string(ctx.QueryArgs().Peek("team_ids")); teamIDs != "" { + filters.TeamIDs = parseCommaSeparated(teamIDs) + } + if customerIDs := string(ctx.QueryArgs().Peek("customer_ids")); customerIDs != "" { + filters.CustomerIDs = parseCommaSeparated(customerIDs) + } + if userIDs := string(ctx.QueryArgs().Peek("user_ids")); userIDs != "" { + filters.UserIDs = parseCommaSeparated(userIDs) + } + if businessUnitIDs := string(ctx.QueryArgs().Peek("business_unit_ids")); businessUnitIDs != "" { + filters.BusinessUnitIDs = parseCommaSeparated(businessUnitIDs) + } if routingEngines := string(ctx.QueryArgs().Peek("routing_engine_used")); routingEngines != "" { filters.RoutingEngineUsed = parseCommaSeparated(routingEngines) } @@ -467,6 +482,18 @@ func (h *LoggingHandler) getLogsStats(ctx *fasthttp.RequestCtx) { if routingRuleIDs := string(ctx.QueryArgs().Peek("routing_rule_ids")); routingRuleIDs != "" { filters.RoutingRuleIDs = parseCommaSeparated(routingRuleIDs) } + if teamIDs := string(ctx.QueryArgs().Peek("team_ids")); teamIDs != "" { + filters.TeamIDs = parseCommaSeparated(teamIDs) + } + if customerIDs := string(ctx.QueryArgs().Peek("customer_ids")); customerIDs != "" { + filters.CustomerIDs = parseCommaSeparated(customerIDs) + } + if userIDs := string(ctx.QueryArgs().Peek("user_ids")); userIDs != "" { + filters.UserIDs = parseCommaSeparated(userIDs) + } + if businessUnitIDs := string(ctx.QueryArgs().Peek("business_unit_ids")); businessUnitIDs != "" { + filters.BusinessUnitIDs = parseCommaSeparated(businessUnitIDs) + } if routingEngines := string(ctx.QueryArgs().Peek("routing_engine_used")); routingEngines != "" { filters.RoutingEngineUsed = parseCommaSeparated(routingEngines) } @@ -602,6 +629,18 @@ func parseHistogramFilters(ctx *fasthttp.RequestCtx) *logstore.SearchFilters { if routingRuleIDs := string(ctx.QueryArgs().Peek("routing_rule_ids")); routingRuleIDs != "" { filters.RoutingRuleIDs = parseCommaSeparated(routingRuleIDs) } + if teamIDs := string(ctx.QueryArgs().Peek("team_ids")); teamIDs != "" { + filters.TeamIDs = parseCommaSeparated(teamIDs) + } + if customerIDs := string(ctx.QueryArgs().Peek("customer_ids")); customerIDs != "" { + filters.CustomerIDs = parseCommaSeparated(customerIDs) + } + if userIDs := string(ctx.QueryArgs().Peek("user_ids")); userIDs != "" { + filters.UserIDs = parseCommaSeparated(userIDs) + } + if businessUnitIDs := string(ctx.QueryArgs().Peek("business_unit_ids")); businessUnitIDs != "" { + filters.BusinessUnitIDs = parseCommaSeparated(businessUnitIDs) + } if routingEngines := string(ctx.QueryArgs().Peek("routing_engine_used")); routingEngines != "" { filters.RoutingEngineUsed = parseCommaSeparated(routingEngines) } @@ -763,6 +802,78 @@ func (h *LoggingHandler) getLogsProviderLatencyHistogram(ctx *fasthttp.RequestCt SendJSON(ctx, result) } +// parseDimension extracts and validates the "dimension" query parameter. +// Returns the validated HistogramDimension and true on success, or sends an error response and returns false. +func parseDimension(ctx *fasthttp.RequestCtx) (logstore.HistogramDimension, bool) { + dim := logstore.HistogramDimension(string(ctx.QueryArgs().Peek("dimension"))) + if dim == "" { + SendError(ctx, fasthttp.StatusBadRequest, "Missing required query parameter: dimension. Valid values: provider, team_id, customer_id, user_id, business_unit_id") + return "", false + } + if !logstore.ValidHistogramDimensions[dim] { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid dimension: %s. Valid values: provider, team_id, customer_id, user_id, business_unit_id", dim)) + return "", false + } + return dim, true +} + +// getLogsDimensionCostHistogram handles GET /api/logs/histogram/cost/by-dimension +// Returns time-bucketed cost data grouped by the dimension specified in the "dimension" query param. +func (h *LoggingHandler) getLogsDimensionCostHistogram(ctx *fasthttp.RequestCtx) { + dimension, ok := parseDimension(ctx) + if !ok { + return + } + filters := parseHistogramFilters(ctx) + bucketSizeSeconds := calculateBucketSize(filters.StartTime, filters.EndTime) + + result, err := h.logManager.GetDimensionCostHistogram(ctx, filters, bucketSizeSeconds, dimension) + if err != nil { + logger.Error("failed to get dimension cost histogram: %v", err) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Dimension cost histogram calculation failed: %v", err)) + return + } + SendJSON(ctx, result) +} + +// getLogsDimensionTokenHistogram handles GET /api/logs/histogram/tokens/by-dimension +// Returns time-bucketed token usage grouped by the dimension specified in the "dimension" query param. +func (h *LoggingHandler) getLogsDimensionTokenHistogram(ctx *fasthttp.RequestCtx) { + dimension, ok := parseDimension(ctx) + if !ok { + return + } + filters := parseHistogramFilters(ctx) + bucketSizeSeconds := calculateBucketSize(filters.StartTime, filters.EndTime) + + result, err := h.logManager.GetDimensionTokenHistogram(ctx, filters, bucketSizeSeconds, dimension) + if err != nil { + logger.Error("failed to get dimension token histogram: %v", err) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Dimension token histogram calculation failed: %v", err)) + return + } + SendJSON(ctx, result) +} + +// getLogsDimensionLatencyHistogram handles GET /api/logs/histogram/latency/by-dimension +// Returns time-bucketed latency percentiles grouped by the dimension specified in the "dimension" query param. +func (h *LoggingHandler) getLogsDimensionLatencyHistogram(ctx *fasthttp.RequestCtx) { + dimension, ok := parseDimension(ctx) + if !ok { + return + } + filters := parseHistogramFilters(ctx) + bucketSizeSeconds := calculateBucketSize(filters.StartTime, filters.EndTime) + + result, err := h.logManager.GetDimensionLatencyHistogram(ctx, filters, bucketSizeSeconds, dimension) + if err != nil { + logger.Error("failed to get dimension latency histogram: %v", err) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Dimension latency histogram calculation failed: %v", err)) + return + } + SendJSON(ctx, result) +} + // getDroppedRequests handles GET /api/logs/dropped - Get the number of dropped requests func (h *LoggingHandler) getDroppedRequests(ctx *fasthttp.RequestCtx) { droppedRequests := h.logManager.GetDroppedRequests(ctx) @@ -794,6 +905,10 @@ func (h *LoggingHandler) getAvailableFilterData(ctx *fasthttp.RequestCtx) { virtualKeys []logging.KeyPair routingRules []logging.KeyPair routingEngines []string + teams []logging.KeyPair + customers []logging.KeyPair + users []logging.KeyPair + businessUnits []logging.KeyPair metadataKeys map[string][]string mu sync.Mutex ) @@ -842,6 +957,34 @@ func (h *LoggingHandler) getAvailableFilterData(ctx *fasthttp.RequestCtx) { mu.Unlock() return nil }) + g.Go(func() error { + result := h.logManager.GetAvailableTeams(gCtx) + mu.Lock() + teams = result + mu.Unlock() + return nil + }) + g.Go(func() error { + result := h.logManager.GetAvailableCustomers(gCtx) + mu.Lock() + customers = result + mu.Unlock() + return nil + }) + g.Go(func() error { + result := h.logManager.GetAvailableUsers(gCtx) + mu.Lock() + users = result + mu.Unlock() + return nil + }) + g.Go(func() error { + result := h.logManager.GetAvailableBusinessUnits(gCtx) + mu.Lock() + businessUnits = result + mu.Unlock() + return nil + }) g.Go(func() error { result, err := h.logManager.GetAvailableMetadataKeys(gCtx) if err != nil { @@ -941,7 +1084,7 @@ func (h *LoggingHandler) getAvailableFilterData(ctx *fasthttp.RequestCtx) { if metadataKeys == nil { metadataKeys = make(map[string][]string) } - SendJSON(ctx, map[string]interface{}{"models": models, "aliases": aliases, "selected_keys": selectedKeysArray, "virtual_keys": virtualKeysArray, "routing_rules": routingRulesArray, "routing_engines": routingEngines, "metadata_keys": metadataKeys}) + SendJSON(ctx, map[string]interface{}{"models": models, "aliases": aliases, "selected_keys": selectedKeysArray, "virtual_keys": virtualKeysArray, "routing_rules": routingRulesArray, "routing_engines": routingEngines, "teams": teams, "customers": customers, "users": users, "business_units": businessUnits, "metadata_keys": metadataKeys}) } // deleteLogs handles DELETE /api/logs - Delete logs by their IDs diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index e81456dc3b..b9ba087947 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -12573,6 +12573,91 @@ func TestSQLite_Governance_DBOnly_AllPreserved(t *testing.T) { t.Log("✓ All dashboard-added entities preserved on reload") } +// TestSQLite_Governance_PricingOverrides_Reconciliation tests that pricing overrides +// defined in config.json are properly reconciled on reload (create, update, preserve). +func TestSQLite_Governance_PricingOverrides_Reconciliation(t *testing.T) { + initTestLogger() + tempDir := createTempDir(t) + + configData := makeConfigDataWithProvidersAndDir(nil, tempDir) + configData.Governance = &configstore.GovernanceConfig{ + PricingOverrides: []tables.TablePricingOverride{ + { + ID: "po-1", + Name: "Override One", + ScopeKind: "global", + MatchType: "exact", + Pattern: "gpt-4", + RequestTypes: []schemas.RequestType{ + schemas.ChatCompletionRequest, + }, + }, + }, + } + createConfigFile(t, tempDir, configData) + + ctx := context.Background() + + // First load: pricing override should be created in the DB + config1, err := LoadConfig(ctx, tempDir) + if err != nil { + t.Fatalf("First LoadConfig failed: %v", err) + } + + gov1, err := config1.ConfigStore.GetGovernanceConfig(ctx) + if err != nil { + t.Fatalf("Failed to get governance config after first load: %v", err) + } + if len(gov1.PricingOverrides) != 1 { + t.Fatalf("Expected 1 pricing override after first load, got %d", len(gov1.PricingOverrides)) + } + if gov1.PricingOverrides[0].ID != "po-1" { + t.Errorf("Expected pricing override ID 'po-1', got '%s'", gov1.PricingOverrides[0].ID) + } + if gov1.PricingOverrides[0].ConfigHash == "" { + t.Error("Pricing override hash not set after first load") + } + config1.Close(ctx) + + // Second load (unchanged config): should NOT fail with duplicate key error + config2, err := LoadConfig(ctx, tempDir) + if err != nil { + t.Fatalf("Second LoadConfig failed (duplicate key bug): %v", err) + } + + gov2, err := config2.ConfigStore.GetGovernanceConfig(ctx) + if err != nil { + t.Fatalf("Failed to get governance config after second load: %v", err) + } + if len(gov2.PricingOverrides) != 1 { + t.Fatalf("Expected 1 pricing override after second load, got %d", len(gov2.PricingOverrides)) + } + config2.Close(ctx) + + // Third load (updated config): should update the existing override, not create a duplicate + configData.Governance.PricingOverrides[0].Pattern = "gpt-4o" + createConfigFile(t, tempDir, configData) + + config3, err := LoadConfig(ctx, tempDir) + if err != nil { + t.Fatalf("Third LoadConfig failed: %v", err) + } + defer config3.Close(ctx) + + gov3, err := config3.ConfigStore.GetGovernanceConfig(ctx) + if err != nil { + t.Fatalf("Failed to get governance config after third load: %v", err) + } + if len(gov3.PricingOverrides) != 1 { + t.Fatalf("Expected 1 pricing override after update, got %d", len(gov3.PricingOverrides)) + } + if gov3.PricingOverrides[0].Pattern != "gpt-4o" { + t.Errorf("Pricing override pattern not updated: got '%s', want 'gpt-4o'", gov3.PricingOverrides[0].Pattern) + } + + t.Log("✓ Pricing overrides reconciliation works correctly (create, idempotent reload, update)") +} + // =================================================================================== // RUNTIME VS MIGRATION HASH PARITY TESTS (SQLite Integration) // =================================================================================== diff --git a/transports/go.mod b/transports/go.mod index 55c00cb50a..93dc88acfb 100644 --- a/transports/go.mod +++ b/transports/go.mod @@ -114,7 +114,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/maximhq/bifrost/plugins/mocker v1.5.0 // indirect - github.com/maximhq/maxim-go v0.2.0 // indirect + github.com/maximhq/maxim-go v0.2.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oapi-codegen/runtime v1.1.1 // indirect github.com/oklog/ulid v1.3.1 // indirect @@ -173,7 +173,7 @@ require ( golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect diff --git a/transports/go.sum b/transports/go.sum index a70bc9ed4b..c9ca25c119 100644 --- a/transports/go.sum +++ b/transports/go.sum @@ -233,8 +233,7 @@ github.com/maximhq/bifrost/plugins/semanticcache v1.5.0 h1:tibnQ8lSnKXujnjL4mt84 github.com/maximhq/bifrost/plugins/semanticcache v1.5.0/go.mod h1:+NfIRAlHpuh5ORv0MoOf5f8uY4WPx6v/8Kuk+8FEGnw= github.com/maximhq/bifrost/plugins/telemetry v1.5.0 h1:hECZgcsqeJSmiLrWONTFFU6APzTyILQzZuVV96oql5Q= github.com/maximhq/bifrost/plugins/telemetry v1.5.0/go.mod h1:dl/4mtQhxooqU+r42hXajhUaq04S1X3LaH+km5UJAy0= -github.com/maximhq/maxim-go v0.2.0 h1:3SNpna+Z9bDcUBqPLV/pfaZaxTEtsyix7Rn1KtwoEp4= -github.com/maximhq/maxim-go v0.2.0/go.mod h1:RvESsFEUWSJmIypHtV+vHN2EWjp+HoqWGStEvB/cPBU= +github.com/maximhq/maxim-go v0.2.1 h1:hCp8dQ4HsyyNC+y5HCUuY/HFD0sOnGkjL5MdYCHkgEQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= @@ -391,8 +390,7 @@ golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0c golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/ui/app/_fallbacks/enterprise/components/user-rankings/userRankingsTab.tsx b/ui/app/_fallbacks/enterprise/components/user-rankings/userRankingsTab.tsx new file mode 100644 index 0000000000..d1cca8c2e8 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/user-rankings/userRankingsTab.tsx @@ -0,0 +1,17 @@ +import { Users } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export default function UserRankingsTab() { + return ( +
+ } + title="Unlock user rankings for better visibility" + description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you." + readmeLink="https://docs.getbifrost.ai/enterprise/user-rankings" + testIdPrefix="user-rankings" + /> +
+ ); +} diff --git a/ui/app/workspace/dashboard/page.tsx b/ui/app/workspace/dashboard/page.tsx index 9566a3d426..5b7538067b 100644 --- a/ui/app/workspace/dashboard/page.tsx +++ b/ui/app/workspace/dashboard/page.tsx @@ -43,6 +43,7 @@ import { MCPTab } from "./components/mcpTab"; import { ModelRankingsTab } from "./components/modelRankingsTab"; import { OverviewTab } from "./components/overviewTab"; import { ProviderUsageTab } from "./components/providerUsageTab"; +import UserRankingsTab from "@enterprise/components/user-rankings/userRankingsTab"; // Type-safe parser for chart type URL state const toChartType = (value: string): ChartType => (value === "line" ? "line" : "bar"); @@ -672,6 +673,9 @@ export default function DashboardPage() { MCP usage + + User Rankings + {/* Overview Tab */} @@ -768,6 +772,11 @@ export default function DashboardPage() { onMcpCostChartToggle={handleMcpCostChartToggle} /> + + {/* User Rankings Tab (Enterprise) */} + + + ); diff --git a/ui/app/workspace/logs/page.tsx b/ui/app/workspace/logs/page.tsx index 7bd845e089..08229ca99e 100644 --- a/ui/app/workspace/logs/page.tsx +++ b/ui/app/workspace/logs/page.tsx @@ -90,6 +90,10 @@ export default function LogsPage() { virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]), routing_rule_ids: parseAsArrayOf(parseAsString).withDefault([]), routing_engine_used: parseAsArrayOf(parseAsString).withDefault([]), + user_ids: parseAsArrayOf(parseAsString).withDefault([]), + team_ids: parseAsArrayOf(parseAsString).withDefault([]), + customer_ids: parseAsArrayOf(parseAsString).withDefault([]), + business_unit_ids: parseAsArrayOf(parseAsString).withDefault([]), content_search: parseAsString.withDefault(""), start_time: parseAsInteger.withDefault(defaultTimeRange.startTime), end_time: parseAsInteger.withDefault(defaultTimeRange.endTime), @@ -200,6 +204,10 @@ export default function LogsPage() { virtual_key_ids: urlState.virtual_key_ids, routing_rule_ids: urlState.routing_rule_ids, routing_engine_used: urlState.routing_engine_used, + user_ids: urlState.user_ids, + team_ids: urlState.team_ids, + customer_ids: urlState.customer_ids, + business_unit_ids: urlState.business_unit_ids, content_search: urlState.content_search, start_time: dateUtils.toISOString(urlState.start_time), end_time: dateUtils.toISOString(urlState.end_time), @@ -217,8 +225,11 @@ export default function LogsPage() { // Only re-derive filters when filter-related URL params change (not pagination) [ urlState.providers, urlState.models, urlState.aliases, urlState.status, urlState.objects, - urlState.parent_request_id, urlState.selected_key_ids, urlState.virtual_key_ids, urlState.routing_rule_ids, - urlState.routing_engine_used, urlState.content_search, + urlState.selected_key_ids, urlState.virtual_key_ids, urlState.routing_rule_ids, + urlState.routing_engine_used, + urlState.user_ids, urlState.team_ids, urlState.customer_ids, urlState.business_unit_ids, + urlState.content_search, + urlState.parent_request_id, urlState.start_time, urlState.end_time, urlState.missing_cost_only, urlState.metadata_filters, ], @@ -255,6 +266,10 @@ export default function LogsPage() { virtual_key_ids: newFilters.virtual_key_ids || [], routing_rule_ids: newFilters.routing_rule_ids || [], routing_engine_used: newFilters.routing_engine_used || [], + user_ids: newFilters.user_ids || [], + team_ids: newFilters.team_ids || [], + customer_ids: newFilters.customer_ids || [], + business_unit_ids: newFilters.business_unit_ids || [], content_search: newFilters.content_search || "", start_time: newFilters.start_time ? dateUtils.toUnixTimestamp(new Date(newFilters.start_time)) : undefined, end_time: newFilters.end_time ? dateUtils.toUnixTimestamp(new Date(newFilters.end_time)) : undefined, @@ -680,6 +695,18 @@ export default function LogsPage() { // Helper function to check if a log matches the current filters const matchesFilters = (log: LogEntry, filters: LogFilters, applyTimeFilters = true): boolean => { + if (filters.user_ids?.length) { + if (!log.user_id || !filters.user_ids.includes(log.user_id)) return false; + } + if (filters.team_ids?.length) { + if (!log.team_id || !filters.team_ids.includes(log.team_id)) return false; + } + if (filters.customer_ids?.length) { + if (!log.customer_id || !filters.customer_ids.includes(log.customer_id)) return false; + } + if (filters.business_unit_ids?.length) { + if (!log.business_unit_id || !filters.business_unit_ids.includes(log.business_unit_id)) return false; + } if (filters.missing_cost_only && typeof log.cost === "number" && log.cost > 0) { return false; } diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index 240991ba0e..423109beb1 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -48,6 +48,7 @@ import PluginLogsView from "../views/pluginLogsView"; import SpeechView from "../views/speechView"; import TranscriptionView from "../views/transcriptionView"; import VideoView from "../views/videoView"; +import Link from "next/link"; const formatJsonSafe = (str: string | undefined): string => { try { @@ -85,7 +86,14 @@ interface LogDetailViewProps { onFilterByParentRequestId?: (parentRequestId: string) => void; } -export function LogDetailView({ log, loading = false, handleDelete, onClose, headerAction, onFilterByParentRequestId }: LogDetailViewProps) { +export function LogDetailView({ + log, + loading = false, + handleDelete, + onClose, + headerAction, + onFilterByParentRequestId, +}: LogDetailViewProps) { const { copy: copyRequestId } = useCopyToClipboard({ successMessage: "Request ID copied" }); const { copy: copyBody } = useCopyToClipboard({ successMessage: "Request body copied to clipboard", @@ -127,14 +135,14 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea ) : ( <>
-
+
{headerAction}
{log.id && (

Request ID:{" "} - copyRequestId(log.id)}> + copyRequestId(log.id)}> {log.id}

@@ -166,10 +174,7 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea - copyRequestBody(log, copyBody)} - data-testid="logdetails-copy-request-body-button" - > + copyRequestBody(log, copyBody)} data-testid="logdetails-copy-request-body-button"> Copy request body @@ -185,9 +190,7 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea Are you sure you want to delete this log? - - This action cannot be undone. This will permanently delete the log entry. - + This action cannot be undone. This will permanently delete the log entry. Cancel @@ -214,7 +217,9 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea {!isContainer && } - {!isContainer && log.alias && ( - - )} + {!isContainer && log.alias && } +
{RequestTypeLabels[log.object as keyof typeof RequestTypeLabels] ?? log.object ?? "unknown"}
} @@ -259,7 +264,7 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea onFilterByParentRequestId(log.parent_request_id as string)} > {log.parent_request_id} @@ -268,13 +273,76 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea Filter this session ) : ( - {log.parent_request_id} + {log.parent_request_id} ) } /> )} {log.selected_key && } - {log.number_of_retries > 0 && } + {log.number_of_retries > 0 && ( + + )} + {log.team_id && ( + + {log.team_name || log.team_id} + + } + /> + )} + {log.customer_id && ( + + {log.customer_name || log.customer_id} + + } + /> + )} + {log.business_unit_id && ( + + {log.business_unit_name || log.business_unit_id} + + } + /> + )} + {log.user_id && ( + + {log.user_id} + + } + /> + )} + {log.fallback_index > 0 && } {log.virtual_key && } {log.routing_engines_used && log.routing_engines_used.length > 0 && ( @@ -302,8 +370,12 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea {(log.params as any)?.audio && ( <> - {(log.params as any).audio.format && } - {(log.params as any).audio.voice && } + {(log.params as any).audio.format && ( + + )} + {(log.params as any).audio.voice && ( + + )} )} @@ -311,7 +383,9 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea <> {passthroughParams.method && } {passthroughParams.path && } - {passthroughParams.raw_query && } + {passthroughParams.raw_query && ( + + )} {(passthroughParams.status_code ?? 0) !== 0 && ( )} @@ -338,33 +412,65 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea - + {log.token_usage?.prompt_tokens_details && ( <> {log.token_usage.prompt_tokens_details.cached_read_tokens && ( - + )} {log.token_usage.prompt_tokens_details.cached_write_tokens && ( - + )} {log.token_usage.prompt_tokens_details.audio_tokens && ( - + )} )} {log.token_usage?.completion_tokens_details && ( <> {log.token_usage.completion_tokens_details.reasoning_tokens && ( - + )} {log.token_usage.completion_tokens_details.audio_tokens && ( - + )} {log.token_usage.completion_tokens_details.accepted_prediction_tokens && ( - + )} {log.token_usage.completion_tokens_details.rejected_prediction_tokens && ( - + )} )} @@ -415,9 +521,7 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea } /> )} - {reasoning.max_tokens && ( - - )} + {reasoning.max_tokens && }
@@ -460,7 +564,11 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea )} {log.cache_debug.similarity && ( - + )} {log.cache_debug.input_tokens && ( @@ -521,7 +629,16 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea {log.plugin_logs && } {toolsParameter && ( toolsParameter}> - + )} {log.params?.instructions && ( @@ -531,14 +648,17 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea
)} - {(log.speech_input || log.speech_output) && } + {(log.speech_input || log.speech_output) && ( + + )} {(log.transcription_input || log.transcription_output) && ( - + )} - {(log.image_generation_input || - log.image_edit_input || - log.image_variation_input || - log.image_generation_output) && ( + {(log.image_generation_input || log.image_edit_input || log.image_variation_input || log.image_generation_output) && ( )} {(log.video_generation_input || videoOutput || videoListOutput) && ( - + )} {log.list_models_output && ( - JSON.stringify(log.list_models_output, null, 2)}> - + JSON.stringify(log.list_models_output, null, 2)} + > + )} - {isPassthrough && - passthroughRequestBody && ( - { + {isPassthrough && passthroughRequestBody && ( + { + try { + return JSON.stringify(JSON.parse(passthroughRequestBody || ""), null, 2); + } catch { + return passthroughRequestBody || ""; + } + }} + > + { try { return JSON.stringify(JSON.parse(passthroughRequestBody || ""), null, 2); } catch { return passthroughRequestBody || ""; } - }} - > - { - try { - return JSON.stringify(JSON.parse(passthroughRequestBody || ""), null, 2); - } catch { - return passthroughRequestBody || ""; - } - })()} lang="json" readonly={true} options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }} /> - - )} + })()} + lang="json" + readonly={true} + options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }} + /> + + )} {log.input_history && log.input_history.length > 1 && ( <>
Conversation History
@@ -635,23 +780,46 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea } }} > - { - try { - return JSON.stringify(JSON.parse(passthroughResponseBody || ""), null, 2); - } catch { - return passthroughResponseBody || ""; - } - })()} lang="json" readonly={true} options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }} /> + { + try { + return JSON.stringify(JSON.parse(passthroughResponseBody || ""), null, 2); + } catch { + return passthroughResponseBody || ""; + } + })()} + lang="json" + readonly={true} + options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }} + />
)} {rawRequest && ( <>
Raw Request sent to {log.provider} - {log.is_large_payload_request && (truncated preview)} + {log.is_large_payload_request && ( + (truncated preview) + )}
- formatJsonSafe(rawRequest)}> - + formatJsonSafe(rawRequest)} + > + )} @@ -659,10 +827,24 @@ export function LogDetailView({ log, loading = false, handleDelete, onClose, hea <>
Raw Response from {log.provider} - {log.is_large_payload_response && (truncated preview)} + {log.is_large_payload_response && ( + (truncated preview) + )}
- formatJsonSafe(rawResponse)}> - + formatJsonSafe(rawResponse)} + > + )} @@ -752,7 +934,10 @@ const copyRequestBody = async (log: LogEntry, copy: (text: string) => Promise block && block.type === "text" && block.text).map((block: any) => block.text).join("\n"); + return message.content + .filter((block: any) => block && block.type === "text" && block.text) + .map((block: any) => block.text) + .join("\n"); } return ""; }; diff --git a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx index ac7f28e104..7820717f46 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx @@ -491,7 +491,12 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, return ( !open && handleClose()}> - + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > {isEditing ? virtualKey?.name : "Create Virtual Key"} diff --git a/ui/lib/store/apis/baseApi.ts b/ui/lib/store/apis/baseApi.ts index 330bfb7948..9d51fa171b 100644 --- a/ui/lib/store/apis/baseApi.ts +++ b/ui/lib/store/apis/baseApi.ts @@ -163,6 +163,7 @@ export const baseApi = createApi({ "Versions", "Sessions", "AccessProfiles", + "BusinessUnits", ], endpoints: () => ({}), }); diff --git a/ui/lib/store/apis/logsApi.ts b/ui/lib/store/apis/logsApi.ts index 5a49b5430c..652c9db635 100644 --- a/ui/lib/store/apis/logsApi.ts +++ b/ui/lib/store/apis/logsApi.ts @@ -62,6 +62,18 @@ function buildFilterParams(filters: LogFilters): Record if (filters.max_tokens !== undefined) params.max_tokens = filters.max_tokens; if (filters.missing_cost_only) params.missing_cost_only = "true"; if (filters.content_search) params.content_search = filters.content_search; + if (filters.user_ids && filters.user_ids.length > 0) { + params.user_ids = filters.user_ids.join(","); + } + if (filters.team_ids && filters.team_ids.length > 0) { + params.team_ids = filters.team_ids.join(","); + } + if (filters.customer_ids && filters.customer_ids.length > 0) { + params.customer_ids = filters.customer_ids.join(","); + } + if (filters.business_unit_ids && filters.business_unit_ids.length > 0) { + params.business_unit_ids = filters.business_unit_ids.join(","); + } if (filters.metadata_filters) { for (const [key, value] of Object.entries(filters.metadata_filters)) { params[`metadata_${key}`] = value; diff --git a/ui/lib/types/logs.ts b/ui/lib/types/logs.ts index a2e82b79db..437b8a6950 100644 --- a/ui/lib/types/logs.ts +++ b/ui/lib/types/logs.ts @@ -440,7 +440,6 @@ export interface ImageVariationInput { image: { image: string | null }; // image bytes null when stripped by large-payload threshold } - // Main LogEntry interface matching backend export interface LogEntry { id: string; @@ -453,6 +452,13 @@ export interface LogEntry { number_of_retries: number; fallback_index: number; selected_key_id: string; + team_name?: string; + team_id?: string; + customer_name?: string; + customer_id?: string; + business_unit_id?: string; + business_unit_name?: string; + user_id?: string; virtual_key_id?: string; routing_engines_used?: string[]; routing_rule_id?: string; @@ -522,6 +528,10 @@ export interface LogFilters { missing_cost_only?: boolean; content_search?: string; metadata_filters?: Record; // key=metadataKey, value=metadataValue for filtering by metadata + user_ids?: string[]; + team_ids?: string[]; + customer_ids?: string[]; + business_unit_ids?: string[]; } export interface Pagination { @@ -1079,6 +1089,25 @@ export interface ModelRankingsResponse { rankings: ModelRankingEntry[]; } +export interface UserRankingTrend { + has_previous_period: boolean; + requests_trend: number; + tokens_trend: number; + cost_trend: number; +} + +export interface UserRankingEntry { + user_id: string; + total_requests: number; + total_tokens: number; + total_cost: number; + trend: UserRankingTrend; +} + +export interface UserRankingsResponse { + rankings: UserRankingEntry[]; +} + // Date utility functions for URL state management export const dateUtils = { /**