OpenGUI · Model access

Reasoning efforts need an owner

The empty-array guard in connectionsToModelProviders stops one overwrite. It does not make the menu correct for custom DeepSeek backends. The real bug is split ownership: the editor cannot express intent, the catalog matcher unions reseller noise, and the selector falls back to a fantasy scale.

Partial fix is insufficient Plan only · no implementation Repro: deepseek-v4-pro

1. Symptom

Actual

Custom backend → reasoning ON → effort menu

  • Off
  • Minimal
  • Low
  • Medium
  • High
  • Extra high
  • Maximum
  • Ultra

Picking a noise value sends that literal reasoning_effort to https://api.deepseek.com.

Expected

Canonical models.dev · deepseek/deepseek-v4-pro

  • Off toggle → none
  • High effort
  • Maximum effort · max

Catalog publishes toggle + effort [high, max]. Nothing else is advertised for the first-party DeepSeek entry.

2. Why the partial fix is not enough

The patch only changes merge order when connection capabilities carry an empty list:

// current band-aid
...(caps.reasoningEfforts?.length
  ? { reasoningEfforts: caps.reasoningEfforts }
  : {}),

That unblocks catalog-derived values for the common “editor saved []” path. Three independent defects remain, any one of which still yields a wrong menu.

A

Catalog matcher unions every reseller

reasoningMetadataForModel matches key.endsWith("/" + modelId) and folds all hits into one Set. Live models.dev currently has 51 entries ending in deepseek-v4-pro. Effort signatures diverge wildly:

Count Published options
10 [] / bare reasoning
8 toggle + [high, max] ← canonical shape
6 toggle only
3+ includes low/medium/xhigh, full OpenAI-ish ladders
union almost the entire Host scale — including values no DeepSeek first-party entry publishes

After the empty-array guard, the menu can still show Minimal / Low / Medium / Extra high because those values appear on some reseller row. Ultra may disappear (not in the union today) while the ladder remains wrong.

B

Empty means “unset”, but the product cannot say that

CustomBackendEditor has a Reasoning toggle and no efforts control. buildCustomModelConnection always writes reasoningEfforts: [] when reasoning is on. Downstream code cannot distinguish:

  • Unset / auto — please infer from catalog
  • Explicit empty — reasoning on, no selectable efforts (toggle only)
  • Explicit list — user or preset chose these levels

First-party presets (Codex) correctly persist non-empty lists. Custom backends never can, so they always depend on inference — and inference is broken (A).

C

Selector fallback is a second fantasy catalog

ReasoningEffortSelector treats missing/empty efforts as “show the full Host enum”:

const efforts = model?.reasoningEfforts?.length
  ? model.reasoningEfforts
  : [...EFFORTS]; // none…ultra

That is how the original screenshot got Ultra even though no models.dev entry for this model publishes it. Fallback must be conservative, not maximal — or the menu must refuse to open without resolved metadata.

D

UI catalog ≠ Host transport truth

Enrichment lives only in the Frontend (connectionsToModelProviders). Host resolve still forwards connection.modelCapabilities[modelId].reasoningEfforts as stored — often [] / omitted. The wire payload uses the selected effort from the Session/PromptBox, not a Host-side clamp against catalog. So a polluted UI can ship unsupported literals even when transport never saw models.dev.

3. Ownership model

Pick one source of truth per field, with a single merge function and no silent maximal fallbacks.

Connection capability

Durable user / preset intent

  • reasoning: boolean — user said this model reasons
  • reasoningEfforts?: ReasoningEffort[]only when explicit
  • Omit the field for auto; never persist [] as a fake explicit list

Catalog resolver

Advisory inference

  • Input: model id + optional base URL / provider hint
  • Output: display name, context, reasoning flag, effort list
  • Policy: prefer canonical vendor entry, not reseller union
  • Unknown model: conservative default, not full enum

Resolved model view

What UI and clamp both read

  • One pure function: resolveModelCapabilities(connection, modelId, catalog)
  • Used by Frontend providers list and Host selection clamp
  • Selector only renders resolved.reasoningEfforts
Resolution order
  1. Explicit connection list wins when present and non-empty (presets, power-user override).
  2. Else if reasoning === false → no selector; no efforts.
  3. Else catalog inference with canonical provider preference.
  4. Else conservative unknown default ["none","high"] (toggle-shaped), never the full Host scale.

4. Catalog resolution policy

models.dev is useful and noisy. Treat it as a ranked lookup, not a bag union.

Prefer

  1. Exact vendor key when base URL maps cleanly e.g. api.deepseek.com → deepseek/deepseek-v4-pro
  2. Same-name provider id provider segment equals a known vendor id in the model key
  3. Richest first-party-shaped entry has toggle + effort values; prefer over bare reasoning:true
  4. Single best match if still tied, stable sort by provider id and take one

Reject

  • Unioning every */modelId reseller row
  • Letting OpenRouter / Groq / free-tier ladders redefine DeepSeek first-party
  • Assuming Host enum ⊆ every vendor’s API
  • Client-only enrichment that Host never re-validates
