delos-cli 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.
- delos_cli/__init__.py +3 -0
- delos_cli/agent/__init__.py +34 -0
- delos_cli/agent/session.py +111 -0
- delos_cli/agent/tools.py +131 -0
- delos_cli/agent/transport.py +102 -0
- delos_cli/apps/__init__.py +6 -0
- delos_cli/apps/base.py +101 -0
- delos_cli/apps/chat/__init__.py +5 -0
- delos_cli/apps/chat/app.py +149 -0
- delos_cli/apps/chat/commands.py +17 -0
- delos_cli/apps/chat/render.py +188 -0
- delos_cli/apps/chat/replay.py +108 -0
- delos_cli/auth/__init__.py +24 -0
- delos_cli/auth/config.py +282 -0
- delos_cli/auth/mfa.py +120 -0
- delos_cli/auth/oauth.py +336 -0
- delos_cli/auth/token_manager.py +136 -0
- delos_cli/commands/__init__.py +10 -0
- delos_cli/commands/base.py +54 -0
- delos_cli/commands/builtin.py +160 -0
- delos_cli/ctx.py +65 -0
- delos_cli/loop.py +19 -0
- delos_cli/main.py +230 -0
- delos_cli/state.py +28 -0
- delos_cli/tools/__init__.py +20 -0
- delos_cli/tools/edit_content.py +193 -0
- delos_cli/tools/run_shell.py +150 -0
- delos_cli/tools/write_content.py +120 -0
- delos_cli/transport/__init__.py +24 -0
- delos_cli/transport/chats.py +235 -0
- delos_cli/transport/client.py +321 -0
- delos_cli/transport/models.py +19 -0
- delos_cli/ui/__init__.py +6 -0
- delos_cli/ui/chat_picker.py +151 -0
- delos_cli/ui/completer.py +68 -0
- delos_cli/ui/lexer.py +62 -0
- delos_cli/ui/output.py +180 -0
- delos_cli/ui/repl.py +679 -0
- delos_cli/ui/style.py +24 -0
- delos_cli-0.1.0.dist-info/METADATA +104 -0
- delos_cli-0.1.0.dist-info/RECORD +43 -0
- delos_cli-0.1.0.dist-info/WHEEL +4 -0
- delos_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Read/write access to ``app_cli`` + ``app_cli_messages`` via PostgREST.
|
|
2
|
+
|
|
3
|
+
The CLI talks to Supabase directly for picker / replay / row-creation —
|
|
4
|
+
the backend only sees streaming completions and tool-results. This
|
|
5
|
+
module is the entire data-access layer:
|
|
6
|
+
|
|
7
|
+
- :class:`ChatListItem` + :func:`list_recent` — picker rows.
|
|
8
|
+
- :func:`fetch_messages` — replay payload (OpenAI-format).
|
|
9
|
+
- :func:`create_chat` — INSERT a new ``app_cli`` row.
|
|
10
|
+
|
|
11
|
+
All requests go through :meth:`AuthedClient.supabase_get` /
|
|
12
|
+
:meth:`AuthedClient.supabase_post` so token refresh + the single
|
|
13
|
+
connection pool are shared with the rest of the CLI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
from urllib.parse import urlencode
|
|
23
|
+
|
|
24
|
+
from delos_cli import __version__ as _cli_version
|
|
25
|
+
|
|
26
|
+
from .client import HTTP_ERROR_THRESHOLD, TransportError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .client import AuthedClient
|
|
30
|
+
|
|
31
|
+
_DEFAULT_LIMIT = 30
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ChatListItem:
|
|
36
|
+
"""One row from ``app_cli`` for the recent-chats picker."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
name: str | None
|
|
40
|
+
updated_at: datetime
|
|
41
|
+
message_count: int
|
|
42
|
+
summary: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Picker — list recent chats
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def list_recent(
|
|
51
|
+
http: AuthedClient,
|
|
52
|
+
org_uuid: str,
|
|
53
|
+
*,
|
|
54
|
+
folder: str | None = None,
|
|
55
|
+
limit: int = _DEFAULT_LIMIT,
|
|
56
|
+
) -> list[ChatListItem]:
|
|
57
|
+
"""Return the most-recently-updated ``app_cli`` rows for ``org_uuid``.
|
|
58
|
+
|
|
59
|
+
RLS scopes the result to the signed-in user (CLI conversations are
|
|
60
|
+
personal — see the ``app_cli_owner_policy`` policy). When ``folder``
|
|
61
|
+
is set, the query is also scoped to chats created from that
|
|
62
|
+
working directory; pre-folder rows (``folder IS NULL``) are excluded
|
|
63
|
+
by the equality filter.
|
|
64
|
+
"""
|
|
65
|
+
params = {
|
|
66
|
+
"select": "id,name,updated_at,message_count,summary",
|
|
67
|
+
"org_uuid": f"eq.{org_uuid}",
|
|
68
|
+
"order": "updated_at.desc",
|
|
69
|
+
"limit": str(limit),
|
|
70
|
+
}
|
|
71
|
+
if folder is not None:
|
|
72
|
+
params["folder"] = f"eq.{folder}"
|
|
73
|
+
resp = await http.supabase_get(f"/rest/v1/app_cli?{urlencode(params)}")
|
|
74
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
75
|
+
msg = f"HTTP {resp.status_code}: {resp.text[:400]}"
|
|
76
|
+
raise TransportError(msg)
|
|
77
|
+
rows = resp.json()
|
|
78
|
+
return [_row_to_item(r) for r in rows]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Replay — load every message of one chat
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def fetch_messages(http: AuthedClient, chat_id: str) -> list[dict[str, Any]]:
|
|
87
|
+
"""Load ``app_cli_messages`` rows for one chat, mapped to OpenAI format.
|
|
88
|
+
|
|
89
|
+
Returned list is ordered by ``seq`` (oldest first), matching what
|
|
90
|
+
:func:`apps.chat.replay.replay_messages` expects.
|
|
91
|
+
"""
|
|
92
|
+
params = {
|
|
93
|
+
"select": (
|
|
94
|
+
"seq,role,content,content_parts,tool_call_id,tool_name,tool_calls,metadata"
|
|
95
|
+
),
|
|
96
|
+
"chat_id": f"eq.{chat_id}",
|
|
97
|
+
"order": "seq.asc",
|
|
98
|
+
}
|
|
99
|
+
resp = await http.supabase_get(f"/rest/v1/app_cli_messages?{urlencode(params)}")
|
|
100
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
101
|
+
msg = f"HTTP {resp.status_code}: {resp.text[:400]}"
|
|
102
|
+
raise TransportError(msg)
|
|
103
|
+
return [_message_row_to_openai(r) for r in (resp.json() or [])]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Row creation — INSERT a fresh chat
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def create_chat(
|
|
112
|
+
http: AuthedClient,
|
|
113
|
+
*,
|
|
114
|
+
folder: str,
|
|
115
|
+
name: str | None = None,
|
|
116
|
+
model: str | None = None,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""INSERT a new row in ``app_cli`` and return its id.
|
|
119
|
+
|
|
120
|
+
The CLI generates the UUID client-side so the caller has the id
|
|
121
|
+
immediately (no need to re-read the inserted row). ``user_id`` is
|
|
122
|
+
filled by the DB default (``auth.uid()``); ``model`` falls back to
|
|
123
|
+
the column default (``claude-4.6-sonnet``) when omitted. ``folder``
|
|
124
|
+
is required (the column is ``NOT NULL``) — pass the cwd so the
|
|
125
|
+
picker can scope its recent-chats list.
|
|
126
|
+
"""
|
|
127
|
+
chat_id = str(uuid.uuid4())
|
|
128
|
+
body: dict[str, Any] = {
|
|
129
|
+
"id": chat_id,
|
|
130
|
+
"org_uuid": http.cfg.org_uuid,
|
|
131
|
+
"client_version": _cli_version,
|
|
132
|
+
"region": http.cfg.region,
|
|
133
|
+
"folder": folder,
|
|
134
|
+
}
|
|
135
|
+
if name is not None:
|
|
136
|
+
body["name"] = name
|
|
137
|
+
if model is not None:
|
|
138
|
+
body["model"] = model
|
|
139
|
+
|
|
140
|
+
resp = await http.supabase_post("/rest/v1/app_cli", body, prefer="return=minimal")
|
|
141
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
142
|
+
msg = f"failed to create chat: HTTP {resp.status_code}: {resp.text[:400]}"
|
|
143
|
+
raise TransportError(msg)
|
|
144
|
+
return chat_id
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Mutations — rename + delete
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def rename_chat(http: AuthedClient, chat_id: str, name: str) -> None:
|
|
153
|
+
"""PATCH ``app_cli.name`` for one chat.
|
|
154
|
+
|
|
155
|
+
RLS scopes the UPDATE to the signed-in owner; a 0-row UPDATE on a
|
|
156
|
+
chat owned by someone else returns 200 but changes nothing — same
|
|
157
|
+
semantics as Supabase's REST layer everywhere else.
|
|
158
|
+
"""
|
|
159
|
+
body = {"name": name}
|
|
160
|
+
resp = await http.supabase_patch(
|
|
161
|
+
f"/rest/v1/app_cli?id=eq.{chat_id}",
|
|
162
|
+
body,
|
|
163
|
+
prefer="return=minimal",
|
|
164
|
+
)
|
|
165
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
166
|
+
msg = f"failed to rename chat: HTTP {resp.status_code}: {resp.text[:400]}"
|
|
167
|
+
raise TransportError(msg)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def delete_chat(http: AuthedClient, chat_id: str) -> None:
|
|
171
|
+
"""DELETE one ``app_cli`` row.
|
|
172
|
+
|
|
173
|
+
``app_cli_messages`` rows cascade away via the FK constraint; RLS
|
|
174
|
+
again scopes the DELETE to the owner.
|
|
175
|
+
"""
|
|
176
|
+
resp = await http.supabase_delete(f"/rest/v1/app_cli?id=eq.{chat_id}")
|
|
177
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
178
|
+
msg = f"failed to delete chat: HTTP {resp.status_code}: {resp.text[:400]}"
|
|
179
|
+
raise TransportError(msg)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Internals
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _row_to_item(row: dict[str, Any]) -> ChatListItem:
|
|
188
|
+
raw_updated = row.get("updated_at")
|
|
189
|
+
if isinstance(raw_updated, str):
|
|
190
|
+
updated_at = datetime.fromisoformat(raw_updated)
|
|
191
|
+
else:
|
|
192
|
+
updated_at = datetime.fromtimestamp(0, tz=UTC)
|
|
193
|
+
|
|
194
|
+
name = row.get("name")
|
|
195
|
+
summary = row.get("summary")
|
|
196
|
+
raw_count = row.get("message_count")
|
|
197
|
+
message_count = int(raw_count) if isinstance(raw_count, (int, str)) else 0
|
|
198
|
+
|
|
199
|
+
return ChatListItem(
|
|
200
|
+
id=str(row["id"]),
|
|
201
|
+
name=str(name) if isinstance(name, str) and name else None,
|
|
202
|
+
updated_at=updated_at,
|
|
203
|
+
message_count=message_count,
|
|
204
|
+
summary=str(summary) if isinstance(summary, str) and summary else None,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _message_row_to_openai(row: dict[str, Any]) -> dict[str, Any]:
|
|
209
|
+
"""Translate one ``app_cli_messages`` row into an OpenAI-style dict.
|
|
210
|
+
|
|
211
|
+
Mirrors the backend's ``cli_message_to_openai`` so the replay logic
|
|
212
|
+
sees the same shape as a live agent run.
|
|
213
|
+
"""
|
|
214
|
+
msg: dict[str, Any] = {"role": row["role"]}
|
|
215
|
+
|
|
216
|
+
parts = row.get("content_parts")
|
|
217
|
+
if parts:
|
|
218
|
+
msg["content"] = parts
|
|
219
|
+
else:
|
|
220
|
+
msg["content"] = row.get("content")
|
|
221
|
+
|
|
222
|
+
if row.get("tool_call_id"):
|
|
223
|
+
msg["tool_call_id"] = row["tool_call_id"]
|
|
224
|
+
if row.get("tool_name"):
|
|
225
|
+
msg["name"] = row["tool_name"]
|
|
226
|
+
if row.get("tool_calls"):
|
|
227
|
+
msg["tool_calls"] = row["tool_calls"]
|
|
228
|
+
|
|
229
|
+
metadata = row.get("metadata") or {}
|
|
230
|
+
if isinstance(metadata, dict):
|
|
231
|
+
for k, v in metadata.items():
|
|
232
|
+
if k not in msg:
|
|
233
|
+
msg[k] = v
|
|
234
|
+
|
|
235
|
+
return msg
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""AuthedClient — one httpx session, one place where token refresh lives.
|
|
2
|
+
|
|
3
|
+
Every backend + Supabase REST call in the CLI flows through this class so
|
|
4
|
+
token refresh isn't duplicated, and so we keep a single connection pool for
|
|
5
|
+
the lifetime of the REPL.
|
|
6
|
+
|
|
7
|
+
Entry points:
|
|
8
|
+
* :meth:`json_get` — plain JSON GET against ``{api_url}`` (backend)
|
|
9
|
+
* :meth:`sse_post` — SSE POST that yields parsed ``data:`` events
|
|
10
|
+
* :meth:`supabase_post` — POST to ``{supabase_url}`` (e.g. ``/rest/v1/...``)
|
|
11
|
+
* :meth:`supabase_get` — GET to ``{supabase_url}``
|
|
12
|
+
|
|
13
|
+
Each entry point proactively refreshes via the :class:`TokenManager` before
|
|
14
|
+
the first attempt (based on the JWT's ``exp`` claim), and reactively refreshes
|
|
15
|
+
once on 401/403 before giving up with :class:`TransportError`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from delos_cli.auth.oauth import OAuthError, RefreshTokenInvalidError
|
|
26
|
+
from delos_cli.auth.token_manager import TokenManager
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import AsyncIterator
|
|
30
|
+
from types import TracebackType
|
|
31
|
+
|
|
32
|
+
from delos_cli.auth.config import Config
|
|
33
|
+
|
|
34
|
+
HTTP_UNAUTHORIZED = 401
|
|
35
|
+
HTTP_FORBIDDEN = 403
|
|
36
|
+
HTTP_ERROR_THRESHOLD = 400
|
|
37
|
+
DONE_SENTINEL = "[DONE]"
|
|
38
|
+
_DEFAULT_GET_TIMEOUT_S = 10.0
|
|
39
|
+
_DEFAULT_POST_TIMEOUT_S = 30.0
|
|
40
|
+
_AUTH_REJECTED: frozenset[int] = frozenset({HTTP_UNAUTHORIZED, HTTP_FORBIDDEN})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TransportError(RuntimeError):
|
|
44
|
+
"""Raised when a request fails (HTTP error, auth dead after refresh, bad SSE)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _AuthExpiredError(Exception):
|
|
48
|
+
"""Internal signal: got a 401/403, caller should force-refresh and retry."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthedClient:
|
|
52
|
+
"""Shared authenticated HTTP client for the REPL lifetime.
|
|
53
|
+
|
|
54
|
+
Use as an async context manager so the underlying connection pool is
|
|
55
|
+
cleanly closed on shutdown.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, cfg: Config) -> None:
|
|
59
|
+
"""Build the shared httpx client + a :class:`TokenManager` around ``cfg``."""
|
|
60
|
+
self._tokens = TokenManager(cfg)
|
|
61
|
+
self._http = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=10.0))
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> Self:
|
|
64
|
+
"""Enter the async context — returns self for ``async with`` binding."""
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(
|
|
68
|
+
self,
|
|
69
|
+
exc_type: type[BaseException] | None,
|
|
70
|
+
exc: BaseException | None,
|
|
71
|
+
tb: TracebackType | None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Close the underlying connection pool on exit."""
|
|
74
|
+
_ = exc_type, exc, tb
|
|
75
|
+
await self._http.aclose()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def cfg(self) -> Config:
|
|
79
|
+
"""The live ``Config`` (URL properties, org_uuid, etc.)."""
|
|
80
|
+
return self._tokens.cfg
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def tokens(self) -> TokenManager:
|
|
84
|
+
"""The :class:`TokenManager`. Mainly exposed for tests."""
|
|
85
|
+
return self._tokens
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Backend API calls
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async def json_get(self, path: str) -> dict[str, Any]:
|
|
92
|
+
"""GET ``{api_url}{path}``; decode JSON. Force-refresh once on 401/403."""
|
|
93
|
+
return await self.json_request("GET", path, None)
|
|
94
|
+
|
|
95
|
+
async def json_post(
|
|
96
|
+
self, path: str, body: dict[str, Any] | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""POST ``{api_url}{path}`` with optional JSON body; decode JSON."""
|
|
99
|
+
return await self.json_request("POST", path, body)
|
|
100
|
+
|
|
101
|
+
async def json_request(
|
|
102
|
+
self,
|
|
103
|
+
method: str,
|
|
104
|
+
path: str,
|
|
105
|
+
body: dict[str, Any] | None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Generic JSON request against the backend.
|
|
108
|
+
|
|
109
|
+
Force-refreshes once on 401/403 and retries. Returns ``{}`` for
|
|
110
|
+
empty bodies (e.g. 204) or non-JSON responses so callers don't have
|
|
111
|
+
to special-case them.
|
|
112
|
+
"""
|
|
113
|
+
resp = await self._send_json_request(method, path, body)
|
|
114
|
+
if resp.status_code in _AUTH_REJECTED:
|
|
115
|
+
await self._force_refresh_or_fail()
|
|
116
|
+
resp = await self._send_json_request(method, path, body)
|
|
117
|
+
if resp.status_code in _AUTH_REJECTED:
|
|
118
|
+
msg = "still unauthorized after refresh; run `delos login`"
|
|
119
|
+
raise TransportError(msg)
|
|
120
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
121
|
+
msg = f"HTTP {resp.status_code}: {resp.text[:400]}"
|
|
122
|
+
raise TransportError(msg)
|
|
123
|
+
if not resp.content:
|
|
124
|
+
return {}
|
|
125
|
+
try:
|
|
126
|
+
return resp.json()
|
|
127
|
+
except ValueError:
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
async def sse_post(
|
|
131
|
+
self, path: str, body: dict[str, Any],
|
|
132
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
133
|
+
"""POST ``{api_url}{path}`` → parsed SSE ``data:`` events.
|
|
134
|
+
|
|
135
|
+
Yields parsed JSON dicts. A ``{"type": "[DONE]"}`` dict signals end.
|
|
136
|
+
Force-refreshes once on 401/403 and retries.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
async for event in self._stream_sse(path, body):
|
|
140
|
+
yield event
|
|
141
|
+
except _AuthExpiredError:
|
|
142
|
+
pass
|
|
143
|
+
else:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
await self._force_refresh_or_fail()
|
|
147
|
+
try:
|
|
148
|
+
async for event in self._stream_sse(path, body):
|
|
149
|
+
yield event
|
|
150
|
+
except _AuthExpiredError as e:
|
|
151
|
+
msg = "still unauthorized after refresh; run `delos login`"
|
|
152
|
+
raise TransportError(msg) from e
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Supabase REST (supabase_url)
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def supabase_post(
|
|
159
|
+
self,
|
|
160
|
+
path: str,
|
|
161
|
+
body: dict[str, Any],
|
|
162
|
+
*,
|
|
163
|
+
prefer: str | None = None,
|
|
164
|
+
) -> httpx.Response:
|
|
165
|
+
"""POST ``{supabase_url}{path}``, with Supabase auth headers.
|
|
166
|
+
|
|
167
|
+
Returns the raw :class:`httpx.Response` so callers can inspect
|
|
168
|
+
status (useful for upsert/409 handling); non-auth failures are NOT
|
|
169
|
+
raised here. Force-refreshes once on 401/403.
|
|
170
|
+
"""
|
|
171
|
+
return await self._supabase_request("POST", path, body=body, prefer=prefer)
|
|
172
|
+
|
|
173
|
+
async def supabase_get(self, path: str) -> httpx.Response:
|
|
174
|
+
"""GET ``{supabase_url}{path}`` with Supabase auth headers. Raw response."""
|
|
175
|
+
return await self._supabase_request("GET", path)
|
|
176
|
+
|
|
177
|
+
async def supabase_patch(
|
|
178
|
+
self,
|
|
179
|
+
path: str,
|
|
180
|
+
body: dict[str, Any],
|
|
181
|
+
*,
|
|
182
|
+
prefer: str | None = None,
|
|
183
|
+
) -> httpx.Response:
|
|
184
|
+
"""PATCH ``{supabase_url}{path}`` with Supabase auth headers."""
|
|
185
|
+
return await self._supabase_request("PATCH", path, body=body, prefer=prefer)
|
|
186
|
+
|
|
187
|
+
async def supabase_delete(self, path: str) -> httpx.Response:
|
|
188
|
+
"""DELETE ``{supabase_url}{path}`` with Supabase auth headers."""
|
|
189
|
+
return await self._supabase_request("DELETE", path)
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Internals
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
async def _send_json_request(
|
|
196
|
+
self,
|
|
197
|
+
method: str,
|
|
198
|
+
path: str,
|
|
199
|
+
body: dict[str, Any] | None,
|
|
200
|
+
) -> httpx.Response:
|
|
201
|
+
headers = await self._backend_headers()
|
|
202
|
+
kwargs: dict[str, Any] = {
|
|
203
|
+
"headers": headers,
|
|
204
|
+
"timeout": _DEFAULT_GET_TIMEOUT_S if method == "GET" else _DEFAULT_POST_TIMEOUT_S,
|
|
205
|
+
}
|
|
206
|
+
if body is not None:
|
|
207
|
+
kwargs["json"] = body
|
|
208
|
+
headers["Content-Type"] = "application/json"
|
|
209
|
+
return await self._http.request(method, f"{self.cfg.api_url}{path}", **kwargs)
|
|
210
|
+
|
|
211
|
+
async def _stream_sse(
|
|
212
|
+
self, path: str, body: dict[str, Any],
|
|
213
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
214
|
+
url = f"{self.cfg.api_url}{path}"
|
|
215
|
+
headers = {
|
|
216
|
+
**(await self._backend_headers()),
|
|
217
|
+
"Accept": "text/event-stream",
|
|
218
|
+
"Content-Type": "application/json",
|
|
219
|
+
}
|
|
220
|
+
async with self._http.stream("POST", url, headers=headers, json=body) as resp:
|
|
221
|
+
if resp.status_code in _AUTH_REJECTED:
|
|
222
|
+
raise _AuthExpiredError
|
|
223
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
224
|
+
text = (await resp.aread()).decode(errors="replace")
|
|
225
|
+
msg = f"HTTP {resp.status_code}: {text[:400]}"
|
|
226
|
+
raise TransportError(msg)
|
|
227
|
+
|
|
228
|
+
buffer = ""
|
|
229
|
+
async for chunk in resp.aiter_text():
|
|
230
|
+
buffer += chunk
|
|
231
|
+
while "\n\n" in buffer:
|
|
232
|
+
raw, buffer = buffer.split("\n\n", 1)
|
|
233
|
+
event = _parse_sse_event(raw)
|
|
234
|
+
if event is None:
|
|
235
|
+
continue
|
|
236
|
+
yield event
|
|
237
|
+
if event.get("type") == DONE_SENTINEL:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
async def _supabase_request(
|
|
241
|
+
self,
|
|
242
|
+
method: str,
|
|
243
|
+
path: str,
|
|
244
|
+
*,
|
|
245
|
+
body: dict[str, Any] | None = None,
|
|
246
|
+
prefer: str | None = None,
|
|
247
|
+
) -> httpx.Response:
|
|
248
|
+
"""Send a Supabase REST request, refreshing once on 401/403.
|
|
249
|
+
|
|
250
|
+
Generic across POST / GET / PATCH / DELETE — the per-verb public
|
|
251
|
+
wrappers just delegate here. Returns the raw response so callers
|
|
252
|
+
can inspect status codes (e.g. 409 on upsert race).
|
|
253
|
+
"""
|
|
254
|
+
resp = await self._send_supabase_request(method, path, body, prefer)
|
|
255
|
+
if resp.status_code in _AUTH_REJECTED:
|
|
256
|
+
await self._force_refresh_or_fail()
|
|
257
|
+
resp = await self._send_supabase_request(method, path, body, prefer)
|
|
258
|
+
return resp
|
|
259
|
+
|
|
260
|
+
async def _send_supabase_request(
|
|
261
|
+
self,
|
|
262
|
+
method: str,
|
|
263
|
+
path: str,
|
|
264
|
+
body: dict[str, Any] | None,
|
|
265
|
+
prefer: str | None,
|
|
266
|
+
) -> httpx.Response:
|
|
267
|
+
headers = await self._supabase_headers()
|
|
268
|
+
if prefer:
|
|
269
|
+
headers["Prefer"] = prefer
|
|
270
|
+
timeout = _DEFAULT_GET_TIMEOUT_S if method == "GET" else _DEFAULT_POST_TIMEOUT_S
|
|
271
|
+
kwargs: dict[str, Any] = {"headers": headers, "timeout": timeout}
|
|
272
|
+
if body is not None:
|
|
273
|
+
kwargs["json"] = body
|
|
274
|
+
return await self._http.request(
|
|
275
|
+
method, f"{self.cfg.supabase_url}{path}", **kwargs,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def _backend_headers(self) -> dict[str, str]:
|
|
279
|
+
return {"Authorization": f"Bearer {await self._tokens.access_token()}"}
|
|
280
|
+
|
|
281
|
+
async def _supabase_headers(self) -> dict[str, str]:
|
|
282
|
+
return {
|
|
283
|
+
"apikey": self.cfg.supabase_anon_key,
|
|
284
|
+
"Authorization": f"Bearer {await self._tokens.access_token()}",
|
|
285
|
+
"Content-Type": "application/json",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async def _force_refresh_or_fail(self) -> None:
|
|
289
|
+
"""Translate token-refresh errors into :class:`TransportError`."""
|
|
290
|
+
try:
|
|
291
|
+
await self._tokens.force_refresh()
|
|
292
|
+
except RefreshTokenInvalidError as e:
|
|
293
|
+
msg = f"session expired ({e}); run `delos login`"
|
|
294
|
+
raise TransportError(msg) from e
|
|
295
|
+
except OAuthError as e:
|
|
296
|
+
msg = f"token refresh failed ({e}); try again or run `delos login`"
|
|
297
|
+
raise TransportError(msg) from e
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_sse_event(raw: str) -> dict[str, Any] | None:
|
|
301
|
+
"""Parse one SSE event block into a JSON dict.
|
|
302
|
+
|
|
303
|
+
Handles the ``data: <payload>`` form, including the ``[DONE]`` sentinel.
|
|
304
|
+
Returns ``None`` for keepalive/comment lines.
|
|
305
|
+
"""
|
|
306
|
+
data_lines = [
|
|
307
|
+
line[5:].lstrip() if line.startswith("data:") else ""
|
|
308
|
+
for line in raw.splitlines()
|
|
309
|
+
if line.startswith("data:")
|
|
310
|
+
]
|
|
311
|
+
if not data_lines:
|
|
312
|
+
return None
|
|
313
|
+
payload = "\n".join(data_lines).strip()
|
|
314
|
+
if not payload:
|
|
315
|
+
return None
|
|
316
|
+
if payload == DONE_SENTINEL:
|
|
317
|
+
return {"type": DONE_SENTINEL}
|
|
318
|
+
try:
|
|
319
|
+
return json.loads(payload)
|
|
320
|
+
except json.JSONDecodeError:
|
|
321
|
+
return {"type": "parse-error", "raw": payload}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Thin wrapper around ``GET /client/models/{org_uuid}``.
|
|
2
|
+
|
|
3
|
+
Mirrors the data the web frontend uses for its chat model picker —
|
|
4
|
+
filtered server-side by sigma flag, deployment plan, and config fallbacks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import AuthedClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def fetch_models(client: AuthedClient) -> list[str]:
|
|
16
|
+
"""Return model ids available to the signed-in user/org."""
|
|
17
|
+
path = f"/client/models/{client.cfg.org_uuid}"
|
|
18
|
+
payload = await client.json_get(path)
|
|
19
|
+
return [m["id"] for m in payload.get("models", [])]
|