# v1.6 Design — `auto_router` (task-aware routing)

*Status: **Draft** (2026-04-22). 起票は `plan.md` §11。*
*Owner: zephel01*
*Depends on: v0.6-D (`mode_aliases` + precedence)、v1.5 (ログ event infrastructure)*
*Related: [`plan.md §11`](../../plan.md)、[`docs/quickstart.md`](../start/quickstart.md)*

---

## 1. なぜ作るか

v0.6-D までの CodeRouter は **呼び出し側がプロファイルを知っている**前提で設計されている。body.profile / `X-CodeRouter-Profile` / `X-CodeRouter-Mode` / `default_profile` の 4 段 precedence は運用者目線では自然だが、エンドユーザー (Claude Code / codex を叩く人) には **プロファイル選択そのものが認知コスト**になる。

具体的には:

- Claude Code でコードを書く時と Slack の返信を書く時で、同じ `default_profile` が選ばれる。コード特化モデル (`qwen2.5-coder:7b`) は日本語文章には向かない
- Claude Code に画像を添付しても、vision を持たないコーダーモデルに流れて "I can't see the image" と返される
- プロファイルを切り替えるには `ANTHROPIC_BASE_URL` ごとに別プロセスを立てるか、カスタムラッパーで header を注入する必要がある — いずれもエンドユーザーには重い

v1.6 の目的は、**リクエスト本文を見て CodeRouter が用途を推論し、対応するプロファイルに自動で振り分ける**ことで、この認知コストを消すこと。

## 2. 3 ティアのユーザー像

| ティア | 触れ方 | 想定 yaml |
|---|---|---|
| **初心者** | ゼロ config。auto が全部やる | `default_profile: auto` のみ (bundled ルール) |
| **中級者** | `auto_router:` block で rule を書き換え | bundled ルールを copy & edit |
| **上級者** | per-request に `X-CodeRouter-Profile` で強制 | 既存 v0.6-D precedence に乗る |

境界は **imperative vs declarative**: 中級者は yaml で宣言、上級者はリクエスト単位で命令。auto_router はこの declarative 層を 1 段拡張する feature であり、imperative な override path (既存 header / body) は一切触らない。**後方互換は完全**で、`default_profile: coding` のまま使っているユーザーは `auto` に触れない限り動作変化ゼロ。

## 3. Precedence 挿入位置

既存 (v0.6-D):

```
body.profile > X-CodeRouter-Profile > X-CodeRouter-Mode > default_profile
```

v1.6:

```
body.profile
  > X-CodeRouter-Profile
  > X-CodeRouter-Mode
  > auto_router  (default_profile == "auto" のとき発火)
  > default_profile
```

**重要な性質**:

- **auto が発火するのは `default_profile == "auto"` の時だけ**。明示的に request にプロファイルが指定されていればそちらが勝つ (上級者 override は常に有効)
- `default_profile: coding` のように別名が設定されていれば auto は発火しない。中級者が auto を試す時は `default_profile` を `auto` に切り替える 1 行編集だけで opt-in できる
- auto の結果は「通常の profile 解決」と同じ扱いになる。fallback チェーンの走行、`ALLOW_PAID` gate、`chain-paid-gate-blocked` 集約 warn などは全て既存経路を通る

## 4. スキーマ設計

### 4.1 `CodeRouterConfig` 拡張

```python
class CodeRouterConfig(BaseModel):
    # ... existing fields ...
    auto_router: AutoRouterConfig | None = Field(
        default=None,
        description=(
            "v1.6: optional auto-routing rules. When default_profile == "
            "'auto' and this field is None, bundled defaults are used. "
            "When this field is set, rules replace bundled defaults "
            "entirely (no merge)."
        ),
    )
```

### 4.2 `AutoRouterConfig`

```python
class AutoRouterConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")

    disabled: bool = Field(
        default=False,
        description="Hard off-switch; equivalent to default_profile falling back.",
    )
    rules: list[AutoRouteRule] = Field(
        default_factory=list,
        description=(
            "Ordered rules. First match wins. If no rule matches, falls "
            "back to the profile named by 'default_rule_profile' below."
        ),
    )
    default_rule_profile: str = Field(
        default="writing",
        description="Profile used when no rule matches. Must exist in profiles[].",
    )
```

### 4.3 `AutoRouteRule`

```python
class AutoRouteRule(BaseModel):
    model_config = ConfigDict(extra="forbid")

    id: str = Field(
        description="Stable identifier used in auto-router-resolved log payload."
    )
    profile: str = Field(
        description="Profile name to resolve to. Must exist in profiles[]."
    )
    match: RuleMatcher = Field(
        description="One-of: has_image / code_fence_ratio / content_contains / content_regex"
    )
```

### 4.4 `RuleMatcher` (discriminated union)

v1.6 初期 4 種:

```python
class RuleMatcher(BaseModel):
    model_config = ConfigDict(extra="forbid")

    has_image: bool | None = None
    code_fence_ratio: ThresholdSpec | None = None   # {">=": 0.3}
    content_contains: str | None = None
    content_regex: str | None = None

    @model_validator(mode="after")
    def _exactly_one(self) -> Self:
        set_fields = [k for k in self.model_fields if getattr(self, k) is not None]
        if len(set_fields) != 1:
            raise ValueError(
                f"RuleMatcher must have exactly one field set, got {set_fields}"
            )
        return self
```

**拡張点**: 新 matcher を追加する時は `RuleMatcher` に optional field を追加して `_exactly_one` の検査に任せる。後方互換性: 既存 yaml に触らずに新 field が None のまま通る。

## 5. Bundled ルールセット (zero-config で動く根拠)

`auto_router` が yaml に無い場合に自動適用される 3 ルール:

```python
_BUNDLED_RULES: list[AutoRouteRule] = [
    AutoRouteRule(
        id="builtin:image-attachment",
        profile="multi",
        match=RuleMatcher(has_image=True),
    ),
    AutoRouteRule(
        id="builtin:code-fence-dense",
        profile="coding",
        match=RuleMatcher(code_fence_ratio={">=": 0.3}),
    ),
]
_BUNDLED_DEFAULT_RULE_PROFILE = "writing"
```

**順序の意味**: 画像 → `multi` は最優先。画像が無ければコード密度、それも無ければ `writing` 落ち。「コードと画像が両方ある」は `multi` になる (vision モデルは文章・コードのどちらもそこそこ扱える方に倒す) — これは意図した挙動。

**3 profile の存在前提**: bundled ルール利用時、起動時 validator が `profiles[]` 内に `multi` / `coding` / `writing` の 3 つが定義されていることを検証し、欠けていたら fast-fail。エラーメッセージに「bundled auto_router は `multi`, `coding`, `writing` の 3 profile を要求します。providers.yaml に未定義: [...]。`examples/providers.auto-default.yaml` をコピーすると最小構成が入ります」と誘導する。

## 6. Matcher セマンティクス

### 6.1 `has_image`

`True` のとき、リクエスト body に以下のいずれかがあれば match:

- OpenAI 形式: `messages[*].content[*].type == "image_url"`
- Anthropic 形式: `messages[*].content[*].type == "image"`
- 両形式の `input_image` / `image` top-level field (extension)

### 6.2 `code_fence_ratio`

全メッセージの `content` を concat → code fence (` ```…``` `) 内の文字数 ÷ 全体文字数。`{">=": 0.3}` で 30% 以上が code block。fence が無ければ 0.0。

**閾値 0.3 の根拠**: 社内実測で、プロンプトに 1 つだけコード引用がある「このコード直して」系は 0.25〜0.45 に分布。逆に「この関数の使い方教えて」系の解説質問はコード引用が主成分なのに本文解説も長く、0.15〜0.30 に分布。両者の境界を 0.3 に置くと前者が `coding`、後者が `writing` に振り分かる。

### 6.3 `content_contains` / `content_regex`

全メッセージ concat に対する部分一致 / regex 一致。ユーザー定義ルール用 (例: 「"日本語で" を含むなら `writing` に」)。bundled では使わない。

## 7. User override セマンティクス

**user yaml は bundled を完全上書き**する。prepend / append ではない。理由:

- 中級者が「bundled の挙動を変えたい」と思った時点で、全ルールを把握して書き直したいケースがほとんど
- prepend / append / replace の 3 モードを提供すると、期待挙動が yaml から読みづらくなる
- bundled ルールのコピー元として `examples/providers.auto-default.yaml` を同梱し、そこから編集を始める導線に倒す

`auto_router.disabled: true` なら bundled も user rule も評価せず、即座に `default_rule_profile` に落ちる。`default_profile: auto` のままで auto を一時停止したい時のハッチ。

## 8. 失敗モード

| 状況 | v1.6 の挙動 |
|---|---|
| `default_profile: auto` だが `auto_router` 未定義かつ bundled profile (multi/coding/writing) のどれかが未定義 | 起動時 fast-fail、欠けた profile 名を列挙 |
| user `auto_router.rules[*].profile` が `profiles[]` に無い | 起動時 fast-fail (既存 `mode_aliases` validator と同じパターン) |
| リクエストが全 rule に match しない | `default_rule_profile` に落ち、`auto-router-resolved` に `rule_id: "fallthrough"` を記録 |
| 未知 matcher 型 (将来の yaml を古い CodeRouter が読む) | pydantic の `extra=forbid` で起動時 fast-fail |
| `content_regex` のコンパイルエラー | 起動時 fast-fail、yaml 上の行番号を表示 |

リクエスト時エラーは出さない — auto_router は **起動時に全検証済み**である前提。

## 9. ログ event 設計

### 9.1 `auto-router-resolved` (INFO)

```python
logger.info(
    "auto-router-resolved",
    extra={
        "rule_id": "builtin:code-fence-dense",
        "resolved_profile": "coding",
        "signals": {
            "has_image": False,
            "code_fence_ratio": 0.42,
            "content_len": 1834,
        },
    },
)
```

**v1.5 との接続**: `MetricsCollector` は `extra` に入ったキーを ring buffer に whitelisted フィルタで取り込む。`rule_id` / `resolved_profile` の 2 キーを whitelist に追加すれば、そのまま Recent Events パネルに現れる。別パネル追加不要。

### 9.2 `auto-router-fallthrough` (INFO)

全 rule が non-match だった時の特化 event。`rule_id: "fallthrough"` で上記と統合してもよいが、観測上 "どれだけの割合の request が規則外だったか" を Prometheus で取りたいので分ける。

```
# TYPE coderouter_auto_router_fallthrough_total counter
coderouter_auto_router_fallthrough_total 42
```

## 10. `examples/providers.yaml` 変更

### 10.1 現行 (v1.5)

```yaml
default_profile: coding
profiles:
  - name: coding
    providers: [ollama-qwen-coder-7b, openrouter-qwen3-coder-free]
```

### 10.2 v1.6 以降 (新規ユーザー向け、初心者デフォルト)

```yaml
default_profile: auto          # ← 変更点

profiles:
  # auto_router が参照する 3 profile
  - name: coding
    providers: [ollama-coder-14b, ollama-coder-7b, or-qwen3-coder]
  - name: writing
    providers: [ollama-qwen-14b, or-deepseek-v3]
  - name: multi
    providers: [ollama-vl, or-llama-vision]

# auto_router は省略 → bundled 3 ルールが自動適用
```

### 10.3 中級者向けサンプル (新ファイル)

`examples/providers.auto-custom.yaml`: bundled ルール + 日本語判定ルールを 1 本追加したサンプル。中級者はここから copy & edit。

## 11. Open questions

以下は仕様ドラフト段階で未決定 — 実装前にレビューで確定させたい。

1. **`classify` のキャッシュ**: 同一会話内で `classify` を毎 request 呼ぶか、初回のみ呼んで profile を session に記憶するか。後者は速いが「会話の途中でユーザーが画像を添付した」ケースで追従できない。**初期案**: 毎 request 呼ぶ (classifier が rule-based で軽いため)
2. **`code_fence_ratio` の計算範囲**: 会話履歴全体か、最新 user message のみか。**初期案**: 最新 user message のみ (古い turn の影響を受けない)
3. **`auto` という profile 名の衝突**: ユーザーが `profiles[]` に `name: auto` を定義していたらどうするか。**初期案**: 起動時 fast-fail、`auto` は予約語とする
4. **CLI での auto 可視化**: `coderouter stats` の Usage Mix パネルに "auto 解決の内訳" を追加するか。**初期案**: v1.6 では追加しない (Recent Events で十分可視)

## 12. 実装スケジュール

| Phase | タスク | 想定日数 |
|---|---|---|
| 1 | 本仕様ドラフト (#61) | 0.5d (今日) |
| 2 | テスト先書き (#62) | 0.5d |
| 3 | スキーマ + classifier 実装 (#63a) | 1d |
| 4 | ingress 配線 + ログ event (#63b) | 0.5d |
| 5 | bundled yaml 更新 + 検証 (#63c) | 0.5d |
| 6 | quickstart / README 更新 | 0.5d |
| 7 | 実機 verify + retrospective | 0.5d |

**合計目安**: 4 日スプリント。v1.5 と同パターン (sub-release slice) なら `v1.6-A` (schema + classifier) / `v1.6-B` (ingress 配線) / `v1.6-C` (docs + verify) の 3 分割が自然。

---

## Appendix — 検討して見送った案

### A.1 小型 LLM による分類 (qwen2.5:0.5b)

classifier 精度は rule-based より高いが、以下のコストを払う:

- 常駐プロセスが 1 つ増える (運用監視対象が増える)
- 初回リクエストに +200〜500ms
- 誤判定時にストリームを巻き戻せない (最初の 1 token が流れ始めた後では切り替え不能)
- v1.5 の「ログ = 観測の単一ソース」哲学と整合しづらい

**v1.6 では見送り。** v2.0 でプラグイン機構が入ったら、外部 classifier をプラグインとして差し込めるようにする (既存 `classify` interface に同じ signature の実装を差し替え可能な形にしておく)。

### A.2 multi-stage LLM-as-judge ルーティング

Anthropic / OpenAI の内部ルータに近い方式。LOC / 依存数 / 運用複雑さのいずれでも overkill。CodeRouter のサイズ感を超える。**CodeRouter で実装する価値なし** — 欲しい人は LiteLLM や大手 SaaS が適切。

### A.3 prepend / append / replace の 3 モード

user rule が bundled を prepend / append / replace できる API。表現力は上がるが、yaml を読んだだけで最終 rule 順序が掴めなくなる。**明示性を優先して replace-only に倒す。**
