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,414 @@
1
+ """OpenAI-compatible provider adapter for the rlm-agent agent harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from contextlib import aclosing
9
+ from dataclasses import replace
10
+
11
+ import httpx
12
+
13
+ from rlmagent.harness.contracts.tooling import CancelToken, ToolSpec
14
+ from rlmagent.harness.contracts.transcript import ModelEntry, TranscriptEntry
15
+ from rlmagent.harness.provider.wire import StreamFaultEvent, WireEvent
16
+ from rlmagent.model._oai.helpers import (
17
+ build_reasoning_extra,
18
+ is_passing_failure,
19
+ pick_endpoint,
20
+ stream_failure,
21
+ try_parse_json,
22
+ )
23
+ from rlmagent.model._oai.normalize import EventAssembler
24
+ from rlmagent.model._oai.parsers import ChatDecoder, ParseFault, ResponsesDecoder, StreamDecoder
25
+ from rlmagent.model._oai.payloads import build_chat_payload, build_responses_payload
26
+ from rlmagent.model._oai.transport import HttpStreamError, MalformedPayload, open_event_stream
27
+ from rlmagent.model.settings import Credential, OpenAIProfile, ReasoningPolicy
28
+ from rlmagent.model.token_bounds import TokenBoundError, TokenReservation
29
+ from rlmagent.model.transport.backoff import RetryBudget, build_retry_event, compute_delay, pause_for_retry
30
+ from rlmagent.model.transport.client import build_async_client
31
+ from rlmagent.model.transport.faults import format_http_error
32
+
33
+ _FIRST_PARTY_HOST = "api.openai.com"
34
+ # Routers that front several backends name the one that answered here.
35
+ _SERVED_BY_HEADER = "x-inference-provider"
36
+ _ROUTING_KEY_LIMIT = 64
37
+
38
+
39
+ class OpenAIProvider:
40
+ """Streaming model adapter for OpenAI-compatible API endpoints."""
41
+
42
+ def __init__(self, profile: OpenAIProfile) -> None:
43
+ self._retry_budget = RetryBudget()
44
+ self._profile = profile
45
+ self.default_service_tier = profile.service_tier
46
+ self.supports_service_tier = profile.name in ("openai", "codex", "azure")
47
+ self._client: httpx.AsyncClient = build_async_client(
48
+ timeout=httpx.Timeout(profile.timeout_seconds),
49
+ )
50
+ #: Backend that served the latest accepted response, when reported.
51
+ self.served_by: str | None = None
52
+ #: HTTP status behind the latest request that ended in a fault.
53
+ self.last_failure_status: int | None = None
54
+ self._sockets = None
55
+
56
+ def set_reasoning(self, policy: ReasoningPolicy) -> None:
57
+ """Swap the reasoning-effort policy for subsequent requests."""
58
+ self._profile = replace(self._profile, reasoning=policy)
59
+
60
+ async def close(self) -> None:
61
+ """Release the underlying HTTP client resources."""
62
+ await self._client.aclose()
63
+ if self._sockets is not None:
64
+ await self._sockets.close()
65
+
66
+ async def _open_events(self, url, payload, headers, *, signal=None, cache_key=None):
67
+ if self._profile.transport == "websocket":
68
+ from rlmagent.model.transport.responses_socket import ResponsesSockets
69
+
70
+ if not url.endswith("/responses"):
71
+ raise httpx.ConnectError("WebSocket transport requires Responses")
72
+ if self._sockets is None:
73
+ self._sockets = ResponsesSockets(timeout=self._profile.timeout_seconds)
74
+ async for event in self._sockets.stream(url, payload, headers, signal=signal,
75
+ affinity=cache_key):
76
+ yield event
77
+ else:
78
+ async for event in open_event_stream(self._client, url, payload, headers,
79
+ signal=signal, on_accept=self._note_server):
80
+ yield event
81
+
82
+ def set_service_tier(self, tier: str | None) -> None:
83
+ if tier is not None and not self.supports_service_tier:
84
+ raise ValueError("The current provider does not expose service tiers")
85
+ if tier not in (None, "auto", "default", "flex", "priority"):
86
+ raise ValueError("Use auto, default, flex, priority or off")
87
+ self._profile = replace(self._profile, service_tier=tier)
88
+
89
+ # --- ModelProvider protocol ---------------------------------------------
90
+
91
+ def stream_response(
92
+ self,
93
+ *,
94
+ model: str,
95
+ system: str,
96
+ messages: Sequence[TranscriptEntry],
97
+ tools: Sequence[ToolSpec],
98
+ signal: CancelToken | None = None,
99
+ cache_key: str | None = None,
100
+ ) -> AsyncIterator[WireEvent]:
101
+ """Stream wire events for one model round-trip."""
102
+ return self._stream(
103
+ model=model,
104
+ system=system,
105
+ messages=messages,
106
+ tools=tools,
107
+ signal=signal,
108
+ cache_key=cache_key,
109
+ )
110
+
111
+ async def prepare_token_request(self, *, model, system, messages, tools, remaining,
112
+ signal=None, cache_key=None):
113
+ if self._profile.name == "codex":
114
+ raise TokenBoundError("Codex subscription does not accept a provider-enforced output cap")
115
+ if self._profile.name != "openai" or self._profile.transport != "http":
116
+ raise TokenBoundError("strict token reservations require first-party Responses over HTTP")
117
+ cred = await self._resolve_credential()
118
+ base = cred.base_url.rstrip("/")
119
+ if base != "https://api.openai.com/v1" or (self._profile.endpoint or pick_endpoint(model, base)) != "responses":
120
+ raise TokenBoundError("strict token reservations require the first-party Responses endpoint")
121
+ payload = self._responses_payload(model, system, messages, tools)
122
+ counted = {key: value for key, value in payload.items() if key != "stream"}
123
+ try:
124
+ response = await self._client.post(f"{base}/responses/input_tokens", json=counted,
125
+ headers=self._build_headers(cred))
126
+ response.raise_for_status()
127
+ result = response.json()
128
+ except (httpx.HTTPError, ValueError) as exc:
129
+ raise TokenBoundError("Responses did not supply an input token count") from exc
130
+ count = result.get("input_tokens") if isinstance(result, dict) else None
131
+ if type(count) is not int or count < 0:
132
+ raise TokenBoundError("Responses returned an invalid input token count")
133
+ output = min(8192, remaining - count)
134
+ if output < 16:
135
+ raise TokenBoundError(f"{count} input tokens leave less than 16 tokens for a reply")
136
+ payload.update(max_output_tokens=output, store=False)
137
+
138
+ def start():
139
+ return self._stream(model=model, system=system, messages=messages, tools=tools,
140
+ signal=signal, cache_key=cache_key, prepared=(cred, payload))
141
+
142
+ return TokenReservation(count, output, start)
143
+
144
+ def _responses_payload(self, model, system, messages, tools):
145
+ reasoning = build_reasoning_extra(self._profile.reasoning, model=model, endpoint="responses")
146
+ return build_responses_payload(model=model, system=system, messages=messages,
147
+ tools=tools, extra=reasoning or None)
148
+
149
+ # --- internals ----------------------------------------------------------
150
+
151
+ async def _resolve_credential(self) -> Credential:
152
+ """Obtain authentication material, refreshing if a resolver is configured."""
153
+ if self._profile.resolver is not None:
154
+ return await self._profile.resolver()
155
+ return self._profile.credential
156
+
157
+ def _build_headers(self, cred: Credential) -> dict[str, str]:
158
+ """Assemble HTTP headers from the credential and profile."""
159
+ headers: dict[str, str] = {"Content-Type": "application/json"}
160
+ if cred.api_key:
161
+ headers["Authorization"] = f"Bearer {cred.api_key}"
162
+ if self._profile.organization:
163
+ headers["OpenAI-Organization"] = self._profile.organization
164
+ for key, value in cred.extra_headers:
165
+ headers[key] = value
166
+ return headers
167
+
168
+ async def _stream(
169
+ self,
170
+ *,
171
+ model: str,
172
+ system: str,
173
+ messages: Sequence[TranscriptEntry],
174
+ tools: Sequence[ToolSpec],
175
+ signal: CancelToken | None,
176
+ cache_key: str | None = None,
177
+ prepared=None,
178
+ ) -> AsyncIterator[WireEvent]:
179
+ """Execute the streaming request with retry and normalisation."""
180
+ self.served_by = None
181
+ self.last_failure_status = None
182
+ cred = prepared[0] if prepared is not None else await self._resolve_credential()
183
+ base_url = cred.base_url.rstrip("/")
184
+ endpoint = "responses" if prepared is not None else self._profile.endpoint or pick_endpoint(model, base_url)
185
+
186
+ if endpoint == "responses":
187
+ url = f"{base_url}/responses"
188
+ payload = prepared[1] if prepared is not None else self._responses_payload(model, system, messages, tools)
189
+ else:
190
+ url = f"{base_url}/chat/completions"
191
+ reasoning_extra = build_reasoning_extra(self._profile.reasoning, model=model, endpoint=endpoint)
192
+ payload = build_chat_payload(
193
+ model=model,
194
+ system=system,
195
+ messages=messages,
196
+ tools=tools,
197
+ extra=reasoning_extra or None,
198
+ )
199
+
200
+ routing_key = self._routing_key(cache_key, base_url)
201
+ if self._profile.name == "codex":
202
+ payload["store"] = False
203
+ if self._profile.service_tier is not None:
204
+ payload["service_tier"] = self._profile.service_tier
205
+ if routing_key is not None:
206
+ payload["prompt_cache_key"] = routing_key
207
+
208
+ retry = self._profile.retry
209
+ if prepared is not None:
210
+ retry = replace(retry, max_retries=0)
211
+ max_attempts = retry.max_retries + 1
212
+ # One key per logical call, kept across its retries, so a proxy in
213
+ # front of the provider can drop a duplicate of a request it served.
214
+ # Opt-in: the providers' own clients do not send it on these
215
+ # endpoints, so nothing shows the providers accept it.
216
+ idempotency_key = (
217
+ uuid.uuid4().hex if (self._profile.idempotency if self._profile.idempotency is not None
218
+ else os.environ.get("RLM_AGENT_IDEMPOTENCY") == "1") else None
219
+ )
220
+ logical_request = idempotency_key or uuid.uuid4().hex
221
+
222
+ for attempt in range(max_attempts):
223
+ if signal is not None and signal.is_cancelled():
224
+ yield self._abort_event(model, endpoint)
225
+ return
226
+
227
+ # Refresh credentials on retries
228
+ if attempt > 0:
229
+ if self._profile.resolver is not None:
230
+ cred = await self._profile.resolver()
231
+ headers = self._build_headers(cred)
232
+ else:
233
+ headers = self._build_headers(cred)
234
+ if routing_key is not None and endpoint == "responses":
235
+ headers["session_id"] = routing_key
236
+ if idempotency_key is not None:
237
+ headers["Idempotency-Key"] = idempotency_key
238
+ headers["X-Stainless-Retry-Count"] = str(attempt)
239
+ headers["X-Rlmagent-Attempt-Id"] = f"{logical_request}:{attempt}"
240
+ from rlmagent.agents.trace import model_attempt
241
+
242
+ model_attempt(attempt=attempt, id=headers["X-Rlmagent-Attempt-Id"],
243
+ transport=self._profile.transport, phase="started")
244
+
245
+ try:
246
+ decoder: StreamDecoder
247
+ if endpoint == "chat":
248
+ decoder = ChatDecoder()
249
+ else:
250
+ decoder = ResponsesDecoder()
251
+
252
+ assembler = EventAssembler(
253
+ model=model,
254
+ provider=self._profile.name,
255
+ api=endpoint,
256
+ )
257
+
258
+ # Once anything reaches the caller the attempt is committed: a
259
+ # later failure is surfaced, never replayed, so visible output
260
+ # and tool calls cannot be duplicated.
261
+ committed = False
262
+ passing_failure = False
263
+ failure_kind = ""
264
+ try:
265
+ async with aclosing(self._open_events(
266
+ url, payload, headers, signal=signal,
267
+ cache_key=cache_key,
268
+ )) as events:
269
+ async for sse in events:
270
+ data = try_parse_json(sse.data)
271
+ if data is None:
272
+ raise MalformedPayload(sse.data)
273
+ # Some compatible servers omit the `event:` line;
274
+ # the payload's own `type` names the event then.
275
+ name = sse.event or str(data.get("type") or "")
276
+ if not committed and attempt < retry.max_retries:
277
+ failure = stream_failure(name, data)
278
+ if failure is not None and is_passing_failure(*failure):
279
+ passing_failure = True
280
+ failure_kind = failure[0] or "stream error"
281
+ break
282
+ for sig in decoder.decode(name, data):
283
+ for wire_event in assembler.accept(sig):
284
+ committed = True
285
+ yield wire_event
286
+ except MalformedPayload as exc:
287
+ for wire_event in assembler.accept(ParseFault(message=str(exc))):
288
+ yield wire_event
289
+
290
+ if not passing_failure:
291
+ if signal is not None and signal.is_cancelled():
292
+ yield self._abort_event(model, endpoint)
293
+ return
294
+ for wire_event in assembler.finalize():
295
+ yield wire_event
296
+ return # success
297
+
298
+ delay = compute_delay(attempt, max_delay_seconds=retry.max_delay_seconds)
299
+ yield build_retry_event(
300
+ attempt=attempt, max_retries=retry.max_retries,
301
+ delay_seconds=delay, reason=f"a transient {failure_kind}",
302
+ )
303
+ still_alive = await pause_for_retry(delay, signal=signal)
304
+ if not still_alive:
305
+ yield self._abort_event(model, endpoint)
306
+ return
307
+ continue
308
+
309
+ except HttpStreamError as exc:
310
+ if not exc.retriable or attempt >= retry.max_retries or not self._retry_budget.take():
311
+ self.last_failure_status = exc.status
312
+ yield self._http_error_event(model, endpoint, exc)
313
+ return
314
+
315
+ delay = compute_delay(
316
+ attempt, max_delay_seconds=retry.max_delay_seconds,
317
+ )
318
+ if exc.retry_after_seconds is not None:
319
+ delay = max(delay, exc.retry_after_seconds)
320
+
321
+ yield build_retry_event(
322
+ attempt=attempt, max_retries=retry.max_retries,
323
+ delay_seconds=delay, reason=f"HTTP {exc.status}",
324
+ )
325
+ still_alive = await pause_for_retry(delay, signal=signal)
326
+ if not still_alive:
327
+ yield self._abort_event(model, endpoint)
328
+ return
329
+
330
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc:
331
+ if committed or attempt >= retry.max_retries or not self._retry_budget.take():
332
+ yield self._network_error_event(model, endpoint, exc)
333
+ return
334
+
335
+ delay = compute_delay(
336
+ attempt, max_delay_seconds=retry.max_delay_seconds,
337
+ )
338
+ yield build_retry_event(
339
+ attempt=attempt, max_retries=retry.max_retries,
340
+ delay_seconds=delay, reason="a network error",
341
+ )
342
+ still_alive = await pause_for_retry(delay, signal=signal)
343
+ if not still_alive:
344
+ yield self._abort_event(model, endpoint)
345
+ return
346
+
347
+ def _note_server(self, response: httpx.Response) -> None:
348
+ self.served_by = response.headers.get(_SERVED_BY_HEADER) or None
349
+
350
+ def _routing_key(self, cache_key: str | None, base_url: str) -> str | None:
351
+ """The prompt-cache routing value for this request, if it should be sent."""
352
+ if not cache_key:
353
+ return None
354
+ enabled = self._profile.cache_affinity
355
+ if enabled is None:
356
+ enabled = httpx.URL(base_url).host == _FIRST_PARTY_HOST
357
+ # The API caps the key length; the session id is clamped to fit.
358
+ return cache_key[:_ROUTING_KEY_LIMIT] if enabled else None
359
+
360
+ # --- error event builders -----------------------------------------------
361
+
362
+ def _abort_event(self, model: str, endpoint: str) -> StreamFaultEvent:
363
+ """Build an abort event for a cancelled request."""
364
+ entry = ModelEntry(
365
+ model=model,
366
+ provider=self._profile.name,
367
+ api=endpoint,
368
+ content=[],
369
+ stop_reason="aborted",
370
+ )
371
+ return StreamFaultEvent(reason="aborted", error=entry)
372
+
373
+ def _http_error_event(
374
+ self,
375
+ model: str,
376
+ endpoint: str,
377
+ exc: HttpStreamError,
378
+ ) -> StreamFaultEvent:
379
+ """Build a fault event from an HTTP error response."""
380
+ msg = format_http_error(
381
+ provider_name=self._profile.name,
382
+ status_code=exc.status,
383
+ body=exc.body,
384
+ model=model,
385
+ )
386
+ entry = ModelEntry(
387
+ model=model,
388
+ provider=self._profile.name,
389
+ api=endpoint,
390
+ content=[],
391
+ stop_reason="error",
392
+ error_message=msg,
393
+ )
394
+ return StreamFaultEvent(reason="error", error=entry)
395
+
396
+ def _network_error_event(
397
+ self,
398
+ model: str,
399
+ endpoint: str,
400
+ exc: Exception,
401
+ ) -> StreamFaultEvent:
402
+ """Build a fault event from a network-level exception."""
403
+ entry = ModelEntry(
404
+ model=model,
405
+ provider=self._profile.name,
406
+ api=endpoint,
407
+ content=[],
408
+ stop_reason="error",
409
+ error_message=f"{self._profile.name} network error: {exc}",
410
+ )
411
+ return StreamFaultEvent(reason="error", error=entry)
412
+
413
+
414
+ __all__ = ["OpenAIProvider"]
@@ -0,0 +1,56 @@
1
+ """User-owned model cycles and per-model request settings, without credentials."""
2
+
3
+ import json
4
+ from uuid import uuid4
5
+
6
+ from rlmagent.discovery import _resolve_home
7
+ from rlmagent.refinement.store import _lock
8
+
9
+
10
+ def read(provider):
11
+ try:
12
+ data = json.loads((_resolve_home() / "model-preferences.json").read_text(encoding="utf-8"))
13
+ except FileNotFoundError:
14
+ data = {}
15
+ return data.get(provider, {"cycle": [], "overrides": {}})
16
+
17
+
18
+ def change(provider, operation):
19
+ path = _resolve_home() / "model-preferences.json"
20
+ with _lock(path.with_suffix(".lock")):
21
+ try:
22
+ data = json.loads(path.read_text(encoding="utf-8"))
23
+ except FileNotFoundError:
24
+ data = {}
25
+ selected = data.setdefault(provider, {"cycle": [], "overrides": {}})
26
+ operation(selected)
27
+ temporary = path.with_suffix(f".{uuid4().hex}.tmp")
28
+ try:
29
+ temporary.write_text(json.dumps(data), encoding="utf-8")
30
+ temporary.replace(path)
31
+ finally:
32
+ temporary.unlink(missing_ok=True)
33
+ return selected
34
+
35
+
36
+ def tier(provider, model, value):
37
+ change(
38
+ provider, lambda selected: selected["overrides"].setdefault(model, {}).update(tier=value)
39
+ )
40
+
41
+
42
+ def cycle(provider, operation, model=None):
43
+ def update(selected):
44
+ models = selected["cycle"]
45
+ if operation == "add":
46
+ if not model or len(model) > 256:
47
+ raise ValueError("Supply a model identifier")
48
+ if model not in models:
49
+ if len(models) >= 100:
50
+ raise ValueError("Model cycle limit reached")
51
+ models.append(model)
52
+ elif operation == "remove":
53
+ if model in models:
54
+ models.remove(model)
55
+
56
+ return change(provider, update)["cycle"]
File without changes
@@ -0,0 +1,195 @@
1
+ """OAuth for a user's registered xAI public client, without borrowed client IDs."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import secrets
9
+ import threading
10
+ import time
11
+ from urllib.parse import urlsplit
12
+
13
+ from rlmagent.discovery import _resolve_home
14
+ from rlmagent.model.settings import ConfigError, Credential
15
+ from rlmagent.model.subscription import _callback, _store
16
+ from rlmagent.model.transport.client import build_async_client
17
+ from rlmagent.refinement.store import _lock
18
+
19
+ NAME = "xai-subscription"
20
+ BASE = "https://api.x.ai/v1"
21
+
22
+
23
+ def registration(value):
24
+ required = {
25
+ "client_id",
26
+ "authorization_endpoint",
27
+ "token_endpoint",
28
+ "scope",
29
+ "resource",
30
+ "redirect_port",
31
+ }
32
+ if not isinstance(value, dict) or value.keys() != required:
33
+ raise ConfigError("Registered OAuth client settings have missing or unknown fields")
34
+ for key in ("client_id", "scope"):
35
+ if not isinstance(value[key], str) or not value[key].strip():
36
+ raise ConfigError("Registered OAuth client ID and scope must be nonempty")
37
+ if type(value["redirect_port"]) is not int or not 0 <= value["redirect_port"] <= 65535:
38
+ raise ConfigError("Invalid OAuth loopback port")
39
+ for key in ("authorization_endpoint", "token_endpoint"):
40
+ if not isinstance(value[key], str):
41
+ raise ConfigError("Registered OAuth endpoints must be strings")
42
+ uri = urlsplit(value[key])
43
+ host = uri.hostname or ""
44
+ allowed = host in ("x.ai", "grok.com") or host.endswith((".x.ai", ".grok.com"))
45
+ if (
46
+ not allowed
47
+ or uri.scheme != "https"
48
+ or uri.username
49
+ or uri.password
50
+ or uri.query
51
+ or uri.fragment
52
+ ):
53
+ raise ConfigError("Registered OAuth endpoints must be pinned HTTPS provider endpoints")
54
+ if value["resource"] is not None and value["resource"] != BASE:
55
+ raise ConfigError("OAuth resource must match the public inference endpoint")
56
+ return dict(value)
57
+
58
+
59
+ def _tokens(value, old):
60
+ access = value.get("access_token")
61
+ if (
62
+ value.get("token_type", "").lower() != "bearer"
63
+ or not isinstance(access, str)
64
+ or not access
65
+ or any(char.isspace() for char in access)
66
+ ):
67
+ raise ConfigError("Provider returned an invalid access token")
68
+ lifetime = value.get("expires_in")
69
+ if lifetime is not None and (
70
+ type(lifetime) not in (int, float) or not math.isfinite(lifetime) or lifetime <= 0
71
+ ):
72
+ raise ConfigError("Provider returned an invalid token lifetime")
73
+ refresh = value.get("refresh_token", old.get("refresh_token"))
74
+ if refresh is not None and (not isinstance(refresh, str) or not refresh):
75
+ raise ConfigError("Provider returned an invalid refresh token")
76
+ return {
77
+ "registration": old["registration"],
78
+ "access_token": access,
79
+ "refresh_token": refresh,
80
+ "expires_at": time.time() + lifetime if lifetime is not None else None,
81
+ }
82
+
83
+
84
+ async def login(path=None):
85
+ def selected():
86
+ with _lock(_resolve_home() / "xai-oauth.lock"):
87
+ return _store("load", namespace=NAME)
88
+
89
+ old = await asyncio.to_thread(selected)
90
+ if path is not None:
91
+ settings = registration(json.loads(path.read_text(encoding="utf-8")))
92
+ elif old:
93
+ settings = registration(old["registration"])
94
+ else:
95
+ raise ConfigError(
96
+ "Supply --client-registration with your provider-issued public client settings"
97
+ )
98
+ verifier = secrets.token_urlsafe(64)
99
+ params = {
100
+ "client_id": settings["client_id"],
101
+ "response_type": "code",
102
+ "scope": settings["scope"],
103
+ "state": secrets.token_urlsafe(32),
104
+ "code_challenge_method": "S256",
105
+ "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
106
+ .rstrip(b"=")
107
+ .decode(),
108
+ }
109
+ if settings["resource"]:
110
+ params["resource"] = settings["resource"]
111
+ cancelled = threading.Event()
112
+ try:
113
+ callback, redirect = await asyncio.to_thread(
114
+ _callback,
115
+ settings["authorization_endpoint"],
116
+ params,
117
+ cancelled=cancelled,
118
+ port=settings["redirect_port"],
119
+ )
120
+ if callback.get("client_id", settings["client_id"]) != settings["client_id"]:
121
+ raise ConfigError("Provider returned a different registered client")
122
+ fields = {
123
+ "grant_type": "authorization_code",
124
+ "client_id": settings["client_id"],
125
+ "code": callback["code"],
126
+ "code_verifier": verifier,
127
+ "redirect_uri": redirect,
128
+ }
129
+ if settings["resource"]:
130
+ fields["resource"] = settings["resource"]
131
+ async with build_async_client(timeout=20, follow_redirects=False) as client:
132
+ reply = await client.post(settings["token_endpoint"], data=fields)
133
+ reply.raise_for_status()
134
+ record = _tokens(reply.json(), {"registration": settings})
135
+
136
+ def save():
137
+ with _lock(_resolve_home() / "xai-oauth.lock"):
138
+ _store("save", record, namespace=NAME)
139
+
140
+ await asyncio.to_thread(save)
141
+ except ConfigError:
142
+ raise
143
+ except Exception:
144
+ raise ConfigError(
145
+ "Registered OAuth sign-in failed; saved credentials were retained"
146
+ ) from None
147
+ finally:
148
+ cancelled.set()
149
+
150
+
151
+ def _resolve():
152
+ with _lock(_resolve_home() / "xai-oauth.lock"):
153
+ record = _store("load", namespace=NAME)
154
+ if not record or not record.get("access_token"):
155
+ raise ConfigError(
156
+ "Run provider login xai-subscription with your registered client settings"
157
+ )
158
+ settings = registration(record["registration"])
159
+ if record["expires_at"] is not None and record["expires_at"] < time.time() + 60:
160
+ if not record["refresh_token"]:
161
+ raise ConfigError("Registered OAuth login expired; sign in again")
162
+
163
+ async def refresh():
164
+ fields = {
165
+ "grant_type": "refresh_token",
166
+ "client_id": settings["client_id"],
167
+ "refresh_token": record["refresh_token"],
168
+ }
169
+ if settings["resource"]:
170
+ fields["resource"] = settings["resource"]
171
+ async with build_async_client(timeout=20, follow_redirects=False) as client:
172
+ reply = await client.post(settings["token_endpoint"], data=fields)
173
+ reply.raise_for_status()
174
+ return _tokens(reply.json(), record)
175
+
176
+ try:
177
+ record = asyncio.run(refresh())
178
+ _store("save", record, namespace=NAME)
179
+ except Exception:
180
+ raise ConfigError("Registered OAuth refresh failed; sign in again") from None
181
+ return Credential(record["access_token"], BASE)
182
+
183
+
184
+ async def resolve():
185
+ return await asyncio.to_thread(_resolve)
186
+
187
+
188
+ async def logout():
189
+ def clear():
190
+ with _lock(_resolve_home() / "xai-oauth.lock"):
191
+ record = _store("load", namespace=NAME)
192
+ if record:
193
+ _store("save", {"registration": record["registration"]}, namespace=NAME)
194
+
195
+ await asyncio.to_thread(clear)