Skip to content
Merged
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
11 changes: 10 additions & 1 deletion docs/routing-and-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,16 @@ Request hints under `gateway` can reduce the eligible set or tune scoring:

- `priority`: `cost`, `quality`, `latency`, or `balanced`.
- `cost_quality_tradeoff`: `0` favors quality, `10` favors cost.
- `sticky_session_id` or `session_id`: deterministic tie-breaking for repeated conversations.
- `sticky_session_id` or `session_id`: session affinity. In every routing mode,
candidates the mode itself does not distinguish — equal configured price in
`cheapest`, candidates a `provider_order` hint leaves unranked in `fallback`
and `explicit`, and materially equal weighted scores (within
`SESSION_AFFINITY_SCORE_EPSILON`, 0.01 on the 0..1 score scale) in the scored
modes — are ordered by a deterministic per-session hash, so repeated requests
from one session keep landing on the same candidate. Affinity never overrides
a candidate the mode ranks strictly better, is stateless (no session store),
and each decision discloses it under `decision.session_affinity`
(`session_id_present`, `applied`).
- `required_capabilities`: capabilities such as `tools`, `json`, `vision`, or `reasoning`.
- `min_quality` and `min_context_tokens`.
- `provider_order`, `provider_only`, and `provider_ignore`.
Expand Down
139 changes: 113 additions & 26 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,12 +466,77 @@ function hashString(value: string): number {
return hash >>> 0;
}

function affinitySessionId(request: GatewayRoutableRequest): string | undefined {
const sessionId =
request.gateway?.sticky_session_id ?? request.gateway?.session_id ?? request.session_id;
return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : undefined;
}

function stickyTieBreaker(candidate: GatewayRouteCandidate, request: GatewayRoutableRequest): number {
const sessionId = request.gateway?.sticky_session_id ?? request.gateway?.session_id ?? request.session_id;
if (!sessionId) return 0;
const sessionId = affinitySessionId(request);
if (sessionId === undefined) return 0;
return hashString(`${sessionId}:${candidate.model.id}`) / 0xffffffff;
}

/**
* Score band within which two scored candidates are materially equal for
* session affinity. Weighted scores live on a 0..1 scale; a 0.01 band keeps a
* session pinned to one of several near-interchangeable candidates (whose
* float scores are never EXACTLY equal, which is why the previous
* equal-score-only tie-break never fired in practice) without ever overriding
* a clear winner.
*/
export const SESSION_AFFINITY_SCORE_EPSILON = 0.01;

interface SessionAffinityDisclosure {
session_id_present: boolean;
applied: boolean;
}

function affinityKeysEqual(a: number, b: number, epsilon: number): boolean {
// Object.is covers the Infinity ranks of provider_order-unranked candidates
// and the NaN prices of unpriced candidates, which plain arithmetic cannot.
if (Object.is(a, b)) return true;
return Math.abs(a - b) <= epsilon;
}

/**
* Orders every group of tied candidates (equal mode key, or within `epsilon`
* for scored modes) by a deterministic per-session hash, so a session keeps
* landing on the same candidate among interchangeable ones in EVERY shipped
* routing mode. Candidates the mode actually distinguishes are never
* reordered, and without a session id the input order is returned untouched.
*/
function applySessionAffinity(
sorted: GatewayRouteCandidate[],
request: GatewayRoutableRequest,
keyOf: (candidate: GatewayRouteCandidate) => number,
epsilon: number,
): { sorted: GatewayRouteCandidate[]; affinity: SessionAffinityDisclosure } {
const sessionId = affinitySessionId(request);
if (sessionId === undefined) {
return { sorted, affinity: { session_id_present: false, applied: false } };
}
const result: GatewayRouteCandidate[] = [];
let applied = false;
let index = 0;
while (index < sorted.length) {
const leaderKey = keyOf(sorted[index]!);
let end = index + 1;
while (end < sorted.length && affinityKeysEqual(keyOf(sorted[end]!), leaderKey, epsilon)) {
end += 1;
}
const group = sorted.slice(index, end);
if (group.length > 1) {
applied = true;
group.sort((a, b) => stickyTieBreaker(b, request) - stickyTieBreaker(a, request));
}
result.push(...group);
index = end;
}
return { sorted: result, affinity: { session_id_present: true, applied } };
}

function providerOrderScore(candidate: GatewayRouteCandidate, request: GatewayRoutableRequest): number | undefined {
const order = request.gateway?.provider_order;
if (!order?.length) return undefined;
Expand Down Expand Up @@ -570,43 +635,61 @@ function sortCandidates(
candidates: GatewayRouteCandidate[],
mode: GatewayRoutePolicy["mode"],
request: GatewayRoutableRequest,
): { sorted: GatewayRouteCandidate[]; scores?: GatewayRouteScore[] } {
): {
sorted: GatewayRouteCandidate[];
scores?: GatewayRouteScore[];
affinity: SessionAffinityDisclosure;
} {
const indexes = originalIndexMap(candidates);
const originalIndex = (candidate: GatewayRouteCandidate): number =>
indexes.get(`${candidate.provider.id}:${candidate.model.id}`) ?? 0;
const byOriginalOrder = (a: GatewayRouteCandidate, b: GatewayRouteCandidate): number =>
(indexes.get(`${a.provider.id}:${a.model.id}`) ?? 0) - (indexes.get(`${b.provider.id}:${b.model.id}`) ?? 0);
originalIndex(a) - originalIndex(b);

if (mode === "cheapest") {
return {
sorted: [...candidates].sort((a, b) => configuredTokenPrice(a) - configuredTokenPrice(b) || byOriginalOrder(a, b)),
};
const sorted = [...candidates].sort(
(a, b) => configuredTokenPrice(a) - configuredTokenPrice(b) || byOriginalOrder(a, b),
);
// Price ties are session-stable; a strictly cheaper candidate always wins.
return applySessionAffinity(sorted, request, configuredTokenPrice, 0);
}

if (mode === "fallback" || mode === "explicit") {
const order = request.gateway?.provider_order;
if (!order?.length) return { sorted: candidates };
return {
sorted: [...candidates].sort((a, b) => {
const aIndex = order.indexOf(a.provider.id);
const bIndex = order.indexOf(b.provider.id);
const aRank = aIndex < 0 ? Number.POSITIVE_INFINITY : aIndex;
const bRank = bIndex < 0 ? Number.POSITIVE_INFINITY : bIndex;
return aRank - bRank || byOriginalOrder(a, b);
}),
if (!order?.length) {
// The configured candidate order (fallbackModelIds, alias declaration)
// is a deliberate priority chain: every candidate has a distinct rank,
// so the affinity pass runs but has no tie to order.
return applySessionAffinity(candidates, request, originalIndex, 0);
}
const rank = (candidate: GatewayRouteCandidate): number => {
const index = order.indexOf(candidate.provider.id);
return index < 0 ? Number.POSITIVE_INFINITY : index;
};
const sorted = [...candidates].sort((a, b) => rank(a) - rank(b) || byOriginalOrder(a, b));
const affinityRank = (candidate: GatewayRouteCandidate): number =>
Number.isFinite(rank(candidate)) ? originalIndex(candidate) : Number.POSITIVE_INFINITY;
// Only candidates the hint leaves unranked share an Infinity affinity
// rank. Ranked candidates each keep their original position, including
// ordered fallback models that belong to the same ranked provider.
return applySessionAffinity(sorted, request, affinityRank, 0);
}

const scores = scoreCandidates(candidates, mode, request);
const scoreOf = (candidate: GatewayRouteCandidate): number =>
scoreFor(candidate, scores)?.score ?? 0;
const sorted = [...candidates].sort((a, b) => {
const aScore = scoreFor(a, scores);
const bScore = scoreFor(b, scores);
return (
(bScore?.score ?? 0) - (aScore?.score ?? 0) ||
(bScore?.components.sticky ?? 0) - (aScore?.components.sticky ?? 0) ||
byOriginalOrder(a, b)
);
});
return {
scores,
sorted: [...candidates].sort((a, b) => {
const aScore = scoreFor(a, scores);
const bScore = scoreFor(b, scores);
return (
(bScore?.score ?? 0) - (aScore?.score ?? 0) ||
(bScore?.components.sticky ?? 0) - (aScore?.components.sticky ?? 0) ||
byOriginalOrder(a, b)
);
}),
...applySessionAffinity(sorted, request, scoreOf, SESSION_AFFINITY_SCORE_EPSILON),
};
}

Expand Down Expand Up @@ -660,7 +743,8 @@ export function resolveRoute(
}
}

const { sorted, scores } = sortCandidates(eligible, mode, request);
const { sorted, scores, affinity } = sortCandidates(eligible, mode, request);
decision.session_affinity = affinity;
if (scores) decision.scores = scores.sort((a, b) => b.score - a.score);
if (mode === "cheapest" && sorted.length > 0 && !sorted.some(candidateHasConfiguredPrice)) {
decision.reason = "no eligible model has configured token price for cheapest routing";
Expand All @@ -683,6 +767,9 @@ export function resolveRoute(
: request.gateway?.provider_order?.length
? "first eligible model after provider_order hint"
: "first eligible model";
if (affinity.applied) {
decision.reason += "; session affinity ordered materially-equal candidates";
}
return { candidates: sorted, decision };
}

Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,18 @@ export type GatewayRouteDecision = {
reason: string;
attempts: GatewayRouteAttempt[];
scores?: GatewayRouteScore[];
/**
* Disclosure that the session-affinity path ran for this decision.
* `session_id_present` reports whether the request carried a
* `sticky_session_id` / `session_id`; `applied` reports whether affinity
* actually ordered a group of tied (or, in scored modes, materially equal)
* candidates. Present on every decision so the path is observable in every
* shipped routing mode.
*/
session_affinity?: {
session_id_present: boolean;
applied: boolean;
};
};

export type GatewayRouteCandidate = {
Expand Down
Loading
Loading