feat(budget): measure the token margin against the provider instead of guessing 50% high - #818
Conversation
Documents, uploads and links showed as pills under the chat header while an attached knowledge base showed as a full-width bar above the composer, and a folder — which scopes the chat and is sent on every message — showed nothing anywhere. All three answer the same question about a conversation, so they share one row. The pill row was already the deliberate merge of chat uploads and file-browser selection; the KB bar was never brought along. This finishes that. - Type is icon + text tag + tint, never tint alone: --highlight-color is deploy-customisable and colour alone fails a colourblind reader. The KB and Folder tags preserve what the bar's "Knowledge Base: " prefix said. - Scope chips (KBs, capped at 3; folders) render first and always; the unbounded document tail collapses behind "+N more" past six, so a heavy library selection cannot push the knowledge base out of view. - FileBrowser reports selected folder titles alongside uuids, mirroring the document path, so a folder chip can name itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKDBCBvedFaffXibXnFM2g
…feedback)
Six changes from a sit-down with a DGA working through the course:
1. Module 0 opens with the big picture — what AI is, why it's in research
administration, what the course delivers — before any terminology, and
teaches structured vs. unstructured data as the course's central idea
with no under-the-hood mechanics.
2. Jargon is defined in RA terms: JSON as "the fill-in-the-form format you
read, never write", Token via attachment-size limits, and Module 1's
pipeline lesson drops chunking/embedding/ChromaDB for "it reads the text
and builds an index, like the back of a book".
3. Every module now carries a worked example — eleven new walkthroughs
grounded in the modules' own sample documents.
4. Time estimates show everywhere they were missing: journey-map total,
locked cards, and the open module's header ("your place is saved").
5. An explicit pop-out button opens the course in its own browser window
for a second monitor, and the five cryptic mode icons get descriptive
tooltips with the pin group visually separated.
6. The Module 1 lab now says the Run button stays greyed out until a
document is ticked in the file browser — the exact stuck point.
Point 7 of the feedback (wrong duplicate-name message on import) is a
backend bug, fixed separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tbi5K9tPm3DNKC5VJyXyQ
An Explore import told a user she "already had a workflow with the same name" while her personal library showed no such row — and she was right to disbelieve it. The uniqueness scope counts more than the personal library: a teammate's team-shared workflow, her own workflow filed under the Team tab, and a workflow whose library bookmark was removed while the object (and its name) lives on. The flat "already exists in your library" message claimed all of these were sitting in front of her. The 409 now resolves the conflicting workflow and names the case — team library (hers or a teammate's), or existing-but-unlisted — and quotes the stored name's exact capitalization, since the match is case-insensitive and "budget analyzer" is invisible to someone scanning for "Budget Analyzer". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011tbi5K9tPm3DNKC5VJyXyQ
The recipe called `build`/`update` without `--repo`, so the tool discovered the repository from the working directory. Under `git worktree` that is the worktree, not the checkout holding the graph, so a run from one built a second index there containing only the files that worktree had touched -- and then answered every query from it reporting `"status": "ok"`. Measured on a working copy: four worktrees carried indexes of 7, 27, 26 and 6 files against a real 967. A partial index returns 0 for anything it never parsed and a true 0 is indistinguishable, so `callers_of` on a live symbol reports no callers, which reads as "safe to change". The first `git worktree list` entry is always the main working tree, so one graph now serves every worktree. ORIG_HEAD is read from that same tree, since that is the tree being indexed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBMRynZ8iqpC7JKvwdBw9m
The page noted that semantic search "needs an extra" but not what happens without it. It does not fail: `search` returns `"status": "ok"` with `"search_mode": "fts"` -- keyword matching under a semantic name. A query whose terms appear literally still looks right, so the degradation is invisible until a conceptual query quietly returns nothing, which reads as "there is nothing there". The install line now takes `[embeddings]` and the setup adds the separate `embed` step, since building the graph does not populate vectors. Three `search_mode` values are documented -- `semantic`, `hybrid` (also correct) and the degraded `fts`. Also documents two traps that belong to the tool rather than the recipe: `detect-changes` reads its diff from the same `--repo` path it reads the graph from, so from a worktree it reports on main; and a symbol the graph never indexed is answered exactly like one with no callers, which only 2.3.8+ annotate with a `confidence` field. The network note now separates the local model -- downloaded once from Hugging Face, then run on-device, sending no repository content anywhere -- from the optional remote backends, which stay off for the reason already given. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBMRynZ8iqpC7JKvwdBw9m
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBMRynZ8iqpC7JKvwdBw9m
`token_safety_margin` gains a rung between the tiktoken-is-exact check and `DEFAULT_TOKEN_SAFETY_MARGIN`. When a provider has counted the request, the margin becomes the measured ratio of its count to our local one, rather than the 1.5 that covers the worst content anyone measured. Deliberately a ratio and not a total. The margin is applied per component, `BudgetPlan.total_input_tokens` is a computed property summing those parts, and the planner recounts on mutated text inside its trim loop. A recorded total stops being true the moment anything is trimmed; a ratio stays approximately valid. Clamped at 1.0: a provider counting below tiktoken may only reduce over-inflation, never reclaim window. Rejected outright on a model-name mismatch, a zero baseline, a non-positive count, or a non-finite ratio, each of which falls through to the existing ladder — so an absent or broken measurement is the 1.5 guess, by construction rather than by a second code path remembering. The "estimated, not exact" warning stays quiet only when the measurement actually supplied the margin. Inert until a caller passes one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HucxUJ7gaA6iKveF8aruW2
`_sized_for` re-derived the current model's safety margin from its name and config in order to restate a request in a candidate's units. That is correct only while every count comes from the same estimate-plus-default path. A provider-counted request carries a much tighter margin, and `token_safety_margin` cannot know that happened — it still answers 1.5. Dividing an already-tight number by a factor never applied understates the request by the whole difference, at the exact point the router is deciding whether a candidate can hold it. That is the ui-insight#648 defect — an estimate that reads low, so the request hard-fails — relocated to the routing boundary, which this function's own docstring says it exists to prevent. Callers may now state the margin their number was measured with. `choose_document_model` and `suggest_document_model` pass it through; the candidate's margin stays derived, because the candidate has not been counted. Omitted, every caller keeps today's arithmetic to the token. A supplied margin below 1.0, non-finite, or not a number at all is refused in favour of the honest derivation, matching how `context_budget._configured_margin` refuses the same thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HucxUJ7gaA6iKveF8aruW2
New `native_token_count`: fetches a provider's own count for a prepared request, or reports plainly that it could not. Two properties, and the second is the load-bearing one. A returned figure is the provider's own, unmodified. Anything going wrong returns nothing at all — the caller drops its safety margin on the strength of a usable result, so a count that comes back zero, short, or from a provider that counted half the request is worse than no count, converting a conservative over-estimate into a confident under-estimate. So: gated before any I/O on a protocol that can actually count, since `OpenAIModel.count_tokens` raises and every OpenAI-compatible model would otherwise decrypt a key and build a client per chat turn to learn that. Wrapped in a five-second timeout that is deliberately not the request's own settings — `build_thinking_model_settings` sets 120s and the shared client retries a 429 six times honouring Retry-After up to 60s, which would put a two-minute stall in front of every message. Blanket `except` by necessity: Anthropic raises ModelHTTPError, Google's genai errors are unwrapped, and a misconfigured protocol reaches OpenAIModel; there is no complete and stable union of those. CancelledError passes through, being the caller going away rather than a failure. Google's count omits the system instruction — pydantic-ai attaches `system_instruction` only for providers other than `google-gla`, and an api-key GoogleProvider is `google-gla`. `covers_system_prompt` says so rather than letting a caller silently drop a multi-kilobyte grounding preamble from its budget. Nothing imports this yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HucxUJ7gaA6iKveF8aruW2
Wires the native count into `chat_stream`. Before the request is sized, the provider is asked what the assembled prompt actually costs; the same payload is counted locally; the ratio becomes this turn's safety margin in place of the 1.5 guess. The payload counted is the whole user prompt, documents included. `_build_chat_prompt` is the existing assembly extracted verbatim so the pre-flight counts the request chat is about to make rather than a reconstruction of it — in document chat the documents are the payload, and they are also the digit-dense content the 1.5 was sized for. Sampling the question and history alone would measure prose and apply the answer to a budget table. The baseline covers exactly the components the provider counted, which differ by provider: Anthropic's count includes the system prompt, Google's does not for api-key providers. Counting it on one side and not the other would depress the ratio for a reason unrelated to tokenizer divergence, and a depressed ratio clamps to a margin of 1.0 over a tiktoken figure that itself under-counts these models — the hard-fail direction. Routing is given the margin the number was measured with, rather than re-deriving it. That value survives a model switch on purpose: it describes how `requested_input_tokens` was measured, and that does not change when the model does. A count for one model is not passed to the planner for another. `_native_margin` already rejects on a name mismatch, but relying on that silently is how the next person learns it the hard way. Anything unusable — ineligible protocol, timeout, 429, malformed response — leaves the stream exactly as it is today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HucxUJ7gaA6iKveF8aruW2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HucxUJ7gaA6iKveF8aruW2
…on catch it Extractions and workflows failed against a vLLM model registered under its HuggingFace repo name — "Native structured output is not supported by this model." on every run — while chat worked and the same weights under a bare name were fine. JSON-schema output is a property of the server: vLLM enforces `response_format` via guided decoding for anything it serves. The profile answered for the model family's own hosted API instead — `Qwen/Qwen3-32B` resolved through OpenRouter's family map to a profile leaving `supports_json_schema_output` at its False default, and pydantic-ai refused the request. VLLMProvider now declares the capability for every model it serves, keeping each family's own schema transformer. The per-model "supports structured output" toggle now does something. It was written by the model editor and read by nothing on the extraction path, so switching it off — the obvious escape hatch here — changed nothing. The reason none of this was visible: the admin Test button ran one free-text completion, so it went green for the entire outage. It now makes a second, schema-constrained round trip using the same output-mode decision the extraction engine makes (shared as one function, so the diagnostic cannot pass on a configuration a real run fails on), and reports a model that chats but cannot do that as failing. "Connected" and "usable" are different claims. Two supporting fixes from the same incident: * An unsupported output mode is classified with its two real remedies instead of falling through to "read the raw error". * The Endpoint step reports the URL actually dialed, not the one typed — the vLLM and Ollama providers append `/v1` to a stored endpoint and the OpenAI one does not, so changing the protocol dropdown silently changed the URL. Separately, found while building a model to test that: the external-OpenAI branch passed `openai_client=` to a constructor taking only provider/profile/ settings, so any model added through the "OpenAI" or "Custom" setup preset raised TypeError before a request left the server. No test built a model. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJ3BAa7MWo8CySegCumLEC
The module coalesced the failure log but not the failure work. A deployment whose egress to the count endpoint is slow, or whose org is rate-limited there, paid the full 5s timeout on every chat turn ahead of the first token -- permanently, and after the first occurrence it said so only at DEBUG. There was no metric and no way out short of a restart. Three consecutive failures now pause a model for five minutes. The check sits after the protocol gate, so an ineligible model never enters the circuit, and before any model construction, key decryption or request, so an open circuit costs nothing. One request is let through when the cooldown elapses, and any success clears the record, so an outage cannot disable counting until the next deploy. State is per model name: a failing Anthropic endpoint does not stop Gemini being counted. The clock is monotonic, so a clock adjustment cannot strand a model in cooldown. Skipping is strictly better than blocking here: the estimate path it falls back to is the one that shipped before this feature, so a paused model gets the same budget it would have got anyway, without the wait. Two corrections to the changelog, both things it asserted that the code does not do: - "fires only for models an admin explicitly configured" is not true of Gemini. detect_api_protocol routes any name containing "gemini" to google on its own, so a deployment already running one gets the pre-flight on upgrade without changing anything. Issue ui-insight#816 said this correctly and the entry lost it. - "one unconditional round trip" is no longer true, which is the point of this commit. The existing failure-logging tests needed updating rather than working around: _CIRCUIT is per-process state exactly like _UNAVAILABLE_LOGGED, so it joins that autouse reset -- without it, whether a test sees an open circuit depends on collection order. The repeated-failure test now runs 50 turns rather than 4, pinning the bound instead of an arithmetic coincidence. Verified by disabling only the gate while keeping the state: the provider is called on all 10 turns and the test fails on the count. Stashing the whole module instead only proves a symbol is missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtRYNNEdrq6SPyPt8PjH1v
|
Reviewed and pushed one commit. This is careful work — the fail-closed design is genuinely thorough, the blast radius is narrow ( What I added: a circuit breaker. The module coalesces the failure log but not the failure work. A deployment whose egress to the count endpoint is slow, or whose org is rate-limited there, pays the full 5s timeout on every chat turn ahead of the first token, permanently — and after the first occurrence says so only at DEBUG. No metric, no way out short of a restart. Three consecutive failures now pause a model for five minutes. The check sits after the protocol gate (so an ineligible model never enters the circuit) and before any model construction, key decryption or request (so an open circuit costs nothing). One request goes through when the cooldown elapses; any success clears the record, so an outage cannot disable counting until the next deploy. Per model name, monotonic clock. Skipping is strictly better than blocking here — the estimate path it falls back to is the one that shipped before this feature, so a paused model gets the same budget it would have got anyway, without the wait. Two changelog corrections, both things the entry asserted that the code does not do:
Your existing failure-logging tests needed updating rather than working around: Two things I did not changeThe payload is uploaded twice per turn. A merge-forward hazard for |
…ktree
Three things on top of the worktree fix, which is right.
`awk '/^worktree /{print $2}'` splits on whitespace, so a checkout at
"/Users/me/My Repos/vandalizer" resolved to "/Users/me/My". That is not a
cosmetic truncation: the graph.db test then fails, the recipe takes the
build branch, and `build --repo /Users/me/My` points the tool at a
directory outside the repository. `sed -n '1s/^worktree //p'` takes the
rest of the line whatever it contains.
The Setup block's `code-review-graph embed` carried no --repo, so it had
exactly the bug this PR fixes: run from a worktree it discovers the
worktree, creates a second graph there -- the directory the paragraph
below tells you to hunt down and delete -- and the shared graph never
gets vectors, so search keeps answering in fts mode with nothing saying
why. The PR's own defect survived its own fix.
One graph shared by every worktree means concurrent refreshes now contend
for one SQLite file, where per-worktree databases could not. This repo is
routinely worked from several worktrees at once, so that is the normal
case rather than an edge one. The recipe takes an flock where one exists;
macOS ships none, so the docs say to refresh one worktree at a time
there. `set --` builds the argument list once so the locked and unlocked
branches cannot drift.
The docs also now say what pinning --repo costs: the graph reflects the
main checkout's HEAD, so a symbol added on a worktree branch is absent --
and an absent node is indistinguishable from a real "no callers" answer,
which is the failure this tool is most likely to mislead a reviewer with.
Verified: the sed keeps "/Users/me/My Repos/vandalizer" whole where the
awk truncates it; the path survives as a single argument; make parses the
recipe; review-graph is still a prerequisite of nothing and CI still
never runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtRYNNEdrq6SPyPt8PjH1v
…ot the workspace /certification is a redirect that opens the panel in whatever mode the origin window last persisted, so the 1080x860 pop-out showed the entire workspace with a floating panel. The pop-out button now asks for fullscreen via ?panel=fullscreen and the redirect honours it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qk88vvuDRJz5Nft2pRNpeY
… own workflow bookmark
LibraryItem.find_one({item_id}) matched any bookmark of that object,
including a teammate's team-library row, so a workflow the user had
removed from their library could still be reported as listed in it.
Filter by kind and by who added it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qk88vvuDRJz5Nft2pRNpeY
…tes up front mkdir -p runs before the tool's first build, so the tool may never write its own .gitignore into the directory; without this the flock lock file shows up as untracked. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qk88vvuDRJz5Nft2pRNpeY
…tually breaks Only steps that route through the extraction engine use NativeOutput; LLM prompt steps answer in free text and keep working. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qk88vvuDRJz5Nft2pRNpeY
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qk88vvuDRJz5Nft2pRNpeY
…r Unreleased, not v4.12.0 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qk88vvuDRJz5Nft2pRNpeY
|
Merged after review. Two things the review found that were not blocking, recorded here as follow-ups:
Committed on the branch before merge: the boolean-margin warning in |
Summary
Item 3 of the plan you set out in #629: "For hosted models, use provider-native counting where it exists, falling back to the current margin where it doesn't." Items 1 and 2 landed as #648/#719 and #774.
DEFAULT_TOKEN_SAFETY_MARGINis 1.5 because that covers the densest content anyone measured — a currency-dense budget table diverging ~45% fromcl100k_base. Applied to everything, a hosted model answering prose plans against a third less window than it has, and crosses the #632 routing threshold early. Anthropic and Google publish exact counts, so where one is available the margin is now measured instead of assumed.It is a ratio, not a substituted total, and the margin does not become 1.0. I said 1.0 in the first draft of #816 and that was wrong — three things in the existing code prevent it:
estimate_input_tokens:795-806), andBudgetPlan.total_input_tokens:567is a computed property summing those parts. There is no slot for a total that isn't the sum of its components.:981-1052). A network counter can't live there, and a count of the original text is void once the text is cut.REQUEST_SCAFFOLD_TOKENSexists to absorb.So: count the assembled prompt, count the same payload locally, and use
max(1.0, provider / local)as that request's margin. A ratio stays approximately valid as content is trimmed where a total does not.Changes
backend/app/services/native_token_count.py(new) — fetches a provider's count, or reports plainly that it couldn't. Gated on protocol before any I/O, sinceOpenAIModel.count_tokensraises and every OpenAI-compatible model would otherwise decrypt a key and build a client per turn to learn that. Five-second timeout, deliberately not the request's own settings —build_thinking_model_settingssets 120s and the shared client'sRateLimitRetryTransportretries a 429 six times honouringRetry-Afterup to 60s, which would put a two-minute stall in front of every message. Blanketexceptby necessity: Anthropic raisesModelHTTPError, Google'sgenaierrors are unwrapped, a misconfigured protocol reachesOpenAIModel; there's no complete and stable union of those.CancelledErrorpasses through, being the caller leaving rather than a failure.backend/app/services/context_budget.py—NativeCountplus a rung between the tiktoken-is-exact check and the 1.5 default, so it only ever displaces the guess. Rejected on model-name mismatch, zero baseline, non-positive count, or non-finite ratio, each falling through to the ladder. Clamped at 1.0: a provider counting below tiktoken may reduce over-inflation, never reclaim window. The "estimated, not exact" warning stays quiet only when a measurement actually supplied the margin. No new imports — the module stays a dependency leaf.backend/app/services/model_routing.py—_sized_forre-derived the current model's margin to restate a request in a candidate's units. Handed a natively-counted number it would still answer 1.5 and divide by a factor never applied, understating the request by a third exactly where the router decides whether a candidate can hold it — the #648 defect at the routing boundary, which that function's docstring says it exists to prevent. Callers now state the margin their number was measured with.backend/app/services/chat_service.py— the wiring._build_chat_promptis the existing assembly extracted verbatim, so the pre-flight counts the request chat is about to make rather than a reconstruction. The payload counted is the whole prompt including reference documents: in document chat the documents are the payload, and they're the digit-dense content the 1.5 was sized for — sampling question and history alone would measure prose and apply the answer to a budget table.Three things I'd want you to push back on
The pre-flight is unconditional. One serialized round trip ahead of the first token on every eligible turn. Gating it on "the estimate is near a boundary where the answer could change" would be cheaper, but it makes is this count exact? depend on request size, which is hard to reason about later. I have no latency data for either count endpoint — if you do, that's the number that should decide this.
The reach is narrower than "Claude and Gemini".
detect_api_protocol:372routes a bareclaude-*name to the OpenAI-compatible path, and the OpenRouter / Insight-AI branches produceOpenAIModel, which has no count endpoint. This fires only for models an admin explicitly configuredapi_protocol: anthropicorgooglewith a direct provider key — which theModelEditorpresets do create. I deliberately did not add a symmetric auto-route forclaude-*: that would change which client every existing Claude deployment uses, a behaviour change well beyond token counting.Google's count omits the system instruction. pydantic-ai attaches
system_instructiononly for providers other thangoogle-gla, and an api-keyGoogleProviderisgoogle-gla. Rather than fudge it, the system prompt is excluded from both sides of the ratio. Counting it on one side only would depress the ratio for a reason unrelated to tokenizer divergence — and a depressed ratio clamps to margin 1.0 over a tiktoken figure that itself under-counts these models, which is the hard-fail direction.Also deliberately untouched:
stored_count_margin. It answers "how safe is a count someone else took earlier, when I don't have the text", and a provider count requires the text — sofind_oversize_documentsandfind_context_overflowkeep the 1.5 allowance.Test Plan
make ci) —make backend-cilocally: 4438 passed, 161 skipped, coverage 64.38% against the 50% gate, tier-1 integration 14 passed. Baseline on this branch point was 4410 passed; +28 accounts exactly for the tests added. I could not runmake frontend-cilocally (npm is not on myPATH) and said so when opening this; CI's Frontend job has since run and passed, along with the other ten checks. Nofrontend/file is touched by this PR.make release-check) — not applicable. No packaging or deployment path changed.tokenizer.json, so nothing is on the guessed rung and there is no Claude or Gemini model to exercise. No live provider count endpoint was called at any point. What is covered, against stubbed providers: a good Anthropic count, a good Google count,NotImplementedErrorfrom anOpenAIModel, timeout,ModelHTTPError/429, a malformed usage object, and every ineligible protocol asserting that no client is constructed at all. Live verification needs a deployment holding direct provider keys.CHANGELOG.mdfor user-facing or operator-facing changesRelated Issues
Closes #816.
Adjacent, filed separately and not addressed here: #817 —
update_modelreplaces the whole model dict, sotoken_safety_margin,tokenizer_pathandtokenizer_cache_rootare deleted by the next save from the admin form. It constrains this work (any per-model opt-in would be wiped), which is why the pre-flight is gated onapi_protocol— an existing field the form does manage — rather than on a new one.🤖 Generated with Claude Code
https://claude.ai/code/session_01HucxUJ7gaA6iKveF8aruW2