xsync-cli 0.1.2__tar.gz → 0.1.4__tar.gz
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-0.1.2 → xsync_cli-0.1.4}/PKG-INFO +44 -2
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/README.md +43 -1
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/docs/superpowers/specs/2026-09-13-xsync-design.md +72 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/pyproject.toml +1 -1
- xsync_cli-0.1.4/src/xsync_cli/adapters/claude.py +92 -0
- xsync_cli-0.1.4/src/xsync_cli/adapters/claude_config.py +206 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/codex.py +18 -7
- xsync_cli-0.1.4/src/xsync_cli/adapters/rules/claude.toml +51 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/rules/codex.toml +11 -4
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/cli.py +237 -2
- xsync_cli-0.1.4/tests/test_claude_config.py +191 -0
- xsync_cli-0.1.4/tests/test_claude_render.py +86 -0
- xsync_cli-0.1.4/tests/test_cli_claude.py +131 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_cli_profiles.py +24 -4
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_codex_render.py +62 -1
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_term.py +1 -1
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/uv.lock +1 -1
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/.gitignore +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/LICENSE +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/docs/verification-2026-09-13.md +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/codex_config.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/codex_wiring.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/rules/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/atomic.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/diff.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/filters.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/model.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/profiles.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/term.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/sources/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/sources/openai_compat.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/fixtures/__init__.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/fixtures/ninerouter_models.json +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/fixtures/plain_openai_models.json +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_atomic.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_cli_codex.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_codex_config.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_codex_wiring.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_diff.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_filters.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_model.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_openai_compat.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_package_data.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_profiles.py +0 -0
- {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tools/extract_prompts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: xsync-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -79,10 +79,52 @@ Run this command again when the endpoint changes.
|
|
|
79
79
|
| `xsync codex --reset` | Remove everything that xSync wrote. |
|
|
80
80
|
| `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
|
|
81
81
|
| `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
|
|
82
|
+
| `xsync claude` | Sync the active profile into the Claude Code model picker. |
|
|
83
|
+
| `xsync claude --init` | Point Claude Code at the endpoint of the profile. |
|
|
84
|
+
| `xsync claude --dry-run` | Show the difference. Write nothing. |
|
|
85
|
+
| `xsync claude --reset` | Remove everything that xSync wrote. |
|
|
86
|
+
| `xsync help` | Show the help. `xsync help codex` explains one command. |
|
|
82
87
|
|
|
83
88
|
Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
|
|
84
89
|
answer.
|
|
85
90
|
|
|
91
|
+
## Claude Code
|
|
92
|
+
|
|
93
|
+
xsync claude --init
|
|
94
|
+
xsync claude
|
|
95
|
+
|
|
96
|
+
Claude Code holds no catalog file. It reads the rows of the `/model`
|
|
97
|
+
picker from `modelPicker.options` in `~/.claude/settings.json`, and it reads
|
|
98
|
+
the endpoint from the `env` block of the same file. `xsync claude` writes
|
|
99
|
+
those two keys and nothing else.
|
|
100
|
+
|
|
101
|
+
The endpoint must answer the Anthropic API at `POST /v1/messages`. An
|
|
102
|
+
endpoint that answers only the OpenAI API works with Codex, not with Claude
|
|
103
|
+
Code.
|
|
104
|
+
|
|
105
|
+
### The map to a known model
|
|
106
|
+
|
|
107
|
+
Claude Code refuses a model that it does not know:
|
|
108
|
+
|
|
109
|
+
"cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
|
|
110
|
+
catalog; update Claude Code, or map it with behavesAs on a modelPicker row
|
|
111
|
+
|
|
112
|
+
Therefore each row carries `behavesAs`: the id of a model that Claude Code
|
|
113
|
+
knows. The prompt profile, the capability defaults, and the effort defaults
|
|
114
|
+
of that model then apply. The model id that Claude Code sends does not
|
|
115
|
+
change.
|
|
116
|
+
|
|
117
|
+
A model name that already holds a known model id, such as
|
|
118
|
+
`ed3n/claude-sonnet-5`, gets no `behavesAs`. It keeps its native handling.
|
|
119
|
+
|
|
120
|
+
Edit `adapters/rules/claude.toml` to change a map.
|
|
121
|
+
|
|
122
|
+
### The built-in models stay
|
|
123
|
+
|
|
124
|
+
xSync sets `replaceBuiltInOptions` to `false`. The built-in models stay at
|
|
125
|
+
the top of the picker, and the router models come after them. A router that
|
|
126
|
+
stops therefore leaves a working picker.
|
|
127
|
+
|
|
86
128
|
## Switch between endpoints
|
|
87
129
|
|
|
88
130
|
Codex holds one model provider. Therefore one profile is active at a time.
|
|
@@ -128,7 +170,7 @@ A glob pattern selects a model by name. The sign `*` means any characters.
|
|
|
128
170
|
| Rule | Effect |
|
|
129
171
|
|---|---|
|
|
130
172
|
| `include = []` | Keep every model. This is the default. |
|
|
131
|
-
| `include = ["
|
|
173
|
+
| `include = ["openai/*", "anthropic/*"]` | Keep only the models of those two providers. |
|
|
132
174
|
| `exclude = ["*embedding*"]` | Drop every model with `embedding` in the name. |
|
|
133
175
|
| `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
|
|
134
176
|
|
|
@@ -67,10 +67,52 @@ Run this command again when the endpoint changes.
|
|
|
67
67
|
| `xsync codex --reset` | Remove everything that xSync wrote. |
|
|
68
68
|
| `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
|
|
69
69
|
| `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
|
|
70
|
+
| `xsync claude` | Sync the active profile into the Claude Code model picker. |
|
|
71
|
+
| `xsync claude --init` | Point Claude Code at the endpoint of the profile. |
|
|
72
|
+
| `xsync claude --dry-run` | Show the difference. Write nothing. |
|
|
73
|
+
| `xsync claude --reset` | Remove everything that xSync wrote. |
|
|
74
|
+
| `xsync help` | Show the help. `xsync help codex` explains one command. |
|
|
70
75
|
|
|
71
76
|
Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
|
|
72
77
|
answer.
|
|
73
78
|
|
|
79
|
+
## Claude Code
|
|
80
|
+
|
|
81
|
+
xsync claude --init
|
|
82
|
+
xsync claude
|
|
83
|
+
|
|
84
|
+
Claude Code holds no catalog file. It reads the rows of the `/model`
|
|
85
|
+
picker from `modelPicker.options` in `~/.claude/settings.json`, and it reads
|
|
86
|
+
the endpoint from the `env` block of the same file. `xsync claude` writes
|
|
87
|
+
those two keys and nothing else.
|
|
88
|
+
|
|
89
|
+
The endpoint must answer the Anthropic API at `POST /v1/messages`. An
|
|
90
|
+
endpoint that answers only the OpenAI API works with Codex, not with Claude
|
|
91
|
+
Code.
|
|
92
|
+
|
|
93
|
+
### The map to a known model
|
|
94
|
+
|
|
95
|
+
Claude Code refuses a model that it does not know:
|
|
96
|
+
|
|
97
|
+
"cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
|
|
98
|
+
catalog; update Claude Code, or map it with behavesAs on a modelPicker row
|
|
99
|
+
|
|
100
|
+
Therefore each row carries `behavesAs`: the id of a model that Claude Code
|
|
101
|
+
knows. The prompt profile, the capability defaults, and the effort defaults
|
|
102
|
+
of that model then apply. The model id that Claude Code sends does not
|
|
103
|
+
change.
|
|
104
|
+
|
|
105
|
+
A model name that already holds a known model id, such as
|
|
106
|
+
`ed3n/claude-sonnet-5`, gets no `behavesAs`. It keeps its native handling.
|
|
107
|
+
|
|
108
|
+
Edit `adapters/rules/claude.toml` to change a map.
|
|
109
|
+
|
|
110
|
+
### The built-in models stay
|
|
111
|
+
|
|
112
|
+
xSync sets `replaceBuiltInOptions` to `false`. The built-in models stay at
|
|
113
|
+
the top of the picker, and the router models come after them. A router that
|
|
114
|
+
stops therefore leaves a working picker.
|
|
115
|
+
|
|
74
116
|
## Switch between endpoints
|
|
75
117
|
|
|
76
118
|
Codex holds one model provider. Therefore one profile is active at a time.
|
|
@@ -116,7 +158,7 @@ A glob pattern selects a model by name. The sign `*` means any characters.
|
|
|
116
158
|
| Rule | Effect |
|
|
117
159
|
|---|---|
|
|
118
160
|
| `include = []` | Keep every model. This is the default. |
|
|
119
|
-
| `include = ["
|
|
161
|
+
| `include = ["openai/*", "anthropic/*"]` | Keep only the models of those two providers. |
|
|
120
162
|
| `exclude = ["*embedding*"]` | Drop every model with `embedding` in the name. |
|
|
121
163
|
| `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
|
|
122
164
|
|
|
@@ -425,3 +425,75 @@ works against a second endpoint.
|
|
|
425
425
|
The upload needs a PyPI API token. The token belongs to the user. The token
|
|
426
426
|
never enters the repository. The user supplies it through the environment
|
|
427
427
|
variable `UV_PUBLISH_TOKEN` or through `~/.pypirc`.
|
|
428
|
+
|
|
429
|
+
## 18. The Claude Code adapter
|
|
430
|
+
|
|
431
|
+
Date added: 2026-09-13
|
|
432
|
+
|
|
433
|
+
### 18.1 What Claude Code offers
|
|
434
|
+
|
|
435
|
+
Claude Code holds no catalog file like Codex. It reads two things from
|
|
436
|
+
`~/.claude/settings.json`:
|
|
437
|
+
|
|
438
|
+
| Key | Purpose |
|
|
439
|
+
|---|---|
|
|
440
|
+
| `modelPicker.options` | The rows of the `/model` picker, in order. |
|
|
441
|
+
| `modelPicker.replaceBuiltInOptions` | True hides the built-in models. |
|
|
442
|
+
| `env` | The variables that point Claude Code at an endpoint. |
|
|
443
|
+
|
|
444
|
+
One picker row holds these fields:
|
|
445
|
+
|
|
446
|
+
| Field | Meaning |
|
|
447
|
+
|---|---|
|
|
448
|
+
| `model` | The model id, taken word for word. |
|
|
449
|
+
| `label` | The row title. |
|
|
450
|
+
| `description` | The row subtitle. |
|
|
451
|
+
| `behavesAs` | The id of a model that this version knows. Its prompt profile, its capability defaults, and its effort defaults then apply. |
|
|
452
|
+
|
|
453
|
+
Claude Code refuses a model that it does not know:
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
"cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
|
|
457
|
+
catalog; update Claude Code, or map it with behavesAs on a modelPicker row
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
A model name that holds a known model id, such as
|
|
461
|
+
`ed3n/claude-sonnet-5`, needs no `behavesAs`.
|
|
462
|
+
|
|
463
|
+
### 18.2 The proof
|
|
464
|
+
|
|
465
|
+
Each fact below comes from a test on this machine, not from a document.
|
|
466
|
+
|
|
467
|
+
1. 9router answers the Anthropic API at `POST /v1/messages`.
|
|
468
|
+
2. `claude -p "say ok" --model ed3n/claude-sonnet-5` answered `ok`.
|
|
469
|
+
3. The same command with `cmc/deepseek/deepseek-v4-pro` failed, until a
|
|
470
|
+
picker row gave it `behavesAs = "claude-opus-4-8"`. It then answered
|
|
471
|
+
`ok`.
|
|
472
|
+
4. The variables in the `env` block of `settings.json` are enough. The
|
|
473
|
+
shell needs no variable.
|
|
474
|
+
|
|
475
|
+
### 18.3 The design
|
|
476
|
+
|
|
477
|
+
- `xsync claude` writes `modelPicker.options` into `settings.json`.
|
|
478
|
+
- `replaceBuiltInOptions` stays `false`. The built-in models stay in the
|
|
479
|
+
picker. The router models come after them. A router that stops
|
|
480
|
+
therefore leaves a working picker.
|
|
481
|
+
- A rules file `adapters/rules/claude.toml` gives `behavesAs`. It uses
|
|
482
|
+
the same layers as the Codex rules: defaults, prefix, name, slug.
|
|
483
|
+
- A model whose name holds a known Claude model id gets no `behavesAs`.
|
|
484
|
+
It keeps its native handling.
|
|
485
|
+
- `xsync claude --init` writes the `env` block with
|
|
486
|
+
`ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`.
|
|
487
|
+
- `xsync claude --reset` removes only what the state file records.
|
|
488
|
+
|
|
489
|
+
### 18.4 Safety
|
|
490
|
+
|
|
491
|
+
The file `settings.json` holds the hooks and the settings of the user. It
|
|
492
|
+
is large. Therefore:
|
|
493
|
+
|
|
494
|
+
- xSync changes the key `modelPicker` only, and the key `env` only with
|
|
495
|
+
`--init`.
|
|
496
|
+
- The write is atomic. xSync keeps one backup.
|
|
497
|
+
- xSync hashes the file before the edit and again before the replace. On
|
|
498
|
+
a difference it stops.
|
|
499
|
+
- The state file `~/.claude/.xsync-state.json` records every change.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Claude Code holds no catalog file. It reads the rows of the `/model`
|
|
4
|
+
picker from `modelPicker.options` in `settings.json`. This module makes
|
|
5
|
+
those rows. It knows nothing about HTTP.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from xsync_cli.core.model import Model
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Rules:
|
|
22
|
+
"""The four rule layers, and the list of native model ids."""
|
|
23
|
+
|
|
24
|
+
defaults: dict[str, Any]
|
|
25
|
+
prefixes: dict[str, dict[str, Any]]
|
|
26
|
+
names: dict[str, dict[str, Any]]
|
|
27
|
+
slugs: dict[str, dict[str, Any]]
|
|
28
|
+
|
|
29
|
+
def for_model(self, model: Model) -> dict[str, Any]:
|
|
30
|
+
"""The merged rule values for one model."""
|
|
31
|
+
merged: dict[str, Any] = dict(self.defaults)
|
|
32
|
+
merged.update(self.prefixes.get(model.prefix.lower(), {}))
|
|
33
|
+
merged.update(self.names.get(model.leaf_name.lower(), {}))
|
|
34
|
+
merged.update(self.slugs.get(model.slug, {}))
|
|
35
|
+
return merged
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def native_ids(self) -> list[str]:
|
|
39
|
+
"""The model ids that Claude Code knows."""
|
|
40
|
+
return [str(item).lower() for item in self.defaults.get("native_ids", [])]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_rules(path: Path | None = None) -> Rules:
|
|
44
|
+
"""Read the rules file. The default file is package data."""
|
|
45
|
+
if path is None:
|
|
46
|
+
text = files("xsync_cli.adapters.rules").joinpath("claude.toml").read_text(
|
|
47
|
+
encoding="utf-8"
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
text = path.read_text(encoding="utf-8")
|
|
51
|
+
data = tomllib.loads(text)
|
|
52
|
+
return Rules(
|
|
53
|
+
defaults=dict(data.get("defaults", {})),
|
|
54
|
+
prefixes={k.lower(): dict(v) for k, v in (data.get("prefix") or {}).items()},
|
|
55
|
+
names={k.lower(): dict(v) for k, v in (data.get("name") or {}).items()},
|
|
56
|
+
slugs={k: dict(v) for k, v in (data.get("slug") or {}).items()},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_native(slug: str, rules: Rules) -> bool:
|
|
61
|
+
"""True when Claude Code already knows how to drive this model.
|
|
62
|
+
|
|
63
|
+
Claude Code matches a known model id inside the name of the model.
|
|
64
|
+
Therefore `ed3n/claude-sonnet-5` needs no map.
|
|
65
|
+
"""
|
|
66
|
+
lowered = slug.lower()
|
|
67
|
+
return any(known in lowered for known in rules.native_ids)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _label(model: Model) -> str:
|
|
71
|
+
words = model.leaf_name.replace("_", "-").split("-")
|
|
72
|
+
return " ".join(word[:1].upper() + word[1:] for word in words if word)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_rows(models: Sequence[Model], rules: Rules) -> list[dict[str, Any]]:
|
|
76
|
+
"""One picker row for each model.
|
|
77
|
+
|
|
78
|
+
A row holds `behavesAs` only when Claude Code does not know the
|
|
79
|
+
model. Claude Code refuses such a model without that field.
|
|
80
|
+
"""
|
|
81
|
+
rows: list[dict[str, Any]] = []
|
|
82
|
+
for model in models:
|
|
83
|
+
values = rules.for_model(model)
|
|
84
|
+
row: dict[str, Any] = {
|
|
85
|
+
"model": model.slug,
|
|
86
|
+
"label": values.get("label") or _label(model),
|
|
87
|
+
"description": str(values["description_template"]).format(slug=model.slug),
|
|
88
|
+
}
|
|
89
|
+
if not is_native(model.slug, rules):
|
|
90
|
+
row["behavesAs"] = values["behaves_as"]
|
|
91
|
+
rows.append(row)
|
|
92
|
+
return rows
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The reader and the writer of the Claude Code settings.
|
|
2
|
+
|
|
3
|
+
Claude Code keeps the rows of the `/model` picker in
|
|
4
|
+
`modelPicker.options`. It keeps the endpoint in the `env` block. This
|
|
5
|
+
module changes those two keys and nothing else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from xsync_cli.core.profiles import Profile
|
|
19
|
+
|
|
20
|
+
STATE_FILENAME = ".xsync-state.json"
|
|
21
|
+
STATE_VERSION = 1
|
|
22
|
+
BASE_URL_KEY = "ANTHROPIC_BASE_URL"
|
|
23
|
+
TOKEN_KEY = "ANTHROPIC_AUTH_TOKEN"
|
|
24
|
+
PICKER_KEY = "modelPicker"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClaudeConfigError(Exception):
|
|
28
|
+
"""The settings file cannot be read or written."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_claude_home() -> Path:
|
|
32
|
+
"""The Claude Code home directory."""
|
|
33
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def settings_path_for(claude_home: Path) -> Path:
|
|
37
|
+
"""The settings file of that home."""
|
|
38
|
+
return claude_home / "settings.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def file_sha256(path: Path) -> str:
|
|
42
|
+
"""The hash of a file. An absent file gives an empty string."""
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return ""
|
|
45
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_settings(path: Path) -> dict[str, Any]:
|
|
49
|
+
"""Read the settings. An absent file gives an empty mapping."""
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except json.JSONDecodeError as error:
|
|
55
|
+
raise ClaudeConfigError(f"{path} is not valid JSON: {error}") from None
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
raise ClaudeConfigError(f"{path} does not hold a JSON object.")
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def base_url_of(settings: dict[str, Any]) -> str | None:
|
|
62
|
+
"""The endpoint that Claude Code uses."""
|
|
63
|
+
value = (settings.get("env") or {}).get(BASE_URL_KEY)
|
|
64
|
+
return str(value).rstrip("/") if value else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_endpoint_match(settings: dict[str, Any], profile: Profile) -> str | None:
|
|
68
|
+
"""Confirm that Claude Code talks to the endpoint of the profile."""
|
|
69
|
+
current = base_url_of(settings)
|
|
70
|
+
if current is None:
|
|
71
|
+
return (
|
|
72
|
+
"Claude Code has no endpoint.\n"
|
|
73
|
+
f"run: xsync claude --profile {profile.name} --init"
|
|
74
|
+
)
|
|
75
|
+
if current != profile.base_url:
|
|
76
|
+
return (
|
|
77
|
+
f'the rows would come from "{profile.name}" ({profile.base_url})\n'
|
|
78
|
+
f"but Claude Code talks to {current}\n"
|
|
79
|
+
f"run: xsync claude --profile {profile.name} --init"
|
|
80
|
+
)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class State:
|
|
86
|
+
"""The record of what xSync wrote into the settings."""
|
|
87
|
+
|
|
88
|
+
profile: str
|
|
89
|
+
keys_written: list[str] = field(default_factory=list)
|
|
90
|
+
settings_sha256: str = ""
|
|
91
|
+
written_at: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _now() -> str:
|
|
95
|
+
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_atomic(path: Path, data: dict[str, Any], before: str) -> None:
|
|
99
|
+
"""Write the settings only when the file did not change."""
|
|
100
|
+
if file_sha256(path) != before:
|
|
101
|
+
raise ClaudeConfigError(
|
|
102
|
+
f"{path} changed during the edit. Close Claude Code and try again."
|
|
103
|
+
)
|
|
104
|
+
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
105
|
+
json.loads(text)
|
|
106
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
107
|
+
temporary.write_text(text, encoding="utf-8")
|
|
108
|
+
if path.exists():
|
|
109
|
+
path.with_name(path.name + ".bak").write_bytes(path.read_bytes())
|
|
110
|
+
os.replace(temporary, path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def init_settings(path: Path, profile: Profile, api_key: str | None) -> State:
|
|
114
|
+
"""Point Claude Code at the endpoint of the profile."""
|
|
115
|
+
before = file_sha256(path)
|
|
116
|
+
settings = read_settings(path)
|
|
117
|
+
|
|
118
|
+
environment = dict(settings.get("env") or {})
|
|
119
|
+
environment[BASE_URL_KEY] = profile.base_url
|
|
120
|
+
keys = [f"env.{BASE_URL_KEY}"]
|
|
121
|
+
if api_key:
|
|
122
|
+
environment[TOKEN_KEY] = api_key
|
|
123
|
+
keys.append(f"env.{TOKEN_KEY}")
|
|
124
|
+
settings["env"] = environment
|
|
125
|
+
|
|
126
|
+
_write_atomic(path, settings, before)
|
|
127
|
+
return State(
|
|
128
|
+
profile=profile.name,
|
|
129
|
+
keys_written=keys,
|
|
130
|
+
settings_sha256=file_sha256(path),
|
|
131
|
+
written_at=_now(),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def write_picker(path: Path, rows: list[dict[str, Any]]) -> None:
|
|
136
|
+
"""Write the rows of the `/model` picker.
|
|
137
|
+
|
|
138
|
+
The value `replaceBuiltInOptions` stays false. The built-in models
|
|
139
|
+
stay in the picker, and the rows come after them. A router that
|
|
140
|
+
stops therefore leaves a working picker.
|
|
141
|
+
"""
|
|
142
|
+
before = file_sha256(path)
|
|
143
|
+
settings = read_settings(path)
|
|
144
|
+
settings[PICKER_KEY] = {"options": rows, "replaceBuiltInOptions": False}
|
|
145
|
+
_write_atomic(path, settings, before)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def current_rows(settings: dict[str, Any]) -> list[dict[str, Any]]:
|
|
149
|
+
"""The rows that the settings hold now."""
|
|
150
|
+
picker = settings.get(PICKER_KEY) or {}
|
|
151
|
+
rows = picker.get("options")
|
|
152
|
+
return list(rows) if isinstance(rows, list) else []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def reset_settings(path: Path, state: State) -> list[str]:
|
|
156
|
+
"""Remove every key that the state records."""
|
|
157
|
+
before = file_sha256(path)
|
|
158
|
+
settings = read_settings(path)
|
|
159
|
+
removed: list[str] = []
|
|
160
|
+
|
|
161
|
+
for key in state.keys_written:
|
|
162
|
+
parent, _, child = key.partition(".")
|
|
163
|
+
if child:
|
|
164
|
+
table = settings.get(parent)
|
|
165
|
+
if isinstance(table, dict) and child in table:
|
|
166
|
+
del table[child]
|
|
167
|
+
removed.append(key)
|
|
168
|
+
if not table:
|
|
169
|
+
del settings[parent]
|
|
170
|
+
elif parent in settings:
|
|
171
|
+
del settings[parent]
|
|
172
|
+
removed.append(parent)
|
|
173
|
+
|
|
174
|
+
_write_atomic(path, settings, before)
|
|
175
|
+
return removed
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def read_state(path: Path) -> State | None:
|
|
179
|
+
"""Read the state file. An absent file gives None."""
|
|
180
|
+
if not path.exists():
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
184
|
+
except json.JSONDecodeError as error:
|
|
185
|
+
raise ClaudeConfigError(f"{path} is not valid JSON: {error}") from None
|
|
186
|
+
entry = data.get("claude") or {}
|
|
187
|
+
return State(
|
|
188
|
+
profile=entry.get("profile", ""),
|
|
189
|
+
keys_written=list(entry.get("keys_written", [])),
|
|
190
|
+
settings_sha256=entry.get("settings_sha256", ""),
|
|
191
|
+
written_at=entry.get("written_at", ""),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def write_state(path: Path, state: State) -> None:
|
|
196
|
+
"""Write the state file."""
|
|
197
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
path.write_text(
|
|
199
|
+
json.dumps({"version": STATE_VERSION, "claude": asdict(state)}, indent=2),
|
|
200
|
+
encoding="utf-8",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def clear_state(path: Path) -> None:
|
|
205
|
+
"""Delete the state file."""
|
|
206
|
+
path.unlink(missing_ok=True)
|
|
@@ -58,16 +58,24 @@ def _read_prompt(name: str) -> str:
|
|
|
58
58
|
|
|
59
59
|
@dataclass(frozen=True, slots=True)
|
|
60
60
|
class Rules:
|
|
61
|
-
"""The
|
|
61
|
+
"""The four rule layers.
|
|
62
|
+
|
|
63
|
+
A router gives the same model a different slug. The model gpt-5.5
|
|
64
|
+
is `cx/gpt-5.5` on one router and `openai/gpt-5.5` on another one.
|
|
65
|
+
Therefore a name rule matches the last slug segment, and it works
|
|
66
|
+
on every router. A slug rule still wins over a name rule.
|
|
67
|
+
"""
|
|
62
68
|
|
|
63
69
|
defaults: dict[str, Any]
|
|
64
70
|
prefixes: dict[str, dict[str, Any]]
|
|
71
|
+
names: dict[str, dict[str, Any]]
|
|
65
72
|
slugs: dict[str, dict[str, Any]]
|
|
66
73
|
|
|
67
74
|
def for_model(self, model: Model) -> dict[str, Any]:
|
|
68
75
|
"""The merged rule values for one model."""
|
|
69
76
|
merged: dict[str, Any] = dict(self.defaults)
|
|
70
|
-
merged.update(self.prefixes.get(model.prefix, {}))
|
|
77
|
+
merged.update(self.prefixes.get(model.prefix.lower(), {}))
|
|
78
|
+
merged.update(self.names.get(model.leaf_name.lower(), {}))
|
|
71
79
|
merged.update(self.slugs.get(model.slug, {}))
|
|
72
80
|
return merged
|
|
73
81
|
|
|
@@ -83,7 +91,8 @@ def load_rules(path: Path | None = None) -> Rules:
|
|
|
83
91
|
data = tomllib.loads(text)
|
|
84
92
|
return Rules(
|
|
85
93
|
defaults=dict(data.get("defaults", {})),
|
|
86
|
-
prefixes={k: dict(v) for k, v in (data.get("prefix") or {}).items()},
|
|
94
|
+
prefixes={k.lower(): dict(v) for k, v in (data.get("prefix") or {}).items()},
|
|
95
|
+
names={k.lower(): dict(v) for k, v in (data.get("name") or {}).items()},
|
|
87
96
|
slugs={k: dict(v) for k, v in (data.get("slug") or {}).items()},
|
|
88
97
|
)
|
|
89
98
|
|
|
@@ -103,7 +112,8 @@ def render_entry(model: Model, rules: Rules) -> dict[str, Any]:
|
|
|
103
112
|
"slug": model.slug,
|
|
104
113
|
"display_name": values.get("display_name") or _display_name(model),
|
|
105
114
|
"description": str(values["description_template"]).format(slug=model.slug),
|
|
106
|
-
|
|
115
|
+
# Codex accepts a string here. A null stops Codex at startup.
|
|
116
|
+
"default_reasoning_level": values["default_reasoning_level"],
|
|
107
117
|
"supported_reasoning_levels": (
|
|
108
118
|
[dict(level) for level in REASONING_LEVELS] if model.reasoning else []
|
|
109
119
|
),
|
|
@@ -127,9 +137,10 @@ def render_entry(model: Model, rules: Rules) -> dict[str, Any]:
|
|
|
127
137
|
"support_verbosity": values["support_verbosity"],
|
|
128
138
|
"default_verbosity": values["default_verbosity"],
|
|
129
139
|
"apply_patch_tool_type": values["apply_patch_tool_type"],
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
# Codex accepts a string or a map here. A null stops Codex at
|
|
141
|
+
# startup. The flag supports_search_tool carries the fact from
|
|
142
|
+
# the endpoint.
|
|
143
|
+
"web_search_tool_type": values["web_search_tool_type"],
|
|
133
144
|
"truncation_policy": values["truncation_policy"],
|
|
134
145
|
"supports_parallel_tool_calls": model.tools,
|
|
135
146
|
"supports_image_detail_original": values["supports_image_detail_original"],
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Claude Code refuses a model that it does not know. A picker row gives
|
|
2
|
+
# it `behavesAs`: the id of a model that Claude Code knows. The prompt
|
|
3
|
+
# profile, the capability defaults, and the effort defaults of that
|
|
4
|
+
# model then apply.
|
|
5
|
+
#
|
|
6
|
+
# A model name that already holds a known model id needs no behavesAs.
|
|
7
|
+
# The list `native_ids` holds those known ids.
|
|
8
|
+
#
|
|
9
|
+
# The layers apply in this order. A later layer wins:
|
|
10
|
+
# 1. defaults
|
|
11
|
+
# 2. prefix.<first slug segment>
|
|
12
|
+
# 3. name.<last slug segment> matches on every router
|
|
13
|
+
# 4. slug."<exact slug>" matches one router only
|
|
14
|
+
|
|
15
|
+
[defaults]
|
|
16
|
+
behaves_as = "claude-opus-4-8"
|
|
17
|
+
description_template = "{slug}"
|
|
18
|
+
|
|
19
|
+
# A model name that holds one of these ids keeps its native handling.
|
|
20
|
+
native_ids = [
|
|
21
|
+
"claude-opus-5",
|
|
22
|
+
"claude-opus-4-8",
|
|
23
|
+
"claude-opus-4-7",
|
|
24
|
+
"claude-opus-4-6",
|
|
25
|
+
"claude-opus-4-5",
|
|
26
|
+
"claude-opus-4-1",
|
|
27
|
+
"claude-opus-4",
|
|
28
|
+
"claude-sonnet-5",
|
|
29
|
+
"claude-sonnet-4-6",
|
|
30
|
+
"claude-sonnet-4-5",
|
|
31
|
+
"claude-sonnet-4",
|
|
32
|
+
"claude-sonnet-3-7",
|
|
33
|
+
"claude-haiku-4-5",
|
|
34
|
+
"claude-haiku-4",
|
|
35
|
+
"claude-haiku-3-5",
|
|
36
|
+
"claude-fable-5-1",
|
|
37
|
+
"claude-fable-5",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# A small or fast model works better with the handling of a small model.
|
|
41
|
+
[name."deepseek-v4.1-flash"]
|
|
42
|
+
behaves_as = "claude-haiku-4-5"
|
|
43
|
+
|
|
44
|
+
[name."deepseek-v4-flash"]
|
|
45
|
+
behaves_as = "claude-haiku-4-5"
|
|
46
|
+
|
|
47
|
+
[name."step-3.5-flash"]
|
|
48
|
+
behaves_as = "claude-haiku-4-5"
|
|
49
|
+
|
|
50
|
+
[name."gpt-5.4-mini"]
|
|
51
|
+
behaves_as = "claude-sonnet-4-6"
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
# The layers apply in this order. A later layer wins:
|
|
9
9
|
# 1. defaults
|
|
10
10
|
# 2. prefix.<first slug segment>
|
|
11
|
-
# 3. slug
|
|
11
|
+
# 3. name.<last slug segment> matches on every router
|
|
12
|
+
# 4. slug."<exact slug>" matches one router only
|
|
13
|
+
#
|
|
14
|
+
# Prefer a name rule. A router gives the same model a different slug.
|
|
15
|
+
# The model gpt-5.5 is "cx/gpt-5.5" on one router and "openai/gpt-5.5"
|
|
16
|
+
# on another one. A name rule matches both.
|
|
12
17
|
|
|
13
18
|
[defaults]
|
|
14
19
|
base_instructions_file = "generic-codex.md"
|
|
@@ -20,6 +25,7 @@ shell_type = "shell_command"
|
|
|
20
25
|
supported_in_api = true
|
|
21
26
|
apply_patch_tool_type = "freeform"
|
|
22
27
|
web_search_tool_type = "text_and_image"
|
|
28
|
+
default_reasoning_level = "medium"
|
|
23
29
|
supports_reasoning_summaries = true
|
|
24
30
|
default_reasoning_summary = "none"
|
|
25
31
|
support_verbosity = true
|
|
@@ -40,9 +46,10 @@ limit = 10000
|
|
|
40
46
|
priority = 10
|
|
41
47
|
|
|
42
48
|
# The two OpenAI models carry their own prompts and their own speed
|
|
43
|
-
# tiers. The values come from the working catalog.
|
|
49
|
+
# tiers. The values come from the working catalog. A name rule matches
|
|
50
|
+
# the model on every router.
|
|
44
51
|
|
|
45
|
-
[
|
|
52
|
+
[name."gpt-5.5"]
|
|
46
53
|
base_instructions_file = "gpt-5.5.md"
|
|
47
54
|
model_messages_file = "gpt-5.5.messages.json"
|
|
48
55
|
priority = 18
|
|
@@ -53,7 +60,7 @@ service_tiers = [
|
|
|
53
60
|
{ id = "priority", name = "Fast", description = "1.5x speed, increased usage" },
|
|
54
61
|
]
|
|
55
62
|
|
|
56
|
-
[
|
|
63
|
+
[name."gpt-5.4"]
|
|
57
64
|
base_instructions_file = "gpt-5.4.md"
|
|
58
65
|
model_messages_file = "gpt-5.4.messages.json"
|
|
59
66
|
priority = 14
|