ccgw 0.1.0__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.
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ['3.11', '3.12', '3.13']
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - name: Install package
21
+ run: python -m pip install -e .
22
+ - name: Smoke test
23
+ run: python -c "from ccgw.config import Config; Config.load('config.toml')"
24
+ - name: Build package
25
+ run: python -m pip install build && python -m build
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ contents: read
16
+ id-token: write
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: '3.12'
22
+ - name: Install build backend
23
+ run: python -m pip install build
24
+ - name: Build package
25
+ run: python -m build
26
+ - name: Publish package
27
+ uses: pypa/gh-action-pypi-publish@release/v1
ccgw-0.1.0/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ *.env
7
+ secrets.env
8
+ *.log
9
+ logs/
10
+ *.tar.gz
11
+ .DS_Store
12
+ .idea/
13
+ .vscode/
ccgw-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 XuNeo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ccgw-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.5
2
+ Name: ccgw
3
+ Version: 0.1.0
4
+ Summary: Local multi-provider Anthropic-protocol gateway for Claude Code: coconut, GLM and MiMo in one /model menu.
5
+ Project-URL: Homepage, https://github.com/XuNeo/ccgw
6
+ Project-URL: Repository, https://github.com/XuNeo/ccgw
7
+ Project-URL: Issues, https://github.com/XuNeo/ccgw/issues
8
+ Author: XuNeo
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: anthropic,claude,claude-code,gateway,glm,llm,mimo,proxy
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: fastapi>=0.115
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: uvicorn[standard]>=0.32
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # cc-gateway
32
+
33
+ A local multi-provider gateway that impersonates the Anthropic Messages API for
34
+ Claude Code, so you can switch between models from different backends at any
35
+ point in any session.
36
+
37
+ It forwards the Anthropic dialect only. Every backend must expose an
38
+ Anthropic-compatible endpoint; there is deliberately no OpenAI translation
39
+ layer.
40
+
41
+ ## Providers
42
+
43
+ | provider | endpoint | auth |
44
+ | --- | --- | --- |
45
+ | `coconut` | `http://127.0.0.1:8787` (local daemon) | `Bearer coconut` |
46
+ | `glm` | `https://open.bigmodel.cn/api/anthropic` | `x-api-key`, `$GLM_API_KEY` |
47
+ | `mimo` | `https://token-plan-cn.xiaomimimo.com/anthropic` | `x-api-key`, `$MIMO_API_KEY` |
48
+
49
+ ## Install
50
+
51
+ Requires Python 3.11+ (`tomllib` is stdlib there).
52
+
53
+ ```bash
54
+ uv tool install ccgw
55
+ # or
56
+ pipx install ccgw
57
+ ```
58
+
59
+ ## Run
60
+
61
+ The gateway reads its provider registry from a `config.toml`, looked up in
62
+ this order: `$CCGW_CONFIG`, `./config.toml`, `~/.config/ccgw/config.toml`.
63
+
64
+ ```bash
65
+ export GLM_API_KEY=... # never stored in this repo
66
+ export MIMO_API_KEY=...
67
+ ccgw # listens on 127.0.0.1:8788
68
+ ccgw --port 9000 # or --config /path/to/config.toml
69
+ ```
70
+
71
+ A source checkout can run the same entry point directly:
72
+
73
+ ```bash
74
+ pip install -r requirements.txt
75
+ python serve.py
76
+ ```
77
+
78
+ Point Claude Code at it in `~/.claude/settings.json`:
79
+
80
+ ```json
81
+ {
82
+ "env": {
83
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:8788",
84
+ "ANTHROPIC_AUTH_TOKEN": "ccgw",
85
+ "ANTHROPIC_API_KEY": "",
86
+ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"
87
+ }
88
+ }
89
+ ```
90
+
91
+ Do **not** set `ANTHROPIC_DEFAULT_*_MODEL` or `ANTHROPIC_SMALL_FAST_MODEL`:
92
+ the gateway serves every model from one catalogue, and pinning those variables
93
+ would route background traffic around the router.
94
+
95
+ ## Model naming
96
+
97
+ Public ids are generated as `claude-<provider>-<slug>` plus a `[1m]` suffix for
98
+ models with a 1M context window. Both parts are load-bearing:
99
+
100
+ * Claude Code's gateway discovery drops any id that does not contain `claude`
101
+ or `anthropic`, so the prefix is mandatory for a model to appear in `/model`.
102
+ * Claude Code infers a 1M window from a `[1m]` substring, which is how the
103
+ gateway communicates window size. `/v1/models` cannot carry it: Claude Code's
104
+ parser keeps only `id` and `display_name`.
105
+
106
+ The suffix is presentation only. It is stripped before the request goes
107
+ upstream, because neither GLM nor MiMo accepts a `[1m]` model name on these
108
+ endpoints (both reject it outright), even though their Coding Plan docs
109
+ describe the suffix.
110
+
111
+ ## Behaviour worth knowing
112
+
113
+ **History sanitisation.** Every request has `thinking` and `redacted_thinking`
114
+ blocks stripped from the history, unconditionally. Thinking signatures are
115
+ bound to the model and credentials that produced them, and Claude Code replays
116
+ the full history verbatim when you switch models, so a stale signature is a 400
117
+ that can wedge a session permanently. Stripping is stateless, matches
118
+ Anthropic's own guidance for switching models, and is a no-op for backends that
119
+ ignore thinking anyway. The top-level `thinking` parameter is removed in
120
+ lockstep, since removing the blocks alone is itself a 400.
121
+
122
+ **Tool repair.** Tool ids are normalised to `^[a-zA-Z0-9_-]+$` and dangling
123
+ `tool_use` blocks get a synthetic error result, both of which are otherwise
124
+ 400s after an interrupted turn.
125
+
126
+ **Errors.** Upstream failures are re-shaped into the Anthropic envelope but the
127
+ upstream message text is preserved verbatim, because Claude Code matches on it
128
+ to drive automatic capability downgrade and retry.
129
+
130
+ **Streaming.** Upstream bytes are relayed unbuffered, with a `ping` injected
131
+ every 15 seconds of silence to stay under Claude Code's 300 second stream
132
+ watchdog.
133
+
134
+ **Token counting.** `/v1/messages/count_tokens` is answered locally and never
135
+ proxied; coconut does not implement it, and Claude Code falls back to its own
136
+ estimate anyway.
137
+
138
+ ## License
139
+
140
+ [MIT](LICENSE)
ccgw-0.1.0/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # cc-gateway
2
+
3
+ A local multi-provider gateway that impersonates the Anthropic Messages API for
4
+ Claude Code, so you can switch between models from different backends at any
5
+ point in any session.
6
+
7
+ It forwards the Anthropic dialect only. Every backend must expose an
8
+ Anthropic-compatible endpoint; there is deliberately no OpenAI translation
9
+ layer.
10
+
11
+ ## Providers
12
+
13
+ | provider | endpoint | auth |
14
+ | --- | --- | --- |
15
+ | `coconut` | `http://127.0.0.1:8787` (local daemon) | `Bearer coconut` |
16
+ | `glm` | `https://open.bigmodel.cn/api/anthropic` | `x-api-key`, `$GLM_API_KEY` |
17
+ | `mimo` | `https://token-plan-cn.xiaomimimo.com/anthropic` | `x-api-key`, `$MIMO_API_KEY` |
18
+
19
+ ## Install
20
+
21
+ Requires Python 3.11+ (`tomllib` is stdlib there).
22
+
23
+ ```bash
24
+ uv tool install ccgw
25
+ # or
26
+ pipx install ccgw
27
+ ```
28
+
29
+ ## Run
30
+
31
+ The gateway reads its provider registry from a `config.toml`, looked up in
32
+ this order: `$CCGW_CONFIG`, `./config.toml`, `~/.config/ccgw/config.toml`.
33
+
34
+ ```bash
35
+ export GLM_API_KEY=... # never stored in this repo
36
+ export MIMO_API_KEY=...
37
+ ccgw # listens on 127.0.0.1:8788
38
+ ccgw --port 9000 # or --config /path/to/config.toml
39
+ ```
40
+
41
+ A source checkout can run the same entry point directly:
42
+
43
+ ```bash
44
+ pip install -r requirements.txt
45
+ python serve.py
46
+ ```
47
+
48
+ Point Claude Code at it in `~/.claude/settings.json`:
49
+
50
+ ```json
51
+ {
52
+ "env": {
53
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:8788",
54
+ "ANTHROPIC_AUTH_TOKEN": "ccgw",
55
+ "ANTHROPIC_API_KEY": "",
56
+ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"
57
+ }
58
+ }
59
+ ```
60
+
61
+ Do **not** set `ANTHROPIC_DEFAULT_*_MODEL` or `ANTHROPIC_SMALL_FAST_MODEL`:
62
+ the gateway serves every model from one catalogue, and pinning those variables
63
+ would route background traffic around the router.
64
+
65
+ ## Model naming
66
+
67
+ Public ids are generated as `claude-<provider>-<slug>` plus a `[1m]` suffix for
68
+ models with a 1M context window. Both parts are load-bearing:
69
+
70
+ * Claude Code's gateway discovery drops any id that does not contain `claude`
71
+ or `anthropic`, so the prefix is mandatory for a model to appear in `/model`.
72
+ * Claude Code infers a 1M window from a `[1m]` substring, which is how the
73
+ gateway communicates window size. `/v1/models` cannot carry it: Claude Code's
74
+ parser keeps only `id` and `display_name`.
75
+
76
+ The suffix is presentation only. It is stripped before the request goes
77
+ upstream, because neither GLM nor MiMo accepts a `[1m]` model name on these
78
+ endpoints (both reject it outright), even though their Coding Plan docs
79
+ describe the suffix.
80
+
81
+ ## Behaviour worth knowing
82
+
83
+ **History sanitisation.** Every request has `thinking` and `redacted_thinking`
84
+ blocks stripped from the history, unconditionally. Thinking signatures are
85
+ bound to the model and credentials that produced them, and Claude Code replays
86
+ the full history verbatim when you switch models, so a stale signature is a 400
87
+ that can wedge a session permanently. Stripping is stateless, matches
88
+ Anthropic's own guidance for switching models, and is a no-op for backends that
89
+ ignore thinking anyway. The top-level `thinking` parameter is removed in
90
+ lockstep, since removing the blocks alone is itself a 400.
91
+
92
+ **Tool repair.** Tool ids are normalised to `^[a-zA-Z0-9_-]+$` and dangling
93
+ `tool_use` blocks get a synthetic error result, both of which are otherwise
94
+ 400s after an interrupted turn.
95
+
96
+ **Errors.** Upstream failures are re-shaped into the Anthropic envelope but the
97
+ upstream message text is preserved verbatim, because Claude Code matches on it
98
+ to drive automatic capability downgrade and retry.
99
+
100
+ **Streaming.** Upstream bytes are relayed unbuffered, with a `ping` injected
101
+ every 15 seconds of silence to stay under Claude Code's 300 second stream
102
+ watchdog.
103
+
104
+ **Token counting.** `/v1/messages/count_tokens` is answered locally and never
105
+ proxied; coconut does not implement it, and Claude Code falls back to its own
106
+ estimate anyway.
107
+
108
+ ## License
109
+
110
+ [MIT](LICENSE)
@@ -0,0 +1,7 @@
1
+ """cc-gateway: a multi-provider Anthropic-dialect gateway for Claude Code."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["create_app"]
6
+
7
+ from .app import create_app # noqa: E402
ccgw-0.1.0/ccgw/app.py ADDED
@@ -0,0 +1,182 @@
1
+ """HTTP surface: the three endpoints Claude Code actually calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+
10
+ from fastapi import FastAPI, Request, Response
11
+ from fastapi.responses import JSONResponse, StreamingResponse
12
+
13
+ from .config import Config
14
+ from .errors import anthropic_error
15
+ from .provider import Registry, UpstreamError
16
+
17
+ log = logging.getLogger("ccgw")
18
+
19
+ CONFIG_PATH = os.environ.get("CCGW_CONFIG", "config.toml")
20
+
21
+
22
+ def create_app(config: Config | None = None) -> FastAPI:
23
+ config = config or Config.load(CONFIG_PATH)
24
+ registry = Registry(config)
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(_: FastAPI):
28
+ for warning in await registry.refresh():
29
+ log.warning("%s", warning)
30
+ log.info(
31
+ "catalogue ready: %d model(s) across %d provider(s)",
32
+ len(registry.models),
33
+ len(registry.clients),
34
+ )
35
+ for entry in registry.models.values():
36
+ log.info(" %-38s -> %s/%s", entry.public_id, entry.provider, entry.upstream)
37
+ yield
38
+ await registry.aclose()
39
+
40
+ app = FastAPI(title="cc-gateway", lifespan=lifespan)
41
+
42
+ # ------------------------------------------------------------ /v1/models
43
+
44
+ @app.get("/v1/models")
45
+ async def list_models():
46
+ """Claude Code keeps only `id` and `display_name`; everything else is
47
+ dropped by its parser. It also fails discovery on any redirect and
48
+ gives up after 3 seconds, so this must be served locally and fast."""
49
+ return {
50
+ "object": "list",
51
+ "has_more": False,
52
+ "data": [
53
+ {
54
+ "id": entry.public_id,
55
+ "type": "model",
56
+ "display_name": entry.display_name,
57
+ "created_at": "2026-01-01T00:00:00Z",
58
+ }
59
+ for entry in registry.models.values()
60
+ ],
61
+ }
62
+
63
+ # ---------------------------------------------- /v1/messages/count_tokens
64
+
65
+ @app.post("/v1/messages/count_tokens")
66
+ async def count_tokens(request: Request):
67
+ """Answered locally and never forwarded.
68
+
69
+ coconut does not implement this endpoint at all, and Claude Code allows
70
+ 10 seconds before falling back to its own estimate -- proxying would
71
+ only add latency. A rough local estimate keeps the context meter
72
+ approximately honest.
73
+ """
74
+ try:
75
+ body = await request.json()
76
+ except Exception:
77
+ body = {}
78
+ blob = json.dumps(
79
+ {
80
+ "system": body.get("system"),
81
+ "messages": body.get("messages"),
82
+ "tools": body.get("tools"),
83
+ },
84
+ ensure_ascii=False,
85
+ )
86
+ # ~3.5 chars per token is a better fit for mixed CJK/code than /4.
87
+ return {"input_tokens": max(1, int(len(blob) / 3.5))}
88
+
89
+ # ---------------------------------------------------------- /v1/messages
90
+
91
+ @app.post("/v1/messages")
92
+ async def messages(request: Request):
93
+ try:
94
+ body = await request.json()
95
+ except Exception:
96
+ return JSONResponse(
97
+ anthropic_error(400, "Request body must be valid JSON."), 400
98
+ )
99
+
100
+ entry = registry.resolve(body.get("model"))
101
+ if entry is None:
102
+ return JSONResponse(
103
+ anthropic_error(404, "No models are configured on this gateway."), 404
104
+ )
105
+
106
+ client = registry.client_for(entry)
107
+ session = request.headers.get("x-claude-code-session-id", "-")
108
+ surface = request.headers.get("x-app", "-")
109
+ log.info(
110
+ "%s %s -> %s/%s (session=%s)",
111
+ surface,
112
+ body.get("model"),
113
+ entry.provider,
114
+ entry.upstream,
115
+ session[:8],
116
+ )
117
+
118
+ try:
119
+ headers = client.build_headers(dict(request.headers))
120
+ except RuntimeError as exc:
121
+ return JSONResponse(anthropic_error(500, str(exc)), 500)
122
+
123
+ payload = client.build_payload(body, entry)
124
+
125
+ if not payload.get("stream"):
126
+ try:
127
+ upstream = await client.messages_once(payload, headers)
128
+ except UpstreamError as exc:
129
+ return JSONResponse(exc.payload, exc.status)
130
+ except Exception as exc:
131
+ return JSONResponse(
132
+ anthropic_error(502, f"[{entry.provider}] {exc}"), 502
133
+ )
134
+ result = upstream.json()
135
+ # Report the id the client asked for, not the upstream one.
136
+ if isinstance(result, dict):
137
+ result["model"] = body.get("model") or entry.public_id
138
+ return JSONResponse(result, upstream.status_code)
139
+
140
+ try:
141
+ stream = client.messages_stream(payload, headers)
142
+ first = await stream.__anext__()
143
+ except StopAsyncIteration:
144
+ return JSONResponse(
145
+ anthropic_error(502, f"[{entry.provider}] empty upstream response"), 502
146
+ )
147
+ except UpstreamError as exc:
148
+ return JSONResponse(exc.payload, exc.status)
149
+ except Exception as exc:
150
+ return JSONResponse(anthropic_error(502, f"[{entry.provider}] {exc}"), 502)
151
+
152
+ async def relay():
153
+ yield first
154
+ async for chunk in stream:
155
+ yield chunk
156
+
157
+ return StreamingResponse(
158
+ relay(),
159
+ media_type="text/event-stream",
160
+ headers={
161
+ "Cache-Control": "no-cache, no-transform",
162
+ "Connection": "keep-alive",
163
+ # Defeat any buffering proxy sitting in front of us.
164
+ "X-Accel-Buffering": "no",
165
+ },
166
+ )
167
+
168
+ @app.get("/healthz")
169
+ async def healthz():
170
+ return {"ok": True, "models": len(registry.models)}
171
+
172
+ @app.head("/api/hello")
173
+ async def hello():
174
+ # Claude Code fires this once at startup as a fire-and-forget TCP
175
+ # preconnect probe and ignores the response; answer so it does not
176
+ # land in the access log as a 404.
177
+ return Response(status_code=204)
178
+
179
+ return app
180
+
181
+
182
+ app = create_app
ccgw-0.1.0/ccgw/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ """Command-line entry point: `ccgw` (installed via [project.scripts])."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import uvicorn
11
+
12
+ from ccgw.app import create_app
13
+ from ccgw.config import Config
14
+
15
+ DEFAULT_CONFIG_DIRS = (Path.cwd(), Path.home() / ".config" / "ccgw")
16
+
17
+
18
+ def find_config() -> str | None:
19
+ """First existing config.toml among the well-known locations."""
20
+ for directory in DEFAULT_CONFIG_DIRS:
21
+ candidate = directory / "config.toml"
22
+ if candidate.is_file():
23
+ return str(candidate)
24
+ return None
25
+
26
+
27
+ def main() -> None:
28
+ parser = argparse.ArgumentParser(description="Claude Code multi-provider gateway")
29
+ parser.add_argument(
30
+ "--config",
31
+ default=os.environ.get("CCGW_CONFIG"),
32
+ help="Config file path (default: $CCGW_CONFIG, ./config.toml, ~/.config/ccgw/config.toml)",
33
+ )
34
+ parser.add_argument("--host", default=None)
35
+ parser.add_argument("--port", type=int, default=None)
36
+ args = parser.parse_args()
37
+
38
+ config_path = args.config or find_config()
39
+ if not config_path:
40
+ parser.error(
41
+ "no config found: pass --config, set $CCGW_CONFIG, "
42
+ "or place config.toml in the cwd or ~/.config/ccgw/"
43
+ )
44
+
45
+ logging.basicConfig(
46
+ level=logging.INFO,
47
+ format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
48
+ )
49
+
50
+ config = Config.load(config_path)
51
+ app = create_app(config)
52
+ uvicorn.run(
53
+ app,
54
+ host=args.host or config.server.host,
55
+ port=args.port or config.server.port,
56
+ log_level="info",
57
+ # Streaming correctness: never let the server buffer responses.
58
+ timeout_keep_alive=75,
59
+ )
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()