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 +2 -0
- roadmodel/__main__.py +5 -0
- roadmodel/cli.py +283 -0
- roadmodel/config.py +136 -0
- roadmodel/data/model-selector.txt +653 -0
- roadmodel/data/model-tier-cost-scale.md +204 -0
- roadmodel/data/user-context.example.md +201 -0
- roadmodel/errors.py +40 -0
- roadmodel/providers/__init__.py +16 -0
- roadmodel/providers/anthropic.py +37 -0
- roadmodel/providers/google.py +34 -0
- roadmodel/providers/openai.py +49 -0
- roadmodel/recommend.py +119 -0
- roadmodel/user_context.py +78 -0
- roadmodel-0.0.0.dist-info/METADATA +409 -0
- roadmodel-0.0.0.dist-info/RECORD +20 -0
- roadmodel-0.0.0.dist-info/WHEEL +4 -0
- roadmodel-0.0.0.dist-info/entry_points.txt +2 -0
- roadmodel-0.0.0.dist-info/licenses/LICENSE +202 -0
- roadmodel-0.0.0.dist-info/licenses/NOTICE +16 -0
roadmodel/__init__.py
ADDED
roadmodel/__main__.py
ADDED
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
|
+
)
|