coderouter-cli 2.1.0__py3-none-any.whl → 2.2.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.
- coderouter/cli.py +219 -0
- coderouter/config/schemas.py +132 -2
- coderouter/guards/__init__.py +6 -4
- coderouter/guards/backend_health.py +34 -0
- coderouter/guards/self_healing.py +413 -0
- coderouter/guards/tool_loop.py +71 -0
- coderouter/ingress/anthropic_routes.py +31 -1
- coderouter/ingress/app.py +90 -0
- coderouter/logging.py +108 -0
- coderouter/metrics/collector.py +75 -0
- coderouter/output_filters.py +95 -4
- coderouter/routing/budget.py +35 -0
- coderouter/routing/fallback.py +211 -1
- coderouter/state/__init__.py +15 -0
- coderouter/state/audit_log.py +269 -0
- coderouter/state/replay.py +316 -0
- coderouter/state/request_log.py +178 -0
- coderouter/state/store.py +212 -0
- coderouter/translation/tool_repair.py +42 -1
- coderouter_cli-2.2.0.dist-info/METADATA +243 -0
- {coderouter_cli-2.1.0.dist-info → coderouter_cli-2.2.0.dist-info}/RECORD +24 -18
- coderouter_cli-2.1.0.dist-info/METADATA +0 -560
- {coderouter_cli-2.1.0.dist-info → coderouter_cli-2.2.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.1.0.dist-info → coderouter_cli-2.2.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.1.0.dist-info → coderouter_cli-2.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Request journal for replay analysis (v2.0-K Replay framework).
|
|
2
|
+
|
|
3
|
+
Records per-request metadata (provider, model, profile, token counts,
|
|
4
|
+
latency, cost, stop_reason) as append-only JSONL. The request/response
|
|
5
|
+
body is NOT recorded (privacy + size).
|
|
6
|
+
|
|
7
|
+
Architecture
|
|
8
|
+
============
|
|
9
|
+
|
|
10
|
+
Implements ``logging.Handler`` and captures ``cache-observed`` events
|
|
11
|
+
(one per successful request, carrying token counts + cost) paired with
|
|
12
|
+
timing data computed from ``try-provider`` → ``provider-ok`` deltas.
|
|
13
|
+
|
|
14
|
+
The engine emits these events in sequence::
|
|
15
|
+
|
|
16
|
+
try-provider → provider (name), stream (bool)
|
|
17
|
+
provider-ok → provider (name), stream (bool)
|
|
18
|
+
cache-observed→ provider, input_tokens, output_tokens, cost_usd, ...
|
|
19
|
+
|
|
20
|
+
The handler captures ``cache-observed`` as the authoritative "request
|
|
21
|
+
completed" signal (it fires exactly once per successful response and
|
|
22
|
+
carries the richest payload).
|
|
23
|
+
|
|
24
|
+
File rotation
|
|
25
|
+
=============
|
|
26
|
+
|
|
27
|
+
Same single-backup rotation as :class:`AuditLogHandler`: when the
|
|
28
|
+
active file exceeds ``max_bytes``, rename to ``.1`` and start fresh.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
# Events to capture for the request journal.
|
|
39
|
+
_JOURNAL_EVENTS: frozenset[str] = frozenset({"cache-observed"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RequestLogHandler(logging.Handler):
|
|
43
|
+
"""Append-only JSONL handler for request metadata.
|
|
44
|
+
|
|
45
|
+
Each line records one successful request's metadata (provider,
|
|
46
|
+
tokens, cost, streaming flag). Failed requests are not recorded
|
|
47
|
+
— only requests that produced a response.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
log_path: str | Path,
|
|
53
|
+
*,
|
|
54
|
+
max_bytes: int = 52_428_800, # 50 MiB default
|
|
55
|
+
) -> None:
|
|
56
|
+
super().__init__(level=logging.DEBUG)
|
|
57
|
+
self._log_path = Path(log_path)
|
|
58
|
+
self._max_bytes = max_bytes
|
|
59
|
+
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._file = open(self._log_path, "a", encoding="utf-8") # noqa: SIM115
|
|
61
|
+
|
|
62
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
63
|
+
"""Write a journal line for cache-observed events."""
|
|
64
|
+
if record.msg not in _JOURNAL_EVENTS:
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
self.acquire()
|
|
68
|
+
try:
|
|
69
|
+
line = self._format_line(record)
|
|
70
|
+
self._file.write(line)
|
|
71
|
+
self._file.flush()
|
|
72
|
+
self._maybe_rotate()
|
|
73
|
+
finally:
|
|
74
|
+
self.release()
|
|
75
|
+
except Exception:
|
|
76
|
+
self.handleError(record)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
"""Flush and close the underlying file."""
|
|
80
|
+
self.acquire()
|
|
81
|
+
try:
|
|
82
|
+
if self._file and not self._file.closed:
|
|
83
|
+
self._file.flush()
|
|
84
|
+
self._file.close()
|
|
85
|
+
finally:
|
|
86
|
+
self.release()
|
|
87
|
+
super().close()
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Internals
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _format_line(self, record: logging.LogRecord) -> str:
|
|
94
|
+
"""Build a journal JSONL line from a cache-observed log record."""
|
|
95
|
+
payload: dict[str, object] = {
|
|
96
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
97
|
+
"provider": getattr(record, "provider", None),
|
|
98
|
+
"input_tokens": getattr(record, "input_tokens", 0),
|
|
99
|
+
"output_tokens": getattr(record, "output_tokens", 0),
|
|
100
|
+
"cost_usd": getattr(record, "cost_usd", 0.0),
|
|
101
|
+
"cost_savings_usd": getattr(record, "cost_savings_usd", 0.0),
|
|
102
|
+
"streaming": getattr(record, "streaming", False),
|
|
103
|
+
"cache_read_input_tokens": getattr(record, "cache_read_input_tokens", 0),
|
|
104
|
+
"cache_creation_input_tokens": getattr(record, "cache_creation_input_tokens", 0),
|
|
105
|
+
"outcome": getattr(record, "outcome", "unknown"),
|
|
106
|
+
}
|
|
107
|
+
return json.dumps(payload, default=str) + "\n"
|
|
108
|
+
|
|
109
|
+
def _maybe_rotate(self) -> None:
|
|
110
|
+
"""Rotate if the current file exceeds max_bytes."""
|
|
111
|
+
try:
|
|
112
|
+
size = self._file.tell()
|
|
113
|
+
if size < self._max_bytes:
|
|
114
|
+
return
|
|
115
|
+
self._file.close()
|
|
116
|
+
backup = self._log_path.with_suffix(".jsonl.1")
|
|
117
|
+
if backup.exists():
|
|
118
|
+
backup.unlink()
|
|
119
|
+
self._log_path.rename(backup)
|
|
120
|
+
self._file = open(self._log_path, "a", encoding="utf-8") # noqa: SIM115
|
|
121
|
+
except OSError:
|
|
122
|
+
if self._file.closed:
|
|
123
|
+
self._file = open(self._log_path, "a", encoding="utf-8") # noqa: SIM115
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_request_log(
|
|
127
|
+
log_path: str | Path,
|
|
128
|
+
*,
|
|
129
|
+
tail: int | None = None,
|
|
130
|
+
provider_filter: str | None = None,
|
|
131
|
+
since: str | None = None,
|
|
132
|
+
) -> list[dict[str, object]]:
|
|
133
|
+
"""Read and filter request journal entries.
|
|
134
|
+
|
|
135
|
+
Parameters:
|
|
136
|
+
|
|
137
|
+
- ``tail`` — return only the last N entries.
|
|
138
|
+
- ``provider_filter`` — only entries matching this provider name
|
|
139
|
+
(exact, case-insensitive).
|
|
140
|
+
- ``since`` — only entries with ``ts >= since`` (ISO 8601 prefix).
|
|
141
|
+
|
|
142
|
+
Returns a list of parsed dicts, oldest first.
|
|
143
|
+
"""
|
|
144
|
+
path = Path(log_path)
|
|
145
|
+
if not path.exists():
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
entries: list[dict[str, object]] = []
|
|
149
|
+
with open(path, encoding="utf-8") as f:
|
|
150
|
+
for line in f:
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
entry = json.loads(line)
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if provider_filter and str(entry.get("provider", "")).lower() != provider_filter.lower():
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if since:
|
|
163
|
+
ts = str(entry.get("ts", ""))
|
|
164
|
+
if ts < since:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
entries.append(entry)
|
|
168
|
+
|
|
169
|
+
if tail is not None and tail > 0:
|
|
170
|
+
entries = entries[-tail:]
|
|
171
|
+
|
|
172
|
+
return entries
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = [
|
|
176
|
+
"RequestLogHandler",
|
|
177
|
+
"read_request_log",
|
|
178
|
+
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Persistent KV store backed by sqlite3 (v2.0-K).
|
|
2
|
+
|
|
3
|
+
Provides durable cross-restart storage for operational metadata:
|
|
4
|
+
budget totals, backend health state, self-healing exclusions, and
|
|
5
|
+
metrics counters.
|
|
6
|
+
|
|
7
|
+
Design choices
|
|
8
|
+
==============
|
|
9
|
+
|
|
10
|
+
- **sqlite3 stdlib** — zero new dependencies, matching the 5-deps
|
|
11
|
+
invariant. WAL mode for concurrent readers + single writer.
|
|
12
|
+
- **Namespace-scoped keys** — each subsystem (budget, health, metrics,
|
|
13
|
+
self_healing) gets its own namespace, so ``store.get("budget",
|
|
14
|
+
"totals")`` won't collide with ``store.get("health", "totals")``.
|
|
15
|
+
- **JSON values** — all values are serialized as JSON strings. This
|
|
16
|
+
keeps the schema trivial (one table) while letting subsystems store
|
|
17
|
+
arbitrary structured data.
|
|
18
|
+
- **Thread-safe** — sqlite3 in WAL mode + ``check_same_thread=False``
|
|
19
|
+
is safe for the multi-thread (asyncio + thread pool) pattern
|
|
20
|
+
CodeRouter uses.
|
|
21
|
+
- **Graceful degradation** — if the database can't be opened or
|
|
22
|
+
written, errors are logged but never raised to callers. State
|
|
23
|
+
persistence is a best-effort enhancement, not a correctness
|
|
24
|
+
requirement.
|
|
25
|
+
|
|
26
|
+
Schema::
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
29
|
+
namespace TEXT NOT NULL,
|
|
30
|
+
key TEXT NOT NULL,
|
|
31
|
+
value TEXT NOT NULL,
|
|
32
|
+
updated_at TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (namespace, key)
|
|
34
|
+
);
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import contextlib
|
|
40
|
+
import json
|
|
41
|
+
import sqlite3
|
|
42
|
+
import threading
|
|
43
|
+
from datetime import UTC, datetime
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from coderouter.logging import get_logger
|
|
47
|
+
|
|
48
|
+
logger = get_logger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StateStore:
|
|
52
|
+
"""Persistent KV store for operational metadata.
|
|
53
|
+
|
|
54
|
+
Public API:
|
|
55
|
+
|
|
56
|
+
- :meth:`get(namespace, key)` — retrieve a JSON-decoded value,
|
|
57
|
+
or None if not found.
|
|
58
|
+
- :meth:`put(namespace, key, value)` — upsert a JSON-encoded value.
|
|
59
|
+
- :meth:`delete(namespace, key)` — remove a key.
|
|
60
|
+
- :meth:`get_all(namespace)` — retrieve all key-value pairs in a
|
|
61
|
+
namespace as a dict.
|
|
62
|
+
- :meth:`clear(namespace)` — remove all keys in a namespace.
|
|
63
|
+
- :meth:`close()` — close the database connection.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
67
|
+
self._db_path = Path(db_path)
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
self._conn: sqlite3.Connection | None = None
|
|
70
|
+
self._init_db()
|
|
71
|
+
|
|
72
|
+
def _init_db(self) -> None:
|
|
73
|
+
"""Create the database and table if they don't exist."""
|
|
74
|
+
try:
|
|
75
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
conn = sqlite3.connect(
|
|
77
|
+
str(self._db_path),
|
|
78
|
+
check_same_thread=False,
|
|
79
|
+
timeout=5.0,
|
|
80
|
+
)
|
|
81
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
82
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
83
|
+
conn.execute(
|
|
84
|
+
"""
|
|
85
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
86
|
+
namespace TEXT NOT NULL,
|
|
87
|
+
key TEXT NOT NULL,
|
|
88
|
+
value TEXT NOT NULL,
|
|
89
|
+
updated_at TEXT NOT NULL,
|
|
90
|
+
PRIMARY KEY (namespace, key)
|
|
91
|
+
)
|
|
92
|
+
"""
|
|
93
|
+
)
|
|
94
|
+
conn.commit()
|
|
95
|
+
self._conn = conn
|
|
96
|
+
except (OSError, sqlite3.Error) as exc:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"state-store-init-failed",
|
|
99
|
+
extra={"error": str(exc), "db_path": str(self._db_path)},
|
|
100
|
+
)
|
|
101
|
+
self._conn = None
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Public API
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def get(self, namespace: str, key: str) -> object | None:
|
|
108
|
+
"""Retrieve a value by namespace+key, or None if not found."""
|
|
109
|
+
if self._conn is None:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
with self._lock:
|
|
113
|
+
row = self._conn.execute(
|
|
114
|
+
"SELECT value FROM kv WHERE namespace = ? AND key = ?",
|
|
115
|
+
(namespace, key),
|
|
116
|
+
).fetchone()
|
|
117
|
+
if row is None:
|
|
118
|
+
return None
|
|
119
|
+
return json.loads(row[0])
|
|
120
|
+
except (sqlite3.Error, json.JSONDecodeError) as exc:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"state-store-get-failed",
|
|
123
|
+
extra={"namespace": namespace, "key": key, "error": str(exc)},
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def put(self, namespace: str, key: str, value: object) -> None:
|
|
128
|
+
"""Upsert a value (JSON-serialized)."""
|
|
129
|
+
if self._conn is None:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
now = datetime.now(UTC).isoformat()
|
|
133
|
+
value_json = json.dumps(value, default=str)
|
|
134
|
+
with self._lock:
|
|
135
|
+
self._conn.execute(
|
|
136
|
+
"""
|
|
137
|
+
INSERT INTO kv (namespace, key, value, updated_at)
|
|
138
|
+
VALUES (?, ?, ?, ?)
|
|
139
|
+
ON CONFLICT(namespace, key)
|
|
140
|
+
DO UPDATE SET value = excluded.value,
|
|
141
|
+
updated_at = excluded.updated_at
|
|
142
|
+
""",
|
|
143
|
+
(namespace, key, value_json, now),
|
|
144
|
+
)
|
|
145
|
+
self._conn.commit()
|
|
146
|
+
except (sqlite3.Error, TypeError) as exc:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"state-store-put-failed",
|
|
149
|
+
extra={"namespace": namespace, "key": key, "error": str(exc)},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def delete(self, namespace: str, key: str) -> None:
|
|
153
|
+
"""Remove a key from the store."""
|
|
154
|
+
if self._conn is None:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._conn.execute(
|
|
159
|
+
"DELETE FROM kv WHERE namespace = ? AND key = ?",
|
|
160
|
+
(namespace, key),
|
|
161
|
+
)
|
|
162
|
+
self._conn.commit()
|
|
163
|
+
except sqlite3.Error as exc:
|
|
164
|
+
logger.warning(
|
|
165
|
+
"state-store-delete-failed",
|
|
166
|
+
extra={"namespace": namespace, "key": key, "error": str(exc)},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def get_all(self, namespace: str) -> dict[str, object]:
|
|
170
|
+
"""Return all key-value pairs in a namespace."""
|
|
171
|
+
if self._conn is None:
|
|
172
|
+
return {}
|
|
173
|
+
try:
|
|
174
|
+
with self._lock:
|
|
175
|
+
rows = self._conn.execute(
|
|
176
|
+
"SELECT key, value FROM kv WHERE namespace = ?",
|
|
177
|
+
(namespace,),
|
|
178
|
+
).fetchall()
|
|
179
|
+
return {row[0]: json.loads(row[1]) for row in rows}
|
|
180
|
+
except (sqlite3.Error, json.JSONDecodeError) as exc:
|
|
181
|
+
logger.warning(
|
|
182
|
+
"state-store-get-all-failed",
|
|
183
|
+
extra={"namespace": namespace, "error": str(exc)},
|
|
184
|
+
)
|
|
185
|
+
return {}
|
|
186
|
+
|
|
187
|
+
def clear(self, namespace: str) -> None:
|
|
188
|
+
"""Remove all keys in a namespace."""
|
|
189
|
+
if self._conn is None:
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
with self._lock:
|
|
193
|
+
self._conn.execute(
|
|
194
|
+
"DELETE FROM kv WHERE namespace = ?",
|
|
195
|
+
(namespace,),
|
|
196
|
+
)
|
|
197
|
+
self._conn.commit()
|
|
198
|
+
except sqlite3.Error as exc:
|
|
199
|
+
logger.warning(
|
|
200
|
+
"state-store-clear-failed",
|
|
201
|
+
extra={"namespace": namespace, "error": str(exc)},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def close(self) -> None:
|
|
205
|
+
"""Close the database connection."""
|
|
206
|
+
if self._conn is not None:
|
|
207
|
+
with contextlib.suppress(sqlite3.Error):
|
|
208
|
+
self._conn.close()
|
|
209
|
+
self._conn = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
__all__ = ["StateStore"]
|
|
@@ -41,7 +41,7 @@ import re
|
|
|
41
41
|
import uuid
|
|
42
42
|
from typing import Any
|
|
43
43
|
|
|
44
|
-
__all__ = ["repair_tool_calls_in_text"]
|
|
44
|
+
__all__ = ["deduplicate_tool_calls", "repair_tool_calls_in_text"]
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
# ------------------------------------------------------------------
|
|
@@ -233,4 +233,45 @@ def repair_tool_calls_in_text(
|
|
|
233
233
|
cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
|
|
234
234
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
235
235
|
|
|
236
|
+
# v2.2: deduplicate tool calls within the same response.
|
|
237
|
+
# Small models sometimes output the same tool-call JSON 2-3 times
|
|
238
|
+
# in a single turn. We keep the first occurrence only.
|
|
239
|
+
extracted = deduplicate_tool_calls(extracted)
|
|
240
|
+
|
|
236
241
|
return cleaned, extracted
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# Deduplication (v2.2)
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def deduplicate_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
250
|
+
"""Remove duplicate tool calls sharing the same (name, arguments).
|
|
251
|
+
|
|
252
|
+
Preserves order — the first occurrence wins. Each entry is expected
|
|
253
|
+
to be in OpenAI tool_calls shape (``{"function": {"name": ...,
|
|
254
|
+
"arguments": ...}, ...}``). Entries that lack the expected
|
|
255
|
+
structure are kept unconditionally (conservative fallback).
|
|
256
|
+
|
|
257
|
+
This is separate from L3 tool-loop detection (which operates across
|
|
258
|
+
turns in the conversation history). Deduplication operates within a
|
|
259
|
+
single assistant response where the model outputted the same JSON
|
|
260
|
+
tool-call block multiple times.
|
|
261
|
+
"""
|
|
262
|
+
if len(tool_calls) <= 1:
|
|
263
|
+
return tool_calls
|
|
264
|
+
|
|
265
|
+
seen: set[tuple[str, str]] = set()
|
|
266
|
+
deduped: list[dict[str, Any]] = []
|
|
267
|
+
for tc in tool_calls:
|
|
268
|
+
func = tc.get("function")
|
|
269
|
+
if not isinstance(func, dict):
|
|
270
|
+
# Not in expected shape — keep unconditionally.
|
|
271
|
+
deduped.append(tc)
|
|
272
|
+
continue
|
|
273
|
+
key = (func.get("name", ""), func.get("arguments", ""))
|
|
274
|
+
if key not in seen:
|
|
275
|
+
seen.add(key)
|
|
276
|
+
deduped.append(tc)
|
|
277
|
+
return deduped
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coderouter-cli
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
|
+
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
|
+
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
7
|
+
Project-URL: Issues, https://github.com/zephel01/CodeRouter/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/zephel01/CodeRouter/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://github.com/zephel01/CodeRouter#documentation
|
|
10
|
+
Author-email: zephel01 <zephel01@gmail.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: claude,claude-code,fallback,llm,local-first,nvidia-nim,ollama,openai,openrouter,router
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: System :: Networking
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Requires-Dist: fastapi>=0.115.0
|
|
26
|
+
Requires-Dist: httpx>=0.27.0
|
|
27
|
+
Requires-Dist: pydantic>=2.9.0
|
|
28
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
29
|
+
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-httpx>=0.32.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruamel-yaml>=0.18.6; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.7.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
|
|
38
|
+
Provides-Extra: doctor
|
|
39
|
+
Requires-Dist: ruamel-yaml>=0.18.6; extra == 'doctor'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
<h1 align="center">CodeRouter</h1>
|
|
43
|
+
|
|
44
|
+
<p align="center">
|
|
45
|
+
<strong>ローカル LLM で Claude Code を動かすと壊れる問題、<br>ルーター 1 つで直します。</strong>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
<p align="center">
|
|
49
|
+
<a href="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml"><img src="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
|
50
|
+
<a href=""><img src="https://img.shields.io/badge/version-2.2.0-blue" alt="version"></a>
|
|
51
|
+
<a href=""><img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="python"></a>
|
|
52
|
+
<a href=""><img src="https://img.shields.io/badge/deps-5-brightgreen" alt="deps"></a>
|
|
53
|
+
<a href=""><img src="https://img.shields.io/badge/license-MIT-yellow" alt="license"></a>
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
<p align="center">
|
|
57
|
+
<a href="./README.en.md">English</a> · <strong>日本語</strong> · <a href="./docs/quickstart.md">10 分で動かす</a> · <a href="./docs/architecture.md">設計詳細</a>
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 何ができるか — 30 秒で
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
あなたのエージェント (Claude Code / gemini-cli / codex)
|
|
66
|
+
│
|
|
67
|
+
▼
|
|
68
|
+
┌─ CodeRouter ─┐
|
|
69
|
+
│ 翻訳 + 修復 │──→ ① ローカル (Ollama — 無料・最速)
|
|
70
|
+
│ ガード + 監視 │──→ ② 無料クラウド (OpenRouter / NIM)
|
|
71
|
+
│ 自動フォールバック │──→ ③ 有料 (Claude — opt-in 時のみ)
|
|
72
|
+
└──────────────┘
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**やってくれること:**
|
|
76
|
+
|
|
77
|
+
- ローカルモデルが壊した tool calling を Claude Code に届く前に修復する
|
|
78
|
+
- 1 つ目が落ちたら自動で次のプロバイダに切り替える
|
|
79
|
+
- 有料 API は明示的に許可したときだけ使う (デフォルトは無料のみ)
|
|
80
|
+
- 8 時間回しても止まらないように 6 種類のガードで守る
|
|
81
|
+
- 何がおかしいか `coderouter doctor` コマンド一発で診断する
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## インストール (3 行)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# 1. サンプル設定を置く
|
|
89
|
+
mkdir -p ~/.coderouter
|
|
90
|
+
curl -fsSL https://raw.githubusercontent.com/zephel01/CodeRouter/main/examples/providers.yaml \
|
|
91
|
+
> ~/.coderouter/providers.yaml
|
|
92
|
+
|
|
93
|
+
# 2. 起動 (Python 3.12+)
|
|
94
|
+
uvx --from coderouter-cli coderouter serve --port 8088
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
恒久インストールしたい場合: `uv tool install coderouter-cli`
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Claude Code で使う
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# ターミナル 1
|
|
105
|
+
coderouter serve --port 8088
|
|
106
|
+
|
|
107
|
+
# ターミナル 2
|
|
108
|
+
ANTHROPIC_BASE_URL=http://localhost:8088 ANTHROPIC_AUTH_TOKEN=dummy claude
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
これだけ。Claude Code はいつも通り動きますが、裏ではローカルの Ollama が答えています。
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 自分に必要?
|
|
116
|
+
|
|
117
|
+
| あなたの状況 | CodeRouter は? |
|
|
118
|
+
|---|---|
|
|
119
|
+
| Claude Code + ローカル Ollama で tool calling が壊れる | **必須** — wire 変換 + tool 修復 |
|
|
120
|
+
| Claude Code + ローカルで長時間回すと止まる | **便利** — 6 系統ガード + self-healing |
|
|
121
|
+
| codex / gemini-cli + Ollama 直繋ぎで動いてる | オプション — フォールバックが欲しいなら |
|
|
122
|
+
| Claude API を直接叩いてて問題ない | 不要 |
|
|
123
|
+
|
|
124
|
+
詳細は → [要否判定ガイド](./docs/when-do-i-need-coderouter.md)
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 主な機能
|
|
129
|
+
|
|
130
|
+
### 接続と修復
|
|
131
|
+
|
|
132
|
+
| 機能 | 何をしてくれるか |
|
|
133
|
+
|---|---|
|
|
134
|
+
| **Wire 翻訳** | Claude Code (Anthropic形式) ↔ Ollama (OpenAI形式) を自動変換 |
|
|
135
|
+
| **Tool-call 修復** | ローカルモデルがテキストで吐いた JSON を正しい tool_use ブロックに復元 |
|
|
136
|
+
| **3 層フォールバック** | ローカル → 無料クラウド → 有料の順に自動切替 |
|
|
137
|
+
| **出力フィルタ** | `<think>` タグ漏れ、stop marker 漏れを自動除去 |
|
|
138
|
+
|
|
139
|
+
### 長時間運用ガード
|
|
140
|
+
|
|
141
|
+
| ガード | 何から守るか |
|
|
142
|
+
|---|---|
|
|
143
|
+
| **Context Budget** | メッセージが溜まりすぎて context window 溢れ → 自動 trim |
|
|
144
|
+
| **Drift Detection** | モデルの応答品質が徐々に劣化 → 別 provider に切替 or KV cache flush |
|
|
145
|
+
| **Self-healing** | backend が落ちた → 自動除外 + restart + 回復 probe で自動復帰 |
|
|
146
|
+
| **Tool Loop Guard** | 同じツールを無限に呼び続ける → 検知して停止 |
|
|
147
|
+
| **Memory Pressure** | GPU メモリ不足を検知 → 軽量モデルに切替 |
|
|
148
|
+
| **Mid-stream Guard** | 応答途中で落ちた → 溜まったテキストを安全に返却 |
|
|
149
|
+
|
|
150
|
+
### 診断と可視化
|
|
151
|
+
|
|
152
|
+
| 機能 | 何がわかるか |
|
|
153
|
+
|---|---|
|
|
154
|
+
| **`coderouter doctor`** | プロバイダの問題を 6 プローブで即診断 + 修正パッチ出力 |
|
|
155
|
+
| **`/dashboard`** | ブラウザで今何が起きてるかリアルタイム確認 |
|
|
156
|
+
| **`coderouter audit`** | guard 発火履歴を検索 |
|
|
157
|
+
| **`coderouter replay`** | provider 切替の効果を統計比較 (A/B 分析) |
|
|
158
|
+
| **Continuous Probe** | idle 時も定期的に backend を監視 |
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 設定例 (最小)
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
# ~/.coderouter/providers.yaml
|
|
166
|
+
default_profile: claude-code
|
|
167
|
+
|
|
168
|
+
profiles:
|
|
169
|
+
- name: claude-code
|
|
170
|
+
providers: [ollama-local, openrouter-free]
|
|
171
|
+
|
|
172
|
+
providers:
|
|
173
|
+
- name: ollama-local
|
|
174
|
+
kind: openai_compat
|
|
175
|
+
base_url: http://localhost:11434/v1
|
|
176
|
+
model: qwen3-coder:7b
|
|
177
|
+
|
|
178
|
+
- name: openrouter-free
|
|
179
|
+
kind: openai_compat
|
|
180
|
+
base_url: https://openrouter.ai/api/v1
|
|
181
|
+
model: qwen/qwen3-coder:free
|
|
182
|
+
api_key_env: OPENROUTER_API_KEY
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
もっと詳しい設定 → [利用ガイド](./docs/usage-guide.md) · [設計詳細](./docs/architecture.md)
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## ドキュメント
|
|
190
|
+
|
|
191
|
+
| やりたいこと | ドキュメント |
|
|
192
|
+
|---|---|
|
|
193
|
+
| すぐ動かす | [Quickstart](./docs/quickstart.md) |
|
|
194
|
+
| 使いこなす | [利用ガイド](./docs/usage-guide.md) |
|
|
195
|
+
| 無料で回す | [無料枠ガイド](./docs/free-tier-guide.md) |
|
|
196
|
+
| 詰まった | [トラブルシューティング](./docs/troubleshooting.md) |
|
|
197
|
+
| 設計を知りたい | [アーキテクチャ詳細](./docs/architecture.md) |
|
|
198
|
+
| 全リリース履歴 | [CHANGELOG](./CHANGELOG.md) |
|
|
199
|
+
|
|
200
|
+
English: [Quickstart](./docs/quickstart.en.md) · [Usage guide](./docs/usage-guide.en.md) · [Free-tier](./docs/free-tier-guide.en.md) · [Troubleshooting](./docs/troubleshooting.en.md)
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## トラブルシューティング (早見表)
|
|
205
|
+
|
|
206
|
+
**まず**: `coderouter doctor --check-model <provider名>` を走らせてください。大体これで原因がわかります。
|
|
207
|
+
|
|
208
|
+
| 症状 | 原因 | 詳細 |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| 401 エラー | API キー未設定 / `.env` に `export` 忘れ | [§1](./docs/troubleshooting.md#1-起動設定で踏みやすい-5-つの罠-v162-追加) |
|
|
211
|
+
| 返信が空 / 意味不明 | Ollama の `num_ctx` が 2048 に切り詰め | [§3](./docs/troubleshooting.md#3-ollama-初心者--サイレント失敗-5-症状-v07-c) |
|
|
212
|
+
| `<think>` タグが漏れる | `output_filters: [strip_thinking]` を付ける | [§3](./docs/troubleshooting.md#3-ollama-初心者--サイレント失敗-5-症状-v07-c) |
|
|
213
|
+
| Claude Code でツール呼び出しがおかしい | tool-call 修復が効いてない | [§4](./docs/troubleshooting.md#4-claude-code-連携で踏みやすい罠-v162-追加) |
|
|
214
|
+
|
|
215
|
+
`http://localhost:8088/dashboard` を開いておくと、ほとんどの問題が見て 10 秒でわかります。
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## 技術スペック
|
|
220
|
+
|
|
221
|
+
- **ランタイム依存**: `fastapi` / `uvicorn` / `httpx` / `pydantic` / `pyyaml` の 5 個のみ
|
|
222
|
+
- **テスト**: 964 本 (41 sub-release 連続で依存追加なし)
|
|
223
|
+
- **対応 OS**: macOS (Apple Silicon 推奨) / Linux / Windows WSL2
|
|
224
|
+
- **対応 backend**: Ollama / llama.cpp / LM Studio / vLLM / MLX-LM / OpenRouter / NVIDIA NIM / Anthropic API
|
|
225
|
+
- **ライセンス**: MIT
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## エコシステム
|
|
230
|
+
|
|
231
|
+
CodeRouter は backend ルーター層として独立して動きます。`OPENAI_BASE_URL` を CodeRouter に向けるだけで、他プロジェクトを無改造で吸収:
|
|
232
|
+
|
|
233
|
+
- **[Voice Bridge](https://github.com/zephel01/voice-bridge)** — リアルタイム音声翻訳 + AI 音声チャット。CodeRouter 経由でローカル LLM のフォールバックを効かせると、ずんだもんが沈黙しなくなる
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Security
|
|
238
|
+
|
|
239
|
+
シークレットは環境変数に置きます。[`docs/security.md`](./docs/security.md) に完全な方針と報告手順があります。
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|