-
Notifications
You must be signed in to change notification settings - Fork 0
Implement CORE-01: Per-Tenant Routing Policies #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -113,7 +113,20 @@ def compute_expected_utility( | |
| safety = _safety_multiplier(profile, provider_models) | ||
| policy_weight = directive.policy_weight if directive else 1.0 | ||
|
|
||
| return success_prob * quality * schema * latency * cost * safety * policy_weight | ||
| # Adjust weights based on routing preference | ||
| pref = directive.routing_preference if directive else "balanced" | ||
| if pref == "cost-optimized": | ||
| # Square cost to make it more dominant, root latency and quality | ||
| cost = cost**1.5 | ||
|
Comment on lines
+118
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: The cost-optimized comment doesn't match the actual exponent used. The inline description and the actual exponent don’t match: the comment says “square cost” but the code uses |
||
| latency = latency**0.5 | ||
| quality = quality**0.5 | ||
| elif pref == "performance-first": | ||
| # Square latency and quality, root cost | ||
| latency = latency**1.5 | ||
| quality = quality**1.5 | ||
| cost = cost**0.5 | ||
|
|
||
| return success_prob * quality * latency * cost * safety * policy_weight * schema | ||
|
|
||
|
|
||
| def compute_composite_score( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,8 @@ | |
| CheckoutResponse, | ||
| RegisterRequest, | ||
| RegisterResponse, | ||
| TenantSettingsRequest, | ||
| TenantSettingsResponse, | ||
| ) | ||
|
|
||
| logger = logging.getLogger("freerelay") | ||
|
|
@@ -227,10 +229,11 @@ async def chat_completions(request: Request) -> Response: | |
|
|
||
| user_id = getattr(request.state, "user_id", None) | ||
| tier = getattr(request.state, "tier", "free") | ||
| routing_preference = getattr(request.state, "routing_preference", "balanced") | ||
|
|
||
| if req.is_streaming(): | ||
| return StreamingResponse( | ||
| engine.route_stream(req, user_id=user_id, tier=tier), | ||
| engine.route_stream(req, user_id=user_id, tier=tier, routing_preference=routing_preference), | ||
| media_type="text/event-stream", | ||
| headers={ | ||
| "Cache-Control": "no-cache", | ||
|
|
@@ -239,7 +242,7 @@ async def chat_completions(request: Request) -> Response: | |
| }, | ||
| ) | ||
|
|
||
| response = await engine.route(req, user_id=user_id, tier=tier) | ||
| response = await engine.route(req, user_id=user_id, tier=tier, routing_preference=routing_preference) | ||
|
|
||
| if "error" in response.model_dump(): | ||
| return JSONResponse( | ||
|
|
@@ -302,6 +305,68 @@ async def register(req: RegisterRequest) -> RegisterResponse: | |
| content={"error": f"Registration failed: {str(e)}"}, | ||
| ) # type: ignore | ||
|
|
||
| @app.post("/v1/tenant/settings", response_model=TenantSettingsResponse) | ||
| async def update_tenant_settings( | ||
| request: Request, | ||
| settings_req: TenantSettingsRequest | ||
| ) -> TenantSettingsResponse: | ||
| from freerelay.shared.tenancy.supabase import get_supabase_admin_client | ||
|
|
||
| user_id = getattr(request.state, "user_id", None) | ||
| if not user_id or user_id == "admin": | ||
| return JSONResponse( | ||
| status_code=401, | ||
| content={"error": "Authentication required to update settings"} | ||
| ) # type: ignore | ||
|
|
||
| if settings_req.routing_preference not in ["cost-optimized", "balanced", "performance-first"]: | ||
| return JSONResponse( | ||
| status_code=400, | ||
| content={"error": "Invalid routing preference. Must be 'cost-optimized', 'balanced', or 'performance-first'"} | ||
| ) # type: ignore | ||
|
|
||
| try: | ||
| supabase = get_supabase_admin_client() | ||
| supabase.table("users").update( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟡 Medium] [🔵 Bug]
# freerelay/main.py
supabase.table("users").update(
{"routing_preference": settings_req.routing_preference}
).eq("id", user_id).execute()
return TenantSettingsResponse(
success=True, |
||
| {"routing_preference": settings_req.routing_preference} | ||
| ).eq("id", user_id).execute() | ||
|
|
||
| return TenantSettingsResponse( | ||
| success=True, | ||
| routing_preference=settings_req.routing_preference | ||
| ) | ||
| except Exception as e: | ||
| logger.exception("Failed to update tenant settings") | ||
| return JSONResponse( | ||
| status_code=500, | ||
| content={"error": f"Failed to update settings: {str(e)}"} | ||
| ) # type: ignore | ||
|
|
||
| @app.get("/v1/tenant/settings", response_model=TenantSettingsResponse) | ||
| async def get_tenant_settings(request: Request) -> TenantSettingsResponse: | ||
| from freerelay.shared.tenancy.supabase import get_supabase_client | ||
|
|
||
| user_id = getattr(request.state, "user_id", None) | ||
| if not user_id or user_id == "admin": | ||
| return JSONResponse( | ||
| status_code=401, | ||
| content={"error": "Authentication required"} | ||
| ) # type: ignore | ||
|
|
||
| try: | ||
| supabase = get_supabase_client() | ||
| result = supabase.table("users").select("routing_preference").eq("id", user_id).execute() | ||
| if result.data: | ||
| pref = result.data[0].get("routing_preference", "balanced") | ||
| return TenantSettingsResponse(success=True, routing_preference=pref) | ||
| return TenantSettingsResponse(success=False, routing_preference="balanced") | ||
| except Exception as e: | ||
| logger.exception("Failed to fetch tenant settings") | ||
| return JSONResponse( | ||
| status_code=500, | ||
| content={"error": f"Failed to fetch settings: {str(e)}"} | ||
| ) # type: ignore | ||
|
|
||
| @app.post("/v1/billing/checkout", response_model=None) | ||
| async def billing_checkout(req: CheckoutRequest) -> CheckoutResponse: | ||
| from freerelay.integrations.stripe import create_checkout_session | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,7 +33,7 @@ def _verify_token_supabase(token_hash: str) -> dict[str, str] | None: | |
| # Join with users table to get the tier | ||
| result = ( | ||
| supabase.table("api_keys") | ||
| .select("user_id, users(tier)") | ||
| .select("user_id, users(tier, routing_preference)") | ||
| .eq("key_hash", token_hash) | ||
| .eq("is_active", True) | ||
| .execute() | ||
|
|
@@ -43,9 +43,11 @@ def _verify_token_supabase(token_hash: str) -> dict[str, str] | None: | |
| user_id = str(data["user_id"]) | ||
| users_data: Any = data.get("users") | ||
| tier = "free" | ||
| routing_preference = "balanced" | ||
| if isinstance(users_data, dict): | ||
| tier = str(users_data.get("tier", "free")) | ||
| return {"user_id": user_id, "tier": tier} | ||
| routing_preference = str(users_data.get("routing_preference", "balanced")) | ||
| return {"user_id": user_id, "tier": tier, "routing_preference": routing_preference} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟠 High] [🔵 Bug]
# freerelay/middleware/auth.py
if isinstance(users_data, dict):
tier = str(users_data.get("tier", "free"))
routing_preference = str(users_data.get("routing_preference", "balanced"))
return {"user_id": user_id, "tier": tier, "routing_preference": routing_preference} |
||
| return None | ||
| except Exception as e: | ||
| logger.error(f"Supabase auth error: {e}") | ||
|
|
@@ -86,6 +88,7 @@ async def dispatch( | |
| if self.api_key and token == self.api_key: | ||
| request.state.user_id = "admin" | ||
| request.state.tier = "gold" | ||
| request.state.routing_preference = "balanced" | ||
| return await call_next(request) | ||
|
|
||
| # 2. Supabase check | ||
|
|
@@ -95,6 +98,7 @@ async def dispatch( | |
| if user_info: | ||
| request.state.user_id = user_info["user_id"] | ||
| request.state.tier = user_info["tier"] | ||
| request.state.routing_preference = user_info["routing_preference"] | ||
| return await call_next(request) | ||
|
|
||
| return JSONResponse( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users ( | |
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | ||
| email TEXT UNIQUE NOT NULL, | ||
| tier TEXT NOT NULL DEFAULT 'free', -- 'free', 'bronze', 'silver', 'gold' | ||
| routing_preference TEXT NOT NULL DEFAULT 'balanced', -- 'cost-optimized', 'balanced', 'performance-first' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟠 High] [🔵 Bug] This adds |
||
| created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() | ||
| ); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (bug_risk): Rule-level routing preference is effectively ignored/overridden by tenant preference in
apply.Because
applyalways overwritesdirective.routing_preferencewithtenant_pref, any rule-specific routing preference is effectively ignored. If rule-level overrides are intended, only applytenant_prefwhen the directive is still at its default (e.g.,"balanced"). If tenant preference should always win, update the comment/default onRoutingRule.directiveto match that behavior.