phern 0.2.0__tar.gz → 0.4.0__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.
- {phern-0.2.0 → phern-0.4.0}/PKG-INFO +9 -10
- {phern-0.2.0 → phern-0.4.0}/pyproject.toml +9 -10
- phern-0.4.0/src/ph_app/__init__.py +12 -0
- phern-0.4.0/src/ph_app/adapters/_http.py +398 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/anthropic.py +90 -15
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/google.py +76 -40
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/openai_compatible.py +69 -5
- phern-0.4.0/src/ph_app/adapters/sse.py +91 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/agents.py +4 -3
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/cli.py +33 -10
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/client.py +3 -3
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/duplex.py +20 -11
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/frontend.py +32 -15
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/launch.py +14 -11
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/server.py +130 -46
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/supervisor.py +371 -109
- phern-0.4.0/src/ph_app/kinds.py +84 -0
- phern-0.4.0/src/ph_app/modes/print_mode.py +108 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/rpc_mode.py +2 -1
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/transcript_mode.py +4 -2
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/params.py +8 -3
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/payloads.py +41 -2
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/protocol.py +66 -6
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/runtime.py +9 -70
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/shell.py +72 -32
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/trust.py +6 -2
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/adapter.py +121 -20
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/app.py +172 -17
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/commands.py +37 -4
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/config.py +11 -2
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/frontend.py +28 -3
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/base.py +18 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/remote.py +80 -14
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/state.py +51 -9
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/__init__.py +5 -2
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/trajectory.py +15 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/trajectory_app.py +29 -7
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/transcript.py +30 -5
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/verbs.py +4 -2
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/wire.py +7 -2
- {phern-0.2.0 → phern-0.4.0}/tests/daemon_helpers.py +24 -1
- {phern-0.2.0 → phern-0.4.0}/tests/test_adapters.py +567 -3
- {phern-0.2.0 → phern-0.4.0}/tests/test_agents_cli.py +43 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_app_layering.py +6 -1
- {phern-0.2.0 → phern-0.4.0}/tests/test_attachments_cli.py +2 -1
- {phern-0.2.0 → phern-0.4.0}/tests/test_cli.py +35 -9
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon.py +165 -41
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_asks.py +127 -22
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_framing.py +45 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_invariants.py +6 -4
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_lifetime.py +107 -2
- phern-0.4.0/tests/test_daemon_mutations.py +371 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_shell.py +38 -1
- {phern-0.2.0 → phern-0.4.0}/tests/test_modes.py +74 -29
- {phern-0.2.0 → phern-0.4.0}/tests/test_non_guarantees.py +3 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_payloads.py +9 -3
- {phern-0.2.0 → phern-0.4.0}/tests/test_protocol.py +2 -1
- phern-0.4.0/tests/test_runtime.py +113 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_trajectory.py +53 -24
- {phern-0.2.0 → phern-0.4.0}/tests/test_trajectory_view.py +14 -11
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_adapter.py +258 -29
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_code_cell.py +41 -25
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_copy.py +36 -2
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_pilot.py +177 -5
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_remote.py +211 -23
- phern-0.4.0/tests/test_tui_workers.py +163 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_uploads.py +32 -1
- {phern-0.2.0 → phern-0.4.0}/tests/test_uploads_google.py +115 -8
- {phern-0.2.0 → phern-0.4.0}/tests/test_workspaces_cli.py +2 -1
- {phern-0.2.0 → phern-0.4.0}/tests/tui_helpers.py +61 -2
- phern-0.2.0/src/ph_app/__init__.py +0 -5
- phern-0.2.0/src/ph_app/adapters/_http.py +0 -231
- phern-0.2.0/src/ph_app/adapters/sse.py +0 -58
- phern-0.2.0/src/ph_app/modes/print_mode.py +0 -67
- phern-0.2.0/tests/test_daemon_mutations.py +0 -190
- {phern-0.2.0 → phern-0.4.0}/.gitignore +0 -0
- {phern-0.2.0 → phern-0.4.0}/LICENSE +0 -0
- {phern-0.2.0 → phern-0.4.0}/README.md +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/__main__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/__init__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/_media.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/attach.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/attachments.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/console.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/__init__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/cancelsafe.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/cards.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/follow.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/framing.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/projections.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/recovery.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/__init__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/json_mode.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/anthropic.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/deepseek.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/google.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/llama.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/rlm-stable.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/tui.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/py.typed +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/sessions.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/__init__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/autocomplete.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/__init__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/approval.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/ask_user.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/login.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/pickers.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/trust.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/screens.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/terminal.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-frappe.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-latte.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-macchiato.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-mocha.yaml +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/high-contrast.json +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-dark.json +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-light.json +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/trajectory_screen.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/__init__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/prompt.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/selection.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/status.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/trajectory.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/web/__init__.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/web/serve.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/src/ph_app/workspaces.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_cell_shows_its_program_and_what_it_cost.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_mode_card_lists_its_dispatches.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_compaction_marker_keeps_what_it_replaced.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_failed_tool_card_and_an_error_row.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_settled_tool_card.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[high-contrast].raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[ph-light].raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_markup_in_user_text_renders_literally.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_streaming_assistant_text.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/app_fixtures.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_attach.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_cancelsafe.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_attachments.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_launch.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_methods.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_projections.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_frames.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_screens.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_snapshot.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_state.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_tui_themes.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_uploads_openai.py +0 -0
- {phern-0.2.0 → phern-0.4.0}/tests/test_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: phern
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: pH — a Python agent harness with no privileged core: every part is a row in a profile.
|
|
5
5
|
Project-URL: Homepage, https://github.com/chastabor/pH
|
|
6
6
|
Project-URL: Repository, https://github.com/chastabor/pH
|
|
@@ -22,22 +22,21 @@ Classifier: Topic :: Software Development
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: anyio<5,>=4.4
|
|
25
|
-
Requires-Dist: filelock>=3.15
|
|
26
25
|
Requires-Dist: httpx>=0.27
|
|
27
|
-
Requires-Dist: ph-code-graph==0.
|
|
28
|
-
Requires-Dist: ph-core==0.
|
|
29
|
-
Requires-Dist: ph-rlm==0.
|
|
30
|
-
Requires-Dist: ph-runtime-guest==0.
|
|
31
|
-
Requires-Dist: ph-stabilize==0.
|
|
32
|
-
Requires-Dist: ph-text-index==0.
|
|
26
|
+
Requires-Dist: ph-code-graph==0.4.0
|
|
27
|
+
Requires-Dist: ph-core==0.4.0
|
|
28
|
+
Requires-Dist: ph-rlm==0.4.0
|
|
29
|
+
Requires-Dist: ph-runtime-guest==0.4.0
|
|
30
|
+
Requires-Dist: ph-stabilize==0.4.0
|
|
31
|
+
Requires-Dist: ph-text-index==0.4.0
|
|
33
32
|
Requires-Dist: pyyaml>=6.0
|
|
34
33
|
Requires-Dist: rich>=13.0
|
|
35
34
|
Requires-Dist: textual<9,>=8.2.5
|
|
36
35
|
Requires-Dist: typer>=0.12
|
|
37
36
|
Provides-Extra: local
|
|
38
|
-
Requires-Dist: ph-text-index[local]==0.
|
|
37
|
+
Requires-Dist: ph-text-index[local]==0.4.0; extra == 'local'
|
|
39
38
|
Provides-Extra: otel
|
|
40
|
-
Requires-Dist: ph-core[otel]==0.
|
|
39
|
+
Requires-Dist: ph-core[otel]==0.4.0; extra == 'otel'
|
|
41
40
|
Provides-Extra: web
|
|
42
41
|
Requires-Dist: textual-serve>=1.1.3; extra == 'web'
|
|
43
42
|
Description-Content-Type: text/markdown
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "phern"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "pH — a Python agent harness with no privileged core: every part is a row in a profile."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -45,20 +45,19 @@ dependencies = [
|
|
|
45
45
|
# `ph.bundles` composes a profile on an install of this package, and one
|
|
46
46
|
# uninstalled from an install of this package takes its profiles out of
|
|
47
47
|
# `phern doctor` rather than breaking the six that remain.
|
|
48
|
-
"ph-core==0.
|
|
49
|
-
"ph-rlm==0.
|
|
50
|
-
"ph-stabilize==0.
|
|
51
|
-
"ph-text-index==0.
|
|
52
|
-
"ph-code-graph==0.
|
|
48
|
+
"ph-core==0.4.0",
|
|
49
|
+
"ph-rlm==0.4.0",
|
|
50
|
+
"ph-stabilize==0.4.0",
|
|
51
|
+
"ph-text-index==0.4.0",
|
|
52
|
+
"ph-code-graph==0.4.0",
|
|
53
53
|
# Reached through `ph-rlm` already; pinned here too because `ph_rlm.kernel`
|
|
54
54
|
# installs it *by name* into `$PH_CACHE/runtime-venv`, so the version this
|
|
55
55
|
# deployment resolves is the version a cell runs against.
|
|
56
|
-
"ph-runtime-guest==0.
|
|
56
|
+
"ph-runtime-guest==0.4.0",
|
|
57
57
|
# Declared although `ph-core` already requires it: `ph_app.daemon` imports
|
|
58
58
|
# `anyio` directly at 20-odd sites and `cancelsafe` is keyed to its
|
|
59
59
|
# internals, so arriving here transitively understated the coupling.
|
|
60
60
|
"anyio>=4.4,<5",
|
|
61
|
-
"filelock>=3.15",
|
|
62
61
|
# Declared although `ph-core` already requires it, for `anyio`'s reason above:
|
|
63
62
|
# `ph_app` imports `yaml` directly — `cli.py` to print a composed profile, and
|
|
64
63
|
# `tui.themes` to write `$PH_HOME/themes/theme-profile.yaml` — so arriving here
|
|
@@ -112,13 +111,13 @@ web = ["textual-serve>=1.1.3"]
|
|
|
112
111
|
# default CUDA wheels. Without it `text_index` and `text_search` still register
|
|
113
112
|
# and still search an index you supply vectors for — what refuses to mount is
|
|
114
113
|
# the row that *computes* them, with a message naming this extra.
|
|
115
|
-
local = ["ph-text-index[local]==0.
|
|
114
|
+
local = ["ph-text-index[local]==0.4.0"]
|
|
116
115
|
|
|
117
116
|
# Exporting the session log to an OTel collector, passed through for the same
|
|
118
117
|
# reason `local` is: `ph-core` owns the row and the refusal message, but `phern`
|
|
119
118
|
# is the name a person installs, so all three of this deployment's extras should
|
|
120
119
|
# be reachable without first learning which member implements which.
|
|
121
|
-
otel = ["ph-core[otel]==0.
|
|
120
|
+
otel = ["ph-core[otel]==0.4.0"]
|
|
122
121
|
|
|
123
122
|
[project.entry-points."ph.plugins"]
|
|
124
123
|
llm-openai-compatible = "ph_app.adapters.openai_compatible:apply"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""`ph_app` — the pH command line, and from Phase 2 the Textual TUI.
|
|
2
|
+
|
|
3
|
+
**Importing any of the app declares its intent kinds** (T4): `kinds` is imported here,
|
|
4
|
+
statically, so a process that loaded any part of the app — and so may resume a log the
|
|
5
|
+
app wrote — has `CLIENT_COMMAND` for repair to settle. See `ph_app.kinds`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import kinds as kinds
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = ["kinds"]
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""What every HTTP-streaming adapter shares: the client, the credential, the failure.
|
|
2
|
+
|
|
3
|
+
The three wires pH speaks differ in message shape, in how usage is reported, and
|
|
4
|
+
— since P7-03 — in how a file API is shaped. They do **not** differ in how a
|
|
5
|
+
request is sent, how a secret reaches a header, or what an HTTP status means for
|
|
6
|
+
retry, and when those were written twice the copies drifted (one overflow
|
|
7
|
+
heuristic matched `max_tokens` anywhere in a body, turning a bad-request 400 into
|
|
8
|
+
a compaction trigger). So they live here once.
|
|
9
|
+
|
|
10
|
+
One `httpx.AsyncClient` per adapter, not per request: creating a client inside
|
|
11
|
+
`stream()` pays a fresh TCP connect and TLS handshake on every model call and
|
|
12
|
+
never reaches keep-alive or HTTP/2 multiplexing. The adapter's row disposes the
|
|
13
|
+
client with its scope.
|
|
14
|
+
|
|
15
|
+
@module ph_app.adapters._http
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from ph.cordis import Context
|
|
28
|
+
from ph.json import as_str
|
|
29
|
+
from ph.keys import CREDENTIALS
|
|
30
|
+
from ph.llm.adapter import LlmError
|
|
31
|
+
from ph.llm.types import (
|
|
32
|
+
CONTEXT_WINDOW_EXCEEDED,
|
|
33
|
+
FILE_EXPIRED,
|
|
34
|
+
Finish,
|
|
35
|
+
FinishReason,
|
|
36
|
+
LlmFailure,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from .sse import iter_sse
|
|
40
|
+
|
|
41
|
+
__all__ = ["HttpClient", "failure_from_status", "resolve_secret", "wire_error_finish"]
|
|
42
|
+
|
|
43
|
+
TIMEOUT = httpx.Timeout(600.0, connect=15.0)
|
|
44
|
+
|
|
45
|
+
_STATUS_CODES = {429: "RATE_LIMIT", 401: "AUTHENTICATION", 403: "AUTHENTICATION", 529: "OVERLOADED"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_secret(ctx: Context, env_name: str, provider: str) -> str:
|
|
49
|
+
"""Turn a credential *name* into its value — here, at the edge, and nowhere above (I-3).
|
|
50
|
+
|
|
51
|
+
The value goes into a local that goes out of scope with the request. Nothing
|
|
52
|
+
that traveled to get here held it.
|
|
53
|
+
"""
|
|
54
|
+
credentials = ctx.get(CREDENTIALS)
|
|
55
|
+
if credentials is None:
|
|
56
|
+
raise LlmError("ctx.credentials is not mounted", "NO_CREDENTIALS")
|
|
57
|
+
secret = credentials.resolve(credentials.reference(env_name))
|
|
58
|
+
if secret is None:
|
|
59
|
+
raise LlmError(
|
|
60
|
+
f'{env_name} is not set, so provider "{provider}" cannot be called',
|
|
61
|
+
"MISSING_CREDENTIAL",
|
|
62
|
+
)
|
|
63
|
+
value: str = secret.reveal()
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _refined_code(
|
|
68
|
+
code: str,
|
|
69
|
+
body: str,
|
|
70
|
+
*,
|
|
71
|
+
is_overflow: Callable[[str], bool],
|
|
72
|
+
is_missing_file: Callable[[str], bool] | None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""The wire-specific judgements, applied to a code the shape already gave.
|
|
75
|
+
|
|
76
|
+
**One ladder, because two of them is the bug this module is named for.**
|
|
77
|
+
Both entry points start from a different table — a status from
|
|
78
|
+
`_STATUS_CODES`, an error frame's `type` from `_WIRE_ERROR_CODES` — and then
|
|
79
|
+
ask the same two questions of the same body in the same order. Those four
|
|
80
|
+
lines were written twice, and the order is load-bearing: overflow is applied
|
|
81
|
+
last because it is the one a caller can act on, and a body that reads as
|
|
82
|
+
both is an overflow. Kept apart, that ordering lived in two docstrings and
|
|
83
|
+
nothing checked that they agreed.
|
|
84
|
+
"""
|
|
85
|
+
if is_missing_file is not None and is_missing_file(body):
|
|
86
|
+
code = FILE_EXPIRED
|
|
87
|
+
if is_overflow(body):
|
|
88
|
+
code = CONTEXT_WINDOW_EXCEEDED
|
|
89
|
+
return code
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def failure_from_status(
|
|
93
|
+
status: int,
|
|
94
|
+
body: str,
|
|
95
|
+
*,
|
|
96
|
+
is_overflow: Callable[[str], bool],
|
|
97
|
+
is_missing_file: Callable[[str], bool] | None = None,
|
|
98
|
+
) -> LlmError:
|
|
99
|
+
"""Classify an HTTP error into the codes the retry policy routes on.
|
|
100
|
+
|
|
101
|
+
The wire-specific judgements are callbacks because each provider phrases them
|
|
102
|
+
differently, and both are expensive to get wrong in either direction: a missed
|
|
103
|
+
overflow retries forever, a false one compacts a conversation that fit.
|
|
104
|
+
|
|
105
|
+
`is_missing_file` is the second of them (P7-03), here rather than in an
|
|
106
|
+
adapter because a body classified once must not be re-read into a different
|
|
107
|
+
code further up — and because both wires have a file API, so the next one
|
|
108
|
+
inherits this instead of writing its own parser. Whether the missing file was
|
|
109
|
+
*ours* is a separate question only the caller can answer, and it answers it
|
|
110
|
+
against the code rather than the prose.
|
|
111
|
+
"""
|
|
112
|
+
code = _refined_code(
|
|
113
|
+
_STATUS_CODES.get(status, "SERVER_ERROR" if status >= 500 else "REQUEST_FAILED"),
|
|
114
|
+
body,
|
|
115
|
+
is_overflow=is_overflow,
|
|
116
|
+
is_missing_file=is_missing_file,
|
|
117
|
+
)
|
|
118
|
+
detail = body[:400] or f"HTTP {status}"
|
|
119
|
+
return LlmError(
|
|
120
|
+
f"provider returned {status}: {detail}",
|
|
121
|
+
code,
|
|
122
|
+
LlmFailure(message=detail, code=code, status=status),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@contextmanager
|
|
127
|
+
def _classified_transport() -> Iterator[None]:
|
|
128
|
+
"""Turn httpx's transport failures into the codes the retry policy routes on.
|
|
129
|
+
|
|
130
|
+
**The half of "the request failed" that never reaches a status.** A dropped
|
|
131
|
+
connection, a refused socket or a read that runs out of time raises out of
|
|
132
|
+
httpx rather than returning a response, so `failure_from_status` never sees
|
|
133
|
+
it and the error arrives at the retry policy as a bare exception with code
|
|
134
|
+
`UNKNOWN` — not transient, so the turn fails on the first hiccup. Both
|
|
135
|
+
`CONNECTION_ERROR` and `TIMEOUT` are in `TRANSIENT_CODES`, and before this
|
|
136
|
+
nothing in the codebase produced either: the two entries were dead and the
|
|
137
|
+
failure they were written for was the one being reported as fatal.
|
|
138
|
+
|
|
139
|
+
One `except` and a visible `isinstance`, rather than a clause per code:
|
|
140
|
+
`TimeoutException` is a *subclass* of `TransportError`, so two clauses would
|
|
141
|
+
make the classification depend on the order they are written in — a
|
|
142
|
+
correctness hazard with nothing but a comment holding it in place.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
yield
|
|
146
|
+
except httpx.TransportError as error:
|
|
147
|
+
code = "TIMEOUT" if isinstance(error, httpx.TimeoutException) else "CONNECTION_ERROR"
|
|
148
|
+
# The class name carries the diagnosis — `ConnectError`, `ReadTimeout`,
|
|
149
|
+
# `RemoteProtocolError` — and httpx's message for several of them is
|
|
150
|
+
# empty, so a bare `str(error)` would log a failure with no text at all.
|
|
151
|
+
detail = f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
|
|
152
|
+
raise LlmError(detail, code) from error
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_WIRE_ERROR_CODES = {
|
|
156
|
+
# Anthropic's `error.type` vocabulary.
|
|
157
|
+
"overloaded_error": "OVERLOADED",
|
|
158
|
+
"rate_limit_error": "RATE_LIMIT",
|
|
159
|
+
"api_error": "SERVER_ERROR",
|
|
160
|
+
"authentication_error": "AUTHENTICATION",
|
|
161
|
+
"permission_error": "AUTHENTICATION",
|
|
162
|
+
# The OpenAI-compatible wire's.
|
|
163
|
+
"server_error": "SERVER_ERROR",
|
|
164
|
+
"rate_limit_exceeded": "RATE_LIMIT",
|
|
165
|
+
"insufficient_quota": "RATE_LIMIT",
|
|
166
|
+
"service_unavailable": "SERVICE_UNAVAILABLE",
|
|
167
|
+
# Google's wire spells the same field `error.status`, in gRPC's vocabulary.
|
|
168
|
+
"RESOURCE_EXHAUSTED": "RATE_LIMIT",
|
|
169
|
+
"UNAVAILABLE": "SERVICE_UNAVAILABLE",
|
|
170
|
+
"INTERNAL": "SERVER_ERROR",
|
|
171
|
+
"DEADLINE_EXCEEDED": "TIMEOUT",
|
|
172
|
+
"UNAUTHENTICATED": "AUTHENTICATION",
|
|
173
|
+
"PERMISSION_DENIED": "AUTHENTICATION",
|
|
174
|
+
}
|
|
175
|
+
"""A mid-stream `error.type` onto the shared failure vocabulary (L3).
|
|
176
|
+
|
|
177
|
+
**The same overload has to answer the same way however it arrives.** A provider
|
|
178
|
+
under load reports it as a 529 on one request and an `{"error": {"type":
|
|
179
|
+
"overloaded_error"}}` frame inside a 200 on the next — `failure_from_status`
|
|
180
|
+
mapped the first to `OVERLOADED` and retried it, while the second became a flat
|
|
181
|
+
`PROVIDER_ERROR`, which is not in `TRANSIENT_CODES`, and failed the turn. One
|
|
182
|
+
fact, two codes, decided by which shape the provider happened to use.
|
|
183
|
+
|
|
184
|
+
Only the types whose meaning is unambiguous are here, from all three wires;
|
|
185
|
+
the three vocabularies are disjoint, so one exact-match lookup serves them and
|
|
186
|
+
no adapter has to know about another's. An unrecognized type stays
|
|
187
|
+
`PROVIDER_ERROR`, which is the honest answer for a
|
|
188
|
+
vocabulary neither this module nor `TRANSIENT_CODES` owns: retrying something
|
|
189
|
+
this does not understand is the failure mode the narrow map exists to avoid."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def wire_error_finish(
|
|
193
|
+
error: Mapping[str, Any],
|
|
194
|
+
*,
|
|
195
|
+
kind: object,
|
|
196
|
+
is_overflow: Callable[[str], bool],
|
|
197
|
+
is_missing_file: Callable[[str], bool],
|
|
198
|
+
) -> Finish:
|
|
199
|
+
"""A provider's mid-stream error frame, as the chunk that ends the turn.
|
|
200
|
+
|
|
201
|
+
The other half of `failure_from_status`, and here for the same reason. All
|
|
202
|
+
three wires report a failed request *inside* a 200 — Anthropic as an `error`
|
|
203
|
+
event, the other two as a top-level `error` object — so the status
|
|
204
|
+
classifier never sees it and each adapter built the `Finish` itself.
|
|
205
|
+
The two copies had drifted on the first commit they both existed: one read
|
|
206
|
+
the message through `str()`, which renders a non-string as `"None"` or
|
|
207
|
+
`"3"`, the other through `as_str`, which falls back to the default. That is
|
|
208
|
+
the drift this module's docstring gives as the reason it exists.
|
|
209
|
+
|
|
210
|
+
**The same three judgements as the status path, in the same order** (O1).
|
|
211
|
+
L3 gave this half a code vocabulary and stopped there, so the two halves
|
|
212
|
+
answered differently for the one failure with a remedy: a mid-stream
|
|
213
|
+
`{"code": "context_length_exceeded"}` — which both the Anthropic and
|
|
214
|
+
OpenAI-compatible wires send — became a flat `PROVIDER_ERROR`, and
|
|
215
|
+
`compaction.on_request_error` branches on `CONTEXT_WINDOW_EXCEEDED`, so the
|
|
216
|
+
conversation that would have fitted after a compaction never got one. That
|
|
217
|
+
is the "two answers for one fact" L3 set out to close, surviving on the
|
|
218
|
+
other axis. Overflow is applied last because it is the one a caller can act
|
|
219
|
+
on.
|
|
220
|
+
|
|
221
|
+
**The whole frame is offered to the callbacks, not just its message**, which
|
|
222
|
+
is what makes the two halves classify the same text: the status path hands
|
|
223
|
+
them the raw body, `code` and all, so a callback written against one cannot
|
|
224
|
+
quietly mean something else against the other.
|
|
225
|
+
|
|
226
|
+
`kind` is the frame's own `error.type`, mapped through `_WIRE_ERROR_CODES`
|
|
227
|
+
so a retryable failure is retryable whichever shape it arrived in. Required
|
|
228
|
+
and keyword-only, because an adapter that forgot it would silently get
|
|
229
|
+
`PROVIDER_ERROR` back — which is the shape this exists to prevent. A type
|
|
230
|
+
nobody has mapped still answers `PROVIDER_ERROR`; that is the honest case.
|
|
231
|
+
|
|
232
|
+
**Both predicates are required here**, unlike on the status path, for that
|
|
233
|
+
same argument: every wire that streams has a file API, so an omitted
|
|
234
|
+
`is_missing_file` is an oversight rather than a choice, and the silence it
|
|
235
|
+
buys is an expired file reported as `PROVIDER_ERROR` — a code with no
|
|
236
|
+
remedy standing in for one that has. `failure_from_status` keeps the
|
|
237
|
+
default because `post_json` and `get_json` genuinely call it from paths
|
|
238
|
+
with no file in hand.
|
|
239
|
+
"""
|
|
240
|
+
code = _refined_code(
|
|
241
|
+
_WIRE_ERROR_CODES.get(as_str(kind), "PROVIDER_ERROR"),
|
|
242
|
+
json.dumps(dict(error), default=str),
|
|
243
|
+
is_overflow=is_overflow,
|
|
244
|
+
is_missing_file=is_missing_file,
|
|
245
|
+
)
|
|
246
|
+
failure = LlmFailure(message=as_str(error.get("message"), "provider error"), code=code)
|
|
247
|
+
return Finish(reason=FinishReason(kind="error", failure=failure))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class HttpClient:
|
|
251
|
+
"""A lazily-created, long-lived `httpx.AsyncClient` with one streaming shape."""
|
|
252
|
+
|
|
253
|
+
def __init__(self) -> None:
|
|
254
|
+
self._client: httpx.AsyncClient | None = None
|
|
255
|
+
|
|
256
|
+
def _get(self) -> httpx.AsyncClient:
|
|
257
|
+
if self._client is None:
|
|
258
|
+
self._client = httpx.AsyncClient(timeout=TIMEOUT)
|
|
259
|
+
return self._client
|
|
260
|
+
|
|
261
|
+
async def aclose(self) -> None:
|
|
262
|
+
if self._client is not None:
|
|
263
|
+
await self._client.aclose()
|
|
264
|
+
self._client = None
|
|
265
|
+
|
|
266
|
+
async def post_multipart(
|
|
267
|
+
self,
|
|
268
|
+
url: str,
|
|
269
|
+
*,
|
|
270
|
+
headers: dict[str, str],
|
|
271
|
+
field: str,
|
|
272
|
+
filename: str,
|
|
273
|
+
content: bytes,
|
|
274
|
+
mime: str,
|
|
275
|
+
is_overflow: Callable[[str], bool],
|
|
276
|
+
data: dict[str, str] | None = None,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
"""POST one file as multipart form data and return the parsed reply.
|
|
279
|
+
|
|
280
|
+
Here rather than in an adapter for `stream_sse`'s reason: a file API is
|
|
281
|
+
one more thing both wires have, and the status→code classification is
|
|
282
|
+
the part that must not be written twice. `Content-Type` is left to
|
|
283
|
+
`httpx`, which has to compute the multipart boundary anyway.
|
|
284
|
+
|
|
285
|
+
`data` is the form fields that ride beside the file. Anthropic's Files API
|
|
286
|
+
takes none; OpenAI's requires `purpose`, and without a way to send it the
|
|
287
|
+
second wire's uploader would have had to build its own request and inherit
|
|
288
|
+
none of the classification above (P7-03).
|
|
289
|
+
"""
|
|
290
|
+
sending = {name: value for name, value in headers.items() if name != "Content-Type"}
|
|
291
|
+
with _classified_transport():
|
|
292
|
+
response = await self._get().post(
|
|
293
|
+
url, headers=sending, files={field: (filename, content, mime)}, data=data or {}
|
|
294
|
+
)
|
|
295
|
+
if response.status_code >= 400:
|
|
296
|
+
raise failure_from_status(
|
|
297
|
+
response.status_code,
|
|
298
|
+
response.text,
|
|
299
|
+
is_overflow=is_overflow,
|
|
300
|
+
)
|
|
301
|
+
parsed: dict[str, Any] = response.json()
|
|
302
|
+
return parsed
|
|
303
|
+
|
|
304
|
+
async def post_raw(
|
|
305
|
+
self,
|
|
306
|
+
url: str,
|
|
307
|
+
*,
|
|
308
|
+
headers: dict[str, str],
|
|
309
|
+
json: dict[str, Any] | None = None,
|
|
310
|
+
content: bytes | None = None,
|
|
311
|
+
is_overflow: Callable[[str], bool],
|
|
312
|
+
) -> tuple[dict[str, Any], Mapping[str, str]]:
|
|
313
|
+
"""POST a JSON body or raw bytes; return `(parsed body, response headers)`.
|
|
314
|
+
|
|
315
|
+
The **headers** are why this exists and why it is not `post_multipart`
|
|
316
|
+
(P7-03). Google's Files API is a two-step resumable upload whose first
|
|
317
|
+
step answers with an empty body and the destination in
|
|
318
|
+
`X-Goog-Upload-URL`, and whose second step is the file's bytes with no
|
|
319
|
+
form encoding around them at all. Neither shape fits a multipart helper,
|
|
320
|
+
and both want the same status→code classification, which is the whole
|
|
321
|
+
reason this module exists.
|
|
322
|
+
|
|
323
|
+
A body that is not JSON comes back as `{}` rather than raising: the first
|
|
324
|
+
step of that upload legitimately returns nothing, and a helper that
|
|
325
|
+
insisted on JSON would make the caller catch a decode error to discover
|
|
326
|
+
success.
|
|
327
|
+
"""
|
|
328
|
+
with _classified_transport():
|
|
329
|
+
response = await self._get().post(url, headers=headers, json=json, content=content)
|
|
330
|
+
if response.status_code >= 400:
|
|
331
|
+
raise failure_from_status(response.status_code, response.text, is_overflow=is_overflow)
|
|
332
|
+
try:
|
|
333
|
+
parsed: dict[str, Any] = response.json()
|
|
334
|
+
except ValueError:
|
|
335
|
+
parsed = {}
|
|
336
|
+
return parsed, response.headers
|
|
337
|
+
|
|
338
|
+
async def get_json(
|
|
339
|
+
self,
|
|
340
|
+
url: str,
|
|
341
|
+
*,
|
|
342
|
+
headers: dict[str, str],
|
|
343
|
+
is_overflow: Callable[[str], bool],
|
|
344
|
+
timeout: float | None = None,
|
|
345
|
+
) -> dict[str, Any]:
|
|
346
|
+
"""GET and parse — the poll half of an upload that finishes asynchronously.
|
|
347
|
+
|
|
348
|
+
`timeout` overrides `TIMEOUT` for one call, and exists for the caller
|
|
349
|
+
that is not waiting on a model: a *mount-time* probe inherits a ten
|
|
350
|
+
minute read budget otherwise, so a host that accepts the socket and then
|
|
351
|
+
stalls holds up plugin mount — and with it a TUI start and `phern doctor`.
|
|
352
|
+
A request whose answer nobody is blocked on keeps the generous default.
|
|
353
|
+
"""
|
|
354
|
+
with _classified_transport():
|
|
355
|
+
response = await self._get().get(
|
|
356
|
+
url,
|
|
357
|
+
headers=headers,
|
|
358
|
+
# httpx's own sentinel rather than a splat: "say nothing and take
|
|
359
|
+
# the client's" is a value here, and spreading a conditional dict
|
|
360
|
+
# past a keyword-typed signature is a hole the checker cannot see
|
|
361
|
+
# through.
|
|
362
|
+
timeout=httpx.Timeout(timeout) if timeout is not None else httpx.USE_CLIENT_DEFAULT,
|
|
363
|
+
)
|
|
364
|
+
if response.status_code >= 400:
|
|
365
|
+
raise failure_from_status(response.status_code, response.text, is_overflow=is_overflow)
|
|
366
|
+
parsed: dict[str, Any] = response.json()
|
|
367
|
+
return parsed
|
|
368
|
+
|
|
369
|
+
async def stream_sse(
|
|
370
|
+
self,
|
|
371
|
+
url: str,
|
|
372
|
+
*,
|
|
373
|
+
headers: dict[str, str],
|
|
374
|
+
json: dict[str, Any],
|
|
375
|
+
is_overflow: Callable[[str], bool],
|
|
376
|
+
is_missing_file: Callable[[str], bool] | None = None,
|
|
377
|
+
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
|
378
|
+
"""POST and yield `(event, payload)` for every JSON SSE payload.
|
|
379
|
+
|
|
380
|
+
A non-2xx response is raised as a classified `LlmError` before any
|
|
381
|
+
payload is yielded, so a consumer never sees a half-stream.
|
|
382
|
+
"""
|
|
383
|
+
with _classified_transport():
|
|
384
|
+
async with self._get().stream("POST", url, headers=headers, json=json) as response:
|
|
385
|
+
if response.status_code >= 400:
|
|
386
|
+
body = (await response.aread()).decode("utf-8", errors="replace")
|
|
387
|
+
raise failure_from_status(
|
|
388
|
+
response.status_code,
|
|
389
|
+
body,
|
|
390
|
+
is_overflow=is_overflow,
|
|
391
|
+
is_missing_file=is_missing_file,
|
|
392
|
+
)
|
|
393
|
+
# Inside the guard, not only around the connect: a stream that
|
|
394
|
+
# dies halfway through an answer is the common shape of this
|
|
395
|
+
# failure, and it raises here rather than at the `stream` call.
|
|
396
|
+
async for event, payload in iter_sse(response):
|
|
397
|
+
if isinstance(payload, dict):
|
|
398
|
+
yield event, payload
|