Files
OmniRoute/docs/routing/REASONING_REPLAY.md
James 3ebea07278 fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients (#13031)
* fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients

DeepSeek thinking mode requires the reasoning of every prior assistant turn
to be passed back once the request carries `tools`, including turns that
made no tool call. Since #10540 routed opencode-go/deepseek-v4-* to
`/responses`, the reasoning replay cache had two gaps on Responses-API
targets, and clients that drop `reasoning_content` hit intermittent
`400 The reasoning_text in the thinking mode must be passed back`.

1. Plain (non-tool-call) turns are keyed on a digest of the normalized
   OpenAI transcript. Both capture sites used `translatedBody.messages` as
   the history, which a Responses body (`input`) does not carry, so the
   write-time digest never matched the read side. translateRequest now
   reports the pivot transcript it digested via `onReasoningReplayHistory`,
   and the streaming / non-streaming capture sites digest that transcript.
2. The Responses replay pass was gated on `sourceFormat === "openai"`, so
   Anthropic Messages clients (Claude -> OpenAI -> Responses) got no replay
   at all. The pass now runs on the OpenAI pivot for every source format,
   right before the Responses conversion discards `messages`.

The reported transcript is a shallow snapshot of the digested fields only
and travels through a callback, not the body, so nothing new reaches the
upstream payload.

* docs(changelog): add fragment for #13031

* fix(sse): guard the Responses capture sites and skip plain-turn writes with no history

Review follow-ups for #13031:

- Add tests/unit/chatcore-reasoning-cache-write-guard-responses.test.ts:
  runs the real handleChatCore against a mocked opencode-go/deepseek-v4-flash
  Responses upstream (JSON and SSE), then asserts the next turn's upstream
  body carries the replayed `reasoning` input item. Removing either capture
  site fallback turns both cases red.
- Project the reported transcript down to the digested fields only
  (tool_calls keep type/name/arguments, ids are dropped) and document that
  `content` is shared by reference.
- Skip the plain-turn cache write when the history is empty: a real request
  always has a prior user turn, so an empty history means the transcript
  could not be recovered and a one-message digest can never match.
- Changelog wording: the pre-fix write digested only the assistant message.

* test(sse): select the /responses dispatch by URL in the Responses replay guard

Review follow-ups for #13031: the guard picks the upstream body by URL
(`/responses`) and asserts exactly one such dispatch per turn instead of
taking the last fetch, the streaming case asserts the same body shape as the
non-streaming one, and the `historyMessages` doc on
NonStreamingClientTranslateInput names the Responses-shaped fallback.

* docs(routing): name the replay-history hand-off without tripping the hook heuristic

The fabricated-docs gate treats any `onXxx` token in prose as a plugin hook
name and flagged `onReasoningReplayHistory` (a translateRequest option, not a
hook). Point at the option's home file instead.

* chore(quality): freeze chatCore.ts at 6159 for the Responses replay wiring

check:file-size in PR mode caps a frozen file at max(frozen, base). The rebase onto
the v3.8.51 tip (cde49c937) leaves chatCore.ts at 6159 lines against a 6146 ceiling:
the onReasoningReplayHistory callback on both Responses-capable translateRequest call
sites, reasoningReplayHistory on both non-streaming leg inputs, and the historyMessages
fallback at the streaming cache write. Record the growth with a justification key, as
#13033 did for the same file.

---------

Co-authored-by: jmche <jmche@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:59:06 -03:00

8.6 KiB

title, version, lastUpdated
title version lastUpdated
Reasoning Replay Cache 3.8.40 2026-06-28

Reasoning Replay Cache

Source of truth: src/lib/db/reasoningCache.ts, open-sse/services/reasoningCache.ts Last updated: 2026-06-28 — v3.8.40

OmniRoute captures assistant reasoning_content produced by thinking-mode models and replays it transparently on multi-turn requests when the upstream provider requires it. This eliminates the HTTP 400 errors that strict providers raise when a client's conversation history is missing the prior turn's reasoning.

Why This Exists

Several thinking-mode providers reject a follow-up turn unless the previous assistant message includes the original reasoning_content. The upstream returns 400 with messages like:

Param Incorrect: The reasoning_content in the thinking mode must be passed back to the API.

But typical clients (Cursor, Cline, Roo Code, OpenAI SDK) strip reasoning_content from the history they replay. OmniRoute restores it from a server-side cache so the request the upstream sees is consistent. Issue #1628 introduced the hybrid memory/SQLite persistence so the cache survives process restarts.

Architecture

Turn N (assistant generates):
  → response contains reasoning_content + tool_calls
  → if requiresReasoningReplay(provider, model): cacheReasoningFromAssistantMessage()
      writes (memory + DB), keyed by every tool_call.id
  → forward response to client (which may or may not retain reasoning)

Turn N+1 (client sends follow-up):
  → translator detects: requiresReasoningReplay(provider, model) === true
  → for each assistant message with tool_calls and no reasoning_content:
      lookupReasoning(toolCalls[0].id) → memory → DB
      hit  → msg.reasoning_content = cached; recordReplay()
      miss → msg.reasoning_content = "" (legacy fallback for older DeepSeek)
  → upstream sees consistent history → no 400

Capture happens in open-sse/handlers/chatCore.ts (two sites, at the two cacheReasoningFromAssistantMessage call sites). Replay happens in open-sse/translator/index.ts after schema coercion but before dispatch.

Plain (non-tool-call) assistant turns are keyed differently: buildAssistantMessageCacheKey() digests the session scope plus the normalized OpenAI-format transcript up to that turn, because DeepSeek requires the reasoning of every prior turn once tools is present. For Responses-API targets (for example opencode-go/deepseek-v4-flash, routed to /responses) the upstream body carries input, not messages, so translateRequest() (open-sse/translator/index.ts) reports the pivot transcript it digested through a callback option and the capture sites digest that same transcript. The Responses replay pass runs on the OpenAI pivot for every source format, so Anthropic Messages clients (Claude → OpenAI → Responses) are replayed too.

Storage — Hybrid Memory + SQLite

The hot path uses an in-memory Map (LRU-by-creation) backed by a SQLite table for crash recovery and dashboard visibility.

Layer Implementation Purpose
Memory Map in open-sse/services/reasoningCache.ts Fast lookups, evicts oldest at 200
DB reasoning_cache table (src/lib/db/) Persists across restarts, drives stats

Writes go to both. Reads consult memory first, then fall back to DB (DB hits are promoted back into memory). DB failures are non-fatal — the in-memory cache continues to serve the hot path.

Defaults:

  • TTL: 2h (TTL_MS = 2 * 60 * 60 * 1000)
  • Max memory entries: 200 (MAX_MEMORY_ENTRIES)
  • Eviction: oldest createdAt first

Database Schema

Migration: src/lib/db/migrations/033_create_reasoning_cache.sql

CREATE TABLE IF NOT EXISTS reasoning_cache (
  tool_call_id   TEXT PRIMARY KEY,
  provider       TEXT NOT NULL,
  model          TEXT NOT NULL,
  reasoning      TEXT NOT NULL,
  char_count     INTEGER NOT NULL DEFAULT 0,
  created_at     TEXT NOT NULL DEFAULT (datetime('now')),
  expires_at     INTEGER NOT NULL
);

Indexes: expires_at, provider, model, created_at. expires_at is stored as Unix epoch seconds; the SELECT layer normalizes legacy text values via EXPIRES_AT_EPOCH_SQL.

Provider / Model Detection

Replay is enabled when requiresReasoningReplay(provider, model) returns true. The function checks two lists in open-sse/services/reasoningCache.ts.

Provider IDs (exact match, case-insensitive):

  • deepseek
  • opencode-go
  • siliconflow
  • nebius
  • deepinfra
  • sambanova
  • fireworks
  • together
  • kimi-coding
  • kimi-coding-apikey
  • xiaomi-mimo

Model regex patterns (case-insensitive):

  • /deepseek-r1/i
  • /deepseek-reasoner/i
  • /deepseek-chat/i
  • /deepseek[-/]?v4[-.]flash/i and /deepseek[-/]?v4[-.]pro/i (V4 Flash / Pro, optional -free suffix)
  • /(deepseek|zen\/deepseek)-v4/i
  • /kimi[-/]k\d/i
  • /qwq/i
  • /qwen.*think/i
  • /glm.*think/i
  • /^mimo[-.]?v\d/i

Adding a new strict provider/model means appending to one of these lists and writing a unit test asserting replay injection. The PR description should cite the exact upstream 400 string that motivated the change.

REST API

The cache exposes two endpoints under src/app/api/cache/reasoning/route.ts. Both require management authentication (isAuthenticated from @/shared/utils/apiAuth).

Method Endpoint Description
GET /api/cache/reasoning Stats + paginated entries
GET /api/cache/reasoning?provider=deepseek&model=...&limit= Filtered listing (limit clamped to [1, 200])
DELETE /api/cache/reasoning Clear everything (memory + DB) and reset hit/miss counts
DELETE /api/cache/reasoning?provider=deepseek Clear only entries for one provider
DELETE /api/cache/reasoning?toolCallId=call_abc Delete a single entry

GET response shape:

{
  "stats": {
    "memoryEntries": 12,
    "dbEntries": 47,
    "totalEntries": 47,
    "totalChars": 138291,
    "hits": 84,
    "misses": 6,
    "replays": 81,
    "replayRate": "90.0%",
    "byProvider": { "deepseek": { "entries": 32, "chars": 98412 } },
    "byModel": { "deepseek-reasoner": { "entries": 32, "chars": 98412 } },
    "oldestEntry": "2026-05-13T10:00:00.000Z",
    "newestEntry": "2026-05-13T11:42:11.000Z"
  },
  "entries": [
    {
      "toolCallId": "call_abc",
      "provider": "deepseek",
      "model": "deepseek-reasoner",
      "reasoning": "...",
      "charCount": 3128,
      "createdAt": "...",
      "expiresAt": "..."
    }
  ]
}

Operational Notes

  • Cleanup: cleanupReasoningCache() purges expired memory entries and runs DELETE FROM reasoning_cache WHERE expires_at <= unixepoch('now'). Health-check workers call this periodically.
  • Crash recovery: After a restart, memory is empty but the DB still holds unexpired entries. The first lookup for a given tool_call_id is a DB hit; subsequent lookups are memory hits.
  • No reasoning, no cache: cacheReasoningFromAssistantMessage returns 0 when the assistant message has no reasoning_content / reasoning field, so non-thinking responses cost nothing.
  • Write is gated too: both call sites in chatCore.ts (non-streaming and streaming) only call cacheReasoningFromAssistantMessage() when requiresReasoningReplay(provider, model) is true — the same predicate the read side checks. Installs that never touch a replay provider stop paying for the write, the index update, and the try/catch on every reasoning-bearing response.
  • Non-strict providers: When requiresReasoningReplay is false and the target format is OpenAI, the translator strips any reasoning_content field from outgoing messages — OpenAI Chat Completions does not accept it.

See Also

  • RESILIENCE_GUIDE.md — circuit breakers, cooldowns, model lockouts
  • TROUBLESHOOTING.md — diagnosing upstream 400s
  • Source: src/lib/db/reasoningCache.ts, open-sse/services/reasoningCache.ts, open-sse/translator/index.ts
  • Migration: src/lib/db/migrations/033_create_reasoning_cache.sql
  • API route: src/app/api/cache/reasoning/route.ts
  • Original issue: #1628