hx-cli 0.2.13__tar.gz → 0.2.15__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hx_cli-0.2.13 → hx_cli-0.2.15}/CHANGELOG.md +71 -1
- {hx_cli-0.2.13 → hx_cli-0.2.15}/PKG-INFO +21 -9
- {hx_cli-0.2.13 → hx_cli-0.2.15}/README.md +20 -8
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/agents/subagent.py +1 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/claude_code.py +18 -2
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/cli.py +8 -6
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/loop.py +39 -1
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/net.py +60 -7
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/base.py +5 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/claude_code.py +72 -3
- hx_cli-0.2.15/src/hx/providers/claude_code_catalogue.py +276 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/codex.py +1 -1
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/models.py +256 -31
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/registry.py +15 -14
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/ansi.py +7 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/commands.py +56 -36
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/runtime.py +13 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/pickers.py +101 -14
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/conftest.py +42 -4
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/fake_claude.py +79 -6
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/stub.py +32 -1
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_claude_code.py +538 -11
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_cli.py +3 -4
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_commands.py +163 -2
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_claude_code.py +74 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/tls_proxy.py +41 -1
- hx_cli-0.2.13/src/hx/providers/claude_code_catalogue.py +0 -140
- {hx_cli-0.2.13 → hx_cli-0.2.15}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/.gitignore +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/install.sh +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/pyproject.toml +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/config.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/docs.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/git.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/keys.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/paths.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/trace.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/conftest.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_tools.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.13 → hx_cli-0.2.15}/uv.lock +0 -0
|
@@ -28,6 +28,74 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.15] - 2026-10-04
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `/login` and `hx auth login` list the accounts as OpenAI, Claude, OpenRouter, Devin. At the
|
|
36
|
+
`hx auth login` menu, Enter still picks OpenRouter.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- `/login claude-code` behind a network whose TLS inspection signs with a certificate not
|
|
41
|
+
valid for servers ("unsupported certificate purpose") now gives Claude Code's reason and
|
|
42
|
+
the fix: have the network fixed or exempted, or set `HX_SSL_NO_VERIFY=1` meanwhile. It
|
|
43
|
+
used to give the whole sign-in URL and no fix. Turns and HX's own requests that fail the
|
|
44
|
+
same way give the same advice, not "add the CA", which is trusted already.
|
|
45
|
+
- A `HX_CA_BUNDLE`, `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` that names a file holding no
|
|
46
|
+
certificates no longer crashes HX at startup. HX says so and verifies against the system
|
|
47
|
+
trust store, and does not hand the file to Claude Code.
|
|
48
|
+
- A wrapped error notice no longer shows a bright green background under every line after
|
|
49
|
+
the first.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## [0.2.14] - 2026-10-04
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- When the installed Claude Code is behind its current release - and so lists older Claude
|
|
58
|
+
models than your subscription can run - `/model` says so under the list, with the command
|
|
59
|
+
that updates it (`brew upgrade --cask claude-code` for a Homebrew install, otherwise
|
|
60
|
+
`claude update`). After updating, the next `/model` lists the new models; no
|
|
61
|
+
refresh needed.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- The model catalogue is refreshed from inside the `/model` picker: press `ctrl+r`. The
|
|
66
|
+
list updates in place and keeps your filter and selection; the outcome, or why the refresh
|
|
67
|
+
failed, shows under the list. With no models at all, `/model` now opens anyway and says
|
|
68
|
+
so, so `ctrl+r` is there to fill it.
|
|
69
|
+
|
|
70
|
+
### Removed
|
|
71
|
+
|
|
72
|
+
- `/models refresh`. It sat one letter away from `/model` and did nothing without its one
|
|
73
|
+
argument; use `ctrl+r` in the `/model` picker.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- On the `claude-code` route, the context window is the one Claude Code reports, not a
|
|
78
|
+
200k guess: Opus 5.5 runs with 1M. Claude Code lists its models without their sizes, and
|
|
79
|
+
what it serves depends on the plan, so a model starts at the window it was measured
|
|
80
|
+
serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus 4.7, 200k
|
|
81
|
+
otherwise - until its first turn reports the real window and maximum output for your
|
|
82
|
+
account. From then on the context gauge, the compaction threshold, the `/model` picker
|
|
83
|
+
and later sessions all use it, and a catalogue refresh keeps it.
|
|
84
|
+
- A model Claude Code lists at its long context - Fable 5.1 as `claude-fable-5-1[1m]` -
|
|
85
|
+
runs at it: `/model` offers `claude-code/claude-fable-5-1[1m]` at 1M, where HX used to
|
|
86
|
+
drop the `[1m]` and run the model at its standard context.
|
|
87
|
+
- A turn refused because the model, or its 1M context, needs usage credits your Claude plan
|
|
88
|
+
has off no longer reads "usage limit is reached. It resets at ...": HX says what needs the
|
|
89
|
+
credits, where to turn them on, and which model runs without them. Once they are on, the
|
|
90
|
+
next turn runs.
|
|
91
|
+
- After a catalogue refresh (`ctrl+r` in `/model`, or signing in), the session in progress
|
|
92
|
+
measures its context against the refreshed window, not the one it started with.
|
|
93
|
+
- `/model anthropic` (or typing `anthropic` in the picker) lists the Claude models your Claude
|
|
94
|
+
Code subscription runs, first, ahead of the per-token OpenRouter ones; `openai` does the same
|
|
95
|
+
for Codex. Several words match in any order: `/model anthropic sonnet`.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
31
99
|
## [0.2.13] - 2026-10-04
|
|
32
100
|
|
|
33
101
|
### Added
|
|
@@ -833,7 +901,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
833
901
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
834
902
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
835
903
|
|
|
836
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
904
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.15...HEAD
|
|
905
|
+
[0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
|
|
906
|
+
[0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
|
|
837
907
|
[0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
|
|
838
908
|
[0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
|
|
839
909
|
[0.2.11]: https://github.com/aletisunil/hx/compare/v0.2.10...v0.2.11
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.15
|
|
4
4
|
Summary: HX - an agent harness for the terminal
|
|
5
5
|
Project-URL: Homepage, https://hx.sunilaleti.dev
|
|
6
6
|
Project-URL: Documentation, https://hx.sunilaleti.dev
|
|
@@ -156,7 +156,7 @@ reach it at all.
|
|
|
156
156
|
|
|
157
157
|
**Which Codex models you get** is decided by your ChatGPT account, not by HX.
|
|
158
158
|
Signing in fetches the account's own list from the Codex backend, and `/model`
|
|
159
|
-
offers exactly that; `/
|
|
159
|
+
offers exactly that; ctrl+r in `/model` asks again. Entitlement is per model -
|
|
160
160
|
the same `plus` account can run `gpt-5.6-sol` and be refused `gpt-5.3-codex` —
|
|
161
161
|
so a model the picker does not list is one the subscription does not cover.
|
|
162
162
|
|
|
@@ -218,7 +218,7 @@ you to sign in again. On a machine with no browser at all, a session token in
|
|
|
218
218
|
`HX_DEVIN_API_KEY` or `DEVIN_API_KEY` is used when nothing is saved.
|
|
219
219
|
|
|
220
220
|
Signing in fetches the account's own model list, and `/model` offers exactly
|
|
221
|
-
that; `/
|
|
221
|
+
that; ctrl+r in `/model` asks again. Devin publishes each reasoning depth as a
|
|
222
222
|
model of its own - Claude Opus 5 High, Claude Opus 5 Max - and HX folds each
|
|
223
223
|
family into one model, so `/effort` picks the depth on Devin exactly as it does
|
|
224
224
|
on Codex. A fast lane or a 1M-context lane stays a separate model, since it
|
|
@@ -264,8 +264,15 @@ rest - are removed from its environment; its TLS settings are kept (see
|
|
|
264
264
|
`claude` other than the one on `PATH`.
|
|
265
265
|
|
|
266
266
|
`/model` offers the models Claude Code lists for the account, each with the
|
|
267
|
-
efforts it accepts for `/effort`; `/
|
|
268
|
-
|
|
267
|
+
efforts it accepts for `/effort`; ctrl+r in `/model` asks again. A model Claude
|
|
268
|
+
Code runs at its long context keeps `[1m]` in its id
|
|
269
|
+
(`claude-code/claude-fable-5-1[1m]`), exactly as Claude Code's own `/model` runs
|
|
270
|
+
it. Claude Code lists no context sizes, so a model starts at the window it was
|
|
271
|
+
measured serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus
|
|
272
|
+
4.7, otherwise 200k - and from its first turn uses the window Claude Code
|
|
273
|
+
reports for your account. When a usage limit is reached, HX says which one and
|
|
274
|
+
when it resets; when a model or its 1M context needs usage credits your plan
|
|
275
|
+
has off, HX says that instead, and what to pick without them.
|
|
269
276
|
|
|
270
277
|
**Web search.** Optional, and the one credential that is not a model route.
|
|
271
278
|
It powers two tools:
|
|
@@ -328,7 +335,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
|
|
|
328
335
|
`HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
|
|
329
336
|
startup every session, because it is not a fix. A TLS failure is reported in
|
|
330
337
|
one line - `/model` and the startup notice both name the certificate problem
|
|
331
|
-
rather than reporting an empty catalogue with no cause.
|
|
338
|
+
rather than reporting an empty catalogue with no cause. A file that holds no
|
|
339
|
+
certificates is ignored, with a startup notice that says so.
|
|
340
|
+
|
|
341
|
+
"Unsupported certificate purpose" (or "unsuitable") is different: the CA is
|
|
342
|
+
trusted, but the proxy signs with a certificate that is not valid for servers,
|
|
343
|
+
so no CA setting fixes it. The network has to fix that certificate or exempt
|
|
344
|
+
the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
|
|
332
345
|
|
|
333
346
|
The same settings cover the `claude-code` route, whose requests Claude Code
|
|
334
347
|
makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
|
|
@@ -421,7 +434,7 @@ be rebound (see [Keybindings](#keybindings)).
|
|
|
421
434
|
| `ctrl+d` | exit, when the prompt is empty |
|
|
422
435
|
| `shift+tab` | cycle permission mode |
|
|
423
436
|
| `ctrl+p` | command palette |
|
|
424
|
-
| `ctrl+l` | model picker |
|
|
437
|
+
| `ctrl+l` | model picker; inside it, `ctrl+r` fetches the catalogue again |
|
|
425
438
|
| `ctrl+o` | expand tool output (also `ctrl+r`) |
|
|
426
439
|
| `ctrl+t` | show the current plan |
|
|
427
440
|
| `ctrl+x` | copy the selected message to the clipboard |
|
|
@@ -527,8 +540,7 @@ silently resolved.
|
|
|
527
540
|
|
|
528
541
|
| Command | Does |
|
|
529
542
|
|---|---|
|
|
530
|
-
| `/model [query]` | pick a model; shows context window, price per Mtok, cache support |
|
|
531
|
-
| `/models refresh` | re-fetch the catalogue |
|
|
543
|
+
| `/model [query]` | pick a model; shows context window, price per Mtok, cache support. `ctrl+r` in the picker re-fetches the catalogue |
|
|
532
544
|
| `/effort [level]` | how hard a reasoning model thinks, from the levels it offers |
|
|
533
545
|
| `/configure` | session settings and the API key |
|
|
534
546
|
| `/login [provider]` | sign in to a model route |
|
|
@@ -115,7 +115,7 @@ reach it at all.
|
|
|
115
115
|
|
|
116
116
|
**Which Codex models you get** is decided by your ChatGPT account, not by HX.
|
|
117
117
|
Signing in fetches the account's own list from the Codex backend, and `/model`
|
|
118
|
-
offers exactly that; `/
|
|
118
|
+
offers exactly that; ctrl+r in `/model` asks again. Entitlement is per model -
|
|
119
119
|
the same `plus` account can run `gpt-5.6-sol` and be refused `gpt-5.3-codex` —
|
|
120
120
|
so a model the picker does not list is one the subscription does not cover.
|
|
121
121
|
|
|
@@ -177,7 +177,7 @@ you to sign in again. On a machine with no browser at all, a session token in
|
|
|
177
177
|
`HX_DEVIN_API_KEY` or `DEVIN_API_KEY` is used when nothing is saved.
|
|
178
178
|
|
|
179
179
|
Signing in fetches the account's own model list, and `/model` offers exactly
|
|
180
|
-
that; `/
|
|
180
|
+
that; ctrl+r in `/model` asks again. Devin publishes each reasoning depth as a
|
|
181
181
|
model of its own - Claude Opus 5 High, Claude Opus 5 Max - and HX folds each
|
|
182
182
|
family into one model, so `/effort` picks the depth on Devin exactly as it does
|
|
183
183
|
on Codex. A fast lane or a 1M-context lane stays a separate model, since it
|
|
@@ -223,8 +223,15 @@ rest - are removed from its environment; its TLS settings are kept (see
|
|
|
223
223
|
`claude` other than the one on `PATH`.
|
|
224
224
|
|
|
225
225
|
`/model` offers the models Claude Code lists for the account, each with the
|
|
226
|
-
efforts it accepts for `/effort`; `/
|
|
227
|
-
|
|
226
|
+
efforts it accepts for `/effort`; ctrl+r in `/model` asks again. A model Claude
|
|
227
|
+
Code runs at its long context keeps `[1m]` in its id
|
|
228
|
+
(`claude-code/claude-fable-5-1[1m]`), exactly as Claude Code's own `/model` runs
|
|
229
|
+
it. Claude Code lists no context sizes, so a model starts at the window it was
|
|
230
|
+
measured serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus
|
|
231
|
+
4.7, otherwise 200k - and from its first turn uses the window Claude Code
|
|
232
|
+
reports for your account. When a usage limit is reached, HX says which one and
|
|
233
|
+
when it resets; when a model or its 1M context needs usage credits your plan
|
|
234
|
+
has off, HX says that instead, and what to pick without them.
|
|
228
235
|
|
|
229
236
|
**Web search.** Optional, and the one credential that is not a model route.
|
|
230
237
|
It powers two tools:
|
|
@@ -287,7 +294,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
|
|
|
287
294
|
`HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
|
|
288
295
|
startup every session, because it is not a fix. A TLS failure is reported in
|
|
289
296
|
one line - `/model` and the startup notice both name the certificate problem
|
|
290
|
-
rather than reporting an empty catalogue with no cause.
|
|
297
|
+
rather than reporting an empty catalogue with no cause. A file that holds no
|
|
298
|
+
certificates is ignored, with a startup notice that says so.
|
|
299
|
+
|
|
300
|
+
"Unsupported certificate purpose" (or "unsuitable") is different: the CA is
|
|
301
|
+
trusted, but the proxy signs with a certificate that is not valid for servers,
|
|
302
|
+
so no CA setting fixes it. The network has to fix that certificate or exempt
|
|
303
|
+
the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
|
|
291
304
|
|
|
292
305
|
The same settings cover the `claude-code` route, whose requests Claude Code
|
|
293
306
|
makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
|
|
@@ -380,7 +393,7 @@ be rebound (see [Keybindings](#keybindings)).
|
|
|
380
393
|
| `ctrl+d` | exit, when the prompt is empty |
|
|
381
394
|
| `shift+tab` | cycle permission mode |
|
|
382
395
|
| `ctrl+p` | command palette |
|
|
383
|
-
| `ctrl+l` | model picker |
|
|
396
|
+
| `ctrl+l` | model picker; inside it, `ctrl+r` fetches the catalogue again |
|
|
384
397
|
| `ctrl+o` | expand tool output (also `ctrl+r`) |
|
|
385
398
|
| `ctrl+t` | show the current plan |
|
|
386
399
|
| `ctrl+x` | copy the selected message to the clipboard |
|
|
@@ -486,8 +499,7 @@ silently resolved.
|
|
|
486
499
|
|
|
487
500
|
| Command | Does |
|
|
488
501
|
|---|---|
|
|
489
|
-
| `/model [query]` | pick a model; shows context window, price per Mtok, cache support |
|
|
490
|
-
| `/models refresh` | re-fetch the catalogue |
|
|
502
|
+
| `/model [query]` | pick a model; shows context window, price per Mtok, cache support. `ctrl+r` in the picker re-fetches the catalogue |
|
|
491
503
|
| `/effort [level]` | how hard a reasoning model thinks, from the levels it offers |
|
|
492
504
|
| `/configure` | session settings and the API key |
|
|
493
505
|
| `/login [provider]` | sign in to a model route |
|
|
@@ -173,6 +173,7 @@ class SubagentRunner:
|
|
|
173
173
|
model_info=self.models.get_or_default(model) if self.models else None,
|
|
174
174
|
project_context=self.project_context,
|
|
175
175
|
hooks=self.hooks,
|
|
176
|
+
models=self.models,
|
|
176
177
|
)
|
|
177
178
|
loop.origin = f"{definition.name} subagent"
|
|
178
179
|
return loop
|
|
@@ -290,6 +290,22 @@ def login_terminal() -> int:
|
|
|
290
290
|
|
|
291
291
|
|
|
292
292
|
_URL = re.compile(r"https://\S+")
|
|
293
|
+
_PASTE_PROMPT = re.compile(r"^.*Paste code[^>]*>\s*")
|
|
294
|
+
_FAILED = re.compile(r"^Login failed:\s*", re.IGNORECASE)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _failure(output: list[str]) -> str | None:
|
|
298
|
+
"""Why ``claude auth login`` failed: its last line of its own, ``unsupported
|
|
299
|
+
certificate purpose`` from ``Login failed: unsupported certificate purpose``.
|
|
300
|
+
|
|
301
|
+
Not the URL it printed before - the screen already showed that, and it is
|
|
302
|
+
wider than the notice - nor the paste prompt the reason is printed after.
|
|
303
|
+
"""
|
|
304
|
+
for line in reversed(output):
|
|
305
|
+
line = _FAILED.sub("", _PASTE_PROMPT.sub("", line)).strip()
|
|
306
|
+
if line and not _URL.search(line):
|
|
307
|
+
return line
|
|
308
|
+
return None
|
|
293
309
|
|
|
294
310
|
|
|
295
311
|
class LoginFailed(Exception):
|
|
@@ -361,8 +377,8 @@ async def login_interactive(interaction: LoginInteraction) -> LoginStatus:
|
|
|
361
377
|
forget_status()
|
|
362
378
|
|
|
363
379
|
if code != 0:
|
|
364
|
-
|
|
365
|
-
raise LoginFailed(f"`claude auth login` failed: {
|
|
380
|
+
reason = _failure(output) or f"exit {code}"
|
|
381
|
+
raise LoginFailed(net.with_tls_hint(f"`claude auth login` failed: {reason}"))
|
|
366
382
|
current = await status_async(fresh=True)
|
|
367
383
|
if current is None or not current.logged_in:
|
|
368
384
|
raise LoginFailed("Claude Code still reports no login.")
|
|
@@ -291,13 +291,10 @@ class Runtime:
|
|
|
291
291
|
self.notices.append(f"Model catalogue refresh failed: {describe(exc)}")
|
|
292
292
|
return
|
|
293
293
|
finally:
|
|
294
|
-
|
|
295
|
-
# being replaced. Left alone it would gauge against yesterday's
|
|
296
|
-
# context window and decide image support from a cache that did
|
|
297
|
-
# not record it - until the user happened to switch models.
|
|
298
|
-
self.loop.model_info = self.models.get_or_default(self.loop.model)
|
|
294
|
+
self.loop.catalogue_refreshed()
|
|
299
295
|
for label, error in self.models.subscription_errors():
|
|
300
296
|
self.notices.append(f"Could not list this account's {label} models: {error}")
|
|
297
|
+
self.notices.extend(self.models.subscription_advice())
|
|
301
298
|
|
|
302
299
|
async def aclose(self) -> None:
|
|
303
300
|
if self.mcp is not None:
|
|
@@ -489,6 +486,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
489
486
|
project_context=project_context,
|
|
490
487
|
instructions=instructions,
|
|
491
488
|
hooks=hooks,
|
|
489
|
+
models=models,
|
|
492
490
|
)
|
|
493
491
|
|
|
494
492
|
return Runtime(
|
|
@@ -1303,6 +1301,8 @@ def _report_models_after_login(provider_id: str) -> None:
|
|
|
1303
1301
|
print(f"Could not refresh the model list: {describe(exc)}", file=sys.stderr)
|
|
1304
1302
|
for label, error in models.subscription_errors():
|
|
1305
1303
|
print(f"Could not list this account's {label} models: {error}", file=sys.stderr)
|
|
1304
|
+
for advice in models.subscription_advice():
|
|
1305
|
+
print(advice, file=sys.stderr)
|
|
1306
1306
|
|
|
1307
1307
|
available = [m.id for m in models.all() if m.provider_id == provider_id]
|
|
1308
1308
|
if not available:
|
|
@@ -1432,8 +1432,10 @@ def _choose_provider() -> str:
|
|
|
1432
1432
|
print("Sign in to:")
|
|
1433
1433
|
for index, spec in enumerate(registry.SPECS, start=1):
|
|
1434
1434
|
print(f" {index} {spec.label}")
|
|
1435
|
+
# Enter still means OpenRouter, wherever it sits in the list.
|
|
1436
|
+
default = str(next(i for i, s in enumerate(registry.SPECS, start=1) if s.id == OPENROUTER))
|
|
1435
1437
|
try:
|
|
1436
|
-
choice = input("Choice [
|
|
1438
|
+
choice = input(f"Choice [{default}]: ").strip() or default
|
|
1437
1439
|
except (EOFError, KeyboardInterrupt):
|
|
1438
1440
|
print()
|
|
1439
1441
|
return OPENROUTER
|
|
@@ -66,7 +66,7 @@ if TYPE_CHECKING:
|
|
|
66
66
|
from hx.hooks.engine import HookEngine
|
|
67
67
|
from hx.permissions.engine import PermissionEngine
|
|
68
68
|
from hx.providers.base import Provider
|
|
69
|
-
from hx.providers.models import ModelInfo
|
|
69
|
+
from hx.providers.models import ModelInfo, ModelRegistry
|
|
70
70
|
from hx.skills.runtime import ActiveSkills
|
|
71
71
|
from hx.tools.registry import ToolRegistry
|
|
72
72
|
|
|
@@ -107,6 +107,7 @@ class AgentLoop:
|
|
|
107
107
|
project_context: str | None = None,
|
|
108
108
|
instructions: list[Instructions] | None = None,
|
|
109
109
|
hooks: HookEngine | None = None,
|
|
110
|
+
models: ModelRegistry | None = None,
|
|
110
111
|
) -> None:
|
|
111
112
|
self.provider = provider
|
|
112
113
|
self.session = session
|
|
@@ -124,6 +125,9 @@ class AgentLoop:
|
|
|
124
125
|
self.instructions = instructions or []
|
|
125
126
|
"""The AGENTS.md files folded into ``project_context``, least specific first."""
|
|
126
127
|
self.hooks = hooks
|
|
128
|
+
self.models = models
|
|
129
|
+
"""Where a window the backend reports is kept, so the picker and the
|
|
130
|
+
next session start from it rather than from the catalogue's guess."""
|
|
127
131
|
self._cancelled = False
|
|
128
132
|
self._turn_index = 0
|
|
129
133
|
self._steer: list[UserTurn] = []
|
|
@@ -155,6 +159,19 @@ class AgentLoop:
|
|
|
155
159
|
self.compactor.provider = provider
|
|
156
160
|
return previous
|
|
157
161
|
|
|
162
|
+
def catalogue_refreshed(self) -> None:
|
|
163
|
+
"""Re-read the model in force from a catalogue that has just changed.
|
|
164
|
+
|
|
165
|
+
It was resolved from the one being replaced. Left alone, the window the
|
|
166
|
+
gauge and the compaction threshold measure against would stay the old
|
|
167
|
+
one, and image support would be decided from it, until the user
|
|
168
|
+
happened to switch models.
|
|
169
|
+
"""
|
|
170
|
+
if self.models is None:
|
|
171
|
+
return
|
|
172
|
+
self.model_info = self.models.get_or_default(self.model)
|
|
173
|
+
self.session.usage.context_window = self.model_info.context_window
|
|
174
|
+
|
|
158
175
|
def set_model(self, model_id: str, model_info: ModelInfo | None = None) -> None:
|
|
159
176
|
self.session.meta.model = model_id
|
|
160
177
|
self.model_info = model_info
|
|
@@ -450,6 +467,7 @@ class AgentLoop:
|
|
|
450
467
|
if isinstance(item, StreamEnd):
|
|
451
468
|
stop_reason = item.stop_reason
|
|
452
469
|
usage = item.usage
|
|
470
|
+
self._adopt_limits(item)
|
|
453
471
|
continue
|
|
454
472
|
if not isinstance(item, StreamDelta): # pragma: no cover - defensive
|
|
455
473
|
continue
|
|
@@ -581,6 +599,26 @@ class AgentLoop:
|
|
|
581
599
|
blocks.extend(tool_calls)
|
|
582
600
|
return assistant_message(blocks, model=self.model)
|
|
583
601
|
|
|
602
|
+
def _adopt_limits(self, end: StreamEnd) -> None:
|
|
603
|
+
"""Take the window the backend reported over the catalogue's figure.
|
|
604
|
+
|
|
605
|
+
Before the usage update that follows, so the gauge it drives - and the
|
|
606
|
+
compaction threshold measured against the same window - move together.
|
|
607
|
+
Kept in the catalogue too, by every mode and every subagent alike: a
|
|
608
|
+
figure only the live session knew would be the guess again next time.
|
|
609
|
+
"""
|
|
610
|
+
info = self.model_info
|
|
611
|
+
if info is None or info.id != self.model:
|
|
612
|
+
return
|
|
613
|
+
window = end.context_window or info.context_window
|
|
614
|
+
output = end.max_output_tokens or info.max_output_tokens
|
|
615
|
+
if (window, output) == (info.context_window, info.max_output_tokens):
|
|
616
|
+
return
|
|
617
|
+
self.model_info = replace(info, context_window=window, max_output_tokens=output)
|
|
618
|
+
self.session.usage.context_window = window
|
|
619
|
+
if self.models is not None:
|
|
620
|
+
self.models.adopt_limits(info.id, window, output)
|
|
621
|
+
|
|
584
622
|
def _record_usage(
|
|
585
623
|
self,
|
|
586
624
|
usage: TurnUsage,
|
|
@@ -41,11 +41,38 @@ _TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def ca_bundle() -> Path | None:
|
|
44
|
-
"""The CA bundle the environment names, if it points at a
|
|
44
|
+
"""The CA bundle the environment names, if it points at a file of certificates."""
|
|
45
|
+
named = _named_bundle()
|
|
46
|
+
if named is None or _load_error(named[1]) is not None:
|
|
47
|
+
return None
|
|
48
|
+
return named[1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _named_bundle() -> tuple[str, Path] | None:
|
|
52
|
+
"""The first of :data:`CA_BUNDLE_VARS` that names a real file, and that file."""
|
|
45
53
|
for name in CA_BUNDLE_VARS:
|
|
46
54
|
raw = (os.environ.get(name) or "").strip()
|
|
47
55
|
if raw and Path(raw).expanduser().is_file():
|
|
48
|
-
return Path(raw).expanduser()
|
|
56
|
+
return name, Path(raw).expanduser()
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_error(path: Path) -> str | None:
|
|
61
|
+
"""Why ``path`` cannot serve as a CA bundle - a DER file, a key, an empty
|
|
62
|
+
file - or ``None`` when it can. Asked again only when the file changes."""
|
|
63
|
+
try:
|
|
64
|
+
stat = path.stat()
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
return exc.strerror or str(exc)
|
|
67
|
+
return _load_error_of(path, stat.st_mtime_ns, stat.st_size)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@lru_cache(maxsize=4)
|
|
71
|
+
def _load_error_of(path: Path, mtime_ns: int, size: int) -> str | None:
|
|
72
|
+
try:
|
|
73
|
+
ssl.create_default_context(cafile=str(path))
|
|
74
|
+
except (ssl.SSLError, OSError, ValueError) as exc:
|
|
75
|
+
return _flatten(getattr(exc, "reason", None) or str(exc))
|
|
49
76
|
return None
|
|
50
77
|
|
|
51
78
|
|
|
@@ -80,6 +107,15 @@ def tls_notice() -> str | None:
|
|
|
80
107
|
"""One line for the startup transcript when TLS is not stock."""
|
|
81
108
|
if verification_disabled():
|
|
82
109
|
return f"{NO_VERIFY_VAR} is set: TLS certificates are not being verified."
|
|
110
|
+
named = _named_bundle()
|
|
111
|
+
if named is not None and (error := _load_error(named[1])) is not None:
|
|
112
|
+
# Ignored, as OpenSSL and Node ignore a CA file they cannot read: failing
|
|
113
|
+
# to start would cost every route, the ones this file is not for too.
|
|
114
|
+
name, path = named
|
|
115
|
+
return (
|
|
116
|
+
f"{name} names {path}, which holds no usable CA certificates ({error}). "
|
|
117
|
+
f"Verifying TLS against the system trust store instead."
|
|
118
|
+
)
|
|
83
119
|
bundle = ca_bundle()
|
|
84
120
|
return f"Verifying TLS against {bundle}." if bundle is not None else None
|
|
85
121
|
|
|
@@ -108,13 +144,29 @@ _CERT_FAILURE = re.compile(
|
|
|
108
144
|
r"certificate verif"
|
|
109
145
|
r"|self[- ]signed certificate"
|
|
110
146
|
r"|unable to (?:get local issuer|verify the first) certificate"
|
|
111
|
-
r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN"
|
|
147
|
+
r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN"
|
|
148
|
+
r"|certificate purpose|INVALID_PURPOSE",
|
|
112
149
|
re.IGNORECASE,
|
|
113
150
|
)
|
|
114
151
|
|
|
152
|
+
_WRONG_PURPOSE = re.compile(r"(?:unsupported|unsuitable) certificate purpose|INVALID_PURPOSE")
|
|
153
|
+
"""A chain the client does trust, but with a certificate in it - typically the
|
|
154
|
+
issuing CA of a network that intercepts TLS - that may not serve as a TLS
|
|
155
|
+
server's: OpenSSL says "unsuitable", the BoringSSL in Claude Code "unsupported"."""
|
|
156
|
+
|
|
115
157
|
|
|
116
|
-
def tls_hint() -> str:
|
|
117
|
-
"""What to do about a certificate the client would not
|
|
158
|
+
def tls_hint(reason: str = "") -> str:
|
|
159
|
+
"""What to do about a certificate the client would not accept.
|
|
160
|
+
|
|
161
|
+
``reason`` is the client's own words, when there are any: a certificate of
|
|
162
|
+
the wrong purpose is already trusted, so adding its CA again changes nothing.
|
|
163
|
+
"""
|
|
164
|
+
if _WRONG_PURPOSE.search(reason):
|
|
165
|
+
return (
|
|
166
|
+
"Your network intercepts TLS with a certificate that is not valid for servers. "
|
|
167
|
+
"Ask its administrator to fix that certificate or to exclude this service from "
|
|
168
|
+
f"inspection; until then, {NO_VERIFY_VAR}=1 turns verification off."
|
|
169
|
+
)
|
|
118
170
|
bundle = ca_bundle()
|
|
119
171
|
if bundle is not None:
|
|
120
172
|
return (
|
|
@@ -130,7 +182,7 @@ def tls_hint() -> str:
|
|
|
130
182
|
def with_tls_hint(message: str) -> str:
|
|
131
183
|
"""``message``, followed by :func:`tls_hint` when it reports a certificate
|
|
132
184
|
failure - in any client's words: OpenSSL's, Node's, Claude Code's."""
|
|
133
|
-
hint = tls_hint()
|
|
185
|
+
hint = tls_hint(message)
|
|
134
186
|
if not _CERT_FAILURE.search(message) or hint in message:
|
|
135
187
|
return message
|
|
136
188
|
return f"{message.rstrip().rstrip('.')}. {hint}"
|
|
@@ -156,7 +208,8 @@ def describe(exc: BaseException) -> str:
|
|
|
156
208
|
or getattr(err, "reason", None)
|
|
157
209
|
or "certificate verify failed"
|
|
158
210
|
)
|
|
159
|
-
|
|
211
|
+
reason = _flatten(reason)
|
|
212
|
+
return f"TLS certificate verification failed ({reason}). {tls_hint(reason)}"
|
|
160
213
|
if isinstance(err, ssl.SSLError):
|
|
161
214
|
return f"TLS handshake failed: {_flatten(err.reason or str(err))}."
|
|
162
215
|
if isinstance(exc, httpx.TimeoutException):
|
|
@@ -38,6 +38,11 @@ class StreamDelta:
|
|
|
38
38
|
class StreamEnd:
|
|
39
39
|
stop_reason: StopReason
|
|
40
40
|
usage: TurnUsage = field(default_factory=TurnUsage)
|
|
41
|
+
context_window: int | None = None
|
|
42
|
+
"""The model's window as the backend reported it running this turn, when
|
|
43
|
+
it does. Truer than the catalogue's figure, which for some routes is a
|
|
44
|
+
guess: Claude Code lists its models without their sizes."""
|
|
45
|
+
max_output_tokens: int | None = None
|
|
41
46
|
|
|
42
47
|
|
|
43
48
|
StreamItem = StreamDelta | StreamEnd
|
|
@@ -51,6 +51,7 @@ from hx.core.usage import TurnUsage
|
|
|
51
51
|
from hx.net import with_tls_hint
|
|
52
52
|
from hx.providers import claude_code_transcript as transcript
|
|
53
53
|
from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd, StreamItem
|
|
54
|
+
from hx.providers.claude_code_catalogue import DATED
|
|
54
55
|
from hx.providers.claude_code_cli import CliProcess, Exit, ServedTool, ToolServer
|
|
55
56
|
|
|
56
57
|
PROVIDER_ID = cc_auth.PROVIDER_ID
|
|
@@ -435,7 +436,7 @@ class ClaudeCodeProvider:
|
|
|
435
436
|
frame = await frames.get()
|
|
436
437
|
if isinstance(frame, Exit):
|
|
437
438
|
conversation.state = State.DEAD
|
|
438
|
-
raise ProviderError(_exit_message(frame, turn.rate_limit))
|
|
439
|
+
raise ProviderError(_exit_message(frame, turn.rate_limit, request.model))
|
|
439
440
|
kind = frame.get("type")
|
|
440
441
|
if kind == "stream_event" and frame.get("parent_tool_use_id") is None:
|
|
441
442
|
for item in turn.consume(frame.get("event") or {}):
|
|
@@ -465,7 +466,13 @@ class ClaudeCodeProvider:
|
|
|
465
466
|
payload = from_payload(turn)
|
|
466
467
|
if payload is not None:
|
|
467
468
|
yield payload
|
|
468
|
-
|
|
469
|
+
window, output = _reported_limits(frame, request.model)
|
|
470
|
+
yield StreamEnd(
|
|
471
|
+
turn.stop_reason(),
|
|
472
|
+
turn.finish_usage(started),
|
|
473
|
+
context_window=window,
|
|
474
|
+
max_output_tokens=output,
|
|
475
|
+
)
|
|
469
476
|
return
|
|
470
477
|
|
|
471
478
|
def _remember(self, conversation: Conversation, turn: _Turn) -> None:
|
|
@@ -800,6 +807,30 @@ def _wire_model(model_id: str) -> str:
|
|
|
800
807
|
return model_id[len(MODEL_PREFIX) :] if model_id.startswith(MODEL_PREFIX) else model_id
|
|
801
808
|
|
|
802
809
|
|
|
810
|
+
def _reported_limits(frame: dict[str, Any], model_id: str) -> tuple[int | None, int | None]:
|
|
811
|
+
"""``(contextWindow, maxOutputTokens)`` Claude Code reports for the model run.
|
|
812
|
+
|
|
813
|
+
``modelUsage`` is keyed by the resolved name, and lists every model the
|
|
814
|
+
turn touched - one Claude Code chose for itself among them - so only the
|
|
815
|
+
entry for the model asked for is taken. Started by alias (``opus``), that
|
|
816
|
+
entry cannot be told apart and nothing is taken.
|
|
817
|
+
"""
|
|
818
|
+
wanted = _wire_model(model_id)
|
|
819
|
+
usage = frame.get("modelUsage")
|
|
820
|
+
for name, entry in usage.items() if isinstance(usage, dict) else ():
|
|
821
|
+
if not isinstance(entry, dict):
|
|
822
|
+
continue
|
|
823
|
+
names = {DATED.sub("", str(n)) for n in (name, entry.get("canonicalModel")) if n}
|
|
824
|
+
if wanted not in names:
|
|
825
|
+
continue
|
|
826
|
+
window, output = entry.get("contextWindow"), entry.get("maxOutputTokens")
|
|
827
|
+
return (
|
|
828
|
+
window if isinstance(window, int) and window > 0 else None,
|
|
829
|
+
output if isinstance(output, int) and output > 0 else None,
|
|
830
|
+
)
|
|
831
|
+
return None, None
|
|
832
|
+
|
|
833
|
+
|
|
803
834
|
def _private_file(text: str) -> Path:
|
|
804
835
|
fd, name = tempfile.mkstemp(prefix="hx-claude-", suffix=".md")
|
|
805
836
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
@@ -833,7 +864,9 @@ def _startup_failure(message: str, cli: CliProcess) -> str:
|
|
|
833
864
|
return with_tls_hint(f"Claude Code failed to start: {message}")
|
|
834
865
|
|
|
835
866
|
|
|
836
|
-
def _exit_message(frame: Exit, rate_limit: dict[str, Any] | None) -> str:
|
|
867
|
+
def _exit_message(frame: Exit, rate_limit: dict[str, Any] | None, model_id: str) -> str:
|
|
868
|
+
if _needs_credits(rate_limit, frame.stderr):
|
|
869
|
+
return _credits_message(model_id, frame.stderr.strip())
|
|
837
870
|
if rate_limit:
|
|
838
871
|
return _limit_message(rate_limit)
|
|
839
872
|
detail = frame.stderr.strip()
|
|
@@ -849,6 +882,8 @@ def _result_error(
|
|
|
849
882
|
errors = frame.get("errors")
|
|
850
883
|
text = "; ".join(str(e) for e in errors) if isinstance(errors, list) and errors else ""
|
|
851
884
|
text = text or str(frame.get("result") or frame.get("subtype") or "unknown error")
|
|
885
|
+
if _needs_credits(rate_limit, text):
|
|
886
|
+
return _credits_message(request.model, text)
|
|
852
887
|
if _billed_as_extra_usage(frame, text, rate_limit):
|
|
853
888
|
return _extra_usage_message(request, text)
|
|
854
889
|
if rate_limit or "usage limit" in text.lower() or "out of extra usage" in text.lower():
|
|
@@ -858,6 +893,40 @@ def _result_error(
|
|
|
858
893
|
return with_tls_hint(f"Claude Code: {text.strip()}")
|
|
859
894
|
|
|
860
895
|
|
|
896
|
+
def _needs_credits(rate_limit: dict[str, Any] | None, text: str) -> bool:
|
|
897
|
+
"""A turn refused because the model, or its long context, needs usage credits.
|
|
898
|
+
|
|
899
|
+
Claude Code reports it as a rejected rate limit, with the billing period's
|
|
900
|
+
reset beside it, but no usage window is reached: the plan does not include
|
|
901
|
+
what was asked for, and it fails the same way until credits are turned on.
|
|
902
|
+
"""
|
|
903
|
+
if (rate_limit or {}).get("errorCode") == "credits_required":
|
|
904
|
+
return True
|
|
905
|
+
lowered = text.lower()
|
|
906
|
+
return "requires usage credits" in lowered or "usage credits required" in lowered
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _credits_message(model_id: str, detail: str) -> str:
|
|
910
|
+
"""Said in HX's words alone: Claude Code's own adds a link meant for its
|
|
911
|
+
CLI and advice for flags HX sets itself."""
|
|
912
|
+
model = _wire_model(model_id)
|
|
913
|
+
base = model.removesuffix("[1m]")
|
|
914
|
+
lowered = detail.lower()
|
|
915
|
+
# Asked for at 1M, it is the long context that needs them - unless what was
|
|
916
|
+
# said names the model itself: "Fable 5.1 requires usage credits".
|
|
917
|
+
names_model = "requires usage credits" in lowered
|
|
918
|
+
if "1m context" in lowered or "long context" in lowered or (model != base and not names_model):
|
|
919
|
+
what = f"The 1M context of {base} needs"
|
|
920
|
+
instead = f"pick {MODEL_PREFIX}{base} for its standard context"
|
|
921
|
+
else:
|
|
922
|
+
what = f"{base} needs"
|
|
923
|
+
instead = "pick another model"
|
|
924
|
+
return (
|
|
925
|
+
f"{what} usage credits, which your Claude plan has off - its usage limits are not "
|
|
926
|
+
f"reached. Turn them on at claude.ai/settings/usage and send again, or {instead}."
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
861
930
|
def _billed_as_extra_usage(
|
|
862
931
|
frame: dict[str, Any], text: str, rate_limit: dict[str, Any] | None
|
|
863
932
|
) -> bool:
|