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
rlmagent/cli/update.py ADDED
@@ -0,0 +1,188 @@
1
+ """Upgrade an installed application using its package manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.metadata
7
+ import json
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
15
+ from packaging.version import InvalidVersion, Version
16
+
17
+
18
+ def select_release(document: dict, channel: str) -> str:
19
+ """Select a published, non-yanked release from the requested channel."""
20
+ candidates = []
21
+ current_python = ".".join(str(part) for part in sys.version_info[:3])
22
+ for text, files in document.get("releases", {}).items():
23
+ try:
24
+ version = Version(text)
25
+ except InvalidVersion:
26
+ continue
27
+ eligible = {
28
+ "stable": not version.is_prerelease and not version.is_devrelease,
29
+ "preview": version.is_prerelease and not version.is_devrelease,
30
+ "nightly": version.is_devrelease,
31
+ }[channel]
32
+ if not eligible:
33
+ continue
34
+ for artifact in files:
35
+ if artifact.get("yanked", False):
36
+ continue
37
+ try:
38
+ supported = SpecifierSet(artifact.get("requires_python") or "")
39
+ except InvalidSpecifier:
40
+ continue
41
+ if supported.contains(current_python, prereleases=True):
42
+ candidates.append(version)
43
+ break
44
+ if not candidates:
45
+ raise ValueError(f"No compatible, non-yanked {channel} release is published")
46
+ return str(max(candidates))
47
+
48
+
49
+ def run(argv: list[str]) -> int:
50
+ if argv[:1] == ["--run"]:
51
+ from rlmagent.managed_release import run as managed_run
52
+
53
+ try:
54
+ return managed_run(argv[1:])
55
+ except (OSError, ValueError) as error:
56
+ print(str(error), file=sys.stderr)
57
+ return 1
58
+ parser = argparse.ArgumentParser(prog="rlm-agent update")
59
+ parser.add_argument("--manifest", type=Path, help="Verified offline wheel-bundle manifest")
60
+ parser.add_argument(
61
+ "--managed", action="store_true", help="Use isolated installations and atomic activation"
62
+ )
63
+ parser.add_argument("--drain", action="store_true", help="Let workers finish before activation")
64
+ parser.add_argument("--wait", type=float, default=60)
65
+ parser.add_argument("--restart-workers", action="store_true")
66
+ parser.add_argument("--apply", action="store_true", help="Run the upgrade command.")
67
+ choice = parser.add_mutually_exclusive_group()
68
+ choice.add_argument("--check", action="store_true", help="Compare against the package index.")
69
+ choice.add_argument("--version", dest="target", help="Install a specific package version.")
70
+ choice.add_argument(
71
+ "--rollback", action="store_true", help="Reinstall the previously recorded version."
72
+ )
73
+ parser.add_argument("--channel", choices=("stable", "preview", "nightly"), default="stable")
74
+ args = parser.parse_args(argv)
75
+ if args.managed or args.manifest is not None:
76
+ if args.target is not None or args.check or (args.manifest is not None and args.rollback):
77
+ parser.error(
78
+ "Use one managed manifest or --managed --rollback; omit --version and --check"
79
+ )
80
+ from rlmagent import managed_release
81
+
82
+ try:
83
+ if not args.apply:
84
+ if args.manifest is not None:
85
+ print(json.dumps(managed_release.manifest(args.manifest), indent=2))
86
+ print("Use --apply to install or roll back the reviewed managed release.")
87
+ return 0
88
+ if args.rollback:
89
+ record = managed_release.rollback(
90
+ drain=args.drain, wait=args.wait, restart=args.restart_workers
91
+ )
92
+ elif args.manifest is not None:
93
+ record = managed_release.install(
94
+ args.manifest, drain=args.drain, wait=args.wait, restart=args.restart_workers
95
+ )
96
+ else:
97
+ raise ValueError("Managed installation needs --manifest, or --rollback")
98
+ print(f"Active managed release: {record['version']} ({record['channel']})")
99
+ return 0
100
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
101
+ print(f"Managed update failed: {error}", file=sys.stderr)
102
+ return 1
103
+ if args.drain or args.restart_workers:
104
+ parser.error("Worker drain and restart require a managed installation")
105
+ try:
106
+ package = importlib.metadata.distribution("rlmness-cli")
107
+ except importlib.metadata.PackageNotFoundError:
108
+ parser.exit(1, "rlmagent must be installed to update it.\n")
109
+ if package.read_text("direct_url.json"):
110
+ parser.exit(1, "Update this local or URL installation from its original source.\n")
111
+
112
+ from rlmagent.plugins import rlm_agent_home
113
+
114
+ receipt = rlm_agent_home() / "updates" / "previous.json"
115
+ target = args.target
116
+ if args.rollback:
117
+ try:
118
+ target = json.loads(receipt.read_text(encoding="utf-8"))["version"]
119
+ except (OSError, ValueError, KeyError):
120
+ parser.exit(1, "No successful earlier upgrade has been recorded.\n")
121
+ if target is not None and (
122
+ not isinstance(target, str) or not re.fullmatch(r"[0-9][A-Za-z0-9.!+_-]*", target)
123
+ ):
124
+ parser.error("invalid package version")
125
+ if args.check or (args.channel != "stable" and target is None):
126
+ import os
127
+
128
+ if os.environ.get("RLM_AGENT_OFFLINE") == "1":
129
+ parser.exit(1, "Update checks are disabled in offline startup mode.\n")
130
+ from rlmagent.model.transport.client import fetch_json
131
+
132
+ try:
133
+ latest = select_release(
134
+ fetch_json("https://pypi.org/pypi/rlmness-cli/json", timeout=15), args.channel
135
+ )
136
+ except ValueError as error:
137
+ parser.exit(1, str(error) + "\n")
138
+ except Exception:
139
+ parser.exit(1, "Could not check the package index.\n")
140
+ if args.check:
141
+ print(f"Installed rlmagent {package.version}; index {args.channel} release {latest}")
142
+ return 0
143
+ target = latest
144
+
145
+ prefix = Path(sys.prefix)
146
+ if (prefix / "uv-receipt.toml").is_file():
147
+ command = ["uv", "tool", "upgrade", "rlmness-cli"]
148
+ elif (prefix / "pipx_metadata.json").is_file():
149
+ command = ["pipx", "upgrade", "rlmness-cli"]
150
+ elif (package.read_text("INSTALLER") or "").strip() == "uv":
151
+ command = ["uv", "pip", "install", "--python", sys.executable, "--upgrade", "rlmness-cli"]
152
+ else:
153
+ command = [sys.executable, "-m", "pip", "install", "--upgrade", "rlmness-cli"]
154
+
155
+ if target is not None:
156
+ spec = f"rlmness-cli=={target}"
157
+ if command[:3] == ["uv", "tool", "upgrade"]:
158
+ command = ["uv", "tool", "install", "--force", spec]
159
+ elif command[:2] == ["pipx", "upgrade"]:
160
+ command = ["pipx", "install", "--force", spec]
161
+ else:
162
+ command[-1] = spec
163
+ if args.channel in ("preview", "nightly"):
164
+ if command[0] != "pipx":
165
+ command.extend(["--prerelease", "allow"] if command[0] == "uv" else ["--pre"])
166
+
167
+ print(f"Installed rlmagent {package.version}", flush=True)
168
+ if not args.apply:
169
+ print("Run rlm-agent update --apply with the same options to install the requested update.")
170
+ return 0
171
+ executable = shutil.which(command[0])
172
+ if executable is None:
173
+ parser.exit(1, f"The installer {command[0]} is missing from PATH.\n")
174
+ try:
175
+ status = subprocess.run([executable, *command[1:]], check=False).returncode
176
+ if status == 0:
177
+ receipt.parent.mkdir(parents=True, exist_ok=True)
178
+ from uuid import uuid4
179
+
180
+ staged = receipt.with_suffix(f".{uuid4().hex}.tmp")
181
+ try:
182
+ staged.write_text(json.dumps({"version": package.version}), encoding="utf-8")
183
+ staged.replace(receipt)
184
+ finally:
185
+ staged.unlink(missing_ok=True)
186
+ return status
187
+ except OSError as exc:
188
+ parser.exit(1, f"Could not start the installer: {exc}\n")
rlmagent/clipboard.py ADDED
@@ -0,0 +1,63 @@
1
+ """Native clipboard writes with explicit terminal-delivery fallback."""
2
+
3
+ import base64
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def sequence(text: str, *, tmux=False) -> str:
11
+ encoded = base64.b64encode(text.encode()).decode()
12
+ value = f"\x1b]52;c;{encoded}\x07"
13
+ return "\x1bPtmux;" + value.replace("\x1b", "\x1b\x1b") + "\x1b\\" if tmux else value
14
+
15
+
16
+ def copy(text: str, terminal_write) -> str:
17
+ if len(text.encode()) > 1024 * 1024:
18
+ raise ValueError("Clipboard content exceeds the 1 MiB limit")
19
+ remote = bool(
20
+ os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY") or os.environ.get("TMUX")
21
+ )
22
+ command = None
23
+ if not remote:
24
+ if sys.platform == "win32":
25
+ shell = shutil.which("powershell.exe")
26
+ if shell:
27
+ command = [
28
+ shell,
29
+ "-NoProfile",
30
+ "-NonInteractive",
31
+ "-Command",
32
+ "[Console]::InputEncoding = [Text.UTF8Encoding]::new(); "
33
+ "Set-Clipboard -Value ([Console]::In.ReadToEnd())",
34
+ ]
35
+ else:
36
+ for candidate in (
37
+ ("pbcopy",) if sys.platform == "darwin" else ("wl-copy", "xclip", "xsel")
38
+ ):
39
+ executable = shutil.which(candidate)
40
+ if executable:
41
+ command = [executable]
42
+ if candidate == "xclip":
43
+ command += ["-selection", "clipboard"]
44
+ elif candidate == "xsel":
45
+ command += ["--clipboard", "--input"]
46
+ break
47
+ if command is not None:
48
+ try:
49
+ result = subprocess.run(
50
+ command,
51
+ input=text.encode(),
52
+ stdout=subprocess.DEVNULL,
53
+ stderr=subprocess.DEVNULL,
54
+ timeout=5,
55
+ check=False,
56
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0,
57
+ )
58
+ if result.returncode == 0:
59
+ return "Copied to the native clipboard."
60
+ except (OSError, subprocess.TimeoutExpired):
61
+ pass
62
+ terminal_write(sequence(text, tmux=bool(os.environ.get("TMUX"))))
63
+ return "Clipboard delivery requested from the terminal; delivery is unconfirmed."
@@ -0,0 +1,37 @@
1
+ """rlm-agent config: application configuration and provider selection."""
2
+
3
+ from rlmagent.config.config import (
4
+ list_providers,
5
+ resolve_provider,
6
+ select_provider,
7
+ setup_provider,
8
+ )
9
+ from rlmagent.config.loader import (
10
+ apply_config,
11
+ load_api_key,
12
+ load_model,
13
+ load_provider,
14
+ )
15
+ from rlmagent.config.store import (
16
+ RlmAgentConfig,
17
+ config_path,
18
+ load_config,
19
+ mask_key,
20
+ save_config,
21
+ )
22
+
23
+ __all__ = [
24
+ "RlmAgentConfig",
25
+ "apply_config",
26
+ "config_path",
27
+ "list_providers",
28
+ "load_api_key",
29
+ "load_config",
30
+ "load_model",
31
+ "load_provider",
32
+ "mask_key",
33
+ "resolve_provider",
34
+ "save_config",
35
+ "select_provider",
36
+ "setup_provider",
37
+ ]
@@ -0,0 +1,297 @@
1
+ """Provider resolution, catalog, and environment-based auto-detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from dataclasses import replace
8
+
9
+ from rlmagent.harness.provider.base import ModelProvider
10
+ from rlmagent.model.settings import (
11
+ AnthropicProfile,
12
+ ConfigError,
13
+ OpenAIProfile,
14
+ load_anthropic_profile,
15
+ load_openai_profile,
16
+ )
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Known provider identifiers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _KNOWN_PROVIDERS = ("anthropic", "openai", "openrouter", "ollama", "huggingface", "scripted", "cli", "vertex",
23
+ "google", "mistral", "azure", "codex", "copilot", "claude-subscription", "xai", "xai-subscription")
24
+
25
+ # OpenAI-compatible backends reuse the OpenAI adapter with a different base URL.
26
+ _OPENAI_COMPATIBLE = ("openai", "openrouter", "ollama", "huggingface")
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Detection
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _detect_provider_name() -> str | None:
35
+ """Infer the provider name from environment variables, then saved config."""
36
+ explicit = os.environ.get("RLM_AGENT_PROVIDER")
37
+ if explicit:
38
+ return explicit.strip().lower()
39
+
40
+ from rlmagent.config.loader import project_settings
41
+ from rlmagent.config.store import load_config
42
+
43
+ project = project_settings().get("provider")
44
+ if project:
45
+ return project.lower()
46
+
47
+ saved = load_config().provider
48
+ if saved:
49
+ return saved.strip().lower()
50
+
51
+ if os.environ.get("ANTHROPIC_API_KEY"):
52
+ return "anthropic"
53
+ if os.environ.get("OPENAI_API_KEY"):
54
+ return "openai"
55
+
56
+ return None
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Builder helpers (one per provider)
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _build_anthropic(profile: AnthropicProfile) -> ModelProvider:
65
+ """Instantiate an Anthropic adapter from a loaded profile."""
66
+ try:
67
+ from rlmagent.model.claude import AnthropicProvider # type: ignore[import-not-found]
68
+ except ImportError:
69
+ raise ConfigError(
70
+ "The 'anthropic' provider requires the httpx extra.\n"
71
+ " Install it with: pip install rlmness-cli[providers]"
72
+ ) from None
73
+ return AnthropicProvider(profile) # type: ignore[return-value]
74
+
75
+
76
+ def _build_openai(profile: OpenAIProfile) -> ModelProvider:
77
+ """Instantiate an OpenAI-compatible adapter from a loaded profile."""
78
+ try:
79
+ from rlmagent.model.oai_compatible import OpenAIProvider # type: ignore[import-not-found]
80
+ except ImportError:
81
+ raise ConfigError(
82
+ "The 'openai' provider requires the httpx extra.\n"
83
+ " Install it with: pip install rlmness-cli[providers]"
84
+ ) from None
85
+ return OpenAIProvider(profile) # type: ignore[return-value]
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Resolution
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def resolve_provider(name: str | None = None) -> ModelProvider:
94
+ """Return a ready-to-use ``ModelProvider`` for *name*, with any fallbacks."""
95
+ from rlmagent.model.failover import with_fallbacks
96
+
97
+ return with_fallbacks(_resolve_one(name), _resolve_one)
98
+
99
+
100
+ def _resolve_one(name: str | None = None) -> ModelProvider:
101
+ """Build the provider for *name* alone."""
102
+ resolved = name or _detect_provider_name()
103
+ if resolved is None:
104
+ raise ConfigError(
105
+ "No provider configured.\n"
106
+ " Set RLM_AGENT_PROVIDER or export an API key env var.\n"
107
+ " Run 'rlm-agent' to pick a provider and model, or 'rlm-agent config'."
108
+ )
109
+
110
+ resolved = resolved.strip().lower()
111
+ if resolved == "copilot":
112
+ from rlmagent.model.copilot import CopilotProvider
113
+ return CopilotProvider()
114
+ if resolved == "xai-subscription":
115
+ from rlmagent.model.registered_oauth import BASE, resolve
116
+ from rlmagent.model.settings import Credential
117
+
118
+ return _build_openai(OpenAIProfile(name=resolved, credential=Credential(None, BASE),
119
+ resolver=resolve, endpoint="chat"))
120
+ if resolved == "claude-subscription":
121
+ from rlmagent.model.external import ExternalAgentProvider
122
+ return ExternalAgentProvider()
123
+
124
+ if resolved == "codex":
125
+ from rlmagent.model.settings import Credential
126
+ from rlmagent.model.subscription import RESOURCE, resolve
127
+
128
+ transport = os.environ.get("RLM_AGENT_CODEX_TRANSPORT", "http")
129
+ if transport not in ("http", "websocket"):
130
+ raise ConfigError("RLM_AGENT_CODEX_TRANSPORT must be http or websocket")
131
+ return _build_openai(OpenAIProfile(
132
+ name="codex", credential=Credential(None, RESOURCE),
133
+ resolver=resolve, endpoint="responses", transport=transport,
134
+ ))
135
+
136
+ if resolved == "anthropic":
137
+ profile = load_anthropic_profile()
138
+ return _build_anthropic(profile)
139
+
140
+ if resolved == "google":
141
+ from rlmagent.model.google import build_google
142
+
143
+ return build_google()
144
+
145
+ if resolved in ("mistral", "azure", "xai"):
146
+ from rlmagent.config.loader import load_api_key
147
+ from rlmagent.config.store import load_config, provider_info
148
+ from rlmagent.model.settings import Credential
149
+
150
+ info = provider_info(resolved)
151
+ key = load_api_key(resolved)
152
+ if not key:
153
+ raise ConfigError(f"{info.key_env} is not set")
154
+ base = os.environ.get("AZURE_OPENAI_BASE_URL" if resolved == "azure" else resolved.upper() + "_BASE_URL")
155
+ base = base or load_config().base_urls.get(resolved) or info.base_url
156
+ if not base:
157
+ raise ConfigError("Set AZURE_OPENAI_BASE_URL to your deployed /openai/v1 endpoint")
158
+ credential = (Credential(None, base, (("api-key", key),)) if resolved == "azure"
159
+ else Credential(key, base))
160
+ return _build_openai(OpenAIProfile(name=resolved, credential=credential,
161
+ endpoint="responses" if resolved == "azure" else "chat"))
162
+
163
+ if resolved in _OPENAI_COMPATIBLE:
164
+ # Named for the chosen provider, so replies, stats, and errors say
165
+ # "openrouter" or "ollama" rather than the shared adapter's "openai".
166
+ profile = replace(load_openai_profile(), name=resolved)
167
+ return _build_openai(profile)
168
+
169
+ if resolved == "vertex":
170
+ from rlmagent.model.vertex import build_vertex
171
+ return build_vertex()
172
+
173
+ if resolved == "cli":
174
+ from rlmagent.model.external import ExternalAgentProvider
175
+
176
+ return ExternalAgentProvider() # type: ignore[return-value]
177
+
178
+ if resolved == "scripted":
179
+ from rlmagent.model.scripted import ReplayProvider
180
+
181
+ return ReplayProvider([]) # type: ignore[return-value]
182
+
183
+ raise ConfigError(
184
+ f"Unknown provider: {resolved!r}.\n"
185
+ f" Known providers: {', '.join(_KNOWN_PROVIDERS)}"
186
+ )
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Catalog
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ def _probe_status(provider_name: str) -> str:
195
+ """Return a short status string for a provider: 'ready', 'no key', etc."""
196
+ if provider_name == "codex":
197
+ return "subscription (rlm-agent provider login codex)"
198
+ if provider_name in ("google", "mistral", "azure"):
199
+ from rlmagent.config.loader import load_api_key
200
+ from rlmagent.config.store import provider_info
201
+
202
+ return "key configured" if load_api_key(provider_name) else (
203
+ f"no key (set {provider_info(provider_name).key_env})")
204
+ if provider_name == "anthropic":
205
+ if not os.environ.get("ANTHROPIC_API_KEY"):
206
+ return "no key (set ANTHROPIC_API_KEY)"
207
+ try:
208
+ load_anthropic_profile()
209
+ return "ready"
210
+ except ConfigError as exc:
211
+ return f"misconfigured ({exc})"
212
+
213
+ if provider_name == "openai":
214
+ if not os.environ.get("OPENAI_API_KEY"):
215
+ return "no key (set OPENAI_API_KEY)"
216
+ try:
217
+ load_openai_profile()
218
+ return "ready"
219
+ except ConfigError as exc:
220
+ return f"misconfigured ({exc})"
221
+
222
+ if provider_name == "scripted":
223
+ return "available (test only)"
224
+
225
+ return "unknown"
226
+
227
+
228
+ def list_providers() -> None:
229
+ """Print available providers and their configuration status to stderr."""
230
+ active = _detect_provider_name()
231
+ sys.stderr.write("Providers:\n")
232
+ for name in _KNOWN_PROVIDERS:
233
+ marker = "*" if name == active else " "
234
+ status = _probe_status(name)
235
+ sys.stderr.write(f" {marker} {name:<12} {status}\n")
236
+ sys.stderr.flush()
237
+
238
+
239
+ def setup_provider() -> None:
240
+ """Print environment-variable guidance for configuring each provider."""
241
+ sys.stderr.write(
242
+ "Provider setup\n"
243
+ "\nCodex subscription:\n"
244
+ " Install rlmagent[subscription], then run rlm-agent provider login codex.\n"
245
+ " Use --provider codex; tokens stay in the platform credential store.\n"
246
+ "\nNative providers:\n"
247
+ " GOOGLE_API_KEY, MISTRAL_API_KEY, or AZURE_OPENAI_API_KEY\n"
248
+ " Azure also requires AZURE_OPENAI_BASE_URL ending in /openai/v1.\n"
249
+ "\n"
250
+ "Set the following environment variables for your chosen provider.\n"
251
+ "\n"
252
+ "Anthropic:\n"
253
+ " ANTHROPIC_API_KEY (required)\n"
254
+ " ANTHROPIC_BASE_URL (default: https://api.anthropic.com)\n"
255
+ " ANTHROPIC_TIMEOUT (seconds, default: 120)\n"
256
+ " ANTHROPIC_MAX_TOKENS (default: 16384)\n"
257
+ " ANTHROPIC_EXTRA_HEADERS (semicolon-separated Key: Value pairs)\n"
258
+ "\n"
259
+ "OpenAI-compatible:\n"
260
+ " OPENAI_API_KEY (required)\n"
261
+ " OPENAI_BASE_URL (default: https://api.openai.com/v1)\n"
262
+ " OPENAI_TIMEOUT (seconds, default: 120)\n"
263
+ " OPENAI_ORG_ID (optional)\n"
264
+ " OPENAI_EXTRA_HEADERS (semicolon-separated Key: Value pairs)\n"
265
+ "\n"
266
+ "Google Vertex:\n"
267
+ " Install rlmagent[vertex]; configure Application Default Credentials.\n"
268
+ " GOOGLE_CLOUD_PROJECT (required unless ADC supplies a project)\n"
269
+ " GOOGLE_CLOUD_LOCATION (default: global)\n"
270
+ "\n"
271
+ "Shared:\n"
272
+ " RLM_AGENT_PROVIDER (anthropic | openai | scripted)\n"
273
+ " RLM_AGENT_MODEL (model identifier override)\n"
274
+ " RLM_AGENT_MAX_RETRIES (default: 2)\n"
275
+ " RLM_AGENT_MAX_RETRY_DELAY (seconds, default: 30)\n"
276
+ " RLM_AGENT_THINKING_ENABLED (true/false, default: false)\n"
277
+ " RLM_AGENT_THINKING_BUDGET (token count, optional)\n"
278
+ )
279
+ sys.stderr.flush()
280
+
281
+
282
+ def select_provider(name: str) -> None:
283
+ """Validate a provider name for selection."""
284
+ normalized = name.strip().lower()
285
+ if normalized not in _KNOWN_PROVIDERS:
286
+ raise ConfigError(
287
+ f"Unknown provider: {name!r}.\n"
288
+ f" Known providers: {', '.join(_KNOWN_PROVIDERS)}"
289
+ )
290
+
291
+
292
+ __all__ = [
293
+ "list_providers",
294
+ "resolve_provider",
295
+ "select_provider",
296
+ "setup_provider",
297
+ ]