A lightweight reverse proxy that sits between an OpenAI-compatible client
(e.g. Cline's "OpenAI Compatible" agent) and a local llama-server
instance or remote provider. It classifies each chat completion request by task type,
injects sampling/reasoning parameters accordingly, detects bad outputs
(repetition, truncation, invalid JSON in fenced code blocks), retries with
adjusted parameters, and shows a live terminal dashboard.
No Mirostat. Mirostat was evaluated and rejected for this use case — it requires a full-vocabulary sort/entropy calculation on every token, which is CPU-bound and roughly halved generation speed in testing. All dynamic behavior comes from bounded temperature + top-p/top-k + reasoning-budget control instead, at full decode speed.
go build -o llama-dyn-proxy .Produces a single static binary.
# start llama-server first, e.g.:
llama-server --port 9091 ...
# then start the proxy
./llama-dyn-proxyOn first run it creates config.ini next to the binary with sane defaults
and logs that it did so. Edit config.ini and restart to change
classification keywords, presets, or detection thresholds — no code
changes required.
Point Cline's "OpenAI Compatible" provider at http://127.0.0.1:9090/v1
(or whatever listen_port you configured).
Ctrl+C shuts down the HTTP server cleanly and exits the dashboard.
The dashboard runs in the terminal's alt-screen (like vim/htop) — your
shell's prior content is restored when it exits, and it redraws within a
proper full-screen viewport instead of relying on terminal scrollback.
Diagnostics go to proxy_<backend>.log, not the terminal. Once the
dashboard starts, it owns the entire display — any log.Printf output
from that point on (streaming/usage warnings, passthrough errors, HTTP
server errors, ...) would otherwise be silently overwritten by the next
redraw, never actually visible even briefly. The proxy prints this log
file's path once at startup, before the dashboard takes over:
llama-dyn-proxy
listening on http://127.0.0.1:9090
backend local (http://127.0.0.1:9091)
log /path/to/proxy_local.log
Startup failures (bad config, unknown provider, can't bind the port) still print directly to the terminal, since those happen before the dashboard exists to steal the display.
By default the proxy talks to the local llama-server configured in
[server]. Pass a provider name as the first CLI argument to redirect the
same Cline session to a remote OpenAI-compatible backend instead, without
touching Cline's own settings:
./llama-dyn-proxy openrouterValid names are claude, gemini, openai, openrouter — each configured
under its own [provider.<name>] section (listen_port, base_url,
api_key, optional model) right below [server] in config.ini. An
unrecognized name fails fast with a clear error listing the valid options,
rather than silently falling back to local.
Each provider has its own listen_port (9092, 9093, 9094, 9095 by
default), separate from [server]'s. That means you can run several
instances of this proxy side by side — one per backend — each on a stable,
predictable port, and point different Cline profiles at whichever one you
need without them fighting over the same port:
./llama-dyn-proxy & # local llama-server, [server].listen_port
./llama-dyn-proxy openrouter & # :9095
./llama-dyn-proxy openai & # :9094API keys: {PROVIDER}_API_KEY in the environment (e.g.
OPENROUTER_API_KEY, OPENAI_API_KEY, CLAUDE_API_KEY, GEMINI_API_KEY)
always wins over api_key in config.ini, so a real key never has to sit
in plaintext on disk:
OPENROUTER_API_KEY=sk-or-v1-... ./llama-dyn-proxy openrouterNote these are the proxy's own env var names, not necessarily each
provider's SDK-standard one (Anthropic's own tooling looks for
ANTHROPIC_API_KEY, not CLAUDE_API_KEY). If the resolved key is empty
from both sources, the proxy logs a warning and proceeds anyway (requests
will likely be rejected by the provider).
CORS: every response carries permissive Access-Control-Allow-Origin: *
headers, and OPTIONS preflight requests get a clean 204. Cline runs in
the VS Code extension host and is never subject to CORS, but browser-based
clients (e.g. Page Assist) are — without this, the browser silently
discards an otherwise-successful response before the client's own JS ever
sees it, which looks like an empty model list even though the proxy did
everything right. This is a local, single-user dev proxy, so a permissive
* origin carries no real multi-tenant risk.
A bare GET /v1 (some clients, e.g. Page Assist, ping the base URL itself
as a reachability check before calling /v1/models) gets a 200 rather
than the generic 501 below — there's no real provider endpoint to proxy it
to, so the proxy just answers it directly.
In remote-provider mode, only /v1/chat/completions and /v1/models
are proxied — /v1/models (Cline calls this on startup to populate its
model picker) forwards to <base_url>/models with auth; anything else,
including /completion (llama.cpp's native endpoint) and generic
passthrough (/health, /props, ...), returns a clear 501 instead of
being silently mis-routed, since each provider mounts its
OpenAI-compatible surface at a different URL prefix and passthrough can't
be mapped reliably across all of them from one code path. Rejected paths
are logged to stdout so a failure like "Cline can't connect" is
diagnosable even though it never reaches the dashboard's request log
(that panel is reserved for the classify/inject/detect/retry cycle on the
chat-completions route). Classification, preset injection, and
retry/detection all still run normally on chat completions — but
repeat_penalty/DRY/top_k/thinking_budget_tokens are llama.cpp sampler
extensions; real providers generally ignore fields they don't recognize
rather than error, so retries still classify and re-send correctly, but
those specific adjustments won't have any real effect against a
non-llama.cpp backend.
-
Only
/v1/chat/completionsand/completionget special treatment; every other path (/health,/props, ...) is passed straight through to the upstreamllama-server. -
The latest user-role message is matched (case-insensitive substring) against the keyword lists in
[classification]to pick a task bucket (strict_code,exploratory_code,explanation,architecture). If several buckets match,priority_orderbreaks the tie; if none match,default_bucketis used. -
The matching
[preset.<bucket>]values (temperature,top_p,top_k,thinking_budget_tokens) are merged into the outgoing request body, but only for fields the client did not already set explicitly — an explicit client value always wins.repeat_penalty, DRY, andmin_pare never injected on this initial pass —min_pis left tollama-server's own adaptive default, andrepeat_penalty/DRY are reserved for the retry path only, so a clean first-pass request never carries them. -
For
/v1/chat/completions, the request is always sent upstream with"stream": true(plusstream_options: {include_usage: true}), regardless of what the client itself asked for — the inverse of this proxy's original "always force stream:false" behavior. This is what makes the dashboard's live activity indicator possible (see "Live activity indicator" below): the proxy observes generation as it happens and accumulates the full text internally, so detection/retry below works exactly as before. If the original client didn't ask for streaming, a completechat.completionJSON object is synthesized from the accumulated deltas once the stream ends; if it did, the accumulated text is re-chunked back into SSE — includingreasoning_content(the model's thinking/plan trace, kept distinct from the final answer per the DeepSeek-style conventionllama-serverand Cline both use) as its own delta chunk, so clients that render reasoning separately from the answer (e.g. Cline's Plan Mode) still see it. Re-chunking splits by rune count, not by words: an earlier version split onstrings.Fields(any run of whitespace) and rejoined pieces with a single ASCII space, which silently destroyed every newline, indentation level, and multi-space run in the response — reported live as "any formatting is lost, including code formatting," since it only affected clients that request streaming (Cline does, by default). A non-streaming request to the same backend looked completely normal, which is what made it easy to miss — the corruption only happened in this re-chunking path, never in the underlying content.chunkTextnow slices the original string directly (by rune, so a multi-byte UTF-8 character never gets split across two chunks), so concatenating every delta reproduces the response exactly./completion(llama.cpp's native endpoint, local-only) is unaffected by any of this — it keeps the original single blocking, non-streaming call, since it's a low-traffic legacy path and localllama-serveralready gives console-level progress visibility (GPU utilization,tg = N t/s, ...) that a remote provider never does. -
The response is checked for repetition (sliding n-gram window), truncation (
finish_reason == "length"plus unbalanced braces/brackets/parens or an unterminated fence/string), and — best effort — invalid JSON inside fenced```jsonblocks. -
On an issue, if retries remain, the proxy adjusts the relevant sampling parameter and resends the identical messages/prompt (only sampling params change, so
llama-server's per-slot KV cache is reused). See[retry]inconfig.ini:- repetition → if
prefer_dry_over_repeat_penalty(default): setsdry_multiplier/dry_base/dry_allowed_lengthfrom config, since DRY only fires on genuine verbatim loops and leaves ordinary code repetition (variable names, imports) untouched. Otherwise falls back torepeat_penalty += repeat_penalty_increment. - truncated →
max_tokens *= max_tokens_retry_multiplier, capped atmax_tokens_ceiling— unaffected byretry_step_exponentbelow, since it already compounds naturally attempt over attempt (each retry multiplies the previous attempt's value, not a fixed base). The starting point for that multiplication is the real number of tokens the model actually generated (usage.completion_tokensfor the OAI chat endpoint,tokens_predictedfor llama.cpp's native/completion), not a guess — no preset ever injectsmax_tokens, so there's usually nothing already in the request body to read on the first truncation. Guessing a fixed fallback here previously meant the "escalated" cap could already be smaller than what the backend's own default limit had already produced, guaranteeing the retry got truncated again too — that fallback (512) is now only used if the response genuinely has no usage info to read. - bad_syntax →
temperature = max(temperature_floor, temperature - temperature_decrement_on_bad_syntax)— the floor exists specifically so repeated bad-syntax retries can never land on exactly 0 (greedy/ degenerate decoding).
Within one request, each successive retry scales its repeat_penalty increment, DRY multiplier, or temperature decrement by
retry_step_exponent^attempt(attempt 0 = first retry). The default,1.0, means every retry takes the same flat step — the original behavior. Raising it makes later retries within the same request take bigger steps than earlier ones, so a request that needs several retries escalates instead of repeating the same small nudge. See "Tuningretry_step_exponent" below for how to find a good value instead of guessing. - repetition → if
-
Every request/response cycle pushes an event to the dashboard over a channel — proxy handling never blocks on UI redraw.
-
When a remote provider is active, a configured
modelin its[provider.<name>]section always overrides whatever model name the client requested (a local model name is almost certainly invalid on a remote API); leavingmodelblank passes the client's request through unchanged. The outbound request always targets exactly<base_url>/chat/completions— never the client's own incoming path — since most providers'base_urlalready ends in/v1(or, for Gemini,/v1beta/openai), and naively appending the client's/v1/...path would double that prefix.
There's no principled default for this yet — it needs real data from your
model/workload, not a guess. Every request that retries at least once gets
its full trajectory appended as one JSON line to retry_log_<backend>.jsonl
next to the binary (e.g. retry_log_local.jsonl, retry_log_openrouter.jsonl
— one file per backend, since retry behavior isn't comparable across
different models). A clean first-pass request logs nothing; there's no
trajectory to record when nothing needed adjusting.
Each line looks like:
{
"timestamp": "2026-07-09T15:04:05Z",
"bucket": "strict_code",
"step_exponent": 1.0,
"adjustments": [
{"attempt": 0, "issue": "repetition", "param": "dry_multiplier", "old_value": 0, "new_value": 0.8, "detail": "the exact n-gram that repeated"}
],
"resolved": true,
"total_attempts": 1,
"latency_ms": 842
}detail says what triggered the detection — for repetition, the exact
n-gram that repeated (also logged to proxy_<backend>.log at detection
time). This is the first thing to read when retries seem too frequent:
agentic clients like Cline produce output full of legitimately repeated
structure (XML tool tags, file paths, boilerplate phrasing), which can
trip the n-gram detector on perfectly healthy content. A false-positive
retry is expensive — the client gets nothing until every attempt
finishes, so each one silently adds a full extra generation of latency
per step, which from the client's side looks like hanging. If the logged
n-grams are real degenerate loops, the retry was earned; if they're tool
tags or paths, tune [detection] (repetition_ngram_size up,
repetition_min_repeats up) rather than blaming the sampling params.
This exact scenario happened in practice and drove a detector change: the
first detail-logged entries showed a perfectly normal JS debug-print
line (console.log(' - ' + c.id + ...)) that legitimately appeared 3
times in a generated script — flagged as "repetition", costing a full
extra generation per request (121s in one case), with the DRY retry then
actively penalizing the model for writing correct repeated code. The fix:
repetition_window_words (default 96) requires all
repetition_min_repeats occurrences to fall within that many words of
each other before counting as degeneration. Real stuck loops emit their
repeats back-to-back (well inside the window); legitimate code repeats
scatter across the file (outside it). The check applies to the most
recent N occurrences, so earlier scattered legitimate repeats don't mask
a genuine loop that starts later in the output. Set it to 0 to restore
pure occurrence counting.
The window alone wasn't enough, though: a second confirmed false positive
showed a code-generator response building output like
+ NL + I1 + '}' + NL + NL + I1 — string-concatenation of newline/indent
constants, which is inherently dense and clustered even when completely
legitimate (that's what code-formatting logic looks like). No spacing
heuristic can tell that apart from a real loop, because it isn't scattered
to begin with. What does tell them apart: a genuine degenerate loop
doesn't stop on its own — it cycles until it exhausts the token cap
(finish_reason: "length") — while every confirmed false positive so far
came from a generation that completed normally ("stop"). So
repetition_requires_length_finish (default true) gates repetition
detection on finish_reason == "length" entirely: a normally-completed
response is never scanned for repetition at all, however dense its
legitimate repeats are. Set to false to go back to scanning every
response regardless of how it finished.
resolved: false entries (retries exhausted without fixing the issue)
matter as much as successes — they're the signal that an exponent was too
timid for that many retries to still not work, same as consistently
resolving in 1 attempt at a low exponent is the signal it's already fine
and doesn't need raising. Compare resolved rate and total_attempts
across runs at different retry_step_exponent values to find one that
converges faster without overshooting into values that hurt output
quality (e.g. too high a repeat_penalty suppressing legitimate repeated
tokens like variable names).
Bug found via real trajectory data (fixed): at the shipped default
retry_step_exponent = 1.0 (and prefer_dry_over_repeat_penalty = true,
also the default), a second consecutive repetition retry within the same
request used to compute dry_multiplier from scratch each time
(dry_multiplier_on_retry * step) instead of building on the value the
previous attempt had already set. Since step = step_exponent^attempt is
1^attempt = 1 for every attempt at the default exponent, this reproduced
the exact same dry_multiplier on every retry — e.g. two consecutive
entries both showing "old_value": 0.8, "new_value": 0.8. A retry that
resends identical sampling params has nothing but token-level randomness
to save it, which matches multi-minute latencies observed on requests that
needed 2 attempts to resolve. Fixed to escalate additively off the real
accumulated value (old + dry_multiplier_on_retry*step), the same pattern
repeat_penalty's branch already used — so now attempt 1 genuinely
produces a higher dry_multiplier than attempt 0, even at the default
step_exponent = 1.0.
Underneath the bars, the dashboard shows what's actively generating right now:
generating (strict_code, attempt 1)... ~340 tokens (est) · 4.2s elapsed · ~62.4 tok/s (est)
or idle when nothing's in flight. This exists specifically for remote
providers: local llama-server already gives you GPU utilization and a
live tg = N t/s console log to tell whether it's working or stuck; a
remote provider gives you nothing until the whole response comes back,
which previously meant "thinking..." for anywhere from a few seconds to
several minutes with no way to tell the difference between normal latency
and a stalled/rate-limited request.
The elapsed timer starts ticking before upstream sends a single byte,
not just once content starts arriving — p.client.Do blocks until
response headers come back, and for a provider that queues a request
before generation starts (OpenRouter's free-tier rate-limiting is exactly
this), that wait can be most of the total latency. A background ticker
emits periodic "still waiting, N seconds elapsed, 0 tokens so far" updates
during that phase specifically so it doesn't look identical to no request
being in flight at all (regression: this originally only started tracking
once Do returned, so the whole pre-first-byte queueing wait showed
idle the entire time even with a message actively running in Cline).
That still leaves one gap the first fix didn't cover: Do only blocks
until response headers arrive, not until the body has anything in
it. Some upstreams flush headers immediately, then spend a long time
evaluating the prompt (prefill) before writing the first SSE line —
scanner.Scan() blocks synchronously for that whole window with nothing
else running, which would silently reproduce the exact same "looks idle
while a request is genuinely in flight" bug one layer deeper (real
example: local llama-server took 40+s on a large prompt before the
first token, entirely inside this window). The SSE reader now runs on
its own goroutine feeding a channel, so the main loop can select
between an incoming line and a periodic ticker tick — the same
"processing prompt" state as the pre-headers wait now also covers the
post-headers, pre-first-line gap, however long it turns out to be. The
reader goroutine always drains to a real EOF/error rather than being
cancelled the moment [DONE] is seen, so it can never block forever
trying to hand off a line nobody's reading (verified under go test -race, including a misbehaving-upstream case that keeps sending lines
after [DONE]).
The tok/s rate is computed from a separate clock that only starts once
the first real token arrives, not from total elapsed time — total
elapsed includes that same connection/queueing/prompt-processing wait,
and dividing total tokens by total elapsed would silently understate the
real decode speed (a request that waits 5s then generates 50 tokens in 3s
of actual decode is a 16.7 tok/s model, not 6). Before the first token
arrives, the line reads processing prompt (...) · N.Ns elapsed instead
of showing a rate at all — that pre-generation wait is largely upstream
actually evaluating the prompt (prefill), not idle network time, so the
label says that rather than the vaguer "waiting for upstream" (confirmed
locally: a ~54k-token prompt took 40+s of prompt processing before the
first generated token). There's still no way to show a live prompt t/s
figure during this phase — no signal for prompt-eval progress exists
until either the first token or the final usage object arrives — but
naming the phase correctly at least tells you what's actually happening
instead of leaving it ambiguous.
The token count is an estimate based on the number of SSE delta events
received, not an exact tokenizer count — an exact count needs the
model's own tokenizer, which isn't available for arbitrary remote models.
For llama.cpp's streaming loop (and most OpenAI-compatible streaming
implementations), each delta is exactly one generated token, so this
tracks the real count closely in practice — don't read it as ground truth
the way llama-server's own n_decoded is, but it's a much better
estimate than it sounds. An earlier version counted whitespace-separated
words instead, which badly undercounts code (brackets, operators, and
identifiers each tokenize separately without being split by spaces) —
that version reported roughly 3-4x below the server's own logged rate for
code-heavy generations.
tokens_sec_multiplier (per-provider, config.ini): the
one-delta-equals-one-token assumption above is verified against
llama-server's own source, but there's no such guarantee for a remote
provider — some batch more than one token into each streamed delta, which
would make the live estimate undercount, and the reported tok/s read
artificially low (e.g. a paid model that never seems to top ~25 tok/s on
the dashboard even though it's genuinely faster). There's no generic way
to detect this per-provider automatically, so it's a manual correction
factor in each [provider.*] section:
[provider.openrouter]
...
tokens_sec_multiplier = 1.0Defaults to 1.0 (no correction) everywhere, including local
llama-server (which has no [provider.*] section at all, so it always
gets the no-op). If you suspect a specific provider's live number is off
by a roughly constant factor, compare it against that provider's own
reported speed (dashboards, response headers, etc.) over a few requests
and set the multiplier accordingly — e.g. 2.0 if every delta actually
carries about two tokens. This only rescales the live in-flight
estimate; the "last request: N prompt + M completion" line stays exact,
since it comes from the real usage object, not the chunk count.
This only applies to /v1/chat/completions (see "How it works" above for
why /completion is out of scope) and updates at most 4x/second
(progressEmitInterval in stream.go) — fast enough to feel live,
throttled enough not to flood the terminal with redraws on a very fast
stream.
Once a request finishes, a second line shows its exact token counts:
last request: 727 prompt + 1437 completion = 2164 tokens
Unlike the live indicator's estimate, these come straight from upstream's
own usage object (stream_options: {include_usage: true} is set on
every outgoing streamed request specifically so this is available) — real
numbers, not a word count. The catch: usage only arrives in the final
SSE chunk, right before [DONE], so there's no way to show live
prompt-processing progress the way generation gets one — prompt token
counts are only ever knowable once the whole response is already back.
This line is blank until the first /v1/chat/completions response
completes (kindCompletion and connection-error events never populate it).
If it stays blank, or completion tokens show but prompt tokens read
0: that means upstream isn't sending prompt_tokens in its usage
object, despite stream_options.include_usage being requested — this can
happen with some free-tier/proxied models (or particular llama-server
forks/builds) even when the general API is documented to support it. The
proxy logs this to proxy_<backend>.log (see above — not the terminal,
once the dashboard is running) so it's diagnosable instead of silently
wrong:
stream (strict_code, attempt 0): upstream never sent a usage object despite stream_options.include_usage being requested — prompt/completion token counts will show as 0
or, if usage arrived but was missing just the one field:
stream (strict_code, attempt 0): usage object present but has no prompt_tokens field; raw usage: map[completion_tokens:1437]
There's nothing the proxy can do to recover a number the upstream never sent — no local tokenizer to fall back to — but at least it's visible which case you're hitting rather than a silent zero.
main.go // wiring: load config, resolve backend (local/provider), start HTTP server + dashboard, graceful shutdown
config.go // load/parse config.ini, write defaults if missing
classify.go // task classification
proxy.go // reverse proxy handler, param injection, retry loop, remote provider routing, retry trajectory logging
stream.go // always-stream-upstream for /v1/chat/completions, SSE parsing/accumulation, live progress events, response re-chunking/synthesis
detect.go // repetition/truncation/syntax checks
ui.go // bubbletea dashboard model, live activity indicator
config.ini // generated with defaults on first run
retry_log_<backend>.jsonl // one JSON line per request that retried at least once (see "Tuning retry_step_exponent" above)
proxy_<backend>.log // all log.Printf diagnostics once the dashboard is running (see "Run" above — the dashboard's alt-screen would otherwise swallow them)
go test ./...Covers classification priority/defaults, repetition/truncation/syntax
detection, the retry-on-repetition flow against a fake upstream (both the
DRY-preferred path and the repeat_penalty fallback), the
client-value-always-wins merge rule, the temperature floor on repeated
bad-syntax retries, the "clean requests carry no reactive params" rule, the
connection-refused path (dashboard gets an error event, proxy returns 502,
nothing crashes), the "upstream error never wrapped in a fake SSE stream"
regression, remote-provider request routing/auth/model-override, the
/v1/models proxying, the {PROVIDER}_API_KEY env var overriding
config.ini (and working even with no [provider.*] section present at
all), per-provider default/overridden listen_port assignment, CORS
headers being present on every response and preflight OPTIONS requests
getting a clean 204, reasoning_content surviving SSE re-chunking as its
own delta chunk, the CLI provider-selection logic in main.go,
retry_step_exponent scaling repeat_penalty/DRY/temperature adjustments
correctly (and specifically not leaking into max_tokens's own
multiplier), the retry trajectory log itself — written on retries that
resolve and ones that exhaust without resolving, skipped on a clean first
pass, and safe under concurrent requests (verified with go test -race) —
the dashboard's log line formatting always fitting one row regardless of
how long/verbose an upstream error is (regression: a long error body
word-wrapped a single log entry into several rows, and with only entry
count capped rather than rendered height, that pushed the bars panel
above it off the top of the terminal — bubbletea's inline renderer can't
reposition past whatever the terminal actually has room to show), and the
truncation retry escalating from the real usage.completion_tokens /
tokens_predicted instead of a hardcoded 512 guess (regression: found via
real retry_log_*.jsonl data showing the exact same truncation sequence
repeating twice, exhausting retries without ever resolving, because the
old guess could already be smaller than what the backend's own default
cap had already produced), and the always-stream-upstream mechanism for
/v1/chat/completions — content/reasoning_content/finish_reason/
completion_tokens correctly accumulated from a multi-chunk SSE response,
live progress events firing during a stream (and the final one marked
Done), a non-2xx status read as a raw error body rather than fed through
the SSE parser, /completion confirmed to still force stream:false and
never emit progress events (unaffected by any of this), the dashboard
model correctly clearing its in-flight indicator on a Done event, real
prompt/completion token counts from upstream's usage object surfacing
correctly on the completed-request UIEvent, and — the regression that
motivated it — a progress event firing with a ticking elapsed time (zero
tokens, not Done) while still waiting for upstream's first byte, so a
provider that queues a request before generation starts no longer looks
identical to idle, and GenerationElapsedMs correctly excluding that
same pre-first-byte wait (a slow-to-start-but-fast-to-decode upstream
reports a rate close to its real decode speed, not artificially deflated
by connection/queueing time it had nothing to do with). All of this is
verified clean under go test -race across repeated runs, including a
real synchronization bug the first version of the waiting-ticker fix had:
the background ticker goroutine wasn't guaranteed to have fully exited
before postUpstreamChatStreaming returned, so it could still be
mid-emitProgress after the caller had moved on — fixed by waiting on the
goroutine's own exit signal, not just closing its stop channel. Also
covers ApproxTokens tracking real per-token SSE delta count rather than
whitespace word count — regression test simulates 10 real token-level
deltas that concatenate into only 2 whitespace-separated words, and
asserts the reported count is 10, not 2 (the exact shape of the
undercounting bug that showed ~20 tok/s against a server logging ~70).
Also covers the truncation retry's logged max_tokens value matching the
integer actually sent upstream rather than the pre-truncation float
(regression: found in real retry_log_*.jsonl data logging
new_value: 457.5 — adjustForIssue correctly sent int(old * multiplier) but reported the un-truncated float in the trajectory log,
an impossible fractional token count that didn't match what upstream
actually received). Also covers tokens_sec_multiplier: config parsing
defaults it to 1.0 both when a [provider.*] section exists but omits
the key and when the section is absent entirely, an explicit override
value is read correctly, the live chunk-based ApproxTokens estimate is
scaled by the configured multiplier (verified with a mocked stream with
no usage object, forcing the chunks fallback rather than real
completion_tokens), and tokensSecMultiplier() treats a zero-value
ProviderConfig (as built by several other tests' struct literals, and
as Go's zero value would otherwise silently be) as "unset" and returns
the no-op 1.0 rather than a real 0x multiplier that would always
report zero tokens. Also covers the post-headers, pre-first-line
progress gap: a progress event fires (elapsed ticking up, zero tokens,
not Done) while upstream has already sent response headers but hasn't
written the first SSE body line yet — simulated with a handler that
explicitly flushes a 200 response before sleeping, so p.client.Do
returns quickly and the old code would've blocked silently inside
scanner.Scan() with no ticker running. And a goroutine-leak regression:
a misbehaving upstream that keeps sending lines after [DONE] must not
hang postUpstreamChatStreaming — the reader goroutine has to keep
draining to a real EOF rather than being cut off the moment [DONE] is
seen, or it'd block forever handing off a line nobody's reading. Also
covers dry_multiplier escalating across two real consecutive attempts
sharing one body map (matching how the actual retry loop in
handleClassified mutates body in place), at the shipped default
retry_step_exponent = 1.0 — the regression test for real
retry_log_*.jsonl data showing two attempts both landing on
dry_multiplier: 0.8 (identical, no escalation at all). This is
deliberately a separate test from the existing exponent-scaling one,
which uses a fresh body per case and so can't see a bug that only exists
when attempts share state the way production actually does. Also covers
the repetition detail: findRepeatedNgram returns the actual offending
n-gram (not just a boolean), DetectIssue surfaces it, and the
end-to-end retry-log test asserts the detail field lands in the JSONL
entry containing the repeated text — the field false-positive diagnosis
depends on. Also covers the repetition_window_words clustering
requirement, using the exact confirmed false positive from real retry
logs as the regression case: the same legitimate console.log line
appearing 3 times separated by unrelated code must not be flagged, while
a back-to-back loop still is, a genuine loop late in the output is still
caught even after earlier scattered occurrences of the same n-gram, and
config parsing defaults the window to 96 for existing config.ini files
that predate the key (0 would silently mean "no clustering" and keep the
false-positive behavior the window exists to fix). Also covers
repetition_requires_length_finish, using the second confirmed false
positive as the regression case: dense, inherently-clustered code
(newline/indent string concatenation) is not flagged when the response
finished normally ("stop"), the identical content is flagged when
finish_reason is "length", disabling the gate makes finish_reason stop
mattering again, and config parsing defaults the gate to true for
existing config.ini files that predate the key (false would silently
keep scanning normally-completed responses, the exact false-positive
class the gate exists to remove). Also covers the SSE re-chunking
formatting bug reported live as "any formatting is lost, including code
formatting": chunkText preserves the original text exactly (join every
returned piece and it must equal the input byte-for-byte, verified
against a multi-line, tab-indented, blank-line-containing code sample),
and an end-to-end test drives the real /v1/chat/completions streaming
path, reconstructs the message the way a real client would (concatenating
delta.content across every SSE chunk), and asserts the result matches
the original multi-line indented code exactly — the previous
strings.Fields-based implementation would have flattened it to one line
with no indentation.
