Repository navigation
feat(lean): keep Haiku coding sessions cached and under the 5x long-prompt price #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
288bd62
feat(lean): add /lean routes that keep Haiku requests cached and unde…
ALikesToCode 728a4e0
fix(lean): apply the price limit to the prompt before clearing by def…
ALikesToCode 584d540
fix(lean): close the price guard's gaps for documents, uncountable pr…
ALikesToCode b0a3397
fix(lean): satisfy the CI security and type checks
ALikesToCode 58c58d6
fix(lean): keep thinking-block counts valid and let key rotation hand…
ALikesToCode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| # Lean endpoint | ||
|
|
||
| `/lean/v1/messages`, `/lean/v1/chat/completions` and `/lean/v1/responses` are the | ||
| unified routes with extra cost controls for models served by the Claude API | ||
| (`anthropic:` models). They are written for coding harnesses on Claude Haiku 5.5, | ||
| whose price depends on prompt length: | ||
|
|
||
| | Haiku 5.5, per million tokens | Prompt of 100,000 tokens or fewer | Longer prompt | | ||
| |---|---|---| | ||
| | Input | $0.10 | $0.50 | | ||
| | Cache read | $0.01 | $0.05 | | ||
| | Cache write (5 minutes) | $0.125 | $0.625 | | ||
| | Output | $0.50 | $2.50 | | ||
|
|
||
| The prompt length counts every input token, cache reads and writes included, and | ||
| a request one token over the limit pays the higher price on all of its tokens | ||
| ([pricing](https://platform.claude.com/docs/en/about-claude/pricing#long-context-pricing)). | ||
| Lean mode keeps prompts cached and under that limit. | ||
|
|
||
| Requests to other providers behave exactly as on `/v1` and carry | ||
| `X-MultiLLM-Lean: inactive`. `/lean/v1/messages/count_tokens` and `/lean/v1/models` | ||
| are the `/v1` routes unchanged, so a harness can use one base URL. | ||
|
|
||
| ## What lean mode does | ||
|
|
||
| Every change depends only on the request itself, so each later request of a | ||
| conversation repeats the earlier one's bytes up to its new turn. That keeps prompt | ||
| caching working, and keeps Haiku 5.5's replayed thinking blocks valid: Haiku 5.5 | ||
| rejects a thinking block whose earlier conversation changed. Checked against the | ||
| Claude API: a second identical request read its whole 2,006-token prefix from the | ||
| cache, and a thinking block Haiku wrote through `/lean` was accepted when the next | ||
| turn replayed it through `/lean`, but rejected when the same turn went to `/v1` | ||
| with the raw shell output. | ||
|
|
||
| | Stage | Default | What it does | | ||
| |---|---|---| | ||
| | Prompt caching | on | Adds automatic caching (`cache_control` at the top level) and a breakpoint after the system prompt, or after the last tool when there is none. Skipped when the caller placed its own `cache_control`, as Claude Code does. | | ||
| | Tool result clearing | on, from 80,000 tokens | Adds `context_management` with `clear_tool_uses_20250919`: once the prompt passes the trigger, the API replaces old tool results with placeholders and keeps the 3 most recent, clearing at least 20,000 tokens at a time. The API applies it before the model reads the prompt, the harness keeps its full history, and it never invalidates thinking blocks. Skipped when the caller sends its own `context_management` or a compaction block. | | ||
| | Shell output | on | Results of shell tools (`Bash`, `bash`, `shell`, `exec_command`, `run_shell_command` and similar) lose ANSI escapes and progress-bar redraws; runs of four or more identical lines become one line and a count, and runs of blank lines become one. Other tools' results, file reads included, keep their exact bytes, so an edit tool can still match them. | | ||
| | Oversized results | 100,000 characters | A longer tool result keeps its first and last 50,000 characters with a marker in between. | | ||
| | Prompt limit | 99,000 tokens | A prompt estimated near the limit is counted with `count_tokens`, using the key that will send it, which reports its size before and after context editing. Anthropic calls the count an estimate that can differ slightly, so the default sits 1,000 tokens under the 100,000-token price line. Above the limit the request is refused with the error the harness treats as a full context window, so it compacts and retries instead of paying the higher price. The pricing page does not say whether the size before or after clearing picks the price, so by default the limit applies to the size before clearing; set `LEAN_PRICE_LIMIT_BASIS=cleared` once your bill shows that cleared prompts are priced by their smaller size. | | ||
| | Concise answers | off | Adds a short instruction to the system prompt: lead with the answer, no filler, code and commands exact. Turn it on with `LEAN_STYLE=concise` or per request with `X-MultiLLM-Lean-Style: concise` (`caveman` is accepted too). | | ||
|
|
||
| Lean mode never changes the model, effort, thinking settings or `max_tokens`. | ||
|
|
||
| ### The refusal | ||
|
|
||
| | Route | Response | | ||
| |---|---| | ||
| | `/lean/v1/messages` | 400, Anthropic envelope, `invalid_request_error`, `prompt is too long: N tokens > 99000 maximum`. Claude Code compacts on this message. | | ||
| | `/lean/v1/chat/completions` | 400, `{"error": {"code": "context_length_exceeded", ...}}` with a message naming the maximum context length. | | ||
| | `/lean/v1/responses` with `stream: true` | A `response.failed` event with `context_length_exceeded`, the form Codex reads. Without streaming, the Chat Completions body above. | | ||
|
|
||
| When the prompt cannot be counted, the local estimate decides, and an estimate | ||
| above the limit is refused. That happens for URL and file sources, which | ||
| `count_tokens` rejects (lean estimates them at 1,600 tokens per image and 10,000 | ||
| per document); for a request that replays thinking blocks alongside a server tool | ||
| such as web search, because `count_tokens` rejects server tools and dropping them | ||
| would fail its thinking-block check; and when a count fails, for example when | ||
| rate-limited. Such requests report `X-MultiLLM-Lean-Prompt-Count: estimate`, and | ||
| the limit is then a safety net, not a guarantee: the estimate can be low for a | ||
| URL document or an unusual tokenization. Without replayed thinking, server tools | ||
| are left out of the count and added back at 1,000 tokens each. A prompt with a | ||
| PDF is always counted, since its file size does not bound the text inside. A count | ||
| that refuses the key itself (401, 402 or 403) does not decide: the request goes | ||
| out so key rotation can rest that key and count again with the next one | ||
| (`X-MultiLLM-Lean-Prompt-Count: skipped`). If compacting cannot bring a | ||
| prompt under the limit, for example because the newest message alone is larger, | ||
| the harness reports the error: Claude Code stops after three failed compactions. | ||
|
|
||
| ### Response headers | ||
|
|
||
| | Header | Meaning | | ||
| |---|---| | ||
| | `X-MultiLLM-Lean` | `applied` for a Claude API request, `inactive` for other providers | | ||
| | `X-MultiLLM-Lean-Rules` | Version of the rules above | | ||
| | `X-MultiLLM-Lean-Stages` | Stages that changed the request, for example `cache,clear-tool-uses,condense-shell` | | ||
| | `X-MultiLLM-Lean-Prompt-Tokens` | Prompt size the limit was checked against: counted by the API near the limit, otherwise a rough local estimate | | ||
| | `X-MultiLLM-Lean-Prompt-Count` | `provider`, `estimate`, or `skipped` when the count refused the key | | ||
| | `X-MultiLLM-Lean-Prompt-Tokens-After-Clearing` | Counted size after context editing, when it differs | | ||
| | `X-MultiLLM-Lean-Condensed-Chars` | Characters removed from tool results | | ||
|
|
||
| The API reports what it cleared in the response's `context_management` field and | ||
| cache use in `usage.cache_read_input_tokens` and `usage.cache_creation_input_tokens`. | ||
|
|
||
| ## Harness setup | ||
|
|
||
| Set each harness to compact before 100,000 tokens, so the refusal stays a safety | ||
| net. | ||
|
|
||
| ### Claude Code | ||
|
|
||
| ```bash | ||
| export ANTHROPIC_BASE_URL="$PROXY_BASE_URL/lean" | ||
| export ANTHROPIC_API_KEY="$MULTILLM_API_KEY" | ||
| export ANTHROPIC_MODEL="anthropic:claude-haiku-5-5" | ||
| export ANTHROPIC_DEFAULT_HAIKU_MODEL="anthropic:claude-haiku-5-5" | ||
| export CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000 | ||
| claude | ||
| ``` | ||
|
|
||
| Claude Code places its own cache breakpoints, so lean mode leaves caching to it. | ||
|
|
||
| ### Codex | ||
|
|
||
| ```toml | ||
| model = "anthropic:claude-haiku-5-5" | ||
| model_provider = "multillm-lean" | ||
| model_context_window = 100000 | ||
| model_auto_compact_token_limit = 85000 | ||
|
|
||
| [model_providers.multillm-lean] | ||
| name = "MultiLLM lean" | ||
| base_url = "https://multillm-proxy.cserules.workers.dev/lean/v1" | ||
| env_key = "MULTILLM_API_KEY" | ||
| wire_api = "responses" | ||
| ``` | ||
|
|
||
| ### opencode | ||
|
|
||
| ```json | ||
| { | ||
| "provider": { | ||
| "multillm-lean": { | ||
| "npm": "@ai-sdk/openai-compatible", | ||
| "options": { | ||
| "baseURL": "https://multillm-proxy.cserules.workers.dev/lean/v1", | ||
| "apiKey": "{env:MULTILLM_API_KEY}" | ||
| }, | ||
| "models": { | ||
| "anthropic:claude-haiku-5-5": { "limit": { "context": 100000, "output": 32000 } } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Chat Completions and Responses requests reach the Claude API translated, and the | ||
| translation drops caller `cache_control`, so for Codex and opencode lean mode's | ||
| caching is what makes cache reads happen at all. | ||
|
|
||
| ## Settings | ||
|
|
||
| | Variable | Default | | | ||
| |---|---|---| | ||
| | `LEAN_ENDPOINT_ENABLED` | `true` | `false` stops serving `/lean` | | ||
| | `LEAN_PROMPT_TOKEN_LIMIT` | `99000` | `0` turns the refusal off | | ||
| | `LEAN_PRICE_LIMIT_BASIS` | `original` | `cleared` applies the limit to the prompt after context editing | | ||
| | `LEAN_CLEAR_TRIGGER_TOKENS` | `80000` | `0` turns tool result clearing off | | ||
| | `LEAN_CLEAR_KEEP_TOOL_USES` | `3` | Recent tool uses that are never cleared | | ||
| | `LEAN_CLEAR_AT_LEAST_TOKENS` | `20000` | Minimum cleared per edit, so a cache rewrite is worth it | | ||
| | `LEAN_CLEAR_EXCLUDE_TOOLS` | empty | Comma-separated tool names whose results are never cleared | | ||
| | `LEAN_CACHE_TTL` | `5m` | `1h` for sessions with long pauses; 1-hour writes cost 2x input | | ||
| | `LEAN_CONDENSE_SHELL_OUTPUT` | `true` | | | ||
| | `LEAN_TOOL_RESULT_MAX_CHARS` | `100000` | `0` turns clipping off | | ||
| | `LEAN_STYLE` | `none` | `concise` adds the concise-answer instruction | | ||
|
|
||
| Keep a conversation on one base URL and its settings fixed. Lean mode sends shell | ||
| output condensed, so moving a conversation between `/v1` and `/lean`, or changing | ||
| `LEAN_CONDENSE_SHELL_OUTPUT`, `LEAN_TOOL_RESULT_MAX_CHARS` or `LEAN_STYLE` partway | ||
| through, changes bytes the model already saw. On accounts created on or after | ||
| 31 August 2026 the next request that replays a thinking block then fails with | ||
| ``Invalid `signature` in `thinking` block. The block is bound to a different | ||
| conversation``; start a new conversation. The cache and clearing settings change | ||
| only cost: one cache rewrite. | ||
|
|
||
| ## Notes | ||
|
|
||
| - Cleared tool results are gone for the model, not for the harness: if the model | ||
| needs one again it reruns the tool. | ||
| - The Claude API's prompt cache belongs to the account. The key pool keeps using | ||
| the first key until it is refused ([key failover](anthropic.md#spare-keys-and-failover)); a conversation | ||
| that moves to the spare key starts with a cold cache, and its earlier thinking | ||
| blocks are dropped rather than rejected. | ||
| - Research behind these choices, including what Claude Code, Codex, opencode, | ||
| Cline and Gemini CLI do, is summarised in the pull request that added this | ||
| page. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """Serve `/lean/v1/...` from the unified `/v1/...` routes in lean mode.""" | ||
|
|
||
| from collections.abc import Callable, Iterable | ||
| from typing import Any | ||
|
|
||
| WSGIStartResponse = Callable[..., Any] | ||
| WSGIApplication = Callable[[dict[str, Any], WSGIStartResponse], Iterable[bytes]] | ||
|
|
||
| LEAN_ENVIRON_KEY = "multillm.lean" | ||
| LEAN_PREFIX = "/lean" | ||
| LEAN_PATHS = frozenset( | ||
| { | ||
| "/v1/chat/completions", | ||
| "/v1/messages", | ||
| "/v1/messages/count_tokens", | ||
| "/v1/models", | ||
| "/v1/responses", | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class LeanPathMiddleware: | ||
| """Rewrite a lean path to its unified route and mark the request as lean. | ||
|
|
||
| Rewriting before Flask routes the request keeps every path-based rule (rate | ||
| limits, body limits, error envelopes, cache affinity) identical to `/v1`. | ||
| The mark lives in the WSGI environ, which a client cannot set. | ||
| """ | ||
|
|
||
| def __init__(self, application: WSGIApplication) -> None: | ||
| self.application = application | ||
|
|
||
| def __call__( | ||
| self, | ||
| environ: dict[str, Any], | ||
| start_response: WSGIStartResponse, | ||
| ) -> Iterable[bytes]: | ||
| environ.pop(LEAN_ENVIRON_KEY, None) | ||
| path = environ.get("PATH_INFO", "") | ||
| if path.startswith(f"{LEAN_PREFIX}/") and path[len(LEAN_PREFIX):] in LEAN_PATHS: | ||
| environ["PATH_INFO"] = path[len(LEAN_PREFIX):] | ||
| environ[LEAN_ENVIRON_KEY] = True | ||
| return self.application(environ, start_response) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| """Lean mode responses: report headers and the protocol's own "prompt is too long" error.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| from flask import Response, jsonify, request | ||
|
|
||
| from error_handlers import get_request_id | ||
| from services.lean_mode import LeanPromptTooLong, lean_response_headers | ||
| from services.protocol_translation import anthropic_error_body | ||
|
|
||
|
|
||
| def prompt_too_long_response(error: LeanPromptTooLong): | ||
| """Refuse in the shape each harness treats as a context overflow, so it compacts. | ||
|
|
||
| Claude Code compacts on Anthropic's "prompt is too long" error. OpenAI-style | ||
| clients match `context_length_exceeded`; Codex reads it from a streamed | ||
| `response.failed` event. | ||
| """ | ||
| request_id = get_request_id() | ||
| if request.path.startswith("/v1/messages"): | ||
| return jsonify(anthropic_error_body(400, str(error), "invalid_request_error", request_id)), 400 | ||
| message = ( | ||
| f"This model's maximum context length is {error.limit} tokens. However, your " | ||
| f"messages resulted in {error.tokens} tokens ({error}). Compact the " | ||
| "conversation and try again." | ||
| ) | ||
| payload = request.get_json(silent=True) | ||
| if request.path == "/v1/responses" and isinstance(payload, dict) and payload.get("stream"): | ||
| event = { | ||
| "type": "response.failed", | ||
| "sequence_number": 0, | ||
| "response": { | ||
| "object": "response", | ||
| "status": "failed", | ||
| "error": {"code": "context_length_exceeded", "message": message}, | ||
| }, | ||
| } | ||
| return Response( | ||
| f"event: response.failed\ndata: {json.dumps(event)}\n\n", | ||
| mimetype="text/event-stream", | ||
| ) | ||
| error_body = { | ||
| "message": message, | ||
| "type": "invalid_request_error", | ||
| "param": "messages", | ||
| "code": "context_length_exceeded", | ||
| } | ||
| return jsonify({"error": error_body, "request_id": request_id}), 400 | ||
|
|
||
|
|
||
| def register_lean_routes(app) -> None: | ||
| app.after_request(lean_response_headers) | ||
| app.register_error_handler(LeanPromptTooLong, prompt_too_long_response) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a caller opts into response caching with
X-MultiLLM-Cache: onand sends a cacheable request such astemperature: 0, rewriting the path makes/lean/v1/chat/completionsshare its cache identity with/v1/chat/completions.routes/chat_cache.pykeys entries usingrequest.pathand the original payload, without the lean marker or style header. Consequently, an answer generated with lean's concise system instruction can be returned to an ordinary/v1request, or an ordinary cached answer can bypass the requested concise instruction entirely. Preserve lean mode and the effective style in the response-cache identity, or bypass that cache for lean requests.Useful? React with 👍 / 👎.