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.
Files changed (152) hide show
  1. {phern-0.2.0 → phern-0.4.0}/PKG-INFO +9 -10
  2. {phern-0.2.0 → phern-0.4.0}/pyproject.toml +9 -10
  3. phern-0.4.0/src/ph_app/__init__.py +12 -0
  4. phern-0.4.0/src/ph_app/adapters/_http.py +398 -0
  5. {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/anthropic.py +90 -15
  6. {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/google.py +76 -40
  7. {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/openai_compatible.py +69 -5
  8. phern-0.4.0/src/ph_app/adapters/sse.py +91 -0
  9. {phern-0.2.0 → phern-0.4.0}/src/ph_app/agents.py +4 -3
  10. {phern-0.2.0 → phern-0.4.0}/src/ph_app/cli.py +33 -10
  11. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/client.py +3 -3
  12. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/duplex.py +20 -11
  13. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/frontend.py +32 -15
  14. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/launch.py +14 -11
  15. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/server.py +130 -46
  16. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/supervisor.py +371 -109
  17. phern-0.4.0/src/ph_app/kinds.py +84 -0
  18. phern-0.4.0/src/ph_app/modes/print_mode.py +108 -0
  19. {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/rpc_mode.py +2 -1
  20. {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/transcript_mode.py +4 -2
  21. {phern-0.2.0 → phern-0.4.0}/src/ph_app/params.py +8 -3
  22. {phern-0.2.0 → phern-0.4.0}/src/ph_app/payloads.py +41 -2
  23. {phern-0.2.0 → phern-0.4.0}/src/ph_app/protocol.py +66 -6
  24. {phern-0.2.0 → phern-0.4.0}/src/ph_app/runtime.py +9 -70
  25. {phern-0.2.0 → phern-0.4.0}/src/ph_app/shell.py +72 -32
  26. {phern-0.2.0 → phern-0.4.0}/src/ph_app/trust.py +6 -2
  27. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/adapter.py +121 -20
  28. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/app.py +172 -17
  29. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/commands.py +37 -4
  30. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/config.py +11 -2
  31. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/frontend.py +28 -3
  32. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/base.py +18 -0
  33. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/remote.py +80 -14
  34. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/state.py +51 -9
  35. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/__init__.py +5 -2
  36. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/trajectory.py +15 -0
  37. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/trajectory_app.py +29 -7
  38. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/transcript.py +30 -5
  39. {phern-0.2.0 → phern-0.4.0}/src/ph_app/verbs.py +4 -2
  40. {phern-0.2.0 → phern-0.4.0}/src/ph_app/wire.py +7 -2
  41. {phern-0.2.0 → phern-0.4.0}/tests/daemon_helpers.py +24 -1
  42. {phern-0.2.0 → phern-0.4.0}/tests/test_adapters.py +567 -3
  43. {phern-0.2.0 → phern-0.4.0}/tests/test_agents_cli.py +43 -0
  44. {phern-0.2.0 → phern-0.4.0}/tests/test_app_layering.py +6 -1
  45. {phern-0.2.0 → phern-0.4.0}/tests/test_attachments_cli.py +2 -1
  46. {phern-0.2.0 → phern-0.4.0}/tests/test_cli.py +35 -9
  47. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon.py +165 -41
  48. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_asks.py +127 -22
  49. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_framing.py +45 -0
  50. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_invariants.py +6 -4
  51. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_lifetime.py +107 -2
  52. phern-0.4.0/tests/test_daemon_mutations.py +371 -0
  53. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_shell.py +38 -1
  54. {phern-0.2.0 → phern-0.4.0}/tests/test_modes.py +74 -29
  55. {phern-0.2.0 → phern-0.4.0}/tests/test_non_guarantees.py +3 -0
  56. {phern-0.2.0 → phern-0.4.0}/tests/test_payloads.py +9 -3
  57. {phern-0.2.0 → phern-0.4.0}/tests/test_protocol.py +2 -1
  58. phern-0.4.0/tests/test_runtime.py +113 -0
  59. {phern-0.2.0 → phern-0.4.0}/tests/test_trajectory.py +53 -24
  60. {phern-0.2.0 → phern-0.4.0}/tests/test_trajectory_view.py +14 -11
  61. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_adapter.py +258 -29
  62. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_code_cell.py +41 -25
  63. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_copy.py +36 -2
  64. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_pilot.py +177 -5
  65. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_remote.py +211 -23
  66. phern-0.4.0/tests/test_tui_workers.py +163 -0
  67. {phern-0.2.0 → phern-0.4.0}/tests/test_uploads.py +32 -1
  68. {phern-0.2.0 → phern-0.4.0}/tests/test_uploads_google.py +115 -8
  69. {phern-0.2.0 → phern-0.4.0}/tests/test_workspaces_cli.py +2 -1
  70. {phern-0.2.0 → phern-0.4.0}/tests/tui_helpers.py +61 -2
  71. phern-0.2.0/src/ph_app/__init__.py +0 -5
  72. phern-0.2.0/src/ph_app/adapters/_http.py +0 -231
  73. phern-0.2.0/src/ph_app/adapters/sse.py +0 -58
  74. phern-0.2.0/src/ph_app/modes/print_mode.py +0 -67
  75. phern-0.2.0/tests/test_daemon_mutations.py +0 -190
  76. {phern-0.2.0 → phern-0.4.0}/.gitignore +0 -0
  77. {phern-0.2.0 → phern-0.4.0}/LICENSE +0 -0
  78. {phern-0.2.0 → phern-0.4.0}/README.md +0 -0
  79. {phern-0.2.0 → phern-0.4.0}/src/ph_app/__main__.py +0 -0
  80. {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/__init__.py +0 -0
  81. {phern-0.2.0 → phern-0.4.0}/src/ph_app/adapters/_media.py +0 -0
  82. {phern-0.2.0 → phern-0.4.0}/src/ph_app/attach.py +0 -0
  83. {phern-0.2.0 → phern-0.4.0}/src/ph_app/attachments.py +0 -0
  84. {phern-0.2.0 → phern-0.4.0}/src/ph_app/console.py +0 -0
  85. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/__init__.py +0 -0
  86. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/cancelsafe.py +0 -0
  87. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/cards.py +0 -0
  88. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/follow.py +0 -0
  89. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/framing.py +0 -0
  90. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/projections.py +0 -0
  91. {phern-0.2.0 → phern-0.4.0}/src/ph_app/daemon/recovery.py +0 -0
  92. {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/__init__.py +0 -0
  93. {phern-0.2.0 → phern-0.4.0}/src/ph_app/modes/json_mode.py +0 -0
  94. {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/anthropic.yaml +0 -0
  95. {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/deepseek.yaml +0 -0
  96. {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/google.yaml +0 -0
  97. {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/llama.yaml +0 -0
  98. {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/rlm-stable.yaml +0 -0
  99. {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles/tui.yaml +0 -0
  100. {phern-0.2.0 → phern-0.4.0}/src/ph_app/profiles.py +0 -0
  101. {phern-0.2.0 → phern-0.4.0}/src/ph_app/py.typed +0 -0
  102. {phern-0.2.0 → phern-0.4.0}/src/ph_app/sessions.py +0 -0
  103. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/__init__.py +0 -0
  104. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/autocomplete.py +0 -0
  105. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/__init__.py +0 -0
  106. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/approval.py +0 -0
  107. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/ask_user.py +0 -0
  108. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/login.py +0 -0
  109. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/pickers.py +0 -0
  110. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/modals/trust.py +0 -0
  111. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/screens.py +0 -0
  112. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/terminal.py +0 -0
  113. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-frappe.yaml +0 -0
  114. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-latte.yaml +0 -0
  115. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-macchiato.yaml +0 -0
  116. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-mocha.yaml +0 -0
  117. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/high-contrast.json +0 -0
  118. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-dark.json +0 -0
  119. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-light.json +0 -0
  120. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/trajectory_screen.py +0 -0
  121. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/__init__.py +0 -0
  122. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/prompt.py +0 -0
  123. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/selection.py +0 -0
  124. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/status.py +0 -0
  125. {phern-0.2.0 → phern-0.4.0}/src/ph_app/tui/widgets/trajectory.py +0 -0
  126. {phern-0.2.0 → phern-0.4.0}/src/ph_app/web/__init__.py +0 -0
  127. {phern-0.2.0 → phern-0.4.0}/src/ph_app/web/serve.py +0 -0
  128. {phern-0.2.0 → phern-0.4.0}/src/ph_app/workspaces.py +0 -0
  129. {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
  130. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_mode_card_lists_its_dispatches.raw +0 -0
  131. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_compaction_marker_keeps_what_it_replaced.raw +0 -0
  132. {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
  133. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_settled_tool_card.raw +0 -0
  134. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[high-contrast].raw +0 -0
  135. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[ph-light].raw +0 -0
  136. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_markup_in_user_text_renders_literally.raw +0 -0
  137. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_streaming_assistant_text.raw +0 -0
  138. {phern-0.2.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +0 -0
  139. {phern-0.2.0 → phern-0.4.0}/tests/app_fixtures.py +0 -0
  140. {phern-0.2.0 → phern-0.4.0}/tests/test_attach.py +0 -0
  141. {phern-0.2.0 → phern-0.4.0}/tests/test_cancelsafe.py +0 -0
  142. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_attachments.py +0 -0
  143. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_launch.py +0 -0
  144. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_methods.py +0 -0
  145. {phern-0.2.0 → phern-0.4.0}/tests/test_daemon_projections.py +0 -0
  146. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_frames.py +0 -0
  147. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_screens.py +0 -0
  148. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_snapshot.py +0 -0
  149. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_state.py +0 -0
  150. {phern-0.2.0 → phern-0.4.0}/tests/test_tui_themes.py +0 -0
  151. {phern-0.2.0 → phern-0.4.0}/tests/test_uploads_openai.py +0 -0
  152. {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.2.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.2.0
28
- Requires-Dist: ph-core==0.2.0
29
- Requires-Dist: ph-rlm==0.2.0
30
- Requires-Dist: ph-runtime-guest==0.2.0
31
- Requires-Dist: ph-stabilize==0.2.0
32
- Requires-Dist: ph-text-index==0.2.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.2.0; extra == 'local'
37
+ Requires-Dist: ph-text-index[local]==0.4.0; extra == 'local'
39
38
  Provides-Extra: otel
40
- Requires-Dist: ph-core[otel]==0.2.0; extra == 'otel'
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.2.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.2.0",
49
- "ph-rlm==0.2.0",
50
- "ph-stabilize==0.2.0",
51
- "ph-text-index==0.2.0",
52
- "ph-code-graph==0.2.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.2.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.2.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.2.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