Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions freerelay/core/routing/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,17 @@ class RequestContext:
total_tokens: int
schema_success_ratio: float
tenant_tier: str
routing_preference: str = "balanced"
policy_directive: RoutingDirective = field(default_factory=RoutingDirective)

def policy_context(self) -> dict[str, Any]:
return {
"workload": self.workload_profile.to_dict(),
"tenant": {"id": self.user_id, "tier": self.tenant_tier},
"tenant": {
"id": self.user_id,
"tier": self.tenant_tier,
"routing_preference": self.routing_preference
},
"schema": {"success_ratio": self.schema_success_ratio},
"compression": {
"tokens_saved": self.compression.tokens_saved,
Expand Down Expand Up @@ -206,6 +211,7 @@ def _prepare_context(
request: ChatCompletionRequest,
user_id: str | None = None,
tier: str = "free",
routing_preference: str = "balanced",
) -> RequestContext:
profile = self.profiler.profile(request)
bundle = self.context_optimizer.optimize(request)
Expand All @@ -221,6 +227,7 @@ def _prepare_context(
total_tokens=bundle.total_tokens,
schema_success_ratio=0.95,
tenant_tier=tier,
routing_preference=routing_preference,
)

def _build_policy_context(self, context: RequestContext) -> dict[str, Any]:
Expand Down Expand Up @@ -334,8 +341,11 @@ async def route(
request: ChatCompletionRequest,
user_id: str | None = None,
tier: str = "free",
routing_preference: str = "balanced",
) -> ChatCompletionResponse:
context = self._prepare_context(request, user_id=user_id, tier=tier)
context = self._prepare_context(
request, user_id=user_id, tier=tier, routing_preference=routing_preference
)
ranked, directive = await self._ranked_slots(context)

if not ranked:
Expand Down Expand Up @@ -439,8 +449,11 @@ async def route_stream(
request: ChatCompletionRequest,
user_id: str | None = None,
tier: str = "free",
routing_preference: str = "balanced",
) -> Any:
context = self._prepare_context(request, user_id=user_id, tier=tier)
context = self._prepare_context(
request, user_id=user_id, tier=tier, routing_preference=routing_preference
)
ranked, _ = await self._ranked_slots(context)

if not ranked:
Expand Down
12 changes: 10 additions & 2 deletions freerelay/core/routing/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class RoutingDirective:
require_hedging: str | None = None
human_gate: bool = False
policy_weight: float = 1.0
routing_preference: str = "balanced"


@dataclass
Expand Down Expand Up @@ -84,6 +85,7 @@ def directive(self) -> RoutingDirective:
require_hedging=self.require_hedging,
human_gate=self.human_gate,
policy_weight=self.policy_weight,
routing_preference="balanced", # Default, can be overridden by rule if we want
Comment on lines 87 to +88

Copy link
Copy Markdown

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 apply always overwrites directive.routing_preference with tenant_pref, any rule-specific routing preference is effectively ignored. If rule-level overrides are intended, only apply tenant_pref when the directive is still at its default (e.g., "balanced"). If tenant preference should always win, update the comment/default on RoutingRule.directive to match that behavior.

)


Expand Down Expand Up @@ -149,19 +151,25 @@ def apply(
available_providers: list[str],
) -> tuple[list[str], RoutingDirective]:
"""Reorder providers and surface the matching directive."""
tenant_pref = context.get("tenant", {}).get("routing_preference", "balanced")

for rule in self.rules:
if self._eval_condition(rule.condition, context):
directive = rule.directive
directive.routing_preference = tenant_pref # Apply tenant preference
logger.info(
"Routing rule %s matched → %s",
"Routing rule %s matched → %s (pref: %s)",
rule.name,
", ".join(directive.prefer or ["(no prefer)"]),
tenant_pref,
)
ordered = self._reorder_providers(
available_providers, directive.prefer, directive.exclude
)
return ordered, directive
return available_providers, RoutingDirective()

default_directive = RoutingDirective(routing_preference=tenant_pref)
return available_providers, default_directive

def _eval_condition(self, condition: str, context: dict[str, Any]) -> bool:
return _eval_condition_context(condition, context)
Expand Down
15 changes: 14 additions & 1 deletion freerelay/core/routing/scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 cost**1.5. Please update either the comment or the exponent so the intended weighting is clear to future readers.

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(
Expand Down
69 changes: 67 additions & 2 deletions freerelay/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
CheckoutResponse,
RegisterRequest,
RegisterResponse,
TenantSettingsRequest,
TenantSettingsResponse,
)

logger = logging.getLogger("freerelay")
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

POST /v1/tenant/settings unconditionally returns success after the update call, and the paired GET path turns an empty result into 200 {success:false} instead of surfacing a missing tenant. This is reachable because authentication is cached in AuthMiddleware, so a token can remain authorized after its backing tenant row has been removed or the data becomes inconsistent; in that state the update matches zero rows, but the client still gets a success response even though nothing was persisted. Check the Supabase result and return 404/401 when no row matched before reporting success.

# 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
Expand Down
8 changes: 6 additions & 2 deletions freerelay/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

_verify_token_supabase is wrapped in @lru_cache, and this PR adds routing_preference to the cached payload without adding any invalidation when /v1/tenant/settings updates users.routing_preference. After a tenant changes their preference, subsequent requests with the same API key will keep reusing the old cached value until process restart or cache eviction, so the new routing policy never takes effect even though the settings API returns success. Fix by not caching mutable tenant settings here, or by clearing/refreshing the cache when settings are updated.

# 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}")
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions freerelay/shared/models/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,13 @@ class RegisterRequest(BaseModel):
class RegisterResponse(BaseModel):
api_key: str

class TenantSettingsRequest(BaseModel):
routing_preference: str # 'cost-optimized', 'balanced', 'performance-first'

class TenantSettingsResponse(BaseModel):
success: bool
routing_preference: str


class CheckoutRequest(BaseModel):
email: str
Expand Down
1 change: 1 addition & 0 deletions supabase_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

This adds routing_preference only inside the bootstrap CREATE TABLE IF NOT EXISTS users block, which means existing Supabase deployments will never receive the new column because PostgreSQL skips the whole statement once users already exists. Verified against @README.md:462, which instructs operators to run supabase_schema.sql, and against @freerelay/middleware/auth.py:36-50 and @freerelay/main.py:322-361, which now immediately read and write users.routing_preference; on an upgraded install those queries will fail due to the missing column. Add an explicit ALTER TABLE users ADD COLUMN IF NOT EXISTS routing_preference TEXT NOT NULL DEFAULT 'balanced' migration/backfill step before shipping the new readers. ```sql
-- supabase_schema.sql
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',
routing_preference TEXT NOT NULL DEFAULT 'balanced',

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Expand Down
Loading