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 +7 -0
- ccgw/app.py +182 -0
- ccgw/cli.py +63 -0
- ccgw/config.py +131 -0
- ccgw/errors.py +80 -0
- ccgw/provider.py +335 -0
- ccgw/sanitize.py +185 -0
- ccgw-0.1.0.dist-info/METADATA +140 -0
- ccgw-0.1.0.dist-info/RECORD +12 -0
- ccgw-0.1.0.dist-info/WHEEL +4 -0
- ccgw-0.1.0.dist-info/entry_points.txt +2 -0
- ccgw-0.1.0.dist-info/licenses/LICENSE +21 -0
ccgw/__init__.py
ADDED
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,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.
|