// target shape (illustrative)
function pickCatalogModel(catalog, modelId, hints?: { baseUrl?: string; providerHint?: string }) {
  const candidates = entriesEndingWith(catalog, modelId);
  if (candidates.length === 0) return null;
  const ranked = rankCandidates(candidates, hints); // vendor URL, provider id, option richness
  return ranked[0]; // ONE entry — do not fold options across the set
}

function effortsFromEntry(entry): ReasoningEffort[] | undefined {
  // toggle → include "none"; effort.values filtered to SUPPORTED_EFFORTS
  // no effort values but reasoning → ["none","high"]
  // reasoning false → undefined
}

5. Correct design (end-to-end)

5.1 Persistence semantics

Stored Meaning Resolved efforts
reasoning: false Not a reasoning model hidden / none
reasoning: true, field omitted Auto catalog → conservative default
reasoning: true, ["high","max"] Explicit override / preset exactly that list (+ none if product wants Off)
reasoningEfforts: [] Illegal on write. Normalize away in buildCustomModelConnection and Host ingest.

5.2 Shared resolver module

Move pure resolution out of “frontend fetch helper” identity. Suggested home: packages/protocol or a small shared lib both Frontend and Host import.

  • loadModelsDevCatalog() may stay environment-specific (fetch vs cache file), but resolveModelCapabilities must be pure and unit-tested without network.
  • Frontend connectionsToModelProviders becomes a thin adapter over the shared resolver.
  • Host, when accepting a run / setting reasoning, clamps the selected effort to resolved.reasoningEfforts. Unknown effort → nearest supported or reject.

5.3 Custom backend editor

Minimum viable (recommended first)

  • Keep Reasoning toggle only
  • Persist omitted efforts when auto
  • Show read-only hint: “Efforts: Auto (catalog)” or resolved preview when model id matches
  • No multi-select yet

Follow-up power user

  • Mode: Auto | Custom
  • Custom: checklist of Host-supported efforts
  • Only Custom writes an array
  • Still never write []

5.4 Selector contract

// stop inventing a menu
if (model.capabilities.reasoning === false) return null;
const efforts = model.reasoningEfforts; // already resolved
if (!efforts?.length) return null; // or show Off-only — never full EFFORTS

The full EFFORTS constant remains the allow-list for parsing, not the default menu.

5.5 Base URL → vendor hints

Small static map is enough for v1; do not scrape HTML.

const VENDOR_HOST_HINTS: Record<string, string> = {
  "api.deepseek.com": "deepseek",
  "api.openai.com": "openai",
  "api.anthropic.com": "anthropic",
  "api.x.ai": "xai",
  // ...
};

Custom OpenAI-compatible proxies that re-host DeepSeek still get model-id matching; the hint only breaks ties toward the vendor when the URL is first-party.

6. Phased delivery

P0

Make Auto correct

  • Stop writing reasoningEfforts: [] from custom backend builder
  • Normalize empty arrays to omitted on Host ingest
  • Replace reseller union with single-entry pick + base URL hint
  • Conservative unknown default: ["none","high"]
  • Selector: no full-scale fallback
  • Keep empty-array merge guard as defense in depth until writers are clean

Exit: DeepSeek custom backend menu is Off / High / Maximum against live or fixture catalog.

P1

One resolver, two consumers

  • Extract pure resolveModelCapabilities to shared package
  • Frontend providers list uses it
  • Host clamps selected reasoning on set/send
  • Fixture catalog tests for deepseek, openai, unknown, reseller-only

Exit: UI cannot select an effort Host would not accept; transport never sees unsupported literals from PromptBox.

P2

Editor honesty (optional)

  • Read-only “resolved efforts” preview in CustomBackendEditor
  • Optional Auto | Custom multi-select
  • i18n for new strings

Exit: Power users can pin an explicit ladder; Auto remains default and correct.

7. Acceptance criteria

8. Non-goals (this plan)

  • Building a full models.dev mirror service
  • Per-reseller effort accuracy when user points at OpenRouter etc. (later: provider hint from base URL of that reseller)
  • Budget-token UIs (budget_tokens options in catalog)
  • Changing DeepSeek wire format beyond effort allow-listing
  • Requiring users to hand-edit effort lists for the default path

9. Likely touch points

Area Files Change
Catalog resolve src/lib/models-dev.ts → shared module Single-entry pick, hints, no union
Custom backend write src/features/model-access/custom-backend.ts Omit empty efforts; optional mode later
Selector src/components/ReasoningEffortSelector.tsx No maximal fallback
Host ingest packages/backend/src/routes/host-product.ts Normalize empty → omit
Host clamp packages/backend/src/host/opengui-host.ts Resolve + clamp on set/send (P1)
Tests src/lib/__tests__/models-dev.test.ts + shared package tests Reseller pollution, deepseek, omit-empty, presets

Verdict

Do not ship “empty array no longer overwrites” as the fix. Ship Auto that picks one catalog entry, never persist empty lists, and never invent the full effort ladder in the selector. Host clamp is the hardening layer so UI bugs cannot poison the wire.

The DeepSeek repro is the acceptance needle: Off · High · Maximum — nothing else.