coderouter-cli 2.6.0__py3-none-any.whl → 2.7.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/adapters/anthropic_native.py +66 -30
- coderouter/adapters/base.py +46 -3
- coderouter/adapters/openai_compat.py +53 -11
- coderouter/config/schemas.py +154 -0
- coderouter/guards/context_budget.py +114 -19
- coderouter/guards/continuous_probe.py +9 -1
- coderouter/guards/memory_pressure.py +16 -2
- coderouter/guards/self_healing.py +14 -0
- coderouter/ingress/anthropic_routes.py +115 -1
- coderouter/ingress/app.py +162 -1
- coderouter/ingress/dashboard_routes.py +23 -0
- coderouter/ingress/launcher_routes.py +187 -11
- coderouter/ingress/openai_routes.py +77 -7
- coderouter/logging.py +24 -3
- coderouter/metrics/collector.py +479 -210
- coderouter/metrics/prometheus.py +3 -3
- coderouter/routing/fallback.py +387 -27
- coderouter/state/audit_log.py +69 -7
- coderouter/state/request_log.py +70 -7
- coderouter/translation/convert.py +112 -9
- coderouter/translation/tool_repair.py +39 -23
- {coderouter_cli-2.6.0.dist-info → coderouter_cli-2.7.0.dist-info}/METADATA +32 -1
- {coderouter_cli-2.6.0.dist-info → coderouter_cli-2.7.0.dist-info}/RECORD +26 -26
- {coderouter_cli-2.6.0.dist-info → coderouter_cli-2.7.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.6.0.dist-info → coderouter_cli-2.7.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.6.0.dist-info → coderouter_cli-2.7.0.dist-info}/licenses/LICENSE +0 -0
coderouter/state/audit_log.py
CHANGED
|
@@ -41,11 +41,22 @@ lines (no partial-line interleaving) because Python's stdlib file
|
|
|
41
41
|
|
|
42
42
|
from __future__ import annotations
|
|
43
43
|
|
|
44
|
+
import atexit
|
|
45
|
+
import contextlib
|
|
44
46
|
import json
|
|
45
47
|
import logging
|
|
48
|
+
import time
|
|
46
49
|
from datetime import UTC, datetime
|
|
47
50
|
from pathlib import Path
|
|
48
51
|
|
|
52
|
+
# M12(2) buffering defaults. Audit lines are lower-volume than request
|
|
53
|
+
# journal lines, but still fsync'd once per event pre-M12. We buffer and
|
|
54
|
+
# flush on whichever comes first — a count threshold or a wall-clock
|
|
55
|
+
# interval — with an unconditional flush on close()/atexit so no
|
|
56
|
+
# audit-worthy event is lost on clean shutdown.
|
|
57
|
+
_DEFAULT_FLUSH_EVERY_N: int = 20
|
|
58
|
+
_DEFAULT_FLUSH_INTERVAL_S: float = 2.0
|
|
59
|
+
|
|
49
60
|
# Events that are audit-worthy: guard state changes, chain decisions,
|
|
50
61
|
# cost/budget, self-healing lifecycle, drift, probing milestones.
|
|
51
62
|
_AUDIT_EVENTS: frozenset[str] = frozenset(
|
|
@@ -106,38 +117,89 @@ class AuditLogHandler(logging.Handler):
|
|
|
106
117
|
log_path: str | Path,
|
|
107
118
|
*,
|
|
108
119
|
max_bytes: int = 10_485_760,
|
|
120
|
+
flush_every_n: int = _DEFAULT_FLUSH_EVERY_N,
|
|
121
|
+
flush_interval_s: float = _DEFAULT_FLUSH_INTERVAL_S,
|
|
109
122
|
) -> None:
|
|
110
123
|
super().__init__(level=logging.DEBUG)
|
|
111
124
|
self._log_path = Path(log_path)
|
|
112
125
|
self._max_bytes = max_bytes
|
|
126
|
+
self._flush_every_n = max(1, flush_every_n)
|
|
127
|
+
self._flush_interval_s = flush_interval_s
|
|
128
|
+
self._buffer: list[str] = []
|
|
129
|
+
self._last_flush_monotonic = time.monotonic()
|
|
113
130
|
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
131
|
self._file = open(self._log_path, "a", encoding="utf-8") # noqa: SIM115
|
|
132
|
+
# Guarantee a final flush even if close() is never called
|
|
133
|
+
# explicitly (e.g. an ungraceful lifespan teardown).
|
|
134
|
+
atexit.register(self._atexit_flush)
|
|
115
135
|
|
|
116
136
|
def emit(self, record: logging.LogRecord) -> None:
|
|
117
|
-
"""
|
|
137
|
+
"""Buffer an audit line if the event is audit-worthy; flush lazily.
|
|
138
|
+
|
|
139
|
+
Write buffering (M12(2)): lines are flushed when either
|
|
140
|
+
``flush_every_n`` lines have accumulated or ``flush_interval_s``
|
|
141
|
+
seconds have elapsed since the last flush. :meth:`close`
|
|
142
|
+
(registered with :mod:`atexit`) always flushes so a clean
|
|
143
|
+
shutdown never drops a buffered audit line. Pass
|
|
144
|
+
``flush_every_n=1`` for the pre-M12 write-through behavior.
|
|
145
|
+
"""
|
|
118
146
|
if record.msg not in _AUDIT_EVENTS:
|
|
119
147
|
return
|
|
120
148
|
try:
|
|
121
149
|
self.acquire()
|
|
122
150
|
try:
|
|
123
|
-
|
|
124
|
-
self.
|
|
125
|
-
|
|
126
|
-
self._maybe_rotate()
|
|
151
|
+
self._buffer.append(self._format_line(record))
|
|
152
|
+
if self._should_flush():
|
|
153
|
+
self._flush_buffer()
|
|
127
154
|
finally:
|
|
128
155
|
self.release()
|
|
129
156
|
except Exception:
|
|
130
157
|
self.handleError(record)
|
|
131
158
|
|
|
159
|
+
def _should_flush(self) -> bool:
|
|
160
|
+
"""True when the count threshold or time interval has been reached."""
|
|
161
|
+
if len(self._buffer) >= self._flush_every_n:
|
|
162
|
+
return True
|
|
163
|
+
return (
|
|
164
|
+
time.monotonic() - self._last_flush_monotonic
|
|
165
|
+
) >= self._flush_interval_s
|
|
166
|
+
|
|
167
|
+
def _flush_buffer(self) -> None:
|
|
168
|
+
"""Write all buffered lines to disk, fsync, and maybe rotate.
|
|
169
|
+
|
|
170
|
+
Caller must hold the handler lock. No-op when the buffer is empty.
|
|
171
|
+
"""
|
|
172
|
+
if not self._buffer:
|
|
173
|
+
return
|
|
174
|
+
self._file.write("".join(self._buffer))
|
|
175
|
+
self._buffer.clear()
|
|
176
|
+
self._file.flush()
|
|
177
|
+
self._last_flush_monotonic = time.monotonic()
|
|
178
|
+
self._maybe_rotate()
|
|
179
|
+
|
|
180
|
+
def _atexit_flush(self) -> None:
|
|
181
|
+
"""Best-effort flush of any buffered lines at interpreter exit."""
|
|
182
|
+
try:
|
|
183
|
+
self.acquire()
|
|
184
|
+
try:
|
|
185
|
+
if self._file and not self._file.closed:
|
|
186
|
+
self._flush_buffer()
|
|
187
|
+
finally:
|
|
188
|
+
self.release()
|
|
189
|
+
except Exception: # pragma: no cover - defensive at shutdown
|
|
190
|
+
pass
|
|
191
|
+
|
|
132
192
|
def close(self) -> None:
|
|
133
|
-
"""Flush and close the underlying file."""
|
|
193
|
+
"""Flush buffered lines and close the underlying file."""
|
|
134
194
|
self.acquire()
|
|
135
195
|
try:
|
|
136
196
|
if self._file and not self._file.closed:
|
|
137
|
-
self.
|
|
197
|
+
self._flush_buffer()
|
|
138
198
|
self._file.close()
|
|
139
199
|
finally:
|
|
140
200
|
self.release()
|
|
201
|
+
with contextlib.suppress(Exception):
|
|
202
|
+
atexit.unregister(self._atexit_flush)
|
|
141
203
|
super().close()
|
|
142
204
|
|
|
143
205
|
# ------------------------------------------------------------------
|
coderouter/state/request_log.py
CHANGED
|
@@ -30,14 +30,25 @@ active file exceeds ``max_bytes``, rename to ``.1`` and start fresh.
|
|
|
30
30
|
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
33
|
+
import atexit
|
|
34
|
+
import contextlib
|
|
33
35
|
import json
|
|
34
36
|
import logging
|
|
37
|
+
import time
|
|
35
38
|
from datetime import UTC, datetime
|
|
36
39
|
from pathlib import Path
|
|
37
40
|
|
|
38
41
|
# Events to capture for the request journal.
|
|
39
42
|
_JOURNAL_EVENTS: frozenset[str] = frozenset({"cache-observed"})
|
|
40
43
|
|
|
44
|
+
# M12(2) buffering defaults. A request journal line is written per
|
|
45
|
+
# successful request; fsync-on-every-write is wasteful under load. We
|
|
46
|
+
# buffer in-process and flush on whichever comes first — a count
|
|
47
|
+
# threshold or a wall-clock interval — plus an unconditional flush on
|
|
48
|
+
# close()/atexit so no completed request is ever lost on clean shutdown.
|
|
49
|
+
_DEFAULT_FLUSH_EVERY_N: int = 20
|
|
50
|
+
_DEFAULT_FLUSH_INTERVAL_S: float = 2.0
|
|
51
|
+
|
|
41
52
|
|
|
42
53
|
class RequestLogHandler(logging.Handler):
|
|
43
54
|
"""Append-only JSONL handler for request metadata.
|
|
@@ -45,6 +56,15 @@ class RequestLogHandler(logging.Handler):
|
|
|
45
56
|
Each line records one successful request's metadata (provider,
|
|
46
57
|
tokens, cost, streaming flag). Failed requests are not recorded
|
|
47
58
|
— only requests that produced a response.
|
|
59
|
+
|
|
60
|
+
Write buffering (M12(2))
|
|
61
|
+
Lines are buffered and flushed to disk when either
|
|
62
|
+
``flush_every_n`` lines have accumulated or ``flush_interval_s``
|
|
63
|
+
seconds have elapsed since the last flush — whichever comes
|
|
64
|
+
first. :meth:`close` (registered with :mod:`atexit`) always
|
|
65
|
+
flushes, so a clean shutdown never drops a buffered line. Pass
|
|
66
|
+
``flush_every_n=1`` for the pre-M12 write-through behavior (tests
|
|
67
|
+
that assert immediate on-disk visibility use this).
|
|
48
68
|
"""
|
|
49
69
|
|
|
50
70
|
def __init__(
|
|
@@ -52,38 +72,81 @@ class RequestLogHandler(logging.Handler):
|
|
|
52
72
|
log_path: str | Path,
|
|
53
73
|
*,
|
|
54
74
|
max_bytes: int = 52_428_800, # 50 MiB default
|
|
75
|
+
flush_every_n: int = _DEFAULT_FLUSH_EVERY_N,
|
|
76
|
+
flush_interval_s: float = _DEFAULT_FLUSH_INTERVAL_S,
|
|
55
77
|
) -> None:
|
|
56
78
|
super().__init__(level=logging.DEBUG)
|
|
57
79
|
self._log_path = Path(log_path)
|
|
58
80
|
self._max_bytes = max_bytes
|
|
81
|
+
self._flush_every_n = max(1, flush_every_n)
|
|
82
|
+
self._flush_interval_s = flush_interval_s
|
|
83
|
+
self._buffer: list[str] = []
|
|
84
|
+
self._last_flush_monotonic = time.monotonic()
|
|
59
85
|
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
86
|
self._file = open(self._log_path, "a", encoding="utf-8") # noqa: SIM115
|
|
87
|
+
# Guarantee a final flush even if close() is never called
|
|
88
|
+
# explicitly (e.g. an ungraceful lifespan teardown).
|
|
89
|
+
atexit.register(self._atexit_flush)
|
|
61
90
|
|
|
62
91
|
def emit(self, record: logging.LogRecord) -> None:
|
|
63
|
-
"""
|
|
92
|
+
"""Buffer a journal line for cache-observed events; flush lazily."""
|
|
64
93
|
if record.msg not in _JOURNAL_EVENTS:
|
|
65
94
|
return
|
|
66
95
|
try:
|
|
67
96
|
self.acquire()
|
|
68
97
|
try:
|
|
69
|
-
|
|
70
|
-
self.
|
|
71
|
-
|
|
72
|
-
self._maybe_rotate()
|
|
98
|
+
self._buffer.append(self._format_line(record))
|
|
99
|
+
if self._should_flush():
|
|
100
|
+
self._flush_buffer()
|
|
73
101
|
finally:
|
|
74
102
|
self.release()
|
|
75
103
|
except Exception:
|
|
76
104
|
self.handleError(record)
|
|
77
105
|
|
|
106
|
+
def _should_flush(self) -> bool:
|
|
107
|
+
"""True when the count threshold or time interval has been reached."""
|
|
108
|
+
if len(self._buffer) >= self._flush_every_n:
|
|
109
|
+
return True
|
|
110
|
+
return (
|
|
111
|
+
time.monotonic() - self._last_flush_monotonic
|
|
112
|
+
) >= self._flush_interval_s
|
|
113
|
+
|
|
114
|
+
def _flush_buffer(self) -> None:
|
|
115
|
+
"""Write all buffered lines to disk, fsync, and maybe rotate.
|
|
116
|
+
|
|
117
|
+
Caller must hold the handler lock. No-op when the buffer is empty.
|
|
118
|
+
"""
|
|
119
|
+
if not self._buffer:
|
|
120
|
+
return
|
|
121
|
+
self._file.write("".join(self._buffer))
|
|
122
|
+
self._buffer.clear()
|
|
123
|
+
self._file.flush()
|
|
124
|
+
self._last_flush_monotonic = time.monotonic()
|
|
125
|
+
self._maybe_rotate()
|
|
126
|
+
|
|
127
|
+
def _atexit_flush(self) -> None:
|
|
128
|
+
"""Best-effort flush of any buffered lines at interpreter exit."""
|
|
129
|
+
try:
|
|
130
|
+
self.acquire()
|
|
131
|
+
try:
|
|
132
|
+
if self._file and not self._file.closed:
|
|
133
|
+
self._flush_buffer()
|
|
134
|
+
finally:
|
|
135
|
+
self.release()
|
|
136
|
+
except Exception: # pragma: no cover - defensive at shutdown
|
|
137
|
+
pass
|
|
138
|
+
|
|
78
139
|
def close(self) -> None:
|
|
79
|
-
"""Flush and close the underlying file."""
|
|
140
|
+
"""Flush buffered lines and close the underlying file."""
|
|
80
141
|
self.acquire()
|
|
81
142
|
try:
|
|
82
143
|
if self._file and not self._file.closed:
|
|
83
|
-
self.
|
|
144
|
+
self._flush_buffer()
|
|
84
145
|
self._file.close()
|
|
85
146
|
finally:
|
|
86
147
|
self.release()
|
|
148
|
+
with contextlib.suppress(Exception):
|
|
149
|
+
atexit.unregister(self._atexit_flush)
|
|
87
150
|
super().close()
|
|
88
151
|
|
|
89
152
|
# ------------------------------------------------------------------
|
|
@@ -44,6 +44,14 @@ from coderouter.translation.tool_repair import repair_tool_calls_in_text
|
|
|
44
44
|
# ============================================================
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
# OpenAI's role=tool message has no structured error flag, so a failed
|
|
48
|
+
# tool_result is conventionally surfaced by prefixing the content with an
|
|
49
|
+
# "Error: " marker. We add it on the Anthropic→OpenAI leg when is_error is
|
|
50
|
+
# true and detect it on the reverse leg to restore is_error, so a round-trip
|
|
51
|
+
# preserves the flag without double-prefixing.
|
|
52
|
+
_TOOL_ERROR_MARKER = "Error: "
|
|
53
|
+
|
|
54
|
+
|
|
47
55
|
def _system_as_text(system: str | list[dict[str, Any]] | None) -> str | None:
|
|
48
56
|
"""Anthropic's `system` can be a string or a list of content blocks.
|
|
49
57
|
|
|
@@ -142,11 +150,20 @@ def _convert_anthropic_message(
|
|
|
142
150
|
elif btype == "tool_use":
|
|
143
151
|
tool_calls.append(_tool_use_to_openai_tool_call(block))
|
|
144
152
|
elif btype == "tool_result":
|
|
153
|
+
result_str = _tool_result_content_to_str(block.get("content"))
|
|
154
|
+
# Preserve Anthropic's is_error flag: OpenAI's role=tool message
|
|
155
|
+
# has no structured error field, so mark failures with an
|
|
156
|
+
# "Error: " prefix (skip if already prefixed to avoid doubling on
|
|
157
|
+
# a round-trip) (M8).
|
|
158
|
+
if block.get("is_error") and not result_str.startswith(
|
|
159
|
+
_TOOL_ERROR_MARKER
|
|
160
|
+
):
|
|
161
|
+
result_str = f"{_TOOL_ERROR_MARKER}{result_str}"
|
|
145
162
|
tool_result_messages.append(
|
|
146
163
|
{
|
|
147
164
|
"role": "tool",
|
|
148
165
|
"tool_call_id": block.get("tool_use_id", ""),
|
|
149
|
-
"content":
|
|
166
|
+
"content": result_str,
|
|
150
167
|
}
|
|
151
168
|
)
|
|
152
169
|
# Unknown block types (thinking, document, …) are skipped in v0.2.
|
|
@@ -396,8 +413,19 @@ class _StreamState:
|
|
|
396
413
|
self.current_block_index: int = -1
|
|
397
414
|
self.current_block_type: str | None = None # "text" | "tool_use"
|
|
398
415
|
# openai tool_call index (from delta.tool_calls[i].index) →
|
|
399
|
-
# anthropic content block index we allocated for it
|
|
416
|
+
# anthropic content block index we allocated for it. Note: this maps
|
|
417
|
+
# to the MOST RECENT anthropic index; a single openai tool index may
|
|
418
|
+
# be re-opened under a new anthropic index if a text delta (or another
|
|
419
|
+
# tool call) forced its block closed mid-stream (M7).
|
|
400
420
|
self.tool_call_block_map: dict[int, int] = {}
|
|
421
|
+
# Original tool_use metadata (id, name) keyed by openai tool_call
|
|
422
|
+
# index. Needed to re-open a tool_use block with the correct id/name
|
|
423
|
+
# after an interleaving delta closed it — id/name arrive only on the
|
|
424
|
+
# first fragment, so placeholders are not acceptable (M7).
|
|
425
|
+
self.tool_call_meta: dict[int, tuple[str, str]] = {}
|
|
426
|
+
# The openai tool_call index whose anthropic block is currently open
|
|
427
|
+
# (None when the open block is a text block or nothing is open).
|
|
428
|
+
self.current_tool_call_index: int | None = None
|
|
401
429
|
self.message_id: str = f"msg_{uuid.uuid4().hex[:24]}"
|
|
402
430
|
self.model: str = "unknown"
|
|
403
431
|
# Usage accounting (v0.3-C). The translator's job is to make sure
|
|
@@ -446,12 +474,14 @@ def _close_current_block(state: _StreamState) -> list[AnthropicStreamEvent]:
|
|
|
446
474
|
{"index": state.current_block_index},
|
|
447
475
|
)
|
|
448
476
|
state.current_block_type = None
|
|
477
|
+
state.current_tool_call_index = None
|
|
449
478
|
return [evt]
|
|
450
479
|
|
|
451
480
|
|
|
452
481
|
def _open_text_block(state: _StreamState) -> list[AnthropicStreamEvent]:
|
|
453
482
|
state.current_block_index += 1
|
|
454
483
|
state.current_block_type = "text"
|
|
484
|
+
state.current_tool_call_index = None
|
|
455
485
|
return [
|
|
456
486
|
_event(
|
|
457
487
|
"content_block_start",
|
|
@@ -471,6 +501,7 @@ def _open_tool_use_block(
|
|
|
471
501
|
) -> list[AnthropicStreamEvent]:
|
|
472
502
|
state.current_block_index += 1
|
|
473
503
|
state.current_block_type = "tool_use"
|
|
504
|
+
state.current_tool_call_index = openai_tc_index
|
|
474
505
|
state.tool_call_block_map[openai_tc_index] = state.current_block_index
|
|
475
506
|
return [
|
|
476
507
|
_event(
|
|
@@ -515,21 +546,50 @@ def _handle_delta(state: _StreamState, delta: dict[str, Any]) -> list[AnthropicS
|
|
|
515
546
|
fn = tc.get("function", {}) or {}
|
|
516
547
|
args_fragment = fn.get("arguments", "") or ""
|
|
517
548
|
|
|
518
|
-
|
|
519
|
-
|
|
549
|
+
# Record / refresh the tool metadata (id, name) as it arrives. id and
|
|
550
|
+
# name normally ride only the first fragment for a given tool index;
|
|
551
|
+
# keep whatever non-empty values we've seen so a later re-open uses the
|
|
552
|
+
# real id/name rather than placeholders (M7).
|
|
553
|
+
prev_id, prev_name = state.tool_call_meta.get(tc_index, ("", ""))
|
|
554
|
+
tool_id = tc.get("id", "") or prev_id
|
|
555
|
+
tool_name = fn.get("name", "") or prev_name
|
|
556
|
+
state.tool_call_meta[tc_index] = (tool_id, tool_name)
|
|
557
|
+
|
|
558
|
+
first_sight = tc_index not in state.tool_call_block_map
|
|
559
|
+
if first_sight:
|
|
560
|
+
# First time we see this tool_call — close any prior block and open
|
|
561
|
+
# a new tool_use block.
|
|
520
562
|
out.extend(_close_current_block(state))
|
|
521
563
|
out.extend(
|
|
522
564
|
_open_tool_use_block(
|
|
523
565
|
state,
|
|
524
566
|
openai_tc_index=tc_index,
|
|
525
|
-
tool_id=
|
|
526
|
-
tool_name=
|
|
567
|
+
tool_id=tool_id,
|
|
568
|
+
tool_name=tool_name,
|
|
527
569
|
)
|
|
528
570
|
)
|
|
529
571
|
# Function name itself is generated output even though it rides on
|
|
530
572
|
# content_block_start, not on a delta. Include it in the estimate
|
|
531
573
|
# so we don't under-count tool-heavy responses.
|
|
532
|
-
state.emitted_chars += len(
|
|
574
|
+
state.emitted_chars += len(tool_name)
|
|
575
|
+
elif state.current_tool_call_index != tc_index:
|
|
576
|
+
# We've seen this tool index before, but its anthropic block is no
|
|
577
|
+
# longer the open one — an interleaving text delta or a different
|
|
578
|
+
# tool call closed it. Sending an input_json_delta to the stale
|
|
579
|
+
# (closed) index is a wire-protocol violation, so re-open the tool
|
|
580
|
+
# under a fresh anthropic index, preserving its original id/name
|
|
581
|
+
# (placeholders are not acceptable). Downstream reassembles all
|
|
582
|
+
# fragments for the same tool id, so a split block is safe (M7).
|
|
583
|
+
out.extend(_close_current_block(state))
|
|
584
|
+
out.extend(
|
|
585
|
+
_open_tool_use_block(
|
|
586
|
+
state,
|
|
587
|
+
openai_tc_index=tc_index,
|
|
588
|
+
tool_id=tool_id,
|
|
589
|
+
tool_name=tool_name,
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
533
593
|
block_idx = state.tool_call_block_map[tc_index]
|
|
534
594
|
if args_fragment:
|
|
535
595
|
out.append(
|
|
@@ -625,6 +685,16 @@ async def stream_chat_to_anthropic_events(
|
|
|
625
685
|
if isinstance(pt, int) and pt >= 0:
|
|
626
686
|
state.upstream_input_tokens = pt
|
|
627
687
|
|
|
688
|
+
# Empty-stream guard (H6): if the upstream iterator produced no chunks,
|
|
689
|
+
# state.started is still False and no message_start was emitted. Emitting
|
|
690
|
+
# message_delta/message_stop without a preceding message_start is a wire
|
|
691
|
+
# protocol violation that breaks the Anthropic SSE parser (Claude Code
|
|
692
|
+
# aborts). Synthesize the opening event so the terminator sequence is
|
|
693
|
+
# always framed by a valid message_start.
|
|
694
|
+
if not state.started:
|
|
695
|
+
state.started = True
|
|
696
|
+
yield _start_event(state.model, state.message_id)
|
|
697
|
+
|
|
628
698
|
# Terminator sequence
|
|
629
699
|
for evt in _close_current_block(state):
|
|
630
700
|
yield evt
|
|
@@ -897,11 +967,19 @@ def _openai_tool_message_to_block(msg: Message) -> dict[str, Any]:
|
|
|
897
967
|
content_str = content
|
|
898
968
|
else:
|
|
899
969
|
content_str = ""
|
|
900
|
-
|
|
970
|
+
# Restore Anthropic's is_error flag from the OpenAI "Error: " convention
|
|
971
|
+
# (mirror of the forward leg). Strip the marker so a round-trip yields the
|
|
972
|
+
# original content plus is_error, with no marker doubling (M8).
|
|
973
|
+
block: dict[str, Any] = {
|
|
901
974
|
"type": "tool_result",
|
|
902
975
|
"tool_use_id": msg.tool_call_id or "",
|
|
903
|
-
"content": content_str,
|
|
904
976
|
}
|
|
977
|
+
if content_str.startswith(_TOOL_ERROR_MARKER):
|
|
978
|
+
block["content"] = content_str[len(_TOOL_ERROR_MARKER) :]
|
|
979
|
+
block["is_error"] = True
|
|
980
|
+
else:
|
|
981
|
+
block["content"] = content_str
|
|
982
|
+
return block
|
|
905
983
|
|
|
906
984
|
|
|
907
985
|
def _openai_tools_to_anthropic(
|
|
@@ -1289,3 +1367,28 @@ async def stream_anthropic_to_chat_chunks(
|
|
|
1289
1367
|
)
|
|
1290
1368
|
|
|
1291
1369
|
# Unknown event types are skipped silently (forward-compat).
|
|
1370
|
+
|
|
1371
|
+
# Truncated-stream guard (M9): the upstream iterator ended without a
|
|
1372
|
+
# message_stop (dropped connection, truncated SSE, provider that omits the
|
|
1373
|
+
# terminator). The normal terminator path returns from inside the loop, so
|
|
1374
|
+
# reaching here means we never emitted a finish_reason chunk or a usage
|
|
1375
|
+
# chunk. OpenAI clients that requested a stream treat the absence of a
|
|
1376
|
+
# finish_reason as an incomplete/hung response, so synthesize both from the
|
|
1377
|
+
# state accumulated so far. Mirrors the forward-direction terminator
|
|
1378
|
+
# synthesis (H6). If we never even saw a message_start, nothing was
|
|
1379
|
+
# streamed at all — still emit a clean finish so the client doesn't hang.
|
|
1380
|
+
finish = _REVERSE_FINISH_REASON_MAP.get(
|
|
1381
|
+
state.stop_reason_anthropic or "end_turn", "stop"
|
|
1382
|
+
)
|
|
1383
|
+
yield _make_chunk(state, {}, finish_reason=finish)
|
|
1384
|
+
yield StreamChunk(
|
|
1385
|
+
id=state.message_id,
|
|
1386
|
+
created=state.created,
|
|
1387
|
+
model=state.model,
|
|
1388
|
+
choices=[],
|
|
1389
|
+
usage={
|
|
1390
|
+
"prompt_tokens": state.usage_in,
|
|
1391
|
+
"completion_tokens": state.usage_out,
|
|
1392
|
+
"total_tokens": state.usage_in + state.usage_out,
|
|
1393
|
+
},
|
|
1394
|
+
)
|
|
@@ -101,16 +101,41 @@ _FENCED_RE = re.compile(
|
|
|
101
101
|
)
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
def _extract_tool_call_fenced_blocks(
|
|
105
|
+
text: str,
|
|
106
|
+
allowed: set[str] | None,
|
|
107
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
108
|
+
"""Pull tool-call-shaped ```...``` blocks out of text.
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
Only fenced blocks whose body parses to a recognised tool-call shape are
|
|
111
|
+
removed from the text and returned as normalised OpenAI tool_calls. Any
|
|
112
|
+
other fenced block (prose, real source code, non-tool JSON) is preserved
|
|
113
|
+
verbatim in the returned text — the removal decision and the extraction
|
|
114
|
+
decision use the exact same predicate, so a fenced block is never dropped
|
|
115
|
+
without also being surfaced as a tool call (bug H2: data loss when a
|
|
116
|
+
response mixed a code example with a tool-call block).
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
Returns (text_without_tool_fences, tool_calls).
|
|
119
|
+
"""
|
|
120
|
+
tool_calls: list[dict[str, Any]] = []
|
|
121
|
+
|
|
122
|
+
def _repair(match: re.Match[str]) -> str:
|
|
123
|
+
body = match.group(1).strip()
|
|
124
|
+
if not body.startswith("{"):
|
|
125
|
+
return match.group(0) # keep non-JSON fenced blocks (e.g. code)
|
|
126
|
+
try:
|
|
127
|
+
obj = json.loads(body)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
return match.group(0) # keep unparseable fenced blocks
|
|
130
|
+
hit = _looks_like_tool_call(obj, allowed)
|
|
131
|
+
if hit is None:
|
|
132
|
+
return match.group(0) # keep non-tool-call JSON blocks
|
|
133
|
+
name, args = hit
|
|
134
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
135
|
+
return "" # remove only recognised tool-call blocks
|
|
136
|
+
|
|
137
|
+
cleaned = _FENCED_RE.sub(_repair, text)
|
|
138
|
+
return cleaned, tool_calls
|
|
114
139
|
|
|
115
140
|
|
|
116
141
|
def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
@@ -188,21 +213,12 @@ def repair_tool_calls_in_text(
|
|
|
188
213
|
|
|
189
214
|
extracted: list[dict[str, Any]] = []
|
|
190
215
|
|
|
191
|
-
# 1. Pull fenced code blocks out first — they're the most
|
|
192
|
-
# when a chat-tuned model explains what it's doing.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
continue
|
|
198
|
-
try:
|
|
199
|
-
obj = json.loads(body)
|
|
200
|
-
except json.JSONDecodeError:
|
|
201
|
-
continue
|
|
202
|
-
hit = _looks_like_tool_call(obj, allowed)
|
|
203
|
-
if hit is not None:
|
|
204
|
-
name, args = hit
|
|
205
|
-
extracted.append(_normalise_to_openai_tool_call(name, args))
|
|
216
|
+
# 1. Pull tool-call-shaped fenced code blocks out first — they're the most
|
|
217
|
+
# common shape when a chat-tuned model explains what it's doing. Fenced
|
|
218
|
+
# blocks that are NOT tool calls (prose, real source code, plain JSON
|
|
219
|
+
# examples) are left in place so we never drop legitimate content (H2).
|
|
220
|
+
cleaned, fenced_tool_calls = _extract_tool_call_fenced_blocks(text, allowed)
|
|
221
|
+
extracted.extend(fenced_tool_calls)
|
|
206
222
|
|
|
207
223
|
# 2. Scan remaining text for bare JSON objects.
|
|
208
224
|
# We walk from back to front so removals by slicing don't shift
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
6
|
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
@@ -161,6 +161,36 @@ ANTHROPIC_BASE_URL=http://localhost:8088 ANTHROPIC_AUTH_TOKEN=dummy claude
|
|
|
161
161
|
| **`coderouter replay`** | provider 切替の効果を統計比較 (A/B 分析) / `--suggest-rules` でルール最適化提案 |
|
|
162
162
|
| **Continuous Probe** | idle 時も定期的に backend を監視 |
|
|
163
163
|
|
|
164
|
+
### 言語税トラッキング — v2.6.0
|
|
165
|
+
|
|
166
|
+
日本語などの CJK テキストは、クラウドのトークナイザだと「同じ意味の英語」より多くのトークンを消費します(**実測: GPT-4o 系 o200k で平均 1.6 倍、GPT-4 系 cl100k で平均 2.0 倍**)。ローカル LLM は課金されないので、この「言語税」はクラウド利用時だけ効いてきます。CodeRouter v2.6.0 はこれを **計測・ルーティング回避・可視化** します。
|
|
167
|
+
|
|
168
|
+
| 機能 | 何をしてくれるか |
|
|
169
|
+
|---|---|
|
|
170
|
+
| **言語税の計測** | プロバイダに `tokenizer_path`(ローカルの `tokenizer.json`)を指定すると、char/4 ヒューリスティック比の実トークン倍率と割増 USD を算出(ネットワーク不要・未設定なら無効) |
|
|
171
|
+
| **`cjk_ratio_min` ルーティング** | CJK 比率が高いリクエストを自動でローカル LLM(課金ゼロ)へ。コードや英語はクラウドへ |
|
|
172
|
+
| **ダッシュボード可視化** | `/dashboard` の「Cost & Language Tax」パネルで総支出・キャッシュ節約・言語税をリアルタイム表示 |
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
# providers.yaml — CJK 多めのターンはローカルへ自動回避
|
|
176
|
+
auto_router:
|
|
177
|
+
rules:
|
|
178
|
+
- match: { cjk_ratio_min: 0.3 } # 日本語が3割以上 → ローカル
|
|
179
|
+
profile: local
|
|
180
|
+
- match: { has_tools: true } # ツール使用 → クラウド
|
|
181
|
+
profile: cloud
|
|
182
|
+
default_rule_profile: cloud
|
|
183
|
+
|
|
184
|
+
providers:
|
|
185
|
+
- name: cloud-sonnet
|
|
186
|
+
kind: anthropic
|
|
187
|
+
base_url: https://api.anthropic.com
|
|
188
|
+
model: claude-sonnet-4-6
|
|
189
|
+
tokenizer_path: ~/.coderouter/tokenizers/sonnet.json # 言語税の正確計測(任意)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
詳細 → [言語税ガイド](./docs/guides/language-tax.md)
|
|
193
|
+
|
|
164
194
|
### Launcher — llama.cpp / vllm 起動 UI
|
|
165
195
|
|
|
166
196
|
`http://localhost:8088/launcher` で開けるブラウザ UI。llama.cpp や vllm を GUI で起動・管理できます。
|
|
@@ -229,6 +259,7 @@ providers:
|
|
|
229
259
|
| 使いこなす | [利用ガイド](./docs/guides/usage-guide.md) |
|
|
230
260
|
| 無料で回す | [無料枠ガイド](./docs/guides/free-tier-guide.md) |
|
|
231
261
|
| llama.cpp / vllm を GUI で起動 | [Launcher ガイド](./docs/backends/launcher.md) |
|
|
262
|
+
| 言語税を計測・回避する | [言語税ガイド](./docs/guides/language-tax.md) |
|
|
232
263
|
| 詰まった | [トラブルシューティング](./docs/guides/troubleshooting.md) |
|
|
233
264
|
| 設計を知りたい | [アーキテクチャ詳細](./docs/concepts/architecture.md) |
|
|
234
265
|
| 全リリース履歴 | [CHANGELOG](./CHANGELOG.md) |
|