pokertools-arena 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  | **Package** | `npx pokertools-arena` |
18
18
  | **Runtime** | Node.js ≥ 24 for tooling; any modern browser for the app |
19
19
 
20
- > **Current release: 0.16.2.** Blind levels now advance by a default multiplier of ×2, so blinds double each level unless changed in the tournament settings.
20
+ > **Current release: 0.17.0.** Seats on OpenRouter models that advertise reasoning efforts can now pick a per-seat reasoning effort, so a slow reasoner such as DeepSeek can be lowered from its `high` default to `low` instead of exhausting the action clock and being auto-folded.
21
21
 
22
22
  ---
23
23
 
package/dist/app.js CHANGED
@@ -114432,8 +114432,15 @@ async function fetchStreamingJson(url, options, { maxRetries = 1, onDelta = null
114432
114432
  throw new ArenaRequestError(message, { status: response.status, category: requestErrorCategory(response.status), payload, incidents });
114433
114433
  }
114434
114434
  }
114435
+ function requestErrorDetail(err) {
114436
+ const parts = [summarizeError(err)];
114437
+ const nested = err?.payload?.error?.metadata?.raw ?? err?.payload?.metadata?.raw;
114438
+ if (typeof nested === "string") parts.push(nested);
114439
+ else if (nested) parts.push(String(JSON.stringify(nested)));
114440
+ return parts.join(" ");
114441
+ }
114435
114442
  function isUnsupportedToolChoiceError(err) {
114436
- const message = summarizeError(err);
114443
+ const message = requestErrorDetail(err);
114437
114444
  return [400, 404, 422].includes(Number(err?.status)) && /tool[_ -]?choice|tool call|tools?.*(?:unsupported|support)|no endpoints?.*support/i.test(message);
114438
114445
  }
