rlmness-cli 0.2.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.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,398 @@
1
+ """Public-client subscription sign-in with an app-owned credential record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import secrets
11
+ import threading
12
+ import time
13
+ import uuid
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16
+ from urllib.parse import parse_qs, urlencode, urlsplit
17
+
18
+ from rlmagent.discovery import default_paths
19
+ from rlmagent.model.settings import ConfigError, Credential
20
+ from rlmagent.model.transport.client import build_async_client
21
+ from rlmagent.refinement.store import _lock
22
+
23
+ ISSUER = "https://auth.openai.com"
24
+ RESOURCE = "https://api.openai.com/v1"
25
+ PERMISSION = "chatgpt.tokens.use.direct"
26
+ SCOPES = f"openid profile email offline_access resource.invoke {PERMISSION}"
27
+
28
+
29
+ def _dependencies():
30
+ try:
31
+ import jwt
32
+ import keyring
33
+ except ImportError:
34
+ raise ConfigError("Install rlmagent[subscription] to use subscription sign-in") from None
35
+ return jwt, keyring
36
+
37
+
38
+ def _store(action: str, record: dict | None = None, *, namespace: str | None = None) -> dict | None:
39
+ _, keyring = _dependencies()
40
+ # Isolated app homes must never accidentally share the selected login.
41
+ identity = str(default_paths().home.resolve()) + ("\0" + namespace if namespace else "")
42
+ account = hashlib.sha256(identity.encode()).hexdigest()
43
+ service = "rlmagent-subscription"
44
+
45
+ def parts(manifest):
46
+ if not isinstance(manifest, dict) or manifest.get("format") != "chunks-v1":
47
+ return []
48
+ generation, count = manifest["generation"], manifest["count"]
49
+ if (
50
+ uuid.UUID(hex=generation).hex != generation
51
+ or type(count) is not int
52
+ or not 1 <= count <= 128
53
+ ):
54
+ raise ValueError("Invalid credential manifest")
55
+ return [f"{account}:{generation}:{index}" for index in range(count)]
56
+
57
+ def cleanup(names):
58
+ for name in names:
59
+ try:
60
+ keyring.delete_password(service, name)
61
+ except Exception:
62
+ pass
63
+
64
+ try:
65
+ raw = keyring.get_password(service, account)
66
+ previous = json.loads(raw) if raw else None
67
+ old_parts = parts(previous)
68
+ if action == "load":
69
+ if not old_parts:
70
+ return previous
71
+ chunks = [keyring.get_password(service, name) for name in old_parts]
72
+ if any(chunk is None for chunk in chunks):
73
+ raise ValueError("Credential record is incomplete")
74
+ return json.loads("".join(chunks))
75
+ if action == "save":
76
+ encoded = json.dumps(record, ensure_ascii=True)
77
+ chunks = [encoded[index : index + 768] for index in range(0, len(encoded), 768)]
78
+ manifest = {"format": "chunks-v1", "generation": uuid.uuid4().hex, "count": len(chunks)}
79
+ names = parts(manifest)
80
+ try:
81
+ for name, chunk in zip(names, chunks, strict=True):
82
+ keyring.set_password(service, name, chunk)
83
+ keyring.set_password(service, account, json.dumps(manifest))
84
+ except Exception:
85
+ cleanup(names)
86
+ raise
87
+ elif raw is not None:
88
+ keyring.delete_password(service, account)
89
+ cleanup(old_parts)
90
+ except Exception:
91
+ raise ConfigError("Could not access the platform subscription credential store") from None
92
+ return None
93
+
94
+
95
+ def _host_id() -> str:
96
+ path = default_paths().home / "subscription-host"
97
+ with _lock(path.with_suffix(".lock")):
98
+ if path.exists():
99
+ return str(uuid.UUID(path.read_text(encoding="utf-8").strip()))
100
+ host = str(uuid.uuid4())
101
+ path.write_text(host, encoding="utf-8")
102
+ return host
103
+
104
+
105
+ async def _metadata(client) -> dict:
106
+ reply = await client.get(f"{ISSUER}/.well-known/openid-configuration")
107
+ reply.raise_for_status()
108
+ data = reply.json()
109
+ if data.get("issuer") != ISSUER:
110
+ raise ConfigError("Subscription identity issuer did not match")
111
+ for key in ("authorization_endpoint", "token_endpoint", "jwks_uri", "revocation_endpoint"):
112
+ value = data.get(key)
113
+ if key == "revocation_endpoint" and value is None:
114
+ continue
115
+ url = urlsplit(value or "")
116
+ if (
117
+ url.scheme != "https"
118
+ or url.netloc != "auth.openai.com"
119
+ or url.username
120
+ or url.fragment
121
+ or url.query
122
+ ):
123
+ raise ConfigError("Subscription discovery returned an invalid endpoint")
124
+ return data
125
+
126
+
127
+ def _callback(
128
+ endpoint: str,
129
+ params: dict,
130
+ *,
131
+ timeout: float = 300,
132
+ cancelled: threading.Event | None = None,
133
+ port: int = 0,
134
+ ) -> tuple[dict, str]:
135
+ result: dict = {}
136
+
137
+ class Callback(BaseHTTPRequestHandler):
138
+ def setup(self):
139
+ self.request.settimeout(3)
140
+ super().setup()
141
+
142
+ def log_message(self, *_args):
143
+ pass
144
+
145
+ def do_GET(self):
146
+ uri = urlsplit(self.path)
147
+ query = parse_qs(uri.query, keep_blank_values=True)
148
+ valid = (
149
+ uri.path == "/auth/callback"
150
+ and len(query.get("state", [])) == 1
151
+ and secrets.compare_digest(query["state"][0], params["state"])
152
+ )
153
+ if valid and not result:
154
+ if any(len(values) != 1 for values in query.values()):
155
+ result["error"] = "invalid_callback"
156
+ else:
157
+ result.update({key: values[0] for key, values in query.items()})
158
+ self.send_response(200 if valid else 400)
159
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
160
+ self.send_header("Cache-Control", "no-store")
161
+ self.send_header("Referrer-Policy", "no-referrer")
162
+ self.end_headers()
163
+ self.wfile.write(b"Return to rlm-agent." if valid else b"Invalid login callback.")
164
+
165
+ with HTTPServer(("127.0.0.1", port), Callback) as server:
166
+ redirect = f"http://127.0.0.1:{server.server_port}/auth/callback"
167
+ target = endpoint + "?" + urlencode({**params, "redirect_uri": redirect})
168
+ if not webbrowser.open(target):
169
+ raise ConfigError("Could not open the subscription sign-in browser")
170
+ deadline = time.monotonic() + timeout
171
+ while (
172
+ not result
173
+ and time.monotonic() < deadline
174
+ and not (cancelled is not None and cancelled.is_set())
175
+ ):
176
+ server.timeout = min(1, max(0, deadline - time.monotonic()))
177
+ server.handle_request()
178
+ if not result or result.get("error") or not result.get("code"):
179
+ raise ConfigError("Subscription sign-in was denied, invalid, or timed out")
180
+ return result, redirect
181
+
182
+
183
+ async def _identity(
184
+ client, metadata: dict, token: str, client_id: str, *, nonce: str | None = None
185
+ ) -> dict:
186
+ jwt, _ = _dependencies()
187
+ try:
188
+ header = jwt.get_unverified_header(token)
189
+ if header.get("alg") not in ("RS256", "ES256") or not header.get("kid"):
190
+ raise ValueError()
191
+ response = await client.get(metadata["jwks_uri"])
192
+ response.raise_for_status()
193
+ keys = [key for key in response.json()["keys"] if key.get("kid") == header["kid"]]
194
+ if len(keys) != 1:
195
+ raise ValueError()
196
+ key = jwt.PyJWK.from_dict(keys[0], algorithm=header["alg"]).key
197
+ claims = jwt.decode(
198
+ token,
199
+ key,
200
+ algorithms=[header["alg"]],
201
+ issuer=ISSUER,
202
+ audience=client_id,
203
+ leeway=5,
204
+ options={"require": ["sub", "exp", "iat"]},
205
+ )
206
+ if not isinstance(claims["sub"], str) or not claims["sub"]:
207
+ raise ValueError()
208
+ if nonce is not None and claims.get("nonce") != nonce:
209
+ raise ValueError()
210
+ return claims
211
+ except Exception:
212
+ raise ConfigError("Subscription identity token validation failed") from None
213
+
214
+
215
+ def _tokens(reply: dict, previous: dict | None = None) -> dict:
216
+ old = previous or {}
217
+ access = reply.get("access_token")
218
+ refresh = reply.get("refresh_token", old.get("refresh_token"))
219
+ scope = reply.get("scope", " ".join(old.get("scopes", [])))
220
+ lifetime = reply.get("expires_in")
221
+ if (
222
+ reply.get("token_type", "").lower() != "bearer"
223
+ or not isinstance(access, str)
224
+ or not access
225
+ or any(c.isspace() for c in access)
226
+ or not isinstance(refresh, str)
227
+ or not refresh
228
+ or not isinstance(scope, str)
229
+ or isinstance(lifetime, bool)
230
+ or not isinstance(lifetime, (int, float))
231
+ or not math.isfinite(lifetime)
232
+ or lifetime <= 0
233
+ ):
234
+ raise ConfigError("Subscription token response was invalid")
235
+ scopes = scope.split()
236
+ if PERMISSION not in scopes:
237
+ raise ConfigError("Sign-in did not grant ChatGPT plan usage")
238
+ return {
239
+ **old,
240
+ "access_token": access,
241
+ "refresh_token": refresh,
242
+ "scopes": scopes,
243
+ "expires_at": time.time() + lifetime,
244
+ "id_token": reply.get("id_token", old.get("id_token")),
245
+ }
246
+
247
+
248
+ async def login(*, new_account: bool = False) -> None:
249
+ """Complete browser consent and validate identity before replacing saved credentials."""
250
+ _dependencies()
251
+
252
+ def selected():
253
+ with _lock(default_paths().home / "subscription-refresh.lock"):
254
+ return _store("load")
255
+
256
+ old = None if new_account else await asyncio.to_thread(selected)
257
+ host = await asyncio.to_thread(_host_id)
258
+ verifier = secrets.token_urlsafe(64)
259
+ nonce = secrets.token_urlsafe(32)
260
+ params = {
261
+ "client_id": old["client_id"] if old else "dynamic_agent_client",
262
+ "ext_agent_host_id": f"urn:uuid:{host}",
263
+ "response_type": "code",
264
+ "scope": SCOPES,
265
+ "resource": RESOURCE,
266
+ "state": secrets.token_urlsafe(32),
267
+ "nonce": nonce,
268
+ "code_challenge_method": "S256",
269
+ "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
270
+ .rstrip(b"=")
271
+ .decode(),
272
+ }
273
+ if old:
274
+ if old.get("id_token"):
275
+ params["id_token_hint"] = old["id_token"]
276
+ else:
277
+ params["agent_name_hint"] = "rlm-agent"
278
+ try:
279
+ async with build_async_client(timeout=20, follow_redirects=False) as client:
280
+ metadata = await _metadata(client)
281
+ cancelled = threading.Event()
282
+ try:
283
+ callback, redirect = await asyncio.to_thread(
284
+ _callback, metadata["authorization_endpoint"], params, cancelled=cancelled
285
+ )
286
+ finally:
287
+ cancelled.set()
288
+ client_id = callback.get("client_id", old["client_id"] if old else "")
289
+ if (
290
+ not client_id
291
+ or client_id == "dynamic_agent_client"
292
+ or (old and client_id != old["client_id"])
293
+ ):
294
+ raise ConfigError("Subscription registration returned an invalid client ID")
295
+ response = await client.post(
296
+ metadata["token_endpoint"],
297
+ data={
298
+ "grant_type": "authorization_code",
299
+ "client_id": client_id,
300
+ "code": callback["code"],
301
+ "code_verifier": verifier,
302
+ "redirect_uri": redirect,
303
+ "resource": RESOURCE,
304
+ },
305
+ )
306
+ response.raise_for_status()
307
+ tokens = _tokens(response.json())
308
+ claims = await _identity(client, metadata, tokens["id_token"], client_id, nonce=nonce)
309
+ if old and claims["sub"] != old["subject"]:
310
+ raise ConfigError("Subscription sign-in returned a different account")
311
+ tokens.update(client_id=client_id, subject=claims["sub"], host_id=host)
312
+
313
+ def save():
314
+ with _lock(default_paths().home / "subscription-refresh.lock"):
315
+ _store("save", tokens)
316
+
317
+ await asyncio.to_thread(save)
318
+ except ConfigError:
319
+ raise
320
+ except Exception:
321
+ raise ConfigError("Subscription sign-in failed; saved credentials were retained") from None
322
+
323
+
324
+ def _resolve_sync() -> Credential:
325
+ # Hold a cross-process lock across refresh and store to protect rotating tokens.
326
+ with _lock(default_paths().home / "subscription-refresh.lock"):
327
+ record = _store("load")
328
+ if not record or not record.get("refresh_token"):
329
+ raise ConfigError("Run rlm-agent provider login codex first")
330
+ if record["expires_at"] <= time.time() + 60:
331
+
332
+ async def renew():
333
+ async with build_async_client(timeout=20, follow_redirects=False) as client:
334
+ metadata = await _metadata(client)
335
+ response = await client.post(
336
+ metadata["token_endpoint"],
337
+ data={
338
+ "grant_type": "refresh_token",
339
+ "client_id": record["client_id"],
340
+ "refresh_token": record["refresh_token"],
341
+ "resource": RESOURCE,
342
+ },
343
+ )
344
+ response.raise_for_status()
345
+ reply = response.json()
346
+ fresh = _tokens(reply, record)
347
+ if reply.get("id_token"):
348
+ identity = await _identity(
349
+ client, metadata, reply["id_token"], record["client_id"]
350
+ )
351
+ if identity["sub"] != record["subject"]:
352
+ raise ConfigError("Subscription refresh returned a different account")
353
+ return fresh
354
+
355
+ try:
356
+ record = asyncio.run(renew())
357
+ _store("save", record)
358
+ except Exception:
359
+ raise ConfigError("Subscription refresh failed; sign in again") from None
360
+ if PERMISSION not in record["scopes"]:
361
+ raise ConfigError("Saved sign-in does not grant ChatGPT plan usage")
362
+ return Credential(record["access_token"], RESOURCE)
363
+
364
+
365
+ async def resolve() -> Credential:
366
+ return await asyncio.to_thread(_resolve_sync)
367
+
368
+
369
+ async def logout() -> bool:
370
+ def revoke():
371
+ with _lock(default_paths().home / "subscription-refresh.lock"):
372
+ record = _store("load")
373
+ confirmed = record is None or not record.get("refresh_token")
374
+
375
+ async def request():
376
+ async with build_async_client(timeout=20, follow_redirects=False) as client:
377
+ metadata = await _metadata(client)
378
+ response = await client.post(
379
+ metadata["revocation_endpoint"],
380
+ data={
381
+ "token": record["refresh_token"],
382
+ "token_type_hint": "refresh_token",
383
+ "client_id": record["client_id"],
384
+ },
385
+ )
386
+ response.raise_for_status()
387
+
388
+ if record and record.get("refresh_token"):
389
+ try:
390
+ asyncio.run(request())
391
+ confirmed = True
392
+ except Exception:
393
+ pass
394
+ if record:
395
+ _store("save", {key: record[key] for key in ("client_id", "subject", "host_id")})
396
+ return confirmed
397
+
398
+ return await asyncio.to_thread(revoke)
@@ -0,0 +1,15 @@
1
+ """A counted request with a provider-enforced single-attempt reply cap."""
2
+
3
+ from collections.abc import AsyncIterator, Callable
4
+ from dataclasses import dataclass
5
+
6
+
7
+ class TokenBoundError(ValueError):
8
+ """The provider cannot establish a bound before generation."""
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TokenReservation:
13
+ input_tokens: int
14
+ output_tokens: int
15
+ start: Callable[[], AsyncIterator]
@@ -0,0 +1 @@
1
+ """rlmagent.model transport: http client, retry/backoff, error formatting."""
@@ -0,0 +1,87 @@
1
+ """Exponential backoff with cancellation support for model adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from asyncio import sleep
6
+ from collections import deque
7
+ from time import monotonic
8
+
9
+ from rlmagent.harness.contracts.tooling import CancelToken
10
+ from rlmagent.harness.contracts.values import JValue
11
+ from rlmagent.harness.provider.wire import SourceRetryEvent
12
+
13
+ RETRY_POLL_SECONDS = 0.05
14
+ RETRY_BASE_DELAY_SECONDS = 0.25
15
+ #: Retries one provider may spend in any rolling window, across all requests.
16
+ #: A provider that keeps failing should fail fast, not stall every turn.
17
+ RETRY_BUDGET = 8
18
+ RETRY_BUDGET_WINDOW_SECONDS = 600.0
19
+
20
+
21
+ class RetryBudget:
22
+ """Caps retries across every request a provider makes, per rolling window."""
23
+
24
+ def __init__(self, limit: int = RETRY_BUDGET,
25
+ window_seconds: float = RETRY_BUDGET_WINDOW_SECONDS, clock=monotonic) -> None:
26
+ self.limit, self.window, self._clock = limit, window_seconds, clock
27
+ self._spent: deque[float] = deque()
28
+
29
+ def take(self) -> bool:
30
+ """Spend one retry if the window allows it."""
31
+ now = self._clock()
32
+ while self._spent and now - self._spent[0] >= self.window:
33
+ self._spent.popleft()
34
+ if len(self._spent) >= self.limit:
35
+ return False
36
+ self._spent.append(now)
37
+ return True
38
+
39
+
40
+ def compute_delay(attempt: int, *, max_delay_seconds: float) -> float:
41
+ """Calculate a capped exponential delay for the given attempt number."""
42
+ if max_delay_seconds <= 0:
43
+ return 0.0
44
+ base_delay = min(RETRY_BASE_DELAY_SECONDS, max_delay_seconds)
45
+ return float(min(max_delay_seconds, base_delay * (2**attempt)))
46
+
47
+
48
+ def build_retry_event(
49
+ *,
50
+ attempt: int,
51
+ max_retries: int,
52
+ delay_seconds: float,
53
+ reason: str,
54
+ data: dict[str, JValue] | None = None,
55
+ ) -> SourceRetryEvent:
56
+ """Assemble a ``SourceRetryEvent`` describing the upcoming reattempt."""
57
+ next_attempt = attempt + 2
58
+ max_attempts = max_retries + 1
59
+ delay_suffix = f" in {delay_seconds:g}s" if delay_seconds else ""
60
+ return SourceRetryEvent(
61
+ attempt=next_attempt,
62
+ max_attempts=max_attempts,
63
+ delay_seconds=delay_seconds,
64
+ message=(
65
+ f"Reattempting request {next_attempt}/{max_attempts} after {reason}{delay_suffix}."
66
+ ),
67
+ data=data,
68
+ )
69
+
70
+
71
+ async def pause_for_retry(
72
+ delay_seconds: float,
73
+ *,
74
+ signal: CancelToken | None,
75
+ ) -> bool:
76
+ """Wait out a backoff interval, returning False early if the signal fires."""
77
+ if delay_seconds <= 0:
78
+ return signal is None or not signal.is_cancelled()
79
+
80
+ remaining = delay_seconds
81
+ while remaining > 0:
82
+ if signal is not None and signal.is_cancelled():
83
+ return False
84
+ step = min(RETRY_POLL_SECONDS, remaining)
85
+ await sleep(step)
86
+ remaining -= step
87
+ return signal is None or not signal.is_cancelled()
@@ -0,0 +1,87 @@
1
+ """Proxy-aware HTTP utilities for model adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import ssl
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager
9
+ from typing import Any
10
+
11
+ import httpx
12
+ import certifi
13
+
14
+ _PROXY_ENV_VARS = (
15
+ "HTTP_PROXY",
16
+ "HTTPS_PROXY",
17
+ "ALL_PROXY",
18
+ "http_proxy",
19
+ "https_proxy",
20
+ "all_proxy",
21
+ )
22
+
23
+
24
+ def sanitize_proxy_url(proxy_url: str) -> str:
25
+ """Rewrite ambiguous ``socks://`` schemes to the explicit ``socks5://`` that httpx requires."""
26
+
27
+ if proxy_url.lower().startswith("socks://"):
28
+ return f"socks5://{proxy_url[len('socks://') :]}"
29
+ return proxy_url
30
+
31
+
32
+ @contextmanager
33
+ def patched_proxy_environment() -> Iterator[None]:
34
+ """Swap proxy env vars to httpx-safe values for the duration of the block."""
35
+
36
+ original: dict[str, str | None] = {}
37
+ changed = False
38
+ for name in _PROXY_ENV_VARS:
39
+ value = os.environ.get(name)
40
+ if value is None:
41
+ continue
42
+ normalized = sanitize_proxy_url(value)
43
+ if normalized == value:
44
+ continue
45
+ original[name] = value
46
+ os.environ[name] = normalized
47
+ changed = True
48
+
49
+ try:
50
+ yield
51
+ finally:
52
+ if changed:
53
+ for name, value in original.items():
54
+ if value is None:
55
+ os.environ.pop(name, None)
56
+ else:
57
+ os.environ[name] = value
58
+
59
+
60
+ def build_async_client(**kwargs: Any) -> httpx.AsyncClient:
61
+ """Construct an ``httpx.AsyncClient`` with sanitized proxy settings."""
62
+
63
+ with patched_proxy_environment():
64
+ if "verify" not in kwargs:
65
+ kwargs["verify"] = certificate_context()
66
+ return httpx.AsyncClient(**kwargs)
67
+
68
+
69
+ def certificate_context() -> ssl.SSLContext:
70
+ context = ssl.create_default_context()
71
+ context.load_verify_locations(cafile=certifi.where())
72
+ if extra := os.environ.get("NODE_EXTRA_CA_CERTS"):
73
+ context.load_verify_locations(cafile=extra)
74
+ return context
75
+
76
+
77
+ def fetch_json(url: str, *, timeout: float, follow_redirects: bool = False) -> dict[str, object]:
78
+ """GET a URL and return the parsed JSON dict, using sanitized proxy settings."""
79
+
80
+ with patched_proxy_environment():
81
+ response = httpx.get(url, timeout=timeout, follow_redirects=follow_redirects,
82
+ verify=certificate_context())
83
+ response.raise_for_status()
84
+ data = response.json()
85
+ if not isinstance(data, dict):
86
+ raise ValueError("Expected a JSON object in the response body")
87
+ return data
@@ -0,0 +1,65 @@
1
+ """Redact-safe formatting of HTTP failures from model providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from json import JSONDecodeError, loads
7
+ from typing import Any
8
+
9
+ _MAX_ERROR_DETAIL_LENGTH = 1000
10
+
11
+
12
+ def format_http_error(
13
+ *,
14
+ provider_name: str,
15
+ status_code: int,
16
+ body: str,
17
+ model: str | None = None,
18
+ ) -> str:
19
+ """Format a user-visible, credential-free error line from a failed provider HTTP call."""
20
+ prefix = f"{provider_name} request failed with status {status_code}"
21
+ if model:
22
+ prefix = f"{prefix} for model {model}"
23
+ detail = extract_http_detail(body)
24
+ if detail:
25
+ return f"{prefix}: {detail}"
26
+ return prefix
27
+
28
+
29
+ def extract_http_detail(body: str) -> str:
30
+ """Pull the most informative snippet from a raw HTTP error body."""
31
+ parsed = _parse_object(body)
32
+ if parsed is not None:
33
+ detail = extract_mapping_detail(parsed)
34
+ if detail:
35
+ return detail
36
+ return body.strip()[:_MAX_ERROR_DETAIL_LENGTH]
37
+
38
+
39
+ def extract_mapping_detail(value: Mapping[str, Any]) -> str:
40
+ """Dig through nested dicts for the best human-readable error string."""
41
+ error = value.get("error")
42
+ if isinstance(error, Mapping):
43
+ message = error.get("message")
44
+ if isinstance(message, str) and message:
45
+ return message
46
+ code = error.get("code")
47
+ if isinstance(code, str) and code:
48
+ return code
49
+ for key in ("message", "detail", "error"):
50
+ detail = value.get(key)
51
+ if isinstance(detail, str) and detail:
52
+ return detail
53
+ if isinstance(detail, Mapping):
54
+ nested = extract_mapping_detail(detail)
55
+ if nested:
56
+ return nested
57
+ return ""
58
+
59
+
60
+ def _parse_object(value: str) -> Mapping[str, Any] | None:
61
+ try:
62
+ parsed = loads(value)
63
+ except JSONDecodeError:
64
+ return None
65
+ return parsed if isinstance(parsed, Mapping) else None