Commit Graph

2938 Commits

Author SHA1 Message Date
beiyesi b55dd04711 fix(streaming): adapt provider errors to relay 2026-08-14 16:40:02 -07:00
beiyesi 04bc5321c9 fix: preserve non-JSON provider stream errors 2026-08-14 16:40:02 -07:00
beiyesi 5cbc645d09 fix: require terminal signal for streamed errors 2026-08-14 16:40:02 -07:00
beiyesi 0fcebb29f4 fix streaming bare data error payloads 2026-08-14 16:40:02 -07:00
beiyesi 661a4c4f88 fix streaming provider error events 2026-08-14 16:40:02 -07:00
SHL0MS b21e0bd8c9 fix(providers): honor per-provider TLS on custom /models and pricing probes
Per-provider ssl_ca_cert / ssl_verify reached the httpx chat client and the
auxiliary clients (#56681), but the endpoint discovery and pricing probes did
not. Both probe families resolved TLS from process-wide env vars only:

- the requests-based metadata/pricing probe
  (agent/model_metadata.py::_resolve_requests_verify)
- the urllib-based /models catalog probe
  (hermes_cli/models.py::probe_api_models)

A custom endpoint whose chain verifies against the provider's configured
bundle, but not the process SSL_CERT_FILE, then logged a spurious
CERTIFICATE_VERIFY_FAILED on every probe even though the chat path worked.
Pointing a global CA env var at the bundle fixes it but changes verification
for every provider, defeating the point of a per-provider setting.

This threads the selected provider's TLS settings into both probe paths,
reusing get_custom_provider_tls_settings so there is no second precedence
chain:

- _resolve_requests_verify(base_url) looks up the provider's ssl_verify /
  ssl_ca_cert before falling back to the env vars. Callers with no base_url
  keep the exact env-only behavior.
- probe_api_models builds an ssl.SSLContext from the provider settings and
  passes it through open_credentialed_url, which gains an ssl_context seam on
  the cloned secure opener. Unmatched or public endpoints pass None and keep
  urllib's default policy.

Tests: tests/agent/test_custom_provider_ca_probes.py covers both probe
families (provider CA, ssl_verify:false, unmatched, missing file, config
lookup failure) plus end-to-end assertions that the resolved verify value and
SSLContext actually reach the request seam. Verified against the neighboring
metadata, pricing, TLS, and urllib-security suites (266 tests) with no
regressions.
2026-08-14 16:37:36 -07:00
fangliquan f1025b2c00 fix(sessions): fence transcript writes with the turn-lease holder
Refresh-loss interrupt is cooperative, so a stalled writer could still flush after another process reclaimed the conversation. Carry the holder into append_message / append_messages_batch and reject the write in the same SQLite transaction when the lease row is missing, expired, or owned by someone else.
2026-08-15 03:22:16 +05:30
Teknium 518bc90e74 fix(agent): bound HERMES_HOME override wins over shared session-db home
The messaging gateway multiplexes profiles over ONE shared launch-home
state.db, binding the profile per turn via the HERMES_HOME ContextVar
(copy_context into the worker thread). _agent_home derived the home from
db_path unconditionally, so on that lane the launch home stomped the
correctly-bound profile — deterministically inverting the leak #86313
fixed (found by @kshitijk4poor's post-merge probe; @helix4u flagged the
plugin-metadata half).

- _agent_home: bound override wins; session_db home is the unbound-thread
  fallback
- _plugin_session_info: profile_name derives from _agent_home too
- full-prompt wiring regression (SOUL + skills + profile line on a bare
  thread with the bot's DB) — reverting any call-site wire fails it;
  multiplex, bare-thread, and plugin-metadata cases each pinned;
  sabotage-verified both new tests fail against the merged behavior
- skills LRU cap 8 -> 32 (key is now per-profile x platform)
2026-08-14 14:37:00 -07:00
Teknium 1f1b4d9947 fix(agent): scope SOUL.md load to the agent's own profile home (#50233)
load_soul_md resolved the home ambiently, so a build thread that lost the
HERMES_HOME ContextVar read the launch profile's SOUL.md into another
profile's prompt — same class as the skills-index leak fixed in #86313.
load_soul_md and build_context_files_prompt now accept home_override, and
build_system_prompt_parts passes the agent's own home (from session_db)
at both SOUL call sites. Ambient behavior unchanged when no override.
2026-08-14 14:15:42 -07:00
Jeff Mettel 126acbf219 fix(agent): stop doubling the profile path in the system-prompt hint
The named-profile branch of the "Active Hermes profile" hint built its
paths by appending `/profiles/{active_profile}` to `get_hermes_home()`.
But `_resolve_active_profile_name()` returns a non-default name *only*
when `get_hermes_home()` has already resolved under `<root>/profiles/`
— that is how it derives the name in the first place. Both scoping
mechanisms (a `HERMES_HOME=<root>/profiles/<name>` env var and the
multiplexer's `set_hermes_home_override` contextvar) satisfy that, so
the suffix always doubled.

The same branch used `get_hermes_home()` for the *default* profile's
data pointers, where the root was intended — placing them inside the
active profile.

On a real 4-profile install the hint rendered:

    reads and writes ~/.hermes/profiles/via/profiles/via/
    default profile's data lives at ~/.hermes/profiles/via/skills/

against actual paths of `~/.hermes/profiles/via/` and `~/.hermes/skills/`.

Use the session home directly as the profile home, and
`get_default_hermes_root()` for the root pointers. Every named-profile
session was shipping a prompt that named nonexistent directories and
mislabeled this profile's own skills/plugins/cron/memories as the
default profile's — the exact cross-profile confusion the hint and
`classify_cross_profile_target` exist to prevent.

The default-profile branch is unchanged.

Fixes #72894

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:11:57 -07:00
Teknium 30e9449403 fix(agent): keep legacy ambient home strings byte-identical when no agent home
The root-derivation for the profile-hint text now only engages when the
agent's own home is known; otherwise the line resolves exactly as before
(via this module's get_hermes_home), fixing
test_coding_prompt_preserves_legacy_workspace_order.
2026-08-14 13:56:58 -07:00
Teknium 8fd0f9c1d2 fix(agent): derive profile name from the hermes ROOT, not the ambient home
On a correctly bound profile session get_hermes_home() returns the profile
dir itself, so relative_to(home/'profiles') never matched and every profile
misreported as 'default' (with wrong paths in the profile hint text). Use
get_default_hermes_root() for both the name derivation and the default-data
root string. Adds regression tests for the bound-profile and root-home cases;
sabotage-verified the bound-profile test fails against the old resolution.
2026-08-14 13:56:58 -07:00
Teknium 2f25dec349 fix(agent): scope the skills index + active-profile line to the agent's OWN home
A bot profile's system prompt could list the DEFAULT profile's ~80
skills and print 'Active Hermes profile: default' — while the live
skills_list() correctly showed the bot's real (often empty) set. The
agent plans against that index, so a false inventory makes it claim
capabilities it doesn't have, waste context tokens, and lose trust.

Root cause (confirmed empirically): the skills-prompt builder and the
active-profile line resolve the home through get_hermes_home(), which
reads a HERMES_HOME ContextVar. ContextVars do NOT propagate into
threading.Thread, so an agent build running on a thread that didn't
bind the profile's home falls back to the launch (default) home and
builds default's index. A bare no-override thread builds default's
full 7621-char block; the same thread with the fix builds empty.

Fix: resolve the agent's OWN home from its dedicated _session_db.db_path
(ground truth, ContextVar-independent) and pass it explicitly:
- build_skills_system_prompt(skills_dir_override=...) scopes the index,
  the disk snapshot, and external-dir resolution to that home
- the active-profile line derives the profile name from the same home
Both fall back to ambient resolution when no db is present, so the CLI
and default-profile paths are unchanged.

Regression tests: an empty bot profile yields an empty skills block on
a bare thread even with ambient HERMES_HOME bound to a skills-rich
default; agent-home resolution from session_db.db_path. 3/3.
2026-08-14 13:56:58 -07:00
kshitij 367f0c21ed feat(agent): resolve sequential tool deadline via timeouts.tools.sequential_call (#85125 2a)
Follow-up on the #84795 salvage: the sequential deadline gets its own
resolver key. Unset, it inherits the concurrent batch deadline (same
value, same HERMES_CONCURRENT_TOOL_TIMEOUT_S bridge) so the two executor
paths cannot drift by default; set, it can be tuned or disabled
independently. Documented in cli-config.yaml.example; 5 contract tests.

Deliberately NOT on run_bounded_sync: the executors extend deadlines
dynamically during human approval waits (authorization-gate excluded
seconds) — the shared primitive is fixed-deadline. Noted in the docstring.
2026-08-15 02:21:39 +05:30
fangliquanflq 61645cde82 fix(agent): exempt clarify from sequential tool deadline
Clarify waits on a human for up to 3600s or unlimited. The generic sequential timeout was aborting that wait at 420s and leaving the prompt and worker active.
2026-08-15 02:21:39 +05:30
fangliquan 82a1b5a115 fix(agent): suppress late timeout observer events 2026-08-15 02:21:39 +05:30
fangliquanflq ededa8c4f1 fix(agent): bound sequential tool calls 2026-08-15 02:21:39 +05:30
teknium1 f79440e0f4 feat: /loop — recurring in-session wakeups (Claude Code parity)
Ports Claude Code's /loop (and its /proactive alias) across every Hermes
surface. /loop [interval] <prompt> re-runs a prompt or slash command on a
recurring cadence inside the live session; omitting the interval enables
self-paced mode (starts at the floor, backs off exponentially while the
agent's replies stop changing, snaps back on change — local digest
comparison, zero extra LLM cost).

Stop conditions: agent-emitted LOOP_COMPLETE marker, --times N,
--until <condition> (judged by the existing goal_judge aux task,
fail-open), /loop stop, and a loops.max_ticks backstop budget.

Core: hermes_cli/loops.py (LoopState + LoopManager + shared
dispatch_loop_command), persisted per session in SessionDB state_meta
(loop:<sid>) so /resume picks it up; migrates across compression
boundaries like /goal. New SessionDB.list_meta_prefix() powers the
gateway's cross-session scan.

Surfaces:
- CLI: /loop handler + idle-fire and post-turn-complete hooks in
  process_loop (mirrors the /goal hook shape; Ctrl+C pauses the loop)
- Gateway: /loop handler with route capture, mid-run control-verb guard,
  post-turn tick completion, and a supervised loop_wakeup_watcher that
  injects due wakeups into idle chats via the synthetic-message path
- TUI/dashboard/desktop: command.dispatch handler + per-session
  notification-poller wakeup driver + post-turn completion in the turn
  dispatcher; /loop added to the desktop slash palette
- /goal mixing: an active non-parked goal owns the idle boundary — loop
  ticks defer until it finishes, pauses, or parks; real user input always
  wins over both

Config: loops.{min_interval_seconds,max_ticks,self_paced_floor_seconds,
self_paced_ceiling_seconds}. Docs page + sidebar entry. 77 new tests.
Slack's 50-slash cap: /version moves to /hermes version to free the
native slot for /loop.
2026-08-14 13:40:19 -07:00
kshitij d6a5cb9725 Merge pull request #85147 from kshitijk4poor/feat/unified-deadline-layer
feat(agent): unified deadline layer — bounded execution primitive + timeout resolver (#85125 Phase 1)
2026-08-15 01:10:40 +05:30
joaomarcos 11c5aae104 fix(compaction): gate checkpoint replay/prune on current request eligibility
A captured native-compaction checkpoint lives in the persisted
codex_reasoning_items sidecar, but the wire restructure that follows it
(prune_pre_checkpoint_items) ran unconditionally: the native gate only
decided whether context_management went into the request, and no signal
from it ever reached _chat_messages_to_responses_input.

So a single checkpoint kept deleting every pre-checkpoint item from all
later requests — after a mid-session swap out of the gpt-5.6 family,
after compression.enabled: false, after the rejection kill switch, and
after a session resume that reloads the sidecar from state.db. The model
receiving the opaque blob was no longer the one able to decode it, and
nothing was logged.

Thread a single native_compaction_eligible boolean, derived from the same
value that gates the context_management field, into the converter. When
ineligible: do not replay type: "compaction" items and do not prune. Safe
because native compaction never truncates Hermes' local history, so the
fallback still carries the full conversation.

All Responses call sites are covered: build_kwargs and convert_messages
derive the flag via _native_compaction_active, the auxiliary/compression
client is explicitly ineligible, and the converter defaults to False
(pre-feature wire) so future call sites are safe by construction.

Fixes #85914

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:39:25 -07:00
lkz-de 83d373aae6 fix(cli): guard expensive startup model overrides
Run the expensive-model warning for explicit startup `-m` / `--provider`
overrides before the chat loop starts, and fail closed for non-interactive
invocations that select an expensive or known-confusing model.

Also classify Nous paid-model 404s that say credits are required as billing
exhaustion so they fail fast with billing guidance.

Tested:
- scripts/run_tests.sh tests/hermes_cli/test_cli_startup_model_cost_guard.py tests/hermes_cli/test_model_cost_guard.py tests/agent/test_error_classifier.py -- --tb=short -q
2026-08-14 01:31:31 -07:00
fangliquanflq 8cf9e8a61b fix(compression): ignore background process notifications 2026-08-14 01:08:13 -07:00
Teknium fbec3c78fd fix(compression): clear preflight block on provider-confirmed rearm
A provider-confirmed rearm (#85846) resets the shared attempt budget, but
an earlier insufficient-progress verdict left _preflight_compression_blocked
armed, keeping the pre-API gate dark for the rest of the turn — a later
pressure spike could still grow unchecked until the provider overflow
handler fired. Clear the blocker and the stale pressure reading inside the
provider-confirmed rearm branch: the prompt is proven back below the
threshold, so the old verdict describes a request shape that no longer
exists.

Builds on @h-mascot's #84995, whose commit is preserved on this branch;
his rearm condition was superseded by #85846's latch-verified variant, but
the blocker-clear half was correct and is kept.
2026-08-13 23:43:54 -07:00
Henry Mascot 2b9da1f252 fix(compression): re-arm same-turn attempt budget 2026-08-13 23:43:54 -07:00
Gille 380e4da3d1 fix(compression): rearm budget from verified usage 2026-08-13 23:08:19 -07:00
ai-ag2026 72c828ca2c fix(agent): refund per-turn compression budget after real progress
A marathon tool turn burned all compression_attempts on *successful*
pre-API compactions; the gate then went permanently dark and the context
grew unchecked until the provider rejected the request terminally
("Context length exceeded: max compression attempts (3) reached", session
f087963205f9, 2026-08-01). The budget now refunds at loop top when the
assembled request is back under threshold * 0.8 AND the compressor's own
should_compress() agrees the pressure is gone.

Anti-thrash intent of the cap is preserved (#11529): no-progress passes
never reach the refund margin, divergent-signal cases (should_compress
still True) keep the budget burnt, and the insufficient-progress blocker
is untouched.

Validation: new behavioral suite (7) + all 130 compression/context tests
via scripts/run_tests_hermetic.py.

Rückbau: Commit revertieren; kein Zustand, keine Migration.

(cherry picked from commit 041b489d566bfb1d6816d53d9acd5ac50b8d5af0)
2026-08-13 23:08:19 -07:00
joaomarcos e331b834fe fix(usage): preserve OpenAI-wire cache writes at canonical accounting boundary (#85706)
Salvaged from PR #85702 by @JoaoMarcos44, composed onto the mapping-safe
_usage_get reads (PR #74591 by @RelaxJonh) and the flat cached_tokens /
Anthropic-name fallbacks (PRs #66105, #52571):

- cache-write precedence in the chat_completions branch:
  details.cache_write_tokens > details.cache_creation_input_tokens >
  usage.cache_creation_input_tokens > usage.cache_write_tokens
- codex_responses branch reads details.cache_write_tokens (GPT-5.6+
  documented name) with cache_creation_tokens fallback (from PR #70522)
- _usage_count(): clamp malformed negative counters to 0
- all reads in every branch are mapping-safe via _usage_get
2026-08-13 22:09:37 -07:00
RelaxJonh 11de734331 fix(usage): support dict-shaped usage objects in normalize_usage (#74314)
When the Responses API returns usage as a plain dict (e.g. from a
middleware or proxy that deserialises JSON to dict instead of a typed
SDK object), normalize_usage() used getattr() exclusively, which
silently returned 0 for every field on a dict.

Add _usage_get() helper that reads via .get() for dicts and getattr()
for attribute-style objects. All accessor sites in normalize_usage()
now use this helper, so token counts and cost are correct regardless
of the usage object's type.

Regression tests: two new tests feed the same payload as both a dict
and a SimpleNamespace through the codex_responses and
chat_completions branches, asserting identical output and non-zero
values.
2026-08-13 22:09:37 -07:00
mehmetkr-31 5b82e69693 fix(agent): read Kimi/Moonshot top-level cached_tokens in normalize_usage (#65722)
Kimi/Moonshot's native API (api.moonshot.cn / .ai) reports context-cache hits
as a top-level ``usage.cached_tokens``. The chat-completions branch of
normalize_usage() walks a fallback chain of
prompt_tokens_details.cached_tokens -> cache_read_input_tokens ->
prompt_cache_hit_tokens; none of those names match, so direct Kimi sessions
normalized to cache_read_tokens=0. The hits were invisible in accounting and
the cached prefix was billed at the full input rate.

Appended as the last link in that chain, so it only fills a genuine zero and
cannot override a provider that reports the nested OpenAI shape or DeepSeek's
prompt_cache_hit_tokens.

Rebuilt on current main rather than rebased — the branch was ~3400 commits
behind. The DeepSeek half of the original branch is dropped: 03c0b00f4
(#65678) landed prompt_cache_hit_tokens on main, so this is Kimi-only as the
review asked. The scripts/release.py addition to the frozen LEGACY_AUTHOR_MAP
is dropped too; contributors/emails/mehmet.kar@std.yildiz.edu.tr already
exists on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:09:37 -07:00
Chengxi Zhou 69bc3159e8 fix(agent): fallback to Anthropic-style token fields in normalize_usage
Local OpenAI-compatible servers like mlx_vlm.server emit
input_tokens/output_tokens in chat completion responses instead of
prompt_tokens/completion_tokens. The OpenAI Python client preserves
these as extra attributes, but normalize_usage() only looked at
OpenAI-style field names, causing input_tokens to always be 0.

This made the context progress bar stay at 0% forever and prevented
auto-compression from triggering.

Fix: add Anthropic-style fallback (input_tokens/output_tokens) in the
default else-branch of normalize_usage, with OpenAI-style names taking
priority.

Fixes #14686
2026-08-13 22:09:37 -07:00
Ben Barclay f52feed1ef fix(azure-foundry): scope Responses reasoning suppression to post-tool turns (#84320)
Azure Foundry's OpenAI-compatible Responses surface rejects the post-tool
follow-up payload with HTTP 400 `invalid_payload` when a replayed encrypted
`reasoning` item is sent alongside `function_call` / `function_call_output`.
The initial function-call request and ordinary multi-turn continuity are both
accepted, so the failure only appears after the first tool executes.

Detect the Foundry endpoint in `ResponsesApiTransport.build_kwargs` and drop
only the encrypted reasoning replay on that follow-up turn, leaving
function_call / function_call_output continuity intact.

Salvage of #59981, rebuilt on current main. Same root cause and fix direction
as the original, which was correct; this version resolves three defects:

- No `chat_completion_helpers.py` change. main already forwards `provider`
  and `base_url` to the Responses transport, so the original's re-added
  arguments produced `SyntaxError: keyword argument repeated: provider` on
  merge. Dropping the hunk removed the syntax error and the conflict.

- Host matching uses `utils.base_url_host_matches`, not a substring test.
  `".services.ai.azure.com" in base_url` also matches URLs carrying the
  domain in a path or query segment, which would silently disable reasoning
  replay on an unrelated provider.

- The post-tool predicate tests the trailing messages, not the whole history.
  Scanning for any tool call plus any tool result made it sticky: one tool
  call early in a conversation suppressed reasoning on every later turn.

- Tool calls pair on `call_id` as well as `id`. Responses histories carry the
  function call id in `call_id` while `id` holds the response item id
  (`fc_...`). Identity is resolved via the converter's own
  `_split_responses_tool_id`, covering composite `"call_x|fc_y"` ids and bare
  `fc_` ids on both sides of the pairing.

Tests: 27 cases across the transport and the live `build_api_kwargs` bridge,
including six parametrized tool-call id shapes, non-Foundry host lookalikes,
the sticky-history guard, parallel tool results, and an unpaired tool result.
Each guard was confirmed to catch its defect by reverting the fix.

Verified with `scripts/run_tests.sh tests/agent/ tests/run_agent/`:
532 files, 5602 tests passed, 0 failed.

Not verified against a live Azure Foundry endpoint — no credentials. The
original HTTP 400 reproduction and post-fix Foundry Project / Azure Container
Apps harness runs are @AshuJoshi's, from #59981. This change is verified at
the payload-construction layer only.

Closes #59981.

Co-authored-by: Ashu Joshi <AshuJoshi@users.noreply.github.com>
2026-08-14 12:41:26 +10:00
kshitij a3cda34137 fix(models): repair the two CI slices the default-flip broke
- web_server CONFIG_SCHEMA: fold the one-field models_dev category
  (models_dev.url) into the agent tab via _CATEGORY_MERGE, matching the
  established pattern for single-field categories (slice 7,
  test_no_single_field_categories).
- image_routing._lookup_supports_vision: pass allow_network=True to
  get_model_capabilities. The vision-capability lookup runs when an
  image actually needs routing (not per conversation turn), and the
  #31179 text-only-main guard depends on catalog data — with the new
  allow_network=False default a cold cache returned 'unknown', which
  falls back to attempting the call and reintroduced the #31179
  failure shape (slice 8, test_text_only_main_skipped_when_no_
  aggregator). This preserves that path's historical
  network-on-cold-cache behavior; the fetch stays 4h-TTL cached and
  backoff-limited.
2026-08-14 03:31:22 +05:30
kshitij 5b4c91f1db refactor(models): simplify-pass follow-ups on the refresh path
- Cold force_refresh (fresh CLI process, e.g. hermes config refresh)
  now hydrates the memory cache from disk before fetching, so the
  conditional GET actually fires on the flow the feature was built for
  instead of silently re-downloading the full ~2 MB registry
  (empirically probed: If-None-Match sent, 304 serves disk data).
- Conditional-GET decision is passed in explicitly
  (_fetch_models_dev_from_network(conditional=...)) by callers holding
  the fetch lock, removing the hidden read of module globals inside
  the fetch; the background worker now fetches INSIDE the lock,
  symmetric with foreground (true singleflight — no concurrent
  double-download, no fetching against mid-commit etag state).
- Corrupt disk cache is QUARANTINED (renamed to .json.corrupt) rather
  than left in place: rejection becomes a one-time event instead of a
  re-read + re-parse + warning + unlink on every hot-path call while
  offline (probed: 1 warning across 5 calls, was 5).
- Dropped the dead _DEFAULT_MODELS_DEV_URL constant; module and
  function docstrings updated to match the servable-cache conditional
  semantics.
2026-08-14 03:31:22 +05:30
kshitij b1ce502535 fix(models): close review findings on the ETag refresh path
- Conditional GET now requires a servable in-memory registry: an
  If-None-Match sent while holding no cache invited a 304 against
  nothing, permanently serving {} with a blocking foreground fetch on
  every call (the exact #35838 class this PR fixes). Empirically
  repro'd and verified fixed (corrupt cache + stale sidecar: was 3
  calls -> {} forever; now 1 unconditional fetch -> real data).
- ETag persists atomically WITH the cache body via
  _commit_registry -> _save_disk_cache(data, etag), wiring up the
  previously-dead etag param; the sidecar can no longer get ahead of
  the registry it vouches for. _save_etag now uses
  utils.atomic_write_text (unique tempnames + fsync) instead of a
  hand-rolled fixed-name .tmp replace.
- Corrupt/unreadable disk cache clears the ETag sidecar so the
  refetch is unconditional; _confirm_cache_not_modified keeps a
  defense-in-depth guard (clear sidecar + arm backoff) should a 304
  ever land on an empty registry.
- allow_network=True paths use the zero-arg fetch_models_dev() call
  shape at all sites (was 1 of 5) — ~46 test sites monkeypatch it
  with zero-arg lambdas; the unconditional kwarg broke
  test_xiaomi_provider (verified fail->pass).
- _get_models_dev_url falls back to the MODELS_DEV_URL module global
  (not the constant) so existing patch sites keep working.
- Tests: replaced two mock-riddled corrupt-cache tests with real
  tmp_path file tests; added regression tests for the 304/empty-cache
  loop, sidecar clearing, and conditional-GET gating.
2026-08-14 03:31:22 +05:30
kshitij acd8737c10 fix(models): ETag conditional GET, no-network hot-path invariant, mirror URL override for models.dev catalog
Harden the models.dev catalog refresh path (#35838) with three missing
pieces:

1. ETag conditional GET — every network request sends If-None-Match
   with the last-known ETag (persisted alongside the cache file). A 304
   Not Modified re-confirms the existing cache without re-downloading
   the full ~2 MB registry. This makes the 4-hour TTL effectively free
   to maintain.

2. No-network-on-hot-paths invariant — allow_network=False is now the
   default for every query function called on the conversation hot path:
   get_model_capabilities, get_model_info, lookup_models_dev_context,
   _get_provider_models. These are called during vision routing, image
   routing, cost-guard checks, and context-length resolution on every
   turn — they must never block on the network. Interactive flows
   (model picker, model switch) explicitly pass allow_network=True.

3. Mirror URL override — models_dev.url in config.yaml lets deployments
   point at a self-hosted mirror without code changes. Follows the same
   pattern as model_catalog.url.

Additional hardening:
- Cache TTL bumped from 1h to 4h (ETag makes refresh cheap)
- Corrupt/empty disk cache is rejected with a warning instead of being
  served as {} and silently breaking provider/model resolution
- _validate_registry() guards against non-dict and empty-dict payloads

Fixes #35838
2026-08-14 03:31:22 +05:30
kshitij f80f453ae0 refactor(usage): simplify-pass follow-ups
- Single-source the included note as _INCLUDED_NOTE and attach it at
  BOTH status='included' sites (the zero-amount pricing-entry branch
  previously returned the same status with no note).
- Docstring/comment precision on format_cost_label: the fallback
  triggers on 4dp ROUNDING to 0.0000 (banker's rounding includes the
  exact $0.00005 boundary), not truncation; note why the rendered-label
  guard beats a naive Decimal threshold.
- Tests: replaced a dead assertion with the exact-boundary case
  ($0.00005), fixed an overclaiming comment, aligned the terminal
  cost column.
2026-08-14 02:04:24 +05:30
kshitij 2c068d7680 fix(usage): close sub-cent gaps found in review
- Insights formatters now route aggregate estimated cost through the
  shared format_cost_label() instead of hardcoded 2dp — a sub-cent
  aggregate (one cheap DeepSeek session, ~$0.0046) no longer renders
  'Estimated: ~$0.00', the exact bug class this PR fixes (#79220).
- format_cost_label: positive amounts below $0.00005 render '~$<0.0001'
  instead of the zero-looking '~$0.0000' 4dp truncation artifact.
- Renamed _format_cost_label -> format_cost_label (now a cross-module
  shared helper).
- Tests: renamed test_gateway_format_hides_cost ->
  test_gateway_format_hides_cache_details and
  test_no_cost_section_when_all_zero ->
  test_unknown_bucket_shown_for_costless_session (names contradicted
  behavior); restored a real assertion in the custom-models test that
  had been weakened to a comment; added sub-cent-aggregate and 4dp-floor
  contract tests (mutation-checked).
2026-08-14 02:04:24 +05:30
kshitij ccaaca7e66 fix(usage): cost display honesty — sub-cent labels, cost buckets, included notes
Three cost-display honesty fixes:

1. Sub-cent cost label rendering (#79220) — _format_cost_label() scales
   precision to magnitude: zero renders as '$0.00', sub-cent (< $0.01)
   renders at 4 decimal places (e.g. '~$0.0046'), normal costs keep 2dp.
   This fixes the bug where DeepSeek per-turn costs of $0.004640 rendered
   as '~$0.00' despite amount_usd carrying full Decimal precision.

2. Cost bucket surfacing (#77223) — insights format_terminal and
   format_gateway now display three cost buckets: estimated (with dollar
   figure), included (session count, labeled 'subscription — no provider
   invoice'), and unknown (session count, labeled 'no pricing data').
   Previously, included and unknown sessions silently collapsed to $0 in
   the aggregate view, hiding 315 of 473 sessions in the reporter's DB.

3. Subscription-included cost notes — estimate_usage_cost now attaches a
   'subscription-included; no provider invoice for usage' note to
   CostResult for subscription-included routes (openai-codex), so
   consumers can distinguish 'free because subscription' from 'free
   because $0 pricing'.

Fixes #79220
Fixes #77223
2026-08-14 02:04:24 +05:30
kshitij 67ab2f2968 refactor(models): simplify-pass follow-ups on model_overrides
- Deleted the id(cfg)-keyed _OVERRIDE_CACHE layer: id() is unique only
  among live objects, so a config reload could serve stale overrides
  forever when CPython reuses the freed dict's address. The upstream
  load_config_readonly is already (mtime,size)-cached (~1 stat/hit), so
  the local layer was redundant state with a correctness risk.
- _override_to_catalog_shape returns (patch, vision) instead of
  smuggling an in-band _vision_override sentinel key through the merged
  dict; removed the two dead call-site pops.
- _find_model_entry gains the :cloud/-cloud suffix fallback that
  lookup_models_dev_context already had, so 'catalog hit' means the
  same thing to every consumer — a suffix-keyed model (kimi-k2.6:cloud)
  now counts as KNOWN and keeps its catalog capabilities instead of
  being displaced by a fill-gap _default (mutation-checked contract
  test added).
- get_model_info's unknown-model override path seeds the same safe
  defaults as get_model_capabilities (200K ctx, tools on, 8192 out),
  so a partial override no longer yields ctx=0/tools-off on that path
  (contract test added); the DEFAULT_CONFIG defaults claim is now true
  for both paths.
- Activated the previously-dead _MODELS_DEV_TO_PROVIDER reverse map
  (lazily built, many-to-one aware) and used it in
  _provider_override_section instead of a per-call linear scan.
2026-08-14 02:03:54 +05:30
kshitij de47d19f1f fix(models): one canonical override schema, fill-gap _default semantics
Review follow-ups on the model_overrides feature:

- ONE canonical override schema everywhere. get_model_info previously
  merged the override dict raw into the models.dev catalog shape
  ({**raw, **override}), so the documented context_window/supports_*
  keys silently did nothing on that path (cost guard, inventory) while
  working in capabilities/context paths — same config key, two
  incompatible schemas. Overrides are now translated into the catalog
  shape at the get_model_info boundary (_override_to_catalog_shape),
  and sub-dicts (limit, modalities) are MERGED, not clobbered — an
  override setting only context_window no longer wipes the catalog's
  limit.output.
- _default is now a FILL-GAP default, not an override: it applies only
  to models the catalog does not know (the #8731/#84482 self-unblock
  path) and never displaces catalog data. A
  _default: {context_window: 128000} can no longer clamp every model
  of a provider. Explicit per-provider+model entries keep their
  win-over-catalog semantics.
- Early-chain _override_context_window (model_metadata step 0b) is
  explicit-only, so a _default can never preempt custom_providers
  per-model settings or live probes; fill-gap defaults apply at the
  lookup_models_dev_context catalog-miss boundary (step 5f) instead.
  This fixes the precedence inversion where a provider/global _default
  silently overrode an explicit per-endpoint per-model context_length.
- Provider keys accept BOTH id spaces (Hermes id and models.dev id:
  copilot/github-copilot both work) and model ids match
  case-insensitively, mirroring catalog lookup.
- Malformed override values (context_window: '512k') log a one-shot
  warning instead of being silently swallowed.
- DEFAULT_CONFIG comment: removed the false family/dated-snapshot
  inheritance claim, documented the recognized field list, fill-gap
  semantics, and the id-space rule.
- Tests: rewritten for the new contracts (fill-gap invariants,
  dual-id-space keys, sub-dict merge preservation, one-shot warning);
  added a real-config-yaml e2e plumbing test (mutation-checked: fails
  when the config key wiring is broken).
2026-08-14 02:03:54 +05:30
kshitij dafdba324a feat(models): per-model metadata overrides via model_overrides config
Add a unified model_overrides config section that lets users manually
declare context_window, max_output_tokens, capabilities, cost, and
family for any provider+model — winning over models.dev, OpenRouter, and
hardcoded defaults.

Resolution order (first hit wins):
  1. model_overrides.<provider>.<model_id>  (per-provider+model)
  2. model_overrides.<provider>._default    (per-provider default)
  3. model_overrides._default               (global default)
  4. Normal catalog resolution

Key subtlety: an unknown model id (not in the
catalog) derives base metadata from sensible defaults before patching,
so overriding a model the catalog doesn't know yet is the supported
self-unblock path. This is exactly the #84482 scenario (Upstage
solar-pro4/syn-pro wrong context) and the #8731 scenario (custom/local
models with manual capability declaration).

Wired into:
  - get_model_capabilities() — patches capability fields; unknown models
    get safe defaults (tools on, vision/reasoning off) before patching
  - lookup_models_dev_context() — context_window override, checked before
    catalog lookup so it works even for providers not in PROVIDER_TO_MODELS_DEV
  - get_model_info() — merges override dict onto catalog entry (shallow
    merge); for unknown models, the override is the sole source of metadata
  - get_model_context_length() — step 0b in the resolution pipeline,
    before custom_providers (0c) and before any network probe

Config example:
  model_overrides:
    upstage:
      solar-pro4:
        context_window: 524288
      syn-pro:
        context_window: 65536
    custom:my-local-vllm:
      my-llava-model:
        context_window: 8192
        supports_vision: true
        supports_reasoning: false
        supports_tools: true
    _default:
      context_window: 128000

Fixes #8731
Fixes #84482
Refs #47247
2026-08-14 02:03:54 +05:30
Teknium d0be93bd9a fix: explicit fallback api_mode always wins; clean up dead-code guard
Maintainer fixup on the #79787 salvage:

- An explicit fb.api_mode of "chat_completions" was silently overridden
  by the codex_responses / bedrock re-detection pass (which only skipped
  re-detection when the pre-computed mode was non-default). Track
  explicitness in fb_api_mode_explicit and gate the whole re-detection
  block on it.
- Replace the locals().get('fb_api_mode') dead-code hack with clean code
  (fb_api_mode is always bound at that point).
- Restore the post-resolve /anthropic + api.anthropic.com host check for
  named custom providers whose base_url comes from config rather than
  the fallback entry (#32243, #49247), which the PR's restructure dropped.
- Add regression tests: explicit api_mode honored (incl. explicit
  chat_completions not overridden), /anthropic-hint fallback detected
  pre-rewrite, api_mode forwarded to resolve_provider_client, plain
  fallback unchanged.
2026-08-13 12:31:02 -07:00
sjungwon03 17a675574c fix: preserve anthropic_messages api_mode during fallback activation
Fallback activation determined api_mode from the POST-rewrite client
base_url, losing the Anthropic wire signal for /anthropic endpoints
routed through provider 'custom', and never honored an explicit
fb.api_mode config field. Pre-compute fb_api_mode from the ORIGINAL
fallback base_url hint (before _to_openai_base_url rewriting), honor
the explicit api_mode config field, check provider name before the
base_url gate, and pass api_mode into resolve_provider_client at the
fallback call site.

Salvaged from PR #79787 (chat_completion_helpers.py hunks; the
auxiliary_client.py hunk is redundant with #85466's wrap_base fix).
2026-08-13 12:31:02 -07:00
Xie ddbef9cd79 fix(auxiliary): keep ZAI Coding Plan routing 2026-08-13 12:30:29 -07:00
kshitij a0939901df fix: address review feedback from #85512
- /model switch now refreshes agent._custom_providers from the config
  loaded during the switch before re-evaluating cache policy — a
  prompt_caching flag added to config.yaml after session start was
  invisible to a mid-session switch (policy read the stale init-time
  snapshot while context_length resolution used the live list).
- Production-path test: real config.yaml in the modern providers: dict
  shape through the real loader chain, exercising the init-order fallback
  (no _custom_providers attr) for both the fable opt-in and the opus
  explicit opt-out.
- Pin operator kill-switch precedence: _cache_disabled (prompt_caching.
  cache_ttl falsy) beats an explicit per-model prompt_caching: true.
2026-08-14 00:59:38 +05:30
kshitij 4fa728b6be fix: follow-up polish for salvaged PR #85512
- Log (debug) instead of silently swallowing capability-lookup failures in
  anthropic_prompt_cache_policy — a swallowed failure would otherwise
  downgrade an explicit prompt_caching: true to (False, False) with zero
  trace. Matches the sibling MoA branch's logger.debug style.
- Use load_config_readonly() for the None-fallback in
  get_custom_provider_model_capability: the helper only reads, and the
  fallback fires on the blank-stub paths (agent init before
  _custom_providers is assigned, MoA/auxiliary destination planning), so
  skip the ~135us defensive deepcopy per call.
- Add route-isolation regression tests at both levels (config helper +
  agent policy): a prompt_caching declaration for one provider route must
  never apply to another route with the same model name. Mutation-checked:
  both tests fail when the URL match is disabled.
2026-08-14 00:59:38 +05:30
fangliquanflq 316f31c9d5 fix(agent): honor prompt caching capabilities for aliases 2026-08-14 00:59:38 +05:30
Teknium 6f33f510e8 fix(agent): anchor dual-surface marker matching to the URL host
Substring matching over the whole URL let a path containing
'api.minimax' false-positive an Anthropic-only gateway into the
/anthropic→/v1 rewrite. Parse the host and match exact-domain /
subdomain suffixes (plus the api.minimax.* prefix family) instead.
2026-08-13 11:45:27 -07:00
686f6c61 0d24c4f413 fix(agent): only rewrite /anthropic→/v1 for dual-surface hosts
Unconditionally rewriting any */anthropic base_url to /v1 broke
Anthropic-only custom gateways (e.g. Alibaba Bailian Token Plan) used by
auxiliary compression/vision under provider=auto. Keep MiniMax dual-surface
hosts rewriting; leave pure Anthropic paths intact.

Fixes #83642
2026-08-13 11:45:27 -07:00
iso2kx cd344a280f fix(auxiliary): honor /anthropic-suffixed gateway base_url on aux + fallback calls
`_try_anthropic()` applies the configured `model.base_url` only when
`_is_anthropic_compatible_host()` trusts it, but that check accepted only the
literal `api.anthropic.com` host. Anthropic-compatible gateways that expose the
native Messages protocol under a `/anthropic` path suffix (MiniMax, Zhipu GLM,
LiteLLM-style relays, self-hosted proxies) were rejected, so every auxiliary
call (title generation, memory extraction, vision, reflection) and the
`provider: anthropic` fallback chain discarded the configured base_url and fell
back to `https://api.anthropic.com`. That diverges from the primary path, which
already trusts the `/anthropic` suffix via
`runtime_provider._detect_api_mode_for_url`, and fails outright when the gateway
(not Anthropic) holds the credentials.

Accept `/anthropic` and `/anthropic/v1` suffixed URLs in
`_is_anthropic_compatible_host()`, matching the primary-path convention and
`_wrap_if_needed`. A bare non-Anthropic base_url (e.g. `openrouter.ai/api/v1`
left on `provider: anthropic`) still returns False, preserving the #52608 guard.
2026-08-13 11:41:59 -07:00