roadmodel 0.0.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.
roadmodel/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # src/roadmodel/__init__.py
2
+ __version__ = "0.1.0"
roadmodel/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # src/roadmodel/__main__.py
2
+ from roadmodel.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
roadmodel/cli.py ADDED
@@ -0,0 +1,283 @@
1
+ # src/roadmodel/cli.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import traceback
7
+ from functools import wraps
8
+ from importlib import resources
9
+ from importlib.resources.abc import Traversable
10
+ from pathlib import Path
11
+ from typing import Any, Callable, TypeVar, cast
12
+
13
+ import click
14
+
15
+ from roadmodel import __version__, recommend as recommender, user_context
16
+ from roadmodel.config import load_config
17
+ from roadmodel.errors import (
18
+ BundledDocNotFoundError,
19
+ MalformedResponseError,
20
+ MissingProviderKeyError,
21
+ ProviderCallError,
22
+ UserContextNotFoundError,
23
+ )
24
+
25
+ F = TypeVar("F", bound=Callable[..., Any])
26
+
27
+
28
+ def _error_mapped(command: F) -> F:
29
+ @wraps(command)
30
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
31
+ try:
32
+ return command(*args, **kwargs)
33
+ except click.ClickException:
34
+ raise
35
+ except click.exceptions.Exit:
36
+ raise
37
+ except click.Abort:
38
+ raise
39
+ except MissingProviderKeyError as exc:
40
+ click.echo(str(exc), err=True)
41
+ raise click.exceptions.Exit(2) from exc
42
+ except ProviderCallError as exc:
43
+ click.echo(str(exc), err=True)
44
+ raise click.exceptions.Exit(3) from exc
45
+ except MalformedResponseError as exc:
46
+ click.echo(f"Malformed provider response (truncated to 2KB):\n{exc.raw_text}", err=True)
47
+ raise click.exceptions.Exit(4) from exc
48
+ except BundledDocNotFoundError as exc:
49
+ click.echo(str(exc), err=True)
50
+ raise click.exceptions.Exit(5) from exc
51
+ except UserContextNotFoundError as exc:
52
+ click.echo(
53
+ f"User context file not found: {exc.path}. "
54
+ "create the file, or omit --user-context to let the CLI bootstrap one",
55
+ err=True,
56
+ )
57
+ raise click.exceptions.Exit(7) from exc
58
+ except Exception as exc:
59
+ if os.environ.get("ROADMODEL_DEBUG") == "1":
60
+ traceback.print_exc()
61
+ else:
62
+ click.echo(f"Unexpected error: {exc}", err=True)
63
+ raise click.exceptions.Exit(1) from exc
64
+
65
+ return cast(F, wrapper)
66
+
67
+
68
+ def _catalog_doc_resource(doc: str) -> tuple[Traversable, str]:
69
+ normalized = doc.lower()
70
+ if normalized == "tier-cost-scale":
71
+ return recommender.BUNDLED_TIER_COST_PATH, "model-tier-cost-scale.md"
72
+ return recommender.BUNDLED_SELECTOR_PATH, "model-selector.txt"
73
+
74
+
75
+ def _read_catalog_doc(doc: str) -> str:
76
+ resource, filename = _catalog_doc_resource(doc)
77
+ try:
78
+ return resource.read_text(encoding="utf-8")
79
+ except FileNotFoundError as exc:
80
+ raise BundledDocNotFoundError(filename) from exc
81
+
82
+
83
+ def _catalog_doc_path(doc: str) -> Path:
84
+ resource, filename = _catalog_doc_resource(doc)
85
+ try:
86
+ with resources.as_file(resource) as on_disk_path:
87
+ return on_disk_path
88
+ except FileNotFoundError as exc:
89
+ raise BundledDocNotFoundError(filename) from exc
90
+
91
+
92
+ @click.group(help=None)
93
+ def cli() -> None:
94
+ """Recommend AI models and access paths from the bundled roadmodel catalog."""
95
+
96
+
97
+ @cli.command()
98
+ @click.argument("prompt", required=False)
99
+ @click.option(
100
+ "--file",
101
+ "prompt_file",
102
+ type=click.Path(path_type=Path, dir_okay=False, resolve_path=False),
103
+ help="Read the recommendation prompt from a file.",
104
+ )
105
+ @click.option("--json", "emit_json", is_flag=True, help="Emit parsed structured output as JSON.")
106
+ @click.option(
107
+ "--provider",
108
+ type=click.Choice(["anthropic", "openai", "google"], case_sensitive=False),
109
+ help="Provider override (anthropic/openai/google).",
110
+ )
111
+ @click.option("--model", type=str, help="Optional explicit model id override for the selected provider.")
112
+ @click.option(
113
+ "--user-context",
114
+ "user_context_path",
115
+ type=click.Path(path_type=Path, dir_okay=False, resolve_path=False),
116
+ help="Path to user-context.md override.",
117
+ )
118
+ @_error_mapped
119
+ def recommend(
120
+ prompt: str | None,
121
+ prompt_file: Path | None,
122
+ emit_json: bool,
123
+ provider: str | None,
124
+ model: str | None,
125
+ user_context_path: Path | None,
126
+ ) -> None:
127
+ """Recommend MODEL/PLATFORM/MAX MODE/THINKING/CONVERSATION/RATIONALE for a prompt."""
128
+
129
+ if prompt and prompt_file:
130
+ raise click.UsageError("Use either PROMPT or --file, not both.")
131
+ if not prompt and not prompt_file:
132
+ raise click.UsageError("Provide PROMPT or --file PATH.")
133
+ if prompt is not None:
134
+ prompt_text = prompt
135
+ else:
136
+ assert prompt_file is not None
137
+ prompt_text = prompt_file.read_text(encoding="utf-8")
138
+ config = load_config(
139
+ cli_provider=provider,
140
+ cli_model=model,
141
+ cli_user_context=user_context_path,
142
+ )
143
+
144
+ if user_context_path is not None and not user_context_path.expanduser().exists():
145
+ raise UserContextNotFoundError(user_context_path.expanduser())
146
+ env_user_context = os.environ.get("ROADMODEL_USER_CONTEXT")
147
+ if (
148
+ user_context_path is None
149
+ and env_user_context
150
+ and not Path(env_user_context).expanduser().exists()
151
+ ):
152
+ raise UserContextNotFoundError(Path(env_user_context).expanduser())
153
+
154
+ if not config.user_context_path.exists():
155
+ bootstrap_target = user_context.default_user_context_home()
156
+ user_context.bootstrap(bootstrap_target)
157
+ click.echo(
158
+ f"Created {bootstrap_target} from bundled template. "
159
+ "Edit it with your real subscription state, then re-run.",
160
+ err=True,
161
+ )
162
+ raise click.exceptions.Exit(6)
163
+
164
+ if user_context.is_bootstrap_unchanged(config.user_context_path):
165
+ click.echo(
166
+ "Warning: user-context.md still includes placeholder values like $XXX; proceeding anyway.",
167
+ err=True,
168
+ )
169
+
170
+ result = recommender.recommend(prompt_text, config)
171
+ if emit_json:
172
+ click.echo(json.dumps(result))
173
+ return
174
+ click.echo(
175
+ "\n".join(
176
+ [
177
+ f"MODEL: {result['model']}",
178
+ f"PLATFORM: {result['platform']}",
179
+ f"MAX MODE: {result['max_mode']}",
180
+ f"THINKING: {result['thinking']}",
181
+ f"CONVERSATION: {result['conversation']}",
182
+ f"RATIONALE: {result['rationale']}",
183
+ ]
184
+ )
185
+ )
186
+
187
+
188
+ @cli.group()
189
+ def catalog() -> None:
190
+ """Read bundled catalog documents shipped with roadmodel."""
191
+
192
+
193
+ @catalog.command("show")
194
+ @click.option(
195
+ "--doc",
196
+ type=click.Choice(["selector", "tier-cost-scale"], case_sensitive=False),
197
+ default="selector",
198
+ show_default=True,
199
+ help="Catalog document to print.",
200
+ )
201
+ @_error_mapped
202
+ def catalog_show(doc: str) -> None:
203
+ """Print a bundled catalog document to stdout."""
204
+
205
+ click.echo(_read_catalog_doc(doc), nl=False)
206
+
207
+
208
+ @catalog.command("path")
209
+ @click.option(
210
+ "--doc",
211
+ type=click.Choice(["selector", "tier-cost-scale"], case_sensitive=False),
212
+ default="selector",
213
+ show_default=True,
214
+ help="Catalog document path to print.",
215
+ )
216
+ @_error_mapped
217
+ def catalog_path(doc: str) -> None:
218
+ """Print the on-disk path for a bundled catalog document."""
219
+
220
+ click.echo(str(_catalog_doc_path(doc)))
221
+
222
+
223
+ @cli.group()
224
+ def context() -> None:
225
+ """Inspect or initialize the user-context.md file used at recommendation time."""
226
+
227
+
228
+ @context.command("show")
229
+ @click.option(
230
+ "--user-context",
231
+ "user_context_path",
232
+ type=click.Path(path_type=Path, dir_okay=False, resolve_path=False),
233
+ help="Path override for user-context.md.",
234
+ )
235
+ @_error_mapped
236
+ def context_show(user_context_path: Path | None) -> None:
237
+ """Print the resolved user-context.md file."""
238
+
239
+ resolved = user_context.resolve(cli_path=user_context_path)
240
+ if user_context_path is not None and not user_context_path.expanduser().exists():
241
+ raise UserContextNotFoundError(user_context_path.expanduser())
242
+ if not resolved.exists():
243
+ raise UserContextNotFoundError(resolved)
244
+ click.echo(user_context.read(resolved), nl=False)
245
+
246
+
247
+ @context.command("path")
248
+ @click.option(
249
+ "--user-context",
250
+ "user_context_path",
251
+ type=click.Path(path_type=Path, dir_okay=False, resolve_path=False),
252
+ help="Path override for user-context.md.",
253
+ )
254
+ @_error_mapped
255
+ def context_path(user_context_path: Path | None) -> None:
256
+ """Print the resolved user-context.md path (or bootstrap target when missing)."""
257
+
258
+ click.echo(str(user_context.resolve(cli_path=user_context_path)))
259
+
260
+
261
+ @context.command("init")
262
+ @click.option("--force", is_flag=True, help="Overwrite the existing file if present.")
263
+ @_error_mapped
264
+ def context_init(force: bool) -> None:
265
+ """Bootstrap user-context.md from the bundled template at the default config location."""
266
+
267
+ target = user_context.default_user_context_home()
268
+ if target.exists() and not force:
269
+ raise click.ClickException(f"{target} already exists; re-run with --force to overwrite.")
270
+ user_context.bootstrap(target)
271
+ click.echo(str(target))
272
+
273
+
274
+ @cli.command()
275
+ @_error_mapped
276
+ def version() -> None:
277
+ """Print the installed roadmodel version."""
278
+
279
+ click.echo(__version__)
280
+
281
+
282
+ def main() -> None:
283
+ cli()
roadmodel/config.py ADDED
@@ -0,0 +1,136 @@
1
+ # src/roadmodel/config.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from roadmodel import user_context
11
+ from roadmodel.errors import MissingProviderKeyError
12
+
13
+ ProviderName = Literal["anthropic", "openai", "google"]
14
+
15
+ PROVIDER_KEY_ENV: dict[ProviderName, str] = {
16
+ "anthropic": "ANTHROPIC_API_KEY",
17
+ "openai": "OPENAI_API_KEY",
18
+ "google": "GOOGLE_API_KEY",
19
+ }
20
+ PROVIDER_ORDER: tuple[ProviderName, ...] = ("anthropic", "openai", "google")
21
+
22
+ _MISSING_KEY_REMEDIATION = (
23
+ "No provider key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY. "
24
+ "Try: export ANTHROPIC_API_KEY=..."
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Config:
30
+ provider: ProviderName
31
+ model: str | None
32
+ api_key: str
33
+ user_context_path: Path
34
+
35
+
36
+ def _config_home() -> Path:
37
+ xdg_home = os.environ.get("XDG_CONFIG_HOME")
38
+ if xdg_home:
39
+ return Path(xdg_home).expanduser() / "roadmodel"
40
+ return Path.home() / ".config" / "roadmodel"
41
+
42
+
43
+ def _config_path() -> Path:
44
+ return _config_home() / "config.toml"
45
+
46
+
47
+ def _normalize_provider(value: str | None) -> ProviderName | None:
48
+ if value is None:
49
+ return None
50
+ normalized = value.strip().lower()
51
+ if normalized in PROVIDER_KEY_ENV:
52
+ return normalized
53
+ raise MissingProviderKeyError(
54
+ f"Invalid provider {value!r}. Use one of: anthropic, openai, google."
55
+ )
56
+
57
+
58
+ def _first_present_env_provider() -> ProviderName | None:
59
+ for provider in PROVIDER_ORDER:
60
+ env_name = PROVIDER_KEY_ENV[provider]
61
+ if os.environ.get(env_name):
62
+ return provider
63
+ return None
64
+
65
+
66
+ def _read_config_toml() -> dict[str, Any]:
67
+ config_path = _config_path()
68
+ if not config_path.exists():
69
+ return {}
70
+ with config_path.open("rb") as handle:
71
+ data = tomllib.load(handle)
72
+ if isinstance(data, dict):
73
+ return data
74
+ return {}
75
+
76
+
77
+ def _config_api_key(config_data: dict[str, Any], provider: ProviderName) -> str | None:
78
+ providers = config_data.get("providers")
79
+ if not isinstance(providers, dict):
80
+ return None
81
+ provider_config = providers.get(provider)
82
+ if not isinstance(provider_config, dict):
83
+ return None
84
+ api_key = provider_config.get("api_key")
85
+ if isinstance(api_key, str) and api_key.strip():
86
+ return api_key.strip()
87
+ return None
88
+
89
+
90
+ def _config_user_context_override(config_data: dict[str, Any]) -> Path | None:
91
+ paths = config_data.get("paths")
92
+ if not isinstance(paths, dict):
93
+ return None
94
+ raw_path = paths.get("user_context")
95
+ if isinstance(raw_path, str) and raw_path.strip():
96
+ return Path(raw_path).expanduser()
97
+ return None
98
+
99
+
100
+ def load_config(
101
+ *, cli_provider: str | None, cli_model: str | None, cli_user_context: Path | None
102
+ ) -> Config:
103
+ provider = (
104
+ _normalize_provider(cli_provider)
105
+ or _normalize_provider(os.environ.get("ROADMODEL_PROVIDER"))
106
+ or _first_present_env_provider()
107
+ )
108
+ if provider is None:
109
+ raise MissingProviderKeyError(_MISSING_KEY_REMEDIATION)
110
+
111
+ key_env_name = PROVIDER_KEY_ENV[provider]
112
+ env_api_key = os.environ.get(key_env_name, "").strip()
113
+ config_data: dict[str, Any] = {}
114
+ if env_api_key:
115
+ api_key = env_api_key
116
+ else:
117
+ config_data = _read_config_toml()
118
+ api_key = _config_api_key(config_data, provider) or ""
119
+ if not api_key:
120
+ raise MissingProviderKeyError(_MISSING_KEY_REMEDIATION)
121
+
122
+ resolved_cli_path = cli_user_context
123
+ if (
124
+ resolved_cli_path is None
125
+ and not os.environ.get("ROADMODEL_USER_CONTEXT")
126
+ and config_data
127
+ ):
128
+ resolved_cli_path = _config_user_context_override(config_data)
129
+
130
+ resolved_path = user_context.resolve(cli_path=resolved_cli_path)
131
+ return Config(
132
+ provider=provider,
133
+ model=cli_model.strip() if isinstance(cli_model, str) and cli_model.strip() else None,
134
+ api_key=api_key,
135
+ user_context_path=resolved_path,
136
+ )