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,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"]
|
rlmagent/model/py.typed
ADDED
|
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)
|