diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index e1ccfd9a4f3..61004386774 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -320,14 +320,36 @@ JEV decides again, and the new decision replaces the pin, when:
- any send of a pinned session fails (that request still fails over as usual);
- the configuration has changed since the pin was written.
+#### Deciding effort on every pinned turn
+
+A pin keeps the effort of the first decision by default. A session that starts with a planning
+request and continues with small edits then pays for that effort on every turn. Set
+`effort: "per-turn"` to keep the pinned target, and its prompt cache, while JEV chooses the effort
+again on every turn:
+
+```bash
+ocx combo set jev-auto --session-pin 20m --session-pin-effort per-turn --json
+```
+
+```json
+{ "strategy": "jev", "sessionPin": { "ttlMs": 1200000, "effort": "per-turn" } }
+```
+
+Each reused turn sends one decision request that offers only the pinned target and its efforts.
+If JEV answers with an effort for that target, the turn uses it and the pin keeps the new effort.
+If the call fails, times out or answers anything else, the turn uses the pinned effort as it would
+without this setting. A target with a single effort skips the call. `--session-pin-effort pinned`
+returns to the default, and changing the idle window with `--session-pin` keeps the mode.
+
A cancelled request keeps the pin. A fail-open decision (timeout, missing key, invalid answer)
never creates one. Pins live in the proxy's memory, at most 512 sessions, and do not survive a
restart.
-Usage records mark a reused turn with `jevDecision.pinned: true` and `pinReason: "session"`. A
+Usage records mark a reused turn with `jevDecision.pinned: true` and `pinReason: "session"`, or
+`pinReason: "session_effort"` when a per-turn effort decision set its effort. A
decision that replaced a released pin records why in `pinReason` (`expired`, `config_changed`,
`target_unavailable`, `effort_unavailable` or `quota_nearly_exhausted`). `ocx combo stats`
-counts pinned requests apart from decisions; they still count toward successful requests, model
+counts pinned requests apart from decisions, and also counts a `session_effort` turn as a decision; they still count toward successful requests, model
fallbacks and per-model tokens.
A pin makes provider cache reuse possible; whether the provider actually serves cached tokens still
diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md
index ac5e26bf3ad..18d2ffbc711 100644
--- a/docs-site/src/content/docs/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/reference/configuration/routing.md
@@ -179,7 +179,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`,
| `decisionTimeoutMs?` | `number` | `4000` | `strategy: "jev"` only. Decision deadline before failing open, 1000–120000 ms. |
| `decisionQuotaSignals?` | `boolean` | `false` | JEV only. Explicit `true` sends loaded advisory quota evidence to the selected backend; never changes hard eligibility or account selection. |
| `decisionQuotaTiers?` | `{ moderate?: number; limited?: number; nearlyExhausted?: number }` | `{ limited: 70, nearlyExhausted: 90 }` | JEV only. Partial overrides merge with defaults; finite 0–100 values must be strictly ascending. `moderate` is absent unless explicit. Does not enable signals by itself. |
-| `sessionPin?` | `{ ttlMs: number }` | off | JEV only. Keeps an explicit client session on the target JEV chose for it while the session stays active; `ttlMs` is the idle window, 60000–86400000. `null` clears. See [Session pin for JEV](/guides/combos/#session-pin-for-jev). |
+| `sessionPin?` | `{ ttlMs: number, effort?: "pinned" \| "per-turn" }` | off | JEV only. Keeps an explicit client session on the target JEV chose for it while the session stays active; `ttlMs` is the idle window, 60000–86400000. `effort: "per-turn"` keeps the target but lets JEV choose its effort on every turn. `null` clears. See [Session pin for JEV](/guides/combos/#session-pin-for-jev). |
Quota settings, privacy and unknown-pool semantics are described in
[Advisory quota signals for JEV](/guides/combos/#advisory-quota-signals-for-jev).
diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts
index 84bbc12198c..5654f2bd81d 100644
--- a/gui/src/combo-workspace-data.ts
+++ b/gui/src/combo-workspace-data.ts
@@ -498,6 +498,7 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean {
|| (a.decisionTimeoutMs ?? null) !== (b.decisionTimeoutMs ?? null)
|| (a.sessionPin === undefined) !== (b.sessionPin === undefined)
|| (a.sessionPin?.ttlMs ?? null) !== (b.sessionPin?.ttlMs ?? null)
+ || (a.sessionPin?.effort ?? null) !== (b.sessionPin?.effort ?? null)
))
) return false;
if (a.targets.length !== b.targets.length) return false;
diff --git a/gui/src/components/combo-workspace-jev-decision.tsx b/gui/src/components/combo-workspace-jev-decision.tsx
index 869f4e9caef..0b6493db140 100644
--- a/gui/src/components/combo-workspace-jev-decision.tsx
+++ b/gui/src/components/combo-workspace-jev-decision.tsx
@@ -308,12 +308,14 @@ export function ComboJevDecisionSection({
aria-invalid={pinInvalid || undefined}
aria-describedby={pinInvalid ? `${idPrefix}-session-pin-hint ${idPrefix}-session-pin-error` : `${idPrefix}-session-pin-hint`}
onChange={(e) => {
+ // Editing the idle window keeps a per-turn effort mode set through the CLI or API.
+ const kept = sessionPin.effort ? { effort: sessionPin.effort } : {};
if (e.target.value === "") {
- onChange({ sessionPin: { ttlMs: 0 } });
+ onChange({ sessionPin: { ttlMs: 0, ...kept } });
return;
}
const ttlMs = Number(e.target.value);
- onChange({ sessionPin: { ttlMs: Number.isFinite(ttlMs) ? ttlMs : 0 } });
+ onChange({ sessionPin: { ttlMs: Number.isFinite(ttlMs) ? ttlMs : 0, ...kept } });
}}
/>
{pinInvalid &&
diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md
index 903e50b5275..a50370b6af5 100644
--- a/skills/ocx/references/01_surface_agents-routing.md
+++ b/skills/ocx/references/01_surface_agents-routing.md
@@ -977,7 +977,7 @@ State-changing: no. JSON mode: `payload`.
### `ocx combo set`
-Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--cooldown-reprobe ] [--cooldown-reprobe-interval ] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--jev-effort-mode ] [--json]`
+Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--cooldown-reprobe ] [--cooldown-reprobe-interval ] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--session-pin-effort ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--jev-effort-mode ] [--json]`
Create, update or rename a combo.
@@ -1007,6 +1007,7 @@ State-changing: yes. JSON mode: `payload`.
| `--decision-quota-signals` | string | JEV advisory quota on/off; - clears. |
| `--decision-quota-tiers` | string | Partial JSON thresholds; - clears. |
| `--session-pin` | string | JEV session pin idle window 1m-24h (90s, 20m, 1h); - clears. |
+| `--session-pin-effort` | string | pinned reuses the pinned effort; per-turn asks JEV for the pinned target's effort each turn. |
| `--rename-from` | string | Existing combo ID to rename. |
| `--json` | boolean | Emit structured JSON. |
| `--native-alias` | string | Bare legacy flag means on; explicit on/off preserves presence, including false. |
diff --git a/src/cli/capabilities-agents-routing.ts b/src/cli/capabilities-agents-routing.ts
index 6a8824bcb74..052bc358eaa 100644
--- a/src/cli/capabilities-agents-routing.ts
+++ b/src/cli/capabilities-agents-routing.ts
@@ -395,10 +395,10 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [
},
{
command: ["combo","set"],
- usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--cooldown-reprobe ] [--cooldown-reprobe-interval ] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--jev-effort-mode ] [--json]",
+ usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--cooldown-reprobe ] [--cooldown-reprobe-interval ] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--decision-quota-signals ] [--decision-quota-tiers ] [--session-pin ] [--session-pin-effort ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--jev-effort-mode ] [--json]",
summary: "Create, update or rename a combo.",
routes: [{"method":"GET","path":"/api/combos"},{"method":"PUT","path":"/api/combos"}],
- flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--cooldown-reprobe","value":"string","summary":"Opt into one recovery pass when all eligible targets cool; off by default."},{"name":"--cooldown-reprobe-interval","value":"string","summary":"Minimum interval between recovery passes in ms (1000..86400000); default 300000."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--session-pin","value":"string","summary":"JEV session pin idle window 1m-24h (90s, 20m, 1h); - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."},{"name":"--jev-effort-mode","value":"string","summary":"JEV wire-effort owner: decide (default) applies the decision effort; client keeps its target and leaves effort to combo policy."}],
+ flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--cooldown-reprobe","value":"string","summary":"Opt into one recovery pass when all eligible targets cool; off by default."},{"name":"--cooldown-reprobe-interval","value":"string","summary":"Minimum interval between recovery passes in ms (1000..86400000); default 300000."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--session-pin","value":"string","summary":"JEV session pin idle window 1m-24h (90s, 20m, 1h); - clears."},{"name":"--session-pin-effort","value":"string","summary":"pinned reuses the pinned effort; per-turn asks JEV for the pinned target's effort each turn."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."},{"name":"--jev-effort-mode","value":"string","summary":"JEV wire-effort owner: decide (default) applies the decision effort; client keeps its target and leaves effort to combo policy."}],
mutates: true,
json: "payload",
details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets. Without --strategy an existing combo keeps its stored strategy and JEV policy; a new combo defaults to failover.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."],
diff --git a/src/cli/combo.ts b/src/cli/combo.ts
index dc0074cdf6c..9e2088ff991 100644
--- a/src/cli/combo.ts
+++ b/src/cli/combo.ts
@@ -1,5 +1,5 @@
import { normalizeJevQuotaTiers } from "../combos/jev-quota-config";
-import { parseJevSessionPinDuration } from "../combos/jev-session-pin-config";
+import { normalizeJevSessionPin, parseJevSessionPinDuration } from "../combos/jev-session-pin-config";
import { JEV_DECISION_TIMEOUT_MAX_MS, JEV_DECISION_TIMEOUT_MIN_MS } from "../combos/types";
import {
CliUsageError,
@@ -34,6 +34,7 @@ const USAGE = `Usage:
[--decision-provider | --decision-model ] [--decision-timeout ]
[--decision-quota-signals ] [--decision-quota-tiers ]
[--session-pin ] (e.g. 20m; keeps a session on JEV's target while active)
+ [--session-pin-effort ] (per-turn decides the pinned target's effort each turn)
(jev only; the provider must be a configured jev-decision row)
[--rename-from ] [--json]
ocx combo stats [--range <7d|30d|all>] [--json]
@@ -151,6 +152,10 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise {
if (sessionPinRaw !== undefined && sessionPinRaw !== "-" && sessionPinMs === undefined) {
throw new CliUsageError("--session-pin must be a duration from 1m to 24h (for example 90s, 20m, 1h, or milliseconds), or -", USAGE);
}
+ const sessionPinEffort = takeOption(args, "--session-pin-effort");
+ if (sessionPinEffort !== undefined && sessionPinEffort !== "pinned" && sessionPinEffort !== "per-turn") {
+ throw new CliUsageError("--session-pin-effort must be pinned or per-turn", USAGE);
+ }
const decisionTimeout = takeOption(args, "--decision-timeout");
let decisionTimeoutMs: number | null | undefined;
if (decisionTimeout !== undefined) {
@@ -175,6 +180,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise {
if (decisionProvider !== undefined && decisionProvider !== "-") throw new CliUsageError("--decision-provider applies only to the jev strategy", USAGE);
if (quotaSignals !== undefined && quotaSignals !== "-" || quotaTiers !== undefined && quotaTiers !== "-") throw new CliUsageError("quota settings apply only to jev", USAGE);
if (sessionPinMs !== undefined) throw new CliUsageError("--session-pin applies only to the jev strategy", USAGE);
+ if (sessionPinEffort !== undefined) throw new CliUsageError("--session-pin-effort applies only to the jev strategy", USAGE);
if (decisionTimeoutMs != null) throw new CliUsageError("--decision-timeout applies only to the jev strategy", USAGE);
if (additional.jevEffortMode === "client") throw new CliUsageError("--jev-effort-mode client applies only to the jev strategy", USAGE);
};
@@ -222,7 +228,18 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise {
if (decisionTimeoutMs !== undefined) combo.decisionTimeoutMs = decisionTimeoutMs;
if (quotaSignals !== undefined) combo.decisionQuotaSignals = quotaSignals === "-" ? null : quotaSignals === "on";
if (quotaTiers !== undefined) combo.decisionQuotaTiers = parsedQuotaTiers;
- if (sessionPinRaw !== undefined) combo.sessionPin = sessionPinMs === undefined ? null : { ttlMs: sessionPinMs };
+ if (sessionPinRaw !== undefined || sessionPinEffort !== undefined) {
+ // A new TTL keeps the stored effort mode, and an effort mode alone keeps the stored TTL.
+ const previous = normalizeJevSessionPin(combo.sessionPin);
+ const ttlMs = sessionPinRaw !== undefined ? sessionPinMs : previous?.ttlMs;
+ if (ttlMs === undefined) {
+ if (sessionPinEffort !== undefined) throw new CliUsageError("--session-pin-effort needs a session pin; pass --session-pin", USAGE);
+ combo.sessionPin = null;
+ } else {
+ const pinEffort = sessionPinEffort ?? previous?.effort ?? "pinned";
+ combo.sessionPin = pinEffort === "per-turn" ? { ttlMs, effort: pinEffort } : { ttlMs };
+ }
+ }
if (strategy !== "jev") {
delete combo.decisionProvider;
delete combo.decisionModel;
diff --git a/src/combos/jev-session-pin-config.ts b/src/combos/jev-session-pin-config.ts
index 94ec4838877..5c785ed3a74 100644
--- a/src/combos/jev-session-pin-config.ts
+++ b/src/combos/jev-session-pin-config.ts
@@ -11,11 +11,18 @@ export const JEV_SESSION_PIN_TTL_MAX_MS = 86_400_000;
export interface JevSessionPinConfig {
/** Idle window after the last successful pinned turn; a quieter session is decided afresh. */
ttlMs: number;
+ /**
+ * `per-turn` keeps the pinned target but asks the decision backend for its effort on every
+ * pinned turn, offering only the pinned target's efforts. Absent (the default) reuses the
+ * pinned effort too. `pinned` is accepted as input and normalized away.
+ */
+ effort?: "per-turn";
}
/**
* Closed persisted vocabulary: `session` marks a turn that reused its pin; every other value names
- * why an existing pin was released before a fresh decision.
+ * why an existing pin was released before a fresh decision. `session_effort` marks a pinned turn
+ * whose effort was decided afresh (`effort: "per-turn"`), so it also sent a decision request.
*/
export const JEV_SESSION_PIN_REASONS = [
"session",
@@ -24,20 +31,26 @@ export const JEV_SESSION_PIN_REASONS = [
"target_unavailable",
"effort_unavailable",
"quota_nearly_exhausted",
+ "session_effort",
] as const;
export type JevSessionPinReason = (typeof JEV_SESSION_PIN_REASONS)[number];
-export type JevSessionPinRelease = Exclude;
+export type JevSessionPinRelease = Exclude;
+/** Reasons persisted with `pinned: true`: the turn kept its session target. */
+export const JEV_SESSION_PINNED_REASONS: readonly JevSessionPinReason[] = ["session", "session_effort"];
-/** The canonical pin setting, or undefined for anything that is not exactly `{ ttlMs }` in range. */
+/**
+ * The canonical pin setting, or undefined for anything other than `{ ttlMs }` in range with an
+ * optional `effort` of `pinned` or `per-turn`.
+ */
export function normalizeJevSessionPin(value: unknown): JevSessionPinConfig | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const keys = Object.keys(value);
- if (keys.length !== 1 || keys[0] !== "ttlMs") return undefined;
- const ttlMs = (value as { ttlMs?: unknown }).ttlMs;
- return typeof ttlMs === "number" && Number.isSafeInteger(ttlMs)
- && ttlMs >= JEV_SESSION_PIN_TTL_MIN_MS && ttlMs <= JEV_SESSION_PIN_TTL_MAX_MS
- ? { ttlMs }
- : undefined;
+ if (!keys.includes("ttlMs") || keys.some(key => key !== "ttlMs" && key !== "effort")) return undefined;
+ const { ttlMs, effort } = value as { ttlMs?: unknown; effort?: unknown };
+ if (effort !== undefined && effort !== "pinned" && effort !== "per-turn") return undefined;
+ if (typeof ttlMs !== "number" || !Number.isSafeInteger(ttlMs)
+ || ttlMs < JEV_SESSION_PIN_TTL_MIN_MS || ttlMs > JEV_SESSION_PIN_TTL_MAX_MS) return undefined;
+ return effort === "per-turn" ? { ttlMs, effort } : { ttlMs };
}
const DURATION_UNITS_MS: Readonly> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts
index 337540ad8e4..c533f3f0f54 100644
--- a/src/server/responses/core-combo.ts
+++ b/src/server/responses/core-combo.ts
@@ -766,13 +766,11 @@ export async function executeComboResponses(
choices.map(choice => choice.candidate),
combo.decisionQuotaSignals === true ? { config, tiers: combo.decisionQuotaTiers } : undefined,
);
- let decision: JevDecision;
- if (pinReuse?.decision) decision = pinReuse.decision;
- else try {
- decision = await resolveJevComboDecision({
+ const decide = (candidates: JevCandidate[], fallbackDecision: Pick) =>
+ resolveJevComboDecision({
body,
- candidates: choices.map(choice => choice.candidate),
- fallback,
+ candidates,
+ fallback: fallbackDecision,
config,
isDestinationAllowed: (providerName, modelId) => routeAllowedByScope(
admissionScope, { providerName, modelId },
@@ -793,6 +791,27 @@ export async function executeComboResponses(
...(combo.decisionTimeoutMs !== undefined ? { timeoutMs: combo.decisionTimeoutMs } : {}),
signal: options.abortSignal,
});
+ let decision: JevDecision;
+ if (pinReuse?.decision) {
+ decision = pinReuse.decision;
+ // `effort: "per-turn"` keeps the pinned target and asks only for its effort, offering the
+ // pinned target alone. Anything but an applied answer for that target keeps the pinned effort.
+ const pinned = decision;
+ const pinnedChoice = combo.sessionPin?.effort === "per-turn"
+ ? choices.find(choice => choice.candidate.key === pinned.targetKey)
+ : undefined;
+ if (pinnedChoice && pinnedChoice.candidate.reasoningEfforts.length > 1) {
+ try {
+ const effortDecision = await decide([pinnedChoice.candidate], { targetKey: pinned.targetKey, effort: pinned.effort });
+ if (effortDecision.gate === "apply" && effortDecision.targetKey === pinned.targetKey) {
+ decision = { ...effortDecision, pinned: true, pinReason: "session_effort" };
+ }
+ } catch {
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
+ }
+ }
+ } else try {
+ decision = await decide(choices.map(choice => choice.candidate), fallback);
} catch (error) {
if (options.abortSignal?.aborted) return clientCancelledResponse();
decision = {
diff --git a/src/usage/jev-stats.ts b/src/usage/jev-stats.ts
index c41c84f9c90..4b48de43a60 100644
--- a/src/usage/jev-stats.ts
+++ b/src/usage/jev-stats.ts
@@ -1,5 +1,5 @@
import { normalizeJevQuotaSummary, type JevQuotaDecisionSummary } from "../combos/jev-quota-config";
-import { JEV_SESSION_PIN_REASONS, type JevSessionPinReason } from "../combos/jev-session-pin-config";
+import { JEV_SESSION_PIN_REASONS, JEV_SESSION_PINNED_REASONS, type JevSessionPinReason } from "../combos/jev-session-pin-config";
import type { OcxComboDefaultEffort } from "../types";
import type { PersistedUsageEntry } from "./log";
import { usageDisplayTotalTokens } from "./totals";
@@ -49,9 +49,12 @@ export interface PersistedJevDecisionV1 {
outputTokens: number;
totalTokens: number;
};
- /** The turn reused its session pin (#7525); no decision request was sent for it. */
+ /**
+ * The turn reused its session pin (#7525). With `session` no decision request was sent; with
+ * `session_effort` one was sent for the pinned target's effort only.
+ */
pinned?: true;
- /** `session` with `pinned`; otherwise why an existing session pin was released. */
+ /** `session` or `session_effort` with `pinned`; otherwise why an existing session pin was released. */
pinReason?: JevSessionPinReason;
}
@@ -199,9 +202,10 @@ export function normalizePersistedJevDecision(value: unknown): PersistedJevDecis
&& (JEV_SESSION_PIN_REASONS as readonly string[]).includes(value.pinReason)
? value.pinReason as JevSessionPinReason
: undefined;
- // A pinned turn always says so with `session`; any other pairing is not a record this writes.
- const pinned = value.pinned === true && pinReason === "session";
- if ((value.pinned === true || pinReason === "session") && !pinned) return undefined;
+ // A pinned turn always says so with a pinned reason; any other pairing is not a record this writes.
+ const pinnedReason = pinReason !== undefined && JEV_SESSION_PINNED_REASONS.includes(pinReason);
+ const pinned = value.pinned === true && pinnedReason;
+ if ((value.pinned === true || pinnedReason) && !pinned) return undefined;
return {
version: 1,
comboId,
@@ -405,8 +409,9 @@ class StreamingJevStatsAccumulator implements JevStatsAccumulator {
this.addAttempts(attempts);
if (decision.pinned) {
// A session-pinned turn sent no decision request, so it stays out of every decision total.
+ // A per-turn effort decision on a pinned turn did send one and is counted below as well.
this.pinnedRequests = saturatingAdd(this.pinnedRequests, 1);
- return;
+ if (decision.pinReason !== "session_effort") return;
}
const backend = decision.backend ?? "unknown";
diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md
index 9fa14c4d157..89bf5f87581 100644
--- a/structure/providers/jev-decision.md
+++ b/structure/providers/jev-decision.md
@@ -260,6 +260,15 @@ in-flight writers. A reused turn sends no decision request and persists `pinned:
a decision that replaced a released pin persists the reason. `src/usage/jev-stats.ts` counts pinned turns as
`pinnedRequests`, never as decisions, gates or backend latency; request success, model fallback and physical
attempts count every JEV-routed request, pinned or decided. The CLI projection reads an absent `pinnedRequests` as 0.
+
+`sessionPin.effort: "per-turn"` (absent means `pinned`, which normalization drops) keeps the reused target
+but asks the decision backend again on every reused turn whose target offers more than one effort, with the
+pinned target as the only candidate. An applied answer for that target sets the effort, persists `pinned: true`
+with `pinReason: "session_effort"` and is rewritten into the pin on completion; any other answer, a thrown
+decision call or a single-effort target keeps the pinned decision (`pinReason: "session"`), and a cancellation
+during that call returns the client-cancelled response. `jev-stats` counts a `session_effort` turn as pinned
+and also as a decision, since it sent one. The CLI sets the mode with `--session-pin-effort `;
+a new `--session-pin` keeps the stored mode, and the GUI idle-window input preserves it.
Besides a classified failure response, a non-cancellation dispatch, preflight or failure-read exception and a
committed stream that ends `failed` also forget the pin; `incomplete` is an answer and keeps it. Each forget stamps a
per-session failure fence, so a request already in flight on that session cannot write the pin back.
diff --git a/tests/cli/cli-combo-session-pin.test.ts b/tests/cli/cli-combo-session-pin.test.ts
index eca05257af6..9e2ca1ea599 100644
--- a/tests/cli/cli-combo-session-pin.test.ts
+++ b/tests/cli/cli-combo-session-pin.test.ts
@@ -78,4 +78,21 @@ describe("JEV session pin management (#7525)", () => {
expect(await handleComboCommand(["set", "saved", "--strategy", "failover", "--json"], deps)).toBe(0);
expect(config.combos?.saved).not.toHaveProperty("sessionPin");
});
+
+ test("the CLI and API carry the per-turn effort mode with the pin", async () => {
+ expect(await handleComboCommand(["set", "saved", "--session-pin-effort", "per-turn", "--json"], deps)).not.toBe(0);
+ expect(await handleComboCommand(["set", "saved", "--session-pin", "20m", "--session-pin-effort", "per-turn", "--json"], deps)).toBe(0);
+ expect(config.combos?.saved?.sessionPin).toEqual({ ttlMs: 1_200_000, effort: "per-turn" });
+ expect(stored().saved).toMatchObject({ sessionPin: { ttlMs: 1_200_000, effort: "per-turn" } });
+ // A new idle window keeps the effort mode; the mode alone keeps the idle window.
+ expect(await handleComboCommand(["set", "saved", "--session-pin", "1h", "--json"], deps)).toBe(0);
+ expect(config.combos?.saved?.sessionPin).toEqual({ ttlMs: 3_600_000, effort: "per-turn" });
+ expect(await handleComboCommand(["set", "saved", "--session-pin-effort", "pinned", "--json"], deps)).toBe(0);
+ expect(config.combos?.saved?.sessionPin).toEqual({ ttlMs: 3_600_000 });
+ for (const args of [["--session-pin-effort", "sometimes"], ["--session-pin", "-", "--session-pin-effort", "per-turn"],
+ ["--strategy", "failover", "--session-pin-effort", "per-turn"]]) {
+ expect(await handleComboCommand(["set", "saved", ...args, "--json"], deps)).not.toBe(0);
+ }
+ expect((await put({ strategy: "jev", targets, sessionPin: { ttlMs: 60_000, effort: "always" } })).status).toBe(400);
+ });
});
diff --git a/tests/routing/jev-session-pin.test.ts b/tests/routing/jev-session-pin.test.ts
index cfd09a99bbe..8bf9df68f43 100644
--- a/tests/routing/jev-session-pin.test.ts
+++ b/tests/routing/jev-session-pin.test.ts
@@ -53,10 +53,14 @@ beforeEach(() => {
afterEach(() => clearJevSessionPinsForTests());
describe("JEV session pin settings", () => {
- test("accept exactly { ttlMs } inside the documented range", () => {
+ test("accept { ttlMs } inside the documented range with an optional effort mode", () => {
expect(normalizeJevSessionPin({ ttlMs: 1_200_000 })).toEqual({ ttlMs: 1_200_000 });
+ expect(normalizeJevSessionPin({ ttlMs: 1_200_000, effort: "per-turn" })).toEqual({ ttlMs: 1_200_000, effort: "per-turn" });
+ // The default mode is accepted as input and normalized away, so stored configs stay sparse.
+ expect(normalizeJevSessionPin({ ttlMs: 1_200_000, effort: "pinned" })).toEqual({ ttlMs: 1_200_000 });
for (const value of [null, [], {}, { ttlMs: JEV_SESSION_PIN_TTL_MIN_MS - 1 }, { ttlMs: JEV_SESSION_PIN_TTL_MAX_MS + 1 },
- { ttlMs: 90_000.5 }, { ttlMs: "20m" }, { ttlMs: 90_000, turns: 3 }]) {
+ { ttlMs: 90_000.5 }, { ttlMs: "20m" }, { ttlMs: 90_000, turns: 3 }, { effort: "per-turn" },
+ { ttlMs: 90_000, effort: "always" }, { ttlMs: 90_000, effort: null }]) {
expect(normalizeJevSessionPin(value)).toBeUndefined();
}
});
@@ -212,6 +216,23 @@ describe("persisted session pin evidence", () => {
expect(normalizePersistedJevDecision({ ...base, pinned: true })).toBeUndefined();
expect(normalizePersistedJevDecision({ ...base, pinReason: "session" })).toBeUndefined();
expect(normalizePersistedJevDecision({ ...base, pinned: true, pinReason: "expired" })).toBeUndefined();
+ expect(normalizePersistedJevDecision({ ...base, pinned: true, pinReason: "session_effort" }))
+ .toMatchObject({ pinned: true, pinReason: "session_effort" });
+ expect(normalizePersistedJevDecision({ ...base, pinReason: "session_effort" })).toBeUndefined();
+ });
+
+ test("a per-turn effort decision on a pinned turn counts as pinned and as a decision", () => {
+ const entry = (jevDecision: Record, timestamp: number) => ({
+ timestamp, status: 200, jevDecision, attempts: [],
+ }) as unknown as PersistedUsageEntry;
+ const accumulator = createJevStatsAccumulator({ comboId: "auto" });
+ accumulator.add(entry({ ...base, latencyMs: 40 }, 1));
+ accumulator.add(entry({ ...base, latencyMs: 20, pinned: true, pinReason: "session_effort",
+ usage: { inputTokens: 10, outputTokens: 2, totalTokens: 12 } }, 2));
+ accumulator.add(entry({ ...base, pinned: true, pinReason: "session" }, 3));
+ expect(accumulator.clone().summarize("all", 3).summary).toMatchObject({
+ decisions: 2, appliedDecisions: 2, pinnedRequests: 2, averageLatencyMs: 30, decisionTotalTokens: 12,
+ });
});
test("stats count pinned turns apart from decisions while keeping their physical tokens and request outcomes", () => {
diff --git a/tests/server/server-jev-session-pin.test.ts b/tests/server/server-jev-session-pin.test.ts
index 0191e656a36..02d9f70643a 100644
--- a/tests/server/server-jev-session-pin.test.ts
+++ b/tests/server/server-jev-session-pin.test.ts
@@ -287,3 +287,72 @@ describe("JEV session pin (#7525)", () => {
}
});
});
+
+describe("JEV session pin per-turn effort", () => {
+ /** A per-turn pinned Combo whose decision service answers `answers` in order and records each payload. */
+ function perTurnConfig(answers: string[], payloads: Array>): OcxConfig {
+ const config = makeConfig("unused", [], true);
+ config.combos!.auto!.sessionPin = { ttlMs: 20 * 60_000, effort: "per-turn" };
+ config.providers.jev!.fetch = (async (_input, init) => {
+ payloads.push(JSON.parse(String(init?.body)) as Record);
+ const choice = answers.shift();
+ if (choice === undefined) throw new Error("decision service down");
+ return Response.json({ answers: { route: { choice, confidence: 0.7 } } });
+ }) as typeof fetch;
+ return config;
+ }
+ const offered = (payload: Record | undefined) => {
+ const criteria = (payload?.questions as { route: { criteria: Record } }).route.criteria;
+ return [...new Set(Object.values(criteria).map(option => option.target))];
+ };
+ const effortOf = (body: Record) => (body.reasoning as { effort?: string } | undefined)?.effort;
+
+ test("keeps the pinned target and asks JEV only for its effort on each pinned turn", async () => {
+ const payloads: Array> = [];
+ const config = perTurnConfig(["sol/gpt-5.6-sol:high", "sol/gpt-5.6-sol:low"], payloads);
+ const bodies: Record[] = [];
+ const child: Child = (body, complete) => { bodies.push(body); return served(body, complete); };
+ expect((await turn(config, { child })).status).toBe(200);
+ const log: RequestLogContext = { model: "", provider: "" };
+ expect((await turn(config, { child, log })).status).toBe(200);
+ expect(payloads).toHaveLength(2);
+ expect(offered(payloads[0])).toEqual(["luna/gpt-5.6-luna", "sol/gpt-5.6-sol"]);
+ expect(offered(payloads[1])).toEqual(["sol/gpt-5.6-sol"]);
+ expect(bodies.map(body => [body.model, effortOf(body)])).toEqual([["sol/gpt-5.6-sol", "high"], ["sol/gpt-5.6-sol", "low"]]);
+ expect(log.jevDecision).toMatchObject({
+ selected: { provider: "sol", model: SOL.model, effort: "low" },
+ gate: "apply",
+ pinned: true,
+ pinReason: "session_effort",
+ });
+ expect(log.routeDecision?.selected.reason).toBe(JEV_SESSION_PIN_ROUTE_REASON);
+ });
+
+ test("an unusable effort answer or a failed decision call keeps the pinned effort and the pin", async () => {
+ const payloads: Array> = [];
+ // Turn 2 answers a target that was not offered; turn 3 finds the service down.
+ const config = perTurnConfig(["sol/gpt-5.6-sol:high", "luna/gpt-5.6-luna:low"], payloads);
+ const bodies: Record[] = [];
+ const child: Child = (body, complete) => { bodies.push(body); return served(body, complete); };
+ await turn(config, { child });
+ const logs: RequestLogContext[] = [{ model: "", provider: "" }, { model: "", provider: "" }];
+ expect((await turn(config, { child, log: logs[0] })).status).toBe(200);
+ expect((await turn(config, { child, log: logs[1] })).status).toBe(200);
+ expect(payloads).toHaveLength(3);
+ expect(bodies.map(body => [body.model, effortOf(body)])).toEqual([
+ ["sol/gpt-5.6-sol", "high"], ["sol/gpt-5.6-sol", "high"], ["sol/gpt-5.6-sol", "high"],
+ ]);
+ for (const log of logs) expect(log.jevDecision).toMatchObject({ pinned: true, pinReason: "session", gate: "apply" });
+ });
+
+ test("a target with a single effort is reused without a decision call", async () => {
+ const payloads: Array> = [];
+ const config = perTurnConfig(["sol/gpt-5.6-sol:high"], payloads);
+ config.providers.sol!.modelReasoningEfforts = { [SOL.model]: ["high"] };
+ await turn(config);
+ const log: RequestLogContext = { model: "", provider: "" };
+ await turn(config, { log });
+ expect(payloads).toHaveLength(1);
+ expect(log.jevDecision).toMatchObject({ pinned: true, pinReason: "session" });
+ });
+});