diff --git a/.github/workflows/scripts/cost-accuracy-test.sh b/.github/workflows/scripts/cost-accuracy-test.sh
index 16c1b919f66..e106cc5cc80 100755
--- a/.github/workflows/scripts/cost-accuracy-test.sh
+++ b/.github/workflows/scripts/cost-accuracy-test.sh
@@ -357,11 +357,22 @@ params = {
"order": "asc",
}
+def logs_complete(logs):
+ # Log writes are fully async (single batched insert in PostLLMHook), so a
+ # row can be visible before its usage/cost are readable. Poll on the
+ # predicate we assert (every row has usage and cost), not just row count.
+ return all(
+ (item.get("token_usage") or {}).get("prompt_tokens") is not None
+ and (item.get("token_usage") or {}).get("completion_tokens") is not None
+ and item.get("cost") is not None
+ for item in logs
+ )
+
logs = []
for _ in range(60):
payload = get_json("/api/logs", params)
logs = payload.get("logs", [])
- if len(logs) >= expected_count:
+ if len(logs) >= expected_count and logs_complete(logs):
break
time.sleep(1)
diff --git a/.github/workflows/scripts/run-migration-tests.sh b/.github/workflows/scripts/run-migration-tests.sh
index 049c6de2fc4..77b202b17ab 100755
--- a/.github/workflows/scripts/run-migration-tests.sh
+++ b/.github/workflows/scripts/run-migration-tests.sh
@@ -819,7 +819,8 @@ append_dynamic_mcp_clients_insert() {
generate_pricing_overrides_insert_postgres "$now" "$faker_sql"
generate_mcp_library_insert_postgres "$now" "$faker_sql"
generate_skills_repo_tables_insert_postgres "$now" "$faker_sql"
- generate_sidekiq_insert_postgres "$now" "$faker_sql"
+ generate_oauth2_issuance_tables_insert_postgres "$now" "$faker_sql"
+ generate_sidekiq_insert_postgres "$now" "$past" "$faker_sql"
append_dynamic_columns_postgres "$now" "$past" "$faker_sql"
else
now="datetime('now')"
@@ -840,7 +841,8 @@ append_dynamic_mcp_clients_insert() {
generate_pricing_overrides_insert_sqlite "$now" "$faker_sql" "$config_db"
generate_mcp_library_insert_sqlite "$now" "$faker_sql" "$config_db"
generate_skills_repo_tables_insert_sqlite "$now" "$faker_sql" "$config_db"
- generate_sidekiq_insert_sqlite "$now" "$faker_sql" "$config_db"
+ generate_oauth2_issuance_tables_insert_sqlite "$now" "$faker_sql" "$config_db"
+ generate_sidekiq_insert_sqlite "$now" "$past" "$faker_sql" "$config_db"
append_dynamic_columns_sqlite "$now" "$past" "$faker_sql" "$config_db"
fi
}
@@ -2077,6 +2079,37 @@ append_dynamic_columns_postgres() {
echo "UPDATE logs SET redaction_mapping = '' WHERE id = 'log-migration-test-001';" >> "$output_file"
echo "UPDATE logs SET redaction_mapping = '' WHERE id = 'log-migration-test-002';" >> "$output_file"
echo "UPDATE logs SET redaction_mapping = '' WHERE id = 'log-migration-test-003';" >> "$output_file"
+ # v1.6.3 columns - config store tables
+ # -------------------------------------------------------------------------
+
+ # config_client.mcp_server_auth_mode (added in v1.6.3 - varchar(20), default 'headers')
+ if column_exists_postgres "config_client" "mcp_server_auth_mode"; then
+ echo "UPDATE config_client SET mcp_server_auth_mode = 'headers' WHERE id = 1;" >> "$output_file"
+ fi
+
+ # config_client.oauth2_server_config_json (added in v1.6.3 - text, empty string when unset)
+ if column_exists_postgres "config_client" "oauth2_server_config_json"; then
+ echo "UPDATE config_client SET oauth2_server_config_json = '' WHERE id = 1;" >> "$output_file"
+ fi
+
+ # config_keys.bedrock_mantle_* (added in v1.6.3 - nullable text SecretVars for Bedrock Mantle auth)
+ for mantle_col in bedrock_mantle_access_key bedrock_mantle_secret_key bedrock_mantle_session_token bedrock_mantle_region bedrock_mantle_role_arn bedrock_mantle_external_id bedrock_mantle_role_session_name; do
+ if column_exists_postgres "config_keys" "$mantle_col"; then
+ echo "UPDATE config_keys SET $mantle_col = NULL WHERE name = 'migration-test-key-openai';" >> "$output_file"
+ echo "UPDATE config_keys SET $mantle_col = NULL WHERE name = 'migration-test-key-anthropic';" >> "$output_file"
+ fi
+ done
+
+ # governance_model_pricing.is_deprecated (added in v1.6.3 - bool, default false)
+ if column_exists_postgres "governance_model_pricing" "is_deprecated"; then
+ echo "UPDATE governance_model_pricing SET is_deprecated = false WHERE id = 1;" >> "$output_file"
+ echo "UPDATE governance_model_pricing SET is_deprecated = false WHERE id = 2;" >> "$output_file"
+ fi
+
+ # governance_virtual_keys.expires_at (added in v1.6.3 - nullable timestamp, NULL = never expires)
+ if column_exists_postgres "governance_virtual_keys" "expires_at"; then
+ echo "UPDATE governance_virtual_keys SET expires_at = NULL WHERE id = 'vk-migration-test-1';" >> "$output_file"
+ echo "UPDATE governance_virtual_keys SET expires_at = NULL WHERE id = 'vk-migration-test-2';" >> "$output_file"
fi
# -------------------------------------------------------------------------
@@ -2131,6 +2164,51 @@ append_dynamic_columns_postgres() {
echo "UPDATE logs SET server_side_fallback_model = NULL WHERE id = 'log-migration-test-001';" >> "$output_file"
echo "UPDATE logs SET server_side_fallback_model = 'gpt-4-turbo' WHERE id = 'log-migration-test-002';" >> "$output_file"
echo "UPDATE logs SET server_side_fallback_model = NULL WHERE id = 'log-migration-test-003';" >> "$output_file"
+ # v1.6.4 columns
+ # -------------------------------------------------------------------------
+
+ # config_keys.vertex_force_single_region (added in v1.6.4 via add_vertex_force_single_region_column - nullable bool)
+ if column_exists_postgres "config_keys" "vertex_force_single_region"; then
+ echo "UPDATE config_keys SET vertex_force_single_region = NULL WHERE name = 'migration-test-key-openai';" >> "$output_file"
+ echo "UPDATE config_keys SET vertex_force_single_region = NULL WHERE name = 'migration-test-key-anthropic';" >> "$output_file"
+ fi
+
+ # config_keys.bedrock_project_id, bedrock_mantle_project_id (added in v1.6.4 via add_bedrock_project_id_columns - nullable text SecretVars)
+ for bedrock_proj_col in bedrock_project_id bedrock_mantle_project_id; do
+ if column_exists_postgres "config_keys" "$bedrock_proj_col"; then
+ echo "UPDATE config_keys SET $bedrock_proj_col = NULL WHERE name = 'migration-test-key-openai';" >> "$output_file"
+ echo "UPDATE config_keys SET $bedrock_proj_col = NULL WHERE name = 'migration-test-key-anthropic';" >> "$output_file"
+ fi
+ done
+
+ # governance_model_pricing flex/272k cache-creation tiers (added in v1.6.4 via
+ # add_flex_and_cache_creation_272k_pricing_columns), fast-mode cache pricing
+ # (add_fast_mode_cache_pricing_columns), and inference geo multiplier
+ # (add_inference_geo_multiplier_column) - all nullable float64
+ for pricing_col in \
+ input_cost_per_token_flex_above_272k_tokens \
+ output_cost_per_token_flex_above_272k_tokens \
+ cache_read_input_token_cost_flex_above_272k_tokens \
+ cache_creation_input_token_cost_above_272k_tokens \
+ cache_creation_input_token_cost_flex \
+ cache_creation_input_token_cost_flex_above_272k_tokens \
+ cache_creation_input_token_cost_priority \
+ cache_creation_input_token_cost_fast \
+ cache_creation_input_token_cost_above_1hr_fast \
+ cache_read_input_token_cost_fast \
+ inference_geo_us_multiplier; do
+ if column_exists_postgres "governance_model_pricing" "$pricing_col"; then
+ echo "UPDATE governance_model_pricing SET $pricing_col = NULL WHERE id = 1;" >> "$output_file"
+ echo "UPDATE governance_model_pricing SET $pricing_col = NULL WHERE id = 2;" >> "$output_file"
+ fi
+ done
+
+ # logs.redaction_mapping (added in v1.6.4 via logs_add_redaction_mapping_column -
+ # nullable text, stores the encrypted reversible redaction mapping)
+ if column_exists_postgres "logs" "redaction_mapping"; then
+ echo "UPDATE logs SET redaction_mapping = NULL WHERE id = 'log-migration-test-001';" >> "$output_file"
+ echo "UPDATE logs SET redaction_mapping = NULL WHERE id = 'log-migration-test-002';" >> "$output_file"
+ echo "UPDATE logs SET redaction_mapping = NULL WHERE id = 'log-migration-test-003';" >> "$output_file"
fi
}
@@ -3195,6 +3273,80 @@ append_dynamic_columns_sqlite() {
echo "UPDATE governance_model_pricing SET output_cost_per_token_fast = NULL WHERE id = 1;" >> "$output_file"
echo "UPDATE governance_model_pricing SET output_cost_per_token_fast = NULL WHERE id = 2;" >> "$output_file"
fi
+
+ # -----------------------------------------------------------------------
+ # v1.6.3 columns - config store tables
+ # -----------------------------------------------------------------------
+
+ # config_client.mcp_server_auth_mode (added in v1.6.3 - varchar(20), default 'headers')
+ if column_exists_sqlite "$config_db" "config_client" "mcp_server_auth_mode"; then
+ echo "UPDATE config_client SET mcp_server_auth_mode = 'headers' WHERE id = 1;" >> "$output_file"
+ fi
+
+ # config_client.oauth2_server_config_json (added in v1.6.3 - text, empty string when unset)
+ if column_exists_sqlite "$config_db" "config_client" "oauth2_server_config_json"; then
+ echo "UPDATE config_client SET oauth2_server_config_json = '' WHERE id = 1;" >> "$output_file"
+ fi
+
+ # config_keys.bedrock_mantle_* (added in v1.6.3 - nullable text SecretVars for Bedrock Mantle auth)
+ for mantle_col in bedrock_mantle_access_key bedrock_mantle_secret_key bedrock_mantle_session_token bedrock_mantle_region bedrock_mantle_role_arn bedrock_mantle_external_id bedrock_mantle_role_session_name; do
+ if column_exists_sqlite "$config_db" "config_keys" "$mantle_col"; then
+ echo "UPDATE config_keys SET $mantle_col = NULL WHERE name = 'migration-test-key-openai';" >> "$output_file"
+ echo "UPDATE config_keys SET $mantle_col = NULL WHERE name = 'migration-test-key-anthropic';" >> "$output_file"
+ fi
+ done
+
+ # governance_model_pricing.is_deprecated (added in v1.6.3 - bool, default false)
+ if column_exists_sqlite "$config_db" "governance_model_pricing" "is_deprecated"; then
+ echo "UPDATE governance_model_pricing SET is_deprecated = 0 WHERE id = 1;" >> "$output_file"
+ echo "UPDATE governance_model_pricing SET is_deprecated = 0 WHERE id = 2;" >> "$output_file"
+ fi
+
+ # governance_virtual_keys.expires_at (added in v1.6.3 - nullable timestamp, NULL = never expires)
+ if column_exists_sqlite "$config_db" "governance_virtual_keys" "expires_at"; then
+ echo "UPDATE governance_virtual_keys SET expires_at = NULL WHERE id = 'vk-migration-test-1';" >> "$output_file"
+ echo "UPDATE governance_virtual_keys SET expires_at = NULL WHERE id = 'vk-migration-test-2';" >> "$output_file"
+ fi
+
+ # -----------------------------------------------------------------------
+ # v1.6.4 columns
+ # -----------------------------------------------------------------------
+
+ # config_keys.vertex_force_single_region (added in v1.6.4 via add_vertex_force_single_region_column - nullable bool)
+ if column_exists_sqlite "$config_db" "config_keys" "vertex_force_single_region"; then
+ echo "UPDATE config_keys SET vertex_force_single_region = NULL WHERE name = 'migration-test-key-openai';" >> "$output_file"
+ echo "UPDATE config_keys SET vertex_force_single_region = NULL WHERE name = 'migration-test-key-anthropic';" >> "$output_file"
+ fi
+
+ # config_keys.bedrock_project_id, bedrock_mantle_project_id (added in v1.6.4 via add_bedrock_project_id_columns - nullable text SecretVars)
+ for bedrock_proj_col in bedrock_project_id bedrock_mantle_project_id; do
+ if column_exists_sqlite "$config_db" "config_keys" "$bedrock_proj_col"; then
+ echo "UPDATE config_keys SET $bedrock_proj_col = NULL WHERE name = 'migration-test-key-openai';" >> "$output_file"
+ echo "UPDATE config_keys SET $bedrock_proj_col = NULL WHERE name = 'migration-test-key-anthropic';" >> "$output_file"
+ fi
+ done
+
+ # governance_model_pricing flex/272k cache-creation tiers (added in v1.6.4 via
+ # add_flex_and_cache_creation_272k_pricing_columns), fast-mode cache pricing
+ # (add_fast_mode_cache_pricing_columns), and inference geo multiplier
+ # (add_inference_geo_multiplier_column) - all nullable float64
+ for pricing_col in \
+ input_cost_per_token_flex_above_272k_tokens \
+ output_cost_per_token_flex_above_272k_tokens \
+ cache_read_input_token_cost_flex_above_272k_tokens \
+ cache_creation_input_token_cost_above_272k_tokens \
+ cache_creation_input_token_cost_flex \
+ cache_creation_input_token_cost_flex_above_272k_tokens \
+ cache_creation_input_token_cost_priority \
+ cache_creation_input_token_cost_fast \
+ cache_creation_input_token_cost_above_1hr_fast \
+ cache_read_input_token_cost_fast \
+ inference_geo_us_multiplier; do
+ if column_exists_sqlite "$config_db" "governance_model_pricing" "$pricing_col"; then
+ echo "UPDATE governance_model_pricing SET $pricing_col = NULL WHERE id = 1;" >> "$output_file"
+ echo "UPDATE governance_model_pricing SET $pricing_col = NULL WHERE id = 2;" >> "$output_file"
+ fi
+ done
fi
# logs multi-team/BU/customer JSON-array columns (added in v1.5.9 via
@@ -3259,6 +3411,12 @@ append_dynamic_columns_sqlite() {
echo "UPDATE logs SET redaction_mapping = '' WHERE id = 'log-migration-test-001';" >> "$output_file"
echo "UPDATE logs SET redaction_mapping = '' WHERE id = 'log-migration-test-002';" >> "$output_file"
echo "UPDATE logs SET redaction_mapping = '' WHERE id = 'log-migration-test-003';" >> "$output_file"
+ # logs.redaction_mapping (added in v1.6.4 via logs_add_redaction_mapping_column -
+ # nullable text, stores the encrypted reversible redaction mapping)
+ if column_exists_sqlite "$logs_db" "logs" "redaction_mapping"; then
+ echo "UPDATE logs SET redaction_mapping = NULL WHERE id = 'log-migration-test-001';" >> "$output_file"
+ echo "UPDATE logs SET redaction_mapping = NULL WHERE id = 'log-migration-test-002';" >> "$output_file"
+ echo "UPDATE logs SET redaction_mapping = NULL WHERE id = 'log-migration-test-003';" >> "$output_file"
fi
# -------------------------------------------------------------------------
@@ -3397,11 +3555,6 @@ generate_mcp_clients_insert_postgres() {
vals="$vals, 'oauth-config-migration-test-001'"
fi
- if column_exists_postgres "config_mcp_clients" "tool_execution_timeout"; then
- cols="$cols, tool_execution_timeout"
- vals="$vals, 30"
- fi
-
# config_mcp_clients.encryption_status (added in v1.4.8)
if column_exists_postgres "config_mcp_clients" "encryption_status"; then
cols="$cols, encryption_status"
@@ -3453,6 +3606,12 @@ generate_mcp_clients_insert_postgres() {
vals="$vals, '[\"X-Tenant-Id\",\"X-Request-Id\"]'"
fi
+ # config_mcp_clients.tool_execution_timeout (added in v1.6.3 - per-client timeout in seconds, 0 = use global)
+ if column_exists_postgres "config_mcp_clients" "tool_execution_timeout"; then
+ cols="$cols, tool_execution_timeout"
+ vals="$vals, 0"
+ fi
+
# Append the dynamic INSERT to the output file
echo "" >> "$output_file"
echo "-- config_mcp_clients (MCP server configurations - dynamically generated based on schema)" >> "$output_file"
@@ -3668,11 +3827,6 @@ generate_mcp_clients_insert_sqlite() {
vals="$vals, 'oauth-config-migration-test-001'"
fi
- if column_exists_sqlite "$config_db" "config_mcp_clients" "tool_execution_timeout"; then
- cols="$cols, tool_execution_timeout"
- vals="$vals, 30"
- fi
-
# config_mcp_clients.encryption_status (added in v1.4.8)
if column_exists_sqlite "$config_db" "config_mcp_clients" "encryption_status"; then
cols="$cols, encryption_status"
@@ -3724,6 +3878,12 @@ generate_mcp_clients_insert_sqlite() {
vals="$vals, '[\"X-Tenant-Id\",\"X-Request-Id\"]'"
fi
+ # config_mcp_clients.tool_execution_timeout (added in v1.6.3 - per-client timeout in seconds, 0 = use global)
+ if column_exists_sqlite "$config_db" "config_mcp_clients" "tool_execution_timeout"; then
+ cols="$cols, tool_execution_timeout"
+ vals="$vals, 0"
+ fi
+
# Append the dynamic INSERT to the output file
echo "" >> "$output_file"
echo "-- config_mcp_clients (MCP server configurations - dynamically generated based on schema)" >> "$output_file"
@@ -4031,44 +4191,6 @@ generate_skills_repo_tables_insert_sqlite() {
echo "INSERT INTO skill_files (id, skill_version_id, path, source_type, source_url, storage_key, blob_id, mime_type, file_size_bytes, created_at, updated_at) VALUES ('skill-file-migration-002', 'skill-version-migration-002', 'reference/doc.md', 'url', 'https://example.com/doc.md', 'skills/skill-002/doc.md', NULL, 'text/markdown', 0, $now, $now) ON CONFLICT DO NOTHING;" >> "$output_file"
}
-# Generate sidekiq (durable background-job) table INSERTs for PostgreSQL.
-# Table added in v1.6.4-era via migrationAddSidekiqTable; no FKs.
-generate_sidekiq_insert_postgres() {
- local now="$1"
- local output_file="$2"
-
- if ! column_exists_postgres "sidekiq" "id"; then
- return
- fi
-
- echo "" >> "$output_file"
- echo "-- sidekiq (durable background-job queue)" >> "$output_file"
- echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-001', 'migration-test-job', 'completed', 'runner-migration-test-001', '{}', 1, '', $now, $now, $now, 'migration-tester', $now) ON CONFLICT DO NOTHING;" >> "$output_file"
- echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-002', 'migration-test-job', 'pending', NULL, '{}', 0, NULL, $now, $now, NULL, NULL, NULL) ON CONFLICT DO NOTHING;" >> "$output_file"
-}
-
-# Generate sidekiq (durable background-job) table INSERTs for SQLite.
-generate_sidekiq_insert_sqlite() {
- local now="$1"
- local output_file="$2"
- local config_db="$3"
-
- if [ ! -f "$config_db" ]; then
- return
- fi
-
- local table_exists
- table_exists=$(sqlite3 "$config_db" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sidekiq';" 2>/dev/null || echo "0")
- if [ "$table_exists" != "1" ]; then
- return
- fi
-
- echo "" >> "$output_file"
- echo "-- sidekiq (durable background-job queue)" >> "$output_file"
- echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-001', 'migration-test-job', 'completed', 'runner-migration-test-001', '{}', 1, '', $now, $now, $now, 'migration-tester', $now) ON CONFLICT DO NOTHING;" >> "$output_file"
- echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-002', 'migration-test-job', 'pending', NULL, '{}', 0, NULL, $now, $now, NULL, NULL, NULL) ON CONFLICT DO NOTHING;" >> "$output_file"
-}
-
# Generate per-user OAuth tables INSERTs for PostgreSQL
# These tables were added in v1.5.0-prerelease4 via migrationAddPerUserOAuthTables
generate_per_user_oauth_tables_insert_postgres() {
@@ -4326,6 +4448,85 @@ generate_temp_tokens_insert_sqlite() {
echo "INSERT INTO temp_tokens (id, token, token_hash, scope, resource_id, expires_at, created_at, updated_at, encryption_status) VALUES ('temp-token-migration-test-001', 'migration-test-temp-token-value-001', 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', 'mcp_auth', 'oauth-config-migration-test-001', datetime('now', '+15 minutes'), $now, $now, 'plain_text') ON CONFLICT DO NOTHING;" >> "$output_file"
}
+# Generate sidekiq INSERT for PostgreSQL (added in v1.6.4 via migrationAddSidekiqTable)
+# Only terminal statuses (completed/failed) are seeded: the sidekiq runner and reaper
+# mutate pending/running jobs on startup, which would diff the before/after snapshots.
+generate_sidekiq_insert_postgres() {
+ local now="$1"
+ local past="$2"
+ local output_file="$3"
+
+ if ! column_exists_postgres "sidekiq" "id"; then
+ return
+ fi
+
+ echo "" >> "$output_file"
+ echo "-- sidekiq (generic durable background jobs - added in v1.6.4, dynamically generated)" >> "$output_file"
+ echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-001', 'migration-test-kind', 'completed', 'runner-migration-test-001', '{\"cursor\":\"migration-test\"}', 1, '', $past, $now, $past, 'user-migration-test-001', $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+ echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-002', 'migration-test-kind', 'failed', '', '{}', 3, 'migration test failure message', $past, $now, $past, NULL, NULL) ON CONFLICT DO NOTHING;" >> "$output_file"
+}
+
+# Generate sidekiq INSERT for SQLite (added in v1.6.4 via migrationAddSidekiqTable)
+generate_sidekiq_insert_sqlite() {
+ local now="$1"
+ local past="$2"
+ local output_file="$3"
+ local config_db="$4"
+
+ if [ ! -f "$config_db" ]; then
+ return
+ fi
+
+ if ! column_exists_sqlite "$config_db" "sidekiq" "id"; then
+ return
+ fi
+
+ echo "" >> "$output_file"
+ echo "-- sidekiq (generic durable background jobs - added in v1.6.4, dynamically generated)" >> "$output_file"
+ echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-001', 'migration-test-kind', 'completed', 'runner-migration-test-001', '{\"cursor\":\"migration-test\"}', 1, '', $past, $now, $past, 'user-migration-test-001', $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+ echo "INSERT INTO sidekiq (id, kind, status, runner_id, metadata, attempts, last_error, created_at, updated_at, started_at, created_by_user_id, completed_at) VALUES ('sidekiq-migration-test-002', 'migration-test-kind', 'failed', '', '{}', 3, 'migration test failure message', $past, $now, $past, NULL, NULL) ON CONFLICT DO NOTHING;" >> "$output_file"
+}
+
+# Generate oauth2_clients / oauth2_authorize_requests / oauth2_refresh_tokens INSERTs for PostgreSQL
+# (added in v1.6.3 via migrationAddOAuth2IssuanceTables - MCP OAuth2 authorization server)
+# Every column is named explicitly so faker coverage validation passes; the authorize request
+# uses a future expires_at and 'consented' status so no TTL cleanup mutates it between snapshots.
+generate_oauth2_issuance_tables_insert_postgres() {
+ local now="$1"
+ local output_file="$2"
+
+ if ! column_exists_postgres "oauth2_clients" "id"; then
+ return
+ fi
+
+ echo "" >> "$output_file"
+ echo "-- oauth2 issuance tables (MCP OAuth2 authorization server - added in v1.6.3, dynamically generated)" >> "$output_file"
+ echo "INSERT INTO oauth2_clients (id, client_id, client_name, redirect_uris_json, grant_types_json, scope, created_at) VALUES ('oauth2-client-migration-test-001', 'migration-test-oauth2-client-001', 'Migration Test Client', '[\"http://localhost:3000/callback\"]', '[\"authorization_code\",\"refresh_token\"]', 'openid', $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+ echo "INSERT INTO oauth2_authorize_requests (id, client_id, redirect_uri, state, scope, resource, code_challenge, code_challenge_method, status, bf_mode, bf_sub, code_hash, expires_at, created_at, updated_at) VALUES ('oauth2-authreq-migration-test-001', 'migration-test-oauth2-client-001', 'http://localhost:3000/callback', 'migration-test-state-001', 'openid', 'https://bifrost.example.com/mcp', 'migration-test-challenge-001', 'S256', 'consented', 'vk', 'vk-migration-test-1', 'migration-test-code-hash-001', $now + INTERVAL '10 minutes', $now, $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+ echo "INSERT INTO oauth2_refresh_tokens (id, token_hash, family_id, client_id, bf_mode, bf_sub, scope, resource, revoked_at, last_used_at, created_at) VALUES ('oauth2-rt-migration-test-001', 'migration-test-rt-hash-001', 'oauth2-authreq-migration-test-001', 'migration-test-oauth2-client-001', 'vk', 'vk-migration-test-1', 'openid', 'https://bifrost.example.com/mcp', NULL, NULL, $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+}
+
+# Generate oauth2 issuance table INSERTs for SQLite (added in v1.6.3 via migrationAddOAuth2IssuanceTables)
+generate_oauth2_issuance_tables_insert_sqlite() {
+ local now="$1"
+ local output_file="$2"
+ local config_db="$3"
+
+ if [ ! -f "$config_db" ]; then
+ return
+ fi
+
+ if ! column_exists_sqlite "$config_db" "oauth2_clients" "id"; then
+ return
+ fi
+
+ echo "" >> "$output_file"
+ echo "-- oauth2 issuance tables (MCP OAuth2 authorization server - added in v1.6.3, dynamically generated)" >> "$output_file"
+ echo "INSERT INTO oauth2_clients (id, client_id, client_name, redirect_uris_json, grant_types_json, scope, created_at) VALUES ('oauth2-client-migration-test-001', 'migration-test-oauth2-client-001', 'Migration Test Client', '[\"http://localhost:3000/callback\"]', '[\"authorization_code\",\"refresh_token\"]', 'openid', $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+ echo "INSERT INTO oauth2_authorize_requests (id, client_id, redirect_uri, state, scope, resource, code_challenge, code_challenge_method, status, bf_mode, bf_sub, code_hash, expires_at, created_at, updated_at) VALUES ('oauth2-authreq-migration-test-001', 'migration-test-oauth2-client-001', 'http://localhost:3000/callback', 'migration-test-state-001', 'openid', 'https://bifrost.example.com/mcp', 'migration-test-challenge-001', 'S256', 'consented', 'vk', 'vk-migration-test-1', 'migration-test-code-hash-001', datetime('now', '+10 minutes'), $now, $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+ echo "INSERT INTO oauth2_refresh_tokens (id, token_hash, family_id, client_id, bf_mode, bf_sub, scope, resource, revoked_at, last_used_at, created_at) VALUES ('oauth2-rt-migration-test-001', 'migration-test-rt-hash-001', 'oauth2-authreq-migration-test-001', 'migration-test-oauth2-client-001', 'vk', 'vk-migration-test-1', 'openid', 'https://bifrost.example.com/mcp', NULL, NULL, $now) ON CONFLICT DO NOTHING;" >> "$output_file"
+}
+
# Generate governance_model_parameters INSERT for PostgreSQL
# This table stores model parameters/capabilities data synced from external API
generate_model_parameters_insert_postgres() {
diff --git a/README.md b/README.md
index 48971ec7dc1..e826b463edf 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ curl -X POST http://localhost:8080/v1/chat/completions \
## Enterprise Deployments
Bifrost supports enterprise-grade, private deployments for teams running production AI systems at scale.
-In addition to private networking, custom security controls, and governance, enterprise deployments unlock advanced capabilities including adaptive load balancing, clustering, guardrails, MCP gateway and and other features designed for enterprise-grade scale and reliability.
+In addition to private networking, custom security controls, and governance, enterprise deployments unlock advanced capabilities including adaptive load balancing, clustering, guardrails, MCP gateway, and other features designed for enterprise-grade scale and reliability.
@@ -88,7 +88,7 @@ In addition to private networking, custom security controls, and governance, ent
- **[Model Context Protocol (MCP)](https://docs.getbifrost.ai/mcp/overview)** - Enable AI models to use external tools (filesystem, web search, databases)
- **[Semantic Caching](https://docs.getbifrost.ai/features/semantic-caching)** - Intelligent response caching based on semantic similarity to reduce costs and latency
-- **[Multimodal Support](https://docs.getbifrost.ai/quickstart/gateway/streaming)** - Support for text,images, audio, and streaming, all behind a common interface.
+- **[Multimodal Support](https://docs.getbifrost.ai/quickstart/gateway/streaming)** - Support for text, images, audio, and streaming, all behind a common interface.
- **[Custom Plugins](https://docs.getbifrost.ai/enterprise/custom-plugins)** - Extensible middleware architecture for analytics, monitoring, and custom logic
- **[Governance](https://docs.getbifrost.ai/features/governance/virtual-keys)** - Usage tracking, rate limiting, and fine-grained access control
@@ -120,8 +120,8 @@ bifrost/
│ ├── schemas/ # Interfaces and structs used throughout Bifrost
│ └── bifrost.go # Main Bifrost implementation
├── framework/ # Framework components for data persistence
-│ ├── configstore/ # Configuration storages
-│ ├── logstore/ # Request logging storages
+│ ├── configstore/ # Configuration storage backends
+│ ├── logstore/ # Request logging storage backends
│ └── vectorstore/ # Vector storages
├── transports/ # HTTP gateway and other interface layers
│ └── bifrost-http/ # HTTP transport implementation
@@ -181,7 +181,7 @@ go get github.com/maximhq/bifrost/core
- base_url = "https://api.openai.com"
+ base_url = "http://localhost:8080/openai"
-# Anthropic SDK
+# Anthropic SDK
- base_url = "https://api.anthropic.com"
+ base_url = "http://localhost:8080/anthropic"
@@ -241,7 +241,7 @@ Bifrost adds virtually zero overhead to your AI requests. In sustained 5,000 RPS
- [AWS Bedrock SDK](https://docs.getbifrost.ai/integrations/bedrock-sdk/overview) - AWS Bedrock integration
- [Google GenAI SDK](https://docs.getbifrost.ai/integrations/genai-sdk/overview) - Drop-in GenAI replacement
- [LiteLLM SDK](https://docs.getbifrost.ai/integrations/litellm-sdk) - LiteLLM integration
-- [Langchain SDK](https://docs.getbifrost.ai/integrations/langchain-sdk) - Langchain integration
+- [LangChain SDK](https://docs.getbifrost.ai/integrations/langchain-sdk) - LangChain integration
### Enterprise
@@ -259,7 +259,7 @@ Bifrost adds virtually zero overhead to your AI requests. In sustained 5,000 RPS
Get help with:
- Quick setup assistance and troubleshooting
-- Best practices and configuration tips
+- Best practices and configuration tips
- Community discussions and support
- Real-time help with integrations
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 00000000000..ebe91368fe6
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,42 @@
+# Third-Party Notices
+
+Bifrost is licensed under the [Apache License, Version 2.0](LICENSE). This file lists third-party components with license terms beyond the common MIT/BSD/Apache-2.0 permissive set — specifically, code carrying Mozilla Public License 2.0 (MPL-2.0) terms — along with third-party source code embedded directly in this repository. All components below are used unmodified and combined as a "Larger Work" per MPL-2.0 Section 3.3; no Bifrost source files are themselves MPL-licensed.
+
+This file does not enumerate the full dependency tree (see `go.sum` / `package-lock.json` in each module for that) — only the entries that need attribution beyond what those permissive licenses require.
+
+## Embedded source code
+
+### `framework/migrator/migrator.go`
+
+Portions of this file are derived from [go-gormigrate/gormigrate](https://github.com/go-gormigrate/gormigrate).
+
+```
+MIT License
+Copyright (c) 2016 Andrey Nering
+```
+
+Full license text is preserved in the file's header comment.
+
+## MPL-2.0 components — Go (compiled into the binary)
+
+### `github.com/cyphar/filepath-securejoin`
+
+Dual-licensed: most files are BSD-3-Clause; a subset of files (see the package's own `COPYING.md`) are MPL-2.0. Used unmodified as an upstream dependency. Source: https://github.com/cyphar/filepath-securejoin
+
+### `github.com/hashicorp/go-version`
+
+Licensed under MPL-2.0. Copyright IBM Corp. Used unmodified as an upstream dependency. Source: https://github.com/hashicorp/go-version
+
+## MPL-2.0 / dual-licensed components — npm (build tooling only, not shipped)
+
+### `lightningcss`
+
+Licensed under MPL-2.0. Pulled in transitively via `vite`/`tailwindcss` as a build-time devDependency — it runs during the UI build and is never bundled into the built output shipped to end users. Source: https://github.com/parcel-bundler/lightningcss
+
+### `dompurify`
+
+Dual-licensed `MPL-2.0 OR Apache-2.0`. Bifrost elects the **Apache-2.0** option; no MPL-2.0 obligations apply to Bifrost's use of this package. Source: https://github.com/cure53/DOMPurify
+
+---
+
+*This file covers MPL-2.0 attribution only. It is not a complete open-source license inventory and is not a substitute for legal review.*
diff --git a/cmd/e2eseed/go.mod b/cmd/e2eseed/go.mod
new file mode 100644
index 00000000000..3fe7e24ab02
--- /dev/null
+++ b/cmd/e2eseed/go.mod
@@ -0,0 +1,138 @@
+module github.com/maximhq/bifrost/cmd/e2eseed
+
+go 1.26.5
+
+replace (
+ github.com/maximhq/bifrost/core => ../../core
+ github.com/maximhq/bifrost/framework => ../../framework
+)
+
+require (
+ github.com/maximhq/bifrost/core v1.7.5
+ github.com/maximhq/bifrost/framework v1.3.15
+ gorm.io/driver/postgres v1.6.0
+ gorm.io/driver/sqlite v1.6.0
+ gorm.io/gorm v1.31.1
+)
+
+require (
+ cel.dev/expr v0.25.1 // indirect
+ cloud.google.com/go v0.123.0 // indirect
+ cloud.google.com/go/auth v0.20.0 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ cloud.google.com/go/iam v1.7.0 // indirect
+ cloud.google.com/go/monitoring v1.24.3 // indirect
+ cloud.google.com/go/storage v1.62.1 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
+ github.com/ClickHouse/ch-go v0.65.0 // indirect
+ github.com/ClickHouse/clickhouse-go/v2 v2.32.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
+ github.com/andybalholm/brotli v1.2.2 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
+ github.com/aws/smithy-go v1.27.1 // indirect
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/buger/jsonparser v1.1.2 // indirect
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.15.1 // indirect
+ github.com/bytedance/sonic/loader v0.5.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
+ github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
+ github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/go-faster/city v1.0.1 // indirect
+ github.com/go-faster/errors v0.7.1 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
+ github.com/googleapis/gax-go/v2 v2.22.0 // indirect
+ github.com/hashicorp/go-version v1.8.0 // indirect
+ github.com/invopop/jsonschema v0.13.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.9.2 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
+ github.com/mailru/easyjson v0.9.1 // indirect
+ github.com/mark3labs/mcp-go v0.43.2 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-sqlite3 v1.14.32 // indirect
+ github.com/paulmach/orb v0.11.1 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
+ github.com/rs/zerolog v1.34.0 // indirect
+ github.com/segmentio/asm v1.2.0 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
+ github.com/tidwall/gjson v1.18.0 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.71.0 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.43.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect
+ golang.org/x/arch v0.23.0 // indirect
+ golang.org/x/crypto v0.52.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/api v0.282.0 // indirect
+ google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect
+ google.golang.org/grpc v1.82.1 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ gorm.io/driver/clickhouse v0.7.0 // indirect
+)
diff --git a/cmd/e2eseed/go.sum b/cmd/e2eseed/go.sum
new file mode 100644
index 00000000000..65457f73b68
--- /dev/null
+++ b/cmd/e2eseed/go.sum
@@ -0,0 +1,383 @@
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
+cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
+cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
+cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
+cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
+cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
+cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
+cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
+cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
+cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
+cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
+cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8=
+cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
+cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
+cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
+github.com/ClickHouse/ch-go v0.65.0 h1:vZAXfTQliuNNefqkPDewX3kgRxN6Q4vUENnnY+ynTRY=
+github.com/ClickHouse/ch-go v0.65.0/go.mod h1:tCM0XEH5oWngoi9Iu/8+tjPBo04I/FxNIffpdjtwx3k=
+github.com/ClickHouse/clickhouse-go/v2 v2.32.0 h1:zVWJUmUGdtCApM/vRfQhruGXIm1M643bk68B3IYbR1I=
+github.com/ClickHouse/clickhouse-go/v2 v2.32.0/go.mod h1:rGFIgeNbJVggBp2C+0FXOdfjsMlpsKx7FUYnHHyy2KE=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
+github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
+github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
+github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
+github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
+github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
+github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
+github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
+github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
+github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
+github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
+github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
+github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
+github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
+github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
+github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
+github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
+github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
+github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
+github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
+github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
+github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
+github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
+github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
+github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
+github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
+github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
+github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
+github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa24bR+2i53WBCz1nW/Pc47oVYauC4=
+github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
+github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
+github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
+github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
+go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.starlark.net v0.0.0-20260102030733-3fee463870c9 h1:nV1OyvU+0CYrp5eKfQ3rD03TpFYYhH08z31NK1HmtTk=
+go.starlark.net v0.0.0-20260102030733-3fee463870c9/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
+golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
+golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
+google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
+google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
+google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
+google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
+gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
+gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/cmd/e2eseed/main.go b/cmd/e2eseed/main.go
new file mode 100644
index 00000000000..ca1e7f69707
--- /dev/null
+++ b/cmd/e2eseed/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/maximhq/bifrost/cmd/e2eseed/seed"
+)
+
+// main runs the OSS API e2e seed command.
+func main() {
+ if err := run(context.Background(), os.Args[1:]); err != nil {
+ fmt.Fprintf(os.Stderr, "e2e seed failed: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+// run parses flags, connects to the target stores, and writes seed fixtures.
+func run(ctx context.Context, args []string) error {
+ opts := seed.DefaultOptions()
+ summaryPath := ""
+
+ fs := flag.NewFlagSet("e2eseed", flag.ContinueOnError)
+ fs.StringVar(&opts.Prefix, "prefix", opts.Prefix, "stable prefix for all seeded rows")
+ fs.StringVar(&opts.ConfigPath, "config-path", opts.ConfigPath, "optional Bifrost config.json path used to derive DB settings")
+ fs.StringVar(&opts.EncryptionKey, "encryption-key", opts.EncryptionKey, "optional Bifrost encryption key for encrypted config rows")
+ fs.StringVar(&opts.ConfigDialect, "config-db-dialect", opts.ConfigDialect, "config DB dialect: postgres or sqlite")
+ fs.StringVar(&opts.ConfigDSN, "config-db-dsn", opts.ConfigDSN, "config DB DSN")
+ fs.StringVar(&opts.LogsDialect, "logs-db-dialect", opts.LogsDialect, "logs DB dialect: postgres or sqlite")
+ fs.StringVar(&opts.LogsDSN, "logs-db-dsn", opts.LogsDSN, "logs DB DSN")
+ fs.IntVar(&opts.LogRowsPerShape, "logs-per-shape", opts.LogRowsPerShape, "number of log rows to seed per DAC ownership shape (applied to both logs and mcp_tool_logs)")
+ fs.IntVar(&opts.BatchSize, "batch-size", opts.BatchSize, "log insert batch size")
+ fs.StringVar(&opts.OutputEnvPath, "output-env", opts.OutputEnvPath, "path for generated environment values")
+ fs.StringVar(&summaryPath, "summary", "", "optional JSON summary path")
+ fs.BoolVar(&opts.DryRun, "dry-run", opts.DryRun, "build the manifest without writing rows")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ opts, err := seed.NormalizeOptions(opts)
+ if err != nil {
+ return err
+ }
+ seed.InitEncryption(opts)
+ configDB, err := seed.OpenDB(opts.ConfigDialect, opts.ConfigDSN)
+ if err != nil {
+ return fmt.Errorf("open config DB: %w", err)
+ }
+ if sqlDB, dbErr := configDB.DB(); dbErr == nil {
+ defer sqlDB.Close()
+ }
+ logsDB, err := seed.OpenDB(opts.LogsDialect, opts.LogsDSN)
+ if err != nil {
+ return fmt.Errorf("open logs DB: %w", err)
+ }
+ if sqlDB, dbErr := logsDB.DB(); dbErr == nil {
+ defer sqlDB.Close()
+ }
+
+ summary, err := seed.SeedBase(ctx, configDB, logsDB, opts)
+ if err != nil {
+ return err
+ }
+ if summaryPath != "" {
+ if err := seed.WriteJSONFile(summaryPath, summary); err != nil {
+ return err
+ }
+ }
+ fmt.Printf("seeded OSS API e2e data prefix=%s logs_per_shape=%d shapes=%d env=%s\n", summary.Prefix, summary.LogRowsPerShape, len(summary.Expected.Shapes), opts.OutputEnvPath)
+ return nil
+}
diff --git a/cmd/e2eseed/seed/seed.go b/cmd/e2eseed/seed/seed.go
new file mode 100644
index 00000000000..2108c936d8f
--- /dev/null
+++ b/cmd/e2eseed/seed/seed.go
@@ -0,0 +1,851 @@
+// Package seed creates deterministic OSS fixtures for API and DAC tests.
+package seed
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ bifrost "github.com/maximhq/bifrost/core"
+ "github.com/maximhq/bifrost/core/schemas"
+ "github.com/maximhq/bifrost/framework/configstore/tables"
+ "github.com/maximhq/bifrost/framework/encrypt"
+ "github.com/maximhq/bifrost/framework/logstore"
+ "gorm.io/driver/postgres"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// Options controls the shared seed dataset.
+type Options struct {
+ Prefix string
+ ConfigPath string
+ EncryptionKey string
+ ConfigDialect string
+ ConfigDSN string
+ LogsDialect string
+ LogsDSN string
+ LogRowsPerShape int
+ BatchSize int
+ OutputEnvPath string
+ DryRun bool
+}
+
+// Shape describes one DAC ownership combination.
+type Shape struct {
+ Name string `json:"name"`
+ UserID string `json:"user_id,omitempty"`
+ TeamID string `json:"team_id,omitempty"`
+ CustomerID string `json:"customer_id,omitempty"`
+ BusinessUnitID string `json:"business_unit_id,omitempty"`
+ VirtualKeyID string `json:"virtual_key_id,omitempty"`
+ Marker string `json:"marker"`
+ VisibleTo []string `json:"visible_to"`
+}
+
+// ExpectedManifest records seeded IDs and expected DAC visibility.
+type ExpectedManifest struct {
+ Prefix string `json:"prefix"`
+ Personas map[string]string `json:"personas"`
+ Shapes []Shape `json:"shapes"`
+ LogIDs map[string][]string `json:"log_ids"`
+ MCPLogIDs map[string][]string `json:"mcp_log_ids"`
+}
+
+// Summary is returned after a seed run.
+type Summary struct {
+ Prefix string `json:"prefix"`
+ LogRowsPerShape int `json:"log_rows_per_shape"`
+ SeedEnv map[string]string `json:"seed_env"`
+ Expected ExpectedManifest `json:"expected"`
+ TableCounts map[string]int64 `json:"table_counts"`
+ DryRun bool `json:"dry_run"`
+ GeneratedAt time.Time `json:"generated_at"`
+}
+
+// seedBaseTime is the per-run anchor timestamp for seeded log rows. SeedBase
+// refreshes it to time.Now().UTC() on every invocation so the rows land inside
+// the dashboard's default time-range filters (Last hour / Last day) instead of
+// drifting toward the original package-load timestamp on repeated seeds.
+var seedBaseTime = time.Now().UTC()
+
+// DefaultOptions returns defaults for local e2e seeding.
+func DefaultOptions() Options {
+ return Options{
+ Prefix: "e2e-seed",
+ ConfigDialect: "postgres",
+ LogsDialect: "postgres",
+ LogRowsPerShape: 30,
+ BatchSize: 1000,
+ OutputEnvPath: "tmp/e2e-seed.env",
+ }
+}
+
+// NormalizeOptions applies default values to unset options.
+func NormalizeOptions(opts Options) (Options, error) {
+ def := DefaultOptions()
+ if opts.Prefix == "" {
+ opts.Prefix = def.Prefix
+ }
+ if opts.ConfigPath != "" {
+ resolved, err := optionsFromConfigFile(opts.ConfigPath)
+ if err != nil {
+ return Options{}, fmt.Errorf("load config path %q: %w", opts.ConfigPath, err)
+ }
+ if opts.EncryptionKey == "" {
+ opts.EncryptionKey = resolved.EncryptionKey
+ }
+ if opts.ConfigDialect == "" && resolved.ConfigDialect != "" {
+ opts.ConfigDialect = resolved.ConfigDialect
+ }
+ if opts.ConfigDSN == "" && resolved.ConfigDSN != "" {
+ opts.ConfigDSN = resolved.ConfigDSN
+ }
+ if opts.LogsDialect == "" && resolved.LogsDialect != "" {
+ opts.LogsDialect = resolved.LogsDialect
+ }
+ if opts.LogsDSN == "" && resolved.LogsDSN != "" {
+ opts.LogsDSN = resolved.LogsDSN
+ }
+ }
+ if opts.ConfigDialect == "" {
+ opts.ConfigDialect = def.ConfigDialect
+ }
+ if opts.EncryptionKey == "" {
+ opts.EncryptionKey = os.Getenv("BIFROST_ENCRYPTION_KEY")
+ }
+ if opts.ConfigDSN == "" {
+ opts.ConfigDSN = "postgres://bifrost:bifrost_password@localhost:5432/bifrost?sslmode=disable"
+ }
+ if opts.LogsDialect == "" {
+ opts.LogsDialect = def.LogsDialect
+ }
+ if opts.LogsDSN == "" {
+ opts.LogsDSN = "postgres://bifrost:bifrost_password@localhost:5432/bifrost?sslmode=disable"
+ }
+ if opts.LogRowsPerShape <= 0 {
+ opts.LogRowsPerShape = def.LogRowsPerShape
+ }
+ if opts.BatchSize <= 0 {
+ opts.BatchSize = def.BatchSize
+ }
+ if opts.OutputEnvPath == "" {
+ opts.OutputEnvPath = def.OutputEnvPath
+ }
+ return opts, nil
+}
+
+// InitEncryption initializes Bifrost field encryption for DBs containing encrypted rows.
+func InitEncryption(opts Options) {
+ if opts.EncryptionKey == "" {
+ return
+ }
+ encrypt.Init(opts.EncryptionKey, bifrost.NewDefaultLogger(schemas.LogLevelWarn))
+}
+
+// optionsFromConfigFile extracts config/log store connection settings from a Bifrost config.json.
+func optionsFromConfigFile(path string) (Options, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return Options{}, err
+ }
+ var cfg struct {
+ EncryptionKey json.RawMessage `json:"encryption_key"`
+ ConfigStore storeConfig `json:"config_store"`
+ LogsStore storeConfig `json:"logs_store"`
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return Options{}, err
+ }
+ out := Options{}
+ out.EncryptionKey = rawConfigValue(cfg.EncryptionKey)
+ configDialect, configDSN, err := dsnFromStoreConfig(cfg.ConfigStore)
+ if err != nil {
+ return Options{}, err
+ }
+ out.ConfigDialect = configDialect
+ out.ConfigDSN = configDSN
+ logsDialect, logsDSN, err := dsnFromStoreConfig(cfg.LogsStore)
+ if err != nil {
+ return Options{}, err
+ }
+ out.LogsDialect = logsDialect
+ out.LogsDSN = logsDSN
+ return out, nil
+}
+
+// storeConfig is the subset of config.json needed to locate a DB-backed store.
+type storeConfig struct {
+ Enabled bool `json:"enabled"`
+ Type string `json:"type"`
+ Config map[string]json.RawMessage `json:"config"`
+}
+
+// dsnFromStoreConfig converts a raw store config into the dialect and DSN used by gorm.
+func dsnFromStoreConfig(store storeConfig) (string, string, error) {
+ if !store.Enabled {
+ return "", "", nil
+ }
+ switch store.Type {
+ case "postgres":
+ host := configValue(store.Config, "host")
+ port := configValue(store.Config, "port")
+ user := configValue(store.Config, "user")
+ password := configValue(store.Config, "password")
+ dbName := configValue(store.Config, "db_name")
+ sslMode := configValue(store.Config, "ssl_mode")
+ if sslMode == "" {
+ sslMode = "disable"
+ }
+ return "postgres", fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", url.QueryEscape(user), url.QueryEscape(password), host, port, dbName, sslMode), nil
+ case "sqlite":
+ return "sqlite", configValue(store.Config, "path"), nil
+ default:
+ return "", "", fmt.Errorf("unsupported store type %q", store.Type)
+ }
+}
+
+// configValue returns a raw JSON config value, resolving Bifrost env-var wrappers.
+func configValue(config map[string]json.RawMessage, key string) string {
+ return rawConfigValue(config[key])
+}
+
+// rawConfigValue returns a raw JSON config value, resolving Bifrost env-var wrappers.
+func rawConfigValue(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var text string
+ if err := json.Unmarshal(raw, &text); err == nil {
+ return schemas.NewSecretVar(text).GetValue()
+ }
+ var env schemas.SecretVar
+ if err := json.Unmarshal(raw, &env); err == nil {
+ return env.GetValue()
+ }
+ return strings.Trim(string(raw), `"`)
+}
+
+// OpenDB opens a supported GORM database.
+func OpenDB(dialect, dsn string) (*gorm.DB, error) {
+ switch strings.ToLower(strings.TrimSpace(dialect)) {
+ case "postgres", "postgresql":
+ if dsn == "" {
+ return nil, fmt.Errorf("postgres dsn is required")
+ }
+ return gorm.Open(postgres.Open(dsn), &gorm.Config{})
+ case "sqlite":
+ if dsn == "" {
+ return nil, fmt.Errorf("sqlite dsn is required")
+ }
+ return gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ default:
+ return nil, fmt.Errorf("unsupported db dialect %q", dialect)
+ }
+}
+
+// SeedBase writes the OSS-owned seed graph.
+func SeedBase(ctx context.Context, configDB, logsDB *gorm.DB, opts Options) (*Summary, error) {
+ opts, err := NormalizeOptions(opts)
+ if err != nil {
+ return nil, err
+ }
+ seedBaseTime = time.Now().UTC()
+ // Encryption is initialized by the caller (see cmd/e2eseed/main.go) before
+ // SeedBase runs, so we do not re-initialize it here.
+ env := SeedEnv(opts.Prefix)
+ manifest := BuildExpectedManifest(opts.Prefix, opts.LogRowsPerShape)
+ summary := &Summary{
+ Prefix: opts.Prefix,
+ LogRowsPerShape: opts.LogRowsPerShape,
+ SeedEnv: env,
+ Expected: manifest,
+ TableCounts: map[string]int64{},
+ DryRun: opts.DryRun,
+ GeneratedAt: time.Now().UTC(),
+ }
+ if opts.DryRun {
+ return summary, nil
+ }
+ if err := seedConfig(ctx, configDB, opts); err != nil {
+ return nil, err
+ }
+ if err := seedLogs(ctx, logsDB, opts, manifest); err != nil {
+ return nil, err
+ }
+ if err := WriteEnvFile(opts.OutputEnvPath, env); err != nil {
+ return nil, err
+ }
+ counts, err := CountKnownTables(ctx, configDB, logsDB)
+ if err != nil {
+ return nil, err
+ }
+ summary.TableCounts = counts
+ return summary, nil
+}
+
+// SeedEnv returns deterministic values shared by seeders and tests.
+func SeedEnv(prefix string) map[string]string {
+ return map[string]string{
+ "e2e_seed_prefix": prefix,
+ "enterprise_dac_model": "openai/gpt-4o-mini",
+ "enterprise_dac_visible_virtual_key": prefix + "-vk-user-team-secret",
+ "enterprise_dac_hidden_virtual_key": prefix + "-vk-outside-secret",
+ "e2e_seed_team_tiggings": prefix + "-team-tiggings",
+ "e2e_seed_team_outside": prefix + "-team-outside",
+ "e2e_seed_user_tiggings": prefix + "-user-tiggings",
+ "e2e_seed_user_outside": prefix + "-user-outside",
+ "e2e_seed_vk_user_team": prefix + "-vk-user-team",
+ "e2e_seed_vk_outside": prefix + "-vk-outside",
+ }
+}
+
+// BuildShapes returns the DAC ownership matrix.
+//
+// The set is built by enumerating all 15 non-empty subsets of the four DAC
+// dimensions {VirtualKey, UserID, TeamID, BusinessUnitID}, all flavoured with
+// tiggings-side values so the tiggings-flavoured personas see them, plus
+// three appended shapes for negative coverage:
+// - user-not-in-tiggings: outside user + BU (cross-team isolation)
+// - outside-team-virtual-key: outside user + team + BU + VK
+// - legacy-unowned: no DAC dimensions (fail-closed for non-admin)
+//
+// CustomerID is set together with BusinessUnitID when the B dimension is
+// present; the two columns are conceptually paired in the seeded dataset.
+//
+// The VirtualKey value for each tiggings shape is teamVK when the team
+// dimension is present without a user, so the team-only VK ownership path
+// (governance_virtual_keys.team_id) gets exercised. Every other VK-bearing
+// shape uses userVK so the user-attached VK ownership path is exercised too.
+func BuildShapes(prefix string) []Shape {
+ tiggingsUser := prefix + "-user-tiggings"
+ outsideUser := prefix + "-user-outside"
+ tiggingsTeam := prefix + "-team-tiggings"
+ outsideTeam := prefix + "-team-outside"
+ tiggingsCustomer := prefix + "-customer-tiggings"
+ outsideCustomer := prefix + "-customer-outside"
+ tiggingsBU := prefix + "-bu-tiggings"
+ outsideBU := prefix + "-bu-outside"
+ userVK := prefix + "-vk-user-team"
+ teamVK := prefix + "-vk-team-only"
+ outsideVK := prefix + "-vk-outside"
+
+ shapes := make([]Shape, 0, 18)
+ for mask := 1; mask <= 15; mask++ {
+ hasVK := mask&0x1 != 0
+ hasU := mask&0x2 != 0
+ hasT := mask&0x4 != 0
+ hasB := mask&0x8 != 0
+
+ shape := Shape{Name: shapeNameFromDims(hasVK, hasU, hasT, hasB)}
+ if hasVK {
+ if hasT && !hasU {
+ shape.VirtualKeyID = teamVK
+ } else {
+ shape.VirtualKeyID = userVK
+ }
+ }
+ if hasU {
+ shape.UserID = tiggingsUser
+ }
+ if hasT {
+ shape.TeamID = tiggingsTeam
+ }
+ if hasB {
+ shape.BusinessUnitID = tiggingsBU
+ shape.CustomerID = tiggingsCustomer
+ }
+ shape.Marker = prefix + "-shape-" + shape.Name
+ shape.VisibleTo = computeVisibleTo(shape, tiggingsUser, outsideUser, tiggingsTeam, outsideTeam, userVK, teamVK, outsideVK)
+ shapes = append(shapes, shape)
+ }
+
+ shapes = append(shapes,
+ Shape{
+ Name: "user-not-in-tiggings",
+ UserID: outsideUser,
+ CustomerID: outsideCustomer,
+ BusinessUnitID: outsideBU,
+ Marker: prefix + "-shape-user-not-in-tiggings",
+ VisibleTo: []string{"all_data_admin", "own_reader_outside", "team_reader_outside"},
+ },
+ Shape{
+ Name: "outside-team-virtual-key",
+ UserID: outsideUser,
+ TeamID: outsideTeam,
+ CustomerID: outsideCustomer,
+ BusinessUnitID: outsideBU,
+ VirtualKeyID: outsideVK,
+ Marker: prefix + "-shape-outside-team-vk",
+ VisibleTo: []string{"all_data_admin", "own_reader_outside", "team_reader_outside"},
+ },
+ Shape{
+ Name: "legacy-unowned",
+ Marker: prefix + "-shape-legacy-unowned",
+ VisibleTo: []string{"all_data_admin"},
+ },
+ )
+ return shapes
+}
+
+// shapeNameFromDims returns a deterministic kebab-case name for the given
+// dimension presence flags. Single-dimension shapes are prefixed with "only-"
+// so the matrix reads naturally in the manifest.
+func shapeNameFromDims(hasVK, hasU, hasT, hasB bool) string {
+ var parts []string
+ if hasVK {
+ parts = append(parts, "virtual-key")
+ }
+ if hasU {
+ parts = append(parts, "user")
+ }
+ if hasT {
+ parts = append(parts, "team")
+ }
+ if hasB {
+ parts = append(parts, "business-unit")
+ }
+ if len(parts) == 1 {
+ return "only-" + parts[0]
+ }
+ return strings.Join(parts, "-")
+}
+
+// computeVisibleTo returns the sorted set of personas that can see rows of the
+// given shape, derived directly from the shape's DAC dimensions and the
+// well-known seeded principal/VK values.
+func computeVisibleTo(s Shape, tigU, outU, tigT, outT, userVK, teamVK, outVK string) []string {
+ set := map[string]struct{}{"all_data_admin": {}}
+
+ switch s.UserID {
+ case tigU:
+ set["own_reader_tiggings"] = struct{}{}
+ set["team_reader_tiggings"] = struct{}{}
+ case outU:
+ set["own_reader_outside"] = struct{}{}
+ set["team_reader_outside"] = struct{}{}
+ }
+
+ switch s.TeamID {
+ case tigT:
+ set["team_reader_tiggings"] = struct{}{}
+ case outT:
+ set["team_reader_outside"] = struct{}{}
+ }
+
+ switch s.VirtualKeyID {
+ case userVK:
+ set["vk_user_owned"] = struct{}{}
+ set["own_reader_tiggings"] = struct{}{}
+ set["team_reader_tiggings"] = struct{}{}
+ case teamVK:
+ set["vk_team_owned"] = struct{}{}
+ set["team_reader_tiggings"] = struct{}{}
+ case outVK:
+ set["own_reader_outside"] = struct{}{}
+ set["team_reader_outside"] = struct{}{}
+ }
+
+ out := make([]string, 0, len(set))
+ for k := range set {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// BuildExpectedManifest returns the expected DAC visibility document. Each
+// shape gets logRowsPerShape contiguous log IDs and an equal number of MCP
+// log IDs so visibility tests can assert exact set membership for either
+// table.
+func BuildExpectedManifest(prefix string, logRowsPerShape int) ExpectedManifest {
+ shapes := BuildShapes(prefix)
+ logIDs := make(map[string][]string, len(shapes))
+ mcpLogIDs := make(map[string][]string, len(shapes))
+ for shapeIdx, shape := range shapes {
+ for j := 0; j < logRowsPerShape; j++ {
+ i := shapeIdx*logRowsPerShape + j
+ logIDs[shape.Name] = append(logIDs[shape.Name], fmt.Sprintf("%s-log-%06d", prefix, i))
+ mcpLogIDs[shape.Name] = append(mcpLogIDs[shape.Name], fmt.Sprintf("%s-mcp-log-%06d", prefix, i))
+ }
+ }
+ return ExpectedManifest{
+ Prefix: prefix,
+ Personas: map[string]string{
+ "own_reader_tiggings": prefix + "-apikey-own-tiggings",
+ "team_reader_tiggings": prefix + "-apikey-team-tiggings",
+ "own_reader_outside": prefix + "-apikey-own-outside",
+ "team_reader_outside": prefix + "-apikey-team-outside",
+ "all_data_admin": prefix + "-apikey-admin",
+ "vk_user_owned": prefix + "-vk-user-team",
+ "vk_team_owned": prefix + "-vk-team-only",
+ },
+ Shapes: shapes,
+ LogIDs: logIDs,
+ MCPLogIDs: mcpLogIDs,
+ }
+}
+
+// WriteEnvFile writes deterministic environment values.
+func WriteEnvFile(path string, env map[string]string) error {
+ if path == "" {
+ return nil
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ keys := make([]string, 0, len(env))
+ for key := range env {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ lines := make([]string, 0, len(keys))
+ for _, key := range keys {
+ lines = append(lines, fmt.Sprintf("%s=%s", key, quoteEnv(env[key])))
+ }
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600)
+}
+
+// WriteJSONFile writes a pretty JSON file.
+func WriteJSONFile(path string, value any) error {
+ if path == "" {
+ return nil
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(data, '\n'), 0o600)
+}
+
+// CountKnownTables returns row counts for tables touched by the shared seed.
+func CountKnownTables(ctx context.Context, configDB, logsDB *gorm.DB) (map[string]int64, error) {
+ out := map[string]int64{}
+ for _, table := range []string{"config_providers", "config_keys", "config_models", "governance_customers", "governance_teams", "governance_virtual_keys", "governance_virtual_key_provider_configs", "governance_budgets", "governance_rate_limits", "folders", "prompts", "prompt_versions", "config_mcp_clients"} {
+ var count int64
+ if err := configDB.WithContext(ctx).Table(table).Count(&count).Error; err != nil {
+ return nil, fmt.Errorf("count table %s: %w", table, err)
+ }
+ out[table] = count
+ }
+ for _, table := range []string{"logs", "mcp_tool_logs", "async_jobs"} {
+ var count int64
+ if err := logsDB.WithContext(ctx).Table(table).Count(&count).Error; err != nil {
+ return nil, fmt.Errorf("count table %s: %w", table, err)
+ }
+ out[table] = count
+ }
+ return out, nil
+}
+
+// seedConfig writes OSS-owned relational fixtures.
+func seedConfig(ctx context.Context, db *gorm.DB, opts Options) error {
+ if db == nil {
+ return fmt.Errorf("config db is required")
+ }
+ now := seedBaseTime
+ if err := seedProviders(ctx, db, opts.Prefix, now); err != nil {
+ return err
+ }
+ if err := seedGovernance(ctx, db, opts.Prefix, now); err != nil {
+ return err
+ }
+ if err := seedPrompts(ctx, db, opts.Prefix, now); err != nil {
+ return err
+ }
+ return seedMCP(ctx, db, opts.Prefix, now)
+}
+
+// seedProviders writes providers, keys, and models.
+func seedProviders(ctx context.Context, db *gorm.DB, prefix string, now time.Time) error {
+ for _, providerName := range []string{"openai", "anthropic", "gemini"} {
+ provider := tables.TableProvider{Name: providerName, Status: "active", Description: "e2e seeded provider", CreatedAt: now, UpdatedAt: now}
+ if err := db.WithContext(ctx).Where("name = ?", providerName).Assign(provider).FirstOrCreate(&provider).Error; err != nil {
+ return err
+ }
+ key := tables.TableKey{
+ Name: prefix + "-" + providerName + "-key",
+ ProviderID: provider.ID,
+ Provider: providerName,
+ KeyID: prefix + "-" + providerName + "-key",
+ Value: *schemas.NewSecretVar(strings.ToUpper(providerName) + "_API_KEY"),
+ Models: schemas.WhiteList{"*"},
+ Status: "active",
+ Description: "e2e seeded provider key",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.WithContext(ctx).Where("key_id = ?", key.KeyID).Assign(key).FirstOrCreate(&key).Error; err != nil {
+ return err
+ }
+ model := tables.TableModel{ID: prefix + "-" + providerName + "-model", ProviderID: provider.ID, Name: defaultModel(providerName), CreatedAt: now, UpdatedAt: now}
+ if err := db.WithContext(ctx).Where("id = ?", model.ID).Assign(model).FirstOrCreate(&model).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// seedGovernance writes customers, teams, budgets, rate limits, VKs, and VK provider configs.
+func seedGovernance(ctx context.Context, db *gorm.DB, prefix string, now time.Time) error {
+ tiggingsCustomer := prefix + "-customer-tiggings"
+ outsideCustomer := prefix + "-customer-outside"
+ tiggingsTeam := prefix + "-team-tiggings"
+ outsideTeam := prefix + "-team-outside"
+ for _, customer := range []tables.TableCustomer{{ID: tiggingsCustomer, Name: "Tiggings Customer", CreatedAt: now, UpdatedAt: now}, {ID: outsideCustomer, Name: "Outside Customer", CreatedAt: now, UpdatedAt: now}} {
+ if err := db.WithContext(ctx).Where("id = ?", customer.ID).Assign(customer).FirstOrCreate(&customer).Error; err != nil {
+ return err
+ }
+ }
+ for _, team := range []tables.TableTeam{{ID: tiggingsTeam, Name: "Tiggings", CustomerID: &tiggingsCustomer, CreatedAt: now, UpdatedAt: now}, {ID: outsideTeam, Name: "Outside Team", CustomerID: &outsideCustomer, CreatedAt: now, UpdatedAt: now}} {
+ if err := db.WithContext(ctx).Where("id = ?", team.ID).Assign(team).FirstOrCreate(&team).Error; err != nil {
+ return err
+ }
+ }
+ for _, vk := range []tables.TableVirtualKey{
+ {ID: prefix + "-vk-user-team", Name: "E2E User Team VK", Value: *schemas.NewSecretVar(prefix + "-vk-user-team-secret"), IsActive: bifrost.Ptr(true), TeamID: &tiggingsTeam, CreatedAt: now, UpdatedAt: now},
+ {ID: prefix + "-vk-team-only", Name: "E2E Team Only VK", Value: *schemas.NewSecretVar(prefix + "-vk-team-only-secret"), IsActive: bifrost.Ptr(true), TeamID: &tiggingsTeam, CreatedAt: now, UpdatedAt: now},
+ {ID: prefix + "-vk-outside", Name: "E2E Outside VK", Value: *schemas.NewSecretVar(prefix + "-vk-outside-secret"), IsActive: bifrost.Ptr(true), TeamID: &outsideTeam, CreatedAt: now, UpdatedAt: now},
+ } {
+ if err := db.WithContext(ctx).Where("id = ?", vk.ID).Assign(vk).FirstOrCreate(&vk).Error; err != nil {
+ return err
+ }
+ pc := tables.TableVirtualKeyProviderConfig{VirtualKeyID: vk.ID, Provider: "openai", AllowedModels: schemas.WhiteList{"*"}, AllowAllKeys: true}
+ if err := db.WithContext(ctx).Where("virtual_key_id = ? AND provider = ?", vk.ID, "openai").Assign(pc).FirstOrCreate(&pc).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// seedPrompts writes a folder, prompt, and prompt version.
+func seedPrompts(ctx context.Context, db *gorm.DB, prefix string, now time.Time) error {
+ folder := tables.TableFolder{ID: prefix + "-folder", Name: "E2E Seed Folder", CreatedAt: now, UpdatedAt: now}
+ if err := db.WithContext(ctx).Where("id = ?", folder.ID).Assign(folder).FirstOrCreate(&folder).Error; err != nil {
+ return err
+ }
+ prompt := tables.TablePrompt{ID: prefix + "-prompt", Name: "E2E Seed Prompt", FolderID: &folder.ID, CreatedAt: now, UpdatedAt: now}
+ if err := db.WithContext(ctx).Where("id = ?", prompt.ID).Assign(prompt).FirstOrCreate(&prompt).Error; err != nil {
+ return err
+ }
+ version := tables.TablePromptVersion{PromptID: prompt.ID, VersionNumber: 1, CommitMessage: "seed", Provider: "openai", Model: "gpt-4o-mini", IsLatest: true, CreatedAt: now}
+ return db.WithContext(ctx).Where("prompt_id = ? AND version_number = ?", prompt.ID, 1).Assign(version).FirstOrCreate(&version).Error
+}
+
+// seedMCP writes a minimal MCP client.
+func seedMCP(ctx context.Context, db *gorm.DB, prefix string, now time.Time) error {
+ client := tables.TableMCPClient{
+ ClientID: prefix + "-mcp-client",
+ Name: "E2E Seed MCP",
+ ConnectionType: "sse",
+ ConnectionString: schemas.NewSecretVar("https://mcp.e2e.local/sse"),
+ ToolsToExecute: schemas.WhiteList{"*"},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ return db.WithContext(ctx).Where("client_id = ?", client.ClientID).Assign(client).FirstOrCreate(&client).Error
+}
+
+// seedLogs writes the DAC matrix LLM logs. Each shape gets exactly
+// opts.LogRowsPerShape rows, so admin sees len(shapes) * LogRowsPerShape
+// total.
+func seedLogs(ctx context.Context, db *gorm.DB, opts Options, manifest ExpectedManifest) error {
+ if db == nil {
+ return fmt.Errorf("logs db is required")
+ }
+ shapes := manifest.Shapes
+ batch := make([]logstore.Log, 0, opts.BatchSize)
+ for shapeIdx, shape := range shapes {
+ for j := 0; j < opts.LogRowsPerShape; j++ {
+ i := shapeIdx*opts.LogRowsPerShape + j
+ batch = append(batch, buildLog(opts.Prefix, shape, i))
+ if len(batch) == opts.BatchSize {
+ if err := db.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).Create(&batch).Error; err != nil {
+ return err
+ }
+ batch = batch[:0]
+ }
+ }
+ }
+ if len(batch) > 0 {
+ if err := db.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).Create(&batch).Error; err != nil {
+ return err
+ }
+ }
+ if err := seedMCPLogs(ctx, db, opts, manifest); err != nil {
+ return err
+ }
+ return seedAsyncJobCompanion(ctx, db, opts.Prefix)
+}
+
+// seedMCPLogs writes the DAC matrix MCP tool logs in the same shape and
+// volume as the LLM logs so MCP visibility tests can assert against the
+// expected.mcp_log_ids manifest the same way the LLM tests do.
+func seedMCPLogs(ctx context.Context, db *gorm.DB, opts Options, manifest ExpectedManifest) error {
+ shapes := manifest.Shapes
+ batch := make([]logstore.MCPToolLog, 0, opts.BatchSize)
+ for shapeIdx, shape := range shapes {
+ for j := 0; j < opts.LogRowsPerShape; j++ {
+ i := shapeIdx*opts.LogRowsPerShape + j
+ batch = append(batch, buildMCPLog(opts.Prefix, shape, i))
+ if len(batch) == opts.BatchSize {
+ if err := db.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).Create(&batch).Error; err != nil {
+ return err
+ }
+ batch = batch[:0]
+ }
+ }
+ }
+ if len(batch) > 0 {
+ if err := db.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).Create(&batch).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// seedAsyncJobCompanion writes one async-job row tied to the seeded user VK
+// so the /api/async-jobs CRUD coverage has a deterministic fixture to read.
+func seedAsyncJobCompanion(ctx context.Context, db *gorm.DB, prefix string) error {
+ now := seedBaseTime
+ vk := prefix + "-vk-user-team"
+ completed := now
+ job := logstore.AsyncJob{ID: prefix + "-async-job", Status: schemas.AsyncJobStatusCompleted, RequestType: schemas.ChatCompletionRequest, Response: `{}`, StatusCode: 200, VirtualKeyID: &vk, ResultTTL: 3600, CreatedAt: now, CompletedAt: &completed}
+ return db.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).Create(&job).Error
+}
+
+// buildMCPLog returns one deterministic MCP tool log row mirroring the DAC
+// dimensions of the shape so the same persona/visibility logic that drives
+// LLM log tests applies here.
+func buildMCPLog(prefix string, shape Shape, index int) logstore.MCPToolLog {
+ timestamp := seedBaseTime.Add(-time.Duration(index) * time.Second)
+ vkName := ""
+ if shape.VirtualKeyID != "" {
+ vkName = "E2E " + shape.VirtualKeyID
+ }
+ latency := float64(20 + index%200)
+ cost := float64(1+index%50) / 100000
+ status := "success"
+ if index%19 == 0 {
+ status = "error"
+ }
+ return logstore.MCPToolLog{
+ ID: fmt.Sprintf("%s-mcp-log-%06d", prefix, index),
+ RequestID: fmt.Sprintf("%s-mcp-req-%06d", prefix, index),
+ Timestamp: timestamp,
+ ToolName: "e2e-tool",
+ ServerLabel: "e2e-mcp",
+ VirtualKeyID: emptyPtr(shape.VirtualKeyID),
+ VirtualKeyName: emptyPtr(vkName),
+ UserID: emptyPtr(shape.UserID),
+ TeamID: emptyPtr(shape.TeamID),
+ CustomerID: emptyPtr(shape.CustomerID),
+ BusinessUnitID: emptyPtr(shape.BusinessUnitID),
+ ArgumentsParsed: map[string]any{"seed_prefix": prefix, "shape": shape.Name, "marker": shape.Marker, "index": index},
+ ResultParsed: map[string]any{"ok": true},
+ Latency: &latency,
+ Cost: &cost,
+ Status: status,
+ MetadataParsed: map[string]any{"seed_prefix": prefix, "shape": shape.Name, "marker": shape.Marker},
+ CreatedAt: timestamp,
+ }
+}
+
+// buildLog returns one deterministic log row.
+func buildLog(prefix string, shape Shape, index int) logstore.Log {
+ timestamp := seedBaseTime.Add(-time.Duration(index) * time.Second)
+ vkName := ""
+ if shape.VirtualKeyID != "" {
+ vkName = "E2E " + shape.VirtualKeyID
+ }
+ latency := float64(100 + index%500)
+ cost := float64(1+index%100) / 100000
+ promptTokens := 10 + index%30
+ completionTokens := 5 + index%20
+ status := "success"
+ if index%17 == 0 {
+ status = "error"
+ }
+ return logstore.Log{
+ ID: fmt.Sprintf("%s-log-%06d", prefix, index),
+ Timestamp: timestamp,
+ Object: "chat.completion",
+ Provider: "openai",
+ Model: "gpt-4o-mini",
+ SelectedKeyID: prefix + "-openai-key",
+ SelectedKeyName: "E2E OpenAI Key",
+ VirtualKeyID: emptyPtr(shape.VirtualKeyID),
+ VirtualKeyName: emptyPtr(vkName),
+ UserID: emptyPtr(shape.UserID),
+ UserName: emptyPtr(nameFromID(shape.UserID)),
+ TeamID: emptyPtr(shape.TeamID),
+ TeamName: emptyPtr(nameFromID(shape.TeamID)),
+ CustomerID: emptyPtr(shape.CustomerID),
+ CustomerName: emptyPtr(nameFromID(shape.CustomerID)),
+ BusinessUnitID: emptyPtr(shape.BusinessUnitID),
+ BusinessUnitName: emptyPtr(nameFromID(shape.BusinessUnitID)),
+ InputHistoryParsed: []schemas.ChatMessage{{
+ Role: schemas.ChatMessageRoleUser,
+ Content: &schemas.ChatMessageContent{ContentStr: ptr(shape.Marker + " prompt")},
+ }},
+ OutputMessageParsed: &schemas.ChatMessage{Role: schemas.ChatMessageRoleAssistant, Content: &schemas.ChatMessageContent{ContentStr: ptr("seeded response")}},
+ Latency: &latency,
+ Cost: &cost,
+ Status: status,
+ ContentSummary: shape.Marker,
+ MetadataParsed: map[string]any{"seed_prefix": prefix, "shape": shape.Name, "marker": shape.Marker},
+ PromptTokens: promptTokens,
+ CompletionTokens: completionTokens,
+ TotalTokens: promptTokens + completionTokens,
+ RoutingEnginesUsed: []string{"governance"},
+ CreatedAt: timestamp,
+ }
+}
+
+// defaultModel returns a model for a provider.
+func defaultModel(provider string) string {
+ switch provider {
+ case "anthropic":
+ return "claude-3-5-haiku"
+ case "gemini":
+ return "gemini-1.5-flash"
+ default:
+ return "gpt-4o-mini"
+ }
+}
+
+// emptyPtr returns nil for empty strings.
+func emptyPtr(value string) *string {
+ if value == "" {
+ return nil
+ }
+ return &value
+}
+
+// ptr returns a string pointer.
+func ptr(value string) *string {
+ return &value
+}
+
+// nameFromID derives a display name from an id.
+func nameFromID(id string) string {
+ if id == "" {
+ return ""
+ }
+ return strings.Title(strings.ReplaceAll(id, "-", " "))
+}
+
+// quoteEnv returns a shell-safe env value.
+func quoteEnv(value string) string {
+ return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
+}
diff --git a/core/changelog.md b/core/changelog.md
index e69de29bb2d..a0220eecb01 100644
--- a/core/changelog.md
+++ b/core/changelog.md
@@ -0,0 +1 @@
+[fix]: refresh global per-user OAuth MCP tool catalogs [@zachgersh](https://github.com/zachgersh)
diff --git a/core/internal/mcptests/plugin_test.go b/core/internal/mcptests/plugin_test.go
index 5219a844727..f8294fa6822 100644
--- a/core/internal/mcptests/plugin_test.go
+++ b/core/internal/mcptests/plugin_test.go
@@ -286,7 +286,7 @@ func TestPlugin_MultiplePlugins(t *testing.T) {
// Setup Bifrost with multiple plugins in pipeline
bifrost, err := core.Init(context.Background(), schemas.BifrostConfig{
- Account: &testAccount{},
+ Account: &testAccount{},
MCPPlugins: []schemas.MCPPlugin{
loggingPlugin,
modifyPlugin,
@@ -725,3 +725,30 @@ func TestPlugin_CustomTestPlugin(t *testing.T) {
t.Logf("✅ Custom test plugin test completed successfully")
}
+
+func TestPlugin_PreMCPHookShortCircuitsBeforeToolResolution(t *testing.T) {
+ t.Parallel()
+
+ manager := setupMCPManager(t)
+ shortCircuitPlugin := NewTestShortCircuitPlugin()
+ shortCircuitPlugin.SetShouldShortCircuit(true)
+ shortCircuitPlugin.SetShortCircuitMessage("Blocked before tool resolution")
+
+ bifrost, err := core.Init(context.Background(), schemas.BifrostConfig{
+ Account: &testAccount{},
+ MCPPlugins: []schemas.MCPPlugin{shortCircuitPlugin},
+ Logger: core.NewDefaultLogger(schemas.LogLevelInfo),
+ })
+ require.NoError(t, err)
+ bifrost.SetMCPManager(manager)
+
+ ctx := createTestContext()
+ missingToolCall := CreateToolCall("missing-tool", "missing-client-echo", map[string]interface{}{"message": "test"})
+ result, bifrostErr := bifrost.ExecuteChatMCPTool(ctx, &missingToolCall)
+
+ require.Nil(t, bifrostErr)
+ require.NotNil(t, result)
+ require.NotNil(t, result.Content)
+ require.NotNil(t, result.Content.ContentStr)
+ assert.Contains(t, *result.Content.ContentStr, "Blocked before tool resolution")
+}
diff --git a/core/mcp/clientmanager.go b/core/mcp/clientmanager.go
index 16109c13639..a6c70e68ff1 100644
--- a/core/mcp/clientmanager.go
+++ b/core/mcp/clientmanager.go
@@ -11,6 +11,7 @@ import (
"os"
"slices"
"strings"
+ "sync"
"time"
"github.com/mark3labs/mcp-go/client"
@@ -21,6 +22,43 @@ import (
"github.com/maximhq/bifrost/core/schemas"
)
+// toolSyncFlight coalesces in-flight async per-user OAuth tool-list refreshes
+// so only one upstream tools/list call runs per client at a time.
+type toolSyncFlight struct {
+ mu sync.Mutex
+ running bool
+ lastAttempt time.Time
+}
+
+func (f *toolSyncFlight) tryStart(now, lastSuccessfulSync time.Time, interval time.Duration) bool {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.running {
+ return false
+ }
+ lastAttempt := f.lastAttempt
+ if lastSuccessfulSync.After(lastAttempt) {
+ lastAttempt = lastSuccessfulSync
+ }
+ if !lastAttempt.IsZero() && now.Sub(lastAttempt) < interval {
+ return false
+ }
+ f.running = true
+ f.lastAttempt = now
+ return true
+}
+
+func (f *toolSyncFlight) finish(completedAt time.Time, retryOnNextRequest bool) {
+ f.mu.Lock()
+ f.running = false
+ if retryOnNextRequest {
+ f.lastAttempt = time.Time{}
+ } else if completedAt.After(f.lastAttempt) {
+ f.lastAttempt = completedAt
+ }
+ f.mu.Unlock()
+}
+
// AcquireClientConn returns a live upstream MCP client connection for the
// given client state, along with a release function the caller must invoke
// (typically via defer).
@@ -75,6 +113,9 @@ func (m *MCPManager) AcquireClientConn(ctx *schemas.BifrostContext, state *schem
// covers both per-user-OAuth (Kind=oauth) and per-user-headers
// (Kind=headers) surfaces.
var authRequiredErr *schemas.MCPAuthRequiredError
+ // Capture the bearer token from the resolved OAuth headers so an async
+ // tool-list refresh can reuse it without re-resolving credentials.
+ var accessToken string
start := time.Now()
_, gateErr := m.runConnectWithPluginPipeline(ctx, connectReq, func(preReq *schemas.BifrostMCPConnectRequest) (*schemas.BifrostMCPConnectResponse, error) {
@@ -85,6 +126,9 @@ func (m *MCPManager) AcquireClientConn(ctx *schemas.BifrostContext, state *schem
errors.As(credErr, &authRequiredErr)
return nil, credErr
}
+ if ah := authHeaders.Get("Authorization"); ah != "" {
+ accessToken = strings.TrimPrefix(ah, "Bearer ")
+ }
// Compose final transport headers: plugin-mutated static base + auth on top.
finalHeaders := make(map[string]string, len(preReq.Headers)+len(authHeaders))
@@ -199,9 +243,110 @@ func (m *MCPManager) AcquireClientConn(ctx *schemas.BifrostContext, state *schem
m.logger.Warn("%s Failed to close ephemeral client for %s: %v", MCPLogPrefix, config.Name, err)
}
}
+
+ // Kick off an async tool-list refresh for per-user OAuth clients when the
+ // cached tool list is stale. The current request uses the existing tools;
+ // the refreshed list is available for subsequent requests.
+ m.maybeRefreshDiscoveredToolsAsync(state, accessToken)
+
return tempClient, release, nil
}
+// maybeRefreshDiscoveredToolsAsync triggers a coalesced async tools/list for
+// per-user OAuth clients when the cached tool list is older than the sync
+// interval. Only one refresh runs per client at a time; failures leave the
+// existing cached tools in place.
+func (m *MCPManager) maybeRefreshDiscoveredToolsAsync(state *schemas.MCPClientState, accessToken string) {
+ if state == nil || state.ExecutionConfig == nil || accessToken == "" {
+ return
+ }
+ config := state.ExecutionConfig
+ if config.AuthType != schemas.MCPAuthTypePerUserOauth {
+ return
+ }
+ interval := ResolveToolSyncInterval(config, m.toolSyncManager.GetGlobalInterval())
+ if interval <= 0 {
+ return
+ }
+ flightRaw, _ := m.toolSyncInFlight.LoadOrStore(config.ID, &toolSyncFlight{})
+ flight := flightRaw.(*toolSyncFlight)
+ if !flight.tryStart(time.Now(), config.DiscoveredToolsLastSync, interval) {
+ return
+ }
+
+ go func() {
+ retryOnNextRequest := false
+ completedAt := time.Time{}
+ defer func() {
+ flight.finish(completedAt, retryOnNextRequest)
+ }()
+
+ ctx, cancel := context.WithTimeout(m.ctx, ToolSyncTimeout)
+ defer cancel()
+
+ tools, mapping, err := m.VerifyPerUserOAuthConnection(ctx, config, accessToken)
+ if err != nil {
+ m.logger.Warn("%s async tool-list refresh failed for %s: %v", MCPLogPrefix, config.Name, err)
+ return
+ }
+ if tools == nil {
+ tools = make(map[string]schemas.ChatTool)
+ }
+ if mapping == nil {
+ mapping = make(map[string]string)
+ }
+
+ completedAt = time.Now()
+ if !m.applyDiscoveredToolsRefresh(config, tools, mapping, completedAt) {
+ // The client was removed or its immutable config snapshot changed
+ // while tools/list was running. Do not apply a result produced from
+ // stale headers/name/configuration, and allow the next request to retry.
+ retryOnNextRequest = true
+ return
+ }
+
+ if m.persistMCPClientDiscoveredTools != nil {
+ persistCtx, persistCancel := context.WithTimeout(m.ctx, ToolSyncTimeout)
+ defer persistCancel()
+ if err := m.persistMCPClientDiscoveredTools(persistCtx, config.ID, config.Name, tools, mapping, completedAt); err != nil {
+ m.logger.Warn("%s failed to persist refreshed tools for %s: %v", MCPLogPrefix, config.Name, err)
+ return
+ }
+ }
+
+ m.logger.Info("%s async tool-list refresh completed for %s: %d tools", MCPLogPrefix, config.Name, len(tools))
+ }()
+}
+
+// applyDiscoveredToolsRefresh atomically replaces the global tool catalog and
+// immutable execution-config snapshot when the client still has the exact
+// configuration used for discovery. It rejects stale async results produced
+// across an update, removal, or remove/re-add lifecycle.
+func (m *MCPManager) applyDiscoveredToolsRefresh(
+ expectedConfig *schemas.MCPClientConfig,
+ tools map[string]schemas.ChatTool,
+ mapping map[string]string,
+ lastSync time.Time,
+) bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ client, ok := m.clientMap[expectedConfig.ID]
+ if !ok || client.ExecutionConfig != expectedConfig {
+ return false
+ }
+
+ newConfig := *expectedConfig
+ newConfig.DiscoveredTools = maps.Clone(tools)
+ newConfig.DiscoveredToolNameMapping = maps.Clone(mapping)
+ newConfig.DiscoveredToolsLastSync = lastSync
+
+ client.ToolMap = maps.Clone(tools)
+ client.ToolNameMapping = maps.Clone(mapping)
+ client.ExecutionConfig = &newConfig
+ return true
+}
+
// GetClients returns all MCP clients managed by the manager.
//
// Returns:
@@ -753,6 +898,7 @@ func (m *MCPManager) removeClientUnsafe(id string) error {
client.ToolMap = make(map[string]schemas.ChatTool)
delete(m.clientMap, id)
+ m.toolSyncInFlight.Delete(id)
return nil
}
@@ -812,7 +958,9 @@ func (m *MCPManager) DisableClient(id string) error {
clientState.ToolNameMapping = make(map[string]string)
}
clientState.State = schemas.MCPConnectionStateDisabled
- clientState.ExecutionConfig.Disabled = true
+ disabledConfig := *clientState.ExecutionConfig
+ disabledConfig.Disabled = true
+ clientState.ExecutionConfig = &disabledConfig
m.logger.Debug("%s MCP client '%s' disabled successfully", MCPLogPrefix, clientState.ExecutionConfig.Name)
return nil
}
@@ -837,7 +985,9 @@ func (m *MCPManager) EnableClient(id string) error {
return fmt.Errorf("client %s is not disabled (current state: %s)", clientState.ExecutionConfig.Name, clientState.State)
}
- clientState.ExecutionConfig.Disabled = false
+ enabledConfig := *clientState.ExecutionConfig
+ enabledConfig.Disabled = false
+ clientState.ExecutionConfig = &enabledConfig
configCopy := clientState.ExecutionConfig
m.mu.Unlock()
@@ -963,16 +1113,19 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon
// will continue to see consistent data.
newConfig := &schemas.MCPClientConfig{
// Immutable fields - copy from existing config
- ID: client.ExecutionConfig.ID,
- ConnectionType: client.ExecutionConfig.ConnectionType,
- ConnectionString: client.ExecutionConfig.ConnectionString,
- StdioConfig: client.ExecutionConfig.StdioConfig,
- AuthType: client.ExecutionConfig.AuthType,
- OauthConfigID: oauthConfigID,
- State: client.ExecutionConfig.State,
- InProcessServer: client.ExecutionConfig.InProcessServer,
- ConfigHash: client.ExecutionConfig.ConfigHash,
- ToolPricing: maps.Clone(client.ExecutionConfig.ToolPricing),
+ ID: client.ExecutionConfig.ID,
+ ConnectionType: client.ExecutionConfig.ConnectionType,
+ ConnectionString: client.ExecutionConfig.ConnectionString,
+ StdioConfig: client.ExecutionConfig.StdioConfig,
+ AuthType: client.ExecutionConfig.AuthType,
+ OauthConfigID: oauthConfigID,
+ State: client.ExecutionConfig.State,
+ InProcessServer: client.ExecutionConfig.InProcessServer,
+ ConfigHash: client.ExecutionConfig.ConfigHash,
+ ToolPricing: maps.Clone(client.ExecutionConfig.ToolPricing),
+ DiscoveredTools: maps.Clone(client.ExecutionConfig.DiscoveredTools),
+ DiscoveredToolNameMapping: maps.Clone(client.ExecutionConfig.DiscoveredToolNameMapping),
+ DiscoveredToolsLastSync: client.ExecutionConfig.DiscoveredToolsLastSync,
// Updatable fields - copy from updated config with proper cloning
Name: updatedConfig.Name,
IsCodeModeClient: updatedConfig.IsCodeModeClient,
@@ -989,9 +1142,6 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon
PerUserHeaderKeys: slices.Clone(updatedConfig.PerUserHeaderKeys),
}
- // Atomically replace the config pointer
- client.ExecutionConfig = newConfig
-
// Rebind ToolMap keys (and inner Function.Name) to the current client name.
newPrefix := updatedConfig.Name + "-"
newToolMap := make(map[string]schemas.ChatTool, len(client.ToolMap))
@@ -1010,6 +1160,13 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon
// Replace the old ToolMap with the new one
client.ToolMap = newToolMap
+ if newConfig.DiscoveredTools != nil {
+ newConfig.DiscoveredTools = maps.Clone(newToolMap)
+ }
+
+ // Atomically replace the config pointer only after all fields in the new
+ // immutable snapshot have been finalized.
+ client.ExecutionConfig = newConfig
// Also update the client Name field
client.Name = updatedConfig.Name
diff --git a/core/mcp/clientmanager_test.go b/core/mcp/clientmanager_test.go
index 32901e50567..b0c62149d3f 100644
--- a/core/mcp/clientmanager_test.go
+++ b/core/mcp/clientmanager_test.go
@@ -3,11 +3,131 @@ package mcp
import (
"context"
"testing"
+ "time"
"github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/require"
)
+func TestToolSyncFlightCoalescesAndThrottlesAttempts(t *testing.T) {
+ t.Parallel()
+
+ interval := 10 * time.Minute
+ now := time.Unix(1_000, 0)
+ flight := &toolSyncFlight{}
+
+ require.True(t, flight.tryStart(now, time.Time{}, interval))
+ require.False(t, flight.tryStart(now.Add(time.Second), time.Time{}, interval), "in-flight requests must coalesce")
+
+ // A failed upstream refresh records the attempt time so request traffic
+ // cannot turn an immediate failure into a tools/list retry storm.
+ flight.finish(time.Time{}, false)
+ require.False(t, flight.tryStart(now.Add(time.Minute), time.Time{}, interval))
+ require.True(t, flight.tryStart(now.Add(interval), time.Time{}, interval))
+
+ // A discarded stale result should be retried by the next request instead
+ // of waiting a full interval.
+ flight.finish(time.Time{}, true)
+ require.True(t, flight.tryStart(now.Add(interval+time.Second), time.Time{}, interval))
+ flight.finish(now.Add(interval+2*time.Second), false)
+
+ // A persisted successful-sync timestamp also seeds a fresh manager's
+ // throttle state after restart.
+ freshFlight := &toolSyncFlight{}
+ require.False(t, freshFlight.tryStart(now.Add(time.Minute), now, interval))
+}
+
+func TestApplyDiscoveredToolsRefreshUsesImmutableConfigAndRejectsStaleResults(t *testing.T) {
+ t.Parallel()
+
+ originalConfig := &schemas.MCPClientConfig{ID: "client-1", Name: "global-client"}
+ manager := &MCPManager{
+ clientMap: map[string]*schemas.MCPClientState{
+ originalConfig.ID: {
+ ExecutionConfig: originalConfig,
+ ToolMap: map[string]schemas.ChatTool{},
+ ToolNameMapping: map[string]string{},
+ },
+ },
+ }
+ tools := map[string]schemas.ChatTool{
+ "global-client-search": {
+ Type: schemas.ChatToolTypeFunction,
+ Function: &schemas.ChatToolFunction{
+ Name: "global-client-search",
+ },
+ },
+ }
+ mapping := map[string]string{"search": "search"}
+ lastSync := time.Unix(2_000, 0)
+
+ require.True(t, manager.applyDiscoveredToolsRefresh(originalConfig, tools, mapping, lastSync))
+ refreshedConfig := manager.clientMap[originalConfig.ID].ExecutionConfig
+ require.NotSame(t, originalConfig, refreshedConfig)
+ require.Empty(t, originalConfig.DiscoveredTools, "published config snapshots must remain immutable")
+ require.Equal(t, tools, refreshedConfig.DiscoveredTools)
+ require.Equal(t, mapping, refreshedConfig.DiscoveredToolNameMapping)
+ require.Equal(t, lastSync, refreshedConfig.DiscoveredToolsLastSync)
+ require.Equal(t, tools, manager.clientMap[originalConfig.ID].ToolMap)
+
+ replacementConfig := *refreshedConfig
+ replacementConfig.Name = "renamed-client"
+ manager.clientMap[originalConfig.ID].ExecutionConfig = &replacementConfig
+
+ staleTools := map[string]schemas.ChatTool{
+ "global-client-stale": {Type: schemas.ChatToolTypeFunction},
+ }
+ require.False(t, manager.applyDiscoveredToolsRefresh(refreshedConfig, staleTools, nil, lastSync.Add(time.Minute)))
+ require.Equal(t, tools, manager.clientMap[originalConfig.ID].ToolMap)
+}
+
+func TestUpdateClientPreservesDiscoveredToolState(t *testing.T) {
+ t.Parallel()
+
+ lastSync := time.Unix(3_000, 0)
+ tools := map[string]schemas.ChatTool{
+ "client-search": {
+ Type: schemas.ChatToolTypeFunction,
+ Function: &schemas.ChatToolFunction{Name: "client-search"},
+ },
+ }
+ mapping := map[string]string{"search": "search"}
+ config := &schemas.MCPClientConfig{
+ ID: "client-1",
+ Name: "client",
+ ConnectionType: schemas.MCPConnectionTypeHTTP,
+ AuthType: schemas.MCPAuthTypePerUserOauth,
+ DiscoveredTools: tools,
+ DiscoveredToolNameMapping: mapping,
+ DiscoveredToolsLastSync: lastSync,
+ AllowOnAllVirtualKeys: true,
+ }
+ manager := &MCPManager{
+ clientMap: map[string]*schemas.MCPClientState{
+ config.ID: {
+ Name: config.Name,
+ ExecutionConfig: config,
+ ToolMap: tools,
+ ToolNameMapping: mapping,
+ },
+ },
+ }
+
+ err := manager.UpdateClient(config.ID, &schemas.MCPClientConfig{
+ Name: "renamed",
+ ConnectionType: config.ConnectionType,
+ AuthType: config.AuthType,
+ AllowOnAllVirtualKeys: true,
+ })
+ require.NoError(t, err)
+
+ updated := manager.clientMap[config.ID].ExecutionConfig
+ require.Equal(t, lastSync, updated.DiscoveredToolsLastSync)
+ require.Equal(t, mapping, updated.DiscoveredToolNameMapping)
+ require.Contains(t, updated.DiscoveredTools, "renamed-search")
+ require.Contains(t, manager.clientMap[config.ID].ToolMap, "renamed-search")
+}
+
func TestCreateSTDIOConnectionAllowsInlineEnvAssignments(t *testing.T) {
t.Parallel()
diff --git a/core/mcp/exec.go b/core/mcp/exec.go
index 21bdf0edb24..794708acfbd 100644
--- a/core/mcp/exec.go
+++ b/core/mcp/exec.go
@@ -1,7 +1,6 @@
package mcp
import (
- "errors"
"fmt"
"strings"
"sync"
@@ -82,37 +81,24 @@ func (m *MCPManager) executeToolWithHooks(
}
}
- // Resolve the upstream client and acquire its connection BEFORE the plugin
- // gate runs. Connection lifecycle is the orchestrator's concern, not the
- // plugin op's — the plugin pipeline only wraps the actual CallTool. When
- // AcquireClientConn fails (e.g. *MCPAuthRequiredError for per-user
- // clients that need re-auth or headers submission), the plugin gate is
- // never invoked.
- state, conn, release, prepErr := m.prepareToolExecution(ctx, request)
- if prepErr != nil {
- bErr := &schemas.BifrostError{
- IsBifrostError: false,
- Error: &schemas.ErrorField{Message: prepErr.Error()},
- ExtraFields: schemas.BifrostErrorExtraFields{RequestType: requestType, MCPRequestType: request.RequestType},
- }
- var authRequiredErr *schemas.MCPAuthRequiredError
- if errors.As(prepErr, &authRequiredErr) {
- bErr.ExtraFields.MCPAuthRequired = authRequiredErr
+ resp, bErr := m.RunWithPluginPipeline(ctx, request, func(preReq *schemas.BifrostMCPRequest) (*schemas.BifrostMCPResponse, error) {
+ // Resolve and acquire after PreMCPHook so policy plugins can reject denied
+ // MCP clients before any auth or transport work.
+ state, conn, release, prepErr := m.prepareToolExecution(ctx, preReq)
+ if prepErr != nil {
+ return nil, prepErr
}
- return nil, bErr
- }
- defer release()
+ defer release()
- // state == nil signals a code-mode tool: pass nil conn/config/mapping and
- // ToolsManager.ExecuteTool routes directly to CodeMode.
- var executionConfig *schemas.MCPClientConfig
- var toolNameMapping map[string]string
- if state != nil {
- executionConfig = state.ExecutionConfig
- toolNameMapping = state.ToolNameMapping
- }
+ // state == nil signals a code-mode tool: pass nil conn/config/mapping and
+ // ToolsManager.ExecuteTool routes directly to CodeMode.
+ var executionConfig *schemas.MCPClientConfig
+ var toolNameMapping map[string]string
+ if state != nil {
+ executionConfig = state.ExecutionConfig
+ toolNameMapping = state.ToolNameMapping
+ }
- resp, bErr := m.RunWithPluginPipeline(ctx, request, func(preReq *schemas.BifrostMCPRequest) (*schemas.BifrostMCPResponse, error) {
result, opErr := m.toolsManager.ExecuteTool(ctx, preReq, conn, executionConfig, toolNameMapping)
if opErr != nil {
return nil, opErr
diff --git a/core/mcp/mcp.go b/core/mcp/mcp.go
index 6138d3b0d4a..c7b235fabe6 100644
--- a/core/mcp/mcp.go
+++ b/core/mcp/mcp.go
@@ -43,6 +43,7 @@ type MCPManager struct {
healthMonitorManager *HealthMonitorManager // Manager for client health monitors
toolSyncManager *ToolSyncManager // Manager for periodic tool synchronization
reconnectingClients sync.Map // Tracks in-flight reconnect attempts per client ID (map[string]bool)
+ toolSyncInFlight sync.Map // Tracks async per-user OAuth tool sync attempts per client ID (map[string]*toolSyncFlight)
bootClientConfigs []*schemas.MCPClientConfig // Client configs supplied at construction, dialed by ConnectConfiguredClients
connectOnce sync.Once // Ensures ConnectConfiguredClients dials the boot configs exactly once
@@ -51,6 +52,10 @@ type MCPManager struct {
// existing execute-tool hooks.
pluginPipelineProvider func() PluginPipeline
releasePluginPipeline func(pipeline PluginPipeline)
+
+ // persistMCPClientDiscoveredTools is an optional narrow callback that
+ // persists only the refreshed global tool catalog and its last-sync time.
+ persistMCPClientDiscoveredTools func(context.Context, string, string, map[string]schemas.ChatTool, map[string]string, time.Time) error
}
// MCPToolFunction is a generic function type for handling tool calls with typed arguments.
@@ -78,6 +83,9 @@ type MCPToolFunction[T any] func(args T) (string, error)
// Returns:
// - *MCPManager: Initialized manager instance
func NewMCPManager(ctx context.Context, config schemas.MCPConfig, credStore schemas.MCPCredentialStore, logger schemas.Logger, codeMode CodeMode) *MCPManager {
+ if ctx == nil {
+ ctx = context.Background()
+ }
if logger == nil {
logger = defaultLogger
}
@@ -97,12 +105,13 @@ func NewMCPManager(ctx context.Context, config schemas.MCPConfig, credStore sche
}
// Creating new instance
manager := &MCPManager{
- ctx: ctx,
- logger: logger,
- clientMap: make(map[string]*schemas.MCPClientState),
- healthMonitorManager: NewHealthMonitorManager(),
- toolSyncManager: NewToolSyncManager(config.ToolSyncInterval),
- credStore: credStore,
+ ctx: ctx,
+ logger: logger,
+ clientMap: make(map[string]*schemas.MCPClientState),
+ healthMonitorManager: NewHealthMonitorManager(),
+ toolSyncManager: NewToolSyncManager(config.ToolSyncInterval),
+ credStore: credStore,
+ persistMCPClientDiscoveredTools: config.PersistMCPClientDiscoveredTools,
}
// Convert plugin pipeline provider functions to the interface expected by ToolsManager
var pluginPipelineProvider func() PluginPipeline
diff --git a/core/mcp/pluginpipeline.go b/core/mcp/pluginpipeline.go
index ed6368e3af9..b52227dd0c8 100644
--- a/core/mcp/pluginpipeline.go
+++ b/core/mcp/pluginpipeline.go
@@ -159,11 +159,16 @@ func (m *MCPManager) RunWithPluginPipeline(
startOpSpan(req)
resp, opErr := op(req)
if opErr != nil {
- return resp, &schemas.BifrostError{
+ bErr := &schemas.BifrostError{
IsBifrostError: false,
Error: &schemas.ErrorField{Message: opErr.Error(), Error: opErr},
ExtraFields: schemas.BifrostErrorExtraFields{MCPRequestType: mcpReqType},
}
+ var authRequiredErr *schemas.MCPAuthRequiredError
+ if errors.As(opErr, &authRequiredErr) {
+ bErr.ExtraFields.MCPAuthRequired = authRequiredErr
+ }
+ return resp, bErr
}
return resp, nil
}
@@ -241,6 +246,10 @@ func (m *MCPManager) RunWithPluginPipeline(
Error: &schemas.ErrorField{Message: opErr.Error(), Error: opErr},
ExtraFields: schemas.BifrostErrorExtraFields{MCPRequestType: mcpReqType},
}
+ var authRequiredErr *schemas.MCPAuthRequiredError
+ if errors.As(opErr, &authRequiredErr) {
+ bErr.ExtraFields.MCPAuthRequired = authRequiredErr
+ }
}
finalResp, finalErr := pipeline.RunMCPPostHooks(ctx, resp, bErr, preCount)
diff --git a/core/providers/openai/openai.go b/core/providers/openai/openai.go
index dae76f76521..96eff7b3363 100644
--- a/core/providers/openai/openai.go
+++ b/core/providers/openai/openai.go
@@ -7435,16 +7435,7 @@ func (provider *OpenAIProvider) Passthrough(
return nil, err
}
- path := req.Path
- // if path has v1 or v1/ remove it
- if after, ok := strings.CutPrefix(path, "/v1"); ok {
- path = after
- }
-
- url := provider.networkConfig.BaseURL + "/v1" + path
- if req.RawQuery != "" {
- url += "?" + req.RawQuery
- }
+ url := provider.buildPassthroughURL(req)
fasthttpReq := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
@@ -7500,6 +7491,34 @@ func (provider *OpenAIProvider) Passthrough(
return bifrostResponse, nil
}
+// buildPassthroughURL returns the upstream URL for raw passthrough requests.
+func (provider *OpenAIProvider) buildPassthroughURL(req *schemas.BifrostPassthroughRequest) string {
+ path := req.Path
+ baseURL := provider.networkConfig.BaseURL
+ if req.UpstreamURL != "" {
+ baseURL = strings.TrimRight(req.UpstreamURL, "/")
+ if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+ url := baseURL + path
+ if req.RawQuery != "" {
+ url += "?" + req.RawQuery
+ }
+ return url
+ }
+
+ // if path has v1 or v1/ remove it
+ if after, ok := strings.CutPrefix(path, "/v1"); ok {
+ path = after
+ }
+
+ url := baseURL + "/v1" + path
+ if req.RawQuery != "" {
+ url += "?" + req.RawQuery
+ }
+ return url
+}
+
func (provider *OpenAIProvider) PassthroughStream(
ctx *schemas.BifrostContext,
postHookRunner schemas.PostHookRunner,
@@ -7512,14 +7531,7 @@ func (provider *OpenAIProvider) PassthroughStream(
}
providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, provider.networkConfig.StreamIdleTimeoutInSeconds)
- path := req.Path
- if after, ok := strings.CutPrefix(path, "/v1"); ok {
- path = after
- }
- url := provider.networkConfig.BaseURL + "/v1" + path
- if req.RawQuery != "" {
- url += "?" + req.RawQuery
- }
+ url := provider.buildPassthroughURL(req)
fasthttpReq := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
diff --git a/core/providers/openai/passthrough_test.go b/core/providers/openai/passthrough_test.go
new file mode 100644
index 00000000000..f98805bf32e
--- /dev/null
+++ b/core/providers/openai/passthrough_test.go
@@ -0,0 +1,92 @@
+package openai
+
+import (
+ "testing"
+
+ "github.com/maximhq/bifrost/core/schemas"
+)
+
+// TestBuildPassthroughURLWithUpstreamOverride verifies host-backed passthrough
+// routes do not receive OpenAI's /v1 prefix.
+func TestBuildPassthroughURLWithUpstreamOverride(t *testing.T) {
+ provider := NewOpenAIProvider(&schemas.ProviderConfig{}, passthroughTestLogger{})
+
+ req := &schemas.BifrostPassthroughRequest{
+ Path: "/backend-api/codex/responses",
+ RawQuery: "conversation=abc",
+ UpstreamURL: "https://chatgpt.com",
+ }
+
+ got := provider.buildPassthroughURL(req)
+ want := "https://chatgpt.com/backend-api/codex/responses?conversation=abc"
+ if got != want {
+ t.Fatalf("buildPassthroughURL = %q, want %q", got, want)
+ }
+}
+
+// TestBuildPassthroughURLDefaultsToOpenAIV1 verifies normal OpenAI passthrough
+// routes keep the existing /v1 URL construction.
+func TestBuildPassthroughURLDefaultsToOpenAIV1(t *testing.T) {
+ provider := NewOpenAIProvider(&schemas.ProviderConfig{}, passthroughTestLogger{})
+
+ req := &schemas.BifrostPassthroughRequest{
+ Path: "/v1/responses",
+ RawQuery: "stream=true",
+ }
+
+ got := provider.buildPassthroughURL(req)
+ want := "https://api.openai.com/v1/responses?stream=true"
+ if got != want {
+ t.Fatalf("buildPassthroughURL = %q, want %q", got, want)
+ }
+}
+
+// passthroughTestLogger discards provider logs in passthrough URL tests.
+type passthroughTestLogger struct{}
+
+// Debug discards a debug log.
+func (passthroughTestLogger) Debug(string, ...any) {}
+
+// Info discards an info log.
+func (passthroughTestLogger) Info(string, ...any) {}
+
+// Warn discards a warning log.
+func (passthroughTestLogger) Warn(string, ...any) {}
+
+// Error discards an error log.
+func (passthroughTestLogger) Error(string, ...any) {}
+
+// Fatal discards a fatal log.
+func (passthroughTestLogger) Fatal(string, ...any) {}
+
+// SetLevel ignores log level changes.
+func (passthroughTestLogger) SetLevel(schemas.LogLevel) {}
+
+// SetOutputType ignores output type changes.
+func (passthroughTestLogger) SetOutputType(schemas.LoggerOutputType) {}
+
+// LogHTTPRequest returns a no-op structured log builder.
+func (passthroughTestLogger) LogHTTPRequest(schemas.LogLevel, string) schemas.LogEventBuilder {
+ return passthroughTestLogEvent{}
+}
+
+// passthroughTestLogEvent discards structured HTTP log fields.
+type passthroughTestLogEvent struct{}
+
+// Str discards a string field.
+func (passthroughTestLogEvent) Str(string, string) schemas.LogEventBuilder {
+ return passthroughTestLogEvent{}
+}
+
+// Int discards an integer field.
+func (passthroughTestLogEvent) Int(string, int) schemas.LogEventBuilder {
+ return passthroughTestLogEvent{}
+}
+
+// Int64 discards an int64 field.
+func (passthroughTestLogEvent) Int64(string, int64) schemas.LogEventBuilder {
+ return passthroughTestLogEvent{}
+}
+
+// Send discards the built log event.
+func (passthroughTestLogEvent) Send() {}
diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go
index 216b22c7165..c136de93d42 100644
--- a/core/schemas/bifrost.go
+++ b/core/schemas/bifrost.go
@@ -280,6 +280,7 @@ const (
BifrostContextKeyParentMCPRequestID BifrostContextKey = "bf-parent-mcp-request-id" // string (parent request ID for nested tool calls from executeCode)
BifrostContextKeyStructuredOutputToolName BifrostContextKey = "bifrost-structured-output-tool-name" // string (to store the name of the structured output tool (set by bifrost))
BifrostContextKeyUserAgent BifrostContextKey = "bifrost-user-agent" // string (set by bifrost)
+ BifrostContextKeyApp BifrostContextKey = "app" // string (canonical app key such as claude-code; set by plugins)
BifrostContextKeySkipBudgetAndRateLimits BifrostContextKey = "bifrost-skip-budget-and-rate-limits" // bool (set by bifrost for read-only requests like list models that don't consume quota)
BifrostContextKeySkipVirtualKeyUsageTracking BifrostContextKey = "bifrost-skip-virtual-key-usage-tracking" // bool (set by governance callers to skip VK usage while preserving VK auth/attribution)
BifrostContextKeyTraceID BifrostContextKey = "bifrost-trace-id" // string (trace ID for distributed tracing - set by tracing middleware)
diff --git a/core/schemas/mcp.go b/core/schemas/mcp.go
index 07d7ec0b2ec..3c0a6054ea7 100644
--- a/core/schemas/mcp.go
+++ b/core/schemas/mcp.go
@@ -181,6 +181,19 @@ type MCPConfig struct {
// ReleasePluginPipeline releases a plugin pipeline back to the pool.
// This should be called after the plugin pipeline is no longer needed.
ReleasePluginPipeline func(pipeline interface{}) `json:"-"`
+
+ // PersistMCPClientDiscoveredTools persists a refreshed global tool catalog
+ // for a per-user OAuth MCP client. The expected client name lets the backing
+ // store reject a refresh that raced with a client rename. When nil, async
+ // refreshes are in-memory only.
+ PersistMCPClientDiscoveredTools func(
+ ctx context.Context,
+ clientID string,
+ expectedClientName string,
+ tools map[string]ChatTool,
+ toolNameMapping map[string]string,
+ lastSync time.Time,
+ ) error `json:"-"`
}
// UnmarshalJSON supports Go duration strings (e.g. "10m") for tool_sync_interval.
@@ -298,19 +311,19 @@ const (
// MCPClientConfig defines tool filtering for an MCP client.
type MCPClientConfig struct {
- ID string `json:"client_id"` // Client ID
- Name string `json:"name"` // Client name
- IsCodeModeClient bool `json:"is_code_mode_client"` // Whether the client is a code mode client
- ConnectionType MCPConnectionType `json:"connection_type"` // How to connect (HTTP, STDIO, SSE, or InProcess)
- ConnectionString *SecretVar `json:"connection_string,omitempty"` // HTTP or SSE URL (required for HTTP or SSE connections)
- StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty"` // STDIO configuration (required for STDIO connections)
- TLSConfig *MCPTLSConfig `json:"tls_config,omitempty"` // TLS configuration for HTTP/SSE connections
- AuthType MCPAuthType `json:"auth_type"` // Authentication type (none, headers, or oauth)
- OauthConfigID *string `json:"oauth_config_id,omitempty"` // OAuth config ID (references oauth_configs table)
- OauthClientID *SecretVar `json:"oauth_client_id,omitempty"` // Redacted OAuth client ID (populated on GET, not stored here)
- OauthClientSecret *SecretVar `json:"oauth_client_secret,omitempty"` // Redacted OAuth client secret (populated on GET, not stored here)
- State string `json:"state,omitempty"` // Connection state (connected, disconnected, error)
- Headers map[string]SecretVar `json:"headers,omitempty"` // Headers to send with the request (for headers auth type)
+ ID string `json:"client_id"` // Client ID
+ Name string `json:"name"` // Client name
+ IsCodeModeClient bool `json:"is_code_mode_client"` // Whether the client is a code mode client
+ ConnectionType MCPConnectionType `json:"connection_type"` // How to connect (HTTP, STDIO, SSE, or InProcess)
+ ConnectionString *SecretVar `json:"connection_string,omitempty"` // HTTP or SSE URL (required for HTTP or SSE connections)
+ StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty"` // STDIO configuration (required for STDIO connections)
+ TLSConfig *MCPTLSConfig `json:"tls_config,omitempty"` // TLS configuration for HTTP/SSE connections
+ AuthType MCPAuthType `json:"auth_type"` // Authentication type (none, headers, or oauth)
+ OauthConfigID *string `json:"oauth_config_id,omitempty"` // OAuth config ID (references oauth_configs table)
+ OauthClientID *SecretVar `json:"oauth_client_id,omitempty"` // Redacted OAuth client ID (populated on GET, not stored here)
+ OauthClientSecret *SecretVar `json:"oauth_client_secret,omitempty"` // Redacted OAuth client secret (populated on GET, not stored here)
+ State string `json:"state,omitempty"` // Connection state (connected, disconnected, error)
+ Headers map[string]SecretVar `json:"headers,omitempty"` // Headers to send with the request (for headers auth type)
// PerUserHeaderKeys lists the header *names* each caller must supply for
// MCPAuthTypePerUserHeaders clients. Admin-declared schema only — the
// values live per-user in the mcp_per_user_header_credentials table and
@@ -334,17 +347,18 @@ type MCPClientConfig struct {
// - nil/omitted => treated as [] (no tools)
// - ["tool1", "tool2"] => auto-execute only the specified tools
// Note: If a tool is in ToolsToAutoExecute but not in ToolsToExecute, it will be skipped.
- IsPingAvailable *bool `json:"is_ping_available,omitempty"` // Whether the MCP server supports ping for health checks (nil/true = ping; false = listTools). Defaults to true.
- ToolSyncInterval time.Duration `json:"tool_sync_interval,omitempty"` // Per-client override for tool sync interval (0 = use global, negative = disabled)
- ToolExecutionTimeout time.Duration `json:"tool_execution_timeout,omitempty"` // Per-client override for tool execution timeout (0 = use global from tool_manager_config)
- ToolPricing map[string]float64 `json:"tool_pricing,omitempty"` // Tool pricing for each tool (cost per execution)
- Disabled bool `json:"disabled"` // Whether the client is intentionally disabled (stops connection and workers)
- ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized)
- AllowOnAllVirtualKeys bool `json:"allow_on_all_virtual_keys"` // Whether to allow the MCP client to run on all virtual keys
+ IsPingAvailable *bool `json:"is_ping_available,omitempty"` // Whether the MCP server supports ping for health checks (nil/true = ping; false = listTools). Defaults to true.
+ ToolSyncInterval time.Duration `json:"tool_sync_interval,omitempty"` // Per-client override for tool sync interval (0 = use global, negative = disabled)
+ ToolExecutionTimeout time.Duration `json:"tool_execution_timeout,omitempty"` // Per-client override for tool execution timeout (0 = use global from tool_manager_config)
+ ToolPricing map[string]float64 `json:"tool_pricing,omitempty"` // Tool pricing for each tool (cost per execution)
+ Disabled bool `json:"disabled"` // Whether the client is intentionally disabled (stops connection and workers)
+ ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized)
+ AllowOnAllVirtualKeys bool `json:"allow_on_all_virtual_keys"` // Whether to allow the MCP client to run on all virtual keys
// Discovered tools for per-user OAuth clients (persisted so they survive restart)
DiscoveredTools map[string]ChatTool `json:"-"` // Discovered tool schemas keyed by prefixed name
DiscoveredToolNameMapping map[string]string `json:"-"` // Mapping from sanitized tool names to original MCP names
+ DiscoveredToolsLastSync time.Time `json:"-"` // Last time the global tool catalog was refreshed from a live per-user token
}
// UnmarshalJSON supports Go duration strings (e.g. "10m") for tool_sync_interval and
@@ -554,7 +568,7 @@ type MCPStdioConfig struct {
// MCPTLSConfig holds TLS options for HTTP and SSE MCP connections.
// InsecureSkipVerify takes priority over CACertPEM when both are set.
type MCPTLSConfig struct {
- InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"` // Disable TLS certificate verification (development only)
+ InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"` // Disable TLS certificate verification (development only)
CACertPEM *SecretVar `json:"ca_cert_pem,omitempty"` // PEM-encoded CA certificate to trust (supports env.*)
}
diff --git a/core/schemas/passthrough.go b/core/schemas/passthrough.go
index 2f48289014f..c6a784269b0 100644
--- a/core/schemas/passthrough.go
+++ b/core/schemas/passthrough.go
@@ -6,6 +6,7 @@ type BifrostPassthroughRequest struct {
Method string
Path string // stripped path, e.g. "/v1/fine-tuning/jobs"
RawQuery string // raw query string, no "?"
+ UpstreamURL string // optional base URL override for host-backed passthrough routes
Body []byte
SafeHeaders map[string]string // client headers, auth already stripped
}
diff --git a/core/schemas/useragents.go b/core/schemas/useragents.go
index 50f60b2ef2e..4bed13559d7 100644
--- a/core/schemas/useragents.go
+++ b/core/schemas/useragents.go
@@ -1,27 +1,93 @@
package schemas
-import "strings"
+import (
+ "regexp"
+ "strings"
+)
// UserAgentIdentifiers lists substrings that may appear in User-Agent for a given integration.
// Versions of the same client may use different strings; Matches checks any of them.
type UserAgentIdentifiers []string
var (
- // ClaudeCLI — Anthropic Claude Code / Claude CLI (identifiers vary by release).
- ClaudeCLI = UserAgentIdentifiers{"claude-cli", "claude-code", "claude-vscode"}
- GeminiCLI = UserAgentIdentifiers{"geminicli"}
- CodexCLI = UserAgentIdentifiers{"codex-tui"}
- QwenCodeCLI = UserAgentIdentifiers{"qwencode"}
- OpenCode = UserAgentIdentifiers{"opencode"}
- Cursor = UserAgentIdentifiers{"cursor"}
+ // ClaudeChatWeb identifies requests from the Claude web app.
+ ClaudeChatWeb = UserAgentIdentifiers{"claude-chat-web", "claude-web"}
+ // ClaudeDesktop identifies requests from the Claude Desktop app.
+ ClaudeDesktop = UserAgentIdentifiers{"claude-desktop", "claude/"}
+ // ClaudeCLI identifies requests from Claude Code / Claude CLI clients.
+ ClaudeCLI = UserAgentIdentifiers{"claude-cli", "claude-code", "claude-vscode"}
+ // APIClient identifies generic programmatic API clients.
+ APIClient = UserAgentIdentifiers{"fasthttp"}
+ // CodexCLI identifies requests from Codex CLI clients.
+ CodexCLI = UserAgentIdentifiers{"codex-cli", "codex-tui"}
+ // CodexDesktop identifies requests from the Codex desktop app.
+ CodexDesktop = UserAgentIdentifiers{"codex-desktop", "codex desktop/", "codex/"}
+ // Cursor identifies requests from Cursor clients.
+ Cursor = UserAgentIdentifiers{"cursor"}
+ // KiloCode identifies requests from Kilo Code clients.
+ KiloCode = UserAgentIdentifiers{"kilo"}
+ // RooCode identifies requests from Roo Code clients.
+ RooCode = UserAgentIdentifiers{"roo"}
+ // Cline identifies requests from Cline clients.
+ Cline = UserAgentIdentifiers{"cline"}
+ // OpenCode identifies requests from OpenCode clients.
+ OpenCode = UserAgentIdentifiers{"opencode"}
+ // Windsurf identifies requests from Windsurf clients.
+ Windsurf = UserAgentIdentifiers{"windsurf"}
+ // GeminiCLI identifies requests from Gemini CLI clients.
+ GeminiCLI = UserAgentIdentifiers{"gemini-cli", "geminicli", "gemini"}
+ // QwenCodeCLI identifies requests from Qwen Code clients.
+ QwenCodeCLI = UserAgentIdentifiers{"qwen-code", "qwencode", "qwen"}
)
-// integrationUserAgents is the set of known client User-Agent patterns we persist on the context.
-var integrationUserAgents = []UserAgentIdentifiers{
- ClaudeCLI, GeminiCLI, CodexCLI, QwenCodeCLI, OpenCode, Cursor,
+// UserAgentAppMatcher maps a detected application label to User-Agent identifiers.
+type UserAgentAppMatcher struct {
+ App string
+ Identifiers UserAgentIdentifiers
}
-// Matches reports whether userAgent contains any identifier (case-insensitive substring match).
+const (
+ // UserAgentAppOther is returned when a non-empty User-Agent has no known app match.
+ UserAgentAppOther = "Other"
+)
+
+// UserAgentMappingMatchType identifies how a custom mapping pattern should match a User-Agent.
+type UserAgentMappingMatchType string
+
+const (
+ // UserAgentMappingMatchTypeContains matches when the User-Agent contains the pattern.
+ UserAgentMappingMatchTypeContains UserAgentMappingMatchType = "contains"
+ // UserAgentMappingMatchTypeStartsWith matches when the User-Agent starts with the pattern.
+ UserAgentMappingMatchTypeStartsWith UserAgentMappingMatchType = "starts_with"
+ // UserAgentMappingMatchTypeExact matches when the User-Agent equals the pattern.
+ UserAgentMappingMatchTypeExact UserAgentMappingMatchType = "exact"
+ // UserAgentMappingMatchTypeRegex matches when the regex pattern matches the User-Agent.
+ UserAgentMappingMatchTypeRegex UserAgentMappingMatchType = "regex"
+)
+
+// UserAgentAppMatchers is evaluated top-to-bottom. More specific identifiers
+// should appear before generic ancestors.
+var UserAgentAppMatchers = []UserAgentAppMatcher{
+ {App: "Claude Chat Web", Identifiers: ClaudeChatWeb},
+ {App: "Claude Desktop", Identifiers: ClaudeDesktop},
+ {App: "Claude Code", Identifiers: ClaudeCLI},
+ {App: "API", Identifiers: APIClient},
+ {App: "Codex CLI", Identifiers: CodexCLI},
+ {App: "Codex Desktop", Identifiers: CodexDesktop},
+ {App: "Cursor", Identifiers: Cursor},
+ {App: "Kilo Code", Identifiers: KiloCode},
+ {App: "Roo Code", Identifiers: RooCode},
+ {App: "Cline", Identifiers: Cline},
+ {App: "OpenCode", Identifiers: OpenCode},
+ {App: "Windsurf", Identifiers: Windsurf},
+ {App: "Gemini CLI", Identifiers: GeminiCLI},
+ {App: "Qwen Code", Identifiers: QwenCodeCLI},
+}
+
+// Matches reports whether userAgent starts with or contains any identifier
+// (case-insensitive). User-Agent values are commonly versioned, e.g.
+// "claude-cli/2.1.168 (external, cli)", so exact matching is intentionally
+// avoided.
func (ids UserAgentIdentifiers) Matches(userAgent string) bool {
if len(ids) == 0 || userAgent == "" {
return false
@@ -31,7 +97,8 @@ func (ids UserAgentIdentifiers) Matches(userAgent string) bool {
if id == "" {
continue
}
- if strings.Contains(ua, strings.ToLower(id)) {
+ normalizedID := strings.ToLower(id)
+ if strings.HasPrefix(ua, normalizedID) || strings.Contains(ua, normalizedID) {
return true
}
}
@@ -46,6 +113,57 @@ func (ids UserAgentIdentifiers) String() string {
return ids[0]
}
+// DetectAppFromUserAgent returns the built-in app label for a User-Agent.
+func DetectAppFromUserAgent(userAgent string) string {
+ if strings.TrimSpace(userAgent) == "" {
+ return ""
+ }
+ for _, matcher := range UserAgentAppMatchers {
+ if matcher.Identifiers.Matches(userAgent) {
+ return matcher.App
+ }
+ }
+ return UserAgentAppOther
+}
+
+// AppKeyFromName returns the canonical policy key for a detected app name.
+func AppKeyFromName(name string) string {
+ name = strings.TrimSpace(name)
+ if name == "" || name == UserAgentAppOther {
+ return ""
+ }
+ return strings.Join(strings.Fields(strings.ToLower(name)), "-")
+}
+
+// MatchUserAgent reports whether a User-Agent matches a pattern using the given match type.
+// An empty matchType defaults to UserAgentMappingMatchTypeContains.
+//
+// For UserAgentMappingMatchTypeRegex the pattern is compiled on every call; performance-sensitive
+// callers should pre-compile with regexp.Compile and use Regexp.MatchString directly instead.
+func MatchUserAgent(userAgent, pattern string, matchType UserAgentMappingMatchType) bool {
+ userAgent = strings.TrimSpace(userAgent)
+ pattern = strings.TrimSpace(pattern)
+ if userAgent == "" || pattern == "" {
+ return false
+ }
+ ua := strings.ToLower(userAgent)
+ p := strings.ToLower(pattern)
+ switch matchType {
+ case UserAgentMappingMatchTypeExact:
+ return ua == p
+ case UserAgentMappingMatchTypeStartsWith:
+ return strings.HasPrefix(ua, p)
+ case UserAgentMappingMatchTypeRegex:
+ re, err := regexp.Compile("(?i)" + pattern)
+ return err == nil && re.MatchString(userAgent)
+ case UserAgentMappingMatchTypeContains, "":
+ return strings.Contains(ua, p)
+ default:
+ return false
+ }
+}
+
+// ExtractAndSetUserAgentFromHeaders copies a case-insensitive User-Agent header into BifrostContext.
func ExtractAndSetUserAgentFromHeaders(headers map[string][]string, bifrostCtx *BifrostContext) {
if len(headers) == 0 {
return
@@ -62,11 +180,6 @@ func ExtractAndSetUserAgentFromHeaders(headers map[string][]string, bifrostCtx *
}
if len(userAgent) > 0 {
ua := userAgent[0]
- for _, ids := range integrationUserAgents {
- if ids.Matches(ua) {
- bifrostCtx.SetValue(BifrostContextKeyUserAgent, ua)
- break
- }
- }
+ bifrostCtx.SetValue(BifrostContextKeyUserAgent, ua)
}
}
diff --git a/core/schemas/useragents_test.go b/core/schemas/useragents_test.go
new file mode 100644
index 00000000000..8eb30e838e3
--- /dev/null
+++ b/core/schemas/useragents_test.go
@@ -0,0 +1,89 @@
+package schemas
+
+import "testing"
+
+func TestDetectAppFromUserAgent(t *testing.T) {
+ tests := []struct {
+ name string
+ userAgent string
+ want string
+ }{
+ {name: "claude cli versioned", userAgent: "claude-cli/2.1.168 (external, cli)", want: "Claude Code"},
+ {name: "claude code contains", userAgent: "external claude-code/1.0", want: "Claude Code"},
+ {name: "claude desktop", userAgent: "claude-desktop/1.2.3", want: "Claude Desktop"},
+ {name: "claude desktop electron", userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Claude/1.11187.1 Chrome/146.0.7680.216 Electron/41.6.1 Safari/537.36", want: "Claude Desktop"},
+ {name: "fasthttp api", userAgent: "fasthttp", want: "API"},
+ {name: "codex cli", userAgent: "codex-cli/0.1.0", want: "Codex CLI"},
+ {name: "codex tui", userAgent: "codex-tui/0.1.0", want: "Codex CLI"},
+ {name: "codex tui terminal capture", userAgent: "codex-tui/0.137.0 (Mac OS 14.1.0; arm64) iTerm.app/3.6.6 (codex-tui; 0.137.0)", want: "Codex CLI"},
+ {name: "claude cowork runtime capture", userAgent: "claude-cli/2.1.170 (external, local-agent, agent-sdk/0.3.170)", want: "Claude Code"},
+ {name: "codex desktop mac", userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Codex/1.0 Electron/41.0", want: "Codex Desktop"},
+ {name: "codex desktop native", userAgent: "Codex Desktop/0.142.2 (Mac OS 14.1.0; arm64) unknown (Codex Desktop; 26.623.30605)", want: "Codex Desktop"},
+ {name: "codex desktop windows", userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Codex/1.0 Electron/41.0", want: "Codex Desktop"},
+ {name: "codex desktop linux", userAgent: "Mozilla/5.0 (X11; Linux x86_64) Codex/1.0 Electron/41.0", want: "Codex Desktop"},
+ {name: "cursor", userAgent: "Cursor/0.47", want: "Cursor"},
+ {name: "gemini", userAgent: "gemini-cli/1.0", want: "Gemini CLI"},
+ {name: "qwen", userAgent: "qwen-code/1.0", want: "Qwen Code"},
+ {name: "opencode", userAgent: "opencode/1.0", want: "OpenCode"},
+ {name: "windsurf", userAgent: "Windsurf/1.0", want: "Windsurf"},
+ {name: "kilo before cline", userAgent: "kilo-cline/1.0", want: "Kilo Code"},
+ {name: "roo before cline", userAgent: "roo-cline/1.0", want: "Roo Code"},
+ {name: "cline", userAgent: "cline/3.0.0", want: "Cline"},
+ {name: "unknown", userAgent: "custom-client/1.0", want: "Other"},
+ {name: "empty", userAgent: "", want: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := DetectAppFromUserAgent(tt.userAgent); got != tt.want {
+ t.Fatalf("DetectAppFromUserAgent(%q) = %q, want %q", tt.userAgent, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAppKeyFromName(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {name: "claude code", in: "Claude Code", want: "claude-code"},
+ {name: "custom app", in: " Internal Claude Wrapper ", want: "internal-claude-wrapper"},
+ {name: "collapsed spaces", in: "Gemini CLI", want: "gemini-cli"},
+ {name: "other ignored", in: UserAgentAppOther, want: ""},
+ {name: "empty ignored", in: "", want: ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := AppKeyFromName(tt.in); got != tt.want {
+ t.Fatalf("AppKeyFromName(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestMatchUserAgent(t *testing.T) {
+ tests := []struct {
+ name string
+ userAgent string
+ pattern string
+ matchType UserAgentMappingMatchType
+ want bool
+ }{
+ {name: "contains", userAgent: "claude-cli/2.1.168 (external, cli)", pattern: "CLI/2.1", matchType: UserAgentMappingMatchTypeContains, want: true},
+ {name: "starts with", userAgent: "claude-cli/2.1.168", pattern: "Claude-CLI", matchType: UserAgentMappingMatchTypeStartsWith, want: true},
+ {name: "exact", userAgent: "Cursor/1.0", pattern: "cursor/1.0", matchType: UserAgentMappingMatchTypeExact, want: true},
+ {name: "regex", userAgent: "custom-client/42", pattern: `custom-client/\d+`, matchType: UserAgentMappingMatchTypeRegex, want: true},
+ {name: "invalid regex", userAgent: "custom-client/42", pattern: `[`, matchType: UserAgentMappingMatchTypeRegex, want: false},
+ {name: "unknown match type", userAgent: "custom-client/42", pattern: "custom", matchType: UserAgentMappingMatchType("bad"), want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := MatchUserAgent(tt.userAgent, tt.pattern, tt.matchType); got != tt.want {
+ t.Fatalf("MatchUserAgent(%q, %q, %q) = %v, want %v", tt.userAgent, tt.pattern, tt.matchType, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/core/schemas/utils.go b/core/schemas/utils.go
index 16cad77b536..8f6781de0e7 100644
--- a/core/schemas/utils.go
+++ b/core/schemas/utils.go
@@ -1867,4 +1867,4 @@ func SameBaseModel(a, b string) bool {
// Compare normalized base names.
return BaseModelName(a) == BaseModelName(b)
-}
\ No newline at end of file
+}
diff --git a/docs/changelogs/v2.0.0-prerelease1.mdx b/docs/changelogs/v2.0.0-prerelease1.mdx
index 0adb87749cf..54053bd7679 100644
--- a/docs/changelogs/v2.0.0-prerelease1.mdx
+++ b/docs/changelogs/v2.0.0-prerelease1.mdx
@@ -17,17 +17,78 @@ description: "v2.0.0-prerelease1 changelog - 2026-07-07"
+> This prerelease is based on [v1.6.3](https://docs.getbifrost.ai/changelogs/v1.6.3) - see that changelog for the full baseline.
-
-This prerelease is based on [v1.6.3](/changelogs/v1.6.3) - see that changelog for the full baseline.
-
-
-- feat: ChatGPT passthrough route on the OpenAI integration with dedicated request handling
-- feat: track user agents on LLM and MCP logs, with custom user-agent mapping and dashboard dimension rankings
-- feat: MCP tool logs observed by the Bifrost Edge agent can now be ingested with device, app key, decision, and source attribution
-- feat: fallback pages for Bifrost Edge control views (config, devices, inventory) backed by governance resolver support
-- feat: agent handover page with seeded end-to-end data support
-- fix: stopped `/api/devices` bypassing auth via the `/api/dev` prefix
-- fix: surface the AWS exception type (`X-Amzn-Errortype`) on non-streaming Bedrock error responses instead of dropping it
+## ✨ Features
+
+- **ChatGPT Passthrough** - Added a ChatGPT passthrough route on the OpenAI integration with dedicated request handling
+- **User-Agent Tracking** - Track user agents on LLM and MCP logs, with custom user-agent mapping and dashboard dimension rankings
+- **Edge Agent MCP Log Ingestion** - MCP tool logs observed by the Bifrost Edge agent can now be ingested with device, app key, decision, and source attribution
+- **Edge Fallback Pages** - Added fallback pages for Bifrost Edge control views (config, devices, inventory) backed by governance resolver support
+- **Agent Handover View** - Added an agent handover page with seeded end-to-end data support
+
+## 🐞 Fixed
+
+- **API Auth Bypass** - Stopped `/api/devices` bypassing auth via the `/api/dev` prefix
+- **Bedrock Error Types** - Surface the AWS exception type (`X-Amzn-Errortype`) on non-streaming Bedrock error responses instead of dropping it
+
+
+
+- feat: added ChatGPT passthrough support to the OpenAI provider
+- feat: added user-agent tracking schemas with custom user-agent mapping
+- feat: MCP tool execution plugin pipeline improvements
+- fix: surface AWS exception types on Bedrock non-streaming error responses
+
+
+
+- feat: added user-agent tracking columns, materialized views, and migrations to the log store
+- feat: added endpoint-agent columns (device_id, app_key, decision, source) to mcp_tool_logs for Edge agent log ingestion
+- chore: upgraded core to v1.7.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- feat: added resolver support for Bifrost Edge fallback pages
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- feat: added user-agent tracking to LLM and MCP logs
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
+
+
+
+- chore: upgraded core to v1.7.0 and framework to v1.5.0
diff --git a/docs/deployment-guides/config-json/storage.mdx b/docs/deployment-guides/config-json/storage.mdx
index 23356d9c348..64965e8bcc3 100644
--- a/docs/deployment-guides/config-json/storage.mdx
+++ b/docs/deployment-guides/config-json/storage.mdx
@@ -279,7 +279,7 @@ Set `matview_refresh_interval` (Go duration string) to slow down refreshes when
| Field | Default | Description |
|-------|---------|-------------|
-| `matview_refresh_interval` | `"1m"` | How often to refresh dashboard materialized views. Accepts any Go duration string (`"1m"`, `"5m"`, `"1h"`). Minimum `5s`. |
+| `matview_refresh_interval` | `"1m"` | How often to refresh dashboard materialized views. Accepts any Go duration string (`"1m"`, `"5m"`, `"1h"`); positive values below `5s` are clamped up to `5s`. Set `"off"` or a zero duration (`"0s"`) to disable matview maintenance entirely. |
**Notes**
@@ -297,6 +297,10 @@ Set `matview_refresh_interval` (Go duration string) to slow down refreshes when
- The database has consistent CPU headroom.
- Operators rely on near-real-time dashboards (e.g. live incident triage).
+**When to turn it off:**
+
+- You don't use the Bifrost dashboard (e.g. Bifrost runs headless behind your own observability stack). With `"off"`, the views are neither created nor refreshed, and any dashboard query transparently uses the raw tables.
+
### Object Storage for Logs
Offload LLM request/response logs and MCP tool logs from the database to S3 or GCS. The database retains lightweight index records and fetches full payloads on demand. For MCP logs, the full tool log is stored in object storage and the database keeps dashboard/table fields plus a 200-character input preview.
diff --git a/docs/mcp/auth/per-user-oauth.mdx b/docs/mcp/auth/per-user-oauth.mdx
index 482fc20662e..ab16ac861c7 100644
--- a/docs/mcp/auth/per-user-oauth.mdx
+++ b/docs/mcp/auth/per-user-oauth.mdx
@@ -59,6 +59,19 @@ Per-user OAuth is configured through the **Web UI** only. During setup Bifrost r
If the upstream server supports OAuth Discovery (RFC 8414), you can leave the authorize and token URLs blank and provide only the **Connection URL** plus client ID. Bifrost discovers the endpoints automatically.
+### Global tool catalog refresh
+
+The MCP client's tool catalog is global even though its OAuth credentials are
+per user. Bifrost periodically rewarms that shared catalog after resolving any
+authorized user's token for a tool call. The current call uses the existing
+catalog; a successful asynchronous refresh is visible to every user on later
+requests and is persisted across restarts.
+
+Only the tool definitions are shared. Every tool execution still uses the
+calling user's own OAuth token, so upstream data access remains scoped to that
+user. Refresh attempts are coalesced per MCP client and bounded by the configured
+tool sync interval; a failed refresh leaves the previous catalog intact.
+
---
## How it works
diff --git a/framework/changelog.md b/framework/changelog.md
index e69de29bb2d..b0199a1db82 100644
--- a/framework/changelog.md
+++ b/framework/changelog.md
@@ -0,0 +1 @@
+[fix]: persist refreshed MCP tool catalogs and sync timestamps [@zachgersh](https://github.com/zachgersh)
diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go
index 532b2ccddec..1bc1c89b3ba 100644
--- a/framework/configstore/migrations.go
+++ b/framework/configstore/migrations.go
@@ -371,6 +371,7 @@ var configstoreMigrationSteps = []migrationStep{
{IDs: []string{"add_multi_budget_tables"}, run: migrationAddMultiBudgetTables},
{IDs: []string{"add_per_user_oauth_tables"}, run: migrationAddPerUserOAuthTables},
{IDs: []string{"add_mcp_client_discovered_tools_columns"}, run: migrationAddMCPClientDiscoveredToolsColumns},
+ {IDs: []string{"add_mcp_client_discovered_tools_last_sync_column"}, run: migrationAddMCPClientDiscoveredToolsLastSyncColumn},
{IDs: []string{"add_whitelisted_routes_json_column"}, run: migrationAddWhitelistedRoutesJSONColumn},
{IDs: []string{"replace_enable_litellm_with_compat_columns"}, run: migrationReplaceEnableLiteLLMWithCompatColumns},
{IDs: []string{"add_model_pricing_unique_index"}, run: migrationAddModelPricingUniqueIndex},
@@ -444,8 +445,6 @@ var configstoreMigrationSteps = []migrationStep{
{IDs: []string{"add_sidekiq_table"}, run: migrationAddSidekiqTable},
{IDs: []string{"add_sidekiq_kind_status_created_index"}, run: migrationAddSidekiqKindStatusCreatedIndex},
{IDs: []string{"add_sidekiq_partitioning_key_column"}, run: migrationAddSidekiqPartitioningKeyColumn},
- {IDs: []string{"add_fast_mode_cache_pricing_columns"}, run: migrationAddFastModeCachePricingColumns},
- {IDs: []string{"add_inference_geo_multiplier_column"}, run: migrationAddInferenceGeoMultiplierColumn},
{IDs: []string{"repair_bare_wildcard_allowed_models"}, run: migrationRepairBareWildcardAllowedModels},
{IDs: []string{"add_bedrock_project_id_columns"}, run: migrationAddBedrockProjectIDColumns},
{IDs: []string{"add_dual_credential_conflict_behavior_column"}, run: migrationAddDualCredentialConflictBehaviorColumn},
@@ -7926,6 +7925,28 @@ func migrationAddMCPClientDiscoveredToolsColumns(ctx context.Context, db *gorm.D
return nil
}
+// migrationAddMCPClientDiscoveredToolsLastSyncColumn adds discovered_tools_last_sync column to the mcp_client table
+func migrationAddMCPClientDiscoveredToolsLastSyncColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
+ migrationName := "add_mcp_client_discovered_tools_last_sync_column"
+ logger.Info("[configstore] starting migration %s", migrationName)
+ defer logger.Info("[configstore] finished migration %s", migrationName)
+ m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
+ ID: migrationName,
+ Migrate: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ return addColumnIfNotExists(tx, logger, &tables.TableMCPClient{}, "discovered_tools_last_sync")
+ },
+ Rollback: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ return dropColumnIfExists(tx, logger, &tables.TableMCPClient{}, "discovered_tools_last_sync")
+ },
+ }})
+ if err := m.Migrate(); err != nil {
+ return fmt.Errorf("error running %s migration: %s", migrationName, err.Error())
+ }
+ return nil
+}
+
// migrationAddPriorityTierPricingColumns adds pricing columns for the 272k token tier
// and the 200k priority variants.
func migrationAddPriorityTierPricingColumns(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
@@ -10799,7 +10820,6 @@ func migrationAddSidekiqTable(ctx context.Context, db *gorm.DB, logger schemas.L
migrationName := "add_sidekiq_table"
logger.Info("[configstore] starting migration %s", migrationName)
defer logger.Info("[configstore] finished migration %s", migrationName)
-
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: migrationName,
Migrate: func(tx *gorm.DB) error {
@@ -10847,7 +10867,6 @@ func migrationAddSidekiqTable(ctx context.Context, db *gorm.DB, logger schemas.L
}
return nil
}
-
if err := tx.Exec(createTable).Error; err != nil {
return err
}
diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go
index 81a4a220fec..6bac25c6838 100644
--- a/framework/configstore/rdb.go
+++ b/framework/configstore/rdb.go
@@ -1561,7 +1561,13 @@ func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig,
Disabled: dbClient.Disabled,
DiscoveredTools: dbClient.DiscoveredTools,
DiscoveredToolNameMapping: dbClient.DiscoveredToolNameMapping,
- PerUserHeaderKeys: dbClient.PerUserHeaderKeys,
+ DiscoveredToolsLastSync: func() time.Time {
+ if dbClient.DiscoveredToolsLastSync != nil {
+ return *dbClient.DiscoveredToolsLastSync
+ }
+ return time.Time{}
+ }(),
+ PerUserHeaderKeys: dbClient.PerUserHeaderKeys,
}
}
return &schemas.MCPConfig{
@@ -1604,7 +1610,13 @@ func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig,
ToolPricing: dbClient.ToolPricing,
DiscoveredTools: dbClient.DiscoveredTools,
DiscoveredToolNameMapping: dbClient.DiscoveredToolNameMapping,
- PerUserHeaderKeys: dbClient.PerUserHeaderKeys,
+ DiscoveredToolsLastSync: func() time.Time {
+ if dbClient.DiscoveredToolsLastSync != nil {
+ return *dbClient.DiscoveredToolsLastSync
+ }
+ return time.Time{}
+ }(),
+ PerUserHeaderKeys: dbClient.PerUserHeaderKeys,
}
}
return &schemas.MCPConfig{
@@ -2028,7 +2040,13 @@ func (s *RDBConfigStore) GetMCPClientConfigByID(ctx context.Context, id string)
ToolPricing: dbClient.ToolPricing,
DiscoveredTools: dbClient.DiscoveredTools,
DiscoveredToolNameMapping: dbClient.DiscoveredToolNameMapping,
- PerUserHeaderKeys: dbClient.PerUserHeaderKeys,
+ DiscoveredToolsLastSync: func() time.Time {
+ if dbClient.DiscoveredToolsLastSync != nil {
+ return *dbClient.DiscoveredToolsLastSync
+ }
+ return time.Time{}
+ }(),
+ PerUserHeaderKeys: dbClient.PerUserHeaderKeys,
}, nil
}
@@ -2083,6 +2101,13 @@ func (s *RDBConfigStore) CreateMCPClientConfig(ctx context.Context, clientConfig
// DiscoveredTools has json:"-" so deepCopy loses it; use original clientConfig
DiscoveredTools: clientConfig.DiscoveredTools,
DiscoveredToolNameMapping: clientConfig.DiscoveredToolNameMapping,
+ DiscoveredToolsLastSync: func() *time.Time {
+ if !clientConfig.DiscoveredToolsLastSync.IsZero() {
+ ts := clientConfig.DiscoveredToolsLastSync
+ return &ts
+ }
+ return nil
+ }(),
// PerUserHeaderKeys is the admin-declared schema for
// MCPAuthTypePerUserHeaders. Without this copy the BeforeSave
// hook persists an empty column, and on restart AddClient's
@@ -2296,6 +2321,53 @@ func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, c
})
}
+// UpdateMCPClientDiscoveredTools atomically persists only the global tool
+// catalog fields refreshed with a per-user OAuth token. expectedName prevents
+// an async refresh produced with a stale client-name prefix from overwriting a
+// row that was renamed while tools/list was in flight.
+func (s *RDBConfigStore) UpdateMCPClientDiscoveredTools(
+ ctx context.Context,
+ id string,
+ expectedName string,
+ tools map[string]schemas.ChatTool,
+ toolNameMapping map[string]string,
+ lastSync time.Time,
+) error {
+ if tools == nil {
+ tools = make(map[string]schemas.ChatTool)
+ }
+ if toolNameMapping == nil {
+ toolNameMapping = make(map[string]string)
+ }
+ discoveredToolsJSON, err := json.Marshal(tools)
+ if err != nil {
+ return fmt.Errorf("failed to marshal discovered tools: %w", err)
+ }
+ toolNameMappingJSON, err := json.Marshal(toolNameMapping)
+ if err != nil {
+ return fmt.Errorf("failed to marshal discovered tool name mapping: %w", err)
+ }
+
+ return s.DB().Transaction(func(tx *gorm.DB) error {
+ result := tx.WithContext(ctx).
+ Model(&tables.TableMCPClient{}).
+ Where("client_id = ? AND name = ?", id, expectedName).
+ Updates(map[string]interface{}{
+ "discovered_tools_json": string(discoveredToolsJSON),
+ "tool_name_mapping_json": string(toolNameMappingJSON),
+ "discovered_tools_last_sync": lastSync,
+ "updated_at": time.Now(),
+ })
+ if result.Error != nil {
+ return s.parseGormError(result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("MCP client %q was removed or renamed during tool refresh", id)
+ }
+ return nil
+ })
+}
+
// DeleteMCPClientConfig deletes an MCP client configuration from the database.
func (s *RDBConfigStore) DeleteMCPClientConfig(ctx context.Context, id string) error {
return s.DB().Transaction(func(tx *gorm.DB) error {
diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go
index 3bf436b4293..445de7bbfa7 100644
--- a/framework/configstore/rdb_test.go
+++ b/framework/configstore/rdb_test.go
@@ -93,6 +93,71 @@ func testComplexityAnalyzerConfig() *ComplexityAnalyzerConfig {
}
}
+func TestRDBConfigStore_UpdateMCPClientDiscoveredTools(t *testing.T) {
+ t.Parallel()
+
+ store := setupRDBTestStore(t)
+ ctx := context.Background()
+ connectionString := schemas.NewSecretVar("https://mcp.example.com")
+ originalHeaders := map[string]schemas.SecretVar{
+ "X-Tenant": *schemas.NewSecretVar("tenant-1"),
+ }
+ require.NoError(t, store.CreateMCPClientConfig(ctx, &schemas.MCPClientConfig{
+ ID: "mcp-client-1",
+ Name: "global-client",
+ ConnectionType: schemas.MCPConnectionTypeHTTP,
+ ConnectionString: connectionString,
+ AuthType: schemas.MCPAuthTypePerUserOauth,
+ Headers: originalHeaders,
+ ToolsToExecute: schemas.WhiteList{"*"},
+ ToolSyncInterval: 10 * time.Minute,
+ AllowOnAllVirtualKeys: true,
+ }))
+
+ tools := map[string]schemas.ChatTool{
+ "global-client-search": {
+ Type: schemas.ChatToolTypeFunction,
+ Function: &schemas.ChatToolFunction{Name: "global-client-search"},
+ },
+ }
+ mapping := map[string]string{"search": "search"}
+ lastSync := time.Date(2026, time.July, 31, 12, 0, 0, 0, time.UTC)
+
+ require.NoError(t, store.UpdateMCPClientDiscoveredTools(
+ ctx,
+ "mcp-client-1",
+ "global-client",
+ tools,
+ mapping,
+ lastSync,
+ ))
+
+ got, err := store.GetMCPClientConfigByID(ctx, "mcp-client-1")
+ require.NoError(t, err)
+ require.Equal(t, tools, got.DiscoveredTools)
+ require.Equal(t, mapping, got.DiscoveredToolNameMapping)
+ require.Equal(t, lastSync, got.DiscoveredToolsLastSync)
+ require.Equal(t, originalHeaders, got.Headers, "narrow refresh persistence must preserve unrelated client fields")
+ require.Equal(t, 10*time.Minute, got.ToolSyncInterval)
+ require.True(t, got.AllowOnAllVirtualKeys)
+
+ err = store.UpdateMCPClientDiscoveredTools(
+ ctx,
+ "mcp-client-1",
+ "renamed-client",
+ map[string]schemas.ChatTool{},
+ map[string]string{},
+ lastSync.Add(time.Minute),
+ )
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "removed or renamed")
+
+ got, err = store.GetMCPClientConfigByID(ctx, "mcp-client-1")
+ require.NoError(t, err)
+ require.Equal(t, tools, got.DiscoveredTools)
+ require.Equal(t, lastSync, got.DiscoveredToolsLastSync)
+}
+
func TestRDBConfigStore_UpsertModelPricesSyncsIsDeprecated(t *testing.T) {
store := setupRDBTestStore(t)
require.NoError(t, store.DB().AutoMigrate(&tables.TableModelPricing{}))
diff --git a/framework/configstore/store.go b/framework/configstore/store.go
index a001483a14c..a9b7483aead 100644
--- a/framework/configstore/store.go
+++ b/framework/configstore/store.go
@@ -256,6 +256,7 @@ type ConfigStore interface {
GetMCPClientsPaginated(ctx context.Context, params MCPClientsQueryParams) ([]tables.TableMCPClient, int64, error)
CreateMCPClientConfig(ctx context.Context, clientConfig *schemas.MCPClientConfig) error
UpdateMCPClientConfig(ctx context.Context, id string, clientConfig *tables.TableMCPClient) error
+ UpdateMCPClientDiscoveredTools(ctx context.Context, id, expectedName string, tools map[string]schemas.ChatTool, toolNameMapping map[string]string, lastSync time.Time) error
DeleteMCPClientConfig(ctx context.Context, id string) error
// MCP library catalog (synced + org-custom)
diff --git a/framework/configstore/tables/mcp.go b/framework/configstore/tables/mcp.go
index cd6c2f79e07..2a20982e3f3 100644
--- a/framework/configstore/tables/mcp.go
+++ b/framework/configstore/tables/mcp.go
@@ -31,8 +31,9 @@ type TableMCPClient struct {
ToolExecutionTimeout int `gorm:"default:0" json:"tool_execution_timeout"` // Per-client tool execution timeout in seconds (0 = use global from tool_manager_config)
// Per-user OAuth: discovered tools persisted so they survive restart
- DiscoveredToolsJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]schemas.ChatTool
- ToolNameMappingJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]string
+ DiscoveredToolsJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]schemas.ChatTool
+ ToolNameMappingJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]string
+ DiscoveredToolsLastSync *time.Time `json:"-"` // Last time the global tool catalog was refreshed from a live per-user token
// OAuth authentication fields
AuthType string `gorm:"type:varchar(20);default:'headers'" json:"auth_type"` // "none", "headers", "oauth", "per_user_oauth", "per_user_headers"
diff --git a/framework/logstore/clickhousestore_test.go b/framework/logstore/clickhousestore_test.go
index 92ed7ae8d14..e27e799fad2 100644
--- a/framework/logstore/clickhousestore_test.go
+++ b/framework/logstore/clickhousestore_test.go
@@ -549,24 +549,28 @@ func TestClickHouseMCPToolLogs(t *testing.T) {
chTestMCPToolLog("ch-mcp-1", ts),
chTestMCPToolLog("ch-mcp-2", ts.Add(time.Millisecond)),
}
+ entries[0].RedactionMapping = `plain:{"input":{"EMAIL-1":"private@example.com"}}`
require.NoError(t, store.BatchCreateMCPToolLogsIfNotExists(ctx, entries))
require.NoError(t, store.BatchCreateMCPToolLogsIfNotExists(ctx, nil)) // no-op
found, err := store.FindMCPToolLog(ctx, "ch-mcp-1")
require.NoError(t, err)
assert.Equal(t, "search_web", found.ToolName)
+ assert.Equal(t, entries[0].RedactionMapping, found.RedactionMapping)
// Map update.
latency := 42.0
require.NoError(t, store.UpdateMCPToolLog(ctx, "ch-mcp-1", map[string]interface{}{
- "status": "success",
- "latency": latency,
+ "status": "success",
+ "latency": latency,
+ "redaction_mapping": `plain:{"output":{"EMAIL-2":"result@example.com"}}`,
}))
found, err = store.FindMCPToolLog(ctx, "ch-mcp-1")
require.NoError(t, err)
assert.Equal(t, "success", found.Status)
require.NotNil(t, found.Latency)
assert.Equal(t, 42.0, *found.Latency)
+ assert.Contains(t, found.RedactionMapping, "result@example.com")
assert.Equal(t, int64(1), chCountRows(t, store.db, "mcp_tool_logs", "ch-mcp-1"))
// Struct update preserves untouched fields and the dedup key.
diff --git a/framework/logstore/hybrid.go b/framework/logstore/hybrid.go
index 04b811bbb84..ae6ac8030b6 100644
--- a/framework/logstore/hybrid.go
+++ b/framework/logstore/hybrid.go
@@ -1030,6 +1030,34 @@ func (h *HybridLogStore) GetDistinctStopReasons(ctx context.Context, limit int,
return h.inner.GetDistinctStopReasons(ctx, limit, query)
}
+// GetDistinctUserAgents delegates to the inner store and returns distinct
+// raw User-Agent strings for the logs "App" filter, capped at limit.
+func (h *HybridLogStore) GetDistinctUserAgents(ctx context.Context, limit int, query string) ([]string, error) {
+ return h.inner.GetDistinctUserAgents(ctx, limit, query)
+}
+
+// GetDistinctApps delegates to the inner store and returns distinct backend-
+// detected app labels from recent logs.
+func (h *HybridLogStore) GetDistinctApps(ctx context.Context, limit int, query string) ([]string, error) {
+ return h.inner.GetDistinctApps(ctx, limit, query)
+}
+
+func (h *HybridLogStore) CreateUserAgentMapping(ctx context.Context, mapping *UserAgentMapping) error {
+ return h.inner.CreateUserAgentMapping(ctx, mapping)
+}
+
+func (h *HybridLogStore) UpdateUserAgentMapping(ctx context.Context, id string, mapping *UserAgentMapping) error {
+ return h.inner.UpdateUserAgentMapping(ctx, id, mapping)
+}
+
+func (h *HybridLogStore) DeleteUserAgentMapping(ctx context.Context, id string) error {
+ return h.inner.DeleteUserAgentMapping(ctx, id)
+}
+
+func (h *HybridLogStore) ListUserAgentMappings(ctx context.Context, activeOnly bool) ([]UserAgentMapping, error) {
+ return h.inner.ListUserAgentMappings(ctx, activeOnly)
+}
+
// GetDistinctMetadataKeys delegates to the inner store and returns distinct
// metadata keys (and their distinct values) matching query, capped at limit.
func (h *HybridLogStore) GetDistinctMetadataKeys(ctx context.Context, limit int, query string) (map[string][]string, error) {
@@ -1128,6 +1156,10 @@ func applyMCPToolLogUpdateMap(target *MCPToolLog, updates map[string]interface{}
target.Metadata = v
target.MetadataParsed = nil
}
+ case "redaction_mapping":
+ if v, ok := value.(string); ok {
+ target.RedactionMapping = v
+ }
case "latency":
if v, ok := numericToFloat64(value); ok {
target.Latency = &v
@@ -1193,6 +1225,9 @@ func applyMCPToolLogUpdateStruct(target *MCPToolLog, update *MCPToolLog) error {
target.Metadata = update.Metadata
target.MetadataParsed = nil
}
+ if update.RedactionMapping != "" {
+ target.RedactionMapping = update.RedactionMapping
+ }
if !update.CreatedAt.IsZero() {
target.CreatedAt = update.CreatedAt
}
@@ -1274,6 +1309,9 @@ func prepareMCPToolLogDBUpdatesFromStruct(update MCPToolLog) (map[string]any, er
if update.Metadata != "" {
out["metadata"] = update.Metadata
}
+ if update.RedactionMapping != "" {
+ out["redaction_mapping"] = update.RedactionMapping
+ }
if !update.CreatedAt.IsZero() {
out["created_at"] = update.CreatedAt
}
@@ -1463,6 +1501,17 @@ func (h *HybridLogStore) GetAvailableToolNames(ctx context.Context, limit int, q
return h.inner.GetAvailableToolNames(ctx, limit, query)
}
+// GetAvailableMCPUserAgents returns distinct raw User-Agent strings from MCP tool logs.
+func (h *HybridLogStore) GetAvailableMCPUserAgents(ctx context.Context, limit int, query string) ([]string, error) {
+ return h.inner.GetAvailableMCPUserAgents(ctx, limit, query)
+}
+
+// GetAvailableMCPApps delegates to the inner store and returns distinct backend-
+// detected app labels from MCP tool logs.
+func (h *HybridLogStore) GetAvailableMCPApps(ctx context.Context, limit int, query string) ([]string, error) {
+ return h.inner.GetAvailableMCPApps(ctx, limit, query)
+}
+
// GetAvailableServerLabels returns a list of server labels that match the given query.
func (h *HybridLogStore) GetAvailableServerLabels(ctx context.Context, limit int, query string) ([]string, error) {
return h.inner.GetAvailableServerLabels(ctx, limit, query)
diff --git a/framework/logstore/hybrid_test.go b/framework/logstore/hybrid_test.go
index 5269c72102a..07f177d4bd7 100644
--- a/framework/logstore/hybrid_test.go
+++ b/framework/logstore/hybrid_test.go
@@ -267,6 +267,7 @@ func TestHybrid_CreateAndFindMCPToolLog(t *testing.T) {
ResultParsed: map[string]any{
"ok": true,
},
+ RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`,
}
require.NoError(t, hybrid.CreateMCPToolLog(ctx, entry))
@@ -277,6 +278,7 @@ func TestHybrid_CreateAndFindMCPToolLog(t *testing.T) {
assert.True(t, dbOnly.HasObject)
assert.Empty(t, dbOnly.Result)
assert.Nil(t, dbOnly.ResultParsed)
+ assert.Equal(t, entry.RedactionMapping, dbOnly.RedactionMapping)
preview, ok := dbOnly.ArgumentsParsed.(string)
require.True(t, ok)
assert.Len(t, []rune(preview), 200)
@@ -286,6 +288,7 @@ func TestHybrid_CreateAndFindMCPToolLog(t *testing.T) {
assert.True(t, found.HasObject)
assert.Equal(t, longInput, found.ArgumentsParsed.(map[string]interface{})["input"])
assert.Equal(t, true, found.ResultParsed.(map[string]interface{})["ok"])
+ assert.Equal(t, entry.RedactionMapping, found.RedactionMapping)
}
func TestHybrid_BatchCreateMCPToolLogsIfNotExists(t *testing.T) {
@@ -379,7 +382,8 @@ func TestHybrid_UpdateMCPToolLogOffloadsFullLog(t *testing.T) {
waitForUploads(t, func() bool { return objStore.Len() == 1 })
require.NoError(t, hybrid.UpdateMCPToolLog(ctx, entry.ID, MCPToolLog{
- Status: "success",
+ Status: "success",
+ RedactionMapping: `plain:{"output":{"EMAIL-2":"result@example.com"}}`,
ResultParsed: map[string]any{
"answer": "done",
},
@@ -400,11 +404,13 @@ func TestHybrid_UpdateMCPToolLogOffloadsFullLog(t *testing.T) {
assert.Equal(t, "success", dbOnly.Status)
assert.Empty(t, dbOnly.Result)
assert.Nil(t, dbOnly.ResultParsed)
+ assert.Contains(t, dbOnly.RedactionMapping, "result@example.com")
found, err := hybrid.FindMCPToolLog(ctx, entry.ID)
require.NoError(t, err)
assert.Equal(t, "find this", found.ArgumentsParsed.(map[string]interface{})["query"])
assert.Equal(t, "done", found.ResultParsed.(map[string]interface{})["answer"])
+ assert.Equal(t, dbOnly.RedactionMapping, found.RedactionMapping)
}
func TestHybrid_UpdateMCPToolLogRequiresObjectHydration(t *testing.T) {
diff --git a/framework/logstore/matviewheal.go b/framework/logstore/matviewheal.go
index 53791f0c445..24261558ce8 100644
--- a/framework/logstore/matviewheal.go
+++ b/framework/logstore/matviewheal.go
@@ -82,6 +82,13 @@ const matViewHealCooldown = 30 * time.Second
// - the next query against a still-stale view falls back raw and re-triggers
// the heal, converging once the lock holder finishes.
func (s *RDBLogStore) triggerMatViewSelfHeal() {
+ if s.matViewMaintenanceDisabled {
+ // Unreachable today (the matview read path never enables when
+ // maintenance is disabled, so no shape error can arrive), but guards
+ // the contract against a future caller: a heal would recreate views
+ // the configuration says must not exist.
+ return
+ }
if !s.matViewHealInFlight.CompareAndSwap(false, true) {
return // a heal is already running
}
diff --git a/framework/logstore/matviews.go b/framework/logstore/matviews.go
index fd3eb185f6d..9806326af95 100644
--- a/framework/logstore/matviews.go
+++ b/framework/logstore/matviews.go
@@ -44,6 +44,8 @@ SELECT
COALESCE(business_unit_id, '') AS business_unit_id,
COALESCE(alias, '') AS alias,
COALESCE(canonical_model_name, '') AS canonical_model_name,
+ COALESCE(user_agent, '') AS user_agent,
+ COALESCE(app, '') AS app,
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,
@@ -78,7 +80,7 @@ SELECT
COUNT(*) FILTER (WHERE ` + cacheDebugJSONGuard + `) AS cache_debug_count
FROM logs
WHERE status IN ('success', 'error', 'cancelled')
-GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
+GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16
`
// cacheDebugJSONGuard matches rows whose cache_debug column holds a loose
@@ -96,7 +98,7 @@ const cacheDebugHitTypeExpr = `substring(cache_debug from '"hit_type"[[:space:]]
// during startup ensure / repair paths.
const mvLogsHourlyUniqueIdx = `
CREATE UNIQUE INDEX CONCURRENTLY 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, user_id, team_id, customer_id, business_unit_id, alias, canonical_model_name)
+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, alias, canonical_model_name, user_agent, app)
`
// mvLogsHourlyRequiredColumns is the canonical column set used by
@@ -117,13 +119,27 @@ var mvLogsHourlyRequiredColumns = []string{
"business_unit_id",
"alias",
"canonical_model_name",
- "cancelled_count",
"throughput_completion_tokens",
"throughput_latency_ms",
"throughput_request_count",
"direct_cache_hits",
"semantic_cache_hits",
"cache_debug_count",
+ "user_agent",
+ "app",
+ "count",
+ "success_count",
+ "error_count",
+ "cancelled_count",
+ "avg_latency",
+ "p90_latency",
+ "p95_latency",
+ "p99_latency",
+ "total_prompt_tokens",
+ "total_completion_tokens",
+ "total_tokens",
+ "total_cached_read_tokens",
+ "total_cost",
}
// legacyMatViewNames are matviews from previous schema versions that no longer
@@ -316,6 +332,21 @@ var filterMatViews = []filterMatViewDef{
uniqueIdx: "id, name, " + scopeIdxColumns,
requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...),
},
+ {
+ name: "mv_filter_apps",
+ selectExpr: "app, " + scopeProjection,
+ whereExpr: "app IS NOT NULL AND app != ''",
+ uniqueIdx: "app, " + scopeIdxColumns,
+ requiredColumns: append([]string{"app"}, scopeRequiredColumns...),
+ },
+ {
+ // Distinct raw User-Agent strings kept for compatibility/debug filtering.
+ name: "mv_filter_user_agents",
+ selectExpr: "user_agent, " + scopeProjection,
+ whereExpr: "user_agent IS NOT NULL AND user_agent != ''",
+ uniqueIdx: "user_agent, " + scopeIdxColumns,
+ requiredColumns: append([]string{"user_agent"}, scopeRequiredColumns...),
+ },
}
// filterMatViewKeyPairColumns maps the (idCol, nameCol) pair callers pass into
@@ -591,6 +622,9 @@ func matViewNeedsRebuild(ctx context.Context, conn *sql.Conn, view string, requi
return true, nil
}
}
+ if len(actual) != len(requiredColumns) {
+ return true, nil
+ }
return false, nil
}
@@ -854,7 +888,12 @@ func refreshMatViews(ctx context.Context, db *gorm.DB) error {
// 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.
+// A non-positive interval means maintenance is disabled: no goroutine starts
+// and the stop function is a no-op.
func startMatViewRefresher(ctx context.Context, db *gorm.DB, interval, timeout time.Duration, logger schemas.Logger, readyFlag *atomic.Bool) func() {
+ if interval <= 0 {
+ return func() {}
+ }
stopCh := make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
@@ -911,6 +950,7 @@ func canUseMatViewFilters(f SearchFilters) bool {
f.MinCost == nil && f.MaxCost == nil &&
!f.MissingCostOnly &&
len(f.CacheHitTypes) == 0 &&
+ len(f.UserAgents) == 0 &&
len(f.TeamIDs) == 0 &&
len(f.BusinessUnitIDs) == 0 &&
len(f.CustomerIDs) == 0
@@ -1038,6 +1078,9 @@ func applyMatViewFiltersOnly(q *gorm.DB, f SearchFilters) *gorm.DB {
if len(f.BusinessUnitIDs) > 0 {
q = q.Where("business_unit_id IN ?", f.BusinessUnitIDs)
}
+ if len(f.Apps) > 0 {
+ q = q.Where("app IN ?", f.Apps)
+ }
return q
}
@@ -2581,6 +2624,36 @@ func (s *RDBLogStore) getDistinctStopReasonsFromMatView(ctx context.Context, lim
return stopReasons, nil
}
+// getDistinctUserAgentsFromMatView returns unique raw User-Agent strings from mv_filter_user_agents.
+func (s *RDBLogStore) getDistinctUserAgentsFromMatView(ctx context.Context, limit int, query string) ([]string, error) {
+ var userAgents []string
+ q := s.ScopedDB(ctx).Table("mv_filter_user_agents").
+ Distinct("user_agent").
+ Where("user_agent != ''")
+ if query != "" {
+ q = q.Where("user_agent ILIKE ?", "%"+query+"%")
+ }
+ if err := q.Order("user_agent ASC").Limit(limit).Pluck("user_agent", &userAgents).Error; err != nil {
+ return nil, err
+ }
+ return userAgents, nil
+}
+
+// getDistinctAppsFromMatView returns unique backend-detected app labels from mv_filter_apps.
+func (s *RDBLogStore) getDistinctAppsFromMatView(ctx context.Context, limit int, query string) ([]string, error) {
+ var apps []string
+ q := s.ScopedDB(ctx).Table("mv_filter_apps").
+ Distinct("app").
+ Where("app != ''")
+ if query != "" {
+ q = q.Where("app ILIKE ?", "%"+query+"%")
+ }
+ if err := q.Order("app ASC").Limit(limit).Pluck("app", &apps).Error; err != nil {
+ return nil, err
+ }
+ return apps, nil
+}
+
// getDistinctKeyPairsFromMatView returns unique ID-Name pairs for the given
// (idCol, nameCol) by selecting from the per-dimension matview pre-aggregated
// for that pair. Returns (nil, false) if no matview is registered for the pair —
diff --git a/framework/logstore/matviews_lock_test.go b/framework/logstore/matviews_lock_test.go
index 359996017bb..8243386f33b 100644
--- a/framework/logstore/matviews_lock_test.go
+++ b/framework/logstore/matviews_lock_test.go
@@ -16,6 +16,27 @@ func TestResolveMatViewRefreshIntervalDefaults(t *testing.T) {
assert.Equal(t, time.Minute, resolveMatViewRefreshInterval("not-a-duration", testLogger{}))
assert.Equal(t, minMatViewRefreshInterval, resolveMatViewRefreshInterval("1s", testLogger{}))
assert.Equal(t, 5*time.Minute, resolveMatViewRefreshInterval("5m", testLogger{}))
+ // "off" and non-positive durations disable maintenance rather than clamping
+ // up to the floor.
+ assert.Equal(t, time.Duration(0), resolveMatViewRefreshInterval("off", testLogger{}))
+ assert.Equal(t, time.Duration(0), resolveMatViewRefreshInterval("0s", testLogger{}))
+ assert.Equal(t, time.Duration(0), resolveMatViewRefreshInterval("-1m", testLogger{}))
+}
+
+func TestStartMatViewRefresherDisabled(t *testing.T) {
+ // A disabled interval must not start a ticker (time.NewTicker panics on
+ // non-positive intervals); the returned stop function is a no-op.
+ stop := startMatViewRefresher(context.Background(), nil, 0, time.Minute, testLogger{}, nil)
+ stop()
+}
+
+func TestSelfHealSkippedWhenMaintenanceDisabled(t *testing.T) {
+ // With maintenance disabled, self-heal must not recreate the views (a nil
+ // db would panic if the repair goroutine ran) and must not arm the
+ // single-flight state.
+ s := &RDBLogStore{matViewMaintenanceDisabled: true}
+ s.triggerMatViewSelfHeal()
+ assert.False(t, s.matViewHealInFlight.Load())
}
func TestResolveMatViewRefreshTimeoutDefaults(t *testing.T) {
diff --git a/framework/logstore/migrations.go b/framework/logstore/migrations.go
index 428cc7ad0f6..7a335bd6c97 100644
--- a/framework/logstore/migrations.go
+++ b/framework/logstore/migrations.go
@@ -274,6 +274,7 @@ var logstoreMigrationSteps = []migrationStep{
{IDs: []string{"logs_recreate_filter_customers_matview_multivalue"}, run: migrationRecreateFilterCustomersMatView},
{IDs: []string{"logs_add_canonical_model_columns_v2"}, run: migrationAddCanonicalModelColumns},
{IDs: []string{"logs_add_redaction_mapping_column"}, run: migrationAddRedactionMappingColumn},
+ {IDs: []string{"mcp_tool_logs_add_redaction_mapping_column"}, run: migrationAddMCPRedactionMappingColumn},
{IDs: []string{"webhook_deliveries_init"}, run: migrationCreateWebhookDeliveriesTable},
{IDs: []string{"async_jobs_add_webhook_endpoint_id_column"}, run: migrationAddWebhookEndpointIDColumn},
{IDs: []string{"async_jobs_add_request_id_column"}, run: migrationAddAsyncJobRequestIDColumn},
@@ -281,6 +282,18 @@ var logstoreMigrationSteps = []migrationStep{
{IDs: []string{"logs_add_content_hidden_column"}, run: migrationAddContentHiddenColumn},
{IDs: []string{"logs_add_server_side_fallback_model_column"}, run: migrationAddServerSideFallbackModelColumn},
{IDs: []string{"logs_add_billing_fidelity_columns"}, run: migrationAddBillingFidelityColumns},
+ {IDs: []string{"logs_recreate_matviews_with_user_agent_column"}, run: migrationRecreateMatViewsWithUserAgentColumn},
+ {IDs: []string{"logs_add_user_agent_column"}, run: migrationAddUserAgentColumn},
+ {IDs: []string{"mcp_tool_logs_add_user_agent_column"}, run: migrationAddUserAgentColumnToMCPToolLogs},
+ // Both this step and the "logs_recreate_matviews_with_user_agent_column" step above
+ // intentionally run the same migrationRecreateMatViewsWithUserAgentColumn function: the
+ // function was renamed from migrationRecreateMatViewsWithAppColumn (see git history), and
+ // this second step ID reconciles local DBs that recorded one of the two step IDs before the
+ // rename. The function's own logic (CreateTable-if-not-exists) is idempotent, so running it
+ // twice on a fresh DB is harmless.
+ {IDs: []string{"logs_recreate_matviews_with_app_column"}, run: migrationRecreateMatViewsWithUserAgentColumn},
+ {IDs: []string{"mcp_tool_logs_add_endpoint_columns"}, run: migrationAddEndpointColumnsToMCPToolLogs},
+ {IDs: []string{"mcp_tool_logs_add_plugin_logs_column"}, run: migrationAddMCPPluginLogsColumn},
}
// areThereAnyPendingMigrations returns true if there are any pending migrations to be applied.
@@ -2686,6 +2699,36 @@ var performanceIndexes = []performanceIndexDef{
name: "idx_mcp_logs_business_unit_id",
sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_business_unit_id ON mcp_tool_logs(business_unit_id)",
},
+ {
+ table: "mcp_tool_logs",
+ name: "idx_mcp_logs_device_id",
+ sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_device_id ON mcp_tool_logs(device_id)",
+ },
+ {
+ table: "mcp_tool_logs",
+ name: "idx_mcp_logs_source",
+ sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_source ON mcp_tool_logs(source)",
+ },
+ {
+ table: "logs",
+ name: "idx_logs_user_agent",
+ sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_user_agent ON logs(user_agent)",
+ },
+ {
+ table: "logs",
+ name: "idx_logs_app",
+ sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_app ON logs(app)",
+ },
+ {
+ table: "mcp_tool_logs",
+ name: "idx_mcp_logs_user_agent",
+ sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_user_agent ON mcp_tool_logs(user_agent)",
+ },
+ {
+ table: "mcp_tool_logs",
+ name: "idx_mcp_logs_app",
+ sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mcp_logs_app ON mcp_tool_logs(app)",
+ },
{
table: "logs",
name: "idx_logs_cluster_node_id",
@@ -2811,6 +2854,28 @@ func migrationAddPluginLogsColumn(ctx context.Context, db *gorm.DB, logger schem
return nil
}
+// migrationAddMCPPluginLogsColumn adds the plugin_logs column to MCP tool logs.
+func migrationAddMCPPluginLogsColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
+ migrationName := "mcp_tool_logs_add_plugin_logs_column"
+ logger.Info("[logstore] starting migration %s", migrationName)
+ defer logger.Info("[logstore] finished migration %s", migrationName)
+ opts := *migrator.DefaultOptions
+ opts.UseTransaction = true
+ m := migrator.New(db, &opts, []*migrator.Migration{{
+ ID: migrationName,
+ Migrate: func(tx *gorm.DB) error {
+ return addColumnIfNotExists(tx.WithContext(ctx), logger, &MCPToolLog{}, "plugin_logs")
+ },
+ Rollback: func(tx *gorm.DB) error {
+ return dropColumnIfExists(tx.WithContext(ctx), logger, &MCPToolLog{}, "plugin_logs")
+ },
+ }})
+ if err := m.Migrate(); err != nil {
+ return fmt.Errorf("error while adding MCP plugin logs column: %s", err.Error())
+ }
+ return nil
+}
+
// migrationAddAliasColumn adds the alias column to the logs table.
// The alias field stores the original model name the caller used when routing resolved it to a different model via alias mapping.
// Index creation is deferred to ensurePerformanceIndexes (called post-startup in a background goroutine)
@@ -3151,6 +3216,168 @@ func migrationRecreateMatViewsWithGovernanceColumns(ctx context.Context, db *gor
return nil
}
+// migrationAddUserAgentColumn adds the user_agent and app columns to the logs table.
+// user_agent stores the raw HTTP User-Agent verbatim; app stores the backend-
+// detected client app (Claude Code, Codex, Cursor, ...).
+//
+// Indexes on user_agent and app are built CONCURRENTLY by ensurePerformanceIndexes
+// (entries appended to performanceIndexes) so adding them does not block writes on
+// a populated table.
+func migrationAddUserAgentColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
+ opts := *migrator.DefaultOptions
+ opts.UseTransaction = true
+ m := migrator.New(db, &opts, []*migrator.Migration{{
+ ID: "logs_add_user_agent_column",
+ Migrate: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ migrator := tx.Migrator()
+ if !migrator.HasColumn(&Log{}, "user_agent") {
+ if err := migrator.AddColumn(&Log{}, "user_agent"); err != nil {
+ return err
+ }
+ }
+ if !migrator.HasColumn(&Log{}, "app") {
+ if err := migrator.AddColumn(&Log{}, "app"); err != nil {
+ return err
+ }
+ }
+ if !migrator.HasTable(&UserAgentMapping{}) {
+ if err := migrator.CreateTable(&UserAgentMapping{}); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ migrator := tx.Migrator()
+ if migrator.HasIndex(&Log{}, "idx_logs_app") {
+ if err := migrator.DropIndex(&Log{}, "idx_logs_app"); err != nil {
+ return err
+ }
+ }
+ if migrator.HasIndex(&Log{}, "idx_logs_user_agent") {
+ if err := migrator.DropIndex(&Log{}, "idx_logs_user_agent"); err != nil {
+ return err
+ }
+ }
+ if migrator.HasTable(&UserAgentMapping{}) {
+ if err := migrator.DropTable(&UserAgentMapping{}); err != nil {
+ return err
+ }
+ }
+ if migrator.HasColumn(&Log{}, "app") {
+ if err := migrator.DropColumn(&Log{}, "app"); err != nil {
+ return err
+ }
+ }
+ if migrator.HasColumn(&Log{}, "user_agent") {
+ if err := migrator.DropColumn(&Log{}, "user_agent"); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ }})
+ err := m.Migrate()
+ if err != nil {
+ return fmt.Errorf("error while adding user_agent column: %s", err.Error())
+ }
+ return nil
+}
+
+// migrationAddUserAgentColumnToMCPToolLogs adds the user_agent and app columns to
+// the mcp_tool_logs table, mirroring migrationAddUserAgentColumn for MCP tool calls.
+//
+// Indexes on user_agent and app are built CONCURRENTLY by ensurePerformanceIndexes
+// (entries appended to performanceIndexes) so adding them does not block writes on
+// a populated table.
+func migrationAddUserAgentColumnToMCPToolLogs(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
+ opts := *migrator.DefaultOptions
+ opts.UseTransaction = true
+ m := migrator.New(db, &opts, []*migrator.Migration{{
+ ID: "mcp_tool_logs_add_user_agent_column",
+ Migrate: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ mg := tx.Migrator()
+ if !mg.HasColumn(&MCPToolLog{}, "user_agent") {
+ if err := mg.AddColumn(&MCPToolLog{}, "user_agent"); err != nil {
+ return err
+ }
+ }
+ if !mg.HasColumn(&MCPToolLog{}, "app") {
+ if err := mg.AddColumn(&MCPToolLog{}, "app"); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ mg := tx.Migrator()
+ if mg.HasIndex(&MCPToolLog{}, "idx_mcp_logs_app") {
+ if err := mg.DropIndex(&MCPToolLog{}, "idx_mcp_logs_app"); err != nil {
+ return err
+ }
+ }
+ if mg.HasIndex(&MCPToolLog{}, "idx_mcp_logs_user_agent") {
+ if err := mg.DropIndex(&MCPToolLog{}, "idx_mcp_logs_user_agent"); err != nil {
+ return err
+ }
+ }
+ if mg.HasColumn(&MCPToolLog{}, "app") {
+ if err := mg.DropColumn(&MCPToolLog{}, "app"); err != nil {
+ return err
+ }
+ }
+ if mg.HasColumn(&MCPToolLog{}, "user_agent") {
+ if err := mg.DropColumn(&MCPToolLog{}, "user_agent"); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ }})
+ if err := m.Migrate(); err != nil {
+ return fmt.Errorf("error while adding user_agent column to mcp_tool_logs: %s", err.Error())
+ }
+ return nil
+}
+
+// migrationRecreateMatViewsWithUserAgentColumn is a marker migration: the actual
+// rebuild of mv_logs_hourly (now grouped by app, not raw user_agent) and the
+// creation of mv_filter_apps happen on the next PostgreSQL startup via
+// ensureMatViews / repairMatViewShapes, which detect the required app column
+// and drop+recreate the drifted view. The rebuild is intentionally deferred to
+// startup (not done inline here) to avoid heavy AccessExclusiveLock churn during
+// rolling deploys on large logs tables. The user_agent_mappings table guard
+// also runs here for local DBs that already recorded the edited column migration
+// before the table was added to it.
+func migrationRecreateMatViewsWithUserAgentColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
+ opts := *migrator.DefaultOptions
+ opts.UseTransaction = true
+ m := migrator.New(db, &opts, []*migrator.Migration{{
+ ID: "logs_recreate_matviews_with_app_column",
+ Migrate: func(tx *gorm.DB) error {
+ migrator := tx.Migrator()
+ if !migrator.HasTable(&UserAgentMapping{}) {
+ if err := migrator.CreateTable(&UserAgentMapping{}); err != nil {
+ return fmt.Errorf("failed to create user_agent_mappings table: %w", err)
+ }
+ }
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ // No rollback needed — ensureMatViews recreates on next startup.
+ return nil
+ },
+ }})
+ if err := m.Migrate(); err != nil {
+ return fmt.Errorf("error while recreating matviews with app column: %s", err.Error())
+ }
+ return nil
+}
+
// migrationSplitFilterDataMatView drops the legacy mv_logs_filterdata view so
// ensureMatViews recreates it as per-dimension matviews (mv_filter_models,
// mv_filter_selected_keys, ...). The old view DISTINCTed across 16 columns and
@@ -3390,6 +3617,31 @@ func migrationAddRedactionMappingColumn(ctx context.Context, db *gorm.DB, logger
return nil
}
+// migrationAddMCPRedactionMappingColumn adds the reversible redaction mapping
+// column to MCP tool logs while keeping its lifecycle coupled to the log row.
+func migrationAddMCPRedactionMappingColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
+ migrationName := "mcp_tool_logs_add_redaction_mapping_column"
+ logger.Info("[logstore] starting migration %s", migrationName)
+ defer logger.Info("[logstore] finished migration %s", migrationName)
+ opts := *migrator.DefaultOptions
+ opts.UseTransaction = true
+ m := migrator.New(db, &opts, []*migrator.Migration{{
+ ID: migrationName,
+ Migrate: func(tx *gorm.DB) error {
+ return addColumnIfNotExists(tx.WithContext(ctx), logger, &MCPToolLog{}, "redaction_mapping")
+ },
+ Rollback: func(*gorm.DB) error {
+ // No-op rollback: dropping the column would permanently destroy
+ // reveal data for already-redacted MCP logs.
+ return nil
+ },
+ }})
+ if err := m.Migrate(); err != nil {
+ return fmt.Errorf("error while adding MCP redaction_mapping column: %s", err.Error())
+ }
+ return nil
+}
+
// migrationAddSafeJsonbFunction installs a PL/pgSQL helper that the
// /api/logs list query uses to extract the last element of input_history /
// responses_input_history without aborting the whole query on a single bad row.
@@ -3497,6 +3749,47 @@ func migrationAddDACColumnsToMCPToolLogs(ctx context.Context, db *gorm.DB, logge
return nil
}
+// migrationAddEndpointColumnsToMCPToolLogs adds the endpoint-agent context
+// columns (device_id, app_key, decision, source) to the mcp_tool_logs table so
+// tool calls observed on developer machines by the Bifrost Edge agent can be
+// stored alongside gateway-proxied calls.
+//
+// Indexes on device_id and source are built CONCURRENTLY by
+// ensurePerformanceIndexes (entries appended to performanceIndexes) so adding
+// them does not block writes on a populated table.
+func migrationAddEndpointColumnsToMCPToolLogs(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
+ migrationName := "mcp_tool_logs_add_endpoint_columns"
+ logger.Info("[logstore] starting migration %s", migrationName)
+ defer logger.Info("[logstore] finished migration %s", migrationName)
+ opts := *migrator.DefaultOptions
+ opts.UseTransaction = true
+ m := migrator.New(db, &opts, []*migrator.Migration{{
+ ID: migrationName,
+ Migrate: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ for _, col := range []string{"device_id", "app_key", "decision", "source"} {
+ if err := addColumnIfNotExists(tx, logger, &MCPToolLog{}, col); err != nil {
+ return fmt.Errorf("failed to add %s column to mcp_tool_logs: %w", col, err)
+ }
+ }
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ tx = tx.WithContext(ctx)
+ for _, col := range []string{"source", "decision", "app_key", "device_id"} {
+ if err := dropColumnIfExists(tx, logger, &MCPToolLog{}, col); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ }})
+ if err := m.Migrate(); err != nil {
+ return fmt.Errorf("error while adding endpoint columns to mcp_tool_logs: %w", err)
+ }
+ return nil
+}
+
// migrationAddClusterGovernanceColumns adds cluster_node_id, budget_ids, and rate_limit_ids
// columns to the logs table for node usage recovery in clustered deployments.
func migrationAddClusterGovernanceColumns(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
diff --git a/framework/logstore/migrations_test.go b/framework/logstore/migrations_test.go
index 0ecdc815b7c..ecf98dee8c3 100644
--- a/framework/logstore/migrations_test.go
+++ b/framework/logstore/migrations_test.go
@@ -3,16 +3,52 @@ package logstore
import (
"context"
"fmt"
+ "path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
+ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
+// TestMigrationAddMCPRedactionMappingColumn verifies the MCP mapping column is additive, idempotent, and preserves existing rows.
+func TestMigrationAddMCPRedactionMappingColumn(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migrations.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
+ require.NoError(t, err)
+ require.NoError(t, db.Exec("CREATE TABLE mcp_tool_logs (id TEXT PRIMARY KEY)").Error)
+ require.NoError(t, db.Exec("INSERT INTO mcp_tool_logs (id) VALUES (?)", "mcp-existing").Error)
+
+ ctx := context.Background()
+ require.NoError(t, migrationAddMCPRedactionMappingColumn(ctx, db, testLogger{}))
+ require.True(t, db.Migrator().HasColumn(&MCPToolLog{}, "RedactionMapping"))
+ require.NoError(t, migrationAddMCPRedactionMappingColumn(ctx, db, testLogger{}))
+
+ var count int64
+ require.NoError(t, db.Table("mcp_tool_logs").Where("id = ?", "mcp-existing").Count(&count).Error)
+ assert.Equal(t, int64(1), count)
+}
+
+// TestMigrationAddMCPPluginLogsColumn verifies the MCP plugin-log column is additive, idempotent, and preserves existing rows.
+func TestMigrationAddMCPPluginLogsColumn(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migrations.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
+ require.NoError(t, err)
+ require.NoError(t, db.Exec("CREATE TABLE mcp_tool_logs (id TEXT PRIMARY KEY)").Error)
+ require.NoError(t, db.Exec("INSERT INTO mcp_tool_logs (id) VALUES (?)", "mcp-existing").Error)
+
+ ctx := context.Background()
+ require.NoError(t, migrationAddMCPPluginLogsColumn(ctx, db, testLogger{}))
+ require.True(t, db.Migrator().HasColumn(&MCPToolLog{}, "PluginLogs"))
+ require.NoError(t, migrationAddMCPPluginLogsColumn(ctx, db, testLogger{}))
+
+ var count int64
+ require.NoError(t, db.Table("mcp_tool_logs").Where("id = ?", "mcp-existing").Count(&count).Error)
+ assert.Equal(t, int64(1), count)
+}
+
// pgTestSchema is this package's dedicated Postgres schema. Test packages
// (configstore, configstore/tables, logstore) run in parallel against the same
// database, so each one works in its own schema to avoid clobbering the
diff --git a/framework/logstore/payload.go b/framework/logstore/payload.go
index c434bcb3d47..cd5568f3d88 100644
--- a/framework/logstore/payload.go
+++ b/framework/logstore/payload.go
@@ -399,6 +399,7 @@ func MarshalMCPToolLogPayload(l *MCPToolLog) ([]byte, error) {
func MergeMCPToolLogPayloadFromJSON(l *MCPToolLog, data []byte) error {
hasObject := l.HasObject
virtualKey := l.VirtualKey
+ redactionMapping := l.RedactionMapping
var payload MCPToolLog
if err := sonic.Unmarshal(data, &payload); err != nil {
@@ -410,6 +411,7 @@ func MergeMCPToolLogPayloadFromJSON(l *MCPToolLog, data []byte) error {
*l = payload
l.HasObject = hasObject
l.VirtualKey = virtualKey
+ l.RedactionMapping = redactionMapping
return nil
}
diff --git a/framework/logstore/payload_test.go b/framework/logstore/payload_test.go
index b73e6dd71a1..32ddd4f1504 100644
--- a/framework/logstore/payload_test.go
+++ b/framework/logstore/payload_test.go
@@ -206,12 +206,18 @@ func TestMCPToolLogPayload_RoundTripFullLog(t *testing.T) {
MetadataParsed: map[string]interface{}{
"trace": "abc",
},
+ PluginLogs: `{"guardrails":[{"plugin_name":"guardrails","level":"info","message":"arguments redacted","timestamp":1}]}`,
+ RedactionData: &schemas.RedactionData{
+ ReversibleMappings: schemas.RedactionMapsByPhase{Input: map[string]string{"EMAIL-1": "private@example.com"}},
+ },
+ RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`,
}
data, err := MarshalMCPToolLogPayload(entry)
require.NoError(t, err)
+ assert.NotContains(t, string(data), "private@example.com")
- dbEntry := &MCPToolLog{HasObject: true}
+ dbEntry := &MCPToolLog{HasObject: true, RedactionMapping: entry.RedactionMapping}
err = MergeMCPToolLogPayloadFromJSON(dbEntry, data)
require.NoError(t, err)
@@ -225,6 +231,24 @@ func TestMCPToolLogPayload_RoundTripFullLog(t *testing.T) {
assert.Equal(t, true, dbEntry.ResultParsed.(map[string]interface{})["ok"])
assert.Equal(t, "stored for round trip", dbEntry.ErrorDetailsParsed.Error.Message)
assert.Equal(t, "abc", dbEntry.MetadataParsed["trace"])
+ assert.Equal(t, entry.PluginLogs, dbEntry.PluginLogs)
+ assert.Equal(t, entry.RedactionMapping, dbEntry.RedactionMapping)
+ assert.Nil(t, dbEntry.RedactionData)
+}
+
+// TestMCPToolLogRedactionMappingJSONVisibility verifies only the authorized virtual mapping is API-visible.
+func TestMCPToolLogRedactionMappingJSONVisibility(t *testing.T) {
+ entry := &MCPToolLog{
+ RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`,
+ RevealRedactionMapping: &schemas.RedactionMapsByPhase{
+ Input: map[string]string{"EMAIL-1": "revealed@example.com"},
+ },
+ }
+
+ data, err := sonic.Marshal(entry)
+ require.NoError(t, err)
+ assert.NotContains(t, string(data), "private@example.com")
+ assert.Contains(t, string(data), `"redaction_mapping":{"input":{"EMAIL-1":"revealed@example.com"}}`)
}
func TestPrepareMCPToolDBEntry_KeepsOnlyInputPreview(t *testing.T) {
@@ -254,6 +278,7 @@ func TestPrepareMCPToolDBEntry_KeepsOnlyInputPreview(t *testing.T) {
MetadataParsed: map[string]interface{}{
"trace": "abc",
},
+ PluginLogs: `{"guardrails":[{"message":"arguments redacted"}]}`,
}
PrepareMCPToolDBEntry(entry)
@@ -268,6 +293,7 @@ func TestPrepareMCPToolDBEntry_KeepsOnlyInputPreview(t *testing.T) {
assert.Nil(t, entry.ErrorDetailsParsed)
assert.NotEmpty(t, entry.Arguments)
assert.NotEmpty(t, entry.Metadata)
+ assert.Equal(t, `{"guardrails":[{"message":"arguments redacted"}]}`, entry.PluginLogs)
var preview string
require.NoError(t, sonic.Unmarshal([]byte(entry.Arguments), &preview))
diff --git a/framework/logstore/postgres.go b/framework/logstore/postgres.go
index 5dd2aec0cee..c7335e6a660 100644
--- a/framework/logstore/postgres.go
+++ b/framework/logstore/postgres.go
@@ -20,7 +20,9 @@ type PostgresConfig struct {
// material on the database instance — the matview path already has
// activity-gated short-circuiting (see matViewRefreshGate), so the longer
// interval mostly affects how quickly idle clusters notice the rolling
- // 30-day filter window has aged.
+ // 30-day filter window has aged. Set "off" (or any non-positive duration)
+ // to disable materialized-view maintenance entirely: views are neither
+ // created nor refreshed and dashboard queries use the raw tables.
MatViewRefreshInterval string `json:"matview_refresh_interval,omitempty"`
// MatViewRefreshTimeout bounds a single refresh pass. A refresh holds a pooled
@@ -100,15 +102,24 @@ func resolveMatViewRefreshTimeout(raw string, interval time.Duration, logger sch
// resolveMatViewRefreshInterval parses the configured duration string with
// fallback + clamp. Logs a warning on a bad string so misconfig is noticed.
+// Returns 0 when maintenance is disabled ("off" or a non-positive duration).
func resolveMatViewRefreshInterval(raw string, logger schemas.Logger) time.Duration {
if raw == "" {
return defaultMatViewRefreshInterval
}
+ if raw == "off" {
+ logger.Info("logstore: matview maintenance disabled via config")
+ return 0
+ }
d, err := time.ParseDuration(raw)
if err != nil {
logger.Warn(fmt.Sprintf("logstore: invalid matview_refresh_interval %q (%s); using default %s", raw, err, defaultMatViewRefreshInterval))
return defaultMatViewRefreshInterval
}
+ if d <= 0 {
+ logger.Info("logstore: matview maintenance disabled via config")
+ return 0
+ }
if d < minMatViewRefreshInterval {
logger.Warn(fmt.Sprintf("logstore: matview_refresh_interval %s is below floor %s; clamping to %s", d, minMatViewRefreshInterval, minMatViewRefreshInterval))
return minMatViewRefreshInterval
@@ -215,7 +226,8 @@ func newPostgresLogStore(ctx context.Context, config *PostgresConfig, logger sch
return nil, err
}
logger.Info("logstore: runtime connection pool ready")
- d := &RDBLogStore{db: db, logger: logger}
+ refreshInterval := resolveMatViewRefreshInterval(config.MatViewRefreshInterval, logger)
+ d := &RDBLogStore{db: db, logger: logger, matViewMaintenanceDisabled: refreshInterval <= 0}
// Run all index builds sequentially in a single goroutine to prevent
// deadlocks from concurrent CREATE INDEX CONCURRENTLY on the same table.
@@ -260,14 +272,13 @@ func newPostgresLogStore(ctx context.Context, config *PostgresConfig, logger sch
// Create materialized views and start periodic refresh for dashboard queries.
go func() {
- if db.Dialector.Name() != "postgres" {
+ if db.Dialector.Name() != "postgres" || refreshInterval <= 0 {
return
}
if err := ensureMatViews(context.Background(), db); err != nil {
logger.Warn(fmt.Sprintf("logstore: matview creation failed: %s (dashboard queries will use raw tables)", err))
return
}
- refreshInterval := resolveMatViewRefreshInterval(config.MatViewRefreshInterval, logger)
refreshTimeout := resolveMatViewRefreshTimeout(config.MatViewRefreshTimeout, refreshInterval, logger)
// The initial refresh gets the same budget as a periodic tick; on a large
diff --git a/framework/logstore/rdb.go b/framework/logstore/rdb.go
index 5745987bd12..7eaa1c4d650 100644
--- a/framework/logstore/rdb.go
+++ b/framework/logstore/rdb.go
@@ -70,6 +70,9 @@ type RDBLogStore struct {
db *gorm.DB
logger schemas.Logger
matViewsReady atomic.Bool
+ // matViewMaintenanceDisabled records that matview_refresh_interval resolved
+ // to disabled, so the self-heal path must not recreate the views either.
+ matViewMaintenanceDisabled bool
// Self-heal state for the matview read path (see matviewheal.go).
matViewHealInFlight atomic.Bool
matViewHealLastAttempt atomic.Int64 // unix nanos of the last repair attempt
@@ -271,6 +274,12 @@ func (s *RDBLogStore) applyFilters(baseQuery *gorm.DB, filters SearchFilters) *g
baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs)
}
}
+ if len(filters.UserAgents) > 0 {
+ baseQuery = baseQuery.Where("user_agent IN ?", filters.UserAgents)
+ }
+ if len(filters.Apps) > 0 {
+ baseQuery = baseQuery.Where("app IN ?", filters.Apps)
+ }
if len(filters.RoutingEngineUsed) > 0 {
// Query routing engines (comma-separated values) - find logs containing ANY of the specified engines
dialect := s.db.Dialector.Name()
@@ -1043,6 +1052,7 @@ func (s *RDBLogStore) listSelectColumns() string {
"user_id", "user_name", "team_id", "team_name", "customer_id", "customer_name",
"business_unit_id", "business_unit_name",
"team_ids", "team_names", "customer_ids", "customer_names", "business_unit_ids", "business_unit_names",
+ "user_agent", "app",
"speech_input", "transcription_input", "image_generation_input", "video_generation_input",
// error_details is intentionally excluded from the list select: for status=error
// rows it can carry the provider's full (unbounded) error payload, and 25+ such
@@ -3898,6 +3908,98 @@ func (s *RDBLogStore) GetDistinctStopReasons(ctx context.Context, limit int, que
return stopReasons, nil
}
+// GetDistinctUserAgents returns all unique non-empty user_agent values using SELECT DISTINCT.
+// The UI maps each raw User-Agent to a client app. Matview path is DAC-aware
+// (see GetDistinctModels); falls back to a recency-scoped raw-table scan.
+func (s *RDBLogStore) GetDistinctUserAgents(ctx context.Context, limit int, query string) ([]string, error) {
+ if s.db.Dialector.Name() == "postgres" && s.matViewsReady.Load() {
+ return s.getDistinctUserAgentsFromMatView(ctx, limit, query)
+ }
+ cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays)
+ var userAgents []string
+ q := s.ScopedDB(ctx).Model(&Log{}).
+ Where("user_agent IS NOT NULL AND user_agent != '' AND timestamp >= ?", cutoff).
+ Distinct("user_agent")
+ if query != "" {
+ q = s.applyLikeFilter(q, "user_agent", query)
+ }
+ if err := q.Order("user_agent ASC").Limit(limit).Pluck("user_agent", &userAgents).Error; err != nil {
+ return nil, fmt.Errorf("failed to get distinct user agents: %w", err)
+ }
+ return userAgents, nil
+}
+
+// GetDistinctApps returns all unique non-empty backend-detected app labels.
+func (s *RDBLogStore) GetDistinctApps(ctx context.Context, limit int, query string) ([]string, error) {
+ if s.db.Dialector.Name() == "postgres" && s.matViewsReady.Load() {
+ return s.getDistinctAppsFromMatView(ctx, limit, query)
+ }
+ cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays)
+ var apps []string
+ q := s.ScopedDB(ctx).Model(&Log{}).
+ Where("app IS NOT NULL AND app != '' AND timestamp >= ?", cutoff).
+ Distinct("app")
+ if query != "" {
+ q = s.applyLikeFilter(q, "app", query)
+ }
+ if err := q.Order("app ASC").Limit(limit).Pluck("app", &apps).Error; err != nil {
+ return nil, fmt.Errorf("failed to get distinct apps: %w", err)
+ }
+ return apps, nil
+}
+
+// CreateUserAgentMapping persists a custom User-Agent to app mapping.
+func (s *RDBLogStore) CreateUserAgentMapping(ctx context.Context, mapping *UserAgentMapping) error {
+ if err := s.db.WithContext(ctx).Create(mapping).Error; err != nil {
+ return fmt.Errorf("failed to create user agent mapping: %w", err)
+ }
+ return nil
+}
+
+// UpdateUserAgentMapping updates an existing custom User-Agent mapping by ID.
+func (s *RDBLogStore) UpdateUserAgentMapping(ctx context.Context, id string, mapping *UserAgentMapping) error {
+ result := s.db.WithContext(ctx).Model(&UserAgentMapping{}).Where("id = ?", id).Updates(map[string]interface{}{
+ "pattern": mapping.Pattern,
+ "match_type": mapping.MatchType,
+ "app": mapping.App,
+ "logo": mapping.Logo,
+ "logo_mime": mapping.LogoMime,
+ "is_active": mapping.IsActive,
+ })
+ if result.Error != nil {
+ return fmt.Errorf("failed to update user agent mapping: %w", result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return gorm.ErrRecordNotFound
+ }
+ return nil
+}
+
+// DeleteUserAgentMapping removes a custom User-Agent mapping by ID.
+func (s *RDBLogStore) DeleteUserAgentMapping(ctx context.Context, id string) error {
+ result := s.db.WithContext(ctx).Delete(&UserAgentMapping{}, "id = ?", id)
+ if result.Error != nil {
+ return fmt.Errorf("failed to delete user agent mapping: %w", result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return gorm.ErrRecordNotFound
+ }
+ return nil
+}
+
+// ListUserAgentMappings returns custom User-Agent mappings ordered by creation time.
+func (s *RDBLogStore) ListUserAgentMappings(ctx context.Context, activeOnly bool) ([]UserAgentMapping, error) {
+ var mappings []UserAgentMapping
+ q := s.db.WithContext(ctx).Model(&UserAgentMapping{})
+ if activeOnly {
+ q = q.Where("is_active = ?", true)
+ }
+ if err := q.Order("created_at ASC").Find(&mappings).Error; err != nil {
+ return nil, fmt.Errorf("failed to list user agent mappings: %w", err)
+ }
+ return mappings, nil
+}
+
// metadataSystemKeys are metadata keys added by the system that should be excluded from filter data.
var metadataSystemKeys = map[string]struct{}{
"isAsyncRequest": {},
@@ -4140,6 +4242,12 @@ func (s *RDBLogStore) applyMCPFilters(baseQuery *gorm.DB, filters MCPToolLogSear
if len(filters.LLMRequestIDs) > 0 {
baseQuery = baseQuery.Where("llm_request_id IN ?", filters.LLMRequestIDs)
}
+ if len(filters.UserAgents) > 0 {
+ baseQuery = baseQuery.Where("user_agent IN ?", filters.UserAgents)
+ }
+ if len(filters.Apps) > 0 {
+ baseQuery = baseQuery.Where("app IN ?", filters.Apps)
+ }
if filters.StartTime != nil {
baseQuery = baseQuery.Where("timestamp >= ?", *filters.StartTime)
}
@@ -4438,6 +4546,39 @@ func (s *RDBLogStore) GetAvailableServerLabels(ctx context.Context, limit int, q
return serverLabels, nil
}
+// GetAvailableMCPUserAgents returns unique non-empty user_agent values from MCP tool logs.
+// The UI maps each raw User-Agent to a client app for the MCP logs "App" filter.
+func (s *RDBLogStore) GetAvailableMCPUserAgents(ctx context.Context, limit int, query string) ([]string, error) {
+ cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays)
+ var userAgents []string
+ q := s.ScopedDB(ctx).Model(&MCPToolLog{}).
+ Where("user_agent IS NOT NULL AND user_agent != '' AND timestamp >= ?", cutoff)
+ if query != "" {
+ q = s.applyLikeFilter(q, "user_agent", query)
+ }
+ result := q.Distinct("user_agent").Order("user_agent ASC").Limit(limit).Pluck("user_agent", &userAgents)
+ if result.Error != nil {
+ return nil, fmt.Errorf("failed to get available MCP user agents: %w", result.Error)
+ }
+ return userAgents, nil
+}
+
+// GetAvailableMCPApps returns unique non-empty backend-detected app labels from MCP tool logs.
+func (s *RDBLogStore) GetAvailableMCPApps(ctx context.Context, limit int, query string) ([]string, error) {
+ cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays)
+ var apps []string
+ q := s.ScopedDB(ctx).Model(&MCPToolLog{}).
+ Where("app IS NOT NULL AND app != '' AND timestamp >= ?", cutoff)
+ if query != "" {
+ q = s.applyLikeFilter(q, "app", query)
+ }
+ result := q.Distinct("app").Order("app ASC").Limit(limit).Pluck("app", &apps)
+ if result.Error != nil {
+ return nil, fmt.Errorf("failed to get available MCP apps: %w", result.Error)
+ }
+ return apps, nil
+}
+
func (s *RDBLogStore) GetAvailableMCPVirtualKeys(ctx context.Context, limit int, query string) ([]MCPToolLog, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays)
var logs []MCPToolLog
diff --git a/framework/logstore/rdb_perf_test.go b/framework/logstore/rdb_perf_test.go
index c54ea67ab1c..58bc8fcecec 100644
--- a/framework/logstore/rdb_perf_test.go
+++ b/framework/logstore/rdb_perf_test.go
@@ -474,6 +474,7 @@ func TestMCPToolLogCreateSerializesFields(t *testing.T) {
ResultParsed: map[string]any{
"ok": true,
},
+ RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`,
}
if err := store.CreateMCPToolLog(context.Background(), entry); err != nil {
@@ -490,6 +491,9 @@ func TestMCPToolLogCreateSerializesFields(t *testing.T) {
if logEntry.Result == "" {
t.Fatalf("expected Result to be serialized")
}
+ if logEntry.RedactionMapping != entry.RedactionMapping {
+ t.Fatalf("RedactionMapping = %q, want %q", logEntry.RedactionMapping, entry.RedactionMapping)
+ }
}
func TestBuildBulkUpdateCostPostgresSQL(t *testing.T) {
@@ -574,7 +578,8 @@ func TestUpdateMCPToolLogSerializesStructEntry(t *testing.T) {
}
if err := store.UpdateMCPToolLog(context.Background(), entry.ID, MCPToolLog{
- Status: "success",
+ Status: "success",
+ RedactionMapping: `plain:{"output":{"EMAIL-2":"result@example.com"}}`,
ResultParsed: map[string]any{
"message": "done",
},
@@ -589,6 +594,9 @@ func TestUpdateMCPToolLogSerializesStructEntry(t *testing.T) {
if logEntry.Result == "" {
t.Fatalf("expected Result to be serialized on UpdateMCPToolLog")
}
+ if logEntry.RedactionMapping == "" {
+ t.Fatal("expected RedactionMapping to be updated")
+ }
}
func TestBulkUpdateCostSQLiteFallback(t *testing.T) {
diff --git a/framework/logstore/store.go b/framework/logstore/store.go
index d19cf858ace..d1e98c5de63 100644
--- a/framework/logstore/store.go
+++ b/framework/logstore/store.go
@@ -102,12 +102,21 @@ type LogStore interface {
DeleteLogs(ctx context.Context, ids []string) error
DeleteLogsBatch(ctx context.Context, cutoff time.Time, batchSize int) (deletedCount int64, err error)
+ CreateUserAgentMapping(ctx context.Context, mapping *UserAgentMapping) error
+ UpdateUserAgentMapping(ctx context.Context, id string, mapping *UserAgentMapping) error
+ DeleteUserAgentMapping(ctx context.Context, id string) error
+ ListUserAgentMappings(ctx context.Context, activeOnly bool) ([]UserAgentMapping, error)
+
// Distinct value methods for filter data
GetDistinctModels(ctx context.Context, limit int, query string) ([]string, error)
GetDistinctAliases(ctx context.Context, limit int, query string) ([]string, error)
GetDistinctKeyPairs(ctx context.Context, idCol, nameCol string, limit int, query string) ([]KeyPairResult, error)
GetDistinctRoutingEngines(ctx context.Context, limit int, query string) ([]string, error)
GetDistinctStopReasons(ctx context.Context, limit int, query string) ([]string, error)
+ // GetDistinctUserAgents returns distinct raw User-Agent strings from logs for the "App" filter.
+ GetDistinctUserAgents(ctx context.Context, limit int, query string) ([]string, error)
+ // GetDistinctApps returns distinct backend-detected app labels from logs.
+ GetDistinctApps(ctx context.Context, limit int, query string) ([]string, error)
GetDistinctMetadataKeys(ctx context.Context, limit int, query string) (map[string][]string, error)
// MCP Tool Log histogram methods
@@ -127,6 +136,10 @@ type LogStore interface {
FlushMCPToolLogs(ctx context.Context, since time.Time) error
GetAvailableToolNames(ctx context.Context, limit int, query string) ([]string, error)
GetAvailableServerLabels(ctx context.Context, limit int, query string) ([]string, error)
+ // GetAvailableMCPUserAgents returns distinct raw User-Agent strings from MCP tool logs for the "App" filter.
+ GetAvailableMCPUserAgents(ctx context.Context, limit int, query string) ([]string, error)
+ // GetAvailableMCPApps returns distinct backend-detected app labels from MCP tool logs.
+ GetAvailableMCPApps(ctx context.Context, limit int, query string) ([]string, error)
GetAvailableMCPVirtualKeys(ctx context.Context, limit int, query string) ([]MCPToolLog, error)
// Async Job methods
diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go
index e0733183344..b872164c791 100644
--- a/framework/logstore/tables.go
+++ b/framework/logstore/tables.go
@@ -2,6 +2,7 @@ package logstore
import (
"database/sql/driver"
+ "errors"
"strings"
"time"
@@ -60,6 +61,8 @@ type SearchFilters struct {
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)
+ Apps []string `json:"apps,omitempty"` // Backend-detected client apps
+ UserAgents []string `json:"user_agents,omitempty"` // Raw User-Agent strings; kept for compatibility/debug filtering
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
MinLatency *float64 `json:"min_latency,omitempty"`
@@ -143,6 +146,28 @@ type SearchStats struct {
SemanticCacheHits *int64 `json:"semantic_cache_hits,omitempty"` // Number of semantic (fuzzy) cache hits
}
+// UserAgentMapping stores a custom rule for mapping User-Agent values to app labels.
+type UserAgentMapping struct {
+ ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
+ Pattern string `gorm:"type:varchar(512);not null" json:"pattern"`
+ MatchType string `gorm:"type:varchar(32);not null;index" json:"match_type"`
+ App string `gorm:"type:varchar(128);not null;index" json:"app"`
+ Logo []byte `gorm:"type:bytea" json:"logo,omitempty"`
+ LogoMime *string `gorm:"type:varchar(128)" json:"logo_mime,omitempty"`
+ IsActive bool `gorm:"index" json:"is_active"`
+ CreatedAt time.Time `gorm:"index;not null" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
+}
+
+// BeforeCreate enforces a non-empty primary key before insert, guarding against
+// callers that bypass the plugin layer's UUID assignment.
+func (u *UserAgentMapping) BeforeCreate(tx *gorm.DB) error {
+ if strings.TrimSpace(u.ID) == "" {
+ return errors.New("id is required")
+ }
+ return nil
+}
+
// Log represents a complete log entry for a request/response cycle
// This is the GORM model with appropriate tags
type Log struct {
@@ -184,33 +209,35 @@ type Log struct {
CustomerNames *string `gorm:"type:text" json:"-"`
BusinessUnitIDs *string `gorm:"type:text" json:"-"`
BusinessUnitNames *string `gorm:"type:text" json:"-"`
- 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
- ResponsesOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ResponsesMessage
- EmbeddingOutput string `gorm:"type:text" json:"-"` // JSON serialized [][]float32
- RerankOutput string `gorm:"type:text" json:"-"` // JSON serialized []schemas.RerankResult
- OCROutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostOCRResponse
- Params string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ModelParameters
- Tools string `gorm:"type:text" json:"-"` // JSON serialized []schemas.Tool
- ToolCalls string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ToolCall (For backward compatibility, tool calls are now in the content)
- SpeechInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.SpeechInput
- TranscriptionInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.TranscriptionInput
- OCRInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.OCRDocument
- ImageGenerationInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ImageGenerationInput
- ImageEditInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ImageEditInput
- ImageVariationInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ImageVariationInput
- VideoGenerationInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.VideoGenerationInput
- SpeechOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostSpeech
- TranscriptionOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostTranscribe
- ImageGenerationOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostImageGenerationResponse
- ListModelsOutput string `gorm:"type:text" json:"-"` // JSON serialized []schemas.Model
- VideoGenerationOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoGenerationResponse
- VideoRetrieveOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoRetrieveResponse
- VideoDownloadOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoDownloadResponse
- VideoListOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoListResponse
- VideoDeleteOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoDeleteResponse
- CacheDebug string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostCacheDebug
+ UserAgent *string `gorm:"type:varchar(512);index:idx_logs_user_agent" json:"user_agent,omitempty"` // Raw HTTP User-Agent of the calling client
+ App *string `gorm:"type:varchar(128);index:idx_logs_app" json:"app,omitempty"` // Backend-detected client app derived from user_agent
+ 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
+ ResponsesOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ResponsesMessage
+ EmbeddingOutput string `gorm:"type:text" json:"-"` // JSON serialized [][]float32
+ RerankOutput string `gorm:"type:text" json:"-"` // JSON serialized []schemas.RerankResult
+ OCROutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostOCRResponse
+ Params string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ModelParameters
+ Tools string `gorm:"type:text" json:"-"` // JSON serialized []schemas.Tool
+ ToolCalls string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ToolCall (For backward compatibility, tool calls are now in the content)
+ SpeechInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.SpeechInput
+ TranscriptionInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.TranscriptionInput
+ OCRInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.OCRDocument
+ ImageGenerationInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ImageGenerationInput
+ ImageEditInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ImageEditInput
+ ImageVariationInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ImageVariationInput
+ VideoGenerationInput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.VideoGenerationInput
+ SpeechOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostSpeech
+ TranscriptionOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostTranscribe
+ ImageGenerationOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostImageGenerationResponse
+ ListModelsOutput string `gorm:"type:text" json:"-"` // JSON serialized []schemas.Model
+ VideoGenerationOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoGenerationResponse
+ VideoRetrieveOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoRetrieveResponse
+ VideoDownloadOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoDownloadResponse
+ VideoListOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoListResponse
+ VideoDeleteOutput string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostVideoDeleteResponse
+ CacheDebug string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostCacheDebug
Latency *float64 `gorm:"index:idx_logs_latency" json:"latency,omitempty"`
TokenUsage string `gorm:"type:text" json:"-"` // JSON serialized *schemas.LLMUsage
Cost *float64 `gorm:"index" json:"cost,omitempty"` // Cost in dollars (total cost of the request - includes cache lookup cost)
@@ -1042,16 +1069,32 @@ type MCPToolLog struct {
TeamID *string `gorm:"type:varchar(255);index:idx_mcp_logs_team_id" json:"team_id"`
CustomerID *string `gorm:"type:varchar(255);index:idx_mcp_logs_customer_id" json:"customer_id"`
BusinessUnitID *string `gorm:"type:varchar(255);index:idx_mcp_logs_business_unit_id" json:"business_unit_id"`
- Arguments string `gorm:"type:text" json:"-"` // JSON serialized tool arguments
- Result string `gorm:"type:text" json:"-"` // JSON serialized tool result
- ErrorDetails string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostError
- Latency *float64 `gorm:"index:idx_mcp_logs_latency" json:"latency,omitempty"` // Execution time in milliseconds
- Cost *float64 `gorm:"index:idx_mcp_logs_cost" json:"cost,omitempty"` // Cost in dollars (per execution cost)
- Status string `gorm:"type:varchar(50);index:idx_mcp_logs_status;not null" json:"status"` // "processing", "success", or "error"
- Metadata string `gorm:"type:text" json:"-"` // JSON serialized map[string]interface{}
- HasObject bool `gorm:"default:false" json:"-"` // True when payload is stored in object storage
+ UserAgent *string `gorm:"type:varchar(512);index:idx_mcp_logs_user_agent" json:"user_agent,omitempty"` // Raw HTTP User-Agent of the calling client
+ App *string `gorm:"type:varchar(128);index:idx_mcp_logs_app" json:"app,omitempty"` // Backend-detected client app derived from user_agent
+ Arguments string `gorm:"type:text" json:"-"` // JSON serialized tool arguments
+ Result string `gorm:"type:text" json:"-"` // JSON serialized tool result
+ ErrorDetails string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostError
+ Latency *float64 `gorm:"index:idx_mcp_logs_latency" json:"latency,omitempty"` // Execution time in milliseconds
+ Cost *float64 `gorm:"index:idx_mcp_logs_cost" json:"cost,omitempty"` // Cost in dollars (per execution cost)
+ Status string `gorm:"type:varchar(50);index:idx_mcp_logs_status;not null" json:"status"` // "processing", "success", or "error"
+ Metadata string `gorm:"type:text" json:"-"` // JSON serialized map[string]interface{}
+ PluginLogs string `gorm:"type:text" json:"plugin_logs,omitempty"` // JSON serialized plugin logs grouped by plugin name
+ HasObject bool `gorm:"default:false" json:"-"` // True when payload is stored in object storage
CreatedAt time.Time `gorm:"index;not null" json:"created_at"`
+ RedactionData *schemas.RedactionData `gorm:"-" json:"-"` // Transient guardrail redaction data consumed by enterprise logstore wrappers
+ RedactionMapping string `gorm:"type:text" json:"-"` // Reversible redaction mapping written by enterprise logstore wrappers; deleted with the row
+ RevealRedactionMapping *schemas.RedactionMapsByPhase `gorm:"-" json:"redaction_mapping,omitempty"` // Virtual field populated only on permitted MCP log-detail reads
+
+ // Endpoint-agent context. These are populated for tool calls observed on a
+ // developer machine by the Bifrost Edge agent (rather than proxied by the
+ // gateway). Source distinguishes the origin: empty/null for gateway-proxied
+ // calls, "endpoint" for agent-observed calls.
+ DeviceID *string `gorm:"type:varchar(255);index:idx_mcp_logs_device_id" json:"device_id,omitempty"`
+ AppKey *string `gorm:"type:varchar(64)" json:"app_key,omitempty"` // Canonical policy key of the detected client app (schemas.AppKeyFromName), e.g. "claude-code"; a slug like App, not a secret or credential
+ Decision *string `gorm:"type:varchar(16)" json:"decision,omitempty"`
+ Source *string `gorm:"type:varchar(16);index:idx_mcp_logs_source" json:"source,omitempty"`
+
// Virtual fields for JSON output - populated when needed
ArgumentsParsed interface{} `gorm:"-" json:"arguments,omitempty"`
ResultParsed interface{} `gorm:"-" json:"result,omitempty"`
@@ -1328,6 +1371,8 @@ type MCPToolLogSearchFilters struct {
Status []string `json:"status,omitempty"`
VirtualKeyIDs []string `json:"virtual_key_ids,omitempty"`
LLMRequestIDs []string `json:"llm_request_ids,omitempty"`
+ Apps []string `json:"apps,omitempty"` // Backend-detected client apps
+ UserAgents []string `json:"user_agents,omitempty"` // Raw User-Agent strings; kept for compatibility/debug filtering
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
MinLatency *float64 `json:"min_latency,omitempty"`
@@ -1684,6 +1729,8 @@ const (
DimensionCustomer HistogramDimension = "customer_id"
DimensionUser HistogramDimension = "user_id"
DimensionBusinessUnit HistogramDimension = "business_unit_id"
+ DimensionApp HistogramDimension = "app"
+ DimensionUserAgent HistogramDimension = "user_agent"
)
// ValidHistogramDimensions is the set of allowed dimension values
@@ -1693,6 +1740,8 @@ var ValidHistogramDimensions = map[HistogramDimension]bool{
DimensionCustomer: true,
DimensionUser: true,
DimensionBusinessUnit: true,
+ DimensionApp: true,
+ DimensionUserAgent: true,
}
// histogramDimensionColumn maps a validated dimension to its SQL column name.
@@ -1888,6 +1937,8 @@ const (
RankingDimensionBusinessUnit RankingDimension = "business_unit"
RankingDimensionUser RankingDimension = "user"
RankingDimensionVirtualKey RankingDimension = "virtual_key"
+ RankingDimensionApp RankingDimension = "app"
+ RankingDimensionUserAgent RankingDimension = "user_agent"
)
var ValidRankingDimensions = map[RankingDimension]bool{
@@ -1896,6 +1947,8 @@ var ValidRankingDimensions = map[RankingDimension]bool{
RankingDimensionBusinessUnit: true,
RankingDimensionUser: true,
RankingDimensionVirtualKey: true,
+ RankingDimensionApp: true,
+ RankingDimensionUserAgent: true,
}
type dimensionColumnDef struct {
@@ -1909,6 +1962,8 @@ var dimensionColumns = map[RankingDimension]dimensionColumnDef{
RankingDimensionBusinessUnit: {IDCol: "business_unit_id", NameCol: "business_unit_name"},
RankingDimensionUser: {IDCol: "user_id", NameCol: "user_name"},
RankingDimensionVirtualKey: {IDCol: "virtual_key_id", NameCol: "virtual_key_name"},
+ RankingDimensionApp: {IDCol: "app", NameCol: "app"},
+ RankingDimensionUserAgent: {IDCol: "user_agent", NameCol: "user_agent"},
}
func DimensionColumnDef(d RankingDimension) (idCol, nameCol string, ok bool) {
diff --git a/plugins/governance/main.go b/plugins/governance/main.go
index 245f79c9e2c..b4dbbd69353 100644
--- a/plugins/governance/main.go
+++ b/plugins/governance/main.go
@@ -944,7 +944,7 @@ func (p *GovernancePlugin) EvaluateGovernanceRequest(ctx *schemas.BifrostContext
}
}
p.cfgMutex.RLock()
- if !isVirtualKeyValid && evaluationRequest.UserID == "" && p.isVkMandatory != nil && *p.isVkMandatory {
+ if !isVirtualKeyValid && !hasDirectKeyAuth(ctx) && evaluationRequest.UserID == "" && p.isVkMandatory != nil && *p.isVkMandatory {
message := "virtual key is required. Provide a virtual key via the x-bf-vk header."
if p.isEnterprise {
message = "authentication is required. Provide a virtual key (x-bf-vk), API key, or user token."
@@ -1135,6 +1135,15 @@ func (p *GovernancePlugin) EvaluateGovernanceRequest(ctx *schemas.BifrostContext
}
}
+// hasDirectKeyAuth returns true when the transport accepted an admin-enabled direct provider key.
+func hasDirectKeyAuth(ctx *schemas.BifrostContext) bool {
+ if ctx == nil {
+ return false
+ }
+ _, ok := ctx.Value(schemas.BifrostContextKeyDirectKey).(schemas.Key)
+ return ok
+}
+
// isMCPToolAllowedByVK checks whether a tool pattern (in "clientName-toolName" or "clientName-*"
// format) is permitted by the virtual key's MCPConfigs.
//
@@ -1283,10 +1292,8 @@ func (p *GovernancePlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas.
if headerErr := p.validateRequiredHeaders(ctx); headerErr != nil {
return req, &schemas.LLMPluginShortCircuit{Error: headerErr}, nil
}
-
// Extract virtual key using utility functions
virtualKeyValue := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyVirtualKey)
-
// Extract user ID for enterprise user-level governance
userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyUserID)
// Getting provider and mode from the request
diff --git a/plugins/governance/resolver_test.go b/plugins/governance/resolver_test.go
index 95de31b8608..eb0f7e226b5 100644
--- a/plugins/governance/resolver_test.go
+++ b/plugins/governance/resolver_test.go
@@ -150,6 +150,75 @@ func TestBudgetResolver_EvaluateRequest_ModelBlocked(t *testing.T) {
assertDecision(t, DecisionModelBlocked, result)
}
+// TestGovernancePlugin_EvaluateGovernanceRequest_DirectKeySatisfiesMandatoryAuth verifies direct provider keys satisfy mandatory auth after transport validation.
+func TestGovernancePlugin_EvaluateGovernanceRequest_DirectKeySatisfiesMandatoryAuth(t *testing.T) {
+ logger := NewMockLogger()
+ store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil)
+ require.NoError(t, err)
+
+ mandatory := true
+ plugin := &GovernancePlugin{
+ store: store,
+ resolver: NewBudgetResolver(store, nil, logger, nil),
+ isVkMandatory: &mandatory,
+ isEnterprise: true,
+ }
+
+ ctx := &schemas.BifrostContext{}
+ ctx.SetValue(schemas.BifrostContextKeyDirectKey, schemas.Key{
+ ID: "header-provided",
+ Name: "header-provided",
+ Value: schemas.SecretVar{Val: "sk-real-openai-key"},
+ })
+
+ result, bifrostErr := plugin.EvaluateGovernanceRequest(ctx, &EvaluationRequest{
+ Provider: schemas.OpenAI,
+ Model: "gpt-4o",
+ }, schemas.PassthroughRequest)
+
+ require.Nil(t, bifrostErr)
+ assertDecision(t, DecisionAllow, result)
+}
+
+// TestGovernancePlugin_EvaluateGovernanceRequest_HeaderWithoutContextDoesNotSatisfyMandatoryAuth verifies callers cannot spoof direct-key auth with only a request header.
+func TestGovernancePlugin_EvaluateGovernanceRequest_HeaderWithoutContextDoesNotSatisfyMandatoryAuth(t *testing.T) {
+ logger := NewMockLogger()
+ store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil)
+ require.NoError(t, err)
+
+ mandatory := true
+ plugin := &GovernancePlugin{
+ store: store,
+ resolver: NewBudgetResolver(store, nil, logger, nil),
+ isVkMandatory: &mandatory,
+ isEnterprise: true,
+ }
+
+ _, bifrostErr := plugin.EvaluateGovernanceRequest(&schemas.BifrostContext{}, &EvaluationRequest{
+ Provider: schemas.OpenAI,
+ Model: "gpt-4o",
+ }, schemas.PassthroughRequest)
+
+ require.NotNil(t, bifrostErr)
+ require.NotNil(t, bifrostErr.StatusCode)
+ assert.Equal(t, 401, *bifrostErr.StatusCode)
+ assert.Equal(t, "authentication is required. Provide a virtual key (x-bf-vk), API key, or user token.", bifrostErr.Error.Message)
+}
+
+// TestHasDirectKeyAuth reads only the transport-owned direct-key context value.
+func TestHasDirectKeyAuth(t *testing.T) {
+ ctx := &schemas.BifrostContext{}
+ assert.False(t, hasDirectKeyAuth(ctx))
+
+ ctx.SetValue(schemas.BifrostContextKeyDirectKey, schemas.Key{
+ ID: "header-provided",
+ Name: "header-provided",
+ Value: schemas.SecretVar{Val: "sk-real-openai-key"},
+ })
+
+ assert.True(t, hasDirectKeyAuth(ctx))
+}
+
// TestBudgetResolver_EvaluateRequest_RateLimitExceeded_TokenLimit tests token limit
func TestBudgetResolver_EvaluateRequest_RateLimitExceeded_TokenLimit(t *testing.T) {
logger := NewMockLogger()
diff --git a/plugins/governance/utils.go b/plugins/governance/utils.go
index b845cafcfde..1fd2ff6868f 100644
--- a/plugins/governance/utils.go
+++ b/plugins/governance/utils.go
@@ -61,6 +61,43 @@ func IsModelRequiredForRequest(requestType schemas.RequestType) bool {
return true
}
+// parseVirtualKeyFromHTTPRequest parses the virtual key from HTTP request headers.
+// It checks multiple headers in order: x-bf-vk, Authorization (Bearer token), x-api-key, and x-goog-api-key.
+// Parameters:
+// - req: The HTTP request containing headers to parse
+//
+// Returns:
+// - *string: The virtual key if found, nil otherwise
+func parseVirtualKeyFromHTTPRequest(req *schemas.HTTPRequest) *string {
+ var virtualKeyValue string
+ vkHeader := req.CaseInsensitiveHeaderLookup("x-bf-vk")
+ if vkHeader != "" && strings.HasPrefix(strings.ToLower(vkHeader), VirtualKeyPrefix) {
+ return new(vkHeader)
+ }
+ authHeader := req.CaseInsensitiveHeaderLookup("Authorization")
+ if authHeader != "" {
+ if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
+ authHeaderValue := strings.TrimSpace(authHeader[7:]) // Remove "Bearer " prefix
+ if authHeaderValue != "" && strings.HasPrefix(strings.ToLower(authHeaderValue), VirtualKeyPrefix) {
+ virtualKeyValue = authHeaderValue
+ }
+ }
+ }
+ if virtualKeyValue != "" {
+ return new(virtualKeyValue)
+ }
+ xAPIKey := req.CaseInsensitiveHeaderLookup("x-api-key")
+ if xAPIKey != "" && strings.HasPrefix(strings.ToLower(xAPIKey), VirtualKeyPrefix) {
+ return new(xAPIKey)
+ }
+ // Checking x-goog-api-key header
+ xGoogleAPIKey := req.CaseInsensitiveHeaderLookup("x-goog-api-key")
+ if xGoogleAPIKey != "" && strings.HasPrefix(strings.ToLower(xGoogleAPIKey), VirtualKeyPrefix) {
+ return new(xGoogleAPIKey)
+ }
+ return nil
+}
+
// getWeight safely dereferences a *float64 weight pointer, returning 1.0 as default if nil.
// This allows distinguishing between "not set" (nil -> 1.0) and "explicitly set to 0" (0.0).
func getWeight(w *float64) float64 {
diff --git a/plugins/jsonparser/changelog.md b/plugins/jsonparser/changelog.md
index e69de29bb2d..8b137891791 100644
--- a/plugins/jsonparser/changelog.md
+++ b/plugins/jsonparser/changelog.md
@@ -0,0 +1 @@
+
diff --git a/plugins/logging/changelog.md b/plugins/logging/changelog.md
index e69de29bb2d..8b137891791 100644
--- a/plugins/logging/changelog.md
+++ b/plugins/logging/changelog.md
@@ -0,0 +1 @@
+
diff --git a/plugins/logging/go.mod b/plugins/logging/go.mod
index 340964eb87f..0420db04a0d 100644
--- a/plugins/logging/go.mod
+++ b/plugins/logging/go.mod
@@ -4,8 +4,9 @@ go 1.26.5
require (
github.com/bytedance/sonic v1.15.1
- github.com/maximhq/bifrost/core v1.7.5
- github.com/maximhq/bifrost/framework v1.5.5
+ github.com/google/uuid v1.6.0
+ github.com/maximhq/bifrost/core v1.7.4
+ github.com/maximhq/bifrost/framework v1.5.4
github.com/stretchr/testify v1.11.1
)
@@ -89,7 +90,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
diff --git a/plugins/logging/go.sum b/plugins/logging/go.sum
index 533ad87375d..568d2b9a7af 100644
--- a/plugins/logging/go.sum
+++ b/plugins/logging/go.sum
@@ -262,10 +262,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/maximhq/bifrost/core v1.7.5 h1:Fbjp78OW4yjN+ljU/81Yb8IhQ1FgkhaDZ05Dfax2JXw=
-github.com/maximhq/bifrost/core v1.7.5/go.mod h1:I5STUAFqPF9lzbDGfQYK2LIHt2x68JwxYKyqcjTiSkQ=
-github.com/maximhq/bifrost/framework v1.5.5 h1:dKz2+CxErEXKqHMibnqFHy0u0GFGsNh1GPuhDih906w=
-github.com/maximhq/bifrost/framework v1.5.5/go.mod h1:c0WF/jN49DqoUEFT0sUSDDq2iQmc0M49zCFQr25yG9o=
+github.com/maximhq/bifrost/core v1.7.4 h1:9qWrGZbUlKYkOQtyBvGfeaTEDWBb+2Jd/n8sf0uH2Xk=
+github.com/maximhq/bifrost/core v1.7.4/go.mod h1:jjdqJc0+fCNl3irgUGfSDzgZupMSRLNm4E/2Q7KZKks=
+github.com/maximhq/bifrost/framework v1.5.4 h1:eM8hWyvwqZcF3X+yNdNkl9FHdTg5qqou8MxNYZylTrg=
+github.com/maximhq/bifrost/framework v1.5.4/go.mod h1:Uwafv1Voa68nddhUPua8NIidgI828D8eLYhJ2v+YnRs=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
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=
diff --git a/plugins/logging/main.go b/plugins/logging/main.go
index d59c396ba99..982c64be6b0 100644
--- a/plugins/logging/main.go
+++ b/plugins/logging/main.go
@@ -6,6 +6,7 @@ package logging
import (
"context"
"fmt"
+ "regexp"
"strings"
"sync"
"sync/atomic"
@@ -107,14 +108,35 @@ func applyLargePayloadPreviewsToEntry(ctx *schemas.BifrostContext, entry *logsto
}
}
-// attachLogRedactionData copies guardrail redaction data into the log entry for async writers.
-func attachLogRedactionData(ctx *schemas.BifrostContext, entry *logstore.Log, contentLoggingEnabled bool) {
- if ctx == nil || entry == nil || !contentLoggingEnabled {
- return
+// redactionDataForLogging returns an owned request snapshot for asynchronous log writers.
+func redactionDataForLogging(ctx *schemas.BifrostContext, contentLoggingEnabled bool) *schemas.RedactionData {
+ if ctx == nil || !contentLoggingEnabled {
+ return nil
}
if data, ok := schemas.RedactionDataFromContext(ctx); ok {
snapshot := data.Clone()
- entry.RedactionData = &snapshot
+ return &snapshot
+ }
+ return nil
+}
+
+// attachLogRedactionData copies guardrail redaction data into an LLM log entry.
+func attachLogRedactionData(ctx *schemas.BifrostContext, entry *logstore.Log, contentLoggingEnabled bool) {
+ if entry == nil {
+ return
+ }
+ if snapshot := redactionDataForLogging(ctx, contentLoggingEnabled); snapshot != nil {
+ entry.RedactionData = snapshot
+ }
+}
+
+// attachMCPLogRedactionData copies guardrail redaction data into an MCP tool log entry.
+func attachMCPLogRedactionData(ctx *schemas.BifrostContext, entry *logstore.MCPToolLog, contentLoggingEnabled bool) {
+ if entry == nil {
+ return
+ }
+ if snapshot := redactionDataForLogging(ctx, contentLoggingEnabled); snapshot != nil {
+ entry.RedactionData = snapshot
}
}
@@ -439,6 +461,8 @@ type InitialLogData struct {
RoutingEngineUsed []string
Metadata map[string]any
PassthroughRequestBody string // Raw body for passthrough requests (UTF-8)
+ UserAgent string // Raw HTTP User-Agent of the calling client; mapped to a client app in the UI
+ App string // Backend-detected client app derived from UserAgent
}
// LogCallback is a function that gets called when a new log entry is created
@@ -447,6 +471,7 @@ type LogCallback func(ctx context.Context, logEntry *logstore.Log)
// MCPToolLogCallback is a function that gets called when a new MCP tool log entry is created or updated
type MCPToolLogCallback func(*logstore.MCPToolLog)
+// Config controls logging plugin behavior.
type Config struct {
DisableContentLogging *bool `json:"disable_content_logging"`
RetainContentInObjectStorage *bool `json:"retain_content_in_object_storage"` // Pointer to live config value; when true, content-disabled requests are offloaded to object storage as hidden instead of dropped
@@ -481,6 +506,13 @@ func validateWriterConfig(config logstore.WriterConfig) error {
return nil
}
+type compiledUserAgentMapping struct {
+ Pattern string
+ MatchType schemas.UserAgentMappingMatchType
+ App string
+ Regex *regexp.Regexp
+}
+
// LoggerPlugin implements the schemas.LLMPlugin and schemas.MCPPlugin interfaces
type LoggerPlugin struct {
ctx context.Context
@@ -517,6 +549,8 @@ type LoggerPlugin struct {
batchCancel context.CancelFunc // Cancels batchCtx
batchWriterDone chan struct{} // Closed by batchWriter on exit; receiving from it transfers writeQueue ownership to Cleanup
recoveredBatch []*writeQueueEntry // batchWriter parks its in-memory batch here before exiting; safe to read after batchWriterDone closes (happens-before)
+ userAgentMappings atomic.Value // []compiledUserAgentMapping, read from request hot paths
+ userAgentMappingMu sync.Mutex // serializes user-agent mapping write+reload sequences to keep the cache consistent
}
// Init creates new logger plugin with given log store
@@ -583,6 +617,11 @@ func Init(ctx context.Context, config *Config, logger schemas.Logger, logsStore
plugin.updateDataPool.Put(&UpdateLogData{})
}
+ if err := plugin.ReloadUserAgentMappings(ctx); err != nil {
+ logger.Warn("failed to load user agent mappings: %v", err)
+ plugin.userAgentMappings.Store([]compiledUserAgentMapping{})
+ }
+
// Start cleanup ticker (runs every 1 minute)
plugin.cleanupTicker = time.NewTicker(1 * time.Minute)
plugin.wg.Add(1)
@@ -595,6 +634,57 @@ func Init(ctx context.Context, config *Config, logger schemas.Logger, logsStore
return plugin, nil
}
+// ReloadUserAgentMappings refreshes the in-memory custom User-Agent mapping cache.
+func (p *LoggerPlugin) ReloadUserAgentMappings(ctx context.Context) error {
+ mappings, err := p.store.ListUserAgentMappings(ctx, true)
+ if err != nil {
+ return err
+ }
+ compiled := make([]compiledUserAgentMapping, 0, len(mappings))
+ for _, mapping := range mappings {
+ matchType := schemas.UserAgentMappingMatchType(mapping.MatchType)
+ entry := compiledUserAgentMapping{
+ Pattern: mapping.Pattern,
+ MatchType: matchType,
+ App: mapping.App,
+ }
+ if matchType == schemas.UserAgentMappingMatchTypeRegex {
+ re, err := regexp.Compile(mapping.Pattern)
+ if err != nil {
+ p.logger.Warn("skipping invalid user agent mapping regex %q: %v", mapping.Pattern, err)
+ continue
+ }
+ entry.Regex = re
+ }
+ compiled = append(compiled, entry)
+ }
+ p.userAgentMappings.Store(compiled)
+ return nil
+}
+
+func (p *LoggerPlugin) detectAppFromUserAgent(userAgent string) string {
+ if strings.TrimSpace(userAgent) == "" {
+ return ""
+ }
+ if mappings, ok := p.userAgentMappings.Load().([]compiledUserAgentMapping); ok {
+ for _, mapping := range mappings {
+ if mapping.App == "" || mapping.Pattern == "" {
+ continue
+ }
+ if mapping.Regex != nil {
+ if mapping.Regex.MatchString(userAgent) {
+ return mapping.App
+ }
+ continue
+ }
+ if schemas.MatchUserAgent(userAgent, mapping.Pattern, mapping.MatchType) {
+ return mapping.App
+ }
+ }
+ }
+ return schemas.DetectAppFromUserAgent(userAgent)
+}
+
// SetClusterNodeID sets the cluster node ID that will be attached to all log entries.
// Used in clustered deployments to attribute log entries to specific nodes for
// disconnected node usage recovery. Uses atomic.Value since it is written at
@@ -663,6 +753,26 @@ func (p *LoggerPlugin) HTTPTransportStreamChunkHook(ctx *schemas.BifrostContext,
return chunk, nil
}
+// userAgentFromContext returns the raw HTTP User-Agent of the calling client from
+// the request header map, or "" when absent. Keys in the map are lowercased, so
+// the lookup is case-insensitive. The value is stored verbatim on the log entry;
+// mapping it to a client app happens in the UI.
+func userAgentFromContext(ctx *schemas.BifrostContext) string {
+ allHeaders, _ := ctx.Value(schemas.BifrostContextKeyRequestHeaders).(map[string]string)
+ if allHeaders != nil {
+ if ua := allHeaders["user-agent"]; ua != "" {
+ return ua
+ }
+ for key, value := range allHeaders {
+ if strings.EqualFold(key, "user-agent") && value != "" {
+ return value
+ }
+ }
+ }
+ ua, _ := ctx.Value(schemas.BifrostContextKeyUserAgent).(string)
+ return ua
+}
+
// captureLoggingHeaders extracts configured logging headers and x-bf-lh-* prefixed headers
// from the request context. Returns a new metadata map, or nil if no headers were captured.
// System entries (e.g. isAsyncRequest) should be set AFTER calling this so they take precedence.
@@ -766,6 +876,14 @@ func (p *LoggerPlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas.Bifr
initialData.Object = "realtime.turn"
}
+ // Capture the raw User-Agent of the calling client (stored verbatim; the UI
+ // maps it to a client app such as Claude Code, Codex, or Cursor).
+ initialData.UserAgent = userAgentFromContext(ctx)
+ initialData.App = p.detectAppFromUserAgent(initialData.UserAgent)
+ if appKey := schemas.AppKeyFromName(initialData.App); appKey != "" {
+ ctx.SetValue(schemas.BifrostContextKeyApp, appKey)
+ }
+
if p.contentLoggingEnabled(ctx) {
inputHistory, responsesInputHistory := p.extractInputHistory(req)
initialData.InputHistory = inputHistory
@@ -1048,6 +1166,12 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas.
Timestamp: time.Now().UTC(),
CreatedAt: time.Now().UTC(),
}
+ if ua := userAgentFromContext(ctx); ua != "" {
+ entry.UserAgent = &ua
+ if app := p.detectAppFromUserAgent(ua); app != "" {
+ entry.App = &app
+ }
+ }
entry.MetadataParsed = mergeRealtimeMetadata(p.captureLoggingHeaders(ctx), ctx)
if isAsync, ok := ctx.Value(schemas.BifrostIsAsyncRequest).(bool); ok && isAsync {
if entry.MetadataParsed == nil {
@@ -1505,13 +1629,7 @@ func (p *LoggerPlugin) Inject(_ context.Context, trace *schemas.Trace) error {
return nil
}
// Serialize plugin logs once for all entries
- var pluginLogsJSON string
- if len(trace.PluginLogs) > 0 {
- grouped := schemas.GroupPluginLogsByName(trace.PluginLogs)
- if data, err := sonic.Marshal(grouped); err == nil {
- pluginLogsJSON = string(data)
- }
- }
+ pluginLogsJSON := serializePluginLogs(trace.PluginLogs)
p.logger.Debug("Inject: enqueuing %d log entries", len(pending.entries))
// Enqueue each log entry (supports multiple attempts per trace)
for _, entry := range pending.entries {
@@ -1522,6 +1640,18 @@ func (p *LoggerPlugin) Inject(_ context.Context, trace *schemas.Trace) error {
return nil
}
+// serializePluginLogs groups plugin logs by plugin name for persistence and UI rendering.
+func serializePluginLogs(logs []schemas.PluginLogEntry) string {
+ if len(logs) == 0 {
+ return ""
+ }
+ data, err := sonic.Marshal(schemas.GroupPluginLogsByName(logs))
+ if err != nil {
+ return ""
+ }
+ return string(data)
+}
+
// MCP Plugin Interface Implementation
// SetMCPToolLogCallback sets a callback function that will be called for each MCP tool log entry
@@ -1626,9 +1756,17 @@ func (p *LoggerPlugin) PreMCPHook(ctx *schemas.BifrostContext, req *schemas.Bifr
}
applyMCPGovernanceFieldsToEntry(ctx, entry)
- // Set arguments if content logging is enabled. MCP tool logs have no
- // hidden-content mode, so content is only stored when it is also visible.
- if p.resolveContentPolicy(ctx).visible() {
+ // Capture the raw User-Agent of the calling client (stored verbatim; the UI
+ // maps it to a client app such as Claude Code, Codex, or Cursor).
+ if ua := userAgentFromContext(ctx); ua != "" {
+ entry.UserAgent = &ua
+ if app := p.detectAppFromUserAgent(ua); app != "" {
+ entry.App = &app
+ }
+ }
+
+ // Set arguments if content logging is enabled
+ if p.contentLoggingEnabled(ctx) {
entry.ArgumentsParsed = arguments
}
@@ -1777,6 +1915,8 @@ func (p *LoggerPlugin) PostMCPHook(ctx *schemas.BifrostContext, resp *schemas.Bi
p.mu.Lock()
callback := p.mcpToolLogCallback
p.mu.Unlock()
+ attachMCPLogRedactionData(ctx, entry, p.contentLoggingEnabled(ctx))
+ entry.PluginLogs = serializePluginLogs(ctx.GetPluginLogs())
p.enqueueMCPToolLogEntry(entry, callback)
return resp, bifrostErr, nil
diff --git a/plugins/logging/operations.go b/plugins/logging/operations.go
index 4fb9129fbdd..ad665a0a5b4 100644
--- a/plugins/logging/operations.go
+++ b/plugins/logging/operations.go
@@ -5,10 +5,12 @@ import (
"context"
"errors"
"fmt"
+ "regexp"
"strings"
"time"
"github.com/bytedance/sonic"
+ "github.com/google/uuid"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/logstore"
"github.com/maximhq/bifrost/framework/modelcatalog"
@@ -102,6 +104,15 @@ func (p *LoggerPlugin) insertInitialLogEntry(
if parentRequestID != "" {
entry.ParentRequestID = &parentRequestID
}
+ if data.UserAgent != "" {
+ entry.UserAgent = new(clampString(data.UserAgent, maxPersistedUserAgentLen))
+ if data.App == "" {
+ data.App = p.detectAppFromUserAgent(data.UserAgent)
+ }
+ }
+ if data.App != "" {
+ entry.App = new(clampString(data.App, maxPersistedAppLen))
+ }
return p.store.CreateIfNotExists(ctx, entry)
}
@@ -1280,6 +1291,118 @@ func (p *LoggerPlugin) GetAvailableStopReasons(ctx context.Context, limit int, q
return stopReasons, nil
}
+// GetAvailableUserAgents returns all unique raw User-Agent strings from logs.
+// The UI maps each to a client app. Uses DISTINCT to avoid loading all rows.
+func (p *LoggerPlugin) GetAvailableUserAgents(ctx context.Context, limit int, query string) ([]string, error) {
+ userAgents, err := p.store.GetDistinctUserAgents(ctx, limit, query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get available user agents: %w", err)
+ }
+ return userAgents, nil
+}
+
+// GetAvailableApps returns all unique backend-detected app labels from logs.
+func (p *LoggerPlugin) GetAvailableApps(ctx context.Context, limit int, query string) ([]string, error) {
+ apps, err := p.store.GetDistinctApps(ctx, limit, query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get available apps: %w", err)
+ }
+ return apps, nil
+}
+
+// ErrInvalidUserAgentMapping marks client-fault validation failures so callers
+// (e.g. HTTP handlers) can distinguish them from internal/store errors and map
+// them to a 400 rather than a 500.
+var ErrInvalidUserAgentMapping = errors.New("invalid user agent mapping")
+
+func validateUserAgentMapping(mapping *logstore.UserAgentMapping) error {
+ mapping.Pattern = strings.TrimSpace(mapping.Pattern)
+ mapping.App = strings.TrimSpace(mapping.App)
+ mapping.MatchType = strings.TrimSpace(mapping.MatchType)
+ if mapping.Pattern == "" {
+ return fmt.Errorf("%w: pattern cannot be empty", ErrInvalidUserAgentMapping)
+ }
+ if mapping.App == "" {
+ return fmt.Errorf("%w: app cannot be empty", ErrInvalidUserAgentMapping)
+ }
+ switch schemas.UserAgentMappingMatchType(mapping.MatchType) {
+ case schemas.UserAgentMappingMatchTypeContains,
+ schemas.UserAgentMappingMatchTypeStartsWith,
+ schemas.UserAgentMappingMatchTypeExact:
+ case schemas.UserAgentMappingMatchTypeRegex:
+ if _, err := regexp.Compile(mapping.Pattern); err != nil {
+ return fmt.Errorf("%w: invalid regex pattern: %v", ErrInvalidUserAgentMapping, err)
+ }
+ default:
+ return fmt.Errorf("%w: unsupported match_type %q", ErrInvalidUserAgentMapping, mapping.MatchType)
+ }
+ return nil
+}
+
+// ListUserAgentMappings returns all custom User-Agent mappings.
+func (p *LoggerPlugin) ListUserAgentMappings(ctx context.Context) ([]logstore.UserAgentMapping, error) {
+ return p.store.ListUserAgentMappings(ctx, false)
+}
+
+// CreateUserAgentMapping validates, stores, and activates a custom User-Agent mapping.
+func (p *LoggerPlugin) CreateUserAgentMapping(ctx context.Context, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) {
+ if err := validateUserAgentMapping(mapping); err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ mapping.ID = uuid.NewString()
+ mapping.CreatedAt = now
+ mapping.UpdatedAt = now
+ p.userAgentMappingMu.Lock()
+ defer p.userAgentMappingMu.Unlock()
+ if err := p.store.CreateUserAgentMapping(ctx, mapping); err != nil {
+ return nil, err
+ }
+ // The write is committed; reload with a cancel-immune context and never fail the
+ // operation on reload error, otherwise the client may retry and create a duplicate.
+ if err := p.ReloadUserAgentMappings(context.WithoutCancel(ctx)); err != nil {
+ p.logger.Warn("user-agent mapping created but cache reload failed: %v", err)
+ }
+ return mapping, nil
+}
+
+// UpdateUserAgentMapping validates, stores, and activates changes to a custom User-Agent mapping.
+func (p *LoggerPlugin) UpdateUserAgentMapping(ctx context.Context, id string, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) {
+ if strings.TrimSpace(id) == "" {
+ return nil, fmt.Errorf("%w: id cannot be empty", ErrInvalidUserAgentMapping)
+ }
+ if err := validateUserAgentMapping(mapping); err != nil {
+ return nil, err
+ }
+ mapping.UpdatedAt = time.Now().UTC()
+ p.userAgentMappingMu.Lock()
+ defer p.userAgentMappingMu.Unlock()
+ if err := p.store.UpdateUserAgentMapping(ctx, id, mapping); err != nil {
+ return nil, err
+ }
+ if err := p.ReloadUserAgentMappings(context.WithoutCancel(ctx)); err != nil {
+ p.logger.Warn("user-agent mapping updated but cache reload failed: %v", err)
+ }
+ mapping.ID = id
+ return mapping, nil
+}
+
+// DeleteUserAgentMapping removes a custom User-Agent mapping and refreshes the matcher cache.
+func (p *LoggerPlugin) DeleteUserAgentMapping(ctx context.Context, id string) error {
+ if strings.TrimSpace(id) == "" {
+ return fmt.Errorf("%w: id cannot be empty", ErrInvalidUserAgentMapping)
+ }
+ p.userAgentMappingMu.Lock()
+ defer p.userAgentMappingMu.Unlock()
+ if err := p.store.DeleteUserAgentMapping(ctx, id); err != nil {
+ return err
+ }
+ if err := p.ReloadUserAgentMappings(context.WithoutCancel(ctx)); err != nil {
+ p.logger.Warn("user-agent mapping deleted but cache reload failed: %v", err)
+ }
+ return nil
+}
+
// keyPairResultsToKeyPairs converts logstore.KeyPairResult slice to KeyPair slice
func keyPairResultsToKeyPairs(results []logstore.KeyPairResult) []KeyPair {
pairs := make([]KeyPair, len(results))
diff --git a/plugins/logging/operations_test.go b/plugins/logging/operations_test.go
index 24a950eed21..30c97900ef4 100644
--- a/plugins/logging/operations_test.go
+++ b/plugins/logging/operations_test.go
@@ -2,6 +2,7 @@ package logging
import (
"context"
+ "encoding/json"
"errors"
"path/filepath"
"strings"
@@ -44,6 +45,213 @@ func newTestStore(t *testing.T) logstore.LogStore {
return store
}
+func TestUserAgentFromContextUsesRequestHeaders(t *testing.T) {
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyRequestHeaders, map[string]string{
+ "user-agent": "codex-tui/1.0",
+ })
+ ctx.SetValue(schemas.BifrostContextKeyUserAgent, "fallback/1.0")
+
+ if got := userAgentFromContext(ctx); got != "codex-tui/1.0" {
+ t.Fatalf("expected request-header user agent, got %q", got)
+ }
+}
+
+func TestUserAgentFromContextUsesCanonicalRequestHeader(t *testing.T) {
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyRequestHeaders, map[string]string{
+ "User-Agent": "Claude-Code/1.0",
+ })
+
+ if got := userAgentFromContext(ctx); got != "Claude-Code/1.0" {
+ t.Fatalf("expected canonical request-header user agent, got %q", got)
+ }
+}
+
+func TestUserAgentFromContextUsesUppercaseRequestHeader(t *testing.T) {
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyRequestHeaders, map[string]string{
+ "USER-AGENT": "Cursor/0.47",
+ })
+
+ if got := userAgentFromContext(ctx); got != "Cursor/0.47" {
+ t.Fatalf("expected uppercase request-header user agent, got %q", got)
+ }
+}
+
+func TestUserAgentFromContextFallsBackWhenHeaderValueIsEmpty(t *testing.T) {
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyRequestHeaders, map[string]string{
+ "user-agent": "",
+ })
+ ctx.SetValue(schemas.BifrostContextKeyUserAgent, "fallback/1.0")
+
+ if got := userAgentFromContext(ctx); got != "fallback/1.0" {
+ t.Fatalf("expected fallback user agent for empty header, got %q", got)
+ }
+}
+
+func TestUserAgentFromContextFallsBackToUserAgentKey(t *testing.T) {
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyUserAgent, "cursor/0.47")
+
+ if got := userAgentFromContext(ctx); got != "cursor/0.47" {
+ t.Fatalf("expected fallback user agent, got %q", got)
+ }
+}
+
+func TestPreLLMHookSetsAppContextFromDetectedApp(t *testing.T) {
+ store := newTestStore(t)
+ defer store.Close(context.Background())
+ plugin, err := Init(context.Background(), &Config{}, testLogger{}, store, nil, nil)
+ if err != nil {
+ t.Fatalf("Init() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if cleanupErr := plugin.Cleanup(); cleanupErr != nil {
+ t.Errorf("Cleanup() error = %v", cleanupErr)
+ }
+ })
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyRequestID, "req-app-context")
+ ctx.SetValue(schemas.BifrostContextKeyRequestHeaders, map[string]string{
+ "user-agent": "claude-cli/2.1.168 (external, cli)",
+ "x-bf-vk": "vk-test",
+ "x-bf-user-id": "user-test",
+ })
+
+ _, _, err = plugin.PreLLMHook(ctx, &schemas.BifrostRequest{
+ RequestType: schemas.ChatCompletionRequest,
+ ChatRequest: &schemas.BifrostChatRequest{
+ Provider: schemas.OpenAI,
+ Model: "gpt-4o-mini",
+ Params: &schemas.ChatParameters{},
+ },
+ })
+ if err != nil {
+ t.Fatalf("PreLLMHook() error = %v", err)
+ }
+ if got, _ := ctx.Value(schemas.BifrostContextKeyApp).(string); got != "claude-code" {
+ t.Fatalf("app context = %q, want claude-code", got)
+ }
+}
+
+// TestPreLLMHookContextKeyComesFromUserAgentNotAgentHeader replays the header
+// shape the desktop agent stamps for a Claude Cowork request: Cowork's runtime
+// shares Claude Code's CLI User-Agent, so the logging plugin derives
+// claude-code for the context key. Header-based app enforcement (X-Bf-App)
+// lives in the enterprise agent policy plugin, which prefers the header over
+// this context key.
+func TestPreLLMHookContextKeyComesFromUserAgentNotAgentHeader(t *testing.T) {
+ store := newTestStore(t)
+ defer store.Close(context.Background())
+ plugin, err := Init(context.Background(), &Config{}, testLogger{}, store, nil, nil)
+ if err != nil {
+ t.Fatalf("Init() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if cleanupErr := plugin.Cleanup(); cleanupErr != nil {
+ t.Errorf("Cleanup() error = %v", cleanupErr)
+ }
+ })
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyRequestID, "req-cowork-context")
+ // Captured from the agent's MITM → GATEWAY forwarding of a Cowork request.
+ ctx.SetValue(schemas.BifrostContextKeyRequestHeaders, map[string]string{
+ "user-agent": "claude-cli/2.1.170 (external, local-agent, agent-sdk/0.3.170)",
+ "anthropic-client-platform": "desktop_app",
+ "x-app": "cli",
+ "x-bf-app": "claude-cowork",
+ "x-bifrost-agent": "bifrost-agent/0.1.0",
+ })
+
+ _, _, err = plugin.PreLLMHook(ctx, &schemas.BifrostRequest{
+ RequestType: schemas.ChatCompletionRequest,
+ ChatRequest: &schemas.BifrostChatRequest{
+ Provider: schemas.Anthropic,
+ Model: "claude-opus-4-8",
+ Params: &schemas.ChatParameters{},
+ },
+ })
+ if err != nil {
+ t.Fatalf("PreLLMHook() error = %v", err)
+ }
+ if got, _ := ctx.Value(schemas.BifrostContextKeyApp).(string); got != "claude-code" {
+ t.Fatalf("app context = %q, want claude-code (derived from User-Agent)", got)
+ }
+}
+
+func TestCustomUserAgentMappingOverridesBuiltInDetection(t *testing.T) {
+ store := newTestStore(t)
+ defer store.Close(context.Background())
+ plugin, err := Init(context.Background(), &Config{}, testLogger{}, store, nil, nil)
+ if err != nil {
+ t.Fatalf("Init() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if cleanupErr := plugin.Cleanup(); cleanupErr != nil {
+ t.Errorf("Cleanup() error = %v", cleanupErr)
+ }
+ })
+
+ _, err = plugin.CreateUserAgentMapping(context.Background(), &logstore.UserAgentMapping{
+ Pattern: `claude-cli/\d+\.\d+`,
+ MatchType: string(schemas.UserAgentMappingMatchTypeRegex),
+ App: "Internal Claude Wrapper",
+ IsActive: true,
+ })
+ if err != nil {
+ t.Fatalf("CreateUserAgentMapping() error = %v", err)
+ }
+
+ if got := plugin.detectAppFromUserAgent("claude-cli/2.1.168 (external, cli)"); got != "Internal Claude Wrapper" {
+ t.Fatalf("expected custom app mapping, got %q", got)
+ }
+}
+
+func TestPreLLMHookSetsAppContextFromCustomMapping(t *testing.T) {
+ store := newTestStore(t)
+ defer store.Close(context.Background())
+ plugin, err := Init(context.Background(), &Config{}, testLogger{}, store, nil, nil)
+ if err != nil {
+ t.Fatalf("Init() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if cleanupErr := plugin.Cleanup(); cleanupErr != nil {
+ t.Errorf("Cleanup() error = %v", cleanupErr)
+ }
+ })
+ _, err = plugin.CreateUserAgentMapping(context.Background(), &logstore.UserAgentMapping{
+ Pattern: `custom-wrapper/\d+`,
+ MatchType: string(schemas.UserAgentMappingMatchTypeRegex),
+ App: "Internal Claude Wrapper",
+ IsActive: true,
+ })
+ if err != nil {
+ t.Fatalf("CreateUserAgentMapping() error = %v", err)
+ }
+ ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+ ctx.SetValue(schemas.BifrostContextKeyRequestID, "req-custom-app-context")
+ ctx.SetValue(schemas.BifrostContextKeyRequestHeaders, map[string]string{
+ "user-agent": "custom-wrapper/42",
+ })
+
+ _, _, err = plugin.PreLLMHook(ctx, &schemas.BifrostRequest{
+ RequestType: schemas.ChatCompletionRequest,
+ ChatRequest: &schemas.BifrostChatRequest{
+ Provider: schemas.OpenAI,
+ Model: "gpt-4o-mini",
+ Params: &schemas.ChatParameters{},
+ },
+ })
+ if err != nil {
+ t.Fatalf("PreLLMHook() error = %v", err)
+ }
+ if got, _ := ctx.Value(schemas.BifrostContextKeyApp).(string); got != "internal-claude-wrapper" {
+ t.Fatalf("app context = %q, want internal-claude-wrapper", got)
+ }
+}
+
func TestPostLLMHookNoPendingErrorPreservesMetadata(t *testing.T) {
store := newTestStore(t)
loggingHeaders := []string{"x-custom-log"}
@@ -745,9 +953,89 @@ func TestBuildInitialLogEntryPreservesMetadata(t *testing.T) {
}
}
-// TestMCPHooksDeferDBWriteUntilPostHookBatch verifies MCP logs are kept in
-// memory after PreMCPHook and persisted by the batch writer after PostMCPHook.
-func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) {
+func TestBuildInitialLogEntryPreservesUserAgent(t *testing.T) {
+ userAgent := "codex-cli/1.0"
+ entry := buildInitialLogEntry(&PendingLogData{
+ RequestID: "req-initial-user-agent",
+ Timestamp: time.Now().UTC(),
+ FallbackIndex: 1,
+ InitialData: &InitialLogData{
+ Provider: string(schemas.OpenAI),
+ Model: "gpt-4o",
+ Object: string(schemas.ChatCompletionRequest),
+ UserAgent: userAgent,
+ },
+ })
+
+ if entry.UserAgent == nil {
+ t.Fatalf("expected user agent on initial log entry")
+ }
+ if got := *entry.UserAgent; got != userAgent {
+ t.Fatalf("expected user agent %q, got %q", userAgent, got)
+ }
+ if entry.App == nil {
+ t.Fatalf("expected app on initial log entry")
+ }
+ if got := *entry.App; got != "Codex CLI" {
+ t.Fatalf("expected app Codex CLI, got %q", got)
+ }
+}
+
+func TestBuildCompleteLogEntryPreservesUserAgent(t *testing.T) {
+ userAgent := "cursor/0.47"
+ entry := buildCompleteLogEntryFromPending(&PendingLogData{
+ RequestID: "req-complete-user-agent",
+ Timestamp: time.Now().UTC(),
+ FallbackIndex: 1,
+ InitialData: &InitialLogData{
+ Provider: string(schemas.OpenAI),
+ Model: "gpt-4o",
+ Object: string(schemas.ChatCompletionRequest),
+ UserAgent: userAgent,
+ },
+ })
+
+ if entry.UserAgent == nil {
+ t.Fatalf("expected user agent on complete log entry")
+ }
+ if got := *entry.UserAgent; got != userAgent {
+ t.Fatalf("expected user agent %q, got %q", userAgent, got)
+ }
+ if entry.App == nil {
+ t.Fatalf("expected app on complete log entry")
+ }
+ if got := *entry.App; got != "Cursor" {
+ t.Fatalf("expected app Cursor, got %q", got)
+ }
+}
+
+func TestBuildLogEntriesOmitEmptyUserAgent(t *testing.T) {
+ pending := &PendingLogData{
+ RequestID: "req-empty-user-agent",
+ Timestamp: time.Now().UTC(),
+ FallbackIndex: 1,
+ InitialData: &InitialLogData{
+ Provider: string(schemas.OpenAI),
+ Model: "gpt-4o",
+ Object: string(schemas.ChatCompletionRequest),
+ },
+ }
+
+ if entry := buildInitialLogEntry(pending); entry.UserAgent != nil {
+ t.Fatalf("expected nil initial user agent, got %#v", entry.UserAgent)
+ } else if entry.App != nil {
+ t.Fatalf("expected nil initial app, got %#v", entry.App)
+ }
+ if entry := buildCompleteLogEntryFromPending(pending); entry.UserAgent != nil {
+ t.Fatalf("expected nil complete user agent, got %#v", entry.UserAgent)
+ } else if entry.App != nil {
+ t.Fatalf("expected nil complete app, got %#v", entry.App)
+ }
+}
+
+// TestMCPHooksPersistPluginLogs verifies PostMCPHook stores the plugin-log
+// snapshot accumulated before logging's post-hook runs.
+func TestMCPHooksPersistPluginLogs(t *testing.T) {
store := newTestStore(t)
plugin, err := Init(context.Background(), &Config{}, testLogger{}, store, nil, nil)
if err != nil {
@@ -761,6 +1049,12 @@ func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) {
ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamID, "team-1")
ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerID, "customer-1")
ctx.SetValue(schemas.BifrostContextKeyGovernanceBusinessUnitID, "bu-1")
+ schemas.SetRedactionDataOnContext(ctx, schemas.RedactionData{
+ ReversibleMappings: schemas.RedactionMapsByPhase{
+ Input: map[string]string{"EMAIL-1": "private@example.com"},
+ Output: map[string]string{"EMAIL-2": "result@example.com"},
+ },
+ })
toolName := "docs-search"
_, _, err = plugin.PreMCPHook(ctx, &schemas.BifrostMCPRequest{
@@ -779,8 +1073,24 @@ func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) {
if _, err := store.FindMCPToolLog(context.Background(), "mcp-batch-flow"); !errors.Is(err, logstore.ErrNotFound) {
t.Fatalf("expected MCP log to stay in memory before PostMCPHook, got err=%v", err)
}
+ pendingValue, ok := plugin.pendingMCPLogsToInject.Load("mcp-batch-flow")
+ if !ok {
+ t.Fatal("expected pending MCP log entry")
+ }
+ pendingEntry, ok := pendingValue.(*logstore.MCPToolLog)
+ if !ok {
+ t.Fatalf("pending MCP log entry has type %T", pendingValue)
+ }
+ if pendingEntry.RedactionData != nil {
+ t.Fatal("expected redaction data to be attached only after PostMCPHook")
+ }
result := `{"answer":"done"}`
+ guardrailsName := "guardrails"
+ guardrailsCtx := ctx.WithPluginScope(&guardrailsName)
+ guardrailsCtx.Log(schemas.LogLevelInfo, "MCP tool arguments redacted")
+ guardrailsCtx.ReleasePluginScope()
+
_, _, err = plugin.PostMCPHook(ctx, &schemas.BifrostMCPResponse{
ChatMessage: &schemas.ChatMessage{
Role: schemas.ChatMessageRoleTool,
@@ -796,7 +1106,12 @@ func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) {
if err != nil {
t.Fatalf("PostMCPHook() error = %v", err)
}
-
+ if pendingEntry.RedactionData == nil {
+ t.Fatal("expected PostMCPHook to attach redaction data")
+ }
+ if got := pendingEntry.RedactionData.ReversibleMappings.Output["EMAIL-2"]; got != "result@example.com" {
+ t.Fatalf("output redaction mapping = %q, want %q", got, "result@example.com")
+ }
if err := plugin.Cleanup(); err != nil {
t.Fatalf("Cleanup() error = %v", err)
}
@@ -818,6 +1133,13 @@ func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) {
if logEntry.Latency == nil || *logEntry.Latency != 42 {
t.Fatalf("expected latency 42, got %#v", logEntry.Latency)
}
+ var pluginLogs map[string][]schemas.PluginLogEntry
+ if err := json.Unmarshal([]byte(logEntry.PluginLogs), &pluginLogs); err != nil {
+ t.Fatalf("expected valid plugin logs JSON, got %q: %v", logEntry.PluginLogs, err)
+ }
+ if got := pluginLogs[guardrailsName]; len(got) != 1 || got[0].Message != "MCP tool arguments redacted" {
+ t.Fatalf("expected guardrails plugin log to be persisted, got %#v", pluginLogs)
+ }
assertMCPLogGovernanceFields(t, logEntry, "user-1", "team-1", "customer-1", "bu-1")
}
@@ -854,7 +1176,6 @@ func TestPostMCPHookFallbackStampsGovernanceFields(t *testing.T) {
if err != nil {
t.Fatalf("PostMCPHook() error = %v", err)
}
-
if err := plugin.Cleanup(); err != nil {
t.Fatalf("Cleanup() error = %v", err)
}
diff --git a/plugins/logging/redaction_test.go b/plugins/logging/redaction_test.go
index bce4d4eb86a..72c600de047 100644
--- a/plugins/logging/redaction_test.go
+++ b/plugins/logging/redaction_test.go
@@ -85,3 +85,34 @@ func TestAttachLogRedactionDataIgnoresMissingContext(t *testing.T) {
assert.Nil(t, entry.RedactionData)
}
+
+// TestAttachMCPLogRedactionDataCopiesContextValue verifies MCP entries receive an owned redaction snapshot.
+func TestAttachMCPLogRedactionDataCopiesContextValue(t *testing.T) {
+ ctx := schemas.NewBifrostContext(context.Background(), time.Time{})
+ reversibleMappings := map[string]string{"EMAIL-1": "alex_rivera@gmail.com"}
+ schemas.SetRedactionDataOnContext(ctx, schemas.RedactionData{
+ ReversibleMappings: schemas.RedactionMapsByPhase{Input: reversibleMappings},
+ })
+ entry := &logstore.MCPToolLog{}
+
+ attachMCPLogRedactionData(ctx, entry, true)
+ reversibleMappings["EMAIL-1"] = "mutated@example.com"
+
+ require.NotNil(t, entry.RedactionData)
+ assert.Equal(t, "alex_rivera@gmail.com", entry.RedactionData.ReversibleMappings.Input["EMAIL-1"])
+}
+
+// TestAttachMCPLogRedactionDataSkipsUnavailableContent verifies disabled logging and missing inputs never attach sensitive data.
+func TestAttachMCPLogRedactionDataSkipsUnavailableContent(t *testing.T) {
+ ctx := schemas.NewBifrostContext(context.Background(), time.Time{})
+ schemas.SetRedactionDataOnContext(ctx, schemas.RedactionData{
+ ReversibleMappings: schemas.RedactionMapsByPhase{Input: map[string]string{"EMAIL-1": "alex_rivera@gmail.com"}},
+ })
+ entry := &logstore.MCPToolLog{}
+
+ attachMCPLogRedactionData(ctx, entry, false)
+ attachMCPLogRedactionData(nil, entry, true)
+ attachMCPLogRedactionData(ctx, nil, true)
+
+ assert.Nil(t, entry.RedactionData)
+}
diff --git a/plugins/logging/utils.go b/plugins/logging/utils.go
index 0dcd496ab68..1ed134ac56d 100644
--- a/plugins/logging/utils.go
+++ b/plugins/logging/utils.go
@@ -97,6 +97,11 @@ type LogManager interface {
// GetAvailableStopReasons returns all unique stop reason values from logs
GetAvailableStopReasons(ctx context.Context, limit int, query string) ([]string, error)
+ // GetAvailableUserAgents returns all unique raw User-Agent strings from logs
+ GetAvailableUserAgents(ctx context.Context, limit int, query string) ([]string, error)
+ // GetAvailableApps returns all unique backend-detected app labels from logs
+ GetAvailableApps(ctx context.Context, limit int, query string) ([]string, error)
+
// GetAvailableTeams returns all unique team ID-Name pairs from logs
GetAvailableTeams(ctx context.Context, limit int, query string) ([]KeyPair, error)
@@ -156,6 +161,11 @@ type LogManager interface {
// GetAvailableServerLabels returns all unique server labels from MCP tool logs
GetAvailableServerLabels(ctx context.Context, limit int, query string) ([]string, error)
+ // GetAvailableMCPUserAgents returns all unique raw User-Agent strings from MCP tool logs
+ GetAvailableMCPUserAgents(ctx context.Context, limit int, query string) ([]string, error)
+ // GetAvailableMCPApps returns all unique backend-detected app labels from MCP tool logs
+ GetAvailableMCPApps(ctx context.Context, limit int, query string) ([]string, error)
+
// GetAvailableMCPVirtualKeys returns all unique virtual key ID-Name pairs from MCP tool logs
GetAvailableMCPVirtualKeys(ctx context.Context, limit int, query string) ([]KeyPair, error)
@@ -170,6 +180,11 @@ type LogManager interface {
// DeleteMCPToolLogs deletes multiple MCP tool log entries by their IDs
DeleteMCPToolLogs(ctx context.Context, ids []string) error
+
+ ListUserAgentMappings(ctx context.Context) ([]logstore.UserAgentMapping, error)
+ CreateUserAgentMapping(ctx context.Context, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error)
+ UpdateUserAgentMapping(ctx context.Context, id string, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error)
+ DeleteUserAgentMapping(ctx context.Context, id string) error
}
// PluginLogManager implements LogManager interface wrapping the plugin
@@ -329,6 +344,16 @@ func (p *PluginLogManager) GetAvailableStopReasons(ctx context.Context, limit in
return p.plugin.GetAvailableStopReasons(ctx, limit, query)
}
+// GetAvailableUserAgents returns distinct raw User-Agent strings from logs for the logs "App" filter.
+func (p *PluginLogManager) GetAvailableUserAgents(ctx context.Context, limit int, query string) ([]string, error) {
+ return p.plugin.GetAvailableUserAgents(ctx, limit, query)
+}
+
+// GetAvailableApps returns distinct backend-detected app labels from logs for the logs "App" filter.
+func (p *PluginLogManager) GetAvailableApps(ctx context.Context, limit int, query string) ([]string, error) {
+ return p.plugin.GetAvailableApps(ctx, limit, query)
+}
+
func (p *PluginLogManager) GetAvailableTeams(ctx context.Context, limit int, query string) ([]KeyPair, error) {
return p.plugin.GetAvailableTeams(ctx, limit, query)
}
@@ -462,6 +487,22 @@ func (p *PluginLogManager) GetAvailableServerLabels(ctx context.Context, limit i
return p.plugin.store.GetAvailableServerLabels(ctx, limit, query)
}
+// GetAvailableMCPUserAgents returns distinct raw User-Agent strings from MCP tool logs for the MCP "App" filter.
+func (p *PluginLogManager) GetAvailableMCPUserAgents(ctx context.Context, limit int, query string) ([]string, error) {
+ if p == nil || p.plugin == nil || p.plugin.store == nil {
+ return []string{}, nil
+ }
+ return p.plugin.store.GetAvailableMCPUserAgents(ctx, limit, query)
+}
+
+// GetAvailableMCPApps returns distinct backend-detected app labels from MCP tool logs for the MCP "App" filter.
+func (p *PluginLogManager) GetAvailableMCPApps(ctx context.Context, limit int, query string) ([]string, error) {
+ if p == nil || p.plugin == nil || p.plugin.store == nil {
+ return []string{}, nil
+ }
+ return p.plugin.store.GetAvailableMCPApps(ctx, limit, query)
+}
+
func (p *PluginLogManager) GetAvailableMCPVirtualKeys(ctx context.Context, limit int, query string) ([]KeyPair, error) {
if p == nil || p.plugin == nil {
return []KeyPair{}, nil
@@ -501,6 +542,38 @@ func (p *PluginLogManager) DeleteMCPToolLogs(ctx context.Context, ids []string)
return p.plugin.store.DeleteMCPToolLogs(ctx, ids)
}
+// ListUserAgentMappings returns all custom User-Agent mappings.
+func (p *PluginLogManager) ListUserAgentMappings(ctx context.Context) ([]logstore.UserAgentMapping, error) {
+ return p.plugin.ListUserAgentMappings(ctx)
+}
+
+// CreateUserAgentMapping creates a custom User-Agent mapping through the logging plugin.
+func (p *PluginLogManager) CreateUserAgentMapping(ctx context.Context, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) {
+ if mapping == nil {
+ return nil, fmt.Errorf("%w: mapping cannot be nil", ErrInvalidUserAgentMapping)
+ }
+ return p.plugin.CreateUserAgentMapping(ctx, mapping)
+}
+
+// UpdateUserAgentMapping updates a custom User-Agent mapping through the logging plugin.
+func (p *PluginLogManager) UpdateUserAgentMapping(ctx context.Context, id string, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) {
+ if strings.TrimSpace(id) == "" {
+ return nil, fmt.Errorf("%w: id cannot be empty", ErrInvalidUserAgentMapping)
+ }
+ if mapping == nil {
+ return nil, fmt.Errorf("%w: mapping cannot be nil", ErrInvalidUserAgentMapping)
+ }
+ return p.plugin.UpdateUserAgentMapping(ctx, id, mapping)
+}
+
+// DeleteUserAgentMapping deletes a custom User-Agent mapping through the logging plugin.
+func (p *PluginLogManager) DeleteUserAgentMapping(ctx context.Context, id string) error {
+ if strings.TrimSpace(id) == "" {
+ return fmt.Errorf("%w: id cannot be empty", ErrInvalidUserAgentMapping)
+ }
+ return p.plugin.DeleteUserAgentMapping(ctx, id)
+}
+
// GetPluginLogManager returns a LogManager interface for this plugin
func (p *LoggerPlugin) GetPluginLogManager() *PluginLogManager {
return &PluginLogManager{
diff --git a/plugins/logging/writer.go b/plugins/logging/writer.go
index 2a706a9586c..6571445651a 100644
--- a/plugins/logging/writer.go
+++ b/plugins/logging/writer.go
@@ -1,6 +1,7 @@
package logging
import (
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -397,7 +398,7 @@ func estimateMCPToolLogEntrySize(log *logstore.MCPToolLog) int {
if log == nil {
return 0
}
- return len(log.Arguments) + len(log.Result) + len(log.ErrorDetails) + len(log.Metadata) + 512
+ return len(log.Arguments) + len(log.Result) + len(log.ErrorDetails) + len(log.Metadata) + len(log.PluginLogs) + 512
}
// buildStaleMCPToolLogEntry converts a pending MCP processing row into a
@@ -443,6 +444,8 @@ func buildInitialLogEntry(pending *PendingLogData) *logstore.Log {
if len(pending.RoutingEnginesUsed) > 0 {
entry.RoutingEnginesUsed = pending.RoutingEnginesUsed
}
+ applyUserAgent(entry, pending.InitialData.UserAgent)
+ applyApp(entry, pending.InitialData.App)
return entry
}
@@ -478,9 +481,51 @@ func buildCompleteLogEntryFromPending(pending *PendingLogData) *logstore.Log {
if len(pending.RoutingEnginesUsed) > 0 {
entry.RoutingEnginesUsed = pending.RoutingEnginesUsed
}
+ applyUserAgent(entry, pending.InitialData.UserAgent)
+ applyApp(entry, pending.InitialData.App)
return entry
}
+// User-Agent and App map to fixed-width DB columns (varchar(512) / varchar(128)).
+// User-Agent is an untrusted, unbounded client header, so clamp both before
+// persisting to avoid an insert that fails (and silently drops the log) when a
+// client sends an oversized header.
+const (
+ maxPersistedUserAgentLen = 512
+ maxPersistedAppLen = 128
+)
+
+// clampString truncates s to at most max bytes. The columns are sized in
+// characters but ASCII User-Agent headers make bytes a safe lower bound. A raw
+// byte-slice truncation can split a multi-byte UTF-8 rune, so ToValidUTF8
+// strips the dangling partial rune to keep the result valid UTF-8 for the
+// varchar insert.
+func clampString(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ return strings.ToValidUTF8(s[:max], "")
+}
+
+func applyUserAgent(entry *logstore.Log, userAgent string) {
+ if userAgent == "" {
+ return
+ }
+ entry.UserAgent = new(clampString(userAgent, maxPersistedUserAgentLen))
+ if entry.App == nil {
+ if app := schemas.DetectAppFromUserAgent(userAgent); app != "" {
+ entry.App = new(clampString(app, maxPersistedAppLen))
+ }
+ }
+}
+
+func applyApp(entry *logstore.Log, app string) {
+ if app == "" {
+ return
+ }
+ entry.App = new(clampString(app, maxPersistedAppLen))
+}
+
// applyModelAlias sets entry.Model to resolvedModel (falling back to requestedModel if empty)
// and entry.Alias to requestedModel when the two differ (i.e. an alias mapping was applied).
func applyModelAlias(entry *logstore.Log, requestedModel, resolvedModel string) {
diff --git a/tests/cmd/e2eseed/go.mod b/tests/cmd/e2eseed/go.mod
index 8df44a55143..8ea014299c1 100644
--- a/tests/cmd/e2eseed/go.mod
+++ b/tests/cmd/e2eseed/go.mod
@@ -85,7 +85,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
- github.com/maximhq/bifrost/core v1.7.4 // indirect
+ github.com/maximhq/bifrost/core v1.7.5 // indirect
github.com/maximhq/bifrost/framework v1.3.16 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
diff --git a/tests/cmd/e2eseed/main.go b/tests/cmd/e2eseed/main.go
index df58238e86c..a076aa58ceb 100644
--- a/tests/cmd/e2eseed/main.go
+++ b/tests/cmd/e2eseed/main.go
@@ -6,7 +6,7 @@ import (
"fmt"
"os"
- e2eseed "github.com/maximhq/bifrost/tests/cmd/seed"
+ "github.com/maximhq/bifrost/tests/cmd/seed"
)
// main runs the OSS API e2e seed command.
@@ -19,7 +19,7 @@ func main() {
// run parses flags, connects to the target stores, and writes seed fixtures.
func run(ctx context.Context, args []string) error {
- opts := e2eseed.DefaultOptions()
+ opts := seed.DefaultOptions()
summaryPath := ""
fs := flag.NewFlagSet("e2eseed", flag.ContinueOnError)
@@ -39,19 +39,19 @@ func run(ctx context.Context, args []string) error {
return err
}
- opts, err := e2eseed.NormalizeOptions(opts)
+ opts, err := seed.NormalizeOptions(opts)
if err != nil {
return err
}
- e2eseed.InitEncryption(opts)
- configDB, err := e2eseed.OpenDB(opts.ConfigDialect, opts.ConfigDSN)
+ seed.InitEncryption(opts)
+ configDB, err := seed.OpenDB(opts.ConfigDialect, opts.ConfigDSN)
if err != nil {
return fmt.Errorf("open config DB: %w", err)
}
if sqlDB, dbErr := configDB.DB(); dbErr == nil {
defer sqlDB.Close()
}
- logsDB, err := e2eseed.OpenDB(opts.LogsDialect, opts.LogsDSN)
+ logsDB, err := seed.OpenDB(opts.LogsDialect, opts.LogsDSN)
if err != nil {
return fmt.Errorf("open logs DB: %w", err)
}
@@ -59,12 +59,12 @@ func run(ctx context.Context, args []string) error {
defer sqlDB.Close()
}
- summary, err := e2eseed.SeedBase(ctx, configDB, logsDB, opts)
+ summary, err := seed.SeedBase(ctx, configDB, logsDB, opts)
if err != nil {
return err
}
if summaryPath != "" {
- if err := e2eseed.WriteJSONFile(summaryPath, summary); err != nil {
+ if err := seed.WriteJSONFile(summaryPath, summary); err != nil {
return err
}
}
diff --git a/tests/cmd/seed/go.mod b/tests/cmd/seed/go.mod
index f7a2e38c66c..9f78c250613 100644
--- a/tests/cmd/seed/go.mod
+++ b/tests/cmd/seed/go.mod
@@ -8,7 +8,7 @@ replace (
)
require (
- github.com/maximhq/bifrost/core v1.7.4
+ github.com/maximhq/bifrost/core v1.7.5
github.com/maximhq/bifrost/framework v1.3.16
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
diff --git a/tests/cmd/seed/seed.go b/tests/cmd/seed/seed.go
index 1ba84ec86a9..3ef4028953f 100644
--- a/tests/cmd/seed/seed.go
+++ b/tests/cmd/seed/seed.go
@@ -1,4 +1,4 @@
-// Package e2eseed creates deterministic OSS fixtures for API and DAC tests.
+// Package seed creates deterministic OSS fixtures for API and DAC tests.
package seed
import (
@@ -258,7 +258,8 @@ func SeedBase(ctx context.Context, configDB, logsDB *gorm.DB, opts Options) (*Su
return nil, err
}
seedBaseTime = time.Now().UTC()
- InitEncryption(opts)
+ // Encryption is initialized by the caller (see cmd/e2eseed/main.go) before
+ // SeedBase runs, so we do not re-initialize it here.
env := SeedEnv(opts.Prefix)
manifest := BuildExpectedManifest(opts.Prefix, opts.LogRowsPerShape)
summary := &Summary{
@@ -597,7 +598,6 @@ func seedProviders(ctx context.Context, db *gorm.DB, prefix string, now time.Tim
// seedGovernance writes customers, teams, budgets, rate limits, VKs, and VK provider configs.
func seedGovernance(ctx context.Context, db *gorm.DB, prefix string, now time.Time) error {
- active := true
tiggingsCustomer := prefix + "-customer-tiggings"
outsideCustomer := prefix + "-customer-outside"
tiggingsTeam := prefix + "-team-tiggings"
@@ -612,10 +612,11 @@ func seedGovernance(ctx context.Context, db *gorm.DB, prefix string, now time.Ti
return err
}
}
+ active := new(true)
for _, vk := range []tables.TableVirtualKey{
- {ID: prefix + "-vk-user-team", Name: "E2E User Team VK", Value: *schemas.NewSecretVar(prefix + "-vk-user-team-secret"), IsActive: &active, TeamID: &tiggingsTeam, CreatedAt: now, UpdatedAt: now},
- {ID: prefix + "-vk-team-only", Name: "E2E Team Only VK", Value: *schemas.NewSecretVar(prefix + "-vk-team-only-secret"), IsActive: &active, TeamID: &tiggingsTeam, CreatedAt: now, UpdatedAt: now},
- {ID: prefix + "-vk-outside", Name: "E2E Outside VK", Value: *schemas.NewSecretVar(prefix + "-vk-outside-secret"), IsActive: &active, TeamID: &outsideTeam, CreatedAt: now, UpdatedAt: now},
+ {ID: prefix + "-vk-user-team", Name: "E2E User Team VK", Value: *schemas.NewSecretVar(prefix + "-vk-user-team-secret"), IsActive: active, TeamID: &tiggingsTeam, CreatedAt: now, UpdatedAt: now},
+ {ID: prefix + "-vk-team-only", Name: "E2E Team Only VK", Value: *schemas.NewSecretVar(prefix + "-vk-team-only-secret"), IsActive: active, TeamID: &tiggingsTeam, CreatedAt: now, UpdatedAt: now},
+ {ID: prefix + "-vk-outside", Name: "E2E Outside VK", Value: *schemas.NewSecretVar(prefix + "-vk-outside-secret"), IsActive: active, TeamID: &outsideTeam, CreatedAt: now, UpdatedAt: now},
} {
if err := db.WithContext(ctx).Where("id = ?", vk.ID).Assign(vk).FirstOrCreate(&vk).Error; err != nil {
return err
diff --git a/tests/cmd/seedvks/go.mod b/tests/cmd/seedvks/go.mod
index 24199e42617..d862293e84d 100644
--- a/tests/cmd/seedvks/go.mod
+++ b/tests/cmd/seedvks/go.mod
@@ -9,7 +9,7 @@ replace (
require (
github.com/google/uuid v1.6.0
- github.com/maximhq/bifrost/core v1.7.4
+ github.com/maximhq/bifrost/core v1.7.5
github.com/maximhq/bifrost/framework v1.3.16
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json
index 0a2bcfa091b..604aa89bf0a 100644
--- a/tests/e2e/api/collections/provider-harness.json
+++ b/tests/e2e/api/collections/provider-harness.json
@@ -11092,26 +11092,6 @@
}
}
},
- {
- "name": "anthropic/claude-sonnet-5",
- "event": [{"listen":"test","script":{"type":"text/javascript","exec":["if (pm.response.code < 400) { pm.test('Function call: get_weather invoked with city argument', function () { var j = pm.response.json(); var tc = null; if (j.choices && j.choices[0] && j.choices[0].message && Array.isArray(j.choices[0].message.tool_calls) && j.choices[0].message.tool_calls.length) { tc = j.choices[0].message.tool_calls[0]; } pm.expect(tc, 'no tool_calls in response').to.not.be.null; pm.expect(tc.function && tc.function.name).to.equal('get_weather'); var a; try { a = JSON.parse(tc.function.arguments); } catch (e) { pm.expect.fail('arguments not JSON: ' + e.message); return; } pm.expect(a).to.have.property('city').that.is.a('string'); }); }"]}}],
- "request": {
- "method": "POST",
- "header": [{"key":"Content-Type","value":"application/json"}],
- "body": {"mode":"raw","raw":"{\n \"model\": \"anthropic/claude-sonnet-5\",\n \"messages\": [{\"role\":\"user\",\"content\":\"Weather in Lagos, Nigeria?\"}],\n \"tools\": [{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}}]\n}"},
- "url": {"raw":"{{baseUrl}}/v1/chat/completions","host":["{{baseUrl}}"],"path":["v1","chat","completions"]}
- }
- },
- {
- "name": "bedrock/global.anthropic.claude-sonnet-5",
- "event": [{"listen":"test","script":{"type":"text/javascript","exec":["if (pm.response.code < 400) { pm.test('Function call: get_weather invoked with city argument', function () { var j = pm.response.json(); var tc = null; if (j.choices && j.choices[0] && j.choices[0].message && Array.isArray(j.choices[0].message.tool_calls) && j.choices[0].message.tool_calls.length) { tc = j.choices[0].message.tool_calls[0]; } pm.expect(tc, 'no tool_calls in response').to.not.be.null; pm.expect(tc.function && tc.function.name).to.equal('get_weather'); var a; try { a = JSON.parse(tc.function.arguments); } catch (e) { pm.expect.fail('arguments not JSON: ' + e.message); return; } pm.expect(a).to.have.property('city').that.is.a('string'); }); }"]}}],
- "request": {
- "method": "POST",
- "header": [{"key":"Content-Type","value":"application/json"}],
- "body": {"mode":"raw","raw":"{\n \"model\": \"bedrock/global.anthropic.claude-sonnet-5\",\n \"messages\": [{\"role\":\"user\",\"content\":\"Weather in Lagos, Nigeria?\"}],\n \"tools\": [{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}}]\n}"},
- "url": {"raw":"{{baseUrl}}/v1/chat/completions","host":["{{baseUrl}}"],"path":["v1","chat","completions"]}
- }
- },
{
"name": "gemini/gemini-2.5-flash",
"event": [
diff --git a/tests/e2e/api/runners/run-stream-cancellation.mjs b/tests/e2e/api/runners/run-stream-cancellation.mjs
index 2e489966326..beac2e5e570 100644
--- a/tests/e2e/api/runners/run-stream-cancellation.mjs
+++ b/tests/e2e/api/runners/run-stream-cancellation.mjs
@@ -19,7 +19,7 @@ const args = Object.fromEntries(
acc.push([key, next && !next.startsWith("--") ? next : "true"]);
}
return acc;
- }, [])
+ }, []),
);
const baseUrl = args["base-url"] || process.env.BASE_URL || "http://localhost:8080";
@@ -36,7 +36,8 @@ const nonStreamAbortMs = Number(args["nonstream-abort-ms"] || 300);
const skipCostCheck = args["no-cost-check"] === "true";
const logsDbUrlArg = args["logs-db-url"] || process.env.BIFROST_LOGS_DB_URL || "";
const configPathArg = args["config"] || process.env.BIFROST_CONFIG_PATH || "config.json";
-const pricingUrl = args["pricing-url"] || process.env.BIFROST_PRICING_URL || "https://getbifrost.ai/datasheet";
+const pricingUrl =
+ args["pricing-url"] || process.env.BIFROST_PRICING_URL || "https://getbifrost.ai/datasheet";
// Providers that emit usage in the FIRST stream event → a cancel-after-first-byte
// deterministically has billable usage, so cost MUST be > 0. Only native Anthropic
// qualifies: its message_start event carries input_tokens + cache tokens immediately
@@ -66,11 +67,17 @@ function resolveLogsDbUrl() {
}
if (ls.type === "postgres") {
const c = ls.config || {};
- const host = c.host || "localhost", port = c.port || "5432", user = c.user || "bifrost";
- const pass = c.password || "", db = c.db_name || "bifrost", ssl = c.ssl_mode || "disable";
+ const host = c.host || "localhost",
+ port = c.port || "5432",
+ user = c.user || "bifrost";
+ const pass = c.password || "",
+ db = c.db_name || "bifrost",
+ ssl = c.ssl_mode || "disable";
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}?sslmode=${ssl}`;
}
- } catch (_) { /* no config / unreadable → skip */ }
+ } catch (_) {
+ /* no config / unreadable → skip */
+ }
return "";
}
@@ -79,35 +86,57 @@ async function connectLogsDb(url) {
const pg = require("pg");
const client = new pg.Client({ connectionString: url });
await client.connect();
- return { query: async (sql, p) => (await client.query(sql, p)).rows, close: () => client.end().catch(() => {}) };
+ return {
+ query: async (sql, p) => (await client.query(sql, p)).rows,
+ close: () => client.end().catch(() => {}),
+ };
}
const Database = require("better-sqlite3");
const sdb = new Database(url.replace(/^sqlite:\/\//i, ""), { readonly: true });
- return { query: async (sql, p) => sdb.prepare(sql.replace(/\$\d+/g, "?")).all(...p), close: () => { try { sdb.close(); } catch (_) {} } };
+ return {
+ query: async (sql, p) => sdb.prepare(sql.replace(/\$\d+/g, "?")).all(...p),
+ close: () => {
+ try {
+ sdb.close();
+ } catch (_) {}
+ },
+ };
}
async function pollLogRow(db, id) {
- const sql = "SELECT cost, prompt_tokens, completion_tokens, total_tokens, cached_read_tokens, token_usage, model, provider, status FROM logs WHERE id = $1";
+ const sql =
+ "SELECT cost, prompt_tokens, completion_tokens, total_tokens, cached_read_tokens, token_usage, model, provider, status FROM logs WHERE id = $1";
let last = null;
for (const ms of [300, 700, 1200, 2000]) {
await new Promise((r) => setTimeout(r, ms));
const rows = await db.query(sql, [id]);
- if (rows && rows.length) { last = rows[0]; if (last.cost != null && Number(last.cost) > 0) return last; }
+ if (rows && rows.length) {
+ last = rows[0];
+ if (last.cost != null && Number(last.cost) > 0) return last;
+ }
}
return last;
}
-
function expectedCost(entry, row) {
- const input = entry.input_cost_per_token || 0, output = entry.output_cost_per_token || 0;
- const cr = entry.cache_read_input_token_cost || 0, cw = entry.cache_creation_input_token_cost || 0;
- const prompt = Number(row.prompt_tokens || 0), completion = Number(row.completion_tokens || 0);
- let cachedRead = Number(row.cached_read_tokens || 0), cachedWrite = 0;
+ const input = entry.input_cost_per_token || 0,
+ output = entry.output_cost_per_token || 0;
+ const cr = entry.cache_read_input_token_cost || 0,
+ cw = entry.cache_creation_input_token_cost || 0;
+ const prompt = Number(row.prompt_tokens || 0),
+ completion = Number(row.completion_tokens || 0);
+ let cachedRead = Number(row.cached_read_tokens || 0),
+ cachedWrite = 0;
if (row.token_usage) {
try {
const d = JSON.parse(row.token_usage)?.prompt_tokens_details;
- if (d) { if (cachedRead === 0 && d.cached_read_tokens) cachedRead = Number(d.cached_read_tokens); if (d.cached_write_tokens) cachedWrite = Number(d.cached_write_tokens); }
- } catch (_) { /* ignore */ }
+ if (d) {
+ if (cachedRead === 0 && d.cached_read_tokens) cachedRead = Number(d.cached_read_tokens);
+ if (d.cached_write_tokens) cachedWrite = Number(d.cached_write_tokens);
+ }
+ } catch (_) {
+ /* ignore */
+ }
}
cachedRead = Math.min(cachedRead, prompt);
cachedWrite = Math.min(cachedWrite, Math.max(0, prompt - cachedRead));
@@ -115,12 +144,12 @@ function expectedCost(entry, row) {
return nonCached * input + cachedRead * cr + cachedWrite * cw + completion * output;
}
-// A streaming cancel logs status=cancelled (dedicated state since #4831; status=error
-// on older builds); a non-streaming cancel may instead finish server-side and log
-// success — accept any of these for non-stream.
+// A streaming cancel is logged status=cancelled (dedicated status since #4930;
+// older builds logged error); a non-streaming cancel may finish server-side
+// and log success - accept any terminal cancel outcome per kind.
function statusIsCancelOutcome(status, nonStream) {
- if (nonStream) return status === "error" || status === "success" || status === "cancelled";
- return status === "error" || status === "cancelled";
+ if (nonStream) return status === "cancelled" || status === "error" || status === "success";
+ return status === "cancelled" || status === "error";
}
// Compare the logged cost against the datasheet-recomputed cost. Returns
@@ -129,13 +158,26 @@ function statusIsCancelOutcome(status, nonStream) {
function costAccuracyVerdict(sheet, row, cost, tokens, kind) {
const entry = resolvePricingEntry(sheet, row.model, row.provider);
if (entry && tokens <= 128000) {
- const exp = expectedCost(entry, row), tol = Math.max(1e-9, 1e-4 * exp);
+ const exp = expectedCost(entry, row),
+ tol = Math.max(1e-9, 1e-4 * exp);
if (Math.abs(cost - exp) > tol) {
- return { verdict: "FAIL", detail: `${kind} inaccurate logged=$${cost} expected=$${exp}`, fail: true };
+ return {
+ verdict: "FAIL",
+ detail: `${kind} inaccurate logged=$${cost} expected=$${exp}`,
+ fail: true,
+ };
}
- return { verdict: "PASS", detail: `${kind} cost=$${cost} accurate (expected=$${exp})`, fail: false };
+ return {
+ verdict: "PASS",
+ detail: `${kind} cost=$${cost} accurate (expected=$${exp})`,
+ fail: false,
+ };
}
- return { verdict: "PASS", detail: `${kind} cost=$${cost} (accuracy skipped: no datasheet entry / tiered)`, fail: false };
+ return {
+ verdict: "PASS",
+ detail: `${kind} cost=$${cost} (accuracy skipped: no datasheet entry / tiered)`,
+ fail: false,
+ };
}
const streamCases = [
@@ -214,12 +256,16 @@ const nonStreamCases = streamCases.map((c) => ({
body: { ...c.body, stream: false, max_tokens: Math.max(Number(c.body.max_tokens) || 0, 1024) },
}));
-const cases = [...streamCases, ...nonStreamCases]
- .filter((c) => !providerFilter || c.provider === providerFilter);
+const cases = [...streamCases, ...nonStreamCases].filter(
+ (c) => !providerFilter || c.provider === providerFilter,
+);
const resolveVariables = (value) => {
if (typeof value === "string") {
- return value.replaceAll("{{azureDeployment}}", process.env.AZURE_DEPLOYMENT || process.env.BIFROST_AZURE_DEPLOYMENT || "gpt-4o-mini");
+ return value.replaceAll(
+ "{{azureDeployment}}",
+ process.env.AZURE_DEPLOYMENT || process.env.BIFROST_AZURE_DEPLOYMENT || "gpt-4o-mini",
+ );
}
if (Array.isArray(value)) return value.map(resolveVariables);
if (value && typeof value === "object") {
@@ -315,7 +361,13 @@ async function runCase(testCase) {
};
}
if (!response.body) {
- return { ...testCase, ok: false, status: response.status, contentType, error: "response body is not readable" };
+ return {
+ ...testCase,
+ ok: false,
+ status: response.status,
+ contentType,
+ error: "response body is not readable",
+ };
}
const reader = response.body.getReader();
@@ -380,55 +432,89 @@ if (skipCostCheck) {
} else {
const logsUrl = resolveLogsDbUrl();
if (!logsUrl) {
- console.error("[stream-cancel] no logs DB configured (set BIFROST_LOGS_DB_URL or a config.json logs_store); skipping cost verification");
+ console.error(
+ "[stream-cancel] no logs DB configured (set BIFROST_LOGS_DB_URL or a config.json logs_store); skipping cost verification",
+ );
} else {
let db = null;
- try { db = await connectLogsDb(logsUrl); }
- catch (e) { console.error(`[stream-cancel] could not connect to logs DB: ${e.message}; skipping cost verification`); }
+ try {
+ db = await connectLogsDb(logsUrl);
+ } catch (e) {
+ console.error(
+ `[stream-cancel] could not connect to logs DB: ${e.message}; skipping cost verification`,
+ );
+ }
if (db) {
let sheet = null;
- try { const resp = await fetch(pricingUrl); sheet = resp.ok ? await resp.json() : null; }
- catch (_) { sheet = null; }
- if (!sheet) console.error(`[stream-cancel] pricing datasheet (${pricingUrl}) unavailable; cost-accuracy checks skipped`);
+ try {
+ const resp = await fetch(pricingUrl);
+ sheet = resp.ok ? await resp.json() : null;
+ } catch (_) {
+ sheet = null;
+ }
+ if (!sheet)
+ console.error(
+ `[stream-cancel] pricing datasheet (${pricingUrl}) unavailable; cost-accuracy checks skipped`,
+ );
for (const r of results) {
const kind = r.nonStream ? "non-stream" : "stream";
- if (r.racedToCompletion) { r.costCheck = "SKIP"; r.costDetail = "request completed before abort could fire"; console.error(`[stream-cancel] cost ${r.provider} (${kind}): SKIP — ${r.costDetail}`); continue; }
- if (!r.aborted || !r.requestId) { r.costCheck = "SKIP"; continue; }
+ if (r.racedToCompletion) {
+ r.costCheck = "SKIP";
+ r.costDetail = "request completed before abort could fire";
+ console.error(`[stream-cancel] cost ${r.provider} (${kind}): SKIP — ${r.costDetail}`);
+ continue;
+ }
+ if (!r.aborted || !r.requestId) {
+ r.costCheck = "SKIP";
+ continue;
+ }
const row = await pollLogRow(db, r.requestId);
if (!row) {
- r.costCheck = "FAIL"; r.costDetail = `no log row for ${r.requestId}`; costFailures++;
+ r.costCheck = "FAIL";
+ r.costDetail = `no log row for ${r.requestId}`;
+ costFailures++;
} else if (!statusIsCancelOutcome(row.status, r.nonStream)) {
// A streaming cancel logs status=cancelled (#4831; error on older builds).
// A non-streaming cancel may instead finish the upstream call server-side and
// log success — either way it must be billed, so we accept all of these for
// non-stream and key the cost rules off the provider, not the status.
- r.costCheck = "FAIL"; r.costDetail = `status=${row.status}, unexpected for ${kind} cancel`; costFailures++;
+ r.costCheck = "FAIL";
+ r.costDetail = `status=${row.status}, unexpected for ${kind} cancel`;
+ costFailures++;
} else {
- const cost = Number(row.cost || 0), tokens = Number(row.total_tokens || 0);
+ const cost = Number(row.cost || 0),
+ tokens = Number(row.total_tokens || 0);
// Strict cost-presence only where usage is deterministically available at the
// moment of cancel: native Anthropic streaming (input tokens in message_start).
// Non-streaming cancel billing is provider/timing-dependent (the upstream call
// may or may not have completed), so we report it but don't hard-require it.
if (!r.nonStream && EARLY_USAGE_PROVIDERS.has(r.provider)) {
if (!(cost > 0 && tokens > 0)) {
- r.costCheck = "FAIL"; r.costDetail = `cancelled ${r.provider} ${kind} logged no cost (cost=$${cost} tokens=${tokens})`; costFailures++;
+ r.costCheck = "FAIL";
+ r.costDetail = `cancelled ${r.provider} ${kind} logged no cost (cost=$${cost} tokens=${tokens})`;
+ costFailures++;
} else {
const v = costAccuracyVerdict(sheet, row, cost, tokens, kind);
if (v.fail) costFailures++;
- r.costDetail = v.detail; r.costCheck = v.verdict;
+ r.costDetail = v.detail;
+ r.costCheck = v.verdict;
}
} else {
// Presence not required. If a cost WAS recorded, still verify its accuracy.
if (cost > 0 && tokens > 0) {
const v = costAccuracyVerdict(sheet, row, cost, tokens, kind);
if (v.fail) costFailures++;
- r.costDetail = v.detail; r.costCheck = v.verdict;
+ r.costDetail = v.detail;
+ r.costCheck = v.verdict;
} else {
- r.costCheck = "PASS"; r.costDetail = `status=${row.status} cost=$${cost} (cost-presence not required for ${r.provider} ${kind})`;
+ r.costCheck = "PASS";
+ r.costDetail = `status=${row.status} cost=$${cost} (cost-presence not required for ${r.provider} ${kind})`;
}
}
}
- console.error(`[stream-cancel] cost ${r.provider} (${kind}): ${r.costCheck}${r.costDetail ? " — " + r.costDetail : ""}`);
+ console.error(
+ `[stream-cancel] cost ${r.provider} (${kind}): ${r.costCheck}${r.costDetail ? " — " + r.costDetail : ""}`,
+ );
}
await db.close();
}
@@ -439,8 +525,12 @@ report.costFailures = costFailures;
writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`);
if (report.failed > 0 || costFailures > 0) {
- console.error(`[stream-cancel] ${report.failed}/${report.total} stream case(s) failed, ${costFailures} cost check(s) failed; report: ${out}`);
+ console.error(
+ `[stream-cancel] ${report.failed}/${report.total} stream case(s) failed, ${costFailures} cost check(s) failed; report: ${out}`,
+ );
process.exit(1);
}
-console.error(`[stream-cancel] all ${report.total} case(s) passed (incl. cost checks); report: ${out}`);
+console.error(
+ `[stream-cancel] all ${report.total} case(s) passed (incl. cost checks); report: ${out}`,
+);
\ No newline at end of file
diff --git a/transports/bifrost-http/handlers/integrations.go b/transports/bifrost-http/handlers/integrations.go
index 463e4adf78c..a032f92b26c 100644
--- a/transports/bifrost-http/handlers/integrations.go
+++ b/transports/bifrost-http/handlers/integrations.go
@@ -34,6 +34,7 @@ func NewIntegrationHandler(client *bifrost.Bifrost, handlerStore lib.HandlerStor
integrations.NewBedrockRouter(client, handlerStore, logger),
// passthrough routers
integrations.NewGenAIPassthroughRouter(client, handlerStore, logger),
+ integrations.NewChatGPTPassthroughRouter(client, handlerStore, logger),
integrations.NewOpenAIPassthroughRouter(client, handlerStore, logger),
integrations.NewAnthropicPassthroughRouter(client, handlerStore, logger),
integrations.NewAzurePassthroughRouter(client, handlerStore, logger),
diff --git a/transports/bifrost-http/handlers/logging.go b/transports/bifrost-http/handlers/logging.go
index 4edb1f76b24..638c0582968 100644
--- a/transports/bifrost-http/handlers/logging.go
+++ b/transports/bifrost-http/handlers/logging.go
@@ -24,14 +24,16 @@ import (
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
"github.com/valyala/fasthttp"
"golang.org/x/sync/errgroup"
+ "gorm.io/gorm"
)
// LoggingHandler manages HTTP requests for logging operations
type LoggingHandler struct {
- logManager logging.LogManager
- redactedKeysManager RedactedKeysManager
- config *lib.Config
- logRedactionMappingResolver LogRedactionMappingResolver
+ logManager logging.LogManager
+ redactedKeysManager RedactedKeysManager
+ config *lib.Config
+ logRedactionMappingResolver LogRedactionMappingResolver
+ mcpLogRedactionMappingResolver MCPLogRedactionMappingResolver
// filterDataCache memoizes /api/logs/filterdata response bodies. Filter
// dropdowns don't need request-fresh data and the underlying matview-backed
@@ -180,6 +182,8 @@ const (
filterDimRoutingRules = "routing_rules"
filterDimRoutingEngines = "routing_engines"
filterDimStopReasons = "stop_reasons"
+ filterDimApps = "apps"
+ filterDimUserAgents = "user_agents"
filterDimTeams = "teams"
filterDimCustomers = "customers"
filterDimUsers = "users"
@@ -191,18 +195,20 @@ const (
const (
mcpFilterDimToolNames = "tool_names"
mcpFilterDimServerLabels = "server_labels"
+ mcpFilterDimApps = "apps"
+ mcpFilterDimUserAgents = "user_agents"
mcpFilterDimVirtualKeys = "virtual_keys"
)
var allFilterDimensions = []string{
filterDimModels, filterDimAliases, filterDimSelectedKeys, filterDimVirtualKeys,
- filterDimRoutingRules, filterDimRoutingEngines, filterDimStopReasons,
- filterDimTeams, filterDimCustomers, filterDimUsers, filterDimBusinessUnits,
- filterDimMetadataKeys,
+ filterDimRoutingRules, filterDimRoutingEngines, filterDimStopReasons, filterDimApps,
+ filterDimUserAgents, filterDimTeams, filterDimCustomers, filterDimUsers,
+ filterDimBusinessUnits, filterDimMetadataKeys,
}
var allMCPFilterDimensions = []string{
- mcpFilterDimToolNames, mcpFilterDimServerLabels, mcpFilterDimVirtualKeys,
+ mcpFilterDimToolNames, mcpFilterDimServerLabels, mcpFilterDimApps, mcpFilterDimUserAgents, mcpFilterDimVirtualKeys,
}
// parseFilterDimensions returns the requested subset of dimensions in a
@@ -326,6 +332,14 @@ type LogRedactionMappingResolver interface {
ResolveLogRedactionMapping(ctx *fasthttp.RequestCtx, log *logstore.Log) (*schemas.RedactionMapsByPhase, error)
}
+// MCPLogRedactionMappingResolver optionally exposes decoded redaction mappings on MCP log-detail responses.
+type MCPLogRedactionMappingResolver interface {
+ // ResolveMCPLogRedactionMapping returns phase-scoped placeholder-to-original mappings when the caller may reveal them.
+ // Implementations should return nil, nil when the caller is not authorized or no mapping is available.
+ // Errors are treated as reveal-data failures only; the base MCP log detail response is still served.
+ ResolveMCPLogRedactionMapping(ctx *fasthttp.RequestCtx, log *logstore.MCPToolLog) (*schemas.RedactionMapsByPhase, error)
+}
+
// NewLoggingHandler creates a new logging handler instance
func NewLoggingHandler(logManager logging.LogManager, redactedKeysManager RedactedKeysManager, config *lib.Config) *LoggingHandler {
return &LoggingHandler{
@@ -340,6 +354,11 @@ func (h *LoggingHandler) SetLogRedactionMappingResolver(resolver LogRedactionMap
h.logRedactionMappingResolver = resolver
}
+// SetMCPLogRedactionMappingResolver wires the optional resolver used by Enterprise MCP log-detail reads.
+func (h *LoggingHandler) SetMCPLogRedactionMappingResolver(resolver MCPLogRedactionMappingResolver) {
+ h.mcpLogRedactionMappingResolver = resolver
+}
+
func (h *LoggingHandler) shouldHideDeletedVirtualKeysInFilters() bool {
if h == nil || h.config == nil {
return false
@@ -353,6 +372,10 @@ func (h *LoggingHandler) RegisterRoutes(r *router.Router, middlewares ...schemas
r.GET("/api/logs", lib.ChainMiddlewares(h.getLogs, middlewares...))
r.GET("/api/logs/sessions/{session_id}/summary", lib.ChainMiddlewares(h.getLogSessionSummaryByID, middlewares...))
r.GET("/api/logs/sessions/{session_id}", lib.ChainMiddlewares(h.getLogSessionByID, middlewares...))
+ r.GET("/api/logs/user-agent-mappings", lib.ChainMiddlewares(h.listUserAgentMappings, middlewares...))
+ r.POST("/api/logs/user-agent-mappings", lib.ChainMiddlewares(h.createUserAgentMapping, middlewares...))
+ r.PUT("/api/logs/user-agent-mappings/{id}", lib.ChainMiddlewares(h.updateUserAgentMapping, middlewares...))
+ r.DELETE("/api/logs/user-agent-mappings/{id}", lib.ChainMiddlewares(h.deleteUserAgentMapping, middlewares...))
r.GET("/api/logs/{id}", lib.ChainMiddlewares(h.getLogByID, middlewares...))
r.GET("/api/logs/stats", lib.ChainMiddlewares(h.getLogsStats, middlewares...))
r.GET("/api/logs/histogram", lib.ChainMiddlewares(h.getLogsHistogram, middlewares...))
@@ -389,6 +412,77 @@ func (h *LoggingHandler) RegisterRoutes(r *router.Router, middlewares ...schemas
r.DELETE("/api/mcp-logs", lib.ChainMiddlewares(h.deleteMCPLogs, middlewares...))
}
+func (h *LoggingHandler) listUserAgentMappings(ctx *fasthttp.RequestCtx) {
+ mappings, err := h.logManager.ListUserAgentMappings(ctx)
+ if err != nil {
+ SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to list user agent mappings: %v", err))
+ return
+ }
+ SendJSON(ctx, map[string]any{"mappings": mappings})
+}
+
+func (h *LoggingHandler) createUserAgentMapping(ctx *fasthttp.RequestCtx) {
+ var mapping logstore.UserAgentMapping
+ if err := sonic.Unmarshal(ctx.PostBody(), &mapping); err != nil {
+ SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("invalid request format: %v", err))
+ return
+ }
+ created, err := h.logManager.CreateUserAgentMapping(ctx, &mapping)
+ if err != nil {
+ if errors.Is(err, logging.ErrInvalidUserAgentMapping) {
+ SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("failed to create user agent mapping: %v", err))
+ return
+ }
+ SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to create user agent mapping: %v", err))
+ return
+ }
+ SendJSON(ctx, created)
+}
+
+func (h *LoggingHandler) updateUserAgentMapping(ctx *fasthttp.RequestCtx) {
+ id, ok := ctx.UserValue("id").(string)
+ if !ok || strings.TrimSpace(id) == "" {
+ SendError(ctx, fasthttp.StatusBadRequest, "id is required")
+ return
+ }
+ var mapping logstore.UserAgentMapping
+ if err := sonic.Unmarshal(ctx.PostBody(), &mapping); err != nil {
+ SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("invalid request format: %v", err))
+ return
+ }
+ updated, err := h.logManager.UpdateUserAgentMapping(ctx, id, &mapping)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ SendError(ctx, fasthttp.StatusNotFound, "user agent mapping not found")
+ return
+ }
+ if errors.Is(err, logging.ErrInvalidUserAgentMapping) {
+ SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("failed to update user agent mapping: %v", err))
+ return
+ }
+ SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to update user agent mapping: %v", err))
+ return
+ }
+ SendJSON(ctx, updated)
+}
+
+func (h *LoggingHandler) deleteUserAgentMapping(ctx *fasthttp.RequestCtx) {
+ id, ok := ctx.UserValue("id").(string)
+ if !ok || strings.TrimSpace(id) == "" {
+ SendError(ctx, fasthttp.StatusBadRequest, "id is required")
+ return
+ }
+ if err := h.logManager.DeleteUserAgentMapping(ctx, id); err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ SendError(ctx, fasthttp.StatusNotFound, "user agent mapping not found")
+ return
+ }
+ SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to delete user agent mapping: %v", err))
+ return
+ }
+ SendJSON(ctx, map[string]any{"success": true})
+}
+
// getLogSessionByID handles GET /api/logs/sessions/{session_id} - Get logs in a single session.
func (h *LoggingHandler) getLogSessionByID(ctx *fasthttp.RequestCtx) {
rawSessionID, ok := ctx.UserValue("session_id").(string)
@@ -561,6 +655,12 @@ func (h *LoggingHandler) getLogs(ctx *fasthttp.RequestCtx) {
if stopReasons := string(ctx.QueryArgs().Peek("stop_reasons")); stopReasons != "" {
filters.StopReasons = parseCommaSeparated(stopReasons)
}
+ if userAgents := string(ctx.QueryArgs().Peek("user_agents")); userAgents != "" {
+ filters.UserAgents = parseStringArrayParam(userAgents)
+ }
+ if apps := string(ctx.QueryArgs().Peek("apps")); apps != "" {
+ filters.Apps = parseStringArrayParam(apps)
+ }
if startTime := string(ctx.QueryArgs().Peek("start_time")); startTime != "" {
if t, err := time.Parse(time.RFC3339Nano, startTime); err == nil {
filters.StartTime = &t
@@ -811,6 +911,12 @@ func (h *LoggingHandler) getLogsStats(ctx *fasthttp.RequestCtx) {
if stopReasons := string(ctx.QueryArgs().Peek("stop_reasons")); stopReasons != "" {
filters.StopReasons = parseCommaSeparated(stopReasons)
}
+ if userAgents := string(ctx.QueryArgs().Peek("user_agents")); userAgents != "" {
+ filters.UserAgents = parseStringArrayParam(userAgents)
+ }
+ if apps := string(ctx.QueryArgs().Peek("apps")); apps != "" {
+ filters.Apps = parseStringArrayParam(apps)
+ }
if startTime := string(ctx.QueryArgs().Peek("start_time")); startTime != "" {
if t, err := time.Parse(time.RFC3339Nano, startTime); err == nil {
filters.StartTime = &t
@@ -970,6 +1076,12 @@ func parseHistogramFilters(ctx *fasthttp.RequestCtx) *logstore.SearchFilters {
if stopReasons := string(ctx.QueryArgs().Peek("stop_reasons")); stopReasons != "" {
filters.StopReasons = parseCommaSeparated(stopReasons)
}
+ if userAgents := string(ctx.QueryArgs().Peek("user_agents")); userAgents != "" {
+ filters.UserAgents = parseStringArrayParam(userAgents)
+ }
+ if apps := string(ctx.QueryArgs().Peek("apps")); apps != "" {
+ filters.Apps = parseStringArrayParam(apps)
+ }
if startTime := string(ctx.QueryArgs().Peek("start_time")); startTime != "" {
if t, err := time.Parse(time.RFC3339Nano, startTime); err == nil {
filters.StartTime = &t
@@ -1569,6 +1681,8 @@ func (h *LoggingHandler) getAvailableFilterData(ctx *fasthttp.RequestCtx) {
routingRules []logging.KeyPair
routingEngines []string
stopReasons []string
+ apps []string
+ userAgents []string
teams []logging.KeyPair
customers []logging.KeyPair
users []logging.KeyPair
@@ -1666,6 +1780,30 @@ func (h *LoggingHandler) getAvailableFilterData(ctx *fasthttp.RequestCtx) {
return nil
})
}
+ if _, ok := want[filterDimApps]; ok {
+ g.Go(func() error {
+ result, err := h.logManager.GetAvailableApps(gCtx, defaultFilterDataLimit, query)
+ if err != nil {
+ return err
+ }
+ mu.Lock()
+ apps = result
+ mu.Unlock()
+ return nil
+ })
+ }
+ if _, ok := want[filterDimUserAgents]; ok {
+ g.Go(func() error {
+ result, err := h.logManager.GetAvailableUserAgents(gCtx, defaultFilterDataLimit, query)
+ if err != nil {
+ return err
+ }
+ mu.Lock()
+ userAgents = result
+ mu.Unlock()
+ return nil
+ })
+ }
if _, ok := want[filterDimTeams]; ok {
g.Go(func() error {
result, err := h.logManager.GetAvailableTeams(gCtx, defaultFilterDataLimit, query)
@@ -1840,6 +1978,12 @@ func (h *LoggingHandler) getAvailableFilterData(ctx *fasthttp.RequestCtx) {
if _, ok := want[filterDimStopReasons]; ok {
payload[filterDimStopReasons] = stopReasons
}
+ if _, ok := want[filterDimApps]; ok {
+ payload[filterDimApps] = apps
+ }
+ if _, ok := want[filterDimUserAgents]; ok {
+ payload[filterDimUserAgents] = userAgents
+ }
if _, ok := want[filterDimTeams]; ok {
payload[filterDimTeams] = teams
}
@@ -2149,6 +2293,31 @@ func parseCommaSeparated(s string) []string {
return result
}
+// parseStringArrayParam decodes a list-valued query param losslessly. Values such
+// as raw User-Agent strings legitimately contain commas (e.g.
+// "Mozilla/5.0 ... KHTML, like Gecko"), so newer clients send a JSON array which
+// survives those commas intact. For backward compatibility with older clients (and
+// other list params) it falls back to comma-splitting when the value is not a JSON
+// array.
+func parseStringArrayParam(s string) []string {
+ if s == "" {
+ return nil
+ }
+ if trimmed := strings.TrimSpace(s); strings.HasPrefix(trimmed, "[") {
+ var values []string
+ if err := sonic.Unmarshal([]byte(trimmed), &values); err == nil {
+ result := make([]string, 0, len(values))
+ for _, v := range values {
+ if t := strings.TrimSpace(v); t != "" {
+ result = append(result, t)
+ }
+ }
+ return result
+ }
+ }
+ return parseCommaSeparated(s)
+}
+
// parseMetadataFilters extracts metadata_* query params and sets them on the filters.
func parseMetadataFilters(ctx *fasthttp.RequestCtx, filters *logstore.SearchFilters) {
var metadataFilters map[string]string
@@ -2201,6 +2370,12 @@ func parseMCPFiltersAndPagination(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLo
if llmRequestIDs := string(ctx.QueryArgs().Peek("llm_request_ids")); llmRequestIDs != "" {
filters.LLMRequestIDs = parseCommaSeparated(llmRequestIDs)
}
+ if userAgents := string(ctx.QueryArgs().Peek("user_agents")); userAgents != "" {
+ filters.UserAgents = parseStringArrayParam(userAgents)
+ }
+ if apps := string(ctx.QueryArgs().Peek("apps")); apps != "" {
+ filters.Apps = parseStringArrayParam(apps)
+ }
var startTimeErr, endTimeErr error
if startTime := string(ctx.QueryArgs().Peek("start_time")); startTime != "" {
t, err := time.Parse(time.RFC3339Nano, startTime)
@@ -2321,6 +2496,12 @@ func parseMCPFilters(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLogSearchFilter
if llmRequestIDs := string(ctx.QueryArgs().Peek("llm_request_ids")); llmRequestIDs != "" {
filters.LLMRequestIDs = parseCommaSeparated(llmRequestIDs)
}
+ if userAgents := string(ctx.QueryArgs().Peek("user_agents")); userAgents != "" {
+ filters.UserAgents = parseStringArrayParam(userAgents)
+ }
+ if apps := string(ctx.QueryArgs().Peek("apps")); apps != "" {
+ filters.Apps = parseStringArrayParam(apps)
+ }
var timeParseErr error
if startTime := string(ctx.QueryArgs().Peek("start_time")); startTime != "" {
t, err := time.Parse(time.RFC3339Nano, startTime)
@@ -2436,6 +2617,14 @@ func (h *LoggingHandler) getMCPLogByID(ctx *fasthttp.RequestCtx) {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get MCP log: %v", err))
return
}
+ if h.mcpLogRedactionMappingResolver != nil && log.RedactionMapping != "" {
+ mapping, resolveErr := h.mcpLogRedactionMappingResolver.ResolveMCPLogRedactionMapping(ctx, log)
+ if resolveErr != nil {
+ logger.Error("failed to resolve redaction mapping for MCP log %s: %v", id, resolveErr)
+ } else if mapping != nil && mapping.HasReplacements() {
+ log.RevealRedactionMapping = mapping
+ }
+ }
if log.VirtualKeyID != nil && log.VirtualKeyName != nil && *log.VirtualKeyID != "" && *log.VirtualKeyName != "" {
redactedVirtualKeys := h.redactedKeysManager.GetAllRedactedVirtualKeys(ctx, []string{*log.VirtualKeyID})
@@ -2515,6 +2704,28 @@ func (h *LoggingHandler) getMCPLogsFilterData(ctx *fasthttp.RequestCtx) {
}
}
+ var apps []string
+ if _, ok := want[mcpFilterDimApps]; ok {
+ var err error
+ apps, err = h.logManager.GetAvailableMCPApps(ctx, defaultFilterDataLimit, query)
+ if err != nil {
+ logger.Error("failed to get available MCP apps: %v", err)
+ SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get available MCP apps: %v", err))
+ return
+ }
+ }
+
+ var userAgents []string
+ if _, ok := want[mcpFilterDimUserAgents]; ok {
+ var err error
+ userAgents, err = h.logManager.GetAvailableMCPUserAgents(ctx, defaultFilterDataLimit, query)
+ if err != nil {
+ logger.Error("failed to get available MCP user agents: %v", err)
+ SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get available MCP user agents: %v", err))
+ return
+ }
+ }
+
var virtualKeysArray []tables.TableVirtualKey
if _, ok := want[mcpFilterDimVirtualKeys]; ok {
virtualKeys, err := h.logManager.GetAvailableMCPVirtualKeys(ctx, defaultFilterDataLimit, query)
@@ -2559,6 +2770,12 @@ func (h *LoggingHandler) getMCPLogsFilterData(ctx *fasthttp.RequestCtx) {
if _, ok := want[mcpFilterDimServerLabels]; ok {
payload[mcpFilterDimServerLabels] = serverLabels
}
+ if _, ok := want[mcpFilterDimApps]; ok {
+ payload[mcpFilterDimApps] = apps
+ }
+ if _, ok := want[mcpFilterDimUserAgents]; ok {
+ payload[mcpFilterDimUserAgents] = userAgents
+ }
if _, ok := want[mcpFilterDimVirtualKeys]; ok {
payload[mcpFilterDimVirtualKeys] = virtualKeysArray
}
diff --git a/transports/bifrost-http/handlers/logging_test.go b/transports/bifrost-http/handlers/logging_test.go
index e70a337aade..e4ab727fb1e 100644
--- a/transports/bifrost-http/handlers/logging_test.go
+++ b/transports/bifrost-http/handlers/logging_test.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -50,6 +51,63 @@ func TestShouldUseFilterDataCacheRejectsScopedContext(t *testing.T) {
}
}
+// TestGetMCPLogByIDRedactionMapping verifies raw mappings stay hidden and only resolver-approved mappings are returned.
+func TestGetMCPLogByIDRedactionMapping(t *testing.T) {
+ SetLogger(&mockLogger{})
+ revealed := &schemas.RedactionMapsByPhase{
+ Input: map[string]string{"EMAIL-1": "revealed@example.com"},
+ }
+ tests := []struct {
+ name string
+ resolver *staticMCPLogRedactionResolver
+ wantMapping bool
+ wantCalls int
+ }{
+ {name: "no resolver"},
+ {name: "authorized mapping", resolver: &staticMCPLogRedactionResolver{mapping: revealed}, wantMapping: true, wantCalls: 1},
+ {name: "resolver error", resolver: &staticMCPLogRedactionResolver{err: errors.New("decode failed")}, wantCalls: 1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ manager := &dashboardLogManager{mcpLog: &logstore.MCPToolLog{
+ ID: "mcp-1",
+ RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`,
+ }}
+ handler := &LoggingHandler{logManager: manager}
+ if tt.resolver != nil {
+ handler.SetMCPLogRedactionMappingResolver(tt.resolver)
+ }
+ ctx := &fasthttp.RequestCtx{}
+ ctx.SetUserValue("id", "mcp-1")
+
+ handler.getMCPLogByID(ctx)
+
+ if ctx.Response.StatusCode() != fasthttp.StatusOK {
+ t.Fatalf("status = %d, want %d", ctx.Response.StatusCode(), fasthttp.StatusOK)
+ }
+ var response struct {
+ RedactionMapping *schemas.RedactionMapsByPhase `json:"redaction_mapping"`
+ }
+ if err := json.Unmarshal(ctx.Response.Body(), &response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if bytes.Contains(ctx.Response.Body(), []byte("private@example.com")) {
+ t.Fatalf("raw persisted mapping leaked in response: %s", ctx.Response.Body())
+ }
+ if tt.wantMapping != (response.RedactionMapping != nil) {
+ t.Fatalf("redaction mapping present = %t, want %t", response.RedactionMapping != nil, tt.wantMapping)
+ }
+ if tt.wantMapping && response.RedactionMapping.Input["EMAIL-1"] != "revealed@example.com" {
+ t.Fatalf("revealed mapping = %#v", response.RedactionMapping)
+ }
+ if tt.resolver != nil && tt.resolver.calls != tt.wantCalls {
+ t.Fatalf("resolver calls = %d, want %d", tt.resolver.calls, tt.wantCalls)
+ }
+ })
+ }
+}
+
// TestShouldCacheFilterDimensions_NarrowsToRawScans verifies the cache is spent
// only where it saves real work. Matview-backed dimensions are indexed lookups
// and a cache entry serves exactly one caller, so they are not worth caching;
@@ -374,6 +432,7 @@ func (s *fakeSidekiqStore) ListClaimableSidekiqJobs(ctx context.Context, staleBe
type dashboardLogManager struct {
failStats bool
+ mcpLog *logstore.MCPToolLog
lastLLMFilters logstore.SearchFilters
lastMCPFilters logstore.MCPToolLogSearchFilters
lastRecalculateFilters logstore.SearchFilters
@@ -502,7 +561,11 @@ func (m *dashboardLogManager) RunCostRecalcJob(ctx context.Context, metaJSON str
return metaJSON, nil
}
func (m *dashboardLogManager) GetMCPToolLog(ctx context.Context, id string) (*logstore.MCPToolLog, error) {
- return nil, nil
+ if m.mcpLog == nil {
+ return nil, nil
+ }
+ entry := *m.mcpLog
+ return &entry, nil
}
func (m *dashboardLogManager) SearchMCPToolLogs(ctx context.Context, filters *logstore.MCPToolLogSearchFilters, pagination *logstore.PaginationOptions) (*logstore.MCPToolLogSearchResult, error) {
return nil, nil
@@ -529,4 +592,50 @@ func (m *dashboardLogManager) GetMCPCostHistogram(ctx context.Context, filters l
func (m *dashboardLogManager) GetMCPTopTools(ctx context.Context, filters logstore.MCPToolLogSearchFilters, limit int) (*logstore.MCPTopToolsResult, error) {
return &logstore.MCPTopToolsResult{}, nil
}
+
func (m *dashboardLogManager) DeleteMCPToolLogs(ctx context.Context, ids []string) error { return nil }
+
+// staticMCPLogRedactionResolver records calls and returns a configured reveal result.
+type staticMCPLogRedactionResolver struct {
+ mapping *schemas.RedactionMapsByPhase
+ err error
+ calls int
+}
+
+// ResolveMCPLogRedactionMapping returns the configured test result.
+func (r *staticMCPLogRedactionResolver) ResolveMCPLogRedactionMapping(_ *fasthttp.RequestCtx, _ *logstore.MCPToolLog) (*schemas.RedactionMapsByPhase, error) {
+ r.calls++
+ return r.mapping, r.err
+}
+
+func (m *dashboardLogManager) CreateUserAgentMapping(ctx context.Context, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) {
+ return nil, nil
+}
+
+func (m *dashboardLogManager) DeleteUserAgentMapping(ctx context.Context, id string) error {
+ return nil
+}
+
+func (m *dashboardLogManager) UpdateUserAgentMapping(ctx context.Context, id string, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) {
+ return nil, nil
+}
+
+func (m *dashboardLogManager) ListUserAgentMappings(ctx context.Context) ([]logstore.UserAgentMapping, error) {
+ return nil, nil
+}
+
+func (m *dashboardLogManager) GetAvailableUserAgents(ctx context.Context, _ int, _ string) ([]string, error) {
+ return nil, nil
+}
+
+func (m *dashboardLogManager) GetAvailableApps(ctx context.Context, _ int, _ string) ([]string, error) {
+ return nil, nil
+}
+
+func (m *dashboardLogManager) GetAvailableMCPApps(ctx context.Context, _ int, _ string) ([]string, error) {
+ return nil, nil
+}
+
+func (m *dashboardLogManager) GetAvailableMCPUserAgents(ctx context.Context, _ int, _ string) ([]string, error) {
+ return nil, nil
+}
diff --git a/transports/bifrost-http/handlers/mcpserver.go b/transports/bifrost-http/handlers/mcpserver.go
index 19ac43b6095..856a9762df4 100644
--- a/transports/bifrost-http/handlers/mcpserver.go
+++ b/transports/bifrost-http/handlers/mcpserver.go
@@ -390,6 +390,7 @@ func (h *MCPServerHandler) syncServer(server *server.MCPServer, availableTools [
toolName := tool.Function.Name
handler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ logger.Info("[mcp-server] tool handler start tool=%q arg_count=%d", toolName, len(request.GetArguments()))
// Inject tool filter into execution context if present
if toolFilter != nil {
ctx = context.WithValue(ctx, schemas.MCPContextKeyIncludeTools, toolFilter)
@@ -413,6 +414,7 @@ func (h *MCPServerHandler) syncServer(server *server.MCPServer, availableTools [
// Execute the tool via tool executor
toolMessage, err := h.toolManager.ExecuteChatMCPTool(ctx, &toolCall)
if err != nil {
+ logger.Info("[mcp-server] tool handler error tool=%q error=%s", toolName, bifrost.GetErrorMessage(err))
if authReq := err.ExtraFields.MCPAuthRequired; authReq != nil {
// Two surfaces share this error: per-user OAuth uses
// AuthorizeURL (the upstream provider's authorize page);
@@ -436,6 +438,7 @@ func (h *MCPServerHandler) syncServer(server *server.MCPServer, availableTools [
}
return mcp.NewToolResultError(fmt.Sprintf("Tool execution failed: %v", bifrost.GetErrorMessage(err))), nil
}
+ logger.Info("[mcp-server] tool handler success tool=%q", toolName)
// Extract content from tool message
var resultText string
diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go
index 82e5713ed37..5860985d5b8 100644
--- a/transports/bifrost-http/handlers/middlewares.go
+++ b/transports/bifrost-http/handlers/middlewares.go
@@ -20,12 +20,13 @@ import (
"github.com/maximhq/bifrost/framework/encrypt"
"github.com/maximhq/bifrost/framework/temptoken"
"github.com/maximhq/bifrost/framework/tracing"
+ "github.com/maximhq/bifrost/plugins/governance"
"github.com/maximhq/bifrost/transports/bifrost-http/integrations"
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
"github.com/valyala/fasthttp"
)
-var loggingSkipPaths = []string{"/health", "/_next", "/api/dev"}
+var loggingSkipPaths = []string{"/health", "/_next", "/api/dev/"}
var realtimeTransportPaths = buildRealtimeTransportPathSet()
// SecurityHeadersMiddleware sets security-related HTTP headers on every response.
@@ -564,15 +565,10 @@ func runTransportPostHooksCaptured(capturedReq *schemas.HTTPRequest, capturedRes
defer schemas.ReleaseHTTPRequest(req)
req.Method = capturedReq.Method
req.Path = capturedReq.Path
- for k, v := range capturedReq.Headers {
- req.Headers[k] = v
- }
- for k, v := range capturedReq.Query {
- req.Query[k] = v
- }
- for k, v := range capturedReq.PathParams {
- req.PathParams[k] = v
- }
+
+ maps.Copy(req.Headers, capturedReq.Headers)
+ maps.Copy(req.Query, capturedReq.Query)
+ maps.Copy(req.PathParams, capturedReq.PathParams)
httpResp := schemas.AcquireHTTPResponse()
defer schemas.ReleaseHTTPResponse(httpResp)
@@ -785,6 +781,43 @@ func isRealtimeTransportEndpoint(path string) bool {
return ok
}
+func hasVirtualKeyCredential(ctx *fasthttp.RequestCtx) bool {
+ // x-bf-vk mirrors the canonical VK parser (lib.ConvertToBifrostContext): any
+ // non-empty value is accepted, no sk-bf- prefix required — the header itself
+ // is the signal, not the value shape.
+ if vkHeader := strings.TrimSpace(string(ctx.Request.Header.Peek(string(schemas.BifrostContextKeyVirtualKey)))); vkHeader != "" {
+ return true
+ }
+
+ authHeader := strings.TrimSpace(string(ctx.Request.Header.Peek("Authorization")))
+ if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
+ token := strings.TrimSpace(authHeader[7:])
+ if token != "" && strings.HasPrefix(strings.ToLower(token), governance.VirtualKeyPrefix) {
+ return true
+ }
+ }
+
+ if apiKey := strings.TrimSpace(string(ctx.Request.Header.Peek("x-api-key"))); apiKey != "" {
+ if strings.HasPrefix(strings.ToLower(apiKey), governance.VirtualKeyPrefix) {
+ return true
+ }
+ }
+
+ if apiKey := strings.TrimSpace(string(ctx.Request.Header.Peek("x-goog-api-key"))); apiKey != "" {
+ if strings.HasPrefix(strings.ToLower(apiKey), governance.VirtualKeyPrefix) {
+ return true
+ }
+ }
+
+ if apiKey := strings.TrimSpace(string(ctx.Request.Header.Peek("api-key"))); apiKey != "" {
+ if strings.HasPrefix(strings.ToLower(apiKey), governance.VirtualKeyPrefix) {
+ return true
+ }
+ }
+
+ return false
+}
+
// AuthMiddleware is a middleware that handles authentication for the API.
type AuthMiddleware struct {
store configstore.ConfigStore
@@ -886,7 +919,7 @@ func (m *AuthMiddleware) tryTempTokenOrUnauthorized(ctx *fasthttp.RequestCtx, ne
func (m *AuthMiddleware) InferenceMiddleware() schemas.BifrostHTTPMiddleware {
return m.middleware(func(authConfig *configstore.AuthConfig, url string) bool {
return true
- })
+ }, true)
}
// APIMiddleware is for API requests if authConfig is set, it will verify authentication based on the request type.
@@ -921,7 +954,10 @@ func (m *AuthMiddleware) APIMiddleware() schemas.BifrostHTTPMiddleware {
// it would whitelist /api/oauth/per-user/* (auth-via-temp-token) and
// /api/oauth/config/* (admin-only) and bypass the temp-token fallback
// in tryTempTokenOrUnauthorized.
- "/api/dev",
+ // Trailing slash is required: the dev routes live under "/api/dev/pprof".
+ // A bare "/api/dev" prefix also matches "/api/devices" (and any other
+ // "/api/dev*" route), which would silently bypass auth on those routes.
+ "/api/dev/",
// Skills serving endpoints are public — marketplace URLs cannot carry
// credentials securely. Management endpoints under /api/skills (without
// /serve/) remain authenticated.
@@ -951,11 +987,11 @@ func (m *AuthMiddleware) APIMiddleware() schemas.BifrostHTTPMiddleware {
}
}
return false
- })
+ }, false)
}
// middleware is the core authentication middleware that checks if the request should be authenticated or not.
-func (m *AuthMiddleware) middleware(shouldSkip func(*configstore.AuthConfig, string) bool) schemas.BifrostHTTPMiddleware {
+func (m *AuthMiddleware) middleware(shouldSkip func(*configstore.AuthConfig, string) bool, allowVirtualKeyAuth bool) schemas.BifrostHTTPMiddleware {
return func(next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
// We will first check if its API key auth
@@ -986,6 +1022,10 @@ func (m *AuthMiddleware) middleware(shouldSkip func(*configstore.AuthConfig, str
next(ctx)
return
}
+ if allowVirtualKeyAuth && hasVirtualKeyCredential(ctx) {
+ next(ctx)
+ return
+ }
// If inference is disabled, we skip authorization
// Get the authorization header
authorization := string(ctx.Request.Header.Peek("Authorization"))
diff --git a/transports/bifrost-http/handlers/middlewares_test.go b/transports/bifrost-http/handlers/middlewares_test.go
index 97df8a12705..b590f772828 100644
--- a/transports/bifrost-http/handlers/middlewares_test.go
+++ b/transports/bifrost-http/handlers/middlewares_test.go
@@ -773,6 +773,52 @@ func TestAuthMiddleware_WhitelistedRoutes(t *testing.T) {
}
}
+// TestAuthMiddleware_APIMiddleware_DevPrefixDoesNotMatchDevices guards against the
+// prefix-matching bug where the "/api/dev" whitelist prefix (intended for the dev pprof
+// routes under "/api/dev/pprof") also matched "/api/devices", silently bypassing auth on
+// the edge-control devices route. With the trailing-slash fix, "/api/dev/pprof" must still
+// bypass auth while "/api/devices" must NOT.
+func TestAuthMiddleware_APIMiddleware_DevPrefixDoesNotMatchDevices(t *testing.T) {
+ SetLogger(&mockLogger{})
+
+ am := &AuthMiddleware{}
+ am.UpdateAuthConfig(&configstore.AuthConfig{
+ AdminUserName: schemas.NewSecretVar("admin"),
+ AdminPassword: schemas.NewSecretVar("hashedpassword"),
+ IsEnabled: true,
+ })
+
+ cases := []struct {
+ name string
+ uri string
+ wantNextCalled bool // true => route is whitelisted (auth bypassed)
+ }{
+ {name: "dev pprof is whitelisted", uri: "/api/dev/pprof", wantNextCalled: true},
+ {name: "dev pprof subpath is whitelisted", uri: "/api/dev/pprof/goroutines", wantNextCalled: true},
+ {name: "devices is NOT whitelisted", uri: "/api/devices?limit=25&offset=0", wantNextCalled: false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := &fasthttp.RequestCtx{}
+ ctx.Request.SetRequestURI(tc.uri)
+
+ nextCalled := false
+ next := func(ctx *fasthttp.RequestCtx) { nextCalled = true }
+
+ am.APIMiddleware()(next)(ctx)
+
+ if nextCalled != tc.wantNextCalled {
+ t.Fatalf("route %q: nextCalled = %v, want %v (status %d)", tc.uri, nextCalled, tc.wantNextCalled, ctx.Response.StatusCode())
+ }
+ // A non-whitelisted route with no credentials must be rejected, not passed through.
+ if !tc.wantNextCalled && ctx.Response.StatusCode() != fasthttp.StatusUnauthorized {
+ t.Fatalf("route %q: expected 401 for unauthenticated non-whitelisted route, got %d", tc.uri, ctx.Response.StatusCode())
+ }
+ })
+ }
+}
+
func TestAuthMiddleware_InferenceMiddleware_RealtimeTransportBypassesAuth(t *testing.T) {
SetLogger(&mockLogger{})
diff --git a/transports/bifrost-http/handlers/ui.go b/transports/bifrost-http/handlers/ui.go
index facf37ddef1..98c757431d1 100644
--- a/transports/bifrost-http/handlers/ui.go
+++ b/transports/bifrost-http/handlers/ui.go
@@ -6,6 +6,7 @@ import (
"path"
"path/filepath"
"strings"
+ "time"
"github.com/fasthttp/router"
"github.com/maximhq/bifrost/core/schemas"
@@ -13,16 +14,32 @@ import (
"github.com/valyala/fasthttp"
)
+const uiDevServerAddr = "localhost:3000"
+
// UIHandler handles UI routes.
type UIHandler struct {
uiContent embed.FS
+ // uiDevClient proxies dashboard requests to the local Vite dev server.
+ // It is only set when dev mode is enabled (see NewUIHandler); nil otherwise.
+ uiDevClient *fasthttp.HostClient
}
// NewUIHandler creates a new UIHandler instance.
func NewUIHandler(uiContent embed.FS) *UIHandler {
- return &UIHandler{
+ h := &UIHandler{
uiContent: uiContent,
}
+ // Only wire the dev-server proxy client when running in dev mode. Timeouts
+ // guard against the local Vite server hanging dashboard requests if it is
+ // unresponsive, falling back to the embedded UI instead.
+ if IsDevMode() {
+ h.uiDevClient = &fasthttp.HostClient{
+ Addr: uiDevServerAddr,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
+ }
+ }
+ return h
}
// RegisterRoutes registers the UI routes with the provided router.
@@ -31,8 +48,12 @@ func (h *UIHandler) RegisterRoutes(router *router.Router, middlewares ...schemas
router.GET("/{filepath:*}", lib.ChainMiddlewares(h.serveDashboard, middlewares...))
}
-// ServeDashboard serves the dashboard UI.
+// serveDashboard serves the dashboard UI.
func (h *UIHandler) serveDashboard(ctx *fasthttp.RequestCtx) {
+ if IsDevMode() && h.serveDevDashboard(ctx) {
+ return
+ }
+
// Get the request path
requestPath := string(ctx.Path())
@@ -134,3 +155,32 @@ func (h *UIHandler) serveDashboard(ctx *fasthttp.RequestCtx) {
// Send the file content
ctx.SetBody(data)
}
+
+// serveDevDashboard proxies dashboard requests to the local Vite dev server.
+// Restricted to loopback clients: if the dev server happens to be bound to a
+// non-loopback address, a remote client must not be able to tunnel to
+// Vite-internal endpoints (e.g. /@fs/) via this proxy.
+func (h *UIHandler) serveDevDashboard(ctx *fasthttp.RequestCtx) bool {
+ if h.uiDevClient == nil {
+ return false
+ }
+ if !ctx.RemoteIP().IsLoopback() {
+ return false
+ }
+
+ var req fasthttp.Request
+ var resp fasthttp.Response
+ ctx.Request.CopyTo(&req)
+ req.URI().SetScheme("http")
+ req.URI().SetHost(uiDevServerAddr)
+ req.Header.SetHost(uiDevServerAddr)
+
+ if err := h.uiDevClient.Do(&req, &resp); err != nil {
+ // Dev server unreachable (e.g. Vite not running); fall back to the
+ // embedded UI by signalling the caller to serve from uiContent.
+ return false
+ }
+
+ resp.CopyTo(&ctx.Response)
+ return true
+}
diff --git a/transports/bifrost-http/integrations/passthrough.go b/transports/bifrost-http/integrations/passthrough.go
index c0195e40da6..09c57a68a87 100644
--- a/transports/bifrost-http/integrations/passthrough.go
+++ b/transports/bifrost-http/integrations/passthrough.go
@@ -4,6 +4,7 @@ import (
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
+ "github.com/valyala/fasthttp"
)
// PassthroughRouter is a catch-all router that forwards all requests directly
@@ -47,6 +48,22 @@ func NewOpenAIPassthroughRouter(client *bifrost.Bifrost, handlerStore lib.Handle
})
}
+// NewChatGPTPassthroughRouter creates a passthrough router for /chatgpt_passthrough.
+// Restricted to the Codex responses endpoint only — this is not a general-purpose
+// ChatGPT backend proxy.
+func NewChatGPTPassthroughRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *PassthroughRouter {
+ return NewPassthroughRouter(client, handlerStore, logger, &PassthroughConfig{
+ Provider: schemas.OpenAI,
+ UpstreamURL: "https://chatgpt.com",
+ StripPrefix: []string{
+ "/chatgpt_passthrough",
+ },
+ AllowedRoutes: []PassthroughRoute{
+ {Method: fasthttp.MethodPost, Path: "/chatgpt_passthrough/backend-api/codex/responses"},
+ },
+ })
+}
+
// NewAzurePassthroughRouter creates a passthrough router for /azure_passthrough.
func NewAzurePassthroughRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *PassthroughRouter {
return NewPassthroughRouter(client, handlerStore, logger, &PassthroughConfig{
diff --git a/transports/bifrost-http/integrations/router.go b/transports/bifrost-http/integrations/router.go
index 1cfbdcff556..3494e84a1c8 100644
--- a/transports/bifrost-http/integrations/router.go
+++ b/transports/bifrost-http/integrations/router.go
@@ -509,6 +509,18 @@ type PassthroughConfig struct {
Provider schemas.ModelProvider // which provider's key pool to draw from
ProviderDetector func(ctx *fasthttp.RequestCtx, model string) schemas.ModelProvider // optional: dynamic provider detection
StripPrefix []string // e.g. "/openai" — stripped before forwarding
+ UpstreamURL string // optional upstream base URL override
+ // AllowedRoutes, when non-empty, restricts the passthrough catch-all to exactly
+ // these method+path pairs instead of forwarding every request under StripPrefix.
+ AllowedRoutes []PassthroughRoute
+}
+
+// PassthroughRoute is an exact method+path pair a restricted passthrough router
+// (see PassthroughConfig.AllowedRoutes) will forward; any other request under
+// StripPrefix gets no route match (404) instead of being forwarded upstream.
+type PassthroughRoute struct {
+ Method string
+ Path string // full path including the StripPrefix, matched exactly — no wildcard
}
// LargePayloadHook is called before body parsing to detect and set up large payload streaming.
@@ -646,10 +658,17 @@ func (g *GenericRouter) RegisterRoutes(r *router.Router, middlewares ...schemas.
if g.passthroughCfg != nil {
catchAll := lib.ChainMiddlewares(g.handlePassthrough, middlewares...)
- // Register for all methods that need forwarding
- for _, method := range []string{fasthttp.MethodGet, fasthttp.MethodPost, fasthttp.MethodPut, fasthttp.MethodDelete, fasthttp.MethodPatch, fasthttp.MethodHead} {
- for _, prefix := range g.passthroughCfg.StripPrefix {
- r.Handle(method, prefix+"/{path:*}", catchAll)
+ if len(g.passthroughCfg.AllowedRoutes) > 0 {
+ // Restricted mode: only the explicitly allowed method+path pairs are forwarded.
+ for _, allowed := range g.passthroughCfg.AllowedRoutes {
+ r.Handle(strings.ToUpper(allowed.Method), allowed.Path, catchAll)
+ }
+ } else {
+ // Register for all methods that need forwarding
+ for _, method := range []string{fasthttp.MethodGet, fasthttp.MethodPost, fasthttp.MethodPut, fasthttp.MethodDelete, fasthttp.MethodPatch, fasthttp.MethodHead} {
+ for _, prefix := range g.passthroughCfg.StripPrefix {
+ r.Handle(method, prefix+"/{path:*}", catchAll)
+ }
}
}
}
@@ -3245,6 +3264,7 @@ func (g *GenericRouter) handlePassthrough(ctx *fasthttp.RequestCtx) {
Method: string(ctx.Method()),
Path: path,
RawQuery: string(ctx.URI().QueryString()),
+ UpstreamURL: cfg.UpstreamURL,
Body: body,
SafeHeaders: safeHeaders,
Provider: provider,
diff --git a/transports/bifrost-http/integrations/router_test.go b/transports/bifrost-http/integrations/router_test.go
index 35594e8a349..0299384ce7a 100644
--- a/transports/bifrost-http/integrations/router_test.go
+++ b/transports/bifrost-http/integrations/router_test.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/bytedance/sonic"
+ "github.com/fasthttp/router"
"github.com/maximhq/bifrost/core/providers/anthropic"
"github.com/maximhq/bifrost/core/providers/openai"
"github.com/maximhq/bifrost/core/schemas"
@@ -38,6 +39,24 @@ func TestParsePassthroughBody_MultipartExtractsModelAfterFilePart(t *testing.T)
assert.True(t, stream)
}
+func TestChatGPTPassthroughRouterRegistersCodexResponsesPost(t *testing.T) {
+ r := router.New()
+ passthroughRouter := NewChatGPTPassthroughRouter(nil, &mockHandlerStore{}, &testLogger{})
+ passthroughRouter.RegisterRoutes(r, func(next fasthttp.RequestHandler) fasthttp.RequestHandler {
+ return func(ctx *fasthttp.RequestCtx) {
+ ctx.SetStatusCode(fasthttp.StatusNoContent)
+ }
+ })
+
+ var ctx fasthttp.RequestCtx
+ ctx.Request.Header.SetMethod(fasthttp.MethodPost)
+ ctx.Request.SetRequestURI("/chatgpt_passthrough/backend-api/codex/responses")
+
+ r.Handler(&ctx)
+
+ require.Equal(t, fasthttp.StatusNoContent, ctx.Response.StatusCode())
+}
+
func TestRequestWithSettableExtraParams_OpenAIChatRequest(t *testing.T) {
t.Run("SetExtraParams populates both standalone and embedded ExtraParams", func(t *testing.T) {
req := &openai.OpenAIChatRequest{}
diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go
index ca591c2074d..d3915cfe293 100644
--- a/transports/bifrost-http/lib/config.go
+++ b/transports/bifrost-http/lib/config.go
@@ -1894,6 +1894,7 @@ func mcpClientConfigToTable(clientConfig *schemas.MCPClientConfig) (configstoreT
if authType == "" {
authType = string(schemas.MCPAuthTypeHeaders)
}
+
return configstoreTables.TableMCPClient{
ClientID: clientConfig.ID,
Name: clientConfig.Name,
@@ -6833,4 +6834,4 @@ func DeepCopy[T any](in T) (T, error) {
}
err = sonic.Unmarshal(b, &out)
return out, err
-}
\ No newline at end of file
+}
diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go
index 96c8a4594c8..8a58a6997e3 100644
--- a/transports/bifrost-http/lib/config_test.go
+++ b/transports/bifrost-http/lib/config_test.go
@@ -694,6 +694,17 @@ func (m *MockConfigStore) UpdateMCPClientConfig(ctx context.Context, id string,
return nil
}
+func (m *MockConfigStore) UpdateMCPClientDiscoveredTools(
+ ctx context.Context,
+ id string,
+ expectedName string,
+ tools map[string]schemas.ChatTool,
+ toolNameMapping map[string]string,
+ lastSync time.Time,
+) error {
+ return nil
+}
+
func (m *MockConfigStore) GetMCPClientsPaginated(ctx context.Context, params configstore.MCPClientsQueryParams) ([]tables.TableMCPClient, int64, error) {
return nil, 0, nil
}
diff --git a/transports/bifrost-http/lib/ctx.go b/transports/bifrost-http/lib/ctx.go
index 3caeff883fa..2d9e621b570 100644
--- a/transports/bifrost-http/lib/ctx.go
+++ b/transports/bifrost-http/lib/ctx.go
@@ -675,6 +675,9 @@ func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, store HandlerStore) (*sch
// Direct key bypass: requires both the server-side AllowDirectKeys setting and the
// per-request x-bf-direct-key: true header. The server setting is the admin opt-in;
// the header is the per-request opt-in from the caller.
+ // Enterprise SCIM inference auth runs before this context conversion, so it mirrors
+ // this config/header gate separately to avoid validating provider bearer tokens as
+ // SCIM user JWTs before direct-key extraction can happen here.
if store != nil && store.ShouldAllowDirectKeys() && string(ctx.Request.Header.Peek("x-bf-direct-key")) == "true" {
var apiKey string
authHeader := string(ctx.Request.Header.Peek("Authorization"))
diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go
index 784b445b379..5444647109b 100644
--- a/transports/bifrost-http/server/server.go
+++ b/transports/bifrost-http/server/server.go
@@ -142,6 +142,12 @@ type LogRedactionMappingResolverProvider interface {
GetLogRedactionMappingResolver() handlers.LogRedactionMappingResolver
}
+// MCPLogRedactionMappingResolverProvider is implemented by servers that can attach reveal data to MCP log-detail responses.
+type MCPLogRedactionMappingResolverProvider interface {
+ // GetMCPLogRedactionMappingResolver returns the resolver used by the logging handler.
+ GetMCPLogRedactionMappingResolver() handlers.MCPLogRedactionMappingResolver
+}
+
// BifrostHTTPServer represents a HTTP server instance.
type BifrostHTTPServer struct {
Ctx *schemas.BifrostContext
@@ -1795,6 +1801,9 @@ func (s *BifrostHTTPServer) RegisterAPIRoutes(ctx context.Context, callbacks Ser
if resolverProvider, ok := callbacks.(LogRedactionMappingResolverProvider); ok {
loggingHandler.SetLogRedactionMappingResolver(resolverProvider.GetLogRedactionMappingResolver())
}
+ if resolverProvider, ok := callbacks.(MCPLogRedactionMappingResolverProvider); ok {
+ loggingHandler.SetMCPLogRedactionMappingResolver(resolverProvider.GetMCPLogRedactionMappingResolver())
+ }
// Wire the sidekiq runner so cost recalculation runs as a durable background
// job. Registering the handler here (before RecoverIncomplete) lets a job
// interrupted by a restart resume on boot.
@@ -2159,6 +2168,25 @@ func (s *BifrostHTTPServer) Bootstrap(ctx context.Context) error {
mcpConfig.FetchNewRequestIDFunc = func(ctx *schemas.BifrostContext) string {
return uuid.New().String()
}
+ if s.Config.ConfigStore != nil {
+ mcpConfig.PersistMCPClientDiscoveredTools = func(
+ ctx context.Context,
+ clientID string,
+ expectedClientName string,
+ tools map[string]schemas.ChatTool,
+ toolNameMapping map[string]string,
+ lastSync time.Time,
+ ) error {
+ return s.Config.ConfigStore.UpdateMCPClientDiscoveredTools(
+ ctx,
+ clientID,
+ expectedClientName,
+ tools,
+ toolNameMapping,
+ lastSync,
+ )
+ }
+ }
}
}
// Initialize bifrost client
diff --git a/transports/changelog.md b/transports/changelog.md
index e69de29bb2d..5d9ac3bd976 100644
--- a/transports/changelog.md
+++ b/transports/changelog.md
@@ -0,0 +1 @@
+[fix]: wire MCP tool catalog refresh persistence [@zachgersh](https://github.com/zachgersh)
diff --git a/transports/config.schema.json b/transports/config.schema.json
index 63262950a21..38abcdea86e 100644
--- a/transports/config.schema.json
+++ b/transports/config.schema.json
@@ -1876,8 +1876,8 @@
},
"matview_refresh_interval": {
"type": "string",
- "description": "How often to refresh dashboard materialized views. Go duration string (e.g. '1m', '5m', '1h'). Default 1m. Raise this when matview refresh CPU cost is material on the database instance. Minimum 5s.",
- "pattern": "^[0-9]+(ns|us|µs|ms|s|m|h)$",
+ "description": "How often to refresh dashboard materialized views. Go duration string (e.g. '1m', '5m', '1h'). Default 1m. Raise this when matview refresh CPU cost is material on the database instance. Positive values below 5s are clamped up to 5s. Set 'off' or a zero duration (e.g. '0s') to disable materialized-view maintenance entirely; dashboard queries fall back to the raw tables.",
+ "pattern": "^(off|[0-9]+(ns|us|µs|ms|s|m|h))$",
"default": "1m"
},
"matview_refresh_timeout": {
diff --git a/transports/version b/transports/version
index 400084b1bf2..24bd395d4b0 100644
--- a/transports/version
+++ b/transports/version
@@ -1 +1 @@
-1.6.7
+2.0.0-prerelease2
diff --git a/ui/app/_fallbacks/enterprise/components/agent/agentHandoverView.tsx b/ui/app/_fallbacks/enterprise/components/agent/agentHandoverView.tsx
new file mode 100644
index 00000000000..4f8881575a3
--- /dev/null
+++ b/ui/app/_fallbacks/enterprise/components/agent/agentHandoverView.tsx
@@ -0,0 +1,21 @@
+import { CheckCircle2, CircleAlert } from "lucide-react";
+
+export default function AgentHandoverView() {
+ const status = new URLSearchParams(window.location.search).get("status");
+ const isComplete = !status || status === "complete";
+ const Icon = isComplete ? CheckCircle2 : CircleAlert;
+
+ return (
+
+
+
+
+
+ {isComplete ? "Bifrost Agent sign-in complete" : "Bifrost Agent sign-in"}
+
+ {isComplete ? "You can close this window and return to the Bifrost Agent." : `Sign-in status: ${status}`}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/app/_fallbacks/enterprise/components/edge-control/configView.tsx b/ui/app/_fallbacks/enterprise/components/edge-control/configView.tsx
new file mode 100644
index 00000000000..02bed0e187c
--- /dev/null
+++ b/ui/app/_fallbacks/enterprise/components/edge-control/configView.tsx
@@ -0,0 +1,14 @@
+import { SlidersHorizontal } from "lucide-react";
+import EdgeControlFallbackView from "./fallbackWrapper";
+
+export default function ConfigView() {
+ return (
+ }
+ title="Unlock edge control to govern devices at the edge"
+ 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/edge/admin-configurations"
+ testIdPrefix="edge-config"
+ />
+ );
+}
diff --git a/ui/app/_fallbacks/enterprise/components/edge-control/devicesView.tsx b/ui/app/_fallbacks/enterprise/components/edge-control/devicesView.tsx
new file mode 100644
index 00000000000..c52972487a9
--- /dev/null
+++ b/ui/app/_fallbacks/enterprise/components/edge-control/devicesView.tsx
@@ -0,0 +1,14 @@
+import { MonitorSmartphone } from "lucide-react";
+import EdgeControlFallbackView from "./fallbackWrapper";
+
+export default function DevicesView() {
+ return (
+ }
+ title="Unlock edge control to manage your devices"
+ 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/edge/admin-devices"
+ testIdPrefix="edge-devices"
+ />
+ );
+}
diff --git a/ui/app/_fallbacks/enterprise/components/edge-control/fallbackWrapper.tsx b/ui/app/_fallbacks/enterprise/components/edge-control/fallbackWrapper.tsx
new file mode 100644
index 00000000000..8a84ba4dd11
--- /dev/null
+++ b/ui/app/_fallbacks/enterprise/components/edge-control/fallbackWrapper.tsx
@@ -0,0 +1,24 @@
+import ContactUsView from "../views/contactUsView";
+
+interface EdgeControlFallbackViewProps {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ readmeLink: string;
+ testIdPrefix?: string;
+}
+
+export default function EdgeControlFallbackView({ icon, title, description, readmeLink, testIdPrefix }: EdgeControlFallbackViewProps) {
+ return (
+
+
+
+ );
+}
diff --git a/ui/app/_fallbacks/enterprise/components/edge-control/inventoryView.tsx b/ui/app/_fallbacks/enterprise/components/edge-control/inventoryView.tsx
new file mode 100644
index 00000000000..f1771d87258
--- /dev/null
+++ b/ui/app/_fallbacks/enterprise/components/edge-control/inventoryView.tsx
@@ -0,0 +1,14 @@
+import { ShieldCheck } from "lucide-react";
+import EdgeControlFallbackView from "./fallbackWrapper";
+
+export default function InventoryView() {
+ return (
+ }
+ title="Unlock edge control to approve apps and MCP servers"
+ 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/edge/admin-approvals"
+ testIdPrefix="edge-inventory"
+ />
+ );
+}
diff --git a/ui/app/_fallbacks/enterprise/components/license/licenseInfoView.tsx b/ui/app/_fallbacks/enterprise/components/license/licenseInfoView.tsx
new file mode 100644
index 00000000000..6d8f19604e7
--- /dev/null
+++ b/ui/app/_fallbacks/enterprise/components/license/licenseInfoView.tsx
@@ -0,0 +1,17 @@
+import { KeyRound } from "lucide-react";
+import ContactUsView from "../views/contactUsView";
+
+export default function LicenseSettingsView() {
+ return (
+
+ }
+ title="Unlock license management"
+ 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/overview"
+ testIdPrefix="license"
+ />
+
+ );
+}
diff --git a/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx
index c595f47e9e3..9d0df9dfa1a 100644
--- a/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx
+++ b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx
@@ -27,13 +27,16 @@ export enum RbacResource {
RoutingRules = "RoutingRules",
PromptRepository = "PromptRepository",
PromptDeploymentStrategy = "PromptDeploymentStrategy",
- SkillsRepository = "SkillsRepository",
AccessProfiles = "AccessProfiles",
APIKeys = "APIKeys",
Inference = "Inference",
Metrics = "Metrics",
FeatureFlags = "FeatureFlags",
CircuitBreaker = "CircuitBreaker",
+ Devices = "Devices",
+ Inventory = "Inventory",
+ EdgeConfig = "EdgeConfig",
+ SkillsRepository = "SkillsRepository",
}
// RBAC Operation Names (must match backend definitions)
diff --git a/ui/app/_fallbacks/enterprise/lib/store/slices/index.ts b/ui/app/_fallbacks/enterprise/lib/store/slices/index.ts
index 4f91b5fa654..4d5e51de59a 100644
--- a/ui/app/_fallbacks/enterprise/lib/store/slices/index.ts
+++ b/ui/app/_fallbacks/enterprise/lib/store/slices/index.ts
@@ -1,3 +1,5 @@
+import type { Middleware } from "redux";
+
// Placeholder for enterprise reducers
// Export noop reducers when enterprise features are not available
@@ -8,5 +10,8 @@ export const guardrailReducer = (state = {}) => state;
// Empty reducers map when enterprise features are not available
export const reducers = {};
+// Empty middleware list when enterprise features are not available
+export const middleware: Middleware[] = [];
+
// Empty enterprise state type when enterprise features are not available
export type EnterpriseState = {};
\ No newline at end of file
diff --git a/ui/app/agent/handover/layout.tsx b/ui/app/agent/handover/layout.tsx
new file mode 100644
index 00000000000..9dcf544aeaf
--- /dev/null
+++ b/ui/app/agent/handover/layout.tsx
@@ -0,0 +1,8 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import AgentHandoverPage from "./page";
+
+// Public landing page shown after Bifrost Agent browser sign-in completes.
+export const Route = createFileRoute("/agent/handover")({
+ component: AgentHandoverPage,
+});
\ No newline at end of file
diff --git a/ui/app/agent/handover/page.tsx b/ui/app/agent/handover/page.tsx
new file mode 100644
index 00000000000..e0b974fa1c7
--- /dev/null
+++ b/ui/app/agent/handover/page.tsx
@@ -0,0 +1,5 @@
+import AgentHandoverView from "@enterprise/components/agent/agentHandoverView";
+
+export default function AgentHandoverPage() {
+ return ;
+}
\ No newline at end of file
diff --git a/ui/app/workspace/config/license/layout.tsx b/ui/app/workspace/config/license/layout.tsx
new file mode 100644
index 00000000000..3034901748b
--- /dev/null
+++ b/ui/app/workspace/config/license/layout.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import LicensePage from "./page";
+
+export const Route = createFileRoute("/workspace/config/license")({
+ component: LicensePage,
+});
\ No newline at end of file
diff --git a/ui/app/workspace/config/license/page.tsx b/ui/app/workspace/config/license/page.tsx
new file mode 100644
index 00000000000..904ee35fa75
--- /dev/null
+++ b/ui/app/workspace/config/license/page.tsx
@@ -0,0 +1,24 @@
+import { IS_ENTERPRISE } from "@/lib/constants/config";
+import LicenseSettingsView from "@enterprise/components/license/licenseInfoView";
+import { useNavigate } from "@tanstack/react-router";
+import { useEffect } from "react";
+
+export default function LicensePage() {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (!IS_ENTERPRISE) {
+ navigate({ to: "/workspace/config/client-settings", replace: true });
+ }
+ }, [navigate]);
+
+ if (!IS_ENTERPRISE) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/app/workspace/config/views/clientSettingsView.tsx b/ui/app/workspace/config/views/clientSettingsView.tsx
index 84a61d07ece..01bff3bec9e 100644
--- a/ui/app/workspace/config/views/clientSettingsView.tsx
+++ b/ui/app/workspace/config/views/clientSettingsView.tsx
@@ -13,6 +13,7 @@ import { DefaultLargePayloadConfig, LargePayloadConfig } from "@enterprise/lib/t
import { Info, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
+import UserAgentMappingsView from "./userAgentMappingsView";
// Security headers that cannot be configured in allowlist/denylist
// These headers are always blocked for security reasons regardless of configuration
@@ -379,6 +380,8 @@ export default function ClientSettingsView() {
+
+
{/* Header Filter Section */}
@@ -588,4 +591,4 @@ export default function ClientSettingsView() {
);
-}
\ No newline at end of file
+}
diff --git a/ui/app/workspace/config/views/featureFlagsView.tsx b/ui/app/workspace/config/views/featureFlagsView.tsx
index 07616265145..66204c46364 100644
--- a/ui/app/workspace/config/views/featureFlagsView.tsx
+++ b/ui/app/workspace/config/views/featureFlagsView.tsx
@@ -51,9 +51,7 @@ export default function FeatureFlagsView() {
{flags.length === 0 ? (
-
- No feature flags found. Flags are declared in code via featureflags.Register(...).
-
+ No feature flags found.
) : (
diff --git a/ui/app/workspace/config/views/userAgentMappingsView.tsx b/ui/app/workspace/config/views/userAgentMappingsView.tsx
new file mode 100644
index 00000000000..1afafcaab0f
--- /dev/null
+++ b/ui/app/workspace/config/views/userAgentMappingsView.tsx
@@ -0,0 +1,453 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alertDialog";
+import { Button } from "@/components/ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
+import { Switch } from "@/components/ui/switch";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import {
+ getErrorMessage,
+ type UserAgentMapping,
+ type UserAgentMappingMatchType,
+ type UserAgentMappingPayload,
+ useCreateUserAgentMappingMutation,
+ useDeleteUserAgentMappingMutation,
+ useGetUserAgentMappingsQuery,
+ useUpdateUserAgentMappingMutation,
+} from "@/lib/store";
+import { MoreVertical, Pencil, Plus, Trash2, Upload, X } from "lucide-react";
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+
+const matchTypeOptions: Array<{ value: UserAgentMappingMatchType; label: string }> = [
+ { value: "contains", label: "Contains" },
+ { value: "starts_with", label: "Starts with" },
+ { value: "exact", label: "Exact match" },
+ { value: "regex", label: "Regex" },
+];
+
+const emptyDraft: UserAgentMappingPayload = {
+ pattern: "",
+ match_type: "contains",
+ app: "",
+ logo: undefined,
+ logo_mime: null,
+ is_active: true,
+};
+
+// Cap logo uploads before base64 conversion to avoid freezing the UI and sending oversized payloads.
+const MAX_LOGO_BYTES = 256 * 1024;
+
+interface UserAgentMappingsViewProps {
+ disabled?: boolean;
+}
+
+export default function UserAgentMappingsView({ disabled }: UserAgentMappingsViewProps) {
+ const { data, isLoading } = useGetUserAgentMappingsQuery();
+ const [createMapping, { isLoading: isCreating }] = useCreateUserAgentMappingMutation();
+ const [updateMapping, { isLoading: isUpdating }] = useUpdateUserAgentMappingMutation();
+ const [deleteMapping, { isLoading: isDeleting }] = useDeleteUserAgentMappingMutation();
+ const [draft, setDraft] = useState(emptyDraft);
+ const [editingMappingId, setEditingMappingId] = useState(null);
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
+
+ const mappings = useMemo(() => data?.mappings ?? [], [data]);
+ const controlsDisabled = disabled || isCreating || isUpdating || isDeleting;
+ const isEditing = Boolean(editingMappingId);
+
+ const openAddSheet = () => {
+ setEditingMappingId(null);
+ setDraft(emptyDraft);
+ setIsSheetOpen(true);
+ };
+
+ const openEditSheet = (mapping: UserAgentMapping) => {
+ setEditingMappingId(mapping.id);
+ setDraft(mappingToPayload(mapping));
+ setIsSheetOpen(true);
+ };
+
+ const handleSheetOpenChange = (open: boolean) => {
+ setIsSheetOpen(open);
+ if (!open) {
+ setEditingMappingId(null);
+ setDraft(emptyDraft);
+ }
+ };
+
+ const handleSubmit = async () => {
+ const validated = validateDraft(draft);
+ if (!validated) return;
+ try {
+ if (editingMappingId) {
+ await updateMapping({ id: editingMappingId, data: validated }).unwrap();
+ toast.success("User agent mapping updated.");
+ } else {
+ await createMapping(validated).unwrap();
+ toast.success("User agent mapping added.");
+ }
+ handleSheetOpenChange(false);
+ } catch (error) {
+ toast.error(`Failed to ${editingMappingId ? "update" : "add"} mapping: ${getErrorMessage(error)}`);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ try {
+ await deleteMapping(id).unwrap();
+ toast.success("User agent mapping deleted.");
+ } catch (error) {
+ toast.error(`Failed to delete mapping: ${getErrorMessage(error)}`);
+ }
+ };
+
+ return (
+
+
+
+
User Agent Mappings
+
Map incoming User-Agent strings to app names and optional logos used in logs.
+
+
+
+
+
+
+
+ {isEditing ? "Edit User Agent Mapping" : "Add User Agent Mapping"}
+ Define how a User-Agent value maps to an app label in logs.
+
+
+
+
+
+ handleSheetOpenChange(false)} data-testid="user-agent-mapping-cancel-btn">
+ Cancel
+
+
+ {isEditing ? "Save Changes" : "Add Mapping"}
+
+
+
+
+
+
+
+
+ Pattern
+ Match
+ App
+ Logo
+ Active
+ Actions
+
+
+
+ {isLoading ? (
+
+
+ Loading mappings...
+
+
+ ) : mappings.length === 0 ? (
+
+
+ No user agent mappings configured.
+
+
+ ) : (
+ mappings.map((mapping) => {
+ const logoSrc = mapping.logo && mapping.logo_mime ? `data:${mapping.logo_mime};base64,${mapping.logo}` : "";
+ return (
+
+
+
+ {mapping.pattern}
+
+
+
+ {getMatchTypeLabel(mapping.match_type)}
+
+
+
+ {mapping.app}
+
+
+
+ {logoSrc ? : - }
+
+
+
+ {mapping.is_active ? "Active" : "Inactive"}
+
+
+
+
+
+
+
+
+
+
+
+ openEditSheet(mapping)} data-testid={`user-agent-mapping-edit-${mapping.id}`}>
+
+ Edit
+
+
+
+
+ Delete
+
+
+
+
+
+
+ Are you sure you want to delete this mapping?
+ This action cannot be undone. This will permanently delete the user agent mapping.
+
+
+ Cancel
+ handleDelete(mapping.id)}
+ >
+ Delete
+
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+function MappingForm({
+ draft,
+ onChange,
+ disabled,
+}: {
+ draft: UserAgentMappingPayload;
+ onChange: (next: UserAgentMappingPayload) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+
+
+ Pattern
+
+ onChange({ ...draft, pattern: event.target.value })}
+ disabled={disabled}
+ data-testid="user-agent-mapping-pattern-input"
+ />
+
+
+
+ Match type
+
+ onChange({ ...draft, match_type: matchType })}
+ disabled={disabled}
+ />
+
+
+
+ App
+
+ onChange({ ...draft, app: event.target.value })}
+ disabled={disabled}
+ data-testid="user-agent-mapping-app-input"
+ />
+
+
+
+ Logo
+
+
+
+
+
+
Active
+
Inactive mappings are saved but ignored by detection.
+
+
onChange({ ...draft, is_active: checked })}
+ disabled={disabled}
+ data-testid="user-agent-mapping-active-switch"
+ />
+
+
+ );
+}
+
+function MatchTypeSelect({
+ value,
+ onChange,
+ disabled,
+ id,
+}: {
+ value: UserAgentMappingMatchType;
+ onChange: (value: UserAgentMappingMatchType) => void;
+ disabled?: boolean;
+ id?: string;
+}) {
+ return (
+ onChange(next as UserAgentMappingMatchType)} disabled={disabled}>
+
+
+
+
+ {matchTypeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+}
+
+function LogoInput({
+ draft,
+ onChange,
+ disabled,
+}: {
+ draft: UserAgentMappingPayload;
+ onChange: (next: UserAgentMappingPayload) => void;
+ disabled?: boolean;
+}) {
+ const dataUrl = draft.logo && draft.logo_mime ? `data:${draft.logo_mime};base64,${draft.logo}` : "";
+ return (
+
+ {dataUrl &&
}
+
+
+
+ {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ if (file.size > MAX_LOGO_BYTES) {
+ toast.error("Logo must be 256KB or smaller.");
+ event.target.value = "";
+ return;
+ }
+ try {
+ const logo = await fileToBase64(file);
+ onChange({ ...draft, logo, logo_mime: file.type || "application/octet-stream" });
+ } catch {
+ toast.error("Failed to read logo file.");
+ }
+ event.target.value = "";
+ }}
+ />
+
+
+
onChange({ ...draft, logo: undefined, logo_mime: null })}
+ aria-label="Remove logo"
+ data-testid="user-agent-mapping-logo-remove"
+ >
+
+
+
+ );
+}
+
+function mappingToPayload(mapping: UserAgentMapping): UserAgentMappingPayload {
+ return {
+ pattern: mapping.pattern,
+ match_type: mapping.match_type,
+ app: mapping.app,
+ logo: mapping.logo,
+ logo_mime: mapping.logo_mime ?? null,
+ is_active: mapping.is_active,
+ };
+}
+
+function getMatchTypeLabel(matchType: UserAgentMappingMatchType): string {
+ return matchTypeOptions.find((option) => option.value === matchType)?.label ?? matchType;
+}
+
+function validateDraft(draft?: UserAgentMappingPayload): UserAgentMappingPayload | null {
+ if (!draft || !draft.pattern.trim() || !draft.app.trim()) {
+ toast.error("Pattern and app are required.");
+ return null;
+ }
+ if (draft.match_type === "regex") {
+ try {
+ new RegExp(draft.pattern);
+ } catch {
+ toast.error("Regex pattern is invalid.");
+ return null;
+ }
+ }
+ return {
+ ...draft,
+ pattern: draft.pattern.trim(),
+ app: draft.app.trim(),
+ };
+}
+
+function fileToBase64(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const value = String(reader.result ?? "");
+ resolve(value.includes(",") ? value.split(",")[1] : value);
+ };
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+}
diff --git a/ui/app/workspace/dashboard/page.tsx b/ui/app/workspace/dashboard/page.tsx
index f48adbee764..6fcea2a62fd 100644
--- a/ui/app/workspace/dashboard/page.tsx
+++ b/ui/app/workspace/dashboard/page.tsx
@@ -86,6 +86,7 @@ export default function DashboardPage() {
customer_ids: parseAsSafeArrayOf.withDefault([]),
business_unit_ids: parseAsSafeArrayOf.withDefault([]),
aliases: parseAsSafeArrayOf.withDefault([]),
+ apps: parseAsSafeArrayOf.withDefault([]),
},
{
history: "push",
@@ -144,6 +145,7 @@ export default function DashboardPage() {
...(urlState.customer_ids.length > 0 && { customer_ids: urlState.customer_ids }),
...(urlState.business_unit_ids.length > 0 && { business_unit_ids: urlState.business_unit_ids }),
...(urlState.aliases.length > 0 && { aliases: urlState.aliases }),
+ ...(urlState.apps.length > 0 && { apps: urlState.apps }),
}),
[
urlState.period,
@@ -167,6 +169,7 @@ export default function DashboardPage() {
urlState.customer_ids,
urlState.business_unit_ids,
urlState.aliases,
+ urlState.apps,
],
);
@@ -210,6 +213,7 @@ export default function DashboardPage() {
const buRankingsRef = useRef(null);
const userRankingsRef = useRef(null);
const virtualKeyRankingsRef = useRef(null);
+ const appRankingsRef = useRef(null);
const allRefs = [
overviewRef,
@@ -221,6 +225,7 @@ export default function DashboardPage() {
buRankingsRef,
userRankingsRef,
virtualKeyRankingsRef,
+ appRankingsRef,
];
const getDashboardData = useCallback((): DashboardData => {
@@ -243,6 +248,7 @@ export default function DashboardPage() {
buRankingsData: null,
userRankingsData: null,
virtualKeyRankingsData: null,
+ appRankingsData: null,
mcpHistogramData: null,
mcpCostData: null,
mcpTopToolsData: null,
@@ -276,6 +282,7 @@ export default function DashboardPage() {
"bu-rankings": buRankingsRef,
"user-rankings": userRankingsRef,
"virtual-key-rankings": virtualKeyRankingsRef,
+ "app-rankings": appRankingsRef,
};
const refs = scope === "all" ? allRefs : [refsByTab[scope]];
@@ -304,7 +311,10 @@ export default function DashboardPage() {
const handleProviderCostChartToggle = useCallback((type: ChartType) => setUrlState({ provider_cost_chart: type }), [setUrlState]);
const handleProviderTokenChartToggle = useCallback((type: ChartType) => setUrlState({ provider_token_chart: type }), [setUrlState]);
const handleProviderLatencyChartToggle = useCallback((type: ChartType) => setUrlState({ provider_latency_chart: type }), [setUrlState]);
- const handleProviderThroughputChartToggle = useCallback((type: ChartType) => setUrlState({ provider_throughput_chart: type }), [setUrlState]);
+ const handleProviderThroughputChartToggle = useCallback(
+ (type: ChartType) => setUrlState({ provider_throughput_chart: type }),
+ [setUrlState],
+ );
const handleMcpVolumeChartToggle = useCallback((type: ChartType) => setUrlState({ mcp_volume_chart: type }), [setUrlState]);
const handleMcpCostChartToggle = useCallback((type: ChartType) => setUrlState({ mcp_cost_chart: type }), [setUrlState]);
@@ -361,6 +371,7 @@ export default function DashboardPage() {
customer_ids: newFilters.customer_ids || [],
business_unit_ids: newFilters.business_unit_ids || [],
aliases: newFilters.aliases || [],
+ apps: newFilters.apps || [],
});
},
[setUrlState, urlState.start_time, urlState.end_time],
@@ -556,9 +567,11 @@ export default function DashboardPage() {
BU Rankings
+
+ App Rankings
+
-
{/* Overview Tab */}
@@ -727,9 +740,24 @@ export default function DashboardPage() {
/>
+ {/* App Rankings Tab */}
+
+
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/ui/app/workspace/dashboard/utils/exportUtils.ts b/ui/app/workspace/dashboard/utils/exportUtils.ts
index 409aa9a7bde..99fbab34561 100644
--- a/ui/app/workspace/dashboard/utils/exportUtils.ts
+++ b/ui/app/workspace/dashboard/utils/exportUtils.ts
@@ -202,6 +202,7 @@ export interface DashboardData {
buRankingsData: DimensionRankingsResponse | null;
userRankingsData: DimensionRankingsResponse | null;
virtualKeyRankingsData: DimensionRankingsResponse | null;
+ appRankingsData: DimensionRankingsResponse | null;
// MCP
mcpHistogramData: MCPHistogramResponse | null;
mcpCostData: MCPCostHistogramResponse | null;
@@ -217,6 +218,7 @@ export type DashboardTab =
| "bu-rankings"
| "user-rankings"
| "virtual-key-rankings"
+ | "app-rankings"
| "mcp";
export type ExportTab = DashboardTab | "all";
@@ -236,6 +238,7 @@ export const DASHBOARD_EXPORT_TABS: { value: DashboardTab; label: string; sectio
{ value: "bu-rankings", label: "BU Rankings", sectionId: "dashboard-section-bu-rankings" },
{ value: "user-rankings", label: "User Rankings", sectionId: "dashboard-section-user-rankings" },
{ value: "virtual-key-rankings", label: "Virtual Key Rankings", sectionId: "dashboard-section-virtual-key-rankings" },
+ { value: "app-rankings", label: "App Rankings", sectionId: "dashboard-section-app-rankings" },
];
export const getExportTabLabel = (tab: DashboardTab): string => DASHBOARD_EXPORT_TABS.find((t) => t.value === tab)?.label ?? "Current Tab";
@@ -286,6 +289,10 @@ export function getCSVSections(data: DashboardData, tab: ExportTab): { name: str
sections.push({ name: "virtual-key-rankings", csv: dimensionRankingsToCSV(data.virtualKeyRankingsData, "Virtual Key") });
}
+ if (tab === "all" || tab === "app-rankings") {
+ sections.push({ name: "app-rankings", csv: dimensionRankingsToCSV(data.appRankingsData, "App") });
+ }
+
if (tab === "all" || tab === "mcp") {
sections.push(
{ name: "mcp-volume", csv: mcpVolumeToCSV(data.mcpHistogramData) },
diff --git a/ui/app/workspace/edge-control/config/layout.tsx b/ui/app/workspace/edge-control/config/layout.tsx
new file mode 100644
index 00000000000..fd9f2ee6bb9
--- /dev/null
+++ b/ui/app/workspace/edge-control/config/layout.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { NoPermissionView } from "@/components/noPermissionView";
+import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
+import EdgeConfigPage from "./page";
+
+function RouteComponent() {
+ const hasAccess = useRbac(RbacResource.EdgeConfig, RbacOperation.View);
+ if (!hasAccess) {
+ return ;
+ }
+ return ;
+}
+
+export const Route = createFileRoute("/workspace/edge-control/config")({
+ component: RouteComponent,
+});
diff --git a/ui/app/workspace/edge-control/config/page.tsx b/ui/app/workspace/edge-control/config/page.tsx
new file mode 100644
index 00000000000..36c1d1d202b
--- /dev/null
+++ b/ui/app/workspace/edge-control/config/page.tsx
@@ -0,0 +1,9 @@
+import ConfigView from "@enterprise/components/edge-control/configView";
+
+export default function EdgeConfigPage() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/app/workspace/edge-control/devices/layout.tsx b/ui/app/workspace/edge-control/devices/layout.tsx
new file mode 100644
index 00000000000..0d1654975ed
--- /dev/null
+++ b/ui/app/workspace/edge-control/devices/layout.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { NoPermissionView } from "@/components/noPermissionView";
+import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
+import EdgeDevicesPage from "./page";
+
+function RouteComponent() {
+ const hasAccess = useRbac(RbacResource.Devices, RbacOperation.View);
+ if (!hasAccess) {
+ return ;
+ }
+ return ;
+}
+
+export const Route = createFileRoute("/workspace/edge-control/devices")({
+ component: RouteComponent,
+});
diff --git a/ui/app/workspace/edge-control/devices/page.tsx b/ui/app/workspace/edge-control/devices/page.tsx
new file mode 100644
index 00000000000..2ed00320fe8
--- /dev/null
+++ b/ui/app/workspace/edge-control/devices/page.tsx
@@ -0,0 +1,9 @@
+import DevicesView from "@enterprise/components/edge-control/devicesView";
+
+export default function EdgeDevicesPage() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/app/workspace/edge-control/inventory/layout.tsx b/ui/app/workspace/edge-control/inventory/layout.tsx
new file mode 100644
index 00000000000..0ece3c3d421
--- /dev/null
+++ b/ui/app/workspace/edge-control/inventory/layout.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { NoPermissionView } from "@/components/noPermissionView";
+import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
+import EdgeInventoryPage from "./page";
+
+function RouteComponent() {
+ const hasAccess = useRbac(RbacResource.Inventory, RbacOperation.View);
+ if (!hasAccess) {
+ return ;
+ }
+ return ;
+}
+
+export const Route = createFileRoute("/workspace/edge-control/inventory")({
+ component: RouteComponent,
+});
diff --git a/ui/app/workspace/edge-control/inventory/page.tsx b/ui/app/workspace/edge-control/inventory/page.tsx
new file mode 100644
index 00000000000..f8fab2068f7
--- /dev/null
+++ b/ui/app/workspace/edge-control/inventory/page.tsx
@@ -0,0 +1,9 @@
+import InventoryView from "@enterprise/components/edge-control/inventoryView";
+
+export default function EdgeInventoryPage() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/app/workspace/logs/page.tsx b/ui/app/workspace/logs/page.tsx
index 4ef267a16ee..ea84d9b0073 100644
--- a/ui/app/workspace/logs/page.tsx
+++ b/ui/app/workspace/logs/page.tsx
@@ -17,6 +17,7 @@ import {
useGetLogsHistogramQuery,
useGetLogsQuery,
useGetLogsStatsQuery,
+ useGetUserAgentMappingsQuery,
} from "@/lib/store";
import { useLazyGetLogByIdQuery, useLazyGetLogsQuery } from "@/lib/store/apis/logsApi";
import type { LogEntry, LogFilters, Pagination } from "@/lib/types/logs";
@@ -83,6 +84,8 @@ export default function LogsPage() {
virtual_key_ids: parseAsSafeArrayOf.withDefault([]),
routing_rule_ids: parseAsSafeArrayOf.withDefault([]),
routing_engine_used: parseAsSafeArrayOf.withDefault([]),
+ apps: parseAsSafeArrayOf.withDefault([]),
+ user_agents: parseAsSafeArrayOf.withDefault([]),
user_ids: parseAsSafeArrayOf.withDefault([]),
team_ids: parseAsSafeArrayOf.withDefault([]),
customer_ids: parseAsSafeArrayOf.withDefault([]),
@@ -126,6 +129,8 @@ 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,
+ apps: urlState.apps,
+ user_agents: urlState.user_agents,
user_ids: urlState.user_ids,
team_ids: urlState.team_ids,
customer_ids: urlState.customer_ids,
@@ -162,6 +167,8 @@ export default function LogsPage() {
urlState.virtual_key_ids,
urlState.routing_rule_ids,
urlState.routing_engine_used,
+ urlState.apps,
+ urlState.user_agents,
urlState.user_ids,
urlState.team_ids,
urlState.customer_ids,
@@ -220,6 +227,8 @@ 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 || [],
+ apps: newFilters.apps || [],
+ user_agents: newFilters.user_agents || [],
user_ids: newFilters.user_ids || [],
team_ids: newFilters.team_ids || [],
customer_ids: newFilters.customer_ids || [],
@@ -479,7 +488,21 @@ export default function LogsPage() {
return Object.keys(filterData.metadata_keys).sort();
}, [filterData?.metadata_keys]);
- const columns = useMemo(() => createColumns(handleDelete, hasDeleteAccess, metadataKeys), [handleDelete, hasDeleteAccess, metadataKeys]);
+ const { data: userAgentMappingsData } = useGetUserAgentMappingsQuery();
+ const customAppIcons = useMemo(() => {
+ const icons: Record = {};
+ for (const mapping of userAgentMappingsData?.mappings ?? []) {
+ if (mapping.app && mapping.logo && mapping.logo_mime) {
+ icons[mapping.app] = `data:${mapping.logo_mime};base64,${mapping.logo}`;
+ }
+ }
+ return icons;
+ }, [userAgentMappingsData?.mappings]);
+
+ const columns = useMemo(
+ () => createColumns(handleDelete, hasDeleteAccess, metadataKeys, customAppIcons),
+ [customAppIcons, handleDelete, hasDeleteAccess, metadataKeys],
+ );
const columnIds = useMemo(
() => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean),
@@ -493,6 +516,7 @@ export default function LogsPage() {
input: "Message",
provider: "Provider",
model: "Model",
+ app: "App",
latency: "Latency",
tokens: "Tokens",
cost: "Cost",
diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx
index baa5e8682ca..7f56237b92a 100644
--- a/ui/app/workspace/logs/sheets/logDetailView.tsx
+++ b/ui/app/workspace/logs/sheets/logDetailView.tsx
@@ -27,16 +27,27 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { ProviderIconType, RenderProviderIcon, RoutingEngineUsedIcons } from "@/lib/constants/icons";
-import { RequestTypeColors, RequestTypeLabels, RoutingEngineUsedColors, RoutingEngineUsedLabels, Status } from "@/lib/constants/logs";
+import {
+ logAppDisplayName,
+ mapAppToClientApp,
+ mapUserAgentToApp,
+ RequestTypeColors,
+ RequestTypeLabels,
+ RoutingEngineUsedColors,
+ RoutingEngineUsedLabels,
+ Status,
+} from "@/lib/constants/logs";
import { ContentBlock, LogEntry, ResponsesMessage } from "@/lib/types/logs";
+import { useGetUserAgentMappingsQuery } from "@/lib/store";
import { cn } from "@/lib/utils";
import { downloadAsJson } from "@/lib/utils/browser-download";
import { formatCompactNumber } from "@/lib/utils/numbers";
+import { applyRedactionMapping, hasRedactionMappingEntries } from "@/lib/utils/redaction";
import { isJson } from "@/lib/utils/validation";
import { Link } from "@tanstack/react-router";
import { addMilliseconds, format } from "date-fns";
import { AlertCircle, ChevronDown, Clipboard, Copy, Download, Loader2, MoreVertical, Trash2, Wrench } from "lucide-react";
-import { useEffect, useState, type ReactNode } from "react";
+import { useMemo, useEffect, useState, type ReactNode } from "react";
import { toast } from "sonner";
import BlockHeader from "../views/blockHeader";
import CollapsibleBox from "../views/collapsibleBox";
@@ -72,18 +83,6 @@ const getRealtimeTransportBadgeClass = (value: unknown): string => {
}
};
-const hasRedactionMappingEntries = (mapping?: LogEntry["redaction_mapping"]): boolean =>
- Boolean(mapping && (Object.keys(mapping.input ?? {}).length > 0 || Object.keys(mapping.output ?? {}).length > 0));
-
-const applyRedactionMapping = (text: string | undefined, mapping?: Record): string => {
- if (!text || !mapping) return text || "";
- let result = text;
- for (const [key, value] of Object.entries(mapping)) {
- result = result.replaceAll(`[${key}]`, value);
- }
- return result;
-};
-
const formatRealtimeSource = (value: unknown): string => {
const source = String(value ?? "").trim();
switch (source.toLowerCase()) {
@@ -641,7 +640,21 @@ export function LogDetailView({
const selectedPromptDisplayName = resolvedSelectedPromptName ?? log.selected_prompt_name ?? "";
+ const { data: userAgentMappingsData } = useGetUserAgentMappingsQuery();
+ const customAppIcons = useMemo(() => {
+ const icons: Record = {};
+ for (const mapping of userAgentMappingsData?.mappings ?? []) {
+ if (mapping.app && mapping.logo && mapping.logo_mime) {
+ icons[mapping.app] = `data:${mapping.logo_mime};base64,${mapping.logo}`;
+ }
+ }
+ return icons;
+ }, [userAgentMappingsData?.mappings]);
+
const isContainer = isContainerOperation(log.object);
+ const detectedApp = log.app ? mapAppToClientApp(log.app) : log.user_agent ? mapUserAgentToApp(log.user_agent) : null;
+ const detectedAppIcon = log.app && detectedApp ? customAppIcons[log.app] || detectedApp.icon : detectedApp?.icon;
+ const detectedAppLabel = detectedApp ? logAppDisplayName(detectedApp, log.user_agent) : "";
const showTabs = !isContainer;
const isPassthrough = isPassthroughOperation(log.object);
const isRealtimeTurn = log.object === "realtime.turn";
@@ -702,8 +715,11 @@ export function LogDetailView({
{revealAvailable && (
-
Show original values
+
+ Show original values
+
)}
+ {detectedApp && (
+
+ {detectedAppIcon ? (
+
+ ) : null}
+ {detectedAppLabel}
+
+ }
+ />
+ )}
Promise void,
hasDeleteAccess = true,
metadataKeys: string[] = [],
+ customAppIcons: Record = {},
): ColumnDef[] => {
const baseColumns: ColumnDef[] = [
{
@@ -328,6 +339,23 @@ export const createColumns = (
);
},
},
+ {
+ id: "app",
+ accessorKey: "app",
+ header: "App",
+ size: 140,
+ cell: ({ row }) => {
+ const app = row.original.app ? mapAppToClientApp(row.original.app) : mapUserAgentToApp(row.original.user_agent);
+ const icon = row.original.app ? customAppIcons[row.original.app] || app.icon : app.icon;
+ const label = logAppDisplayName(app, row.original.user_agent);
+ return (
+
+ {icon ?
: null}
+
{label}
+
+ );
+ },
+ },
{
accessorKey: "latency",
header: ({ column }) => (
diff --git a/ui/app/workspace/logs/views/pluginLogsView.tsx b/ui/app/workspace/logs/views/pluginLogsView.tsx
index 4ab86d6c7be..6286126d03e 100644
--- a/ui/app/workspace/logs/views/pluginLogsView.tsx
+++ b/ui/app/workspace/logs/views/pluginLogsView.tsx
@@ -14,6 +14,14 @@ interface PluginLogsViewProps {
pluginLogs: string;
}
+function formatPluginName(name: string): string {
+ return name
+ .split(/[-_\s]+/)
+ .filter(Boolean)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
export default function PluginLogsView({ pluginLogs }: PluginLogsViewProps) {
let parsed: Record;
try {
@@ -58,7 +66,7 @@ function PluginSection({ name, entries }: { name: string; entries: PluginLogEntr
className="hover:bg-muted/50 flex w-full items-center gap-2 px-4 py-2 text-left text-sm"
>
{isOpen ? : }
- {name}
+ {formatPluginName(name)}
({entries.length})
{isOpen && (
diff --git a/ui/app/workspace/mcp-logs/page.tsx b/ui/app/workspace/mcp-logs/page.tsx
index ae5566a4f62..a79176190c1 100644
--- a/ui/app/workspace/mcp-logs/page.tsx
+++ b/ui/app/workspace/mcp-logs/page.tsx
@@ -10,6 +10,7 @@ import {
useGetMCPHistogramQuery,
useGetMCPLogsQuery,
useGetMCPLogsStatsQuery,
+ useGetUserAgentMappingsQuery,
} from "@/lib/store";
import { useLazyGetMCPLogsQuery } from "@/lib/store/apis/mcpLogsApi";
import type { MCPToolLogEntry, MCPToolLogFilters, Pagination } from "@/lib/types/logs";
@@ -33,6 +34,7 @@ export default function MCPLogsPage() {
const [showEmptyState, setShowEmptyState] = useState(false);
const hasCheckedEmptyState = useRef(false);
const hasDeleteAccess = useRbac(RbacResource.MCPLogs, RbacOperation.Delete);
+ const hasRevealAccess = useRbac(RbacResource.Logs, RbacOperation.Reveal);
const [deleteLogs] = useDeleteMCPLogsMutation();
// Lazy query kept only for handleLogNavigate (fetches adjacent pages on demand)
@@ -327,7 +329,18 @@ export default function MCPLogsPage() {
[statsData],
);
- const columns = useMemo(() => createMCPColumns(handleDelete, hasDeleteAccess), [handleDelete, hasDeleteAccess]);
+ const { data: userAgentMappingsData } = useGetUserAgentMappingsQuery();
+ const customAppIcons = useMemo(() => {
+ const icons: Record = {};
+ for (const mapping of userAgentMappingsData?.mappings ?? []) {
+ if (mapping.app && mapping.logo && mapping.logo_mime) {
+ icons[mapping.app] = `data:${mapping.logo_mime};base64,${mapping.logo}`;
+ }
+ }
+ return icons;
+ }, [userAgentMappingsData?.mappings]);
+
+ const columns = useMemo(() => createMCPColumns(handleDelete, hasDeleteAccess, customAppIcons), [customAppIcons, handleDelete, hasDeleteAccess]);
const columnIds = useMemo(
() => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean),
@@ -512,6 +525,7 @@ export default function MCPLogsPage() {
open={selectedLogId !== null}
onOpenChange={(open) => !open && setUrlState({ selected_log: "" }, { history: "replace" })}
handleDelete={hasDeleteAccess ? handleDelete : undefined}
+ canReveal={hasRevealAccess}
onNavigate={handleLogNavigate}
hasPrev={selectedLogIndex > 0 || (selectedLogIndex !== -1 && pagination.offset > 0)}
hasNext={selectedLogIndex !== -1 && (selectedLogIndex < logs.length - 1 || pagination.offset + pagination.limit < totalItems)}
@@ -520,4 +534,4 @@ export default function MCPLogsPage() {
)}
);
-}
\ No newline at end of file
+}
diff --git a/ui/app/workspace/mcp-logs/views/columns.tsx b/ui/app/workspace/mcp-logs/views/columns.tsx
index 96d6d60c321..e696e0b0fc4 100644
--- a/ui/app/workspace/mcp-logs/views/columns.tsx
+++ b/ui/app/workspace/mcp-logs/views/columns.tsx
@@ -1,7 +1,7 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
-import { Status, StatusBarColors, Statuses } from "@/lib/constants/logs";
+import { mapAppToClientApp, mapUserAgentToApp, Status, StatusBarColors, Statuses } from "@/lib/constants/logs";
import type { MCPToolLogEntry } from "@/lib/types/logs";
import { ColumnDef, Row } from "@tanstack/react-table";
import { format, isValid } from "date-fns";
@@ -20,6 +20,7 @@ const getValidatedStatus = (status: string): Status => {
export const createMCPColumns = (
handleDelete: (log: MCPToolLogEntry) => Promise,
hasDeleteAccess: boolean,
+ customAppIcons: Record = {},
): ColumnDef[] => [
{
accessorKey: "status",
@@ -70,6 +71,22 @@ export const createMCPColumns = (
);
},
},
+ {
+ id: "app",
+ accessorKey: "app",
+ header: "App",
+ size: 140,
+ cell: ({ row }) => {
+ const app = row.original.app ? mapAppToClientApp(row.original.app) : mapUserAgentToApp(row.original.user_agent);
+ const icon = row.original.app ? customAppIcons[row.original.app] || app.icon : app.icon;
+ return (
+
+ {icon ?
: null}
+
{app.name}
+
+ );
+ },
+ },
{
accessorKey: "latency",
header: ({ column }) => (
@@ -142,4 +159,4 @@ export const createMCPColumns = (
},
]
: []),
-];
\ No newline at end of file
+];
diff --git a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
index 17fc7b5630e..cf65c6fb912 100644
--- a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
+++ b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
@@ -20,16 +20,20 @@ import {
} from "@/components/ui/dropdownMenu";
import { DottedSeparator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Status, StatusColors, Statuses } from "@/lib/constants/logs";
import { useGetMCPLogByIdQuery } from "@/lib/store";
import type { MCPToolLogEntry } from "@/lib/types/logs";
import { downloadAsJson } from "@/lib/utils/browser-download";
+import { applyRedactionMappingToValue, hasRedactionMappingEntries, mergeRedactionMappings } from "@/lib/utils/redaction";
+import PluginLogsView from "@/app/workspace/logs/views/pluginLogsView";
import { Link } from "@tanstack/react-router";
import { addMilliseconds, format, isValid } from "date-fns";
import { SheetNavigationButtons } from "@/components/sheetNavigationButtons";
import { useSheetNavigation } from "@/hooks/useSheetNavigation";
import { Download, Loader2, MoreVertical, Trash2 } from "lucide-react";
-import { useState, type ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import { toast } from "sonner";
interface MCPLogDetailSheetProps {
@@ -37,6 +41,7 @@ interface MCPLogDetailSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
handleDelete?: (log: MCPToolLogEntry) => Promise;
+ canReveal?: boolean;
onNavigate?: (direction: "prev" | "next") => void;
hasPrev?: boolean;
hasNext?: boolean;
@@ -68,17 +73,30 @@ const getValidatedStatus = (status: string): Status => {
return "processing";
};
+function getPluginLogCount(pluginLogs?: string): number {
+ if (!pluginLogs) return 0;
+ try {
+ const parsed: unknown = JSON.parse(pluginLogs);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
+ return Object.values(parsed).reduce((count, entries) => count + (Array.isArray(entries) ? entries.length : 0), 0);
+ } catch {
+ return 0;
+ }
+}
+
export function MCPLogDetailSheet({
log,
open,
onOpenChange,
handleDelete,
+ canReveal = false,
onNavigate,
hasPrev = false,
hasNext = false,
}: MCPLogDetailSheetProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [showRevealedValues, setShowRevealedValues] = useState(false);
const {
data: fullLog,
isLoading,
@@ -95,10 +113,20 @@ export function MCPLogDetailSheet({
onNavigate: (direction) => onNavigate?.(direction),
});
- if (!log) return null;
+ const isFullDataReady = Boolean(log) && (isError || (fullLog?.id === log?.id && !isLoading));
+ const displayLog = log ? (isFullDataReady && fullLog ? fullLog : log) : null;
+ const revealMapping = displayLog?.redaction_mapping;
+ const revealAvailable = canReveal && hasRedactionMappingEntries(revealMapping);
+ const revealEnabled = revealAvailable && showRevealedValues;
+ const inputRevealMapping = revealEnabled ? revealMapping?.input : undefined;
+ const outputRevealMapping = revealEnabled ? revealMapping?.output : undefined;
+ const mixedRevealMapping = revealEnabled ? mergeRedactionMappings(revealMapping) : undefined;
- const isFullDataReady = isError || (fullLog?.id === log.id && !isLoading);
- const displayLog = isFullDataReady && fullLog ? fullLog : log;
+ useEffect(() => {
+ setShowRevealedValues(false);
+ }, [displayLog?.id, revealAvailable]);
+
+ if (!log || !displayLog) return null;
if (!isFullDataReady) {
return (
@@ -113,6 +141,11 @@ export function MCPLogDetailSheet({
);
}
+ const displayedArguments = applyRedactionMappingToValue(displayLog.arguments, inputRevealMapping);
+ const displayedResult = applyRedactionMappingToValue(displayLog.result, outputRevealMapping);
+ const displayedErrorDetails = applyRedactionMappingToValue(displayLog.error_details, mixedRevealMapping);
+ const pluginLogCount = getPluginLogCount(displayLog.plugin_logs);
+
return (
@@ -133,6 +166,19 @@ export function MCPLogDetailSheet({
nextKeys={nextKeys}
entityLabel="log"
/>
+ {revealAvailable && (
+
+
+ Show original values
+
+ setShowRevealedValues(checked && revealAvailable)}
+ data-testid="mcplogdetails-reveal-toggle"
+ />
+
+ )}
@@ -300,72 +346,96 @@ export function MCPLogDetailSheet({
- {/* Arguments */}
- {displayLog.arguments && (
-
-
Arguments
-
, null, 2)
- }
- lang="json"
- readonly={true}
- options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
- />
-
- )}
+
+
+
+ Execution
+
+
+ Plugin Logs
+ {pluginLogCount > 0 ? (
+
+ {pluginLogCount}
+
+ ) : null}
+
+
- {/* Result */}
- {displayLog.result && displayLog.status !== "processing" && (
-
- )}
+
+ {/* Arguments */}
+ {displayedArguments && (
+
+ )}
- {/* Metadata */}
- {displayLog.metadata && Object.keys(displayLog.metadata).length > 0 && (
-
-
-
- {Object.entries(displayLog.metadata).map(([key, value]) => (
-
- ))}
-
-
- )}
+ {/* Result */}
+ {displayedResult && displayLog.status !== "processing" && (
+
+ )}
- {/* Error Details */}
- {displayLog.error_details && (
-
- )}
+ {/* Metadata */}
+ {displayLog.metadata && Object.keys(displayLog.metadata).length > 0 && (
+
+
+
+ {Object.entries(displayLog.metadata).map(([key, value]) => (
+
+ ))}
+
+
+ )}
+
+ {/* Error Details */}
+ {displayedErrorDetails && (
+
+ )}
+
+
+
+ {displayLog.plugin_logs ? (
+
+ ) : (
+
+ No plugin logs for this request.
+
+ )}
+
+
);
diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
index 33f584b7d09..714a9d15cac 100644
--- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
+++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx
@@ -1045,4 +1045,4 @@ export default function VirtualKeysTable({
>
);
-}
\ No newline at end of file
+}
diff --git a/ui/components/entitySelectors/entitySelector.tsx b/ui/components/entitySelectors/entitySelector.tsx
index 8a69b645773..765db99f8a4 100644
--- a/ui/components/entitySelectors/entitySelector.tsx
+++ b/ui/components/entitySelectors/entitySelector.tsx
@@ -21,6 +21,7 @@
import { CheckIcon, ChevronDownIcon, PlusIcon } from "lucide-react";
import { type ComponentType, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { components } from "react-select";
import { AsyncMultiSelect } from "@/components/ui/asyncMultiselect";
import { Button } from "@/components/ui/button";
@@ -308,6 +309,10 @@ export function EntitySelector(props: EntitySelectorProps) {
if (props.multiple !== true) return;
props.onChange(selected.map((option) => option.value));
}}
+ // Dialog's centering transform turns the menu's `position: fixed`
+ // into relative-to-dialog instead of relative-to-viewport, which
+ // clips it; portaling to body escapes that ancestor entirely.
+ menuPortalTarget={typeof document !== "undefined" ? document.body : undefined}
// Clearing the input never reaches `reload`, so mirror every
// keystroke into the search state instead.
onInputChange={(inputValue) => onSearchChange(inputValue)}
@@ -319,24 +324,26 @@ export function EntitySelector(props: EntitySelectorProps) {
noOptionsMessage={() => (isError ? errorMessage : emptyMessage)}
views={{
option: (optionProps) => {
+ const { Option } = components;
const data = optionProps.data as Option;
+ const isLast = optionProps.options[optionProps.options.length - 1] === optionProps.data;
return (
- optionProps.selectOption(optionProps.data)}
>
- {data.label}
- {optionProps.isSelected && Selected }
+ {data.label}
+
{data.meta?.description && data.meta.description !== data.label && (
-
{data.meta.description}
+
{data.meta.description}
)}
-
+
);
},
}}
diff --git a/ui/components/filters/logsFilterSidebar.tsx b/ui/components/filters/logsFilterSidebar.tsx
index dec306f51bd..a04ea58b11d 100644
--- a/ui/components/filters/logsFilterSidebar.tsx
+++ b/ui/components/filters/logsFilterSidebar.tsx
@@ -112,6 +112,7 @@ export function LogsFilterSidebar({ filters, onFiltersChange }: LogsSidebarProps
+
@@ -260,6 +261,7 @@ function SearchableCheckboxList({
placeholder = "Search...",
inputRef,
testIdPrefix,
+ normalizeTestIdKey = false,
allowCustom = false,
onSearch,
fetching,
@@ -270,6 +272,10 @@ function SearchableCheckboxList({
placeholder?: string;
inputRef?: Ref;
testIdPrefix?: string;
+ // When true, item keys are slugified before composing the per-row data-testid
+ // (e.g. "Claude Desktop" -> "claude-desktop"). Use for free-form keys like app
+ // names so E2E selectors stay space/case-stable; leave off for already-safe keys.
+ normalizeTestIdKey?: boolean;
allowCustom?: boolean;
onSearch?: (query: string) => void;
fetching?: boolean;
@@ -324,7 +330,11 @@ function SearchableCheckboxList({
label={item.label}
checked={isSelected(item.key)}
onCheckedChange={() => onToggle(item.key)}
- testId={testIdPrefix ? `${testIdPrefix}-checkbox-${item.key}` : undefined}
+ testId={
+ testIdPrefix
+ ? `${testIdPrefix}-checkbox-${normalizeTestIdKey ? item.key.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") : item.key}`
+ : undefined
+ }
/>
))}
{filtered.length === 0 && !showAddCustom && (
@@ -424,6 +434,51 @@ function StopReasonFilter({ filters, onFiltersChange, defaultOpen }: FilterCompo
);
}
+// ---------------------------------------------------------------------------
+// AppFilter
+// ---------------------------------------------------------------------------
+
+function AppFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
+ const hasActive = (filters.apps || []).length > 0;
+ const [opened, setOpened] = useState(defaultOpen || hasActive);
+ const searchInputRef = useAutoFocusOnOpen(opened);
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetAvailableFilterDataQuery({ dimensions: ["apps"] }, { skip: !opened && !hasActive });
+ const availableApps = useMemo(() => (filterData?.apps as string[] | undefined) || [], [filterData]);
+ const items = useMemo(() => [...new Set([...availableApps, ...(filters.apps || [])])].sort().map((name) => ({ key: name, label: name })), [availableApps, filters.apps]);
+
+ if (!isUninitialized && !isLoading && availableApps.length === 0 && !hasActive && !opened) return null;
+
+ const selectedSet = new Set(filters.apps || []);
+
+ return (
+
+ selectedSet.has(appName)}
+ onToggle={(appName) => {
+ const current = filters.apps || [];
+ const next = current.includes(appName) ? current.filter((app) => app !== appName) : [...current, appName];
+ onFiltersChange({ ...filters, apps: next.length > 0 ? next : undefined });
+ }}
+ testIdPrefix="app-filter"
+ normalizeTestIdKey
+ />
+
+ );
+}
+
// ---------------------------------------------------------------------------
// ProvidersFilter – fetches providers internally
// ---------------------------------------------------------------------------
@@ -1230,4 +1285,4 @@ function MetadataFilters({ filters, onFiltersChange, defaultOpen }: FilterCompon
)}
);
-}
\ No newline at end of file
+}
diff --git a/ui/components/filters/mcpFilterSidebar.tsx b/ui/components/filters/mcpFilterSidebar.tsx
index 3b0267a2e5c..2cedefd86fb 100644
--- a/ui/components/filters/mcpFilterSidebar.tsx
+++ b/ui/components/filters/mcpFilterSidebar.tsx
@@ -107,6 +107,7 @@ export function MCPFilterSidebar({ filters, onFiltersChange }: MCPFilterSidebarP
{/* Rest closed unless they have active filters */}
+
@@ -147,12 +148,14 @@ function FilterSection({
defaultOpen = false,
loading = false,
onOpenChange,
+ testId,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
loading?: boolean;
onOpenChange?: (open: boolean) => void;
+ testId?: string;
}) {
const [open, setOpen] = useState(defaultOpen);
@@ -167,7 +170,10 @@ function FilterSection({
return (
-
+
{title}
@@ -187,14 +193,16 @@ function CheckboxFilterItem({
checked,
onCheckedChange,
labelClassName,
+ testId,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
labelClassName?: string;
+ testId?: string;
}) {
return (
-
+
{label}
@@ -220,6 +228,8 @@ function SearchableCheckboxList({
onToggle,
placeholder = "Search...",
inputRef,
+ testIdPrefix,
+ normalizeTestIdKey = false,
allowCustom = false,
onSearch,
fetching,
@@ -229,6 +239,11 @@ function SearchableCheckboxList({
onToggle: (key: string) => void;
placeholder?: string;
inputRef?: Ref;
+ testIdPrefix?: string;
+ // When true, item keys are slugified before composing the per-row data-testid
+ // (e.g. "Claude Desktop" -> "claude-desktop"). Use for free-form keys like app
+ // names so E2E selectors stay space/case-stable; leave off for already-safe keys.
+ normalizeTestIdKey?: boolean;
allowCustom?: boolean;
onSearch?: (query: string) => void;
fetching?: boolean;
@@ -274,10 +289,21 @@ function SearchableCheckboxList({
}}
placeholder={placeholder}
className="h-8 border-0 pl-8 text-xs"
+ data-testid={testIdPrefix ? `${testIdPrefix}-search` : undefined}
/>
{filtered.map((item) => (
- onToggle(item.key)} />
+ onToggle(item.key)}
+ testId={
+ testIdPrefix
+ ? `${testIdPrefix}-checkbox-${normalizeTestIdKey ? item.key.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") : item.key}`
+ : undefined
+ }
+ />
))}
{filtered.length === 0 && !showAddCustom && (
No results
@@ -287,6 +313,7 @@ function SearchableCheckboxList({
type="button"
onClick={commitCustom}
className="hover:bg-muted/50 flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm"
+ data-testid={testIdPrefix ? `${testIdPrefix}-add-custom` : undefined}
>
@@ -412,6 +439,51 @@ function ServersFilter({ filters, onFiltersChange, defaultOpen }: FilterComponen
);
}
+// ---------------------------------------------------------------------------
+// AppFilter
+// ---------------------------------------------------------------------------
+
+function AppFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
+ const hasActive = (filters.apps || []).length > 0;
+ const [opened, setOpened] = useState(defaultOpen || hasActive);
+ const searchInputRef = useAutoFocusOnOpen(opened);
+ const {
+ data: filterData,
+ isUninitialized,
+ isLoading,
+ } = useGetMCPLogsFilterDataQuery({ dimensions: ["apps"] }, { skip: !opened && !hasActive });
+ const availableApps = useMemo(() => (filterData?.apps as string[] | undefined) || [], [filterData]);
+ const items = useMemo(() => [...new Set([...availableApps, ...(filters.apps || [])])].sort().map((name) => ({ key: name, label: name })), [availableApps, filters.apps]);
+
+ if (!isUninitialized && !isLoading && availableApps.length === 0 && !hasActive && !opened) return null;
+
+ const selectedSet = new Set(filters.apps || []);
+
+ return (
+
+ selectedSet.has(appName)}
+ onToggle={(appName) => {
+ const current = filters.apps || [];
+ const next = current.includes(appName) ? current.filter((app) => app !== appName) : [...current, appName];
+ onFiltersChange({ ...filters, apps: next.length > 0 ? next : undefined });
+ }}
+ testIdPrefix="mcp-app-filter"
+ normalizeTestIdKey
+ />
+
+ );
+}
+
// ---------------------------------------------------------------------------
// VirtualKeysFilter – fetches virtual keys; maps name→ID
// ---------------------------------------------------------------------------
@@ -457,4 +529,4 @@ function VirtualKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComp
/>
);
-}
\ No newline at end of file
+}
diff --git a/ui/components/sidebar.tsx b/ui/components/sidebar.tsx
index 61a69c96218..0ea5d7b9415 100644
--- a/ui/components/sidebar.tsx
+++ b/ui/components/sidebar.tsx
@@ -20,6 +20,10 @@ import {
History,
KeyRound,
Landmark,
+ Hexagon,
+ BadgeCheck,
+ BadgeInfo,
+ LaptopMinimalCheck,
LayoutGrid,
LogOut,
Logs,
@@ -279,14 +283,15 @@ const SidebarItemView = ({
const isHighlighted = !hasSubItems && highlightedUrl === item.url;
- const buttonClassName = `group/nav-item relative h-7.5 cursor-pointer rounded-sm border px-3 transition-all duration-200 ${isHighlighted
- ? "bg-sidebar-accent text-accent-foreground border-primary/20"
- : isActive || isAnySubItemActive
- ? "bg-sidebar-accent text-primary border-primary/20"
- : item.hasAccess
- ? "hover:bg-sidebar-accent hover:text-accent-foreground border-transparent text-slate-500 dark:text-zinc-400"
- : "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
- } `;
+ const buttonClassName = `group/nav-item relative h-7.5 cursor-pointer rounded-sm border px-3 transition-all duration-200 ${
+ isHighlighted
+ ? "bg-sidebar-accent text-accent-foreground border-primary/20"
+ : isActive || isAnySubItemActive
+ ? "bg-sidebar-accent text-primary border-primary/20"
+ : item.hasAccess
+ ? "hover:bg-sidebar-accent hover:text-accent-foreground border-transparent text-slate-500 dark:text-zinc-400"
+ : "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
+ } `;
const innerContent = (
@@ -295,11 +300,6 @@ const SidebarItemView = ({
{item.title}
- {item.new && (
-
- New
-
- )}
{item.tag && (
{item.tag}
@@ -399,11 +399,6 @@ const SidebarItemView = ({
{subItem.title}
- {subItem.new && (
-
- New
-
- )}
{subItem.tag && (
{subItem.tag}
@@ -447,23 +442,19 @@ const SidebarItemView = ({
const isSubItemActive = subItem.queryParam ? pathname === subItem.url : isRouteMatch(subItem.url);
const isSubItemHighlighted = highlightedUrl ? subItemHref.startsWith(highlightedUrl) : false;
const SubItemIcon = subItem.icon;
- const subItemClassName = `group/nav-item h-7 cursor-pointer rounded-sm px-2 transition-all duration-200 ${isSubItemHighlighted
- ? "bg-sidebar-accent text-accent-foreground"
- : isSubItemActive
- ? "bg-sidebar-accent text-primary font-medium"
- : subItem.hasAccess === false
- ? "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
- : "hover:bg-sidebar-accent hover:text-accent-foreground text-slate-500 dark:text-zinc-400"
- }`;
+ const subItemClassName = `h-7 cursor-pointer rounded-sm px-2 transition-all duration-200 ${
+ isSubItemHighlighted
+ ? "bg-sidebar-accent text-accent-foreground"
+ : isSubItemActive
+ ? "bg-sidebar-accent text-primary font-medium"
+ : subItem.hasAccess === false
+ ? "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
+ : "hover:bg-sidebar-accent hover:text-accent-foreground text-slate-500 dark:text-zinc-400"
+ }`;
const subInner = (
{SubItemIcon &&
}
{subItem.title}
- {subItem.new && (
-
- New
-
- )}
{subItem.tag && (
{subItem.tag}
@@ -591,6 +582,10 @@ export default function AppSidebar() {
const hasAPIKeyAccess = useRbac(RbacResource.APIKeys, RbacOperation.View);
const hasPromptRepositoryAccess = useRbac(RbacResource.PromptRepository, RbacOperation.View);
const hasSkillsRepositoryAccess = useRbac(RbacResource.SkillsRepository, RbacOperation.View);
+ const hasDevicesAccess = useRbac(RbacResource.Devices, RbacOperation.View);
+ const hasInventoryAccess = useRbac(RbacResource.Inventory, RbacOperation.View);
+ const hasEdgeConfigAccess = useRbac(RbacResource.EdgeConfig, RbacOperation.View);
+ const hasAnyEdgeControlAccess = hasDevicesAccess || hasInventoryAccess || hasEdgeConfigAccess;
const hasAccessProfilesAccess = useRbac(RbacResource.AccessProfiles, RbacOperation.View);
const hasAnyGovernanceAccess =
hasVirtualKeysAccess ||
@@ -906,6 +901,36 @@ export default function AppSidebar() {
description: "Async job webhook endpoints",
hasAccess: hasGovernanceLegacyAccess,
},
+ {
+ title: "Edge Control",
+ icon: Hexagon,
+ description: "Edge device management",
+ url: "/workspace/edge-control",
+ hasAccess: hasAnyEdgeControlAccess,
+ subItems: [
+ {
+ title: "Devices",
+ url: "/workspace/edge-control/devices",
+ icon: LaptopMinimalCheck,
+ description: "Manage edge devices",
+ hasAccess: hasDevicesAccess,
+ },
+ {
+ title: "Approvals",
+ url: "/workspace/edge-control/inventory",
+ icon: BadgeCheck,
+ description: "Approve apps and MCP servers",
+ hasAccess: hasInventoryAccess,
+ },
+ {
+ title: "Edge Settings",
+ url: "/workspace/edge-control/config",
+ icon: Settings,
+ description: "Edge settings",
+ hasAccess: hasEdgeConfigAccess,
+ },
+ ],
+ },
{
title: "Cluster Config",
url: "/workspace/cluster",
@@ -938,21 +963,21 @@ export default function AppSidebar() {
},
...(isDbConnected
? [
- {
- title: "Prompt Repository",
- url: "/workspace/prompt-repo",
- icon: FolderGit,
- description: "Prompt repository",
- hasAccess: hasPromptRepositoryAccess,
- },
- {
- title: "Skills Repository",
- url: "/workspace/skills-repo",
- icon: BookOpenText,
- description: "Skills repository",
- hasAccess: hasSkillsRepositoryAccess,
- },
- ]
+ {
+ title: "Prompt Repository",
+ url: "/workspace/prompt-repo",
+ icon: FolderGit,
+ description: "Prompt repository",
+ hasAccess: hasPromptRepositoryAccess,
+ },
+ {
+ title: "Skills Repository",
+ url: "/workspace/skills-repo",
+ icon: BookOpenText,
+ description: "Skills repository",
+ hasAccess: hasSkillsRepositoryAccess,
+ },
+ ]
: []),
{
title: "Evals",
@@ -999,14 +1024,14 @@ export default function AppSidebar() {
},
...(IS_ENTERPRISE
? [
- {
- title: "Proxy",
- url: "/workspace/config/proxy",
- icon: Globe,
- description: "Proxy configuration",
- hasAccess: hasSettingsAccess,
- },
- ]
+ {
+ title: "Proxy",
+ url: "/workspace/config/proxy",
+ icon: Globe,
+ description: "Proxy configuration",
+ hasAccess: hasSettingsAccess,
+ },
+ ]
: []),
{
title: "API Keys",
@@ -1029,6 +1054,17 @@ export default function AppSidebar() {
description: "Toggle feature flags",
hasAccess: hasFeatureFlagsAccess,
},
+ ...(IS_ENTERPRISE
+ ? [
+ {
+ title: "License Info",
+ url: "/workspace/config/license",
+ icon: BadgeInfo,
+ description: "Enterprise license information",
+ hasAccess: hasSettingsAccess,
+ },
+ ]
+ : []),
],
},
],
@@ -1063,6 +1099,12 @@ export default function AppSidebar() {
hasPromptRepositoryAccess,
hasSkillsRepositoryAccess,
hasAccessProfilesAccess,
+ hasAccessProfilesAccess,
+ hasFeatureFlagsAccess,
+ hasDevicesAccess,
+ hasInventoryAccess,
+ hasEdgeConfigAccess,
+ hasAnyEdgeControlAccess,
isDbConnected,
],
);
@@ -1560,4 +1602,4 @@ export default function AppSidebar() {
);
-}
+}
\ No newline at end of file
diff --git a/ui/components/ui/asyncMultiselect.tsx b/ui/components/ui/asyncMultiselect.tsx
index 9ab779751f0..fa9ef925d9a 100644
--- a/ui/components/ui/asyncMultiselect.tsx
+++ b/ui/components/ui/asyncMultiselect.tsx
@@ -421,7 +421,11 @@ export function AsyncMultiSelect(props: AsyncMultiSelectProps) {
}
inputValue={props.inputValue}
styles={{
- menuPortal: (base) => ({ ...base, zIndex: 9999 }),
+ // react-remove-scroll (used by Radix Dialog/Sheet while open) sets
+ // `pointer-events: none` on body; a menu portaled to body inherits
+ // that and never gets it back, so force it here (same fix as the
+ // Sonner toaster override in globals.css).
+ menuPortal: (base) => ({ ...base, zIndex: 9999, pointerEvents: "auto" }),
control: (base) => ({ ...base, boxShadow: "none", minHeight: "32px" }),
multiValue: () => ({}),
multiValueLabel: () => ({}),
diff --git a/ui/lib/constants/logs.test.ts b/ui/lib/constants/logs.test.ts
index a52cd058a91..a9ff30a0fce 100644
--- a/ui/lib/constants/logs.test.ts
+++ b/ui/lib/constants/logs.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { RequestTypeColors, RequestTypeLabels, RequestTypes } from "./logs";
+import { mapAppToClientApp, mapUserAgentToApp, RequestTypeColors, RequestTypeLabels, RequestTypes } from "./logs";
describe("logs constants", () => {
it("registers realtime turn as a known request type", () => {
@@ -8,4 +8,15 @@ describe("logs constants", () => {
expect(RequestTypeLabels["realtime.turn"]).toBe("Realtime Turn");
expect(RequestTypeColors["realtime.turn"]).toBeTruthy();
});
-});
\ No newline at end of file
+
+ it("maps backend app names to display metadata", () => {
+ expect(mapAppToClientApp("Claude Code").name).toBe("Claude Code");
+ expect(mapAppToClientApp("Claude Code").icon).toBe("/images/claude-code.png");
+ expect(mapAppToClientApp("Claude Chat Web").icon).toBe("/images/claude-desktop.png");
+ expect(mapAppToClientApp("Custom App").name).toBe("Custom App");
+ });
+
+ it("maps versioned user agents as a fallback for older rows", () => {
+ expect(mapUserAgentToApp("claude-cli/2.1.168 (external, cli)").name).toBe("Claude Code");
+ });
+});
diff --git a/ui/lib/constants/logs.ts b/ui/lib/constants/logs.ts
index 1184b443613..41fd590f96c 100644
--- a/ui/lib/constants/logs.ts
+++ b/ui/lib/constants/logs.ts
@@ -155,6 +155,72 @@ export const getProviderLabel = (provider: string): string => {
return provider;
};
+// ClientApp is the display info for a client application resolved from a raw
+// User-Agent string. `icon`, when set, is a path under /public/images.
+export interface ClientApp {
+ name: string;
+ icon?: string;
+}
+
+// userAgentAppMatchers maps User-Agent substrings to a client app. The DB stores
+// the raw User-Agent verbatim and this is the single place the UI maps it to an
+// app for the logs table, the "App" filter, and metrics breakdowns.
+//
+// Matching is case-insensitive substring matching against the lowercased UA, and
+// is evaluated top-to-bottom: list more specific identifiers first (e.g. a Roo
+// fork "kilo" before "roo", "roo" before its "cline" ancestor). Versions change
+// every release, so never match on an exact string. Identifiers are best-effort
+// and meant to be extended as new clients appear.
+const userAgentAppMatchers: { identifiers: string[]; app: ClientApp }[] = [
+ { identifiers: ["chatgpt-web"], app: { name: "ChatGPT Web", icon: "/images/openai.png" } },
+ { identifiers: ["claude-chat-web", "claude-web"], app: { name: "Claude Chat Web", icon: "/images/claude-desktop.png" } },
+ { identifiers: ["claude-desktop"], app: { name: "Claude Desktop", icon: "/images/claude-desktop.png" } },
+ { identifiers: ["claude-code", "claude-cli", "claude-vscode"], app: { name: "Claude Code", icon: "/images/claude-code.png" } },
+ { identifiers: ["codex-cli", "codex-tui"], app: { name: "Codex CLI", icon: "/images/codex.png" } },
+ { identifiers: ["codex-desktop"], app: { name: "Codex Desktop", icon: "/images/codex.png" } },
+ { identifiers: ["codex"], app: { name: "Codex Desktop", icon: "/images/codex.png" } },
+ { identifiers: ["cursor"], app: { name: "Cursor", icon: "/images/cursor.png" } },
+ { identifiers: ["kilo"], app: { name: "Kilo Code", icon: "/images/kilo-code.png" } },
+ { identifiers: ["roo"], app: { name: "Roo Code", icon: "/images/roo-code.png" } },
+ { identifiers: ["cline"], app: { name: "Cline", icon: "/images/cline.png" } },
+ { identifiers: ["opencode"], app: { name: "OpenCode", icon: "/images/opencode.png" } },
+ { identifiers: ["windsurf"], app: { name: "Windsurf", icon: "/images/windsurf.png" } },
+ { identifiers: ["gemini", "geminicli"], app: { name: "Gemini CLI", icon: "/images/gemini-cli.png" } },
+ { identifiers: ["qwencode", "qwen"], app: { name: "Qwen Code" } },
+];
+
+const appByName = new Map(userAgentAppMatchers.map((matcher) => [matcher.app.name, matcher.app]));
+
+export const mapAppToClientApp = (app?: string | null): ClientApp => {
+ if (!app || app.trim() === "") {
+ return { name: "Unknown" };
+ }
+ return appByName.get(app) || { name: app };
+};
+
+// mapUserAgentToApp resolves a raw User-Agent string to a client app for display.
+// Returns { name: "Unknown" } for an empty/absent UA and { name: "Other" } for a
+// UA that matches no known client (so it can still be grouped and filtered).
+export const mapUserAgentToApp = (userAgent?: string | null): ClientApp => {
+ if (!userAgent || userAgent.trim() === "") {
+ return { name: "Unknown" };
+ }
+ const ua = userAgent.toLowerCase();
+ for (const matcher of userAgentAppMatchers) {
+ if (matcher.identifiers.some((id) => ua.includes(id))) {
+ return matcher.app;
+ }
+ }
+ return { name: "Other" };
+};
+
+export const logAppDisplayName = (app: ClientApp, userAgent?: string | null): string => {
+ if ((app.name === "Unknown" || app.name === "Other") && userAgent?.trim()) {
+ return userAgent.trim();
+ }
+ return app.name;
+};
+
export const StatusColors = {
success: "bg-green-100 text-green-800",
error: "bg-red-100 text-red-800",
@@ -360,4 +426,4 @@ export const RoutingEngineUsedColors = {
core: "bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300",
} as const;
-export type Status = (typeof Statuses)[number];
\ No newline at end of file
+export type Status = (typeof Statuses)[number];
diff --git a/ui/lib/store/apis/baseApi.ts b/ui/lib/store/apis/baseApi.ts
index b07be3e7892..10ed6f13031 100644
--- a/ui/lib/store/apis/baseApi.ts
+++ b/ui/lib/store/apis/baseApi.ts
@@ -42,10 +42,13 @@ export const clearAuthStorage = () => {
const baseQuery = fetchBaseQuery({
baseUrl: getApiBaseUrl(),
credentials: "include",
- prepareHeaders: async (headers) => {
- // Do not force a default Content-Type here. JSON bodies are handled by
- // fetchBaseQuery, while FormData uploads need the browser-generated
- // multipart boundary.
+ prepareHeaders: async (headers, { arg }) => {
+ // Skip the JSON default for multipart/FormData uploads so the browser
+ // can set Content-Type with the multipart boundary itself (e.g. uploadSkillFile).
+ const isFormData = arg !== null && typeof arg === "object" && arg.body instanceof FormData;
+ if (!isFormData && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
// Automatically include token from localStorage in Authorization header
const token = await getTokenFromStorage();
if (token) {
@@ -192,6 +195,8 @@ export const baseApi = createApi({
"ComplexityAnalyzerConfig",
"Skills",
"OAuth2Grants",
+ "UserAgentMappings",
+ "Devices",
"CircuitBreakerPolicies",
"CircuitBreakerState",
"AlertChannels",
@@ -199,6 +204,9 @@ export const baseApi = createApi({
"AlertHistory",
"WebhookEndpoints",
"WebhookDeliveries",
+ "EdgeApps",
+ "EdgeMCPServers",
+ "EdgeConfig",
],
endpoints: () => ({}),
});
diff --git a/ui/lib/store/apis/configApi.ts b/ui/lib/store/apis/configApi.ts
index 877c5e6f99f..7037fc84cb7 100644
--- a/ui/lib/store/apis/configApi.ts
+++ b/ui/lib/store/apis/configApi.ts
@@ -20,6 +20,36 @@ const applyMetadataPatch = (metadata: BifrostConfig["metadata"] | undefined, pat
export const configApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
+ getUserAgentMappings: builder.query<{ mappings: UserAgentMapping[] }, void>({
+ query: () => ({
+ url: "/logs/user-agent-mappings",
+ }),
+ providesTags: ["UserAgentMappings"],
+ }),
+ createUserAgentMapping: builder.mutation({
+ query: (data) => ({
+ url: "/logs/user-agent-mappings",
+ method: "POST",
+ body: data,
+ }),
+ invalidatesTags: ["UserAgentMappings"],
+ }),
+ updateUserAgentMapping: builder.mutation({
+ query: ({ id, data }) => ({
+ url: `/logs/user-agent-mappings/${id}`,
+ method: "PUT",
+ body: data,
+ }),
+ invalidatesTags: ["UserAgentMappings"],
+ }),
+ deleteUserAgentMapping: builder.mutation<{ success: boolean }, string>({
+ query: (id) => ({
+ url: `/logs/user-agent-mappings/${id}`,
+ method: "DELETE",
+ }),
+ invalidatesTags: ["UserAgentMappings"],
+ }),
+
// Get core configuration
getCoreConfig: builder.query({
query: ({ fromDB = false } = {}) => ({
@@ -142,6 +172,29 @@ export const configApi = baseApi.injectEndpoints({
}),
});
+export type UserAgentMappingMatchType = "contains" | "starts_with" | "exact" | "regex";
+
+export interface UserAgentMapping {
+ id: string;
+ pattern: string;
+ match_type: UserAgentMappingMatchType;
+ app: string;
+ logo?: string;
+ logo_mime?: string | null;
+ is_active: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface UserAgentMappingPayload {
+ pattern: string;
+ match_type: UserAgentMappingMatchType;
+ app: string;
+ logo?: string;
+ logo_mime?: string | null;
+ is_active: boolean;
+}
+
export const {
useGetVersionQuery,
useGetCoreConfigQuery,
@@ -152,4 +205,8 @@ export const {
useLazyGetCoreConfigQuery,
useGetLatestReleaseQuery,
useLazyGetLatestReleaseQuery,
-} = configApi;
\ No newline at end of file
+ useGetUserAgentMappingsQuery,
+ useCreateUserAgentMappingMutation,
+ useUpdateUserAgentMappingMutation,
+ useDeleteUserAgentMappingMutation,
+} = configApi;
diff --git a/ui/lib/store/apis/logsApi.ts b/ui/lib/store/apis/logsApi.ts
index 0717f54ac90..bffd8ef595b 100644
--- a/ui/lib/store/apis/logsApi.ts
+++ b/ui/lib/store/apis/logsApi.ts
@@ -89,6 +89,12 @@ function buildFilterParams(filters: LogFilters): Record
if (filters.business_unit_ids && filters.business_unit_ids.length > 0) {
params.business_unit_ids = filters.business_unit_ids.join(",");
}
+ if (filters.apps && filters.apps.length > 0) {
+ params.apps = JSON.stringify(filters.apps);
+ }
+ if (filters.user_agents && filters.user_agents.length > 0) {
+ params.user_agents = JSON.stringify(filters.user_agents);
+ }
if (filters.metadata_filters) {
for (const [key, value] of Object.entries(filters.metadata_filters)) {
params[`metadata_${key}`] = value;
@@ -368,6 +374,8 @@ export const logsApi = baseApi.injectEndpoints({
routing_rules?: RoutingRule[];
routing_engines?: string[];
stop_reasons?: string[];
+ apps?: string[];
+ user_agents?: string[];
teams?: { id: string; name: string }[];
customers?: { id: string; name: string }[];
users?: { id: string; name: string }[];
diff --git a/ui/lib/store/apis/mcpLogsApi.ts b/ui/lib/store/apis/mcpLogsApi.ts
index 0fbb352130d..556eefffb28 100644
--- a/ui/lib/store/apis/mcpLogsApi.ts
+++ b/ui/lib/store/apis/mcpLogsApi.ts
@@ -28,6 +28,12 @@ function buildMCPFilterParams(filters: MCPToolLogFilters): Record 0) {
params.llm_request_ids = filters.llm_request_ids.join(",");
}
+ if (filters.apps && filters.apps.length > 0) {
+ params.apps = JSON.stringify(filters.apps);
+ }
+ if (filters.user_agents && filters.user_agents.length > 0) {
+ params.user_agents = JSON.stringify(filters.user_agents);
+ }
if (filters.period) {
params.period = filters.period;
} else {
@@ -78,6 +84,12 @@ export const mcpLogsApi = baseApi.injectEndpoints({
if (filters.llm_request_ids && filters.llm_request_ids.length > 0) {
params.llm_request_ids = filters.llm_request_ids.join(",");
}
+ if (filters.apps && filters.apps.length > 0) {
+ params.apps = JSON.stringify(filters.apps);
+ }
+ if (filters.user_agents && filters.user_agents.length > 0) {
+ params.user_agents = JSON.stringify(filters.user_agents);
+ }
if (filters.period) {
params.period = filters.period;
} else {
@@ -128,6 +140,12 @@ export const mcpLogsApi = baseApi.injectEndpoints({
if (filters.llm_request_ids && filters.llm_request_ids.length > 0) {
params.llm_request_ids = filters.llm_request_ids.join(",");
}
+ if (filters.apps && filters.apps.length > 0) {
+ params.apps = JSON.stringify(filters.apps);
+ }
+ if (filters.user_agents && filters.user_agents.length > 0) {
+ params.user_agents = JSON.stringify(filters.user_agents);
+ }
if (filters.period) {
params.period = filters.period;
} else {
@@ -221,4 +239,4 @@ export const {
useLazyGetMCPCostHistogramQuery,
useLazyGetMCPTopToolsQuery,
useDeleteMCPLogsMutation,
-} = mcpLogsApi;
\ No newline at end of file
+} = mcpLogsApi;
diff --git a/ui/lib/store/store.ts b/ui/lib/store/store.ts
index 07cb6f08549..f2c2986c3b6 100644
--- a/ui/lib/store/store.ts
+++ b/ui/lib/store/store.ts
@@ -1,7 +1,7 @@
import { configureStore } from "@reduxjs/toolkit";
import { baseApi } from "./apis/baseApi";
import { appReducer, pluginReducer, providerReducer } from "./slices";
-import { reducers as enterpriseReducers, type EnterpriseState } from "@enterprise/lib/store/slices";
+import { middleware as enterpriseMiddleware, reducers as enterpriseReducers, type EnterpriseState } from "@enterprise/lib/store/slices";
// Importing enterprise APIs triggers their self-injection into baseApi
import "@enterprise/lib/store/apis";
@@ -37,7 +37,7 @@ export const store = configureStore({
// Ignore these paths in the state
ignoredPaths: ["api.queries", "api.mutations"],
},
- }).concat(baseApi.middleware),
+ }).concat(baseApi.middleware, ...enterpriseMiddleware),
devTools: process.env.NODE_ENV !== "production",
});
diff --git a/ui/lib/types/logs.ts b/ui/lib/types/logs.ts
index 25b1ca28565..e0e6f064d69 100644
--- a/ui/lib/types/logs.ts
+++ b/ui/lib/types/logs.ts
@@ -497,6 +497,11 @@ export interface KeyAttemptRecord {
fail_reason?: string | null; // null/undefined on the final (successful or last) attempt
}
+export interface RedactionMapping {
+ input?: Record;
+ output?: Record;
+}
+
export interface LogEntry {
id: string;
object: string; // text.completion, chat.completion, embedding, audio.speech, audio.transcription
@@ -586,10 +591,9 @@ export interface LogEntry {
passthrough_request_body?: string; // Raw passthrough request body (UTF-8)
passthrough_response_body?: string; // Raw passthrough response body (UTF-8)
metadata?: Record; // JSON metadata (e.g., isAsyncRequest)
- redaction_mapping?: {
- input?: Record;
- output?: Record;
- }; // Phase-scoped placeholder-to-original mappings, present only when caller has Logs:Reveal
+ redaction_mapping?: RedactionMapping; // Phase-scoped placeholder-to-original mappings, present only when caller has Logs:Reveal
+ user_agent?: string; // Raw HTTP User-Agent of the calling client
+ app?: string; // Backend-detected client app
}
export interface LogFilters {
@@ -619,6 +623,8 @@ export interface LogFilters {
team_ids?: string[];
customer_ids?: string[];
business_unit_ids?: string[];
+ apps?: string[]; // Backend-detected client apps
+ user_agents?: string[]; // Raw User-Agent strings; kept for backward compatibility/debug filtering
}
export interface Pagination {
@@ -1137,8 +1143,12 @@ export interface MCPToolLogEntry {
cost?: number; // Cost in dollars (per execution cost)
status: string; // "processing", "success", or "error"
metadata?: Record;
+ plugin_logs?: string; // JSON string of plugin execution logs grouped by plugin name
+ redaction_mapping?: RedactionMapping; // Present on detail responses only when the caller has Logs:Reveal
created_at: string; // ISO string format
virtual_key?: VirtualKey;
+ user_agent?: string; // Raw HTTP User-Agent of the calling client
+ app?: string; // Backend-detected client app
}
// MCP Tool Log Filters
@@ -1154,6 +1164,8 @@ export interface MCPToolLogFilters {
min_latency?: number;
max_latency?: number;
content_search?: string;
+ apps?: string[]; // Backend-detected client apps
+ user_agents?: string[]; // Raw User-Agent strings; kept for backward compatibility/debug filtering
}
// MCP Tool Log Statistics
@@ -1175,6 +1187,8 @@ export interface MCPToolLogsResponse {
export interface MCPToolLogFilterData {
tool_names: string[];
server_labels: string[];
+ apps: string[];
+ user_agents: string[];
virtual_keys: VirtualKey[];
}
@@ -1267,7 +1281,7 @@ export interface UserRankingsResponse {
rankings: UserRankingEntry[];
}
-export type RankingDimension = "team" | "customer" | "business_unit" | "user" | "virtual_key";
+export type RankingDimension = "team" | "customer" | "business_unit" | "user" | "app" | "user_agent" | "virtual_key";
export interface DimensionRankingTrend {
has_previous_period: boolean;
diff --git a/ui/lib/utils/redaction.test.ts b/ui/lib/utils/redaction.test.ts
new file mode 100644
index 00000000000..a0ff3bdd235
--- /dev/null
+++ b/ui/lib/utils/redaction.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { applyRedactionMapping, applyRedactionMappingToValue, hasRedactionMappingEntries, mergeRedactionMappings } from "./redaction";
+
+describe("redaction reveal helpers", () => {
+ it("requires at least one phase mapping", () => {
+ expect(hasRedactionMappingEntries()).toBe(false);
+ expect(hasRedactionMappingEntries({ input: {}, output: {} })).toBe(false);
+ expect(hasRedactionMappingEntries({ input: { "EMAIL-1": "private@example.com" } })).toBe(true);
+ });
+
+ it("reveals placeholders without mutating structured log data", () => {
+ const source = { owner: "[EMAIL-1]", nested: ["hello [NAME-1]"] };
+ const revealed = applyRedactionMappingToValue(source, {
+ "EMAIL-1": "private@example.com",
+ "NAME-1": "Madhu",
+ });
+
+ expect(revealed).toEqual({ owner: "private@example.com", nested: ["hello Madhu"] });
+ expect(source).toEqual({ owner: "[EMAIL-1]", nested: ["hello [NAME-1]"] });
+ });
+
+ it("reveals each source placeholder once without reprocessing replacement text", () => {
+ expect(applyRedactionMapping("[A] [B]", { A: "[B]", B: "$&" })).toBe("[B] $&");
+ });
+
+ it("leaves conflicting phase placeholders redacted in mixed fields", () => {
+ const merged = mergeRedactionMappings({
+ input: { "SECRET-1": "input", "INPUT-ONLY": "request" },
+ output: { "SECRET-1": "output", "OUTPUT-ONLY": "response" },
+ });
+
+ expect(applyRedactionMapping("[SECRET-1] [INPUT-ONLY] [OUTPUT-ONLY]", merged)).toBe("[SECRET-1] request response");
+ });
+
+ it("keeps identical phase mappings revealable", () => {
+ const merged = mergeRedactionMappings({ input: { "SECRET-1": "same" }, output: { "SECRET-1": "same" } });
+ expect(applyRedactionMapping("[SECRET-1]", merged)).toBe("same");
+ });
+});
\ No newline at end of file
diff --git a/ui/lib/utils/redaction.ts b/ui/lib/utils/redaction.ts
new file mode 100644
index 00000000000..6b7804b19b1
--- /dev/null
+++ b/ui/lib/utils/redaction.ts
@@ -0,0 +1,41 @@
+import type { RedactionMapping } from "@/lib/types/logs";
+
+// hasRedactionMappingEntries reports whether a detail response contains anything the UI can reveal.
+export function hasRedactionMappingEntries(mapping?: RedactionMapping): boolean {
+ return Boolean(mapping && (Object.keys(mapping.input ?? {}).length > 0 || Object.keys(mapping.output ?? {}).length > 0));
+}
+
+// applyRedactionMapping replaces reversible placeholders in display text without mutating source data.
+export function applyRedactionMapping(text: string | undefined, mapping?: Record): string {
+ if (!text || !mapping) return text || "";
+ return text.replace(/\[([^\]]+)\]/g, (placeholder, key: string) =>
+ Object.prototype.hasOwnProperty.call(mapping, key) ? mapping[key] : placeholder,
+ );
+}
+
+// mergeRedactionMappings combines phase maps for fields, such as errors, that can contain input and output content.
+export function mergeRedactionMappings(mapping?: RedactionMapping): Record | undefined {
+ if (!mapping) return undefined;
+ const merged = { ...mapping.input };
+ for (const [key, value] of Object.entries(mapping.output ?? {})) {
+ if (Object.prototype.hasOwnProperty.call(merged, key) && merged[key] !== value) {
+ delete merged[key];
+ continue;
+ }
+ merged[key] = value;
+ }
+ return Object.keys(merged).length > 0 ? merged : undefined;
+}
+
+// applyRedactionMappingToValue recursively reveals JSON-like display values while preserving the fetched object.
+export function applyRedactionMappingToValue(value: T, mapping?: Record): T {
+ if (!mapping || value == null) return value;
+ if (typeof value === "string") return applyRedactionMapping(value, mapping) as T;
+ if (Array.isArray(value)) return value.map((item) => applyRedactionMappingToValue(item, mapping)) as T;
+ if (typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [applyRedactionMapping(key, mapping), applyRedactionMappingToValue(item, mapping)]),
+ ) as T;
+ }
+ return value;
+}
\ No newline at end of file
diff --git a/ui/public/images/amp.png b/ui/public/images/amp.png
new file mode 100644
index 00000000000..51925d39e95
Binary files /dev/null and b/ui/public/images/amp.png differ
diff --git a/ui/public/images/claude-code.png b/ui/public/images/claude-code.png
new file mode 100644
index 00000000000..7510b3b1908
Binary files /dev/null and b/ui/public/images/claude-code.png differ
diff --git a/ui/public/images/claude-desktop.png b/ui/public/images/claude-desktop.png
new file mode 100644
index 00000000000..6d4076caf69
Binary files /dev/null and b/ui/public/images/claude-desktop.png differ
diff --git a/ui/public/images/cline.png b/ui/public/images/cline.png
new file mode 100644
index 00000000000..dbf6f7db574
Binary files /dev/null and b/ui/public/images/cline.png differ
diff --git a/ui/public/images/codex.png b/ui/public/images/codex.png
new file mode 100644
index 00000000000..afa27436464
Binary files /dev/null and b/ui/public/images/codex.png differ
diff --git a/ui/public/images/copilot.png b/ui/public/images/copilot.png
new file mode 100644
index 00000000000..d5f26cfa70b
Binary files /dev/null and b/ui/public/images/copilot.png differ
diff --git a/ui/public/images/cursor.png b/ui/public/images/cursor.png
new file mode 100644
index 00000000000..b5e399fa755
Binary files /dev/null and b/ui/public/images/cursor.png differ
diff --git a/ui/public/images/gemini-cli.png b/ui/public/images/gemini-cli.png
new file mode 100644
index 00000000000..0a3bfb875aa
Binary files /dev/null and b/ui/public/images/gemini-cli.png differ
diff --git a/ui/public/images/kilo-code.png b/ui/public/images/kilo-code.png
new file mode 100644
index 00000000000..4be725134f1
Binary files /dev/null and b/ui/public/images/kilo-code.png differ
diff --git a/ui/public/images/linux.png b/ui/public/images/linux.png
new file mode 100644
index 00000000000..fc1ffd38d2c
Binary files /dev/null and b/ui/public/images/linux.png differ
diff --git a/ui/public/images/mac.png b/ui/public/images/mac.png
new file mode 100644
index 00000000000..deb8985c90f
Binary files /dev/null and b/ui/public/images/mac.png differ
diff --git a/ui/public/images/mcp-servers/linear.svg b/ui/public/images/mcp-servers/linear.svg
deleted file mode 100644
index 99209b48848..00000000000
--- a/ui/public/images/mcp-servers/linear.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/ui/public/images/openai.png b/ui/public/images/openai.png
new file mode 100644
index 00000000000..6484bfbf6e9
Binary files /dev/null and b/ui/public/images/openai.png differ
diff --git a/ui/public/images/opencode.png b/ui/public/images/opencode.png
new file mode 100644
index 00000000000..0cddcc17ca3
Binary files /dev/null and b/ui/public/images/opencode.png differ
diff --git a/ui/public/images/roo-code.png b/ui/public/images/roo-code.png
new file mode 100644
index 00000000000..b063b5d3b30
Binary files /dev/null and b/ui/public/images/roo-code.png differ
diff --git a/ui/public/images/windows.png b/ui/public/images/windows.png
new file mode 100644
index 00000000000..cfd3da71d9a
Binary files /dev/null and b/ui/public/images/windows.png differ
diff --git a/ui/public/images/windsurf.png b/ui/public/images/windsurf.png
new file mode 100644
index 00000000000..075af16d2b2
Binary files /dev/null and b/ui/public/images/windsurf.png differ