phern 0.1.0__py3-none-any.whl
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.
- ph_app/__init__.py +5 -0
- ph_app/__main__.py +15 -0
- ph_app/adapters/__init__.py +5 -0
- ph_app/adapters/_http.py +231 -0
- ph_app/adapters/_media.py +181 -0
- ph_app/adapters/anthropic.py +635 -0
- ph_app/adapters/google.py +785 -0
- ph_app/adapters/openai_compatible.py +884 -0
- ph_app/adapters/sse.py +58 -0
- ph_app/agents.py +713 -0
- ph_app/attach.py +160 -0
- ph_app/attachments.py +179 -0
- ph_app/cli.py +875 -0
- ph_app/console.py +181 -0
- ph_app/daemon/__init__.py +32 -0
- ph_app/daemon/cancelsafe.py +163 -0
- ph_app/daemon/cards.py +97 -0
- ph_app/daemon/client.py +295 -0
- ph_app/daemon/duplex.py +389 -0
- ph_app/daemon/follow.py +234 -0
- ph_app/daemon/framing.py +134 -0
- ph_app/daemon/frontend.py +284 -0
- ph_app/daemon/launch.py +184 -0
- ph_app/daemon/projections.py +283 -0
- ph_app/daemon/recovery.py +270 -0
- ph_app/daemon/server.py +1505 -0
- ph_app/daemon/supervisor.py +1541 -0
- ph_app/modes/__init__.py +20 -0
- ph_app/modes/json_mode.py +68 -0
- ph_app/modes/print_mode.py +67 -0
- ph_app/modes/rpc_mode.py +208 -0
- ph_app/modes/transcript_mode.py +94 -0
- ph_app/params.py +205 -0
- ph_app/payloads.py +717 -0
- ph_app/profiles/anthropic.yaml +9 -0
- ph_app/profiles/deepseek.yaml +16 -0
- ph_app/profiles/google.yaml +18 -0
- ph_app/profiles/llama.yaml +118 -0
- ph_app/profiles/rlm-stable.yaml +67 -0
- ph_app/profiles/tui.yaml +36 -0
- ph_app/profiles.py +414 -0
- ph_app/protocol.py +639 -0
- ph_app/py.typed +0 -0
- ph_app/runtime.py +154 -0
- ph_app/sessions.py +251 -0
- ph_app/shell.py +221 -0
- ph_app/trust.py +80 -0
- ph_app/tui/__init__.py +21 -0
- ph_app/tui/adapter.py +1203 -0
- ph_app/tui/app.py +882 -0
- ph_app/tui/autocomplete.py +163 -0
- ph_app/tui/commands.py +221 -0
- ph_app/tui/config.py +169 -0
- ph_app/tui/frontend.py +224 -0
- ph_app/tui/modals/__init__.py +1 -0
- ph_app/tui/modals/approval.py +188 -0
- ph_app/tui/modals/ask_user.py +95 -0
- ph_app/tui/modals/base.py +230 -0
- ph_app/tui/modals/login.py +81 -0
- ph_app/tui/modals/pickers.py +226 -0
- ph_app/tui/modals/trust.py +55 -0
- ph_app/tui/remote.py +771 -0
- ph_app/tui/screens.py +141 -0
- ph_app/tui/state.py +311 -0
- ph_app/tui/terminal.py +49 -0
- ph_app/tui/themes/__init__.py +230 -0
- ph_app/tui/themes/high-contrast.json +20 -0
- ph_app/tui/themes/ph-dark.json +20 -0
- ph_app/tui/themes/ph-light.json +20 -0
- ph_app/tui/trajectory.py +533 -0
- ph_app/tui/trajectory_app.py +164 -0
- ph_app/tui/trajectory_screen.py +271 -0
- ph_app/tui/widgets/__init__.py +1 -0
- ph_app/tui/widgets/prompt.py +232 -0
- ph_app/tui/widgets/selection.py +197 -0
- ph_app/tui/widgets/status.py +370 -0
- ph_app/tui/widgets/trajectory.py +301 -0
- ph_app/tui/widgets/transcript.py +563 -0
- ph_app/verbs.py +253 -0
- ph_app/web/__init__.py +26 -0
- ph_app/web/serve.py +450 -0
- ph_app/wire.py +246 -0
- ph_app/workspaces.py +186 -0
- phern-0.1.0.dist-info/METADATA +297 -0
- phern-0.1.0.dist-info/RECORD +88 -0
- phern-0.1.0.dist-info/WHEEL +4 -0
- phern-0.1.0.dist-info/entry_points.txt +8 -0
- phern-0.1.0.dist-info/licenses/LICENSE +21 -0
ph_app/__init__.py
ADDED
ph_app/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""`python -m ph_app` — the CLI, reachable without a console script.
|
|
2
|
+
|
|
3
|
+
Exists for the daemon a UI starts on a person's behalf: `spawn_command` in
|
|
4
|
+
`ph_app.cli` says why it must be *this* interpreter rather than whatever `phern`
|
|
5
|
+
is on `PATH`.
|
|
6
|
+
|
|
7
|
+
@module ph_app.__main__
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .cli import main
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
main()
|
ph_app/adapters/_http.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from ph.cordis import Context
|
|
26
|
+
from ph.keys import CREDENTIALS
|
|
27
|
+
from ph.llm.adapter import LlmError
|
|
28
|
+
from ph.llm.types import CONTEXT_WINDOW_EXCEEDED, FILE_EXPIRED, LlmFailure
|
|
29
|
+
|
|
30
|
+
from .sse import iter_sse
|
|
31
|
+
|
|
32
|
+
__all__ = ["HttpClient", "failure_from_status", "resolve_secret"]
|
|
33
|
+
|
|
34
|
+
TIMEOUT = httpx.Timeout(600.0, connect=15.0)
|
|
35
|
+
|
|
36
|
+
_STATUS_CODES = {429: "RATE_LIMIT", 401: "AUTHENTICATION", 403: "AUTHENTICATION", 529: "OVERLOADED"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_secret(ctx: Context, env_name: str, provider: str) -> str:
|
|
40
|
+
"""Turn a credential *name* into its value — here, at the edge, and nowhere above (I-3).
|
|
41
|
+
|
|
42
|
+
The value goes into a local that goes out of scope with the request. Nothing
|
|
43
|
+
that travelled to get here held it.
|
|
44
|
+
"""
|
|
45
|
+
credentials = ctx.get(CREDENTIALS)
|
|
46
|
+
if credentials is None:
|
|
47
|
+
raise LlmError("ctx.credentials is not mounted", "NO_CREDENTIALS")
|
|
48
|
+
secret = credentials.resolve(credentials.reference(env_name))
|
|
49
|
+
if secret is None:
|
|
50
|
+
raise LlmError(
|
|
51
|
+
f'{env_name} is not set, so provider "{provider}" cannot be called',
|
|
52
|
+
"MISSING_CREDENTIAL",
|
|
53
|
+
)
|
|
54
|
+
value: str = secret.reveal()
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def failure_from_status(
|
|
59
|
+
status: int,
|
|
60
|
+
body: str,
|
|
61
|
+
*,
|
|
62
|
+
is_overflow: Callable[[str], bool],
|
|
63
|
+
is_missing_file: Callable[[str], bool] | None = None,
|
|
64
|
+
) -> LlmError:
|
|
65
|
+
"""Classify an HTTP error into the codes the retry policy routes on.
|
|
66
|
+
|
|
67
|
+
The wire-specific judgements are callbacks because each provider phrases them
|
|
68
|
+
differently, and both are expensive to get wrong in either direction: a missed
|
|
69
|
+
overflow retries forever, a false one compacts a conversation that fit.
|
|
70
|
+
|
|
71
|
+
`is_missing_file` is the second of them (P7-03), here rather than in an
|
|
72
|
+
adapter because a body classified once must not be re-read into a different
|
|
73
|
+
code further up — and because both wires have a file API, so the next one
|
|
74
|
+
inherits this instead of writing its own parser. Whether the missing file was
|
|
75
|
+
*ours* is a separate question only the caller can answer, and it answers it
|
|
76
|
+
against the code rather than the prose.
|
|
77
|
+
"""
|
|
78
|
+
code = _STATUS_CODES.get(status, "SERVER_ERROR" if status >= 500 else "REQUEST_FAILED")
|
|
79
|
+
if is_missing_file is not None and is_missing_file(body):
|
|
80
|
+
code = FILE_EXPIRED
|
|
81
|
+
if is_overflow(body):
|
|
82
|
+
code = CONTEXT_WINDOW_EXCEEDED
|
|
83
|
+
detail = body[:400] or f"HTTP {status}"
|
|
84
|
+
return LlmError(
|
|
85
|
+
f"provider returned {status}: {detail}",
|
|
86
|
+
code,
|
|
87
|
+
LlmFailure(message=detail, code=code, status=status),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class HttpClient:
|
|
92
|
+
"""A lazily-created, long-lived `httpx.AsyncClient` with one streaming shape."""
|
|
93
|
+
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
self._client: httpx.AsyncClient | None = None
|
|
96
|
+
|
|
97
|
+
def _get(self) -> httpx.AsyncClient:
|
|
98
|
+
if self._client is None:
|
|
99
|
+
self._client = httpx.AsyncClient(timeout=TIMEOUT)
|
|
100
|
+
return self._client
|
|
101
|
+
|
|
102
|
+
async def aclose(self) -> None:
|
|
103
|
+
if self._client is not None:
|
|
104
|
+
await self._client.aclose()
|
|
105
|
+
self._client = None
|
|
106
|
+
|
|
107
|
+
async def post_multipart(
|
|
108
|
+
self,
|
|
109
|
+
url: str,
|
|
110
|
+
*,
|
|
111
|
+
headers: dict[str, str],
|
|
112
|
+
field: str,
|
|
113
|
+
filename: str,
|
|
114
|
+
content: bytes,
|
|
115
|
+
mime: str,
|
|
116
|
+
is_overflow: Callable[[str], bool],
|
|
117
|
+
data: dict[str, str] | None = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""POST one file as multipart form data and return the parsed reply.
|
|
120
|
+
|
|
121
|
+
Here rather than in an adapter for `stream_sse`'s reason: a file API is
|
|
122
|
+
one more thing both wires have, and the status→code classification is
|
|
123
|
+
the part that must not be written twice. `Content-Type` is left to
|
|
124
|
+
`httpx`, which has to compute the multipart boundary anyway.
|
|
125
|
+
|
|
126
|
+
`data` is the form fields that ride beside the file. Anthropic's Files API
|
|
127
|
+
takes none; OpenAI's requires `purpose`, and without a way to send it the
|
|
128
|
+
second wire's uploader would have had to build its own request and inherit
|
|
129
|
+
none of the classification above (P7-03).
|
|
130
|
+
"""
|
|
131
|
+
sending = {name: value for name, value in headers.items() if name != "Content-Type"}
|
|
132
|
+
response = await self._get().post(
|
|
133
|
+
url, headers=sending, files={field: (filename, content, mime)}, data=data or {}
|
|
134
|
+
)
|
|
135
|
+
if response.status_code >= 400:
|
|
136
|
+
raise failure_from_status(
|
|
137
|
+
response.status_code,
|
|
138
|
+
response.text,
|
|
139
|
+
is_overflow=is_overflow,
|
|
140
|
+
)
|
|
141
|
+
parsed: dict[str, Any] = response.json()
|
|
142
|
+
return parsed
|
|
143
|
+
|
|
144
|
+
async def post_raw(
|
|
145
|
+
self,
|
|
146
|
+
url: str,
|
|
147
|
+
*,
|
|
148
|
+
headers: dict[str, str],
|
|
149
|
+
json: dict[str, Any] | None = None,
|
|
150
|
+
content: bytes | None = None,
|
|
151
|
+
is_overflow: Callable[[str], bool],
|
|
152
|
+
) -> tuple[dict[str, Any], Mapping[str, str]]:
|
|
153
|
+
"""POST a JSON body or raw bytes; return `(parsed body, response headers)`.
|
|
154
|
+
|
|
155
|
+
The **headers** are why this exists and why it is not `post_multipart`
|
|
156
|
+
(P7-03). Google's Files API is a two-step resumable upload whose first
|
|
157
|
+
step answers with an empty body and the destination in
|
|
158
|
+
`X-Goog-Upload-URL`, and whose second step is the file's bytes with no
|
|
159
|
+
form encoding around them at all. Neither shape fits a multipart helper,
|
|
160
|
+
and both want the same status→code classification, which is the whole
|
|
161
|
+
reason this module exists.
|
|
162
|
+
|
|
163
|
+
A body that is not JSON comes back as `{}` rather than raising: the first
|
|
164
|
+
step of that upload legitimately returns nothing, and a helper that
|
|
165
|
+
insisted on JSON would make the caller catch a decode error to discover
|
|
166
|
+
success.
|
|
167
|
+
"""
|
|
168
|
+
response = await self._get().post(url, headers=headers, json=json, content=content)
|
|
169
|
+
if response.status_code >= 400:
|
|
170
|
+
raise failure_from_status(response.status_code, response.text, is_overflow=is_overflow)
|
|
171
|
+
try:
|
|
172
|
+
parsed: dict[str, Any] = response.json()
|
|
173
|
+
except ValueError:
|
|
174
|
+
parsed = {}
|
|
175
|
+
return parsed, response.headers
|
|
176
|
+
|
|
177
|
+
async def get_json(
|
|
178
|
+
self,
|
|
179
|
+
url: str,
|
|
180
|
+
*,
|
|
181
|
+
headers: dict[str, str],
|
|
182
|
+
is_overflow: Callable[[str], bool],
|
|
183
|
+
timeout: float | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""GET and parse — the poll half of an upload that finishes asynchronously.
|
|
186
|
+
|
|
187
|
+
`timeout` overrides `TIMEOUT` for one call, and exists for the caller
|
|
188
|
+
that is not waiting on a model: a *mount-time* probe inherits a ten
|
|
189
|
+
minute read budget otherwise, so a host that accepts the socket and then
|
|
190
|
+
stalls holds up plugin mount — and with it a TUI start and `phern doctor`.
|
|
191
|
+
A request whose answer nobody is blocked on keeps the generous default.
|
|
192
|
+
"""
|
|
193
|
+
response = await self._get().get(
|
|
194
|
+
url,
|
|
195
|
+
headers=headers,
|
|
196
|
+
# httpx's own sentinel rather than a splat: "say nothing and take the
|
|
197
|
+
# client's" is a value here, and spreading a conditional dict past a
|
|
198
|
+
# keyword-typed signature is a hole the checker cannot see through.
|
|
199
|
+
timeout=httpx.Timeout(timeout) if timeout is not None else httpx.USE_CLIENT_DEFAULT,
|
|
200
|
+
)
|
|
201
|
+
if response.status_code >= 400:
|
|
202
|
+
raise failure_from_status(response.status_code, response.text, is_overflow=is_overflow)
|
|
203
|
+
parsed: dict[str, Any] = response.json()
|
|
204
|
+
return parsed
|
|
205
|
+
|
|
206
|
+
async def stream_sse(
|
|
207
|
+
self,
|
|
208
|
+
url: str,
|
|
209
|
+
*,
|
|
210
|
+
headers: dict[str, str],
|
|
211
|
+
json: dict[str, Any],
|
|
212
|
+
is_overflow: Callable[[str], bool],
|
|
213
|
+
is_missing_file: Callable[[str], bool] | None = None,
|
|
214
|
+
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
|
215
|
+
"""POST and yield `(event, payload)` for every JSON SSE payload.
|
|
216
|
+
|
|
217
|
+
A non-2xx response is raised as a classified `LlmError` before any
|
|
218
|
+
payload is yielded, so a consumer never sees a half-stream.
|
|
219
|
+
"""
|
|
220
|
+
async with self._get().stream("POST", url, headers=headers, json=json) as response:
|
|
221
|
+
if response.status_code >= 400:
|
|
222
|
+
body = (await response.aread()).decode("utf-8", errors="replace")
|
|
223
|
+
raise failure_from_status(
|
|
224
|
+
response.status_code,
|
|
225
|
+
body,
|
|
226
|
+
is_overflow=is_overflow,
|
|
227
|
+
is_missing_file=is_missing_file,
|
|
228
|
+
)
|
|
229
|
+
async for event, payload in iter_sse(response):
|
|
230
|
+
if isinstance(payload, dict):
|
|
231
|
+
yield event, payload
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Loading the bytes for media that is going out (P7-01).
|
|
2
|
+
|
|
3
|
+
The sibling of `_http.py`, and all that is left here once `ph.llm.media` answers
|
|
4
|
+
the *policy* question above every adapter: by the time a request reaches one, any
|
|
5
|
+
`MediaBlock` still on it is one this route said it accepts, so there is nothing
|
|
6
|
+
left to decide — only bytes to fetch and a wire shape to build, and the shape is
|
|
7
|
+
the adapter's own.
|
|
8
|
+
|
|
9
|
+
Also the *upload* half of that, once a second wire grew one (P7-03). `_http`'s
|
|
10
|
+
own docstring is the argument: the two adapters differ in message shape and in
|
|
11
|
+
how usage is reported, and they do **not** differ in what a dead file handle
|
|
12
|
+
means — so `forget_named_handle` lives here rather than being the third copy of
|
|
13
|
+
twelve lines whose bug would be invisible.
|
|
14
|
+
|
|
15
|
+
What is *not* here is the route projection (`MediaRoute`, `resolved`), which the
|
|
16
|
+
three adapters also share: its content is `ResolvedModel`'s field list, so it
|
|
17
|
+
belongs beside that dataclass in `ph.llm` rather than in an application
|
|
18
|
+
package — everything in this module needs `ctx.uploads`, the attachment store or
|
|
19
|
+
`FILE_EXPIRED` semantics to mean anything, and that one needed none of them.
|
|
20
|
+
|
|
21
|
+
@module ph_app.adapters._media
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import Collection, Sequence
|
|
28
|
+
|
|
29
|
+
from ph.cordis import Context
|
|
30
|
+
from ph.keys import UPLOADS
|
|
31
|
+
from ph.llm.adapter import LlmError
|
|
32
|
+
from ph.llm.media import media_pointer_text
|
|
33
|
+
from ph.llm.types import FILE_EXPIRED, AttachmentRef, Message, attachment_of
|
|
34
|
+
from ph.seams.attachments import AttachmentStore
|
|
35
|
+
from ph.seams.uploads import UploadRegistry
|
|
36
|
+
|
|
37
|
+
__all__ = ["forget_named_handle", "load_handles", "load_media", "media_pointer"]
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("ph_app.adapters.media")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def forget_named_handle(
|
|
43
|
+
ctx: Context, error: LlmError, referenced: Sequence[str], *, provider: str
|
|
44
|
+
) -> LlmError:
|
|
45
|
+
"""Drop the one handle this failure named, or leave the failure alone (P7-03).
|
|
46
|
+
|
|
47
|
+
`_http` has already decided the provider is talking about a missing file; what
|
|
48
|
+
only an adapter knows is whether it is a file *we* sent. A `not_found` naming
|
|
49
|
+
no id of ours is somebody else's 404 — a stale route, a gateway — and retrying
|
|
50
|
+
it would be the "unknown failure billed twice" the retry policy exists to
|
|
51
|
+
refuse, so it goes back to the code it came with.
|
|
52
|
+
|
|
53
|
+
**The named handle, not every handle.** A first draft invalidated all of them
|
|
54
|
+
on a match, which on a request carrying twenty files threw away nineteen live
|
|
55
|
+
uploads to re-fetch one dead one.
|
|
56
|
+
|
|
57
|
+
Shared rather than copied because the *classification* is already shared:
|
|
58
|
+
`failure_from_status` takes each wire's phrases and answers one code, and this
|
|
59
|
+
is what a caller does with that code. A second adapter writing its own would
|
|
60
|
+
be free to disagree about which half of the check is load-bearing, which is
|
|
61
|
+
the half that keeps `FILE_EXPIRED` out of an infinite retry.
|
|
62
|
+
"""
|
|
63
|
+
uploads = ctx.get(UPLOADS)
|
|
64
|
+
message = str(error.failure.message)
|
|
65
|
+
named = [handle for handle in referenced if handle in message]
|
|
66
|
+
if uploads is None or not named:
|
|
67
|
+
return (
|
|
68
|
+
LlmError(
|
|
69
|
+
message,
|
|
70
|
+
"REQUEST_FAILED",
|
|
71
|
+
error.failure.model_copy(update={"code": "REQUEST_FAILED"}),
|
|
72
|
+
)
|
|
73
|
+
if error.code == FILE_EXPIRED
|
|
74
|
+
else error
|
|
75
|
+
)
|
|
76
|
+
for handle in named:
|
|
77
|
+
uploads.invalidate_handle(provider, handle)
|
|
78
|
+
return error
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def media_pointer(attachment: AttachmentRef) -> dict[str, str]:
|
|
82
|
+
"""The text block that stands in for media that could not be loaded.
|
|
83
|
+
|
|
84
|
+
Reached only on a race — `media-degrade` already checked the blob was there,
|
|
85
|
+
so arriving here means it went away between that check and this read. The
|
|
86
|
+
wording is `ph.llm.media`'s, so the model reads one sentence for one
|
|
87
|
+
situation however it was arrived at.
|
|
88
|
+
"""
|
|
89
|
+
return {"type": "text", "text": media_pointer_text(attachment)}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def load_handles(
|
|
93
|
+
uploads: UploadRegistry | None,
|
|
94
|
+
messages: Sequence[Message],
|
|
95
|
+
*,
|
|
96
|
+
provider: str,
|
|
97
|
+
mimes: frozenset[str],
|
|
98
|
+
session_id: str | None = None,
|
|
99
|
+
) -> dict[str, str]:
|
|
100
|
+
"""Provider file ids for the attachments this route references rather than inlines.
|
|
101
|
+
|
|
102
|
+
Keyed by attachment id like `load_media`, and consulted first by the
|
|
103
|
+
renderer: an id present here is sent as a reference, absent as bytes. The
|
|
104
|
+
two maps rather than one union type because the fallback has to be silent and
|
|
105
|
+
total — an upload that fails for any reason leaves the id out and the
|
|
106
|
+
attachment goes inline, which is what every route did before this row.
|
|
107
|
+
|
|
108
|
+
`mimes` is the route's own list rather than "everything large": which formats
|
|
109
|
+
are worth a round trip is a fact about a provider's file API, and video is
|
|
110
|
+
the case where it is not a choice.
|
|
111
|
+
"""
|
|
112
|
+
handles: dict[str, str] = {}
|
|
113
|
+
if uploads is None or not mimes:
|
|
114
|
+
return handles
|
|
115
|
+
for message in messages:
|
|
116
|
+
for block in message.content:
|
|
117
|
+
attachment = attachment_of(block)
|
|
118
|
+
if attachment is None or attachment.attachment_id in handles:
|
|
119
|
+
continue
|
|
120
|
+
if attachment.mime not in mimes:
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
handle = await uploads.handle_for(
|
|
124
|
+
attachment, provider=provider, session_id=session_id
|
|
125
|
+
)
|
|
126
|
+
except Exception:
|
|
127
|
+
# Deliberately broad and deliberately not fatal: an upload is an
|
|
128
|
+
# optimisation, and a route that can take the bytes inline must
|
|
129
|
+
# not lose a turn because a file API was down.
|
|
130
|
+
log.warning(
|
|
131
|
+
"ph_app.adapters: could not upload %s to %s; sending it inline",
|
|
132
|
+
attachment.name or attachment.attachment_id,
|
|
133
|
+
provider,
|
|
134
|
+
exc_info=True,
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
if handle is not None:
|
|
138
|
+
handles[attachment.attachment_id] = handle.handle
|
|
139
|
+
return handles
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def load_media(
|
|
143
|
+
store: AttachmentStore | None,
|
|
144
|
+
messages: Sequence[Message],
|
|
145
|
+
*,
|
|
146
|
+
skip: Collection[str] = (),
|
|
147
|
+
) -> dict[str, str]:
|
|
148
|
+
"""Base64 for every attachment still on the request, keyed by id.
|
|
149
|
+
|
|
150
|
+
Absent from the map means the read failed after `media-degrade` had approved
|
|
151
|
+
it, which is a race rather than a policy outcome — the caller renders a
|
|
152
|
+
pointer either way, so one branch covers both.
|
|
153
|
+
|
|
154
|
+
Through `AttachmentStore.load_b64`, which encodes once per process: a media
|
|
155
|
+
block lives in derived history for the session's life, so re-encoding per
|
|
156
|
+
request is a cost paid on every step.
|
|
157
|
+
|
|
158
|
+
**`skip` is what makes an upload actually cheaper** (P7-03). Without it a
|
|
159
|
+
referenced file was still read and base64-encoded here and then discarded by
|
|
160
|
+
the renderer — so the wire payload shrank and nothing else did, while a
|
|
161
|
+
5.5 MB string sat in the store's encode cache for the life of the process.
|
|
162
|
+
Uploading is supposed to remove that work, not move it.
|
|
163
|
+
"""
|
|
164
|
+
loaded: dict[str, str] = {}
|
|
165
|
+
if store is None:
|
|
166
|
+
return loaded
|
|
167
|
+
for message in messages:
|
|
168
|
+
for block in message.content:
|
|
169
|
+
attachment = attachment_of(block)
|
|
170
|
+
if attachment is None or attachment.attachment_id in loaded:
|
|
171
|
+
continue
|
|
172
|
+
if attachment.attachment_id in skip:
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
loaded[attachment.attachment_id] = await store.load_b64(attachment)
|
|
176
|
+
except OSError:
|
|
177
|
+
log.warning(
|
|
178
|
+
"ph_app.adapters: could not read %s; sending a pointer",
|
|
179
|
+
attachment.name or attachment.attachment_id,
|
|
180
|
+
)
|
|
181
|
+
return loaded
|