| Age | Commit message (Collapse) | Author | Files | Lines |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Records why the block style carries white-space:pre-wrap and why the
padding is margins plus a spacer line: Qt's markdown stylesheet does not
cover pre, and Qt ignores padding and vertical margins on it. Also
describes the copy button and the constraints behind its shape, since
neither a widget nor :hover is available in Qt rich text.
|
|
The copy link was a word-plus-glyph sitting above the block, always at
full strength, with no feedback once clicked. It is now an icon-only
button under the block, since Qt cannot float it over the tint.
Qt draws almost no box properties on <pre>: padding and vertical margins
are ignored, so the inset is margin-left/right and the room above and
below is a tinted spacer line. The glyph alone renders small whatever
font-size says, so the button is a tinted pill instead: grey at rest,
the highlight colour under the pointer, and a tick on green once used.
Qt rich text has no :hover, so hovering is QTextBrowser.highlighted()
driving a re-render, the same way the toggles already re-render.
The tick reverts after a moment rather than standing indefinitely, which
would claim the clipboard still holds that block long after it stopped
being true. Copying a second block first cancels the first one's revert.
|
|
Two things made a fenced code block awkward to read. Qt's markdown
stylesheet wraps p and li but not pre, and that stylesheet is dropped
when only the body fragment is kept, so a long line widened the whole
transcript and left it scrolling sideways. The pre style now carries
white-space:pre-wrap itself.
Copying a block meant selecting it by hand. Each block now gets a copy
link above it, on the right, in the same scheme-dispatch the thinking
and search toggles already use, since Qt rich text has no widgets to
put a real button in. The block text is read back from the source
markdown because Qt emits one <pre> per line, which makes the block
unrecoverable from the rendered HTML; _close_fences runs first, so a
block still streaming is copyable mid-reply. Like the other schemes,
a forged copy link in a reply is defused.
|
|
Status messages now show a dismiss button; an error persists until
dismissed or replaced, and a transient message can be cleared early.
|
|
A searched turn makes one API call per search round and all of them are
billed, but prompt_tokens/completion_tokens held only the last round's
counts, so a searched turn under-reported its cost. The columns now hold
the sum across rounds; usage_json keeps each round's raw usage block as
a JSON array for external consumers to verify against. A single-round
turn stores the same numbers as before.
|
|
A searched turn on a cloud reasoning model ended at the thinking: the
model emitted the tool call, but finish_reason: "tool_calls" landed on
the same SSE line as the include_usage block, so the single-event parser
returned that line as a usage chunk and the loop never saw the tool
finish. The parser now emits every event a line carries, so the search
fires.
Also in this change:
- Replay each round's reasoning_content on the assistant tool-call
message, which interleaved-thinking models require to keep going.
- Add a per-provider replay_reasoning option (DeepSeek, SiliconFlow
GLM-4.7+) to carry prior turns' reasoning_content when search is on.
- Add an on-demand diagnostic log gated by $LLAMACHAT_DEBUG_LOG.
- Record provider, usage_json and reported_cost_usd per reply, so a
searched turn keeps every round's billed usage for external consumers.
|
|
- Send max_tokens = 32768 for non-local providers so reasoning models have room for both chain-of-thought and answer.
- Add per-provider thinking_budget option for endpoints (currently SiliconFlow) that cap reasoning tokens separately.
- Accept bare JSON arrays from /v1/models; some OpenAI-compatible endpoints omit the {"data": [...]} envelope.
|
|
The generated config ships the provider block commented out so a fresh
install never reaches an endpoint nobody configured. The README states
plainly that every turn resends the whole conversation, which is free
locally and billed per message on a cloud provider.
|
|
The projection prices the whole request, not just the draft, because
reopening a conversation resends its entire history and that is billed
per turn on a cloud provider. Seeing it before sending is the whole point
of the readout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Fires on selection rather than on send, so the interruption lands while
the user is already changing settings instead of mid-thought. Local
models never trigger it, and a cancelled dialog is recorded so a model
tried once never asks again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Metadata now resolves models.ini over provider defaults over presets.ini,
so a local model keeps getting its context and vision from the preset
while remaining overridable. A model whose vision support is unknown no
longer blocks an attachment: unknown is not the same as no, and the API
will reject an image if it really cannot take one.
Key resolution moves to the worker thread so a pass: key's pinentry
never freezes the GUI. KeyResolver gains a lock since StreamWorker and
TitleWorker can now overlap. Skipped providers surface in the status bar
rather than only on stderr. model_info uses dataclasses.replace to avoid
mutating the ModelInfo that resolve() may return from the store.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Three states stay distinct: blank for a free local model, ? for a cloud
model whose prices were never entered, and a figure when they were.
Collapsing the first two would make an unpriced cloud model read as free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Field conversion is separated from the widget so the part with the edge
cases is testable without a running Qt application. Vision is tri-state
through a binary checkbox: an unchecked box with an unknown prefill stays
unknown rather than writing False, which would shadow a provider-level
vision=True through models.resolve's pick().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
A provider that is unreachable or whose filter matched nothing is
reported rather than fatal: local models must stay usable when the
network is down. Clients are built on demand so a key is resolved only
when that provider is really used.
The model listing carries its own timeout, separate from the per-turn
one: the picker has to populate fast, and a dead cloud provider must
not hang it for minutes. Client.models() takes list_timeout (default 30)
and uses httpx.Timeout(list_timeout, connect=5), and MultiClient threads
its own list_timeout through to each listing call. The fan-out stays
serial on purpose: KeyResolver._cache is unlocked and only safe while
every caller resolves on the GUI thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The local router needs none, so the header is omitted entirely rather
than sent empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The convention was documented but not its consequence: because None means
keep, no caller can write NULL through this method, and that is invisible
from the signature. Unreachable today since every field is filled once
when a reply finishes and never cleared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Cost has to survive reopening a conversation, which means storing what
each reply used. The model goes on the message rather than the session
because sessions records only the current one, and a conversation that
switched models would otherwise be priced entirely at whichever is
selected now. All three columns are nullable so old rows read as unknown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
A nan or inf in [providers.*] survived float() and reached the cost
arithmetic, rendering $nan or, for -inf, a negative cost. Worse than a
display wart: is_priced answered True, so the readout took the priced
branch and showed a poisoned figure in the one state the ? is there to
admit it cannot say.
Guarded at both ends. providers._number mirrors models._get, which
already covered the models.ini writer. is_priced states its own
predicate completely rather than trusting its callers, since the dialog
is a third writer and the file is documented for hand-editing.
Records why float money stays, and that exact-equality assertions in the
cost test hold for the chosen prices rather than in general.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Resolution merges per field, not per source, so an entry setting only
ctx_size still inherits the provider's prices. Cost prices each reply at
the model that produced it, and rows predating the token columns
contribute zero rather than a fabricated number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
float() accepts "nan" and "inf", so a hand-edited models.ini could feed
either into the cost arithmetic and render "$nan". Rejected once in _get
rather than at each downstream call site.
A model whose id is literally DEFAULT wrote a [DEFAULT] section, which a
stock configparser reader treats as defaults inherited by every other
model. Since the file is documented for hand-editing, that reintroduces
the contamination the renamed default_section exists to prevent, so the
id is escaped on write and mapped back on read.
Also drops the marker branch in save(), leaving mark_skipped() as the
sole writer of the "marker present iff no real keys" invariant, and
prunes two test blocks that could not fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Cloud models have no presets.ini entry, so context size, vision and
prices are recorded per model in a file the app writes. A cancelled
dialog leaves a marker so a model tried once never asks again, which is
distinct from an absent section meaning never asked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
A malformed local entry was the one skip that said nothing, on the
reasoning that local still ends up working. It does, but the merge
rebuilds it from the bare base_url, so an api_key, filter or ctx_size
set on that entry is dropped without a word. The app then comes up
looking healthy, which is the strongest possible signal that nothing is
wrong, making this the case that most needs saying, not least.
Local surviving at all depends on DEFAULTS supplying base_url, since
that is what the rebuild reads. Noted in config.py so the key is not
removed as redundant, and pinned by a test.
The not-a-table wording now follows what was written: a list gets the
single-bracket fix by name, while a scalar entry does not, since telling
someone to fix a [[...]] they never typed points at the wrong line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176tzAW6H1i2Kz8vm2XXVGV
|
|
Eight config shapes, all valid TOML, crashed config.load() before any
window existed, so the user got a traceback on a terminal a desktop
launch does not have. Skip malformed providers instead, keeping the
local path working, and say on stderr which one was dropped and why.
The guards are isinstance tests rather than a try/except because the
shapes raise three different exception types, and the local entry is
checked the same way: [[providers.local]] is a truthy list that raised
during the bare base_url merge, before the loop could skip it.
A config that cannot load at all now reports the file and exits rather
than half-starting on defaults the user never configured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176tzAW6H1i2Kz8vm2XXVGV
|
|
base_url stays populated so existing callers are untouched; the provider
table is built alongside it. models.ini sits beside config.toml and
state.ini, following the same pattern as the window layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
A TimeoutExpired chained without from None already prints no secret,
since its __str__ shows only the command and the duration. The suppressed
display buys a tidier traceback, not a narrower leak, and the comments
claimed otherwise. The from None calls stay.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Raise KEY_TIMEOUT to 120s: a graphical pinentry plus a hardware token the
user has to find and touch makes 30s a plausible successful unlock, and
failing one converts a slow success into an error the user can only retry
against the same clock.
Point the timeout message at a pinentry that never appeared, which is the
silent failure, rather than at one the user is already looking at. Name
the binary when pass is missing instead of reporting a bare Errno 2.
Raise from None so a partial secret on the failed process stdout stays
out of printed tracebacks, and record on the cache that it is unlocked
only because every caller resolves on the GUI thread today.
Rename KeyError_ to KeyResolutionError before later tasks import it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
One prefix-dispatched field. Resolution is lazy so a local-only session
never triggers a pinentry, cached for the process lifetime, and bounded
by a timeout so a stuck pinentry surfaces as an error instead of a frozen
send. Failures name the provider.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
A provider name containing a colon builds ids that split back to a
different provider, and an empty name builds ':model'; both routed to the
local router under a nonsense name with no error, so parse() now skips
them. split() stays non-injective by design, since bare local ids are the
premise here, so the residual risk is recorded as a ponytail comment with
its upgrade path rather than hidden. Also pin the trailing-colon form Task
9 relies on, and the empty-needle filter, which were correct but untested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Cloud models are addressed as provider:model; local ones stay bare so
existing sessions keep resolving. An unknown prefix is treated as part of
the model name rather than a provider. Filters are case-insensitive
substrings because provider ids capitalise inconsistently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The synthesis guard tested key membership, so a [providers.local] that set
only an api_key claimed the slot, blocked the bare base_url from filling
it, then failed the URL check and vanished. Losing the local provider is
the one outcome this feature cannot have. The guard now tests the URL and
merges, so the table adds detail to the local provider rather than
replacing it.
A filter given as a bare string was iterated character-wise, turning
filter = "qwen" into four needles that match almost every model id. Both
failures were silent, which is what made them worth fixing now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Providers come from a [providers.*] table. A bare top-level base_url
synthesizes the local provider so existing configs keep working, and an
explicit [providers.local] wins over it. Unset numbers stay None so
'unknown' never collapses into zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Sixteen TDD tasks. Providers, model-id namespacing and key resolution go
in a new providers.py; metadata and cost math in models.py; the settings
dialog in modeldialog.py, keeping ui.py from growing further.
The last task is manual: it needs a real key and spends real money, and
it is where cloud tool-calling behaviour gets found out rather than
guessed at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The name is behaviour, not config: it decides which provider a bare
base_url synthesizes, which one renders without a prefix, which one
skips filtering, and which one is exempt from the model dialog. All
four live in code, so the name had to be picked before implementation.
"default" was doing double duty as "the fallback" and "the local one",
and only the second is true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Design for talking to OpenAI-compatible cloud providers alongside the
local router. Providers are config table entries, local becomes the
"default" one, and a bare base_url still synthesizes it so existing
configs keep working.
Cloud models carry no presets.ini entry and cost money, so missing
metadata (context size, vision, prices) is entered through a per-model
dialog stored in models.ini, and an approximate per-conversation cost
sits next to the context meter. Token counts and the producing model
move onto the messages table so cost survives reopening a chat and
prices each reply at whatever produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
Titles: once a reply lands in a session still wearing its placeholder
title, the exchange goes back to the model in a short side request asking
for six words or fewer. Keyed on the placeholder rather than the turn
number, so an empty first reply does not forfeit titling for the session
and a one-shot window is titled from the question that was answered.
Thinking is disabled for that request, or a reasoning model spends the
whole budget thinking and returns nothing.
Empty replies: the final search round now says so in the tool result.
Withdrawing the tool schema is invisible to the model, which asks for
another search regardless; the request then surfaces as literal
<tool_call> text or vanishes into the thinking block, leaving the reply
empty either way. The note rides on the tool result because Qwen3.5's
template rejects a trailing system message outright.
max_searches now defaults to 2. The other half of that bug was
llama-server's quantized KV cache: with cache-type-k/v = q8_0 three of
five turns broke, and f16 answered six of six.
Measured against a live router, not mocked.
|
|
The comments and README stated flatly that a second tool call always comes
back as literal <tool_call> XML. On llama.cpp b10208 that is no longer true:
forcing a follow-up by starving the first search gave 1/5 parsed as a
structured call, 1/5 leaking XML, 3/5 answered without searching again.
Earlier builds never parsed it.
Still not reliable enough to raise max_searches, since the failure mode is
a blank reply, but the wording should not read as permanent when it was
measured once against one build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|