ccgw 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.
ccgw/__init__.py ADDED
@@ -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/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/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()
ccgw/config.py ADDED
@@ -0,0 +1,131 @@
1
+ """Configuration loading and the public model registry.
2
+
3
+ The registry maps the *public* model id that Claude Code sees onto a concrete
4
+ (provider, upstream model name) pair. Public ids are synthesised so that they
5
+ always satisfy Claude Code's two naming constraints:
6
+
7
+ * the id must contain "claude" (otherwise gateway model discovery drops it);
8
+ * an id containing "[1m]" makes Claude Code assume a 1M context window, so the
9
+ suffix is only appended for models that really have one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import tomllib
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ # Claude Code treats an id containing "[1m]" as a 1M-context model. coconut's
21
+ # models report 936k, which is close enough to round up to the same bucket.
22
+ ONE_M_THRESHOLD = 900_000
23
+ ONE_M_SUFFIX = "[1m]"
24
+ PUBLIC_PREFIX = "claude-"
25
+
26
+
27
+ def _slug(value: str) -> str:
28
+ slug = re.sub(r"[^a-z0-9.]+", "-", str(value).strip().lower())
29
+ return slug.strip("-")
30
+
31
+
32
+ def make_public_id(provider: str, upstream: str, context: int) -> str:
33
+ name = str(upstream).strip()
34
+ name = re.sub(r"\[1m\]$", "", name, flags=re.IGNORECASE)
35
+ name = re.sub(r"^(claude|anthropic)[-_]", "", name, flags=re.IGNORECASE)
36
+ name = _slug(name)
37
+ return PUBLIC_PREFIX + name + (ONE_M_SUFFIX if context >= ONE_M_THRESHOLD else "")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ModelEntry:
42
+ public_id: str
43
+ display_name: str
44
+ provider: str
45
+ upstream: str
46
+ context: int
47
+ max_tokens: int | None = None
48
+
49
+
50
+ @dataclass
51
+ class Provider:
52
+ name: str
53
+ base_url: str
54
+ auth_style: str = "bearer" # "bearer" | "x-api-key" | "none"
55
+ api_key: str | None = None
56
+ api_key_env: str | None = None
57
+ force_host: str | None = None
58
+ user_agent: str | None = None
59
+ discover: bool = False
60
+ drop_thinking_param: bool = False
61
+ normalize_thinking: bool = False
62
+ strip_cache_control: bool = False
63
+ proxy: str | None = None
64
+ models: list[dict] = field(default_factory=list)
65
+
66
+ def resolve_key(self) -> str | None:
67
+ """Read the credential, preferring the environment over the file."""
68
+ if self.api_key_env:
69
+ value = os.environ.get(self.api_key_env, "").strip()
70
+ if not value:
71
+ raise RuntimeError(
72
+ f"provider {self.name!r} needs environment variable "
73
+ f"{self.api_key_env}, which is unset or empty"
74
+ )
75
+ return value
76
+ return self.api_key
77
+
78
+ def auth_headers(self) -> dict[str, str]:
79
+ key = self.resolve_key()
80
+ if self.auth_style == "none" or not key:
81
+ return {}
82
+ if self.auth_style == "x-api-key":
83
+ return {"x-api-key": key}
84
+ return {"Authorization": f"Bearer {key}"}
85
+
86
+
87
+ @dataclass
88
+ class ServerConfig:
89
+ host: str = "127.0.0.1"
90
+ port: int = 8788
91
+ ping_interval: float = 15.0
92
+ connect_timeout: float = 30.0
93
+ read_timeout: float = 600.0
94
+
95
+
96
+ @dataclass
97
+ class Config:
98
+ server: ServerConfig
99
+ providers: dict[str, Provider]
100
+
101
+ @classmethod
102
+ def load(cls, path: str | Path) -> "Config":
103
+ raw = tomllib.loads(Path(path).read_text(encoding="utf-8"))
104
+ server = ServerConfig(**raw.get("server", {}))
105
+ providers: dict[str, Provider] = {}
106
+ for name, body in raw.get("providers", {}).items():
107
+ body = dict(body)
108
+ models = body.pop("models", [])
109
+ providers[name] = Provider(name=name, models=models, **body)
110
+ if not providers:
111
+ raise RuntimeError("no providers configured")
112
+ return cls(server=server, providers=providers)
113
+
114
+ def static_models(self) -> list[ModelEntry]:
115
+ """Model entries declared inline in the config file."""
116
+ entries: list[ModelEntry] = []
117
+ for provider in self.providers.values():
118
+ for model in provider.models:
119
+ upstream = model["upstream"]
120
+ context = int(model.get("context", 200_000))
121
+ entries.append(
122
+ ModelEntry(
123
+ public_id=make_public_id(provider.name, upstream, context),
124
+ display_name=model.get("display_name", upstream),
125
+ provider=provider.name,
126
+ upstream=upstream,
127
+ context=context,
128
+ max_tokens=model.get("max_tokens"),
129
+ )
130
+ )
131
+ return entries
ccgw/errors.py ADDED
@@ -0,0 +1,80 @@
1
+ """Upstream error normalisation.
2
+
3
+ Claude Code parses gateway error bodies and uses them to drive automatic
4
+ capability downgrade and retry, so the *shape* must be the Anthropic one:
5
+
6
+ {"type": "error", "error": {"type": ..., "message": ...}}
7
+
8
+ GLM and MiMo both return non-conforming bodies (GLM auth errors look like
9
+ `{"error": {"message": ..., "type": "1001"}}`, MiMo returns
10
+ `{"error": {"message": ..., "code": "401", "type": "invalid_key"}}`), so we
11
+ re-shape them. The upstream *message text* is always preserved verbatim --
12
+ Claude Code matches on it, and rewriting it would break its retry heuristics.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Any
19
+
20
+ STATUS_TO_ANTHROPIC_TYPE = {
21
+ 400: "invalid_request_error",
22
+ 401: "authentication_error",
23
+ 403: "permission_error",
24
+ 404: "not_found_error",
25
+ 413: "request_too_large",
26
+ 422: "invalid_request_error",
27
+ 429: "rate_limit_error",
28
+ 500: "api_error",
29
+ 502: "api_error",
30
+ 503: "overloaded_error",
31
+ 504: "api_error",
32
+ }
33
+
34
+
35
+ def anthropic_error(status: int, message: str, error_type: str | None = None) -> dict:
36
+ return {
37
+ "type": "error",
38
+ "error": {
39
+ "type": error_type or STATUS_TO_ANTHROPIC_TYPE.get(status, "api_error"),
40
+ "message": message,
41
+ },
42
+ }
43
+
44
+
45
+ def normalize_upstream_error(status: int, body: bytes, provider: str) -> dict:
46
+ """Coerce an upstream error body into Anthropic shape, keeping the text."""
47
+ try:
48
+ parsed: Any = json.loads(body)
49
+ except Exception:
50
+ text = body.decode("utf-8", "replace").strip() or f"HTTP {status}"
51
+ return anthropic_error(status, f"[{provider}] {text[:2000]}")
52
+
53
+ # Already Anthropic shaped -- forward untouched.
54
+ if (
55
+ isinstance(parsed, dict)
56
+ and parsed.get("type") == "error"
57
+ and isinstance(parsed.get("error"), dict)
58
+ and parsed["error"].get("message")
59
+ ):
60
+ return parsed
61
+
62
+ message = None
63
+ error_type = None
64
+ if isinstance(parsed, dict):
65
+ inner = parsed.get("error")
66
+ if isinstance(inner, dict):
67
+ message = inner.get("message")
68
+ raw_type = str(inner.get("type", ""))
69
+ # GLM and MiMo put HTTP-ish codes in `type`; those are not valid
70
+ # Anthropic error types, so fall back to the status mapping.
71
+ if raw_type and not raw_type.isdigit() and "_" in raw_type:
72
+ error_type = raw_type
73
+ elif isinstance(inner, str):
74
+ message = inner
75
+ message = message or parsed.get("message")
76
+
77
+ if not message:
78
+ message = body.decode("utf-8", "replace").strip()[:2000] or f"HTTP {status}"
79
+
80
+ return anthropic_error(status, f"[{provider}] {message}", error_type)
ccgw/provider.py ADDED
@@ -0,0 +1,335 @@
1
+ """Anthropic-dialect upstream provider.
2
+
3
+ Every backend this gateway talks to already speaks the Anthropic Messages API,
4
+ so there is deliberately no OpenAI translation layer here. A provider only
5
+ differs in three respects: how it authenticates, which request fields it
6
+ tolerates, and how malformed its error bodies are.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ from typing import Any, AsyncIterator
15
+
16
+ import httpx
17
+
18
+ from .config import Config, ModelEntry, Provider, make_public_id
19
+ from .errors import normalize_upstream_error
20
+ from .sanitize import (
21
+ enforce_tool_pairing,
22
+ sanitize_messages,
23
+ strip_cache_control,
24
+ )
25
+
26
+ # Hop-by-hop and client-specific headers that must never be relayed upstream.
27
+ DROP_REQUEST_HEADERS = {
28
+ "host",
29
+ "content-length",
30
+ "content-type",
31
+ "connection",
32
+ "keep-alive",
33
+ "transfer-encoding",
34
+ "authorization",
35
+ "x-api-key",
36
+ "accept-encoding",
37
+ "user-agent",
38
+ }
39
+
40
+ DROP_RESPONSE_HEADERS = {
41
+ "content-length",
42
+ "content-encoding",
43
+ "transfer-encoding",
44
+ "connection",
45
+ "keep-alive",
46
+ }
47
+
48
+ PING_EVENT = b'event: ping\ndata: {"type":"ping"}\n\n'
49
+
50
+ LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
51
+
52
+
53
+ def _proxy_for(provider: Provider) -> str | None:
54
+ """Pick the egress proxy for a provider.
55
+
56
+ Local daemons must be reached directly; remote providers go through the
57
+ environment's proxy when one is configured.
58
+ """
59
+ if provider.proxy is not None:
60
+ return provider.proxy or None
61
+ host = httpx.URL(provider.base_url).host
62
+ if host in LOOPBACK_HOSTS:
63
+ return None
64
+ for key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"):
65
+ value = os.environ.get(key)
66
+ if value:
67
+ return value
68
+ return None
69
+
70
+
71
+ class UpstreamError(Exception):
72
+ def __init__(self, status: int, payload: dict):
73
+ super().__init__(payload.get("error", {}).get("message", "upstream error"))
74
+ self.status = status
75
+ self.payload = payload
76
+
77
+
78
+ class ProviderClient:
79
+ def __init__(self, provider: Provider, config: Config):
80
+ self.provider = provider
81
+ self.config = config
82
+ # `trust_env` is off on purpose: httpx chokes on NO_PROXY entries that
83
+ # contain IPv6 literals or CIDR blocks, which corporate environments
84
+ # routinely set. Proxy selection is therefore explicit.
85
+ self.client = httpx.AsyncClient(
86
+ timeout=httpx.Timeout(
87
+ config.server.read_timeout,
88
+ connect=config.server.connect_timeout,
89
+ ),
90
+ follow_redirects=False,
91
+ trust_env=False,
92
+ proxy=_proxy_for(provider),
93
+ )
94
+
95
+ async def aclose(self) -> None:
96
+ await self.client.aclose()
97
+
98
+ # ---------------------------------------------------------------- headers
99
+
100
+ def build_headers(self, incoming: dict[str, str]) -> dict[str, str]:
101
+ headers = {
102
+ key: value
103
+ for key, value in incoming.items()
104
+ if key.lower() not in DROP_REQUEST_HEADERS
105
+ }
106
+ headers.update(self.provider.auth_headers())
107
+ headers["Content-Type"] = "application/json"
108
+ headers.setdefault("anthropic-version", "2023-06-01")
109
+ # coconut refuses a non-loopback Host and gates /v1/models on a
110
+ # User-Agent containing "claude".
111
+ if self.provider.force_host:
112
+ headers["Host"] = self.provider.force_host
113
+ if self.provider.user_agent:
114
+ headers["User-Agent"] = self.provider.user_agent
115
+ else:
116
+ headers.setdefault("User-Agent", "claude-cli/2.1.0 (external, cli)")
117
+ return headers
118
+
119
+ # ---------------------------------------------------------------- payload
120
+
121
+ def build_payload(self, body: dict, entry: ModelEntry) -> dict:
122
+ payload = dict(body)
123
+ payload["model"] = entry.upstream
124
+
125
+ messages, stripped = sanitize_messages(payload.get("messages"))
126
+ payload["messages"] = enforce_tool_pairing(messages)
127
+
128
+ # Removing thinking blocks while keeping the top-level `thinking`
129
+ # parameter is itself a 400 on strict backends -- the two must move
130
+ # together.
131
+ if self.provider.drop_thinking_param or stripped:
132
+ payload.pop("thinking", None)
133
+
134
+ if self.provider.normalize_thinking and "thinking" in payload:
135
+ thinking = payload.get("thinking")
136
+ if isinstance(thinking, dict):
137
+ # MiMo only accepts enabled/disabled and rejects budget_tokens.
138
+ kind = thinking.get("type")
139
+ payload["thinking"] = {
140
+ "type": "disabled" if kind == "disabled" else "enabled"
141
+ }
142
+ else:
143
+ payload.pop("thinking", None)
144
+
145
+ if self.provider.strip_cache_control:
146
+ payload = strip_cache_control(payload)
147
+
148
+ # MiMo silently truncates to a small per-model default when max_tokens
149
+ # is absent, so always send an explicit ceiling.
150
+ if entry.max_tokens:
151
+ requested = payload.get("max_tokens")
152
+ if not isinstance(requested, int) or requested <= 0:
153
+ payload["max_tokens"] = entry.max_tokens
154
+ else:
155
+ payload["max_tokens"] = min(requested, entry.max_tokens)
156
+
157
+ return payload
158
+
159
+ # ------------------------------------------------------------------ calls
160
+
161
+ def _url(self, path: str) -> str:
162
+ return self.provider.base_url.rstrip("/") + path
163
+
164
+ async def messages_once(self, payload: dict, headers: dict[str, str]) -> httpx.Response:
165
+ response = await self.client.post(
166
+ self._url("/v1/messages"), json=payload, headers=headers
167
+ )
168
+ if response.status_code >= 400:
169
+ raise UpstreamError(
170
+ response.status_code,
171
+ normalize_upstream_error(
172
+ response.status_code, response.content, self.provider.name
173
+ ),
174
+ )
175
+ return response
176
+
177
+ async def messages_stream(
178
+ self, payload: dict, headers: dict[str, str]
179
+ ) -> AsyncIterator[bytes]:
180
+ """Relay an upstream SSE stream with zero buffering.
181
+
182
+ Claude Code aborts a stream after 300 seconds without bytes, so a ping
183
+ is injected whenever the upstream goes quiet.
184
+ """
185
+ request = self.client.build_request(
186
+ "POST", self._url("/v1/messages"), json=payload, headers=headers
187
+ )
188
+ response = await self.client.send(request, stream=True)
189
+
190
+ if response.status_code >= 400:
191
+ body = await response.aread()
192
+ await response.aclose()
193
+ raise UpstreamError(
194
+ response.status_code,
195
+ normalize_upstream_error(
196
+ response.status_code, body, self.provider.name
197
+ ),
198
+ )
199
+
200
+ queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=64)
201
+
202
+ async def pump() -> None:
203
+ try:
204
+ async for chunk in response.aiter_raw():
205
+ if chunk:
206
+ await queue.put(chunk)
207
+ except Exception as exc: # surface mid-stream failures to the client
208
+ message = json.dumps(
209
+ {
210
+ "type": "error",
211
+ "error": {
212
+ "type": "api_error",
213
+ "message": f"[{self.provider.name}] stream aborted: {exc}",
214
+ },
215
+ }
216
+ )
217
+ await queue.put(f"event: error\ndata: {message}\n\n".encode())
218
+ finally:
219
+ await queue.put(None)
220
+ await response.aclose()
221
+
222
+ task = asyncio.create_task(pump())
223
+ interval = self.config.server.ping_interval
224
+ try:
225
+ while True:
226
+ try:
227
+ chunk = await asyncio.wait_for(queue.get(), timeout=interval)
228
+ except asyncio.TimeoutError:
229
+ yield PING_EVENT
230
+ continue
231
+ if chunk is None:
232
+ break
233
+ yield chunk
234
+ finally:
235
+ task.cancel()
236
+
237
+ # -------------------------------------------------------------- discovery
238
+
239
+ async def discover_models(self) -> list[ModelEntry]:
240
+ """Fetch a provider's own catalogue and re-key it under our namespace."""
241
+ headers = self.build_headers({})
242
+ response = await self.client.get(
243
+ self._url("/v1/models"), headers=headers, params={"limit": 1000}
244
+ )
245
+ response.raise_for_status()
246
+ data = response.json().get("data") or []
247
+
248
+ entries: list[ModelEntry] = []
249
+ for item in data:
250
+ upstream = item.get("id")
251
+ if not upstream:
252
+ continue
253
+ context = int(item.get("max_input_tokens") or 0)
254
+ if not context:
255
+ context = 1_000_000 if "[1m]" in upstream else 200_000
256
+ entries.append(
257
+ ModelEntry(
258
+ public_id=make_public_id(self.provider.name, upstream, context),
259
+ display_name=item.get("display_name") or upstream,
260
+ provider=self.provider.name,
261
+ upstream=upstream,
262
+ context=context,
263
+ max_tokens=item.get("max_tokens"),
264
+ )
265
+ )
266
+ return entries
267
+
268
+
269
+ class Registry:
270
+ """Public model id -> ModelEntry, plus the provider clients."""
271
+
272
+ def __init__(self, config: Config):
273
+ self.config = config
274
+ self.clients = {
275
+ name: ProviderClient(provider, config)
276
+ for name, provider in config.providers.items()
277
+ }
278
+ self.models: dict[str, ModelEntry] = {}
279
+ self.default_id: str | None = None
280
+
281
+ async def refresh(self) -> list[str]:
282
+ """(Re)build the catalogue. Returns human-readable warnings."""
283
+ warnings: list[str] = []
284
+ models: dict[str, ModelEntry] = {}
285
+
286
+ for entry in self.config.static_models():
287
+ models[entry.public_id] = entry
288
+
289
+ for name, client in self.clients.items():
290
+ if not client.provider.discover:
291
+ continue
292
+ try:
293
+ for entry in await client.discover_models():
294
+ models[entry.public_id] = entry
295
+ except Exception as exc:
296
+ warnings.append(f"discovery failed for provider {name!r}: {exc}")
297
+
298
+ self.models = models
299
+ self.default_id = next(iter(models), None)
300
+ return warnings
301
+
302
+ def resolve(self, requested: Any) -> ModelEntry | None:
303
+ """Map a requested model name onto a catalogue entry.
304
+
305
+ Claude Code emits background/subagent requests whose model may not be
306
+ in our catalogue (for example a hard-coded haiku id). Returning 404 for
307
+ those makes those tasks fail silently and is very hard to debug, so we
308
+ fall back rather than reject.
309
+ """
310
+ if not isinstance(requested, str) or not requested.strip():
311
+ return self.models.get(self.default_id) if self.default_id else None
312
+
313
+ name = requested.strip()
314
+ if name in self.models:
315
+ return self.models[name]
316
+
317
+ lowered = name.lower()
318
+ for public_id, entry in self.models.items():
319
+ if public_id.lower() == lowered or entry.upstream.lower() == lowered:
320
+ return entry
321
+
322
+ # Suffix-insensitive match, so `...-max` and `...-max[1m]` are the same.
323
+ bare = lowered.replace("[1m]", "")
324
+ for public_id, entry in self.models.items():
325
+ if public_id.lower().replace("[1m]", "") == bare:
326
+ return entry
327
+
328
+ return self.models.get(self.default_id) if self.default_id else None
329
+
330
+ def client_for(self, entry: ModelEntry) -> ProviderClient:
331
+ return self.clients[entry.provider]
332
+
333
+ async def aclose(self) -> None:
334
+ for client in self.clients.values():
335
+ await client.aclose()
ccgw/sanitize.py ADDED
@@ -0,0 +1,185 @@
1
+ """Cross-provider conversation history sanitisation.
2
+
3
+ Claude Code lets the user switch models mid-session with `/model`. When that
4
+ happens it replays the *entire* history to the new backend without any
5
+ reconciliation. Thinking blocks carry a `signature` that is bound to the model
6
+ and credentials that produced them, so replaying them at a different provider
7
+ fails with a 400 and can wedge the session permanently.
8
+
9
+ Anthropic's own guidance for switching models is to strip `thinking` and
10
+ `redacted_thinking` from prior assistant turns, so that is exactly what we do --
11
+ unconditionally, on every request. This is stateless (no session bookkeeping,
12
+ nothing to get stale across a gateway restart or `--resume`) and it is a no-op
13
+ for backends that ignore thinking anyway.
14
+
15
+ Two failure modes have to be avoided while stripping:
16
+
17
+ * removing thinking blocks while leaving the top-level `thinking` request
18
+ parameter in place makes strict backends reply
19
+ "Expected `thinking` ... but found `tool_use`";
20
+ * emptying a message's `content` array is itself a 400.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from typing import Any
27
+
28
+ THINKING_BLOCKS = {"thinking", "redacted_thinking"}
29
+ # Server-side tool blocks are provider specific and never replayable elsewhere.
30
+ SERVER_TOOL_BLOCKS = {"server_tool_use", "web_search_tool_result"}
31
+ PLACEHOLDER = {"type": "text", "text": "[thinking]"}
32
+
33
+ # Anthropic requires tool ids to match ^[a-zA-Z0-9_-]+$.
34
+ _ILLEGAL_TOOL_ID = re.compile(r"[^a-zA-Z0-9_-]")
35
+
36
+
37
+ def _normalise_tool_id(tool_id: str) -> str:
38
+ cleaned = _ILLEGAL_TOOL_ID.sub("_", str(tool_id))
39
+ # `srvtoolu_` ids belong to server-side tools and are rejected on replay.
40
+ if cleaned.startswith("srvtoolu_"):
41
+ cleaned = "toolu_" + cleaned[len("srvtoolu_"):]
42
+ return cleaned or "toolu_unknown"
43
+
44
+
45
+ def _clean_assistant_content(content: Any) -> tuple[list[dict], bool]:
46
+ """Drop unreplayable blocks from one assistant message.
47
+
48
+ Returns the new block list and whether anything was removed.
49
+ """
50
+ if not isinstance(content, list):
51
+ return content, False
52
+
53
+ kept: list[dict] = []
54
+ removed = False
55
+ for block in content:
56
+ if not isinstance(block, dict):
57
+ kept.append(block)
58
+ continue
59
+ kind = block.get("type")
60
+ if kind in THINKING_BLOCKS or kind in SERVER_TOOL_BLOCKS:
61
+ removed = True
62
+ continue
63
+ # An empty text block is legal to emit but rejected on replay.
64
+ if kind == "text" and not str(block.get("text", "")).strip():
65
+ removed = True
66
+ continue
67
+ kept.append(block)
68
+
69
+ if not kept:
70
+ kept = [dict(PLACEHOLDER)]
71
+ return kept, removed
72
+
73
+
74
+ def sanitize_messages(messages: Any) -> tuple[list[dict], bool]:
75
+ """Strip thinking blocks and normalise tool ids across the whole history."""
76
+ if not isinstance(messages, list):
77
+ return messages, False
78
+
79
+ stripped_any = False
80
+ id_map: dict[str, str] = {}
81
+ out: list[dict] = []
82
+
83
+ for message in messages:
84
+ if not isinstance(message, dict):
85
+ out.append(message)
86
+ continue
87
+ message = dict(message)
88
+ content = message.get("content")
89
+
90
+ if message.get("role") == "assistant":
91
+ content, removed = _clean_assistant_content(content)
92
+ stripped_any = stripped_any or removed
93
+
94
+ if isinstance(content, list):
95
+ rebuilt = []
96
+ for block in content:
97
+ if not isinstance(block, dict):
98
+ rebuilt.append(block)
99
+ continue
100
+ block = dict(block)
101
+ if block.get("type") == "tool_use" and "id" in block:
102
+ original = str(block["id"])
103
+ fixed = _normalise_tool_id(original)
104
+ if fixed != original:
105
+ id_map[original] = fixed
106
+ block["id"] = fixed
107
+ elif block.get("type") == "tool_result" and "tool_use_id" in block:
108
+ original = str(block["tool_use_id"])
109
+ block["tool_use_id"] = id_map.get(
110
+ original, _normalise_tool_id(original)
111
+ )
112
+ rebuilt.append(block)
113
+ content = rebuilt
114
+
115
+ message["content"] = content
116
+ out.append(message)
117
+
118
+ return out, stripped_any
119
+
120
+
121
+ def enforce_tool_pairing(messages: list[dict]) -> list[dict]:
122
+ """Synthesise placeholder results for tool_use blocks nobody answered.
123
+
124
+ A dangling tool_use is a 400 at every backend. This can happen when the
125
+ client aborted a turn, which is common when the user switches model
126
+ mid-stream.
127
+ """
128
+ if not isinstance(messages, list):
129
+ return messages
130
+
131
+ for index, message in enumerate(messages):
132
+ if not isinstance(message, dict) or message.get("role") != "assistant":
133
+ continue
134
+ content = message.get("content")
135
+ if not isinstance(content, list):
136
+ continue
137
+ pending = [
138
+ block["id"]
139
+ for block in content
140
+ if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
141
+ ]
142
+ if not pending:
143
+ continue
144
+
145
+ nxt = messages[index + 1] if index + 1 < len(messages) else None
146
+ answered: set[str] = set()
147
+ if isinstance(nxt, dict) and nxt.get("role") == "user":
148
+ for block in nxt.get("content") or []:
149
+ if isinstance(block, dict) and block.get("type") == "tool_result":
150
+ answered.add(str(block.get("tool_use_id")))
151
+
152
+ missing = [tool_id for tool_id in pending if tool_id not in answered]
153
+ if not missing:
154
+ continue
155
+
156
+ results = [
157
+ {
158
+ "type": "tool_result",
159
+ "tool_use_id": tool_id,
160
+ "content": "Tool call was interrupted.",
161
+ "is_error": True,
162
+ }
163
+ for tool_id in missing
164
+ ]
165
+ if isinstance(nxt, dict) and nxt.get("role") == "user" and isinstance(
166
+ nxt.get("content"), list
167
+ ):
168
+ nxt["content"] = results + nxt["content"]
169
+ else:
170
+ messages.insert(index + 1, {"role": "user", "content": results})
171
+
172
+ return messages
173
+
174
+
175
+ def strip_cache_control(payload: Any) -> Any:
176
+ """Recursively remove `cache_control`, which most compat layers reject."""
177
+ if isinstance(payload, dict):
178
+ return {
179
+ key: strip_cache_control(value)
180
+ for key, value in payload.items()
181
+ if key != "cache_control"
182
+ }
183
+ if isinstance(payload, list):
184
+ return [strip_cache_control(item) for item in payload]
185
+ return payload
@@ -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)
@@ -0,0 +1,12 @@
1
+ ccgw/__init__.py,sha256=v6uccAOn1cqaanZHJ6rfNvcj6yDxpGaKywhSYmGDxIk,170
2
+ ccgw/app.py,sha256=yon6DiMg8Y6sVM2-5GGIsfTh_JAcb6kRjglEmz4uzU0,6213
3
+ ccgw/cli.py,sha256=CO7kMtQBOGL1bzIcjd28NsJBA4OGrV6x93o3iE8QFmI,1773
4
+ ccgw/config.py,sha256=2ttGwCSmc3V_xVqm1SMp5faC1otEpv2OY4rExNu2ZAA,4431
5
+ ccgw/errors.py,sha256=rEybYEk4ah6XYX-44RiEMWoR8GikMc1tD9_r70Jj7BM,2741
6
+ ccgw/provider.py,sha256=XrnyvFJqZU6xG_Bpzlj5mcgsJRwKNMIovg30eawrtmw,12005
7
+ ccgw/sanitize.py,sha256=swjHKnSmdoSziVuF4Uy4ZBsGpBMchv_HRXDlBDCOZdI,6692
8
+ ccgw-0.1.0.dist-info/METADATA,sha256=_EO9-qQj5meNd2eYJ_Z7T8RULZcJc_BefE0iYYb_-20,5073
9
+ ccgw-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ ccgw-0.1.0.dist-info/entry_points.txt,sha256=Hgvqs6iemro2SAjrbF2D1rixB736av7JTh8CWxJkcgU,39
11
+ ccgw-0.1.0.dist-info/licenses/LICENSE,sha256=iaIFp8ODtIXtlsDf7Fz4vrhKokq7tbGuHdl7z1LZBfw,1062
12
+ ccgw-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ccgw = ccgw.cli:main
@@ -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.