awerouter 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.
- awerouter/__init__.py +1 -0
- awerouter/cli.py +224 -0
- awerouter/config.py +416 -0
- awerouter/default-providers.json +9 -0
- awerouter/default-routing.json +15 -0
- awerouter/logging.py +216 -0
- awerouter/router.py +137 -0
- awerouter/server.py +397 -0
- awerouter/types.py +70 -0
- awerouter-0.2.0.dist-info/METADATA +182 -0
- awerouter-0.2.0.dist-info/RECORD +15 -0
- awerouter-0.2.0.dist-info/WHEEL +5 -0
- awerouter-0.2.0.dist-info/entry_points.txt +2 -0
- awerouter-0.2.0.dist-info/licenses/LICENSE +207 -0
- awerouter-0.2.0.dist-info/top_level.txt +1 -0
awerouter/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
awerouter/cli.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""CLI commands: serve / add / list / show / log / stats / calibrate.
|
|
2
|
+
|
|
3
|
+
Imports the click group from config.py and extends it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from awerouter.config import (
|
|
11
|
+
cli as config_cli,
|
|
12
|
+
config_dir,
|
|
13
|
+
die,
|
|
14
|
+
format_providers_display,
|
|
15
|
+
format_routing_display,
|
|
16
|
+
init_config,
|
|
17
|
+
load_default_profile,
|
|
18
|
+
load_for_profile,
|
|
19
|
+
load_providers,
|
|
20
|
+
load_routing,
|
|
21
|
+
providers_path,
|
|
22
|
+
routing_path,
|
|
23
|
+
save_profile_entry,
|
|
24
|
+
save_provider,
|
|
25
|
+
validate_profiles,
|
|
26
|
+
)
|
|
27
|
+
from awerouter.server import _serve
|
|
28
|
+
|
|
29
|
+
# Attach config sub-group to the main cli group
|
|
30
|
+
cli = config_cli
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run_serve(profile, port: int, host: str) -> None:
|
|
34
|
+
if profile:
|
|
35
|
+
providers, routing, settings = load_for_profile(profile)
|
|
36
|
+
else:
|
|
37
|
+
providers, routing, settings = load_default_profile()
|
|
38
|
+
try:
|
|
39
|
+
asyncio.run(_serve(host, port, providers, routing, settings))
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
raise SystemExit(0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cli.command()
|
|
45
|
+
@click.argument("profile", required=False)
|
|
46
|
+
@click.option("--port", default=20128, show_default=True, help="Listen port.")
|
|
47
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.")
|
|
48
|
+
def serve(profile, port: int, host: str):
|
|
49
|
+
"""Start the awerouter daemon for PROFILE.
|
|
50
|
+
|
|
51
|
+
PROFILE is a profile id from routing.json. If omitted, auto-selects when only
|
|
52
|
+
one profile exists.
|
|
53
|
+
"""
|
|
54
|
+
_run_serve(profile, port, host)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@click.command("__serve_profile__", hidden=True)
|
|
58
|
+
@click.option("--port", default=20128, show_default=True, help="Listen port.")
|
|
59
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.")
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def _serve_profile(ctx, port: int, host: str):
|
|
62
|
+
"""Bare profile launch: `awerouter <profile>` == `awerouter serve <profile>`."""
|
|
63
|
+
_run_serve(ctx.meta["profile_name"], port, host)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
cli.add_command(_serve_profile)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cli.command("add")
|
|
70
|
+
def add():
|
|
71
|
+
"""Interactively add a routing profile (creates any new providers)."""
|
|
72
|
+
if not providers_path().exists() or not routing_path().exists():
|
|
73
|
+
init_config()
|
|
74
|
+
click.echo(f"initialized config in {config_dir()}")
|
|
75
|
+
providers_all = load_providers()
|
|
76
|
+
_, profiles = load_routing()
|
|
77
|
+
|
|
78
|
+
name = click.prompt("Profile name")
|
|
79
|
+
if name in profiles:
|
|
80
|
+
die(f"profile already exists: {name}")
|
|
81
|
+
agent = click.prompt("Agent group", type=click.Choice(["claude", "codex", "opencode"]), default="claude")
|
|
82
|
+
known = set(providers_all.get(agent, {}))
|
|
83
|
+
|
|
84
|
+
def ask_tier(tier: str) -> str:
|
|
85
|
+
hint = ", ".join(sorted(known)) or "none yet"
|
|
86
|
+
pname = click.prompt(f"{tier} provider ({hint})")
|
|
87
|
+
if pname not in known:
|
|
88
|
+
base_url = click.prompt(f" {pname} base_url")
|
|
89
|
+
auth_var = click.prompt(f" {pname} auth env var name (stored as ${{VAR}})")
|
|
90
|
+
save_provider(agent, pname, base_url, f"${{{auth_var}}}")
|
|
91
|
+
known.add(pname)
|
|
92
|
+
model = click.prompt(f"{tier} model id")
|
|
93
|
+
return f"{pname},{model}"
|
|
94
|
+
|
|
95
|
+
flash = ask_tier("flash")
|
|
96
|
+
pro = ask_tier("pro")
|
|
97
|
+
threshold = click.prompt("longContextThreshold", default=8000, type=int)
|
|
98
|
+
save_profile_entry(name, agent, threshold, flash, pro)
|
|
99
|
+
|
|
100
|
+
# Fail loudly if the wizard wrote something inconsistent.
|
|
101
|
+
validate_profiles(load_providers(), load_routing()[1])
|
|
102
|
+
click.echo(f"Profile '{name}' added: flash={flash} pro={pro} L3>{threshold}")
|
|
103
|
+
click.echo(f"Start it with: awerouter {name}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@cli.command("list")
|
|
107
|
+
def list_profiles():
|
|
108
|
+
"""List routing profiles (name, agent, flash, pro, threshold)."""
|
|
109
|
+
providers_all = load_providers()
|
|
110
|
+
_, profiles = load_routing()
|
|
111
|
+
validate_profiles(providers_all, profiles)
|
|
112
|
+
for name, p in profiles.items():
|
|
113
|
+
flash = p.destinations["flash"]
|
|
114
|
+
pro = p.destinations["pro"]
|
|
115
|
+
click.echo(
|
|
116
|
+
f"{name}\t{p.agent}\t{flash.provider_name}/{flash.model}"
|
|
117
|
+
f"\t{pro.provider_name}/{pro.model}\tL3>{p.long_context_threshold}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@cli.command()
|
|
122
|
+
@click.argument("profile", required=False)
|
|
123
|
+
def show(profile):
|
|
124
|
+
"""Show PROFILE (or the whole config) with secrets redacted."""
|
|
125
|
+
providers_all = load_providers()
|
|
126
|
+
settings, profiles = load_routing()
|
|
127
|
+
validate_profiles(providers_all, profiles)
|
|
128
|
+
if not profile:
|
|
129
|
+
click.echo("providers.json:")
|
|
130
|
+
click.echo(format_providers_display(providers_all))
|
|
131
|
+
click.echo()
|
|
132
|
+
click.echo("routing.json:")
|
|
133
|
+
click.echo(format_routing_display(settings, profiles))
|
|
134
|
+
return
|
|
135
|
+
if profile not in profiles:
|
|
136
|
+
avail = ", ".join(profiles) or "(none)"
|
|
137
|
+
die(f"profile '{profile}' not found in routing.json; available: {avail}")
|
|
138
|
+
p = profiles[profile]
|
|
139
|
+
used = {d.provider_name: providers_all[p.agent][d.provider_name]
|
|
140
|
+
for d in p.destinations.values()}
|
|
141
|
+
click.echo("providers:")
|
|
142
|
+
click.echo(format_providers_display({p.agent: used}))
|
|
143
|
+
click.echo()
|
|
144
|
+
click.echo("profile:")
|
|
145
|
+
click.echo(format_routing_display(settings, {profile: p}))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.command()
|
|
149
|
+
@click.option("--lines", default=20, show_default=True, help="Tail N entries.")
|
|
150
|
+
def log(lines: int):
|
|
151
|
+
"""Show recent request logs."""
|
|
152
|
+
from awerouter.logging import tail
|
|
153
|
+
entries = tail(lines)
|
|
154
|
+
if not entries:
|
|
155
|
+
click.echo("(no logs yet)")
|
|
156
|
+
return
|
|
157
|
+
for e in entries:
|
|
158
|
+
status_s = str(e.status) if e.status is not None else "-"
|
|
159
|
+
click.echo(
|
|
160
|
+
f"{e.ts} {e.request_id[:12]:12s} {e.destination:7s} "
|
|
161
|
+
f"{e.provider:12s} {e.model_out:24s} {e.label:14s} "
|
|
162
|
+
f"status={status_s:>3} {e.ms}ms {e.bytes}B "
|
|
163
|
+
f"tokens={e.token_count} in={e.model_in}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@cli.command()
|
|
168
|
+
def stats():
|
|
169
|
+
"""Show aggregated routing stats, grouped by profile."""
|
|
170
|
+
from awerouter.logging import stats as _stats
|
|
171
|
+
s = _stats()
|
|
172
|
+
if not s:
|
|
173
|
+
click.echo("(no logs yet)")
|
|
174
|
+
return
|
|
175
|
+
click.echo(f"total_requests : {s['total_requests']}")
|
|
176
|
+
click.echo(f"total_bytes : {s['total_bytes']}")
|
|
177
|
+
if s["flash_requests"]:
|
|
178
|
+
click.echo(
|
|
179
|
+
f"pro input offloaded to flash: ~{s['flash_tokens']} tokens "
|
|
180
|
+
f"across {s['flash_requests']} requests"
|
|
181
|
+
)
|
|
182
|
+
click.echo(" (message tokens only — system prompt & tools excluded; conservative)")
|
|
183
|
+
for name, p in sorted(s["by_profile"].items()):
|
|
184
|
+
click.echo()
|
|
185
|
+
click.echo(f"profile {name} ({p['requests']} requests, ~{p['flash_tokens']} flash tokens):")
|
|
186
|
+
click.echo(" by_label:")
|
|
187
|
+
for k, v in sorted(p["by_label"].items()):
|
|
188
|
+
click.echo(f" {k:16s} {v}")
|
|
189
|
+
click.echo(" by_destination:")
|
|
190
|
+
for k, v in sorted(p["by_destination"].items()):
|
|
191
|
+
click.echo(f" {k:10s} {v}")
|
|
192
|
+
click.echo(" by_provider:")
|
|
193
|
+
for k, v in sorted(p["by_provider"].items()):
|
|
194
|
+
click.echo(f" {k:16s} {v}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@cli.command()
|
|
198
|
+
def calibrate():
|
|
199
|
+
"""Show L3 token distribution to tune longContextThreshold.
|
|
200
|
+
|
|
201
|
+
Only L3 traffic (default/longContext/image labels) is threshold-sensitive;
|
|
202
|
+
L1 (webSearch) and L2 (background/think) route identically regardless.
|
|
203
|
+
"""
|
|
204
|
+
from awerouter.logging import token_distribution
|
|
205
|
+
d = token_distribution()
|
|
206
|
+
if not d:
|
|
207
|
+
click.echo("(no L3 traffic yet — run some non-background/think requests first)")
|
|
208
|
+
return
|
|
209
|
+
click.echo(f"L3 message-token distribution ({d['n']} requests):")
|
|
210
|
+
click.echo(" (messages only — system prompt and tools definitions are excluded)")
|
|
211
|
+
click.echo(f" min: {d['min']:>7} p50: {d['p50']:>7} p75: {d['p75']:>7}")
|
|
212
|
+
click.echo(f" p90: {d['p90']:>7} p95: {d['p95']:>7} p99: {d['p99']:>7} max: {d['max']:>7}")
|
|
213
|
+
click.echo()
|
|
214
|
+
click.echo("if you set longContextThreshold to:")
|
|
215
|
+
for c in d["candidates"]:
|
|
216
|
+
click.echo(f" {c['threshold']:>7} → {c['flash_pct']}% flash, {100 - c['flash_pct']}% pro")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main(argv=None):
|
|
220
|
+
return cli.main(args=argv, prog_name="awerouter")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
raise SystemExit(main())
|
awerouter/config.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from awerouter import __version__
|
|
13
|
+
from awerouter.types import Destination, Provider, RoutingProfile, Settings
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Constants (mirror aweswitch cli.py conventions exactly)
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
20
|
+
SECRET_RE = re.compile(r"(TOKEN|KEY|SECRET|PASSWORD|AUTH)", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
TEMPLATE_PROVIDERS = Path(__file__).parent / "default-providers.json"
|
|
23
|
+
TEMPLATE_ROUTING = Path(__file__).parent / "default-routing.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def die(message: str) -> "SystemExit":
|
|
27
|
+
raise SystemExit(f"awerouter: {message}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_auth_header(base_url: str) -> str:
|
|
31
|
+
"""Auto-detect auth header from base_url.
|
|
32
|
+
|
|
33
|
+
anthropic.com endpoints use x-api-key (bare token); everyone else uses
|
|
34
|
+
Authorization (Bearer prefix added at request time). Matched on netloc,
|
|
35
|
+
not substring — "https://evil.com/anthropic.com" must not match.
|
|
36
|
+
"""
|
|
37
|
+
netloc = urlparse(base_url).netloc.lower()
|
|
38
|
+
is_anthropic = netloc == "api.anthropic.com" or netloc.endswith(".anthropic.com")
|
|
39
|
+
return "x-api-key" if is_anthropic else "authorization"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Path helpers
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def config_dir() -> Path:
|
|
47
|
+
return Path(os.environ.get("AWEROUTER_CONFIG_DIR", "~/.config/awerouter")).expanduser()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def providers_path() -> Path:
|
|
51
|
+
return config_dir() / "providers.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def routing_path() -> Path:
|
|
55
|
+
return config_dir() / "routing.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Value helpers (mirror aweswitch exactly)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def expand_value(value, env: dict) -> "str | int | float | bool | None":
|
|
63
|
+
if not isinstance(value, str):
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
def replace(match):
|
|
67
|
+
name = match.group(1)
|
|
68
|
+
if name not in env:
|
|
69
|
+
die(
|
|
70
|
+
f"required environment variable not set: {name}\n"
|
|
71
|
+
f" Add it to your shell config (e.g. ~/.zshrc or ~/.bashrc), then reload your shell."
|
|
72
|
+
)
|
|
73
|
+
return env[name]
|
|
74
|
+
|
|
75
|
+
return ENV_REF_RE.sub(replace, value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def redact(data):
|
|
79
|
+
redacted = json.loads(json.dumps(data)) # deep copy via JSON
|
|
80
|
+
|
|
81
|
+
def walk(value, key=""):
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
for child_key, child_value in value.items():
|
|
84
|
+
if SECRET_RE.search(child_key) and isinstance(child_value, str):
|
|
85
|
+
value[child_key] = "<redacted>"
|
|
86
|
+
else:
|
|
87
|
+
walk(child_value, child_key)
|
|
88
|
+
elif isinstance(value, list):
|
|
89
|
+
for item in value:
|
|
90
|
+
walk(item, key)
|
|
91
|
+
|
|
92
|
+
walk(redacted)
|
|
93
|
+
return redacted
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Load / validate
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _load_json(path: Path, label: str) -> dict:
|
|
101
|
+
if not path.exists():
|
|
102
|
+
die(f"{label} not found: {path}\nrun: awerouter config init")
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
105
|
+
except json.JSONDecodeError as exc:
|
|
106
|
+
die(f"invalid JSON in {path}: {exc}")
|
|
107
|
+
if not isinstance(data, dict):
|
|
108
|
+
die(f"{label} must be a JSON object: {path}")
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_destination(raw: str) -> Destination:
|
|
113
|
+
parts = raw.split(",", 1)
|
|
114
|
+
if len(parts) != 2:
|
|
115
|
+
die(f"destination must be 'provider,model': {raw}")
|
|
116
|
+
provider_name, model = parts[0].strip(), parts[1].strip()
|
|
117
|
+
if not provider_name or not model:
|
|
118
|
+
die(f"destination must be 'provider,model': {raw}")
|
|
119
|
+
return Destination(provider_name=provider_name, model=model)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_providers(path: Optional[Path] = None) -> dict[str, dict[str, Provider]]:
|
|
123
|
+
"""Load providers grouped by agent. Returns {agent: {provider_name: Provider}}."""
|
|
124
|
+
path = path or providers_path()
|
|
125
|
+
data = _load_json(path, "providers.json")
|
|
126
|
+
result: dict[str, dict[str, Provider]] = {}
|
|
127
|
+
for agent, group in data.items():
|
|
128
|
+
if not isinstance(group, dict):
|
|
129
|
+
die(f"agent group '{agent}' must be an object")
|
|
130
|
+
agent_providers: dict[str, Provider] = {}
|
|
131
|
+
for name, entry in group.items():
|
|
132
|
+
if not isinstance(entry, dict):
|
|
133
|
+
die(f"provider '{agent}.{name}' must be an object")
|
|
134
|
+
base_url = entry.get("base_url")
|
|
135
|
+
auth = entry.get("auth")
|
|
136
|
+
if not base_url or not auth:
|
|
137
|
+
die(f"provider '{agent}.{name}' missing base_url or auth")
|
|
138
|
+
auth_header = entry.get("auth_header") or detect_auth_header(base_url)
|
|
139
|
+
agent_providers[name] = Provider(
|
|
140
|
+
name=name, base_url=base_url, auth=auth, auth_header=auth_header,
|
|
141
|
+
)
|
|
142
|
+
result[agent] = agent_providers
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_routing(path: Optional[Path] = None) -> tuple[Settings, dict[str, RoutingProfile]]:
|
|
147
|
+
"""Load global settings + all routing profiles keyed by profile id."""
|
|
148
|
+
path = path or routing_path()
|
|
149
|
+
data = _load_json(path, "routing.json")
|
|
150
|
+
|
|
151
|
+
# Parse optional global settings (defaults: flash/pro)
|
|
152
|
+
raw_settings = data.pop("settings", {})
|
|
153
|
+
if not isinstance(raw_settings, dict):
|
|
154
|
+
die("routing.json 'settings' must be an object")
|
|
155
|
+
settings = Settings(
|
|
156
|
+
background_model=str(raw_settings.get("backgroundModel", "flash")),
|
|
157
|
+
think_model=str(raw_settings.get("thinkModel", "pro")),
|
|
158
|
+
web_search_model=str(raw_settings.get("webSearchModel", "pro")),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
profiles: dict[str, RoutingProfile] = {}
|
|
162
|
+
for name, body in data.items():
|
|
163
|
+
if not isinstance(body, dict):
|
|
164
|
+
die(f"profile '{name}' must be an object")
|
|
165
|
+
agent = body.get("agent")
|
|
166
|
+
if not agent:
|
|
167
|
+
die(f"profile '{name}' missing required 'agent' field")
|
|
168
|
+
for key in ("longContextThreshold", "destinations"):
|
|
169
|
+
if key not in body:
|
|
170
|
+
die(f"profile '{name}' missing required key: {key}")
|
|
171
|
+
dests_raw = body["destinations"]
|
|
172
|
+
if not isinstance(dests_raw, dict):
|
|
173
|
+
die(f"profile '{name}' destinations must be an object")
|
|
174
|
+
parsed: dict[str, Destination] = {}
|
|
175
|
+
for tier, raw in dests_raw.items():
|
|
176
|
+
if tier not in ("flash", "pro"):
|
|
177
|
+
die(f"profile '{name}' destination key must be flash or pro, got: {tier}")
|
|
178
|
+
parsed[tier] = _parse_destination(str(raw))
|
|
179
|
+
profiles[name] = RoutingProfile(
|
|
180
|
+
name=name,
|
|
181
|
+
agent=str(agent),
|
|
182
|
+
long_context_threshold=int(body["longContextThreshold"]),
|
|
183
|
+
destinations=parsed,
|
|
184
|
+
)
|
|
185
|
+
return settings, profiles
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def resolve_provider(name: str, providers: dict[str, Provider]) -> Provider:
|
|
189
|
+
if name not in providers:
|
|
190
|
+
avail = ", ".join(providers) or "(none)"
|
|
191
|
+
die(f"provider '{name}' not found in this profile's agent group; available: {avail}")
|
|
192
|
+
return providers[name]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_profiles(providers_all: dict, profiles: dict) -> None:
|
|
196
|
+
"""Cross-check every profile's agent and destinations against providers.json.
|
|
197
|
+
|
|
198
|
+
Called by both serve and config show, so bad references fail at load time
|
|
199
|
+
instead of on the first request.
|
|
200
|
+
"""
|
|
201
|
+
for profile in profiles.values():
|
|
202
|
+
group = providers_all.get(profile.agent)
|
|
203
|
+
if group is None:
|
|
204
|
+
avail = ", ".join(providers_all) or "(none)"
|
|
205
|
+
die(
|
|
206
|
+
f"agent '{profile.agent}' (for profile '{profile.name}') not found in "
|
|
207
|
+
f"providers.json; available: {avail}"
|
|
208
|
+
)
|
|
209
|
+
for tier, dest in profile.destinations.items():
|
|
210
|
+
resolve_provider(dest.provider_name, group)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def load_for_profile(name: str) -> tuple[dict[str, Provider], RoutingProfile, Settings]:
|
|
214
|
+
"""Resolve one profile: returns (agent providers, profile, settings)."""
|
|
215
|
+
providers_all = load_providers()
|
|
216
|
+
settings, profiles = load_routing()
|
|
217
|
+
if name not in profiles:
|
|
218
|
+
avail = ", ".join(profiles) or "(none)"
|
|
219
|
+
die(f"profile '{name}' not found in routing.json; available: {avail}")
|
|
220
|
+
profile = profiles[name]
|
|
221
|
+
validate_profiles(providers_all, {name: profile})
|
|
222
|
+
return providers_all[profile.agent], profile, settings
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_default_profile() -> tuple[dict[str, Provider], RoutingProfile, Settings]:
|
|
226
|
+
"""Auto-select when only one profile exists; prompt otherwise."""
|
|
227
|
+
settings, profiles = load_routing()
|
|
228
|
+
if not profiles:
|
|
229
|
+
die("no profiles in routing.json")
|
|
230
|
+
if len(profiles) == 1:
|
|
231
|
+
return load_for_profile(next(iter(profiles)))
|
|
232
|
+
die(
|
|
233
|
+
"multiple profiles available, specify one:\n"
|
|
234
|
+
f" awerouter serve <name>\navailable: {', '.join(profiles)}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Init / template
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def init_config() -> None:
|
|
243
|
+
d = config_dir()
|
|
244
|
+
if providers_path().exists() or routing_path().exists():
|
|
245
|
+
die(f"config already exists in {d}")
|
|
246
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
if not TEMPLATE_PROVIDERS.exists() or not TEMPLATE_ROUTING.exists():
|
|
248
|
+
die("default templates not found next to config.py")
|
|
249
|
+
shutil.copy2(TEMPLATE_PROVIDERS, providers_path())
|
|
250
|
+
shutil.copy2(TEMPLATE_ROUTING, routing_path())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def save_provider(agent: str, name: str, base_url: str, auth: str) -> None:
|
|
254
|
+
"""Append one provider entry to providers.json."""
|
|
255
|
+
path = providers_path()
|
|
256
|
+
data = _load_json(path, "providers.json")
|
|
257
|
+
group = data.setdefault(agent, {})
|
|
258
|
+
if name in group:
|
|
259
|
+
die(f"provider already exists: {agent}.{name}")
|
|
260
|
+
group[name] = {"base_url": base_url, "auth": auth}
|
|
261
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def save_profile_entry(
|
|
265
|
+
name: str, agent: str, long_context_threshold: int, flash: str, pro: str
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Append one profile entry to routing.json. flash/pro are 'provider,model'."""
|
|
268
|
+
path = routing_path()
|
|
269
|
+
data = _load_json(path, "routing.json")
|
|
270
|
+
if name in data:
|
|
271
|
+
die(f"profile already exists: {name}")
|
|
272
|
+
data[name] = {
|
|
273
|
+
"agent": agent,
|
|
274
|
+
"longContextThreshold": long_context_threshold,
|
|
275
|
+
"destinations": {"flash": flash, "pro": pro},
|
|
276
|
+
}
|
|
277
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Config display
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def format_providers_display(all_providers: dict[str, dict[str, Provider]]) -> str:
|
|
285
|
+
display = {}
|
|
286
|
+
for agent, group in all_providers.items():
|
|
287
|
+
agent_display = {}
|
|
288
|
+
for name, p in group.items():
|
|
289
|
+
entry = {"base_url": p.base_url, "auth_header": p.auth_header}
|
|
290
|
+
if ENV_REF_RE.fullmatch(str(p.auth)):
|
|
291
|
+
entry["auth"] = str(p.auth)
|
|
292
|
+
else:
|
|
293
|
+
entry["auth"] = "<set>"
|
|
294
|
+
agent_display[name] = entry
|
|
295
|
+
display[agent] = agent_display
|
|
296
|
+
return json.dumps(display, indent=2)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def format_routing_display(settings: Settings, profiles: dict[str, RoutingProfile]) -> str:
|
|
300
|
+
display = {
|
|
301
|
+
"settings": {
|
|
302
|
+
"backgroundModel": settings.background_model,
|
|
303
|
+
"thinkModel": settings.think_model,
|
|
304
|
+
"webSearchModel": settings.web_search_model,
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
for name, p in profiles.items():
|
|
308
|
+
display[name] = {
|
|
309
|
+
"agent": p.agent,
|
|
310
|
+
"longContextThreshold": p.long_context_threshold,
|
|
311
|
+
"destinations": {
|
|
312
|
+
k: f"{v.provider_name},{v.model}" for k, v in p.destinations.items()
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
return json.dumps(display, indent=2)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Click CLI
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
class ProfileGroup(click.Group):
|
|
323
|
+
"""Group where an unknown subcommand is treated as a profile name:
|
|
324
|
+
`awerouter cc-router-1` == `awerouter serve cc-router-1`.
|
|
325
|
+
|
|
326
|
+
Defined commands always win, so profiles named after commands are
|
|
327
|
+
unreachable via the shorthand (use `serve <name>` for those).
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def resolve_command(self, ctx, args):
|
|
331
|
+
try:
|
|
332
|
+
return super().resolve_command(ctx, args)
|
|
333
|
+
except click.UsageError:
|
|
334
|
+
if not args:
|
|
335
|
+
raise
|
|
336
|
+
ctx.meta["profile_name"] = args[0]
|
|
337
|
+
command = self.get_command(ctx, "__serve_profile__")
|
|
338
|
+
return args[0], command, args[1:]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@click.group(
|
|
342
|
+
cls=ProfileGroup,
|
|
343
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
344
|
+
)
|
|
345
|
+
@click.version_option(__version__, "-v", "--version", message="awerouter %(version)s")
|
|
346
|
+
def cli():
|
|
347
|
+
"""Smart LLM router: fast cheap tasks to flash, hard decisions to pro."""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@cli.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
351
|
+
def config():
|
|
352
|
+
"""Manage awerouter config."""
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@config.command("path")
|
|
356
|
+
def config_path_cmd():
|
|
357
|
+
"""Print config directory path."""
|
|
358
|
+
click.echo(config_dir())
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@config.command("show")
|
|
362
|
+
def config_show_cmd():
|
|
363
|
+
"""Show config (secrets redacted)."""
|
|
364
|
+
providers_all = load_providers()
|
|
365
|
+
settings, profiles = load_routing()
|
|
366
|
+
validate_profiles(providers_all, profiles)
|
|
367
|
+
click.echo("providers.json:" )
|
|
368
|
+
click.echo(format_providers_display(providers_all))
|
|
369
|
+
click.echo()
|
|
370
|
+
click.echo("routing.json:")
|
|
371
|
+
click.echo(format_routing_display(settings, profiles))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@config.command("edit")
|
|
375
|
+
def config_edit_cmd():
|
|
376
|
+
"""Open config dir in $EDITOR (creates default config if missing)."""
|
|
377
|
+
d = config_dir()
|
|
378
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
if not providers_path().exists() or not routing_path().exists():
|
|
380
|
+
init_config()
|
|
381
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or shutil.which("nano")
|
|
382
|
+
if not editor:
|
|
383
|
+
die("no EDITOR set; edit config manually")
|
|
384
|
+
import subprocess
|
|
385
|
+
import sys
|
|
386
|
+
if os.name == "nt":
|
|
387
|
+
argv = [editor, str(d)]
|
|
388
|
+
result = subprocess.run(argv)
|
|
389
|
+
sys.exit(result.returncode)
|
|
390
|
+
else:
|
|
391
|
+
os.execvp(editor, [editor, str(d)])
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@config.command("init")
|
|
395
|
+
def config_init_cmd():
|
|
396
|
+
"""Create default config from templates."""
|
|
397
|
+
init_config()
|
|
398
|
+
click.echo(config_dir())
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@cli.command("init")
|
|
402
|
+
def init_cmd():
|
|
403
|
+
"""Create default config from templates (same as config init)."""
|
|
404
|
+
init_config()
|
|
405
|
+
click.echo(config_dir())
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def main(argv=None):
|
|
409
|
+
try:
|
|
410
|
+
return cli.main(args=argv, prog_name="awerouter")
|
|
411
|
+
except SystemExit as exc:
|
|
412
|
+
return int(exc.code) if exc.code is not None else 0
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
if __name__ == "__main__":
|
|
416
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"claude": {
|
|
3
|
+
"stepfun": { "base_url": "https://api.stepfun.com/step_plan", "auth": "${STEPFUN_AUTH_TOKEN}" },
|
|
4
|
+
"anthropic": { "base_url": "https://api.anthropic.com", "auth": "${ANTHROPIC_KEY}" }
|
|
5
|
+
},
|
|
6
|
+
"codex": {
|
|
7
|
+
"stepfun": { "base_url": "https://api.stepfun.com/v1", "auth": "${STEPFUN_AUTH_TOKEN}" }
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"settings": {
|
|
3
|
+
"backgroundModel": "flash",
|
|
4
|
+
"thinkModel": "pro",
|
|
5
|
+
"webSearchModel": "pro"
|
|
6
|
+
},
|
|
7
|
+
"cc-router-1": {
|
|
8
|
+
"agent": "claude",
|
|
9
|
+
"longContextThreshold": 8000,
|
|
10
|
+
"destinations": {
|
|
11
|
+
"flash": "stepfun,step-3.7-flash",
|
|
12
|
+
"pro": "anthropic,claude-opus-5"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|