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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|