Lay the prompt out so the endpoint can cache most of it
Prompt caching bills on a shared prefix: the endpoint reuses the request up to the first byte that differs from last time and no further. The live world-state block sat third from the top of the system message, so every turn re-priced the instructions, the plot essentials and the whole story history underneath it. The retrieved memories and the rewritten summary did it again. Everything fixed is emitted first now, and everything that moves goes after the history, ordered least-volatile first — which is also where recency serves it best, the reasoning that already put the emit reminder last. The three tail sections that are last for their own reasons stay last. The moved sections are still charged to the token budget; only their position changed. Two smaller halves of the same problem. OpenRouter serves a model from whichever upstream is free and each upstream holds its own cache, so a deepseek model now names deepseek as its preferred upstream — a preference, not a restriction, so a turn still runs if that upstream is down. And the endpoint's usage block is read back off the response and kept per attempt, so the hit rate shows up in Insights and the debug log instead of being assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
This commit is contained in:
co-authored by
Claude Opus 5
parent
28322b4b82
commit
a408c7b6f7
@@ -40,9 +40,11 @@ from .context import lineage
|
||||
|
||||
# The slices of a context snapshot that belong to one attempt rather than to
|
||||
# the turn: the world-state delta it proposed and what the referee did with it,
|
||||
# the script report, and the model's literal reply. Everything else in a
|
||||
# the script report, the model's literal reply, and the endpoint's token
|
||||
# accounting — each attempt is its own API call, and a retry is precisely the
|
||||
# call expected to read the prompt back out of cache. Everything else in a
|
||||
# snapshot is the prompt, which is assembled once per turn.
|
||||
ATTEMPT_KEYS = ("world_state", "script", "raw_output")
|
||||
ATTEMPT_KEYS = ("world_state", "script", "raw_output", "usage")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
[Story history] newest actions that fit the remaining token budget
|
||||
[Author's Note] injected AUTHORS_NOTE_DEPTH actions before the end of history
|
||||
[Latest player action] (+ script frontMemory right after it, Phase 4)
|
||||
|
||||
Which components are present is AI Dungeon's design, above. The *order* they
|
||||
are laid out in is not: everything fixed is emitted first and everything that
|
||||
moves after the history, because prompt caching bills on a shared prefix and
|
||||
one mutable section high up re-prices the whole prompt under it. See the two
|
||||
"static block" / "live sections" comments in `build_context`.
|
||||
"""
|
||||
|
||||
import functools
|
||||
@@ -190,24 +196,26 @@ def build_context(
|
||||
`exclude_action_id` omits one action from the story (see history.py)."""
|
||||
script_mem = _script_memory(adventure)
|
||||
|
||||
# ----- Always-included components -----
|
||||
# ----- The static block: byte-for-byte the same prompt every turn -----
|
||||
# The ordering here is a billing decision, not a stylistic one. Prompt
|
||||
# caching matches a *prefix*: an endpoint reuses the prompt up to the first
|
||||
# byte that differs from last time and no further. So one mutable section
|
||||
# near the top re-prices everything below it, and what is below it is the
|
||||
# story history, which is the bulk of the prompt. Anything that changes
|
||||
# turn to turn therefore goes *after* the history, in the live sections —
|
||||
# which is also where recency serves it best, the same reasoning that
|
||||
# already puts EMIT_REMINDER last.
|
||||
system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())]
|
||||
|
||||
# RPG world state (Phase 12): current stats/milestones + how to report changes.
|
||||
# RPG world state (Phase 12): how to report changes. The live values are a
|
||||
# live section below; the schema-derived guide and the emit rule are fixed
|
||||
# for as long as the scenario is.
|
||||
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
has_ws = worldstate.has_schema(stat_schema)
|
||||
if has_ws:
|
||||
guide = worldstate.render_reference(stat_schema)
|
||||
if guide:
|
||||
system_sections.append(Section("world_state_guide", guide))
|
||||
block = worldstate.render_state_section(
|
||||
adventure.world_state, stat_schema,
|
||||
_visible_npcs(
|
||||
history.tail(adventure, NPC_WINDOW, exclude_action_id), stat_schema
|
||||
),
|
||||
)
|
||||
if block:
|
||||
system_sections.append(Section("world_state", block))
|
||||
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
|
||||
|
||||
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
|
||||
@@ -218,13 +226,33 @@ def build_context(
|
||||
system_sections.append(
|
||||
Section("plot_essentials", f"Plot essentials:\n{adventure.memory.strip()}")
|
||||
)
|
||||
if adventure.story_summary.strip():
|
||||
system_sections.append(
|
||||
Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}")
|
||||
)
|
||||
|
||||
# ----- Live sections: everything that moves, built here, placed after the
|
||||
# history further down. Ordered least-volatile first, so a turn that
|
||||
# changes only the fastest-moving one keeps the others cached too: the
|
||||
# summary is rewritten every few turns, lore turns over with the scene, the
|
||||
# retrieved memories change on most turns, the stat values on nearly all.
|
||||
# `world_lore` joins them below — it is the history window that triggers
|
||||
# the cards, so it cannot be known yet.
|
||||
summary_section = (
|
||||
Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}")
|
||||
if adventure.story_summary.strip()
|
||||
else None
|
||||
)
|
||||
memories_section = None
|
||||
if memory_bank and memory_bank.get("used"):
|
||||
lines = "\n".join(f"- {m['text']}" for m in memory_bank["used"])
|
||||
system_sections.append(Section("used_memories", f"Memories:\n{lines}"))
|
||||
lines_text = "\n".join(f"- {m['text']}" for m in memory_bank["used"])
|
||||
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
|
||||
world_state_section = None
|
||||
if has_ws:
|
||||
block = worldstate.render_state_section(
|
||||
adventure.world_state, stat_schema,
|
||||
_visible_npcs(
|
||||
history.tail(adventure, NPC_WINDOW, exclude_action_id), stat_schema
|
||||
),
|
||||
)
|
||||
if block:
|
||||
world_state_section = Section("world_state", block)
|
||||
|
||||
authors_note_text = adventure.authors_note.strip()
|
||||
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
|
||||
@@ -237,8 +265,16 @@ def build_context(
|
||||
|
||||
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
|
||||
|
||||
# The live sections moved below the history but they are still in the
|
||||
# prompt, so they are still reserved against the budget. (`world_lore` is
|
||||
# not: it is budgeted out of `available` further down, as it always was.)
|
||||
reserved = (
|
||||
sum(s.tokens for s in system_sections)
|
||||
+ sum(
|
||||
s.tokens
|
||||
for s in (summary_section, memories_section, world_state_section)
|
||||
if s is not None
|
||||
)
|
||||
+ count_tokens(authors_note)
|
||||
+ count_tokens(front_memory)
|
||||
+ count_tokens(length_note)
|
||||
@@ -273,8 +309,9 @@ def build_context(
|
||||
{"id": match["id"], "name": match["name"], "keyword": match["keyword"],
|
||||
"included": included}
|
||||
)
|
||||
if lore_lines:
|
||||
system_sections.append(Section("world_lore", "\n".join(lore_lines)))
|
||||
lore_section = (
|
||||
Section("world_lore", "\n".join(lore_lines)) if lore_lines else None
|
||||
)
|
||||
|
||||
# ----- Story history: newest first until the remaining budget is spent -----
|
||||
history_budget = available - used
|
||||
@@ -316,6 +353,12 @@ def build_context(
|
||||
note_sections.append(Section("recent_history", SEPARATOR.join(after)))
|
||||
else:
|
||||
note_sections.append(Section("history", SEPARATOR.join(texts)))
|
||||
# The live sections, least volatile first (see where they are built). They
|
||||
# sit below the history so the history caches, and above the tail so the
|
||||
# three sections that are last for a reason stay last.
|
||||
for live in (summary_section, lore_section, memories_section, world_state_section):
|
||||
if live is not None:
|
||||
note_sections.append(live)
|
||||
if front_memory:
|
||||
note_sections.append(Section("front_memory", front_memory))
|
||||
# Sits just above the emit reminder, which keeps the strongest recency slot:
|
||||
|
||||
+13
-1
@@ -40,14 +40,26 @@ def start_entry(url: str, model: str, body: dict) -> dict:
|
||||
"request": _clip_obj(body),
|
||||
"status": "pending",
|
||||
"response": "",
|
||||
"usage": None,
|
||||
"error": None,
|
||||
}
|
||||
_entries.appendleft(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def finish_entry(entry: dict, *, response: str = "", error: str | None = None) -> None:
|
||||
def finish_entry(
|
||||
entry: dict,
|
||||
*,
|
||||
response: str = "",
|
||||
error: str | None = None,
|
||||
usage: dict | None = None,
|
||||
) -> None:
|
||||
"""`usage` is the endpoint's own token accounting when it reported any.
|
||||
On OpenRouter it carries `prompt_tokens_details.cached_tokens`, which is
|
||||
the only direct read on whether the prompt prefix is actually being
|
||||
cached — a number worth seeing beside the request that produced it."""
|
||||
entry["response"] = _clip(response)
|
||||
entry["usage"] = usage
|
||||
entry["error"] = error
|
||||
entry["status"] = "error" if error else "ok"
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ class ProviderError(Exception):
|
||||
|
||||
|
||||
class Provider(ABC):
|
||||
# The endpoint's own token accounting for the most recent call, when it
|
||||
# reported any — notably `prompt_tokens_details.cached_tokens`, which is
|
||||
# the only direct read on whether the prompt prefix is being cached.
|
||||
# Callers read it after the call they made; one provider is built per
|
||||
# request, so there is nothing to race.
|
||||
last_usage: dict | None = None
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
|
||||
@@ -10,6 +10,26 @@ from .base import PromptParts, Provider, ProviderError
|
||||
# continuing prose instead of replying conversationally.
|
||||
CHAT_CONTINUE_HINT = "\n\n[Continue the story directly. Output only story text.]"
|
||||
|
||||
# OpenRouter serves one model from whichever upstream is available, and every
|
||||
# upstream holds its own prompt cache — so a request that lands somewhere new
|
||||
# starts cold however stable the prompt is. Naming a preferred upstream makes
|
||||
# routing deterministic, which is what lets a cache be hit at all.
|
||||
#
|
||||
# `allow_fallbacks` is deliberately left at its default of true: this is a
|
||||
# preference, not a restriction. If the named upstream is down the request
|
||||
# still goes through somewhere else and merely misses the cache, which is the
|
||||
# behaviour we had anyway.
|
||||
#
|
||||
# A whitelist rather than a derivation from the model slug. The vendor half of
|
||||
# a slug is *usually* the provider slug ("deepseek/..." -> "deepseek", verified
|
||||
# against /api/v1/providers) but not reliably: Google's models are served by
|
||||
# "google-ai-studio" and "google-vertex", and there is no "google". Look a
|
||||
# vendor up on the model's Providers tab before adding it here — a slug that
|
||||
# does not exist is a routing preference that silently does nothing at best.
|
||||
_OPENROUTER_HOST = "openrouter.ai"
|
||||
_PREFERRED_UPSTREAM = {"deepseek": "deepseek"}
|
||||
|
||||
|
||||
# Completion endpoints have no roles, so a plain chat has to be flattened into
|
||||
# one labelled transcript that trails off on "Assistant:" for the model to continue.
|
||||
_ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant"}
|
||||
@@ -42,6 +62,12 @@ class OpenAICompatibleProvider(Provider):
|
||||
# reject unknown fields); negative = explicitly ask the endpoint to
|
||||
# turn reasoning off.
|
||||
self.reasoning_max_tokens = reasoning_max_tokens
|
||||
# Token accounting from the last call, when the endpoint reported any:
|
||||
# prompt/completion counts plus, on OpenRouter, `prompt_tokens_details.
|
||||
# cached_tokens` — the number of prompt tokens read from cache instead
|
||||
# of billed in full. Written by every request method, so a caller reads
|
||||
# it after the call it made; one provider is built per request.
|
||||
self.last_usage: dict | None = None
|
||||
|
||||
def _headers(self) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -66,6 +92,29 @@ class OpenAICompatibleProvider(Provider):
|
||||
body["reasoning"] = {"max_tokens": self.reasoning_max_tokens}
|
||||
body["max_tokens"] += self.reasoning_max_tokens
|
||||
|
||||
def _apply_provider_routing(self, body: dict) -> None:
|
||||
"""Prefer one upstream on OpenRouter, so the prompt cache is warm.
|
||||
|
||||
Silent no-op everywhere else: `provider` is an OpenRouter extension and
|
||||
Ollama and friends reject fields they do not know — the same trap the
|
||||
`reasoning` param above is written around."""
|
||||
if _OPENROUTER_HOST not in self.base_url:
|
||||
return
|
||||
upstream = _PREFERRED_UPSTREAM.get(self.model.split("/", 1)[0].lower())
|
||||
if upstream:
|
||||
body["provider"] = {"order": [upstream]}
|
||||
|
||||
def _record_usage(self, payload: dict) -> None:
|
||||
"""Record the endpoint's own token accounting, if it reported any.
|
||||
|
||||
OpenRouter always reports usage now (`usage: {include: true}` and
|
||||
`stream_options` are deprecated no-ops), and in a stream it rides on a
|
||||
final chunk that carries no choices — which is why this is read
|
||||
separately from the text extraction rather than beside it."""
|
||||
usage = payload.get("usage")
|
||||
if isinstance(usage, dict) and usage:
|
||||
self.last_usage = usage
|
||||
|
||||
def _request(self, parts: PromptParts, temperature: float, max_tokens: int) -> tuple[str, dict]:
|
||||
if self.api_mode == "completion":
|
||||
url = f"{self.base_url}/completions"
|
||||
@@ -89,6 +138,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
"stream": True,
|
||||
}
|
||||
self._apply_reasoning_budget(body)
|
||||
self._apply_provider_routing(body)
|
||||
return url, body
|
||||
|
||||
@staticmethod
|
||||
@@ -162,6 +212,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
"stream": True,
|
||||
}
|
||||
self._apply_reasoning_budget(body)
|
||||
self._apply_provider_routing(body)
|
||||
async for event in self._stream(url, body):
|
||||
yield event
|
||||
|
||||
@@ -193,12 +244,15 @@ class OpenAICompatibleProvider(Provider):
|
||||
saw_sse = True
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
debuglog.finish_entry(log, response="".join(received))
|
||||
debuglog.finish_entry(
|
||||
log, response="".join(received), usage=self.last_usage
|
||||
)
|
||||
return
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
self._record_usage(payload)
|
||||
reasoning = self._extract_reasoning(payload)
|
||||
if reasoning:
|
||||
yield "reasoning", reasoning
|
||||
@@ -215,6 +269,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
"AI endpoint returned neither an SSE stream nor JSON: "
|
||||
+ body_text[:200]
|
||||
)
|
||||
self._record_usage(payload)
|
||||
reasoning = self._extract_reasoning(payload)
|
||||
if reasoning:
|
||||
yield "reasoning", reasoning
|
||||
@@ -227,7 +282,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
"AI endpoint returned a response with no text: "
|
||||
+ body_text[:200]
|
||||
)
|
||||
debuglog.finish_entry(log, response="".join(received))
|
||||
debuglog.finish_entry(log, response="".join(received), usage=self.last_usage)
|
||||
except httpx.ConnectError as exc:
|
||||
error = f"Could not connect to {self.base_url} — is the AI server running?"
|
||||
debuglog.finish_entry(log, response="".join(received), error=error)
|
||||
@@ -272,6 +327,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
"stream": False,
|
||||
}
|
||||
self._apply_reasoning_budget(body)
|
||||
self._apply_provider_routing(body)
|
||||
|
||||
log = debuglog.start_entry(url, self.model, body)
|
||||
try:
|
||||
@@ -285,11 +341,13 @@ class OpenAICompatibleProvider(Provider):
|
||||
debuglog.finish_entry(log, error=error)
|
||||
raise ProviderError(error)
|
||||
try:
|
||||
text = self._extract_chunk(resp.json())
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
debuglog.finish_entry(log, error="Invalid JSON response")
|
||||
raise ProviderError("AI endpoint returned invalid JSON.") from exc
|
||||
debuglog.finish_entry(log, response=text)
|
||||
self._record_usage(payload)
|
||||
text = self._extract_chunk(payload)
|
||||
debuglog.finish_entry(log, response=text, usage=self.last_usage)
|
||||
return text.strip()
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
|
||||
@@ -809,6 +809,10 @@ async def _generate_turn(
|
||||
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
|
||||
|
||||
snapshot["raw_output"] = raw_output
|
||||
# What the endpoint says the call cost, including how much of the prompt it
|
||||
# read from cache rather than billed in full. Recorded per attempt, beside
|
||||
# the prompt it priced.
|
||||
snapshot["usage"] = provider.last_usage
|
||||
|
||||
reasoning = "".join(reasoning_chunks).strip() or None
|
||||
ai_action = models.Action(
|
||||
|
||||
Reference in New Issue
Block a user