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.
- ccgw-0.1.0/.github/workflows/ci.yml +25 -0
- ccgw-0.1.0/.github/workflows/publish.yml +27 -0
- ccgw-0.1.0/.gitignore +13 -0
- ccgw-0.1.0/LICENSE +21 -0
- ccgw-0.1.0/PKG-INFO +140 -0
- ccgw-0.1.0/README.md +110 -0
- ccgw-0.1.0/ccgw/__init__.py +7 -0
- ccgw-0.1.0/ccgw/app.py +182 -0
- ccgw-0.1.0/ccgw/cli.py +63 -0
- ccgw-0.1.0/ccgw/config.py +131 -0
- ccgw-0.1.0/ccgw/errors.py +80 -0
- ccgw-0.1.0/ccgw/provider.py +335 -0
- ccgw-0.1.0/ccgw/sanitize.py +185 -0
- ccgw-0.1.0/config.toml +94 -0
- ccgw-0.1.0/pyproject.toml +49 -0
- ccgw-0.1.0/requirements.txt +3 -0
- ccgw-0.1.0/serve.py +7 -0
|
@@ -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
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)
|
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()
|