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.
@@ -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
- """Write an audit line if the event is audit-worthy."""
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
- line = self._format_line(record)
124
- self._file.write(line)
125
- self._file.flush()
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._file.flush()
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
  # ------------------------------------------------------------------
@@ -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
- """Write a journal line for cache-observed events."""
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
- line = self._format_line(record)
70
- self._file.write(line)
71
- self._file.flush()
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._file.flush()
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": _tool_result_content_to_str(block.get("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
- if tc_index not in state.tool_call_block_map:
519
- # First time we see this tool_call close any prior block and open a new tool_use block.
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=tc.get("id", ""),
526
- tool_name=fn.get("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(fn.get("name", "") or "")
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
- return {
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 _extract_fenced_blocks(text: str) -> tuple[str, list[str]]:
105
- """Pull ```...``` blocks out of text. Returns (text_without_fences, bodies)."""
106
- bodies: list[str] = []
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
- def _collect(match: re.Match[str]) -> str:
109
- bodies.append(match.group(1))
110
- return "" # remove the fenced block from the text entirely
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
- cleaned = _FENCED_RE.sub(_collect, text)
113
- return cleaned, bodies
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 common shape
192
- # when a chat-tuned model explains what it's doing.
193
- cleaned, fenced_bodies = _extract_fenced_blocks(text)
194
- for body in fenced_bodies:
195
- body = body.strip()
196
- if not body.startswith("{"):
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.6.0
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) |