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.
@@ -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