114439
114446
  function mapGet(mapish, key, fallback = 0) {
@@ -114459,13 +114466,23 @@ function jsonSafe(value) {
114459
114466
  }
114460
114467
  var protocolCapabilityCache = /* @__PURE__ */ new Map();
114461
114468
  var modelSupportedParameters = /* @__PURE__ */ new Map();
114469
+ var modelReasoningCapabilities = /* @__PURE__ */ new Map();
114462
114470
  var DEFAULT_TEMPERATURE = 0.3;
114463
114471
  function registerModelCapabilities(models = []) {
114464
114472
  for (const entry of models ?? []) {
114465
114473
  const id2 = typeof entry === "string" ? entry : entry?.id;
114474
+ if (!id2) continue;
114466
114475
  const parameters = typeof entry === "object" && entry ? entry.supported_parameters : null;
114467
- if (!id2 || !Array.isArray(parameters)) continue;
114468
- modelSupportedParameters.set(String(id2), new Set(parameters.map(String)));
114476
+ if (Array.isArray(parameters)) modelSupportedParameters.set(String(id2), new Set(parameters.map(String)));
114477
+ const reasoning = typeof entry === "object" && entry ? entry.reasoning : null;
114478
+ if (!reasoning || typeof reasoning !== "object") continue;
114479
+ const efforts = Array.isArray(reasoning.supported_efforts) ? reasoning.supported_efforts.map((value) => String(value).trim().toLowerCase()).filter(Boolean) : [];
114480
+ const declaredDefault = String(reasoning.default_effort || "").trim().toLowerCase();
114481
+ modelReasoningCapabilities.set(String(id2), {
114482
+ mandatory: reasoning.mandatory === true,
114483
+ supportedEfforts: efforts,
114484
+ defaultEffort: efforts.includes(declaredDefault) ? declaredDefault : null
114485
+ });
114469
114486
  }
114470
114487
  }
114471
114488
  function modelSupportsParameter(model, parameter) {
@@ -114473,6 +114490,34 @@ function modelSupportsParameter(model, parameter) {
114473
114490
  if (!supported) return null;
114474
114491
  return supported.has(String(parameter));
114475
114492
  }
114493
+ function reasoningCapabilities(model) {
114494
+ return modelReasoningCapabilities.get(String(model || "")) ?? null;
114495
+ }
114496
+ function reasoningEffortsFor(model) {
114497
+ return reasoningCapabilities(model)?.supportedEfforts ?? [];
114498
+ }
114499
+ function defaultReasoningEffort(model) {
114500
+ return reasoningCapabilities(model)?.defaultEffort ?? null;
114501
+ }
114502
+ function requiresReasoning(model) {
114503
+ return reasoningCapabilities(model)?.mandatory === true;
114504
+ }
114505
+ function resolveReasoningEffort(model, requested) {
114506
+ const value = String(requested || "").trim().toLowerCase();
114507
+ if (!value) return null;
114508
+ const capabilities = reasoningCapabilities(model);
114509
+ if (!capabilities) return value;
114510
+ return capabilities.supportedEfforts.includes(value) ? value : null;
114511
+ }
114512
+ function reasoningOverrideFor(agent) {
114513
+ const effort = resolveReasoningEffort(agent?.model, agent?.reasoningEffort);
114514
+ return effort ? { effort } : { max_tokens: 256, exclude: false };
114515
+ }
114516
+ function truncationReasoningOverride(model) {
114517
+ const capabilities = reasoningCapabilities(model);
114518
+ if (!capabilities) return { effort: "low" };
114519
+ return capabilities.supportedEfforts.includes("low") ? { effort: "low" } : { max_tokens: 4096, exclude: false };
114520
+ }
114476
114521
  function resolveTemperature(model, requested) {
114477
114522
  if (modelSupportsParameter(model, "temperature") === false) return void 0;
114478
114523
  return Number.isFinite(Number(requested)) ? Number(requested) : DEFAULT_TEMPERATURE;
@@ -114511,7 +114556,7 @@ function isReasoningModel(model) {
114511
114556
  return /(?:^|[\/._:-])(qwen3|qwq|deepseek-[rv]\d|magistral|glm-(?:\d+[a-z]*|latest|flash-latest)|gpt-oss|nemotron|reason(?:ing)?|thinking|o1|o3|o4)(?:$|[\/._:-])/.test(id2);
114512
114557
  }
114513
114558
  function wantsReasoning(agent) {
114514
- return isReasoningModel(agent?.model) || agent?.captureReasoning === true;
114559
+ return isReasoningModel(agent?.model) || agent?.captureReasoning === true || reasoningCapabilities(agent?.model)?.mandatory === true || Boolean(resolveReasoningEffort(agent?.model, agent?.reasoningEffort));
114515
114560
  }
114516
114561
  function effectiveProtocol(agent, connection) {
114517
114562
  if (isOpenRouterConnection(connection) && isJevModel(agent?.model)) return "jev_decisions";
@@ -115287,7 +115332,7 @@ Respond with one of: ${criteriaKeys.map((k) => `${k} = ${criteria[k]}`).join(";
115287
115332
  }
115288
115333
  if (isOpenRouter) {
115289
115334
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
115290
- if (wantsReasoning(agent)) body.reasoning = reasoning ?? { max_tokens: 256, exclude: false };
115335
+ if (wantsReasoning(agent)) body.reasoning = reasoning ?? reasoningOverrideFor(agent);
115291
115336
  }
115292
115337
  if (stream) {
115293
115338
  body.stream = true;
@@ -115350,7 +115395,7 @@ async function decideOpenAICompatible({ agent, connection, state, legalActions,
115350
115395
  result = await execute(actualProtocol);
115351
115396
  } else if (err?.truncated) {
115352
115397
  truncationRetry = true;
115353
- result = await execute(actualProtocol, { maxTokens: 4096, reasoning: { effort: "low" } });
115398
+ result = await execute(actualProtocol, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
115354
115399
  } else throw err;
115355
115400
  }
115356
115401
  const normalized = normalizeDecisionObject(result.obj, legalActions, decisionId);
@@ -115526,7 +115571,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
115526
115571
  }
115527
115572
  if (isOpenRouter) {
115528
115573
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
115529
- if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? { max_tokens: 256, exclude: false };
115574
+ if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? reasoningOverrideFor(agent);
115530
115575
  }
115531
115576
  if (onDelta) {
115532
115577
  body.stream = true;
@@ -115570,7 +115615,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
115570
115615
  return { ...result, protocolFallback: "tool\u2192json_schema" };
115571
115616
  }
115572
115617
  if (err?.truncated) {
115573
- const result = await execute(actual, { maxTokens: 4096, reasoning: { effort: "low" } });
115618
+ const result = await execute(actual, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
115574
115619
  return { ...result, protocolFallback: "truncated\u2192retry" };
115575
115620
  }
115576
115621
  throw err;
@@ -115843,6 +115888,8 @@ var els = {
115843
115888
  seatProtocol: $("#seatProtocol"),
115844
115889
  seatProvider: $("#seatProvider"),
115845
115890
  seatCaptureReasoning: $("#seatCaptureReasoning"),
115891
+ seatReasoningEffort: $("#seatReasoningEffort"),
115892
+ seatReasoningEffortHelp: $("#seatReasoningEffortHelp"),
115846
115893
  seatError: $("#seatError"),
115847
115894
  removeSeatBtn: $("#removeSeatBtn"),
115848
115895
  cancelSeatBtn: $("#cancelSeatBtn"),
@@ -115893,6 +115940,7 @@ var WINNER_REVIEW_MS = 2e3;
115893
115940
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
115894
115941
  var editingSeatIndex = null;
115895
115942
  var seatNameAuto = false;
115943
+ var seatReasoningEffortDraft = "";
115896
115944
  var lobbyVisible = true;
115897
115945
  var pendingAutostart = false;
115898
115946
  var arenaMaxDecisions = 0;
@@ -116198,7 +116246,8 @@ function injectedEnvironmentConfig() {
116198
116246
  model: String(player.model || ""),
116199
116247
  protocol: String(player.protocol || "tool"),
116200
116248
  provider: String(player.provider || ""),
116201
- captureReasoning: Boolean(player.captureReasoning)
116249
+ captureReasoning: Boolean(player.captureReasoning),
116250
+ reasoningEffort: String(player.reasoningEffort || "").trim().toLowerCase()
116202
116251
  }))
116203
116252
  };
116204
116253
  }
@@ -116515,7 +116564,7 @@ function normalizeConfig(input = {}) {
116515
116564
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
116516
116565
  const model = String(raw.model || "").trim();
116517
116566
  if (!model) throw new Error(`Model missing for ${name}`);
116518
- return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || "tool", provider: String(raw.provider || "").trim(), captureReasoning: raw.captureReasoning === true, temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
116567
+ return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || "tool", provider: String(raw.provider || "").trim(), captureReasoning: raw.captureReasoning === true, reasoningEffort: String(raw.reasoningEffort || "").trim().toLowerCase(), temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
116519
116568
  })
116520
116569
  };
116521
116570
  }
@@ -116833,6 +116882,7 @@ var TournamentDirector = class {
116833
116882
  model: agent.model,
116834
116883
  connection: connection?.name ?? agent.connectionId,
116835
116884
  provider: agent.provider || "auto",
116885
+ reasoningEffort: agent.reasoningEffort || null,
116836
116886
  protocol: effectiveProtocol(agent, connection),
116837
116887
  handNumber: this.handNumber,
116838
116888
  street: this.engine.state.street,
@@ -116981,6 +117031,7 @@ var TournamentDirector = class {
116981
117031
  configuredModel: agent.model,
116982
117032
  resolvedModel: result?.model ?? agent.model,
116983
117033
  provider: agent.provider || "auto",
117034
+ reasoningEffort: agent.reasoningEffort || null,
116984
117035
  action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description },
116985
117036
  forced,
116986
117037
  legalActions: legalActions.map((a) => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
@@ -117221,6 +117272,7 @@ function renderModelOptions(models, currentValue = "") {
117221
117272
  if (currentValue && !byId.has(currentValue)) byId.set(currentValue, { id: currentValue, name: currentValue });
117222
117273
  const rows = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
117223
117274
  els.seatModelOptions.innerHTML = rows.map((model) => `<option value="${escapeHtml(model.id)}" label="${escapeHtml(model.name || model.id)}"></option>`).join("");
117275
+ updateSeatReasoningEffortOptions();
117224
117276
  }
117225
117277
  async function refreshSeatModelCatalog({ force = false } = {}) {
117226
117278
  const connection = fullConnectionById(els.seatConnection?.value);
@@ -117274,9 +117326,41 @@ function defaultSeatDraft(seatIndex) {
117274
117326
  model: conn?.kind === "typesafe" ? "jev-latest" : "",
117275
117327
  protocol: conn?.kind === "typesafe" ? "jev_native" : "tool",
117276
117328
  provider: "",
117277
- captureReasoning: false
117329
+ captureReasoning: false,
117330
+ reasoningEffort: ""
117278
117331
  };
117279
117332
  }
117333
+ function updateSeatReasoningEffortOptions(selected) {
117334
+ const select = els.seatReasoningEffort;
117335
+ if (!select) return;
117336
+ const openRouter = isOpenRouterConnection(connectionById(els.seatConnection.value));
117337
+ const model = els.seatModel.value.trim();
117338
+ const efforts = openRouter ? reasoningEffortsFor(model) : [];
117339
+ const modelDefault = efforts.length ? defaultReasoningEffort(model) : null;
117340
+ const current = String(selected ?? seatReasoningEffortDraft ?? "").trim().toLowerCase();
117341
+ select.innerHTML = [
117342
+ '<option value="">Arena default (capped)</option>',
117343
+ ...efforts.map((effort) => `<option value="${escapeHtml(effort)}">${escapeHtml(effort)}${effort === modelDefault ? " \xB7 model default" : ""}</option>`)
117344
+ ].join("");
117345
+ if (efforts.includes(current)) {
117346
+ select.value = current;
117347
+ seatReasoningEffortDraft = current;
117348
+ } else {
117349
+ select.value = "";
117350
+ if (!current) seatReasoningEffortDraft = "";
117351
+ }
117352
+ select.disabled = !efforts.length;
117353
+ const help = els.seatReasoningEffortHelp;
117354
+ if (!help) return;
117355
+ help.dataset.tone = "";
117356
+ if (!openRouter) help.textContent = "Reasoning effort is available for OpenRouter models that advertise it.";
117357
+ else if (!efforts.length) help.textContent = "This model does not advertise reasoning efforts; the capped default is used.";
117358
+ else if (!select.value && requiresReasoning(model)) {
117359
+ help.textContent = `This model always reasons and ignores the capped default, so it can exceed the action clock and auto-fold. Pick an effort \u2014 ${efforts.includes("minimal") ? "minimal" : efforts[0]} is usually fastest.`;
117360
+ help.dataset.tone = "bad";
117361
+ } else if (modelDefault) help.textContent = `Model default is ${modelDefault}. Lower it when a slow reasoner runs out of action clock and auto-folds.`;
117362
+ else help.textContent = "Lower it when a slow reasoner runs out of action clock and auto-folds.";
117363
+ }
117280
117364
  function applySeatProtocolRules() {
117281
117365
  const conn = connectionById(els.seatConnection.value);
117282
117366
  const kind = conn?.kind || "openrouter";
@@ -117285,6 +117369,7 @@ function applySeatProtocolRules() {
117285
117369
  const jevSeat = kind === "typesafe" || jevViaOpenRouter;
117286
117370
  document.querySelector(".seat-provider-field")?.classList.toggle("hidden", !openRouter || jevViaOpenRouter);
117287
117371
  document.querySelector(".seat-reasoning-field")?.classList.toggle("hidden", jevSeat);
117372
+ document.querySelector(".seat-effort-field")?.classList.toggle("hidden", jevSeat);
117288
117373
  if (els.seatCaptureReasoning) {
117289
117374
  els.seatCaptureReasoning.disabled = jevSeat;
117290
117375
  const alreadyReasoning = isReasoningModel(els.seatModel.value.trim());
@@ -117305,6 +117390,7 @@ function applySeatProtocolRules() {
117305
117390
  els.seatProvider.disabled = !openRouter;
117306
117391
  if (els.seatModel.value === "jev-latest") els.seatModel.value = "";
117307
117392
  }
117393
+ updateSeatReasoningEffortOptions();
117308
117394
  }
117309
117395
  function updateSeatSummary() {
117310
117396
  const count = seatAssignments.filter(Boolean).length;
@@ -117331,13 +117417,16 @@ function openSeatEditor(seatIndex) {
117331
117417
  els.seatProtocol.value = draft.protocol || "tool";
117332
117418
  els.seatProvider.value = draft.provider || "";
117333
117419
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.checked = Boolean(draft.captureReasoning);
117420
+ seatReasoningEffortDraft = String(draft.reasoningEffort || "").trim().toLowerCase();
117334
117421
  applySeatProtocolRules();
117422
+ updateSeatReasoningEffortOptions(seatReasoningEffortDraft);
117335
117423
  syncSeatNameFromModel();
117336
117424
  void refreshSeatModelCatalog();
117337
117425
  els.seatError.classList.add("hidden");
117338
117426
  els.seatLockNotice.classList.toggle("hidden", !locked);
117339
117427
  for (const control of [els.seatName, els.seatConnection, els.seatModel, els.seatProtocol, els.seatProvider]) control.disabled = locked || control === els.seatProvider && (!isOpenRouterConnection(connectionById(els.seatConnection.value)) || isJevModel(els.seatModel.value));
117340
117428
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.disabled = locked || els.seatCaptureReasoning.disabled;
117429
+ if (els.seatReasoningEffort) els.seatReasoningEffort.disabled = locked || els.seatReasoningEffort.disabled;
117341
117430
  els.saveSeatBtn.disabled = locked;
117342
117431
  els.removeSeatBtn.disabled = locked || !seatAssignments[seatIndex];
117343
117432
  if (!els.seatDialog.open) els.seatDialog.showModal();
@@ -117355,7 +117444,8 @@ function readSeatDraft() {
117355
117444
  model: els.seatModel.value.trim(),
117356
117445
  protocol: els.seatProtocol.value,
117357
117446
  provider: els.seatProvider.disabled ? "" : els.seatProvider.value.trim(),
117358
- captureReasoning: Boolean(els.seatCaptureReasoning && !els.seatCaptureReasoning.disabled && els.seatCaptureReasoning.checked)
117447
+ captureReasoning: Boolean(els.seatCaptureReasoning && !els.seatCaptureReasoning.disabled && els.seatCaptureReasoning.checked),
117448
+ reasoningEffort: els.seatReasoningEffort && !els.seatReasoningEffort.disabled ? els.seatReasoningEffort.value.trim().toLowerCase() : ""
117359
117449
  };
117360
117450
  }
117361
117451
  function readSeatPlayers() {
@@ -117384,7 +117474,8 @@ function restoreSeatAssignments(rows = []) {
117384
117474
  model: player.model || "",
117385
117475
  protocol: player.protocol || "tool",
117386
117476
  provider: player.provider || "",
117387
- captureReasoning: player.captureReasoning === true
117477
+ captureReasoning: player.captureReasoning === true,
117478
+ reasoningEffort: String(player.reasoningEffort || "").trim().toLowerCase()
117388
117479
  };
117389
117480
  });
117390
117481
  updateSeatSummary();
@@ -118116,7 +118207,7 @@ function renderStats(s) {
118116
118207
  function tableRenderSignature(s) {
118117
118208
  const running = ["RUNNING", "PAUSED"].includes(s?.status);
118118
118209
  if (lobbyVisible && !running) {
118119
- return JSON.stringify(["lobby", seatAssignments.map((p) => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning)] : null)]);
118210
+ return JSON.stringify(["lobby", seatAssignments.map((p) => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning), p.reasoningEffort || ""] : null)]);
118120
118211
  }
118121
118212
  const t = s?.table;
118122
118213
  if (!t) return JSON.stringify(["empty", s?.status || "IDLE"]);
@@ -119500,6 +119591,9 @@ els.seatName.addEventListener("input", () => {
119500
119591
  });
119501
119592
  els.seatProtocol.addEventListener("change", applySeatProtocolRules);
119502
119593
  els.seatCaptureReasoning?.addEventListener("change", applySeatProtocolRules);
119594
+ els.seatReasoningEffort?.addEventListener("change", () => {
119595
+ seatReasoningEffortDraft = els.seatReasoningEffort.value;
119596
+ });
119503
119597
  els.refreshModelsBtn?.addEventListener("click", () => void refreshSeatModelCatalog({ force: true }));
119504
119598
  els.removeSeatBtn.addEventListener("click", () => {
119505
119599
  if (editingSeatIndex == null || director && ["RUNNING", "PAUSED"].includes(director.status)) return;
package/dist/index.html CHANGED
@@ -304,6 +304,11 @@
304
304
  <span class="checkbox-field"><input id="seatCaptureReasoning" type="checkbox" /> Capture raw model reasoning</span>
305
305
  <span class="field-help seat-reasoning-help hidden">This model is not a reasoning model by default. Capturing its reasoning can be far slower (Gemma measured ~2s → 27–48s per decision) and is outside the standard benchmark track. Raise the action clock before using it.</span>
306
306
  </label>
307
+ <label class="seat-effort-field">
308
+ <span class="field-label-row">Reasoning effort <button class="info-button inline-info" type="button" aria-label="About reasoning effort" data-tip="How much hidden thinking the model may spend. Higher effort is smarter but slower; a slow reasoner can exhaust the action clock and be forced to fold. Options come from the OpenRouter model catalogue, and an unset value keeps the arena's fixed reasoning cap.">i</button></span>
309
+ <select id="seatReasoningEffort"></select>
310
+ <span id="seatReasoningEffortHelp" class="field-help"></span>
311
+ </label>
307
312
  </div>
308
313
  </div>
309
314
  <div id="seatError" class="setup-error hidden"></div>
@@ -548,7 +548,7 @@ label input, label select { margin-top: 5px; font-weight: 450; }
548
548
  .split-footer { justify-content: space-between; }
549
549
  .seat-footer-actions, .replay-share-actions { display: flex; gap: 7px; }
550
550
  .seat-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
551
- .seat-model-field { grid-column: 1 / -1; }
551
+ .seat-model-field, .seat-effort-field { grid-column: 1 / -1; }
552
552
  .model-input-wrap { margin-top: 6px; display: grid; grid-template-columns: minmax(0, 1fr) 38px; gap: 8px; }
553
553
  .model-input-wrap input { margin: 0; }
554
554
  .field-icon-button, .dialog-body .remove-connection { width: 38px; height: 38px; min-height: 38px; border-radius: 8px; }
@@ -1041,6 +1041,11 @@ label input, label select { margin-top: 5px; font-weight: 450; }
1041
1041
  <span class="checkbox-field"><input id="seatCaptureReasoning" type="checkbox" /> Capture raw model reasoning</span>
1042
1042
  <span class="field-help seat-reasoning-help hidden">This model is not a reasoning model by default. Capturing its reasoning can be far slower (Gemma measured ~2s → 27–48s per decision) and is outside the standard benchmark track. Raise the action clock before using it.</span>
1043
1043
  </label>
1044
+ <label class="seat-effort-field">
1045
+ <span class="field-label-row">Reasoning effort <button class="info-button inline-info" type="button" aria-label="About reasoning effort" data-tip="How much hidden thinking the model may spend. Higher effort is smarter but slower; a slow reasoner can exhaust the action clock and be forced to fold. Options come from the OpenRouter model catalogue, and an unset value keeps the arena's fixed reasoning cap.">i</button></span>
1046
+ <select id="seatReasoningEffort"></select>
1047
+ <span id="seatReasoningEffortHelp" class="field-help"></span>
1048
+ </label>
1044
1049
  </div>
1045
1050
  </div>
1046
1051
  <div id="seatError" class="setup-error hidden"></div>
@@ -115591,8 +115596,15 @@ async function fetchStreamingJson(url, options, { maxRetries = 1, onDelta = null
115591
115596
  throw new ArenaRequestError(message, { status: response.status, category: requestErrorCategory(response.status), payload, incidents });
115592
115597
  }
115593
115598
  }
115599
+ function requestErrorDetail(err) {
115600
+ const parts = [summarizeError(err)];
115601
+ const nested = err?.payload?.error?.metadata?.raw ?? err?.payload?.metadata?.raw;
115602
+ if (typeof nested === "string") parts.push(nested);
115603
+ else if (nested) parts.push(String(JSON.stringify(nested)));
115604
+ return parts.join(" ");
115605
+ }
115594
115606
  function isUnsupportedToolChoiceError(err) {
115595
- const message = summarizeError(err);
115607
+ const message = requestErrorDetail(err);
115596
115608
  return [400, 404, 422].includes(Number(err?.status)) && /tool[_ -]?choice|tool call|tools?.*(?:unsupported|support)|no endpoints?.*support/i.test(message);
115597
115609
  }
115598
115610
  function mapGet(mapish, key, fallback = 0) {
@@ -115618,13 +115630,23 @@ function jsonSafe(value) {
115618
115630
  }
115619
115631
  var protocolCapabilityCache = /* @__PURE__ */ new Map();
115620
115632
  var modelSupportedParameters = /* @__PURE__ */ new Map();
115633
+ var modelReasoningCapabilities = /* @__PURE__ */ new Map();
115621
115634
  var DEFAULT_TEMPERATURE = 0.3;
115622
115635
  function registerModelCapabilities(models = []) {
115623
115636
  for (const entry of models ?? []) {
115624
115637
  const id2 = typeof entry === "string" ? entry : entry?.id;
115638
+ if (!id2) continue;
115625
115639
  const parameters = typeof entry === "object" && entry ? entry.supported_parameters : null;
115626
- if (!id2 || !Array.isArray(parameters)) continue;
115627
- modelSupportedParameters.set(String(id2), new Set(parameters.map(String)));
115640
+ if (Array.isArray(parameters)) modelSupportedParameters.set(String(id2), new Set(parameters.map(String)));
115641
+ const reasoning = typeof entry === "object" && entry ? entry.reasoning : null;
115642
+ if (!reasoning || typeof reasoning !== "object") continue;
115643
+ const efforts = Array.isArray(reasoning.supported_efforts) ? reasoning.supported_efforts.map((value) => String(value).trim().toLowerCase()).filter(Boolean) : [];
115644
+ const declaredDefault = String(reasoning.default_effort || "").trim().toLowerCase();
115645
+ modelReasoningCapabilities.set(String(id2), {
115646
+ mandatory: reasoning.mandatory === true,
115647
+ supportedEfforts: efforts,
115648
+ defaultEffort: efforts.includes(declaredDefault) ? declaredDefault : null
115649
+ });
115628
115650
  }
115629
115651
  }
115630
115652
  function modelSupportsParameter(model, parameter) {
@@ -115632,6 +115654,34 @@ function modelSupportsParameter(model, parameter) {
115632
115654
  if (!supported) return null;
115633
115655
  return supported.has(String(parameter));
115634
115656
  }
115657
+ function reasoningCapabilities(model) {
115658
+ return modelReasoningCapabilities.get(String(model || "")) ?? null;
115659
+ }
115660
+ function reasoningEffortsFor(model) {
115661
+ return reasoningCapabilities(model)?.supportedEfforts ?? [];
115662
+ }
115663
+ function defaultReasoningEffort(model) {
115664
+ return reasoningCapabilities(model)?.defaultEffort ?? null;
115665
+ }
115666
+ function requiresReasoning(model) {
115667
+ return reasoningCapabilities(model)?.mandatory === true;
115668
+ }
115669
+ function resolveReasoningEffort(model, requested) {
115670
+ const value = String(requested || "").trim().toLowerCase();
115671
+ if (!value) return null;
115672
+ const capabilities = reasoningCapabilities(model);
115673
+ if (!capabilities) return value;
115674
+ return capabilities.supportedEfforts.includes(value) ? value : null;
115675
+ }
115676
+ function reasoningOverrideFor(agent) {
115677
+ const effort = resolveReasoningEffort(agent?.model, agent?.reasoningEffort);
115678
+ return effort ? { effort } : { max_tokens: 256, exclude: false };
115679
+ }
115680
+ function truncationReasoningOverride(model) {
115681
+ const capabilities = reasoningCapabilities(model);
115682
+ if (!capabilities) return { effort: "low" };
115683
+ return capabilities.supportedEfforts.includes("low") ? { effort: "low" } : { max_tokens: 4096, exclude: false };
115684
+ }
115635
115685
  function resolveTemperature(model, requested) {
115636
115686
  if (modelSupportsParameter(model, "temperature") === false) return void 0;
115637
115687
  return Number.isFinite(Number(requested)) ? Number(requested) : DEFAULT_TEMPERATURE;
@@ -115670,7 +115720,7 @@ function isReasoningModel(model) {
115670
115720
  return /(?:^|[\/._:-])(qwen3|qwq|deepseek-[rv]\d|magistral|glm-(?:\d+[a-z]*|latest|flash-latest)|gpt-oss|nemotron|reason(?:ing)?|thinking|o1|o3|o4)(?:$|[\/._:-])/.test(id2);
115671
115721
  }
115672
115722
  function wantsReasoning(agent) {
115673
- return isReasoningModel(agent?.model) || agent?.captureReasoning === true;
115723
+ return isReasoningModel(agent?.model) || agent?.captureReasoning === true || reasoningCapabilities(agent?.model)?.mandatory === true || Boolean(resolveReasoningEffort(agent?.model, agent?.reasoningEffort));
115674
115724
  }
115675
115725
  function effectiveProtocol(agent, connection) {
115676
115726
  if (isOpenRouterConnection(connection) && isJevModel(agent?.model)) return "jev_decisions";
@@ -116446,7 +116496,7 @@ Respond with one of: ${criteriaKeys.map((k) => `${k} = ${criteria[k]}`).join(";
116446
116496
  }
116447
116497
  if (isOpenRouter) {
116448
116498
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
116449
- if (wantsReasoning(agent)) body.reasoning = reasoning ?? { max_tokens: 256, exclude: false };
116499
+ if (wantsReasoning(agent)) body.reasoning = reasoning ?? reasoningOverrideFor(agent);
116450
116500
  }
116451
116501
  if (stream) {
116452
116502
  body.stream = true;
@@ -116509,7 +116559,7 @@ async function decideOpenAICompatible({ agent, connection, state, legalActions,
116509
116559
  result = await execute(actualProtocol);
116510
116560
  } else if (err?.truncated) {
116511
116561
  truncationRetry = true;
116512
- result = await execute(actualProtocol, { maxTokens: 4096, reasoning: { effort: "low" } });
116562
+ result = await execute(actualProtocol, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
116513
116563
  } else throw err;
116514
116564
  }
116515
116565
  const normalized = normalizeDecisionObject(result.obj, legalActions, decisionId);
@@ -116685,7 +116735,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
116685
116735
  }
116686
116736
  if (isOpenRouter) {
116687
116737
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
116688
- if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? { max_tokens: 256, exclude: false };
116738
+ if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? reasoningOverrideFor(agent);
116689
116739
  }
116690
116740
  if (onDelta) {
116691
116741
  body.stream = true;
@@ -116729,7 +116779,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
116729
116779
  return { ...result, protocolFallback: "tool\u2192json_schema" };
116730
116780
  }
116731
116781
  if (err?.truncated) {
116732
- const result = await execute(actual, { maxTokens: 4096, reasoning: { effort: "low" } });
116782
+ const result = await execute(actual, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
116733
116783
  return { ...result, protocolFallback: "truncated\u2192retry" };
116734
116784
  }
116735
116785
  throw err;
@@ -117002,6 +117052,8 @@ var els = {
117002
117052
  seatProtocol: $("#seatProtocol"),
117003
117053
  seatProvider: $("#seatProvider"),
117004
117054
  seatCaptureReasoning: $("#seatCaptureReasoning"),
117055
+ seatReasoningEffort: $("#seatReasoningEffort"),
117056
+ seatReasoningEffortHelp: $("#seatReasoningEffortHelp"),
117005
117057
  seatError: $("#seatError"),
117006
117058
  removeSeatBtn: $("#removeSeatBtn"),
117007
117059
  cancelSeatBtn: $("#cancelSeatBtn"),
@@ -117052,6 +117104,7 @@ var WINNER_REVIEW_MS = 2e3;
117052
117104
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
117053
117105
  var editingSeatIndex = null;
117054
117106
  var seatNameAuto = false;
117107
+ var seatReasoningEffortDraft = "";
117055
117108
  var lobbyVisible = true;
117056
117109
  var pendingAutostart = false;
117057
117110
  var arenaMaxDecisions = 0;
@@ -117357,7 +117410,8 @@ function injectedEnvironmentConfig() {
117357
117410
  model: String(player.model || ""),
117358
117411
  protocol: String(player.protocol || "tool"),
117359
117412
  provider: String(player.provider || ""),
117360
- captureReasoning: Boolean(player.captureReasoning)
117413
+ captureReasoning: Boolean(player.captureReasoning),
117414
+ reasoningEffort: String(player.reasoningEffort || "").trim().toLowerCase()
117361
117415
  }))
117362
117416
  };
117363
117417
  }
@@ -117674,7 +117728,7 @@ function normalizeConfig(input = {}) {
117674
117728
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
117675
117729
  const model = String(raw.model || "").trim();
117676
117730
  if (!model) throw new Error(`Model missing for ${name}`);
117677
- return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || "tool", provider: String(raw.provider || "").trim(), captureReasoning: raw.captureReasoning === true, temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
117731
+ return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || "tool", provider: String(raw.provider || "").trim(), captureReasoning: raw.captureReasoning === true, reasoningEffort: String(raw.reasoningEffort || "").trim().toLowerCase(), temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
117678
117732
  })
117679
117733
  };
117680
117734
  }
@@ -117992,6 +118046,7 @@ var TournamentDirector = class {
117992
118046
  model: agent.model,
117993
118047
  connection: connection?.name ?? agent.connectionId,
117994
118048
  provider: agent.provider || "auto",
118049
+ reasoningEffort: agent.reasoningEffort || null,
117995
118050
  protocol: effectiveProtocol(agent, connection),
117996
118051
  handNumber: this.handNumber,
117997
118052
  street: this.engine.state.street,
@@ -118140,6 +118195,7 @@ var TournamentDirector = class {
118140
118195
  configuredModel: agent.model,
118141
118196
  resolvedModel: result?.model ?? agent.model,
118142
118197
  provider: agent.provider || "auto",
118198
+ reasoningEffort: agent.reasoningEffort || null,
118143
118199
  action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description },
118144
118200
  forced,
118145
118201
  legalActions: legalActions.map((a) => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
@@ -118380,6 +118436,7 @@ function renderModelOptions(models, currentValue = "") {
118380
118436
  if (currentValue && !byId.has(currentValue)) byId.set(currentValue, { id: currentValue, name: currentValue });
118381
118437
  const rows = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
118382
118438
  els.seatModelOptions.innerHTML = rows.map((model) => `<option value="${escapeHtml(model.id)}" label="${escapeHtml(model.name || model.id)}"></option>`).join("");
118439
+ updateSeatReasoningEffortOptions();
118383
118440
  }
118384
118441
  async function refreshSeatModelCatalog({ force = false } = {}) {
118385
118442
  const connection = fullConnectionById(els.seatConnection?.value);
@@ -118433,9 +118490,41 @@ function defaultSeatDraft(seatIndex) {
118433
118490
  model: conn?.kind === "typesafe" ? "jev-latest" : "",
118434
118491
  protocol: conn?.kind === "typesafe" ? "jev_native" : "tool",
118435
118492
  provider: "",
118436
- captureReasoning: false
118493
+ captureReasoning: false,
118494
+ reasoningEffort: ""
118437
118495
  };
118438
118496
  }
118497
+ function updateSeatReasoningEffortOptions(selected) {
118498
+ const select = els.seatReasoningEffort;
118499
+ if (!select) return;
118500
+ const openRouter = isOpenRouterConnection(connectionById(els.seatConnection.value));
118501
+ const model = els.seatModel.value.trim();
118502
+ const efforts = openRouter ? reasoningEffortsFor(model) : [];
118503
+ const modelDefault = efforts.length ? defaultReasoningEffort(model) : null;
118504
+ const current = String(selected ?? seatReasoningEffortDraft ?? "").trim().toLowerCase();
118505
+ select.innerHTML = [
118506
+ '<option value="">Arena default (capped)</option>',
118507
+ ...efforts.map((effort) => `<option value="${escapeHtml(effort)}">${escapeHtml(effort)}${effort === modelDefault ? " \xB7 model default" : ""}</option>`)
118508
+ ].join("");
118509
+ if (efforts.includes(current)) {
118510
+ select.value = current;
118511
+ seatReasoningEffortDraft = current;
118512
+ } else {
118513
+ select.value = "";
118514
+ if (!current) seatReasoningEffortDraft = "";
118515
+ }
118516
+ select.disabled = !efforts.length;
118517
+ const help = els.seatReasoningEffortHelp;
118518
+ if (!help) return;
118519
+ help.dataset.tone = "";
118520
+ if (!openRouter) help.textContent = "Reasoning effort is available for OpenRouter models that advertise it.";
118521
+ else if (!efforts.length) help.textContent = "This model does not advertise reasoning efforts; the capped default is used.";
118522
+ else if (!select.value && requiresReasoning(model)) {
118523
+ help.textContent = `This model always reasons and ignores the capped default, so it can exceed the action clock and auto-fold. Pick an effort \u2014 ${efforts.includes("minimal") ? "minimal" : efforts[0]} is usually fastest.`;
118524
+ help.dataset.tone = "bad";
118525
+ } else if (modelDefault) help.textContent = `Model default is ${modelDefault}. Lower it when a slow reasoner runs out of action clock and auto-folds.`;
118526
+ else help.textContent = "Lower it when a slow reasoner runs out of action clock and auto-folds.";
118527
+ }
118439
118528
  function applySeatProtocolRules() {
118440
118529
  const conn = connectionById(els.seatConnection.value);
118441
118530
  const kind = conn?.kind || "openrouter";
@@ -118444,6 +118533,7 @@ function applySeatProtocolRules() {
118444
118533
  const jevSeat = kind === "typesafe" || jevViaOpenRouter;
118445
118534
  document.querySelector(".seat-provider-field")?.classList.toggle("hidden", !openRouter || jevViaOpenRouter);
118446
118535
  document.querySelector(".seat-reasoning-field")?.classList.toggle("hidden", jevSeat);
118536
+ document.querySelector(".seat-effort-field")?.classList.toggle("hidden", jevSeat);
118447
118537
  if (els.seatCaptureReasoning) {
118448
118538
  els.seatCaptureReasoning.disabled = jevSeat;
118449
118539
  const alreadyReasoning = isReasoningModel(els.seatModel.value.trim());
@@ -118464,6 +118554,7 @@ function applySeatProtocolRules() {
118464
118554
  els.seatProvider.disabled = !openRouter;
118465
118555
  if (els.seatModel.value === "jev-latest") els.seatModel.value = "";
118466
118556
  }
118557
+ updateSeatReasoningEffortOptions();
118467
118558
  }
118468
118559
  function updateSeatSummary() {
118469
118560
  const count = seatAssignments.filter(Boolean).length;
@@ -118490,13 +118581,16 @@ function openSeatEditor(seatIndex) {
118490
118581
  els.seatProtocol.value = draft.protocol || "tool";
118491
118582
  els.seatProvider.value = draft.provider || "";
118492
118583
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.checked = Boolean(draft.captureReasoning);
118584
+ seatReasoningEffortDraft = String(draft.reasoningEffort || "").trim().toLowerCase();
118493
118585
  applySeatProtocolRules();
118586
+ updateSeatReasoningEffortOptions(seatReasoningEffortDraft);
118494
118587
  syncSeatNameFromModel();
118495
118588
  void refreshSeatModelCatalog();
118496
118589
  els.seatError.classList.add("hidden");
118497
118590
  els.seatLockNotice.classList.toggle("hidden", !locked);
118498
118591
  for (const control of [els.seatName, els.seatConnection, els.seatModel, els.seatProtocol, els.seatProvider]) control.disabled = locked || control === els.seatProvider && (!isOpenRouterConnection(connectionById(els.seatConnection.value)) || isJevModel(els.seatModel.value));
118499
118592
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.disabled = locked || els.seatCaptureReasoning.disabled;
118593
+ if (els.seatReasoningEffort) els.seatReasoningEffort.disabled = locked || els.seatReasoningEffort.disabled;
118500
118594
  els.saveSeatBtn.disabled = locked;
118501
118595
  els.removeSeatBtn.disabled = locked || !seatAssignments[seatIndex];
118502
118596
  if (!els.seatDialog.open) els.seatDialog.showModal();
@@ -118514,7 +118608,8 @@ function readSeatDraft() {
118514
118608
  model: els.seatModel.value.trim(),
118515
118609
  protocol: els.seatProtocol.value,
118516
118610
  provider: els.seatProvider.disabled ? "" : els.seatProvider.value.trim(),
118517
- captureReasoning: Boolean(els.seatCaptureReasoning && !els.seatCaptureReasoning.disabled && els.seatCaptureReasoning.checked)
118611
+ captureReasoning: Boolean(els.seatCaptureReasoning && !els.seatCaptureReasoning.disabled && els.seatCaptureReasoning.checked),
118612
+ reasoningEffort: els.seatReasoningEffort && !els.seatReasoningEffort.disabled ? els.seatReasoningEffort.value.trim().toLowerCase() : ""
118518
118613
  };
118519
118614
  }
118520
118615
  function readSeatPlayers() {
@@ -118543,7 +118638,8 @@ function restoreSeatAssignments(rows = []) {
118543
118638
  model: player.model || "",
118544
118639
  protocol: player.protocol || "tool",
118545
118640
  provider: player.provider || "",
118546
- captureReasoning: player.captureReasoning === true
118641
+ captureReasoning: player.captureReasoning === true,
118642
+ reasoningEffort: String(player.reasoningEffort || "").trim().toLowerCase()
118547
118643
  };
118548
118644
  });
118549
118645
  updateSeatSummary();
@@ -119275,7 +119371,7 @@ function renderStats(s) {
119275
119371
  function tableRenderSignature(s) {
119276
119372
  const running = ["RUNNING", "PAUSED"].includes(s?.status);
119277
119373
  if (lobbyVisible && !running) {
119278
- return JSON.stringify(["lobby", seatAssignments.map((p) => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning)] : null)]);
119374
+ return JSON.stringify(["lobby", seatAssignments.map((p) => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning), p.reasoningEffort || ""] : null)]);
119279
119375
  }
119280
119376
  const t = s?.table;
119281
119377
  if (!t) return JSON.stringify(["empty", s?.status || "IDLE"]);
@@ -120659,6 +120755,9 @@ els.seatName.addEventListener("input", () => {
120659
120755
  });
120660
120756
  els.seatProtocol.addEventListener("change", applySeatProtocolRules);
120661
120757
  els.seatCaptureReasoning?.addEventListener("change", applySeatProtocolRules);
120758
+ els.seatReasoningEffort?.addEventListener("change", () => {
120759
+ seatReasoningEffortDraft = els.seatReasoningEffort.value;
120760
+ });
120662
120761
  els.refreshModelsBtn?.addEventListener("click", () => void refreshSeatModelCatalog({ force: true }));
120663
120762
  els.removeSeatBtn.addEventListener("click", () => {
120664
120763
  if (editingSeatIndex == null || director && ["RUNNING", "PAUSED"].includes(director.status)) return;
package/dist/styles.css CHANGED
@@ -492,7 +492,7 @@ label input, label select { margin-top: 5px; font-weight: 450; }
492
492
  .split-footer { justify-content: space-between; }
493
493
  .seat-footer-actions, .replay-share-actions { display: flex; gap: 7px; }
494
494
  .seat-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
495
- .seat-model-field { grid-column: 1 / -1; }
495
+ .seat-model-field, .seat-effort-field { grid-column: 1 / -1; }
496
496
  .model-input-wrap { margin-top: 6px; display: grid; grid-template-columns: minmax(0, 1fr) 38px; gap: 8px; }
497
497
  .model-input-wrap input { margin: 0; }
498
498
  .field-icon-button, .dialog-body .remove-connection { width: 38px; height: 38px; min-height: 38px; border-radius: 8px; }
@@ -1,4 +1,4 @@
1
- # pokertools-arena 0.16.2 — benchmark methodology & results
1
+ # pokertools-arena 0.17.0 — benchmark methodology & results
2
2
 
3
3
  Generated: 2026-09-19T18:19:58.352Z
4
4
  Methodology: paired fixed-state corpus; deterministic interleaving with recorded experiment seed.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "generatedAt": "2026-09-19T18:19:58.352Z",
5
5
  "experiments": [
6
6
  "paired"
@@ -1,5 +1,37 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.17.0 — per-seat reasoning effort
4
+
5
+ - The seat editor now exposes **Reasoning effort** for OpenRouter models whose
6
+ catalogue entry advertises `supported_efforts` (for example
7
+ `deepseek/deepseek-v4.1-flash`: `max`, `high`, `low`, default `high`). The
8
+ options are populated from `GET /api/v1/models`, so an effort the model does
9
+ not accept can never be selected, and the catalogue default is labelled in the
10
+ list.
11
+ - Choosing an effort sends OpenRouter `reasoning: { effort }` for that seat.
12
+ Leaving it on **Arena default (capped)** keeps the existing
13
+ `reasoning: { max_tokens: 256, exclude: false }` request, so unmodified seats
14
+ and existing benchmark runs are unchanged.
15
+ - This addresses slow reasoners that exhaust the action clock and get
16
+ auto-folded: a seat can drop DeepSeek-class models from their `high` default to
17
+ `low` and answer inside the 30 s action + 30 s time-bank budget. The chosen
18
+ effort is recorded on `DECISION_START` and `DECISION` events.
19
+ - The truncation retry now lowers to an effort the catalogue actually supports
20
+ (falling back to the larger token cap when `low` is not offered) instead of
21
+ always sending `low`.
22
+ - Models whose catalogue entry marks reasoning as `mandatory` now get the larger
23
+ completion budget even when their name misses the reasoning heuristic (for
24
+ example `meta/muse-spark-1.3-contributor`), instead of being starved by the
25
+ 320-token default.
26
+ - Those mandatory-reasoning models also show a warning in the seat editor when no
27
+ effort is selected, because the capped default does not constrain a model that
28
+ always reasons and it can still run past the action clock.
29
+ - The tool-to-JSON-schema fallback now reads OpenRouter's nested provider detail
30
+ (`error.metadata.raw`). Providers that reject every named `tool_choice` — Meta's
31
+ Muse Spark endpoints only accept `"auto"` — return a generic "Provider returned
32
+ error", which previously hid the real reason and forced an auto-fold on every
33
+ hand.
34
+
3
35
  ## 0.16.2 — blind multiplier defaults to ×2
4
36
 
5
37
  - The default `blindMultiplier` is now `2` (was `1.5`), so blinds double at each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pokertools-arena",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Browser-first AI poker benchmark powered by @pokertools/engine with generic OpenAI-compatible endpoints, optional OpenRouter/Jev Decisions routing, deterministic hierarchical decisions, paired fixed-state methodology, and reproducible reporting.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  legalAggressiveSizes, buildHierarchicalDecision, familyCriteria, sizeCriteria,
22
22
  applyBenchmarkMode, renderDecisionState, aggregateActionProbabilitiesByFamily, probabilityStats, SIZE_LABELS,
23
23
  isAggressiveType, familyForActionType, FAMILY_LABELS, decisionClockPhase, isReasoningModel, wantsReasoning,
24
+ reasoningEffortsFor, defaultReasoningEffort, requiresReasoning,
24
25
  } from './lib/decision-core.js';
25
26
  // Table sound effects. esbuild inlines these as data URLs at build time (see
26
27
  // build.mjs), so the bundled and single-file builds both carry the audio and no
@@ -44,7 +45,7 @@ const els = {
44
45
  setupDialog: $('#setupDialog'), setupForm: $('#setupForm'), closeSetup: $('#closeSetup'), setupError: $('#setupError'), saveSettingsBtn: $('#saveSettingsBtn'), seatSummary: $('#seatSummary'),
45
46
  connectionsEditor: $('#connectionsEditor'), connectionRowTemplate: $('#connectionRowTemplate'), addConnectionBtn: $('#addConnectionBtn'),
46
47
  seatDialog: $('#seatDialog'), seatForm: $('#seatForm'), closeSeat: $('#closeSeat'), seatDialogTitle: $('#seatDialogTitle'), seatLockNotice: $('#seatLockNotice'),
47
- seatName: $('#seatName'), seatConnection: $('#seatConnection'), seatModel: $('#seatModel'), seatModelOptions: $('#seatModelOptions'), seatModelStatus: $('#seatModelStatus'), refreshModelsBtn: $('#refreshModelsBtn'), seatProtocol: $('#seatProtocol'), seatProvider: $('#seatProvider'), seatCaptureReasoning: $('#seatCaptureReasoning'), seatError: $('#seatError'), removeSeatBtn: $('#removeSeatBtn'), cancelSeatBtn: $('#cancelSeatBtn'), saveSeatBtn: $('#saveSeatBtn'),
48
+ seatName: $('#seatName'), seatConnection: $('#seatConnection'), seatModel: $('#seatModel'), seatModelOptions: $('#seatModelOptions'), seatModelStatus: $('#seatModelStatus'), refreshModelsBtn: $('#refreshModelsBtn'), seatProtocol: $('#seatProtocol'), seatProvider: $('#seatProvider'), seatCaptureReasoning: $('#seatCaptureReasoning'), seatReasoningEffort: $('#seatReasoningEffort'), seatReasoningEffortHelp: $('#seatReasoningEffortHelp'), seatError: $('#seatError'), removeSeatBtn: $('#removeSeatBtn'), cancelSeatBtn: $('#cancelSeatBtn'), saveSeatBtn: $('#saveSeatBtn'),
48
49
  testsDialog: $('#testsDialog'), closeTests: $('#closeTests'), runTestsBtn: $('#runTestsBtn'), clearTestsBtn: $('#clearTestsBtn'), testsStatus: $('#testsStatus'), testsParticipants: $('#testsParticipants'), testsProgressLabel: $('#testsProgressLabel'), testsProgressFill: $('#testsProgressFill'), testsResults: $('#testsResults'),
49
50
  replayDialog: $('#replayDialog'), closeReplay: $('#closeReplay'), replayTitle: $('#replayTitle'), replayBadge: $('#replayBadge'), replaySubtitle: $('#replaySubtitle'), replayOpponents: $('#replayOpponents'), replayStreet: $('#replayStreet'), replayBoard: $('#replayBoard'), replayPot: $('#replayPot'), replayHero: $('#replayHero'), replaySummary: $('#replaySummary'), replayAction: $('#replayAction'), replayReason: $('#replayReason'), replayReasoning: $('#replayReasoning'), replayHistory: $('#replayHistory'), replayLegal: $('#replayLegal'), replayLegalSummary: $('#replayLegalSummary'), replayShareStatus: $('#replayShareStatus'), copyReplayImage: $('#copyReplayImage'), shareReplayImage: $('#shareReplayImage'), saveReplayImage: $('#saveReplayImage'),
50
51
  };
@@ -64,6 +65,9 @@ const WINNER_REVIEW_MS = 2_000;
64
65
  let seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
65
66
  let editingSeatIndex = null;
66
67
  let seatNameAuto = false;
68
+ // The effort select is rebuilt whenever the model catalogue resolves, so the
69
+ // seat's intended choice is tracked separately from the live option list.
70
+ let seatReasoningEffortDraft = '';
67
71
  let lobbyVisible = true;
68
72
  let pendingAutostart = false;
69
73
  let arenaMaxDecisions = 0;
@@ -356,6 +360,7 @@ function injectedEnvironmentConfig() {
356
360
  protocol: String(player.protocol || 'tool'),
357
361
  provider: String(player.provider || ''),
358
362
  captureReasoning: Boolean(player.captureReasoning),
363
+ reasoningEffort: String(player.reasoningEffort || '').trim().toLowerCase(),
359
364
  })),
360
365
  };
361
366
  }
@@ -628,7 +633,7 @@ function normalizeConfig(input = {}) {
628
633
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
629
634
  const model = String(raw.model || '').trim();
630
635
  if (!model) throw new Error(`Model missing for ${name}`);
631
- return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || 'tool', provider: String(raw.provider || '').trim(), captureReasoning: raw.captureReasoning === true, temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
636
+ return { id: `player-${lobbySeat + 1}`, seat: index, lobbySeat, name, connectionId: raw.connectionId, model, protocol: raw.protocol || 'tool', provider: String(raw.provider || '').trim(), captureReasoning: raw.captureReasoning === true, reasoningEffort: String(raw.reasoningEffort || '').trim().toLowerCase(), temperature: clamp(Number(raw.temperature) || 0.3, 0, 2) };
632
637
  }),
633
638
  };
634
639
  }
@@ -815,6 +820,7 @@ class TournamentDirector {
815
820
  }
816
821
  this.currentDecision = {
817
822
  id: decisionId, playerId: agent.id, playerName: agent.name, seat, model: agent.model, connection: connection?.name ?? agent.connectionId, provider: agent.provider || 'auto',
823
+ reasoningEffort: agent.reasoningEffort || null,
818
824
  protocol: effectiveProtocol(agent, connection), handNumber: this.handNumber, street: this.engine.state.street, position: positionForSeat(this.engine.state, seat),
819
825
  startedAt, baseMs, timeBankMs: bankBefore, lowTimeMs: Math.round(this.config.lowTimeSeconds * 1000), lowTimeFraction: this.config.lowTimeFraction, pausedMs: 0, pausedAt: null, architecture,
820
826
  hierarchy: hierarchy ? { families: hierarchy.families, stage1: hierarchy.stage1, stage2: hierarchy.stage2, aggressiveFamily: hierarchy.aggressiveFamily } : null,
@@ -915,6 +921,7 @@ class TournamentDirector {
915
921
  playerId: agent.id, playerName: agent.name, connection: connection?.name,
916
922
  protocol: result?.meta?.method ?? effectiveProtocol(agent, connection), requestedProtocol: effectiveProtocol(agent, connection), protocolFallback: result?.meta?.protocolFallback ?? null,
917
923
  configuredModel: agent.model, resolvedModel: result?.model ?? agent.model, provider: agent.provider || 'auto',
924
+ reasoningEffort: agent.reasoningEffort || null,
918
925
  action: { id: chosen.id, type: chosen.type, amount: chosen.amount, description: chosen.description }, forced,
919
926
  legalActions: legalActions.map(a => ({ id: a.id, type: a.type, amount: a.amount ?? null, description: a.description })),
920
927
  latencyMs: reportedLatency, primaryDecisionLatencyMs: result?.primaryDecisionLatencyMs ?? reportedLatency,
@@ -1129,6 +1136,7 @@ function renderModelOptions(models, currentValue = '') {
1129
1136
  if (currentValue && !byId.has(currentValue)) byId.set(currentValue, { id: currentValue, name: currentValue });
1130
1137
  const rows = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
1131
1138
  els.seatModelOptions.innerHTML = rows.map(model => `<option value="${escapeHtml(model.id)}" label="${escapeHtml(model.name || model.id)}"></option>`).join('');
1139
+ updateSeatReasoningEffortOptions();
1132
1140
  }
1133
1141
  async function refreshSeatModelCatalog({ force = false } = {}) {
1134
1142
  const connection = fullConnectionById(els.seatConnection?.value);
@@ -1183,8 +1191,46 @@ function defaultSeatDraft(seatIndex) {
1183
1191
  protocol: conn?.kind === 'typesafe' ? 'jev_native' : 'tool',
1184
1192
  provider: '',
1185
1193
  captureReasoning: false,
1194
+ reasoningEffort: '',
1186
1195
  };
1187
1196
  }
1197
+ // The effort choices come from the model catalogue, so an unsupported value can
1198
+ // never be selected. An empty value keeps the historical request shape.
1199
+ function updateSeatReasoningEffortOptions(selected) {
1200
+ const select = els.seatReasoningEffort;
1201
+ if (!select) return;
1202
+ const openRouter = isOpenRouterConnection(connectionById(els.seatConnection.value));
1203
+ const model = els.seatModel.value.trim();
1204
+ const efforts = openRouter ? reasoningEffortsFor(model) : [];
1205
+ const modelDefault = efforts.length ? defaultReasoningEffort(model) : null;
1206
+ const current = String(selected ?? seatReasoningEffortDraft ?? '').trim().toLowerCase();
1207
+ select.innerHTML = [
1208
+ '<option value="">Arena default (capped)</option>',
1209
+ ...efforts.map(effort => `<option value="${escapeHtml(effort)}">${escapeHtml(effort)}${effort === modelDefault ? ' · model default' : ''}</option>`),
1210
+ ].join('');
1211
+ // Until the catalogue resolves, an empty option list must not discard the
1212
+ // seat's saved choice; keep the draft so the picker restores it on rebuild.
1213
+ if (efforts.includes(current)) {
1214
+ select.value = current;
1215
+ seatReasoningEffortDraft = current;
1216
+ } else {
1217
+ select.value = '';
1218
+ if (!current) seatReasoningEffortDraft = '';
1219
+ }
1220
+ select.disabled = !efforts.length;
1221
+ const help = els.seatReasoningEffortHelp;
1222
+ if (!help) return;
1223
+ help.dataset.tone = '';
1224
+ if (!openRouter) help.textContent = 'Reasoning effort is available for OpenRouter models that advertise it.';
1225
+ else if (!efforts.length) help.textContent = 'This model does not advertise reasoning efforts; the capped default is used.';
1226
+ else if (!select.value && requiresReasoning(model)) {
1227
+ // The arena cap is a token limit, not an effort; models that always reason
1228
+ // ignore it and run at their own default, which is how they blow the clock.
1229
+ help.textContent = `This model always reasons and ignores the capped default, so it can exceed the action clock and auto-fold. Pick an effort — ${efforts.includes('minimal') ? 'minimal' : efforts[0]} is usually fastest.`;
1230
+ help.dataset.tone = 'bad';
1231
+ } else if (modelDefault) help.textContent = `Model default is ${modelDefault}. Lower it when a slow reasoner runs out of action clock and auto-folds.`;
1232
+ else help.textContent = 'Lower it when a slow reasoner runs out of action clock and auto-folds.';
1233
+ }
1188
1234
  function applySeatProtocolRules() {
1189
1235
  const conn = connectionById(els.seatConnection.value);
1190
1236
  const kind = conn?.kind || 'openrouter';
@@ -1193,6 +1239,7 @@ function applySeatProtocolRules() {
1193
1239
  const jevSeat = kind === 'typesafe' || jevViaOpenRouter;
1194
1240
  document.querySelector('.seat-provider-field')?.classList.toggle('hidden', !openRouter || jevViaOpenRouter);
1195
1241
  document.querySelector('.seat-reasoning-field')?.classList.toggle('hidden', jevSeat);
1242
+ document.querySelector('.seat-effort-field')?.classList.toggle('hidden', jevSeat);
1196
1243
  if (els.seatCaptureReasoning) {
1197
1244
  els.seatCaptureReasoning.disabled = jevSeat;
1198
1245
  const alreadyReasoning = isReasoningModel(els.seatModel.value.trim());
@@ -1213,6 +1260,7 @@ function applySeatProtocolRules() {
1213
1260
  els.seatProvider.disabled = !openRouter;
1214
1261
  if (els.seatModel.value === 'jev-latest') els.seatModel.value = '';
1215
1262
  }
1263
+ updateSeatReasoningEffortOptions();
1216
1264
  }
1217
1265
  function updateSeatSummary() {
1218
1266
  const count = seatAssignments.filter(Boolean).length;
@@ -1239,13 +1287,16 @@ function openSeatEditor(seatIndex) {
1239
1287
  els.seatProtocol.value = draft.protocol || 'tool';
1240
1288
  els.seatProvider.value = draft.provider || '';
1241
1289
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.checked = Boolean(draft.captureReasoning);
1290
+ seatReasoningEffortDraft = String(draft.reasoningEffort || '').trim().toLowerCase();
1242
1291
  applySeatProtocolRules();
1292
+ updateSeatReasoningEffortOptions(seatReasoningEffortDraft);
1243
1293
  syncSeatNameFromModel();
1244
1294
  void refreshSeatModelCatalog();
1245
1295
  els.seatError.classList.add('hidden');
1246
1296
  els.seatLockNotice.classList.toggle('hidden', !locked);
1247
1297
  for (const control of [els.seatName, els.seatConnection, els.seatModel, els.seatProtocol, els.seatProvider]) control.disabled = locked || (control === els.seatProvider && (!isOpenRouterConnection(connectionById(els.seatConnection.value)) || isJevModel(els.seatModel.value)));
1248
1298
  if (els.seatCaptureReasoning) els.seatCaptureReasoning.disabled = locked || els.seatCaptureReasoning.disabled;
1299
+ if (els.seatReasoningEffort) els.seatReasoningEffort.disabled = locked || els.seatReasoningEffort.disabled;
1249
1300
  els.saveSeatBtn.disabled = locked;
1250
1301
  els.removeSeatBtn.disabled = locked || !seatAssignments[seatIndex];
1251
1302
  if (!els.seatDialog.open) els.seatDialog.showModal();
@@ -1261,6 +1312,7 @@ function readSeatDraft() {
1261
1312
  protocol: els.seatProtocol.value,
1262
1313
  provider: els.seatProvider.disabled ? '' : els.seatProvider.value.trim(),
1263
1314
  captureReasoning: Boolean(els.seatCaptureReasoning && !els.seatCaptureReasoning.disabled && els.seatCaptureReasoning.checked),
1315
+ reasoningEffort: els.seatReasoningEffort && !els.seatReasoningEffort.disabled ? els.seatReasoningEffort.value.trim().toLowerCase() : '',
1264
1316
  };
1265
1317
  }
1266
1318
  function readSeatPlayers() {
@@ -1290,6 +1342,7 @@ function restoreSeatAssignments(rows = []) {
1290
1342
  protocol: player.protocol || 'tool',
1291
1343
  provider: player.provider || '',
1292
1344
  captureReasoning: player.captureReasoning === true,
1345
+ reasoningEffort: String(player.reasoningEffort || '').trim().toLowerCase(),
1293
1346
  };
1294
1347
  });
1295
1348
  updateSeatSummary();
@@ -2019,7 +2072,7 @@ function renderStats(s) {
2019
2072
  function tableRenderSignature(s) {
2020
2073
  const running = ['RUNNING', 'PAUSED'].includes(s?.status);
2021
2074
  if (lobbyVisible && !running) {
2022
- return JSON.stringify(['lobby', seatAssignments.map(p => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning)] : null)]);
2075
+ return JSON.stringify(['lobby', seatAssignments.map(p => p ? [p.name, p.model, p.protocol, p.connectionId, p.provider, Boolean(p.captureReasoning), p.reasoningEffort || ''] : null)]);
2023
2076
  }
2024
2077
  const t = s?.table;
2025
2078
  if (!t) return JSON.stringify(['empty', s?.status || 'IDLE']);
@@ -3223,6 +3276,7 @@ els.seatModel.addEventListener('input', () => { applySeatProtocolRules(); syncSe
3223
3276
  els.seatName.addEventListener('input', () => { seatNameAuto = false; });
3224
3277
  els.seatProtocol.addEventListener('change', applySeatProtocolRules);
3225
3278
  els.seatCaptureReasoning?.addEventListener('change', applySeatProtocolRules);
3279
+ els.seatReasoningEffort?.addEventListener('change', () => { seatReasoningEffortDraft = els.seatReasoningEffort.value; });
3226
3280
  els.refreshModelsBtn?.addEventListener('click', () => void refreshSeatModelCatalog({ force: true }));
3227
3281
  els.removeSeatBtn.addEventListener('click', () => {
3228
3282
  if (editingSeatIndex == null || (director && ['RUNNING', 'PAUSED'].includes(director.status))) return;
package/src/index.html CHANGED
@@ -304,6 +304,11 @@
304
304
  <span class="checkbox-field"><input id="seatCaptureReasoning" type="checkbox" /> Capture raw model reasoning</span>
305
305
  <span class="field-help seat-reasoning-help hidden">This model is not a reasoning model by default. Capturing its reasoning can be far slower (Gemma measured ~2s → 27–48s per decision) and is outside the standard benchmark track. Raise the action clock before using it.</span>
306
306
  </label>
307
+ <label class="seat-effort-field">
308
+ <span class="field-label-row">Reasoning effort <button class="info-button inline-info" type="button" aria-label="About reasoning effort" data-tip="How much hidden thinking the model may spend. Higher effort is smarter but slower; a slow reasoner can exhaust the action clock and be forced to fold. Options come from the OpenRouter model catalogue, and an unset value keeps the arena's fixed reasoning cap.">i</button></span>
309
+ <select id="seatReasoningEffort"></select>
310
+ <span id="seatReasoningEffortHelp" class="field-help"></span>
311
+ </label>
307
312
  </div>
308
313
  </div>
309
314
  <div id="seatError" class="setup-error hidden"></div>
@@ -176,8 +176,20 @@ export async function fetchStreamingJson(url, options, { maxRetries = 1, onDelta
176
176
  }
177
177
  }
178
178
 
179
+ // OpenRouter reports upstream provider failures as a generic "Provider returned
180
+ // error" while the real reason sits in `error.metadata.raw`. Error handling that
181
+ // keys off message text (the tool_choice fallback) must read that nested detail
182
+ // or it silently stops firing for models like meta/muse-spark, whose provider
183
+ // rejects every named tool_choice.
184
+ export function requestErrorDetail(err) {
185
+ const parts = [summarizeError(err)];
186
+ const nested = err?.payload?.error?.metadata?.raw ?? err?.payload?.metadata?.raw;
187
+ if (typeof nested === 'string') parts.push(nested);
188
+ else if (nested) parts.push(String(JSON.stringify(nested)));
189
+ return parts.join(' ');
190
+ }
179
191
  export function isUnsupportedToolChoiceError(err) {
180
- const message = summarizeError(err);
192
+ const message = requestErrorDetail(err);
181
193
  return [400, 404, 422].includes(Number(err?.status)) && /tool[_ -]?choice|tool call|tools?.*(?:unsupported|support)|no endpoints?.*support/i.test(message);
182
194
  }
183
195
  export function mapGet(mapish, key, fallback = 0) {
@@ -216,14 +228,33 @@ export const protocolCapabilityCache = new Map();
216
228
  // catalogue says the model supports them. Unknown models keep the prior default
217
229
  // so behaviour is unchanged when no catalogue has been loaded.
218
230
  export const modelSupportedParameters = new Map();
231
+
232
+ // OpenRouter also advertises a per-model `reasoning` object
233
+ // ({ mandatory, supported_efforts, default_effort }) when the model exposes a
234
+ // reasoning-effort control. Sending an effort the catalogue does not list is a
235
+ // hard routing failure under `require_parameters`, and sending the wrong one is
236
+ // how a slow reasoner burns the action clock and gets auto-folded, so the
237
+ // catalogue is treated as the source of truth for what a seat may request.
238
+ export const modelReasoningCapabilities = new Map();
219
239
  export const DEFAULT_TEMPERATURE = 0.3;
220
240
 
221
241
  export function registerModelCapabilities(models = []) {
222
242
  for (const entry of models ?? []) {
223
243
  const id = typeof entry === 'string' ? entry : entry?.id;
244
+ if (!id) continue;
224
245
  const parameters = typeof entry === 'object' && entry ? entry.supported_parameters : null;
225
- if (!id || !Array.isArray(parameters)) continue;
226
- modelSupportedParameters.set(String(id), new Set(parameters.map(String)));
246
+ if (Array.isArray(parameters)) modelSupportedParameters.set(String(id), new Set(parameters.map(String)));
247
+ const reasoning = typeof entry === 'object' && entry ? entry.reasoning : null;
248
+ if (!reasoning || typeof reasoning !== 'object') continue;
249
+ const efforts = Array.isArray(reasoning.supported_efforts)
250
+ ? reasoning.supported_efforts.map(value => String(value).trim().toLowerCase()).filter(Boolean)
251
+ : [];
252
+ const declaredDefault = String(reasoning.default_effort || '').trim().toLowerCase();
253
+ modelReasoningCapabilities.set(String(id), {
254
+ mandatory: reasoning.mandatory === true,
255
+ supportedEfforts: efforts,
256
+ defaultEffort: efforts.includes(declaredDefault) ? declaredDefault : null,
257
+ });
227
258
  }
228
259
  }
229
260
 
@@ -233,6 +264,43 @@ export function modelSupportsParameter(model, parameter) {
233
264
  return supported.has(String(parameter));
234
265
  }
235
266
 
267
+ export function reasoningCapabilities(model) {
268
+ return modelReasoningCapabilities.get(String(model || '')) ?? null;
269
+ }
270
+ export function reasoningEffortsFor(model) {
271
+ return reasoningCapabilities(model)?.supportedEfforts ?? [];
272
+ }
273
+ export function defaultReasoningEffort(model) {
274
+ return reasoningCapabilities(model)?.defaultEffort ?? null;
275
+ }
276
+ export function requiresReasoning(model) {
277
+ return reasoningCapabilities(model)?.mandatory === true;
278
+ }
279
+ // Returns the requested effort when the model can accept it. Unknown models
280
+ // (non-OpenRouter endpoints, or a catalogue that has not loaded yet) keep the
281
+ // requested value so an explicit seat choice is never silently dropped.
282
+ export function resolveReasoningEffort(model, requested) {
283
+ const value = String(requested || '').trim().toLowerCase();
284
+ if (!value) return null;
285
+ const capabilities = reasoningCapabilities(model);
286
+ if (!capabilities) return value;
287
+ return capabilities.supportedEfforts.includes(value) ? value : null;
288
+ }
289
+ // The reasoning payload for a normal request. An explicit, supported effort
290
+ // wins; otherwise the historical completed-reasoning cap is preserved so
291
+ // benchmark behaviour is unchanged for seats that never touch the control.
292
+ export function reasoningOverrideFor(agent) {
293
+ const effort = resolveReasoningEffort(agent?.model, agent?.reasoningEffort);
294
+ return effort ? { effort } : { max_tokens: 256, exclude: false };
295
+ }
296
+ // The truncation retry lowers effort so a long reasoner can still answer inside
297
+ // the action clock. Models that do not advertise `low` fall back to the cap.
298
+ export function truncationReasoningOverride(model) {
299
+ const capabilities = reasoningCapabilities(model);
300
+ if (!capabilities) return { effort: 'low' };
301
+ return capabilities.supportedEfforts.includes('low') ? { effort: 'low' } : { max_tokens: 4096, exclude: false };
302
+ }
303
+
236
304
  // Returns the temperature to send, or undefined when the model's endpoints do
237
305
  // not support the parameter. Only optional sampling parameters are gated this
238
306
  // way; structural parameters (tools, response_format, max_tokens) stay enforced
@@ -275,7 +343,9 @@ export function isReasoningModel(model) {
275
343
  // asked for hidden reasoning (Gemma measurably can), at a large latency cost, so
276
344
  // it stays an explicit seat choice rather than a blanket default.
277
345
  export function wantsReasoning(agent) {
278
- return isReasoningModel(agent?.model) || agent?.captureReasoning === true;
346
+ return isReasoningModel(agent?.model) || agent?.captureReasoning === true
347
+ || reasoningCapabilities(agent?.model)?.mandatory === true
348
+ || Boolean(resolveReasoningEffort(agent?.model, agent?.reasoningEffort));
279
349
  }
280
350
  export function effectiveProtocol(agent, connection) {
281
351
  if (isOpenRouterConnection(connection) && isJevModel(agent?.model)) return 'jev_decisions';
@@ -1099,7 +1169,7 @@ export function buildOpenAICompatibleBody({ agent, connection, state, legalActio
1099
1169
  }
1100
1170
  if (isOpenRouter) {
1101
1171
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
1102
- if (wantsReasoning(agent)) body.reasoning = reasoning ?? { max_tokens: 256, exclude: false };
1172
+ if (wantsReasoning(agent)) body.reasoning = reasoning ?? reasoningOverrideFor(agent);
1103
1173
  }
1104
1174
  if (stream) { body.stream = true; body.stream_options = { include_usage: true }; }
1105
1175
  return body;
@@ -1160,7 +1230,7 @@ export async function decideOpenAICompatible({ agent, connection, state, legalAc
1160
1230
  // return nothing. Retry once with a larger budget and a lower reasoning
1161
1231
  // effort rather than forcing an automatic fallback.
1162
1232
  truncationRetry = true;
1163
- result = await execute(actualProtocol, { maxTokens: 4096, reasoning: { effort: 'low' } });
1233
+ result = await execute(actualProtocol, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
1164
1234
  } else throw err;
1165
1235
  }
1166
1236
  const normalized = normalizeDecisionObject(result.obj, legalActions, decisionId);
@@ -1347,7 +1417,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
1347
1417
  }
1348
1418
  if (isOpenRouter) {
1349
1419
  body.provider = agent.provider ? { only: [agent.provider], allow_fallbacks: false, require_parameters: true } : { allow_fallbacks: true, require_parameters: true };
1350
- if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? { max_tokens: 256, exclude: false };
1420
+ if (wantsReasoning(agent)) body.reasoning = override.reasoning ?? reasoningOverrideFor(agent);
1351
1421
  }
1352
1422
  if (onDelta) { body.stream = true; body.stream_options = { include_usage: true }; }
1353
1423
  return body;
@@ -1388,7 +1458,7 @@ async function requestChatStage({ agent, connection, schema, toolName, systemPro
1388
1458
  if (err?.truncated) {
1389
1459
  // Retry once with a larger budget and lower reasoning effort instead of
1390
1460
  // letting a truncated reasoning stage force an automatic fallback.
1391
- const result = await execute(actual, { maxTokens: 4096, reasoning: { effort: 'low' } });
1461
+ const result = await execute(actual, { maxTokens: 4096, reasoning: truncationReasoningOverride(agent.model) });
1392
1462
  return { ...result, protocolFallback: 'truncated→retry' };
1393
1463
  }
1394
1464
  throw err;
package/src/styles.css CHANGED
@@ -492,7 +492,7 @@ label input, label select { margin-top: 5px; font-weight: 450; }
492
492
  .split-footer { justify-content: space-between; }
493
493
  .seat-footer-actions, .replay-share-actions { display: flex; gap: 7px; }
494
494
  .seat-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
495
- .seat-model-field { grid-column: 1 / -1; }
495
+ .seat-model-field, .seat-effort-field { grid-column: 1 / -1; }
496
496
  .model-input-wrap { margin-top: 6px; display: grid; grid-template-columns: minmax(0, 1fr) 38px; gap: 8px; }
497
497
  .model-input-wrap input { margin: 0; }
498
498
  .field-icon-button, .dialog-body .remove-connection { width: 38px; height: 38px; min-height: 38px; border-radius: 8px; }