omoctl 0.1.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.
- omoctl/__init__.py +1 -0
- omoctl/__main__.py +3 -0
- omoctl/cli.py +268 -0
- omoctl/config.py +156 -0
- omoctl/models.py +184 -0
- omoctl/omo.py +107 -0
- omoctl/output.py +100 -0
- omoctl/patching.py +221 -0
- omoctl/paths.py +18 -0
- omoctl/store.py +51 -0
- omoctl/types.py +98 -0
- omoctl/validate.py +106 -0
- omoctl-0.1.0.dist-info/METADATA +244 -0
- omoctl-0.1.0.dist-info/RECORD +17 -0
- omoctl-0.1.0.dist-info/WHEEL +4 -0
- omoctl-0.1.0.dist-info/entry_points.txt +2 -0
- omoctl-0.1.0.dist-info/licenses/LICENSE +21 -0
omoctl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
omoctl/__main__.py
ADDED
omoctl/cli.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from omoctl import __version__
|
|
7
|
+
from omoctl.config import load_config
|
|
8
|
+
from omoctl.models import enrich_cache_with_omo, load_model_cache
|
|
9
|
+
from omoctl.omo import fetch_omo_config
|
|
10
|
+
from omoctl.validate import print_validation_result, validate_config
|
|
11
|
+
from omoctl.output import (
|
|
12
|
+
BOLD,
|
|
13
|
+
DIM,
|
|
14
|
+
GREEN,
|
|
15
|
+
RESET,
|
|
16
|
+
die,
|
|
17
|
+
print_diff,
|
|
18
|
+
print_profile_list,
|
|
19
|
+
print_profile_status,
|
|
20
|
+
print_section,
|
|
21
|
+
)
|
|
22
|
+
from omoctl.paths import ACTIVE_CONFIG_PATH
|
|
23
|
+
from omoctl.patching import apply_patches_to_config
|
|
24
|
+
from omoctl.store import (
|
|
25
|
+
activate_profile,
|
|
26
|
+
cleanup_stale,
|
|
27
|
+
get_active_alias,
|
|
28
|
+
get_profile_config,
|
|
29
|
+
save_profile,
|
|
30
|
+
remove_profile,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_status(_args: argparse.Namespace) -> None:
|
|
35
|
+
config = load_config()
|
|
36
|
+
active_alias = get_active_alias()
|
|
37
|
+
|
|
38
|
+
profile = None
|
|
39
|
+
if config.active_profile:
|
|
40
|
+
profile = config.find_profile(config.active_profile)
|
|
41
|
+
if profile is None and active_alias:
|
|
42
|
+
profile = config.find_profile(active_alias)
|
|
43
|
+
|
|
44
|
+
if profile is None:
|
|
45
|
+
print(f"{DIM}No active profile.{RESET}")
|
|
46
|
+
print(f"{DIM}Run 'omoctl list' to see available profiles.{RESET}")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
print_profile_status(profile.name, profile.alias, profile.providers)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_list(_args: argparse.Namespace) -> None:
|
|
53
|
+
config = load_config()
|
|
54
|
+
active_profile = config.get_active_profile()
|
|
55
|
+
active_alias = (
|
|
56
|
+
active_profile.alias if active_profile else get_active_alias()
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
profiles = [
|
|
60
|
+
(p.name, p.alias, p.providers) for p in config.profiles
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if not profiles:
|
|
64
|
+
print(f"{DIM}No profiles defined in config.{RESET}")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
print_profile_list(profiles, active_alias)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_switch(args: argparse.Namespace) -> None:
|
|
71
|
+
config = load_config()
|
|
72
|
+
profile = config.find_profile(args.profile)
|
|
73
|
+
|
|
74
|
+
if profile is None:
|
|
75
|
+
die(f"Profile {args.profile!r} not found. Run 'omoctl list' to see available profiles.")
|
|
76
|
+
|
|
77
|
+
stored_config = get_profile_config(profile.alias)
|
|
78
|
+
if stored_config is None:
|
|
79
|
+
die(
|
|
80
|
+
f"Profile {profile.name!r} has no stored config.\n"
|
|
81
|
+
f" Run 'omoctl update {profile.alias}' first to fetch and build it."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
activate_profile(profile.alias, profile.name, stored_config)
|
|
85
|
+
print(f"{GREEN}Switched to profile: {BOLD}{profile.name}{RESET}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_update(args: argparse.Namespace) -> None:
|
|
89
|
+
config = load_config()
|
|
90
|
+
|
|
91
|
+
if args.profile:
|
|
92
|
+
profile = config.find_profile(args.profile)
|
|
93
|
+
if profile is None:
|
|
94
|
+
die(f"Profile {args.profile!r} not found.")
|
|
95
|
+
profiles = [profile]
|
|
96
|
+
else:
|
|
97
|
+
profiles = list(config.profiles)
|
|
98
|
+
|
|
99
|
+
cache = load_model_cache()
|
|
100
|
+
|
|
101
|
+
active_profile = config.get_active_profile()
|
|
102
|
+
active_alias = active_profile.alias if active_profile else get_active_alias()
|
|
103
|
+
|
|
104
|
+
for idx, profile in enumerate(profiles):
|
|
105
|
+
if idx:
|
|
106
|
+
print()
|
|
107
|
+
print_section(profile.name)
|
|
108
|
+
print()
|
|
109
|
+
|
|
110
|
+
omo_config = fetch_omo_config(tuple(profile.providers))
|
|
111
|
+
enriched_cache = enrich_cache_with_omo(cache, omo_config)
|
|
112
|
+
|
|
113
|
+
curr_config = get_profile_config(profile.alias)
|
|
114
|
+
|
|
115
|
+
patched_config, keys = apply_patches_to_config(
|
|
116
|
+
enriched_cache, profile, omo_config, config,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
save_profile(profile.alias, profile.name, patched_config)
|
|
120
|
+
|
|
121
|
+
print_diff(curr_config, patched_config, keys)
|
|
122
|
+
|
|
123
|
+
to_activate = config.get_active_profile()
|
|
124
|
+
if not to_activate and active_alias:
|
|
125
|
+
to_activate = config.find_profile(active_alias)
|
|
126
|
+
if to_activate:
|
|
127
|
+
stored = get_profile_config(to_activate.alias)
|
|
128
|
+
if stored:
|
|
129
|
+
activate_profile(to_activate.alias, to_activate.name, stored)
|
|
130
|
+
|
|
131
|
+
valid_aliases = {p.alias for p in config.profiles}
|
|
132
|
+
cleanup_stale(valid_aliases)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_remove(args: argparse.Namespace) -> None:
|
|
136
|
+
config = load_config()
|
|
137
|
+
profile = config.find_profile(args.profile)
|
|
138
|
+
|
|
139
|
+
if profile is None:
|
|
140
|
+
die(f"Profile {args.profile!r} not found.")
|
|
141
|
+
|
|
142
|
+
remove_profile(profile.alias)
|
|
143
|
+
print(f"{GREEN}Removed profile: {BOLD}{profile.name}{RESET}")
|
|
144
|
+
print(f"{DIM}Note: the profile definition is still in config.yaml. Edit it to remove permanently.{RESET}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_validate(_args: argparse.Namespace) -> None:
|
|
148
|
+
config = load_config()
|
|
149
|
+
cache = load_model_cache()
|
|
150
|
+
|
|
151
|
+
all_providers = set()
|
|
152
|
+
for profile in config.profiles:
|
|
153
|
+
all_providers.update(profile.providers)
|
|
154
|
+
omo_config = fetch_omo_config(tuple(sorted(all_providers)), quiet=True)
|
|
155
|
+
enriched_cache = enrich_cache_with_omo(cache, omo_config)
|
|
156
|
+
|
|
157
|
+
errors = validate_config(config, enriched_cache)
|
|
158
|
+
if not print_validation_result(errors):
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_show(args: argparse.Namespace) -> None:
|
|
163
|
+
if args.alias:
|
|
164
|
+
alias = get_active_alias()
|
|
165
|
+
if not alias:
|
|
166
|
+
die("No active profile. Run 'omoctl switch <profile>' first.")
|
|
167
|
+
print(alias)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if args.name:
|
|
171
|
+
alias = get_active_alias()
|
|
172
|
+
if not alias:
|
|
173
|
+
die("No active profile. Run 'omoctl switch <profile>' first.")
|
|
174
|
+
config = load_config()
|
|
175
|
+
profile = config.find_profile(alias)
|
|
176
|
+
if profile is None:
|
|
177
|
+
die(f"Active alias {alias!r} is not defined in config.yaml.")
|
|
178
|
+
print(profile.name)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if not ACTIVE_CONFIG_PATH.exists():
|
|
182
|
+
die(
|
|
183
|
+
f"No active config at {ACTIVE_CONFIG_PATH}.\n"
|
|
184
|
+
f" Run 'omoctl switch <profile>' or 'omoctl update' first."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if args.json:
|
|
188
|
+
print(ACTIVE_CONFIG_PATH.read_text())
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
alias = get_active_alias()
|
|
192
|
+
config = load_config()
|
|
193
|
+
profile = config.find_profile(alias) if alias else None
|
|
194
|
+
|
|
195
|
+
if profile:
|
|
196
|
+
print(
|
|
197
|
+
f"{BOLD}Profile:{RESET} "
|
|
198
|
+
f"{GREEN}{profile.name}{RESET} {DIM}({profile.alias}){RESET}"
|
|
199
|
+
)
|
|
200
|
+
elif alias:
|
|
201
|
+
print(f"{BOLD}Profile:{RESET} {DIM}{alias}{RESET}")
|
|
202
|
+
print()
|
|
203
|
+
print(ACTIVE_CONFIG_PATH.read_text())
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_version(_args: argparse.Namespace) -> None:
|
|
207
|
+
print(f"omoctl {__version__}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main() -> None:
|
|
211
|
+
parser = argparse.ArgumentParser(
|
|
212
|
+
prog="omoctl",
|
|
213
|
+
description="Manage oh-my-openagent (OMO) profiles",
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--version",
|
|
217
|
+
action="version",
|
|
218
|
+
version=f"omoctl {__version__}",
|
|
219
|
+
)
|
|
220
|
+
parser.set_defaults(func=cmd_status)
|
|
221
|
+
|
|
222
|
+
sub = parser.add_subparsers(dest="command")
|
|
223
|
+
|
|
224
|
+
list_p = sub.add_parser("list", help="List all profiles")
|
|
225
|
+
list_p.set_defaults(func=cmd_list)
|
|
226
|
+
|
|
227
|
+
switch_p = sub.add_parser("switch", help="Switch to a profile")
|
|
228
|
+
switch_p.add_argument("profile", help="Profile name or alias")
|
|
229
|
+
switch_p.set_defaults(func=cmd_switch)
|
|
230
|
+
|
|
231
|
+
update_p = sub.add_parser("update", help="Update profiles (fetch + patch + save)")
|
|
232
|
+
update_p.add_argument("profile", nargs="?", default=None, help="Profile name or alias (all if omitted)")
|
|
233
|
+
update_p.set_defaults(func=cmd_update)
|
|
234
|
+
|
|
235
|
+
remove_p = sub.add_parser("remove", help="Remove a stored profile")
|
|
236
|
+
remove_p.add_argument("profile", help="Profile name or alias")
|
|
237
|
+
remove_p.set_defaults(func=cmd_remove)
|
|
238
|
+
|
|
239
|
+
validate_p = sub.add_parser("validate", help="Validate config against available models/agents")
|
|
240
|
+
validate_p.set_defaults(func=cmd_validate)
|
|
241
|
+
|
|
242
|
+
show_p = sub.add_parser(
|
|
243
|
+
"show", help="Show the active profile (header + JSON by default)"
|
|
244
|
+
)
|
|
245
|
+
show_mode = show_p.add_mutually_exclusive_group()
|
|
246
|
+
show_mode.add_argument(
|
|
247
|
+
"-a", "--alias", action="store_true",
|
|
248
|
+
help="Print only the active profile alias",
|
|
249
|
+
)
|
|
250
|
+
show_mode.add_argument(
|
|
251
|
+
"-n", "--name", action="store_true",
|
|
252
|
+
help="Print only the active profile name",
|
|
253
|
+
)
|
|
254
|
+
show_mode.add_argument(
|
|
255
|
+
"-j", "--json", action="store_true",
|
|
256
|
+
help="Print only the raw JSON config (no header)",
|
|
257
|
+
)
|
|
258
|
+
show_p.set_defaults(func=cmd_show)
|
|
259
|
+
|
|
260
|
+
version_p = sub.add_parser("version", help="Print version")
|
|
261
|
+
version_p.set_defaults(func=cmd_version)
|
|
262
|
+
|
|
263
|
+
args = parser.parse_args()
|
|
264
|
+
try:
|
|
265
|
+
args.func(args)
|
|
266
|
+
except KeyboardInterrupt:
|
|
267
|
+
print("\nAborted.", file=sys.stderr)
|
|
268
|
+
sys.exit(130)
|
omoctl/config.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import re
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
import dacite
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from omoctl.output import die
|
|
11
|
+
from omoctl.paths import CONFIG_DIR, CONFIG_PATH, PROFILES_DIR
|
|
12
|
+
from omoctl.types import _UNSET
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclasses.dataclass
|
|
16
|
+
class PatchSource:
|
|
17
|
+
provider: str | None = None
|
|
18
|
+
model: typing.Any = None
|
|
19
|
+
agent: str | None = None
|
|
20
|
+
category: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass
|
|
24
|
+
class PatchTarget:
|
|
25
|
+
provider: str | None = None
|
|
26
|
+
model: typing.Any = None
|
|
27
|
+
variant: typing.Any = dataclasses.field(default=_UNSET)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclasses.dataclass
|
|
31
|
+
class Patch:
|
|
32
|
+
source: PatchSource = dataclasses.field(default_factory=PatchSource)
|
|
33
|
+
target: PatchTarget = dataclasses.field(default_factory=PatchTarget)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass
|
|
37
|
+
class Profile:
|
|
38
|
+
name: str = ""
|
|
39
|
+
providers: list[str] = dataclasses.field(default_factory=list)
|
|
40
|
+
patches: list[Patch] | None = None
|
|
41
|
+
overrides: dict | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def alias(self) -> str:
|
|
45
|
+
return re.sub(r"[^a-z0-9]+", "-", self.name.lower()).strip("-")[:50]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclasses.dataclass
|
|
49
|
+
class Config:
|
|
50
|
+
active_profile: str | None = None
|
|
51
|
+
defaults: dict | None = None
|
|
52
|
+
patches: list[Patch] | None = None
|
|
53
|
+
profiles: list[Profile] = dataclasses.field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
def find_profile(self, name_or_alias: str) -> Profile | None:
|
|
56
|
+
key = name_or_alias.lower()
|
|
57
|
+
for profile in self.profiles:
|
|
58
|
+
if profile.name.lower() == key or profile.alias == key:
|
|
59
|
+
return profile
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def get_active_profile(self) -> Profile | None:
|
|
63
|
+
if self.active_profile is None:
|
|
64
|
+
return None
|
|
65
|
+
return self.find_profile(self.active_profile)
|
|
66
|
+
|
|
67
|
+
def get_effective_patches(self, profile: Profile) -> list[Patch]:
|
|
68
|
+
result: list[Patch] = []
|
|
69
|
+
if profile.patches:
|
|
70
|
+
result.extend(profile.patches)
|
|
71
|
+
if self.patches:
|
|
72
|
+
result.extend(self.patches)
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
def get_effective_overrides(self, profile: Profile) -> dict | None:
|
|
76
|
+
if not self.defaults and not profile.overrides:
|
|
77
|
+
return None
|
|
78
|
+
result = dict(self.defaults) if self.defaults else {}
|
|
79
|
+
if profile.overrides:
|
|
80
|
+
result = merge_dicts(result, profile.overrides)
|
|
81
|
+
return result or None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def merge_dicts(
|
|
85
|
+
base: dict[str, typing.Any], override: dict[str, typing.Any]
|
|
86
|
+
) -> dict[str, typing.Any]:
|
|
87
|
+
result = base.copy()
|
|
88
|
+
for key, value in override.items():
|
|
89
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
90
|
+
result[key] = merge_dicts(result[key], value)
|
|
91
|
+
else:
|
|
92
|
+
result[key] = value
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_DACITE_CONFIG = dacite.Config(check_types=False, strict=True)
|
|
97
|
+
|
|
98
|
+
_DEFAULT_YAML = """\
|
|
99
|
+
# active_profile: my-profile
|
|
100
|
+
|
|
101
|
+
defaults:
|
|
102
|
+
disabled_hooks:
|
|
103
|
+
- context-window-monitor
|
|
104
|
+
|
|
105
|
+
# patches:
|
|
106
|
+
# - source: { provider: google }
|
|
107
|
+
# target: { provider: proxy }
|
|
108
|
+
|
|
109
|
+
profiles:
|
|
110
|
+
- name: Claude
|
|
111
|
+
providers: [claude]
|
|
112
|
+
|
|
113
|
+
- name: No Copilot
|
|
114
|
+
providers: [claude, gemini, openai]
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_config() -> Config:
|
|
119
|
+
if not CONFIG_PATH.exists():
|
|
120
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
CONFIG_PATH.write_text(_DEFAULT_YAML)
|
|
123
|
+
die(
|
|
124
|
+
f"No config found. A default has been created at {CONFIG_PATH}\n"
|
|
125
|
+
f" Edit it to define your profiles and re-run."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
with CONFIG_PATH.open() as f:
|
|
130
|
+
data = yaml.safe_load(f)
|
|
131
|
+
except yaml.YAMLError as e:
|
|
132
|
+
die(f"Config at {CONFIG_PATH} has invalid YAML:\n {e}")
|
|
133
|
+
|
|
134
|
+
if not data or not data.get("profiles"):
|
|
135
|
+
die(f"No profiles defined in {CONFIG_PATH}. Add at least one profile.")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
config = dacite.from_dict(Config, data, config=_DACITE_CONFIG)
|
|
139
|
+
except dacite.DaciteError as e:
|
|
140
|
+
die(f"Config at {CONFIG_PATH} has invalid structure:\n {e}")
|
|
141
|
+
|
|
142
|
+
seen_aliases: dict[str, str] = {}
|
|
143
|
+
for profile in config.profiles:
|
|
144
|
+
if not profile.name:
|
|
145
|
+
die("Each profile must have a 'name' field.")
|
|
146
|
+
if not profile.providers:
|
|
147
|
+
die(f"Profile {profile.name!r} must have a 'providers' field.")
|
|
148
|
+
if profile.alias in seen_aliases:
|
|
149
|
+
die(
|
|
150
|
+
f"Profile {profile.name!r} produces alias {profile.alias!r} "
|
|
151
|
+
f"which collides with profile {seen_aliases[profile.alias]!r}.\n"
|
|
152
|
+
f" Rename one of them so they produce distinct aliases."
|
|
153
|
+
)
|
|
154
|
+
seen_aliases[profile.alias] = profile.name
|
|
155
|
+
|
|
156
|
+
return config
|
omoctl/models.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import itertools
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from omoctl.output import die
|
|
10
|
+
from omoctl.paths import CACHED_MODELS_PATH, CUSTOM_MODELS_PATH
|
|
11
|
+
from omoctl.types import ModelFilter, ModelProps
|
|
12
|
+
|
|
13
|
+
DATED_MODEL_PATTERN: typing.Final[re.Pattern] = re.compile(
|
|
14
|
+
r".*("
|
|
15
|
+
r"\d{6,}"
|
|
16
|
+
r"|20\d{2}[-]\d{2}([-]\d{2})?"
|
|
17
|
+
r"|\d{2}[-]20\d{2}"
|
|
18
|
+
r"|\d{2}[-]\d{2}"
|
|
19
|
+
r")$"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
24
|
+
class ModelCache:
|
|
25
|
+
provider_to_models: dict[str, tuple[str, ...]]
|
|
26
|
+
agent_names: tuple[str, ...] = ()
|
|
27
|
+
category_names: tuple[str, ...] = ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_model_cache() -> ModelCache:
|
|
31
|
+
if not CACHED_MODELS_PATH.exists():
|
|
32
|
+
die(
|
|
33
|
+
f"Model cache not found at {CACHED_MODELS_PATH}.\n"
|
|
34
|
+
f" Run 'opencode' once to populate it."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with CACHED_MODELS_PATH.open() as f:
|
|
39
|
+
data = json.load(f)
|
|
40
|
+
except json.JSONDecodeError as e:
|
|
41
|
+
die(f"Model cache at {CACHED_MODELS_PATH} is malformed:\n {e}")
|
|
42
|
+
|
|
43
|
+
custom_data: dict = {}
|
|
44
|
+
if CUSTOM_MODELS_PATH.exists():
|
|
45
|
+
try:
|
|
46
|
+
with CUSTOM_MODELS_PATH.open() as f:
|
|
47
|
+
custom_data = json.load(f).get("provider", {})
|
|
48
|
+
except json.JSONDecodeError as e:
|
|
49
|
+
die(f"Custom models at {CUSTOM_MODELS_PATH} is malformed:\n {e}")
|
|
50
|
+
|
|
51
|
+
providers = set(data.keys()) | set(custom_data.keys())
|
|
52
|
+
|
|
53
|
+
provider_to_models: dict[str, tuple[str, ...]] = {}
|
|
54
|
+
for provider in providers:
|
|
55
|
+
models: set[str] = set()
|
|
56
|
+
for source_data in (data, custom_data):
|
|
57
|
+
if provider in source_data and "models" in source_data[provider]:
|
|
58
|
+
provider_models = source_data[provider]["models"]
|
|
59
|
+
if isinstance(provider_models, dict):
|
|
60
|
+
models.update(provider_models.keys())
|
|
61
|
+
|
|
62
|
+
provider_to_models[provider] = tuple(
|
|
63
|
+
sorted(
|
|
64
|
+
models,
|
|
65
|
+
key=lambda model_id: (
|
|
66
|
+
bool(DATED_MODEL_PATTERN.match(model_id)),
|
|
67
|
+
not model_id.endswith("latest"),
|
|
68
|
+
len(model_id),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return ModelCache(
|
|
74
|
+
provider_to_models=provider_to_models,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def enrich_cache_with_omo(cache: ModelCache, omo_config: dict) -> ModelCache:
|
|
79
|
+
return ModelCache(
|
|
80
|
+
provider_to_models=cache.provider_to_models,
|
|
81
|
+
agent_names=tuple(sorted(omo_config.get("agents", {}).keys())),
|
|
82
|
+
category_names=tuple(sorted(omo_config.get("categories", {}).keys())),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _numbers_key(numbers: tuple[str, ...]) -> tuple[int, ...]:
|
|
87
|
+
return tuple(int(n) for n in numbers) if numbers else ()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_best_matching_model(
|
|
91
|
+
provider_to_models: dict[str, tuple[str, ...]],
|
|
92
|
+
original_provider: str | None,
|
|
93
|
+
original_model: str | None,
|
|
94
|
+
target_provider: str,
|
|
95
|
+
target_hint: ModelFilter | str | None,
|
|
96
|
+
) -> str:
|
|
97
|
+
if target_provider not in provider_to_models:
|
|
98
|
+
die(f"Target provider {target_provider!r} not found in cached models.")
|
|
99
|
+
|
|
100
|
+
if isinstance(target_hint, str):
|
|
101
|
+
if target_hint in provider_to_models[target_provider]:
|
|
102
|
+
return f"{target_provider}/{target_hint}"
|
|
103
|
+
die(
|
|
104
|
+
f"Explicit model {target_hint!r} not found in provider {target_provider!r}."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
original_props = (
|
|
108
|
+
ModelProps.from_model_id(original_model) if original_model else None
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if target_hint is not None:
|
|
112
|
+
include_words = target_hint.words_include
|
|
113
|
+
exclude_words = target_hint.words_exclude
|
|
114
|
+
include_numbers = target_hint.numbers_include
|
|
115
|
+
exclude_numbers = target_hint.numbers_exclude
|
|
116
|
+
else:
|
|
117
|
+
include_words = ()
|
|
118
|
+
exclude_words = ()
|
|
119
|
+
include_numbers = ()
|
|
120
|
+
exclude_numbers = ()
|
|
121
|
+
|
|
122
|
+
if not target_hint and original_props:
|
|
123
|
+
include_words = original_props.words
|
|
124
|
+
include_numbers = original_props.numbers
|
|
125
|
+
|
|
126
|
+
version_specified = bool(include_numbers) or bool(
|
|
127
|
+
original_props and original_props.numbers
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _matches(props: ModelProps) -> bool:
|
|
131
|
+
return (
|
|
132
|
+
all(w in props.words for w in include_words)
|
|
133
|
+
and not any(w in props.words for w in exclude_words)
|
|
134
|
+
and all(n in props.numbers for n in include_numbers)
|
|
135
|
+
and not any(n in props.numbers for n in exclude_numbers)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
candidates: list[tuple[str, ModelProps]] = []
|
|
139
|
+
for model_id in provider_to_models[target_provider]:
|
|
140
|
+
props = ModelProps.from_model_id(model_id)
|
|
141
|
+
if props is not None and _matches(props):
|
|
142
|
+
candidates.append((model_id, props))
|
|
143
|
+
|
|
144
|
+
if not candidates and version_specified:
|
|
145
|
+
for model_id in provider_to_models[target_provider]:
|
|
146
|
+
props = ModelProps.from_model_id(model_id)
|
|
147
|
+
if props is None:
|
|
148
|
+
continue
|
|
149
|
+
if all(w in props.words for w in include_words) and not any(
|
|
150
|
+
w in props.words for w in exclude_words
|
|
151
|
+
):
|
|
152
|
+
candidates.append((model_id, props))
|
|
153
|
+
|
|
154
|
+
if not candidates:
|
|
155
|
+
die(
|
|
156
|
+
f"No matching model for {original_model!r} from "
|
|
157
|
+
f"{original_provider!r} in provider {target_provider!r}."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
original_words = set(original_props.words) if original_props else set()
|
|
161
|
+
original_numbers = original_props.numbers if original_props else ()
|
|
162
|
+
target_version = _numbers_key(include_numbers or original_numbers)
|
|
163
|
+
|
|
164
|
+
def _sort_key(entry: tuple[str, ModelProps]) -> tuple:
|
|
165
|
+
model_id, props = entry
|
|
166
|
+
version = _numbers_key(props.numbers)
|
|
167
|
+
word_overlap = -len(original_words & set(props.words)) if original_words else 0
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
(props.provider_prefix or "") != (original_provider or ""),
|
|
171
|
+
word_overlap,
|
|
172
|
+
bool(DATED_MODEL_PATTERN.match(model_id)),
|
|
173
|
+
"latest" in props.words,
|
|
174
|
+
tuple(
|
|
175
|
+
abs(a - b)
|
|
176
|
+
for a, b in itertools.zip_longest(version, target_version, fillvalue=0)
|
|
177
|
+
)
|
|
178
|
+
if version_specified
|
|
179
|
+
else tuple(-v for v in version),
|
|
180
|
+
len(model_id),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
candidates.sort(key=_sort_key)
|
|
184
|
+
return f"{target_provider}/{candidates[0][0]}"
|