xsync-cli 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.
- xsync_cli/__init__.py +0 -0
- xsync_cli/adapters/__init__.py +0 -0
- xsync_cli/adapters/codex.py +148 -0
- xsync_cli/adapters/codex_config.py +123 -0
- xsync_cli/adapters/codex_wiring.py +137 -0
- xsync_cli/adapters/prompts/__init__.py +0 -0
- xsync_cli/adapters/prompts/generic-codex.md +116 -0
- xsync_cli/adapters/prompts/generic-codex.messages.json +1 -0
- xsync_cli/adapters/prompts/gpt-5.4.md +123 -0
- xsync_cli/adapters/prompts/gpt-5.4.messages.json +1 -0
- xsync_cli/adapters/prompts/gpt-5.5.md +148 -0
- xsync_cli/adapters/prompts/gpt-5.5.messages.json +1 -0
- xsync_cli/adapters/rules/__init__.py +0 -0
- xsync_cli/adapters/rules/codex.toml +64 -0
- xsync_cli/cli.py +321 -0
- xsync_cli/core/__init__.py +0 -0
- xsync_cli/core/atomic.py +28 -0
- xsync_cli/core/diff.py +81 -0
- xsync_cli/core/filters.py +33 -0
- xsync_cli/core/model.py +35 -0
- xsync_cli/core/profiles.py +166 -0
- xsync_cli/sources/__init__.py +0 -0
- xsync_cli/sources/openai_compat.py +117 -0
- xsync_cli-0.1.0.dist-info/METADATA +184 -0
- xsync_cli-0.1.0.dist-info/RECORD +28 -0
- xsync_cli-0.1.0.dist-info/WHEEL +4 -0
- xsync_cli-0.1.0.dist-info/entry_points.txt +2 -0
- xsync_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
xsync_cli/cli.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""The xsync command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from xsync_cli.adapters.codex import load_rules, render_catalog
|
|
12
|
+
from xsync_cli.adapters.codex_config import (
|
|
13
|
+
STATE_FILENAME,
|
|
14
|
+
ConfigError,
|
|
15
|
+
State,
|
|
16
|
+
catalog_path_for,
|
|
17
|
+
check_provider_match,
|
|
18
|
+
clear_state,
|
|
19
|
+
default_codex_home,
|
|
20
|
+
read_config,
|
|
21
|
+
read_state,
|
|
22
|
+
write_state,
|
|
23
|
+
)
|
|
24
|
+
from xsync_cli.adapters.codex_wiring import init_config, known_removals, reset_config
|
|
25
|
+
from xsync_cli.core.atomic import write_json_atomic
|
|
26
|
+
from xsync_cli.core.diff import diff_catalogs
|
|
27
|
+
from xsync_cli.core.filters import apply_filters
|
|
28
|
+
from xsync_cli.core.profiles import (
|
|
29
|
+
WIRE_APIS,
|
|
30
|
+
Profile,
|
|
31
|
+
ProfileError,
|
|
32
|
+
ProfileStore,
|
|
33
|
+
default_store_path,
|
|
34
|
+
)
|
|
35
|
+
from xsync_cli.sources.openai_compat import (
|
|
36
|
+
EndpointUnreachable,
|
|
37
|
+
SourceError,
|
|
38
|
+
fetch_models,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_ERROR = 1
|
|
43
|
+
EXIT_UNREACHABLE = 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def store_path() -> Path:
|
|
47
|
+
"""The profile file. The variable XSYNC_PROFILES wins."""
|
|
48
|
+
override = os.environ.get("XSYNC_PROFILES")
|
|
49
|
+
return Path(override) if override else default_store_path()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def open_store() -> ProfileStore:
|
|
53
|
+
store = ProfileStore(store_path())
|
|
54
|
+
store.load()
|
|
55
|
+
return store
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ask(prompt: str, default: str = "") -> str:
|
|
59
|
+
suffix = f" [{default}]" if default else ""
|
|
60
|
+
answer = input(f"{prompt}{suffix}: ").strip()
|
|
61
|
+
return answer or default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ask_list(prompt: str) -> list[str]:
|
|
65
|
+
answer = _ask(prompt)
|
|
66
|
+
return [part.strip() for part in answer.split(",") if part.strip()]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
70
|
+
"""Make a profile."""
|
|
71
|
+
store = open_store()
|
|
72
|
+
|
|
73
|
+
name = _ask("profile name")
|
|
74
|
+
if not name:
|
|
75
|
+
print("the profile name cannot be empty.", file=sys.stderr)
|
|
76
|
+
return EXIT_ERROR
|
|
77
|
+
|
|
78
|
+
base_url = _ask("base URL (for example http://127.0.0.1:20128/v1)")
|
|
79
|
+
if not base_url:
|
|
80
|
+
print("the base URL cannot be empty.", file=sys.stderr)
|
|
81
|
+
return EXIT_ERROR
|
|
82
|
+
|
|
83
|
+
api_key = _ask("API key (leave empty when the endpoint needs none)")
|
|
84
|
+
include = _ask_list("include globs, separated by a comma (optional)")
|
|
85
|
+
exclude = _ask_list("exclude globs, separated by a comma (optional)")
|
|
86
|
+
|
|
87
|
+
print(f"testing {base_url}/models …")
|
|
88
|
+
try:
|
|
89
|
+
models = fetch_models(base_url, api_key or None)
|
|
90
|
+
except EndpointUnreachable as error:
|
|
91
|
+
print(f"error: {error}", file=sys.stderr)
|
|
92
|
+
return EXIT_UNREACHABLE
|
|
93
|
+
except SourceError as error:
|
|
94
|
+
print(f"error: {error}", file=sys.stderr)
|
|
95
|
+
return EXIT_ERROR
|
|
96
|
+
|
|
97
|
+
print(f"\n{len(models)} models:")
|
|
98
|
+
for model in models:
|
|
99
|
+
window = f"{model.context_window:,}" if model.context_window else "unknown"
|
|
100
|
+
flags = ",".join(
|
|
101
|
+
flag
|
|
102
|
+
for flag, on in (
|
|
103
|
+
("tools", model.tools),
|
|
104
|
+
("reasoning", model.reasoning),
|
|
105
|
+
("vision", model.vision),
|
|
106
|
+
("search", model.search),
|
|
107
|
+
)
|
|
108
|
+
if on
|
|
109
|
+
)
|
|
110
|
+
print(f" {model.slug:<48} {window:>12} {flags}")
|
|
111
|
+
|
|
112
|
+
print()
|
|
113
|
+
wire_api = _ask(f"wire API ({' or '.join(WIRE_APIS)})", "chat")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
profile = Profile(
|
|
117
|
+
name=name,
|
|
118
|
+
base_url=base_url,
|
|
119
|
+
api_key=api_key or None,
|
|
120
|
+
api_key_env=None,
|
|
121
|
+
wire_api=wire_api,
|
|
122
|
+
include=include,
|
|
123
|
+
exclude=exclude,
|
|
124
|
+
)
|
|
125
|
+
except ProfileError as error:
|
|
126
|
+
print(f"error: {error}", file=sys.stderr)
|
|
127
|
+
return EXIT_ERROR
|
|
128
|
+
|
|
129
|
+
store.add(profile)
|
|
130
|
+
store.save()
|
|
131
|
+
print(f"\nsaved profile {name!r} to {store.path}")
|
|
132
|
+
if store.active == name:
|
|
133
|
+
print(f"profile {name!r} is now active")
|
|
134
|
+
return EXIT_OK
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
138
|
+
"""Show the profiles."""
|
|
139
|
+
store = open_store()
|
|
140
|
+
if not store.names():
|
|
141
|
+
print("no profile exists. Run `xsync setup` first.")
|
|
142
|
+
return EXIT_OK
|
|
143
|
+
for name in store.names():
|
|
144
|
+
mark = "*" if name == store.active else " "
|
|
145
|
+
profile = store.get(name)
|
|
146
|
+
print(f"{mark} {name:<20} {profile.base_url:<40} {profile.wire_api}")
|
|
147
|
+
return EXIT_OK
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_use(args: argparse.Namespace) -> int:
|
|
151
|
+
"""Set the active profile."""
|
|
152
|
+
store = open_store()
|
|
153
|
+
try:
|
|
154
|
+
store.set_active(args.name)
|
|
155
|
+
except ProfileError as error:
|
|
156
|
+
print(f"error: {error}", file=sys.stderr)
|
|
157
|
+
return EXIT_ERROR
|
|
158
|
+
store.save()
|
|
159
|
+
print(f"active profile: {args.name}")
|
|
160
|
+
return EXIT_OK
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_remove(args: argparse.Namespace) -> int:
|
|
164
|
+
"""Delete a profile."""
|
|
165
|
+
store = open_store()
|
|
166
|
+
try:
|
|
167
|
+
store.remove(args.name)
|
|
168
|
+
except ProfileError as error:
|
|
169
|
+
print(f"error: {error}", file=sys.stderr)
|
|
170
|
+
return EXIT_ERROR
|
|
171
|
+
store.save()
|
|
172
|
+
print(f"removed profile {args.name!r}")
|
|
173
|
+
return EXIT_OK
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _load_catalog(path: Path) -> dict | None:
|
|
177
|
+
"""The current catalog, or None when it is absent or damaged."""
|
|
178
|
+
if not path.exists():
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
182
|
+
except json.JSONDecodeError:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _do_reset(codex_home: Path, profile_name: str, force: bool) -> int:
|
|
187
|
+
"""Remove everything that xSync wrote."""
|
|
188
|
+
state_path = codex_home / STATE_FILENAME
|
|
189
|
+
state = read_state(state_path)
|
|
190
|
+
|
|
191
|
+
if state is None:
|
|
192
|
+
planned = known_removals(profile_name)
|
|
193
|
+
if not force:
|
|
194
|
+
print("no state file exists. xSync would remove:")
|
|
195
|
+
for key in planned.keys_written:
|
|
196
|
+
print(f" key {key}")
|
|
197
|
+
for block in planned.blocks_written:
|
|
198
|
+
print(f" block [{block}]")
|
|
199
|
+
print("\nadd --force to continue.", file=sys.stderr)
|
|
200
|
+
return EXIT_ERROR
|
|
201
|
+
state = State(
|
|
202
|
+
profile=profile_name,
|
|
203
|
+
keys_written=planned.keys_written,
|
|
204
|
+
blocks_written=planned.blocks_written,
|
|
205
|
+
files_written=[str(codex_home / f"{profile_name}-models.json")],
|
|
206
|
+
config_sha256="",
|
|
207
|
+
written_at="",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
removed = reset_config(codex_home / "config.toml", state)
|
|
211
|
+
clear_state(state_path)
|
|
212
|
+
for name in removed:
|
|
213
|
+
print(f"removed {name}")
|
|
214
|
+
print("Codex is back at its own defaults.")
|
|
215
|
+
return EXIT_OK
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cmd_codex(args: argparse.Namespace) -> int:
|
|
219
|
+
"""Sync a profile into the Codex catalog."""
|
|
220
|
+
store = open_store()
|
|
221
|
+
codex_home = default_codex_home()
|
|
222
|
+
config_path = codex_home / "config.toml"
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
profile = store.get(args.profile) if args.profile else store.active_profile()
|
|
226
|
+
except ProfileError as error:
|
|
227
|
+
print(f"error: {error}", file=sys.stderr)
|
|
228
|
+
return EXIT_ERROR
|
|
229
|
+
|
|
230
|
+
if args.reset:
|
|
231
|
+
try:
|
|
232
|
+
return _do_reset(codex_home, profile.name, args.force)
|
|
233
|
+
except ConfigError as error:
|
|
234
|
+
print(f"error: {error}", file=sys.stderr)
|
|
235
|
+
return EXIT_ERROR
|
|
236
|
+
|
|
237
|
+
catalog_path = catalog_path_for(profile, codex_home)
|
|
238
|
+
|
|
239
|
+
if args.init:
|
|
240
|
+
try:
|
|
241
|
+
api_key = profile.resolve_key(os.environ)
|
|
242
|
+
state = init_config(config_path, profile, catalog_path, api_key)
|
|
243
|
+
except (ConfigError, ProfileError) as error:
|
|
244
|
+
print(f"error: {error}", file=sys.stderr)
|
|
245
|
+
return EXIT_ERROR
|
|
246
|
+
write_state(codex_home / STATE_FILENAME, state)
|
|
247
|
+
print(f"Codex now routes to {profile.name!r} ({profile.base_url})")
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
api_key = profile.resolve_key(os.environ)
|
|
251
|
+
models = fetch_models(profile.base_url, api_key)
|
|
252
|
+
except EndpointUnreachable as error:
|
|
253
|
+
print(f"error: {error}", file=sys.stderr)
|
|
254
|
+
return EXIT_UNREACHABLE
|
|
255
|
+
except (SourceError, ProfileError) as error:
|
|
256
|
+
print(f"error: {error}", file=sys.stderr)
|
|
257
|
+
return EXIT_ERROR
|
|
258
|
+
|
|
259
|
+
models = apply_filters(models, profile.include, profile.exclude)
|
|
260
|
+
|
|
261
|
+
config = read_config(config_path)
|
|
262
|
+
message = check_provider_match(config, profile)
|
|
263
|
+
if message:
|
|
264
|
+
if args.dry_run:
|
|
265
|
+
print(f"warning:\n{message}\n")
|
|
266
|
+
else:
|
|
267
|
+
print(f"error:\n{message}", file=sys.stderr)
|
|
268
|
+
return EXIT_ERROR
|
|
269
|
+
|
|
270
|
+
catalog = render_catalog(models, load_rules())
|
|
271
|
+
report = diff_catalogs(_load_catalog(catalog_path), catalog)
|
|
272
|
+
print(report.render())
|
|
273
|
+
|
|
274
|
+
if args.dry_run:
|
|
275
|
+
print("no files written (--dry-run)")
|
|
276
|
+
return EXIT_OK
|
|
277
|
+
|
|
278
|
+
write_json_atomic(catalog_path, catalog)
|
|
279
|
+
print(f"wrote {len(catalog['models'])} models to {catalog_path}")
|
|
280
|
+
return EXIT_OK
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
284
|
+
parser = argparse.ArgumentParser(
|
|
285
|
+
prog="xsync",
|
|
286
|
+
description="Sync the model list of an OpenAI-compatible endpoint into a harness.",
|
|
287
|
+
)
|
|
288
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
289
|
+
|
|
290
|
+
sub.add_parser("setup", help="make a profile").set_defaults(func=cmd_setup)
|
|
291
|
+
sub.add_parser("list", help="show the profiles").set_defaults(func=cmd_list)
|
|
292
|
+
|
|
293
|
+
use = sub.add_parser("use", help="set the active profile")
|
|
294
|
+
use.add_argument("name")
|
|
295
|
+
use.set_defaults(func=cmd_use)
|
|
296
|
+
|
|
297
|
+
remove = sub.add_parser("remove", help="delete a profile")
|
|
298
|
+
remove.add_argument("name")
|
|
299
|
+
remove.set_defaults(func=cmd_remove)
|
|
300
|
+
|
|
301
|
+
codex = sub.add_parser("codex", help="sync a profile into the Codex catalog")
|
|
302
|
+
codex.add_argument("--profile", help="use this profile for one run")
|
|
303
|
+
codex.add_argument("--dry-run", action="store_true", help="show the difference only")
|
|
304
|
+
codex.add_argument("--init", action="store_true", help="wire Codex to the endpoint")
|
|
305
|
+
codex.add_argument(
|
|
306
|
+
"--reset", action="store_true", help="remove everything xSync wrote"
|
|
307
|
+
)
|
|
308
|
+
codex.add_argument("--force", action="store_true", help="reset without a state file")
|
|
309
|
+
codex.set_defaults(func=cmd_codex)
|
|
310
|
+
|
|
311
|
+
return parser
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main(argv: list[str] | None = None) -> int:
|
|
315
|
+
parser = build_parser()
|
|
316
|
+
args = parser.parse_args(argv)
|
|
317
|
+
try:
|
|
318
|
+
return args.func(args)
|
|
319
|
+
except (ProfileError, ConfigError) as error:
|
|
320
|
+
print(f"error: {error}", file=sys.stderr)
|
|
321
|
+
return EXIT_ERROR
|
|
File without changes
|
xsync_cli/core/atomic.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The safe file write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_json_atomic(path: Path, payload: dict[str, Any], keep_backup: bool = True) -> None:
|
|
12
|
+
"""Write JSON without a partial file.
|
|
13
|
+
|
|
14
|
+
The function writes a temporary file in the same directory, confirms
|
|
15
|
+
that the file holds valid JSON, and then replaces the target. It
|
|
16
|
+
keeps one backup with the suffix `.bak`.
|
|
17
|
+
"""
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
20
|
+
|
|
21
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False)
|
|
22
|
+
json.loads(text)
|
|
23
|
+
|
|
24
|
+
temporary.write_text(text, encoding="utf-8")
|
|
25
|
+
if keep_backup and path.exists():
|
|
26
|
+
backup = path.with_name(path.name + ".bak")
|
|
27
|
+
backup.write_bytes(path.read_bytes())
|
|
28
|
+
os.replace(temporary, path)
|
xsync_cli/core/diff.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The difference between two catalogs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class CatalogDiff:
|
|
11
|
+
"""The report of one sync."""
|
|
12
|
+
|
|
13
|
+
added: list[str]
|
|
14
|
+
removed: list[str]
|
|
15
|
+
changed: list[tuple[str, list[str]]]
|
|
16
|
+
unchanged: list[str]
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def is_empty(self) -> bool:
|
|
20
|
+
"""True when the catalog does not change."""
|
|
21
|
+
return not (self.added or self.removed or self.changed)
|
|
22
|
+
|
|
23
|
+
def render(self, limit: int = 8) -> str:
|
|
24
|
+
"""The report as text."""
|
|
25
|
+
lines: list[str] = []
|
|
26
|
+
if self.added:
|
|
27
|
+
lines.append(f"+ {len(self.added)} added {self._sample(self.added, limit)}")
|
|
28
|
+
if self.removed:
|
|
29
|
+
lines.append(
|
|
30
|
+
f"- {len(self.removed)} removed {self._sample(self.removed, limit)}"
|
|
31
|
+
)
|
|
32
|
+
if self.changed:
|
|
33
|
+
names = [
|
|
34
|
+
f"{slug} ({self._fields(fields)})" for slug, fields in self.changed
|
|
35
|
+
]
|
|
36
|
+
lines.append(f"~ {len(self.changed)} changed {self._sample(names, limit)}")
|
|
37
|
+
lines.append(f" {len(self.unchanged)} unchanged")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _fields(fields: list[str], limit: int = 3) -> str:
|
|
42
|
+
"""The field names of one changed model, cut short."""
|
|
43
|
+
head = ", ".join(fields[:limit])
|
|
44
|
+
return head if len(fields) <= limit else f"{head}, +{len(fields) - limit} more"
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _sample(items: list[str], limit: int) -> str:
|
|
48
|
+
head = ", ".join(items[:limit])
|
|
49
|
+
return head if len(items) <= limit else f"{head}, … (+{len(items) - limit})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _by_slug(catalog: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
|
53
|
+
if not catalog:
|
|
54
|
+
return {}
|
|
55
|
+
return {entry["slug"]: entry for entry in catalog.get("models", []) if "slug" in entry}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def diff_catalogs(old: dict[str, Any] | None, new: dict[str, Any]) -> CatalogDiff:
|
|
59
|
+
"""Compare two catalogs by slug."""
|
|
60
|
+
old_map = _by_slug(old)
|
|
61
|
+
new_map = _by_slug(new)
|
|
62
|
+
|
|
63
|
+
added = [slug for slug in new_map if slug not in old_map]
|
|
64
|
+
removed = [slug for slug in old_map if slug not in new_map]
|
|
65
|
+
changed: list[tuple[str, list[str]]] = []
|
|
66
|
+
unchanged: list[str] = []
|
|
67
|
+
|
|
68
|
+
for slug, entry in new_map.items():
|
|
69
|
+
if slug not in old_map:
|
|
70
|
+
continue
|
|
71
|
+
fields = sorted(
|
|
72
|
+
key
|
|
73
|
+
for key in set(entry) | set(old_map[slug])
|
|
74
|
+
if entry.get(key) != old_map[slug].get(key)
|
|
75
|
+
)
|
|
76
|
+
if fields:
|
|
77
|
+
changed.append((slug, fields))
|
|
78
|
+
else:
|
|
79
|
+
unchanged.append(slug)
|
|
80
|
+
|
|
81
|
+
return CatalogDiff(added=added, removed=removed, changed=changed, unchanged=unchanged)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The include and exclude glob rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from fnmatch import fnmatch
|
|
7
|
+
|
|
8
|
+
from xsync_cli.core.model import Model
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _matches_any(slug: str, patterns: Sequence[str]) -> bool:
|
|
12
|
+
lowered = slug.lower()
|
|
13
|
+
return any(fnmatch(lowered, pattern.lower()) for pattern in patterns)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def apply_filters(
|
|
17
|
+
models: Sequence[Model],
|
|
18
|
+
include: Sequence[str],
|
|
19
|
+
exclude: Sequence[str],
|
|
20
|
+
) -> list[Model]:
|
|
21
|
+
"""Keep the models that the rules allow.
|
|
22
|
+
|
|
23
|
+
An empty include list keeps every model. The exclude list always
|
|
24
|
+
wins over the include list. The match ignores the letter case.
|
|
25
|
+
"""
|
|
26
|
+
kept: list[Model] = []
|
|
27
|
+
for candidate in models:
|
|
28
|
+
if include and not _matches_any(candidate.slug, include):
|
|
29
|
+
continue
|
|
30
|
+
if exclude and _matches_any(candidate.slug, exclude):
|
|
31
|
+
continue
|
|
32
|
+
kept.append(candidate)
|
|
33
|
+
return kept
|
xsync_cli/core/model.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The provider-neutral model record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Model:
|
|
10
|
+
"""One model, as an endpoint reports it.
|
|
11
|
+
|
|
12
|
+
The fields hold facts about the model. They do not hold harness
|
|
13
|
+
settings. An endpoint that reports no capabilities gives None or
|
|
14
|
+
False. The adapter rules then supply a value.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
slug: str
|
|
18
|
+
context_window: int | None
|
|
19
|
+
max_output: int | None
|
|
20
|
+
vision: bool
|
|
21
|
+
reasoning: bool
|
|
22
|
+
tools: bool
|
|
23
|
+
search: bool
|
|
24
|
+
owned_by: str | None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def prefix(self) -> str:
|
|
28
|
+
"""The first slug segment, or an empty string for a flat slug."""
|
|
29
|
+
head, sep, _ = self.slug.partition("/")
|
|
30
|
+
return head if sep else ""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def leaf_name(self) -> str:
|
|
34
|
+
"""The last slug segment."""
|
|
35
|
+
return self.slug.rsplit("/", 1)[-1]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The profile store.
|
|
2
|
+
|
|
3
|
+
A profile holds the connection data for one OpenAI-compatible endpoint.
|
|
4
|
+
The store keeps the profiles in a TOML file with mode 0600.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import tomllib
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import tomlkit
|
|
16
|
+
|
|
17
|
+
WIRE_APIS = ("chat", "responses")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProfileError(Exception):
|
|
21
|
+
"""The profile data is wrong, or the profile does not exist."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_store_path() -> Path:
|
|
25
|
+
"""The default path of the profile file."""
|
|
26
|
+
return Path.home() / ".config" / "xsync" / "profiles.toml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Profile:
|
|
31
|
+
"""One endpoint, with its filters."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
base_url: str
|
|
35
|
+
api_key: str | None
|
|
36
|
+
api_key_env: str | None
|
|
37
|
+
wire_api: str
|
|
38
|
+
include: list[str] = field(default_factory=list)
|
|
39
|
+
exclude: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if self.api_key and self.api_key_env:
|
|
43
|
+
raise ProfileError(
|
|
44
|
+
f"profile {self.name!r} sets api_key and api_key_env. "
|
|
45
|
+
"Set one, not both."
|
|
46
|
+
)
|
|
47
|
+
if self.wire_api not in WIRE_APIS:
|
|
48
|
+
raise ProfileError(
|
|
49
|
+
f"profile {self.name!r} has wire_api {self.wire_api!r}. "
|
|
50
|
+
f"Use one of {', '.join(WIRE_APIS)}."
|
|
51
|
+
)
|
|
52
|
+
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
|
|
53
|
+
|
|
54
|
+
def resolve_key(self, env: Mapping[str, str]) -> str | None:
|
|
55
|
+
"""The API key, or None when the profile has no key."""
|
|
56
|
+
if self.api_key:
|
|
57
|
+
return self.api_key
|
|
58
|
+
if self.api_key_env:
|
|
59
|
+
value = env.get(self.api_key_env)
|
|
60
|
+
if not value:
|
|
61
|
+
raise ProfileError(
|
|
62
|
+
f"profile {self.name!r} needs the variable "
|
|
63
|
+
f"{self.api_key_env}. The variable is empty or absent."
|
|
64
|
+
)
|
|
65
|
+
return value
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def to_table(self) -> dict[str, object]:
|
|
69
|
+
"""The profile as plain data, without the name."""
|
|
70
|
+
table: dict[str, object] = {"base_url": self.base_url, "wire_api": self.wire_api}
|
|
71
|
+
if self.api_key:
|
|
72
|
+
table["api_key"] = self.api_key
|
|
73
|
+
if self.api_key_env:
|
|
74
|
+
table["api_key_env"] = self.api_key_env
|
|
75
|
+
if self.include:
|
|
76
|
+
table["include"] = list(self.include)
|
|
77
|
+
if self.exclude:
|
|
78
|
+
table["exclude"] = list(self.exclude)
|
|
79
|
+
return table
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProfileStore:
|
|
83
|
+
"""The reader and the writer of the profile file."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, path: Path) -> None:
|
|
86
|
+
self.path = path
|
|
87
|
+
self.active: str | None = None
|
|
88
|
+
self._profiles: dict[str, Profile] = {}
|
|
89
|
+
|
|
90
|
+
def load(self) -> None:
|
|
91
|
+
"""Read the file. An absent file gives an empty store."""
|
|
92
|
+
if not self.path.exists():
|
|
93
|
+
self.active = None
|
|
94
|
+
self._profiles = {}
|
|
95
|
+
return
|
|
96
|
+
data = tomllib.loads(self.path.read_text(encoding="utf-8"))
|
|
97
|
+
self.active = data.get("active")
|
|
98
|
+
self._profiles = {}
|
|
99
|
+
for name, table in (data.get("profiles") or {}).items():
|
|
100
|
+
self._profiles[name] = Profile(
|
|
101
|
+
name=name,
|
|
102
|
+
base_url=str(table.get("base_url", "")),
|
|
103
|
+
api_key=table.get("api_key"),
|
|
104
|
+
api_key_env=table.get("api_key_env"),
|
|
105
|
+
wire_api=str(table.get("wire_api", "chat")),
|
|
106
|
+
include=list(table.get("include", [])),
|
|
107
|
+
exclude=list(table.get("exclude", [])),
|
|
108
|
+
)
|
|
109
|
+
if self.active not in self._profiles:
|
|
110
|
+
self.active = next(iter(self._profiles), None)
|
|
111
|
+
|
|
112
|
+
def save(self) -> None:
|
|
113
|
+
"""Write the file with mode 0600."""
|
|
114
|
+
document = tomlkit.document()
|
|
115
|
+
if self.active:
|
|
116
|
+
document["active"] = self.active
|
|
117
|
+
profiles = tomlkit.table(is_super_table=True)
|
|
118
|
+
for name, profile in self._profiles.items():
|
|
119
|
+
entry = tomlkit.table()
|
|
120
|
+
for key, value in profile.to_table().items():
|
|
121
|
+
entry[key] = value
|
|
122
|
+
profiles[name] = entry
|
|
123
|
+
document["profiles"] = profiles
|
|
124
|
+
|
|
125
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
temporary = self.path.with_suffix(".tmp")
|
|
127
|
+
temporary.write_text(tomlkit.dumps(document), encoding="utf-8")
|
|
128
|
+
os.chmod(temporary, 0o600)
|
|
129
|
+
os.replace(temporary, self.path)
|
|
130
|
+
|
|
131
|
+
def add(self, profile: Profile) -> None:
|
|
132
|
+
"""Add a profile. The first profile becomes the active profile."""
|
|
133
|
+
self._profiles[profile.name] = profile
|
|
134
|
+
if self.active is None:
|
|
135
|
+
self.active = profile.name
|
|
136
|
+
|
|
137
|
+
def get(self, name: str) -> Profile:
|
|
138
|
+
"""One profile by name."""
|
|
139
|
+
try:
|
|
140
|
+
return self._profiles[name]
|
|
141
|
+
except KeyError:
|
|
142
|
+
raise ProfileError(f"unknown profile {name!r}") from None
|
|
143
|
+
|
|
144
|
+
def remove(self, name: str) -> None:
|
|
145
|
+
"""Delete a profile. Move the active name when necessary."""
|
|
146
|
+
if name not in self._profiles:
|
|
147
|
+
raise ProfileError(f"unknown profile {name!r}")
|
|
148
|
+
del self._profiles[name]
|
|
149
|
+
if self.active == name:
|
|
150
|
+
self.active = next(iter(self._profiles), None)
|
|
151
|
+
|
|
152
|
+
def names(self) -> list[str]:
|
|
153
|
+
"""The profile names, in insertion order."""
|
|
154
|
+
return list(self._profiles)
|
|
155
|
+
|
|
156
|
+
def set_active(self, name: str) -> None:
|
|
157
|
+
"""Make one profile the active profile."""
|
|
158
|
+
if name not in self._profiles:
|
|
159
|
+
raise ProfileError(f"unknown profile {name!r}")
|
|
160
|
+
self.active = name
|
|
161
|
+
|
|
162
|
+
def active_profile(self) -> Profile:
|
|
163
|
+
"""The active profile."""
|
|
164
|
+
if not self.active:
|
|
165
|
+
raise ProfileError("no profile exists. Run `xsync setup` first.")
|
|
166
|
+
return self.get(self.active)
|
|
File without changes
|