Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# 项目当前不自动读取 .env;不要提交包含真实密钥的 .env。

MINICODE_MODEL=deepseek-v4-pro
# Optional ordered fallbacks on the same OpenAI-compatible endpoint.
MINICODE_FALLBACK_MODELS=
OPENAI_BASE_URL=https://api.deepseek.com
MINICODE_MODEL_TIMEOUT=120
MINICODE_MAX_STEPS=12
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ MiniCode Rebuild 是一个从零、分阶段实现的本地终端 AI Coding Agen

## 当前状态

阶段 0“仓库初始化与工程基线”至阶段 10“可观测性、质量与发布准备”已经完成;阶段 11 已实现“长期记忆与检索”,阶段 12 已实现独立高级能力“成本控制”。
阶段 0“仓库初始化与工程基线”至阶段 10“可观测性、质量与发布准备”已经完成;阶段 11“长期记忆与检索”和阶段 12“成本控制”已经合并,阶段 13“多模型路由与降级”已完成开发和跨平台验证,等待独立 PR 合并。

目前已经具备:

Expand Down Expand Up @@ -40,11 +40,13 @@ MiniCode Rebuild 是一个从零、分阶段实现的本地终端 AI Coding Agen
- 将记忆结果标记为不可信历史数据,模型写入和删除仍经过权限边界;
- 为会话设置可选 token 预算,并在请求前估算输入和工具协议成本;
- 按剩余额度限制单次模型输出,预算不足时不调用 Provider;
- 按配置顺序尝试主模型与后备模型,只对明确的瞬时故障执行降级;
- 将路由失败和后备模型选择作为脱敏终端事件展示;
- 离线检查 Python、运行配置、Provider 配置、会话存储、Skills 与记忆存储 readiness;
- 使用 Ruff、Mypy、分支覆盖率、构建、安装和跨平台 CI 作为发布质量门禁;
- 执行自动化测试。

阶段 0 至阶段 10 的基础路线、阶段 11“长期记忆与检索”和阶段 12“成本控制”已经完成。其余高级能力仍必须单独选择、设计、测试和提交。
阶段 0 至阶段 10 的基础路线、阶段 11“长期记忆与检索”和阶段 12“成本控制”已经完成并合并。阶段 13“多模型路由与降级”的独立 Draft PR 已通过跨平台 CI,合并后才视为正式完成;其余高级能力仍必须单独选择、设计、测试和提交。

## 长期记忆与检索

Expand Down Expand Up @@ -76,6 +78,22 @@ minicode-rebuild --interactive --resume latest --token-budget 50000

该能力用于阻止失控的多步调用,不等同于精确账单上限。输入预算使用跨 Provider 启发式估算;服务端实际计费、缓存 token、推理 token 和价格规则由 Provider 决定。当前请求的真实用量只能在响应返回后得知,因此可能小幅越过估算值,但后续请求会使用更新后的服务端统计重新检查。若 Provider 不返回 usage,累计会话用量也无法精确增长;需要硬货币限额时仍应在 Provider 账户侧设置配额。

## 多模型路由与降级

真实模型模式可以在同一个 OpenAI-compatible 端点上配置一个主模型和最多四个有序后备模型:

```powershell
$env:MINICODE_MODEL="primary-model"
$env:MINICODE_FALLBACK_MODELS="fallback-fast,fallback-stable"
minicode-rebuild "分析并修复当前错误"
```

每次 Agent 模型步骤从主模型开始。只有连接失败、超时、HTTP 408/409/425/429 或 5xx 时,运行时才按顺序尝试下一个模型;HTTP 400/401/403 等永久错误、无法解析的响应、适配器编程错误和 `KeyboardInterrupt` 不会触发降级。每个候选最多调用一次,候选名称必须唯一,主模型不能在后备列表中重复。模型名称最多 256 个字符,并拒绝换行、ANSI 转义等不可打印控制字符。

降级过程只输出模型名称、错误类型和是否继续,不输出 Provider 响应正文、请求内容、API Key 或工具参数。成功的后备响应继续经过原有 Agent Loop、工具权限和 token 成本控制;同一个 `ModelRequest.max_output_tokens` 会传递给所有候选。

当前阶段只支持同一 API 基址与凭据下的多个模型 ID,不实现跨 Provider 凭据池、负载均衡、健康探测、自动能力评分或并行竞速。`OPENAI_BASE_URL` 不允许嵌入用户名或密码,避免 Readiness 输出泄露 URL 凭据。失败请求可能已经被 Provider 计费,但没有可靠 usage 时本地无法补记;需要硬成本保障时仍应结合 Provider 账户配额。

## 可观测性与 Readiness

每次 CLI 会话默认把生命周期元数据追加到工作区 `.minicode-rebuild/events.jsonl`。日志只包含时间、事件名、session ID、工具名、成功状态、错误代码和停止原因;不保存用户提示、工具参数、工具输出或 API Key。该目录已从 Git 和模型通用文件工具中隔离。
Expand Down Expand Up @@ -156,6 +174,7 @@ python -m minicode_rebuild --help
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `MINICODE_MODEL` | `deepseek-v4-pro` | 模型名称 |
| `MINICODE_FALLBACK_MODELS` | 无 | 同一端点上的逗号分隔有序后备模型,最多四个 |
| `OPENAI_BASE_URL` | `https://api.deepseek.com` | API 基址或完整 `/chat/completions` 地址 |
| `MINICODE_MODEL_TIMEOUT` | `120` | 请求超时秒数,必须是正整数 |
| `MINICODE_MAX_STEPS` | `12` | 每轮最大模型调用步数,必须是正整数 |
Expand Down
76 changes: 70 additions & 6 deletions docs/REBUILD_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

| 项目 | 内容 |
|---|---|
| 当前阶段 | 阶段 12:成本控制(已完成) |
| 最近完成 | 阶段 12:Token 成本控制 |
| 当前分支 | `codex/phase-12-cost-control` |
| 最新阶段实现提交 | `fa243dc feat(phase-12): add token cost controls` |
| 测试状态 | 阶段相关回归 `107 passed`;全量回归 `298 passed, 3 skipped`;分支覆盖率 `85.58%` |
| 下一步 | 审核并由用户合并 Draft PR #6;其他高级能力继续保持独立阶段 |
| 当前阶段 | 阶段 13:多模型路由与降级(跨平台验证完成,等待合并) |
| 最近完成 | 阶段 13:开发与跨平台验证 |
| 当前分支 | `codex/phase-13-model-routing` |
| 最新阶段实现提交 | `38cd0eb feat(phase-13): add model routing and fallback` |
| 测试状态 | 本地 `339 passed, 3 skipped`、覆盖率 `86.01%`;GitHub 四组 CI 全部通过 |
| 下一步 | 用户审核并合并 Draft PR #7;合并前不开始下一高级能力 |

## 总体架构

Expand Down Expand Up @@ -42,6 +42,8 @@ flowchart LR
| 9 | Skills、Hooks 与扩展机制 | 已完成 | 按需技能和生命周期扩展点 | `6201245` |
| 10 | 可观测性、质量与发布准备 | 已完成 | 日志、质量门禁、安装与发布验证 | `edf1569` |
| 11 | 长期记忆与检索 | 已完成 | 工作区本地记忆、按需检索和权限控制 | `d06ad40` |
| 12 | 成本控制 | 已完成 | 会话 token 预算、输出上限和预算门禁 | `fa243dc` |
| 13 | 多模型路由与降级 | 待合并 | 有序候选、瞬时故障降级和脱敏路由事件 | `38cd0eb` |

## 阶段 0:仓库初始化与工程基线

Expand Down Expand Up @@ -1871,3 +1873,65 @@ request.max_output_tokens = min(configured_output_limit, available_output)
### 8. 跨平台验证记录

阶段 12 分支推送后创建 Draft PR #6。GitHub Actions 运行 `32097364115` 的 Ubuntu 3.11、Ubuntu 3.13、Windows 3.11、Windows 3.13 四组任务全部通过;PR 以 `master` 为基线并保持 Draft,不自动合并主分支。

## 阶段 13:多模型路由与降级

### 1. 开发前计划

- 只实现高级能力清单中的“多模型路由与降级”,不同时引入 MCP、多 Agent、Worktree 编排、TUI 或跨 Provider 账户管理。
- 允许在现有 OpenAI-compatible 端点和凭据下配置一个主模型与最多四个有序后备模型;未配置后备模型时保持单适配器行为。
- 每个候选对同一规范化 `ModelRequest` 最多调用一次,保持工具声明、上下文压缩结果和成本控制输出上限不变。
- 只对网络传输错误、明确超时/限流状态和 5xx 服务端错误降级;认证、请求格式、畸形响应与程序错误立即暴露。
- 路由日志只包含模型名称、错误类型、尝试序号与是否继续,不包含异常正文、提示、工具参数或密钥。
- 为配置顺序与上限、HTTP 错误分类、主模型成功、瞬时失败恢复、永久失败停止、全部候选失败、观察器隔离和 CLI 装配补齐测试。

### 2. 威胁模型与非目标

- 防止永久错误造成重复请求:400/401/403、畸形 JSON、非法工具调用和普通适配器异常不会切换模型。
- 防止无限重试:候选总数最多五个,每个模型每个步骤只尝试一次,没有循环、退避线程或后台重放。
- 防止错误信息泄露:全部候选失败时聚合结果只保留模型名与异常类型,不拼接可能包含 Provider 细节的异常正文。
- 防止观察器破坏模型恢复:终端路由观察器异常被隔离,不改变路由结果。
- `KeyboardInterrupt`、`SystemExit` 等控制流异常继续传播,不被误判为可降级故障。
- 本阶段不实现跨端点 Key 池、并行竞速、随机负载均衡、自动基准测试、模型能力推断或后台健康检查。

### 3. 配置与错误分类

`MINICODE_MODEL` 仍是主模型;`MINICODE_FALLBACK_MODELS` 使用逗号分隔的优先级顺序。配置加载时去除两侧空白,拒绝空项、重复项、主模型重复和超过四个后备模型。模型名称最多 256 个字符,不允许换行、ANSI 转义等不可打印控制字符,避免候选名称污染 Readiness 和终端路由事件。Readiness 只做离线结构检查并展示最终候选顺序,不向任何模型发送请求;`OPENAI_BASE_URL` 拒绝嵌入用户名或密码,避免展示端点时泄露 URL 凭据。

OpenAI-compatible 非 2xx 响应现在归一化为带状态码的 `ModelHTTPError`。HTTP 408、409、425、429 和全部 5xx 视为可能瞬时失败;其他 HTTP 状态、`ModelResponseError` 和未知异常不具备降级资格。网络层的 `ModelTransportError` 可以降级,但异常正文不会进入路由汇总。

### 4. 路由执行与现有边界

`RoutingModelAdapter` 实现相同的 `ModelAdapter` 协议,因此 Agent Loop 不需要知道当前由哪个模型回答。主模型成功时不会访问后备模型;瞬时失败时按声明顺序串行尝试。后备成功返回普通 `ModelResponse`,其 usage、工具调用和停止原因继续由现有 Agent、会话与成本控制处理。

路由器不会修改请求。阶段 12 计算出的 `max_output_tokens`、阶段 7 压缩后的消息和工具 JSON Schema 会原样交给每个候选。预算门禁发生在路由器外部,因此预算不足时不会调用主模型或任何后备模型。

CLI 只在实际发生故障或选择后备模型时输出 `[model-route]` 行。该输出不包含 Provider message;全部瞬时候选失败时,Agent 将脱敏的 `ModelRoutingError` 作为既有 `model_error` 停止原因展示。

### 5. 验收与安全回归

- 阶段相关回归:`101 passed`。
- 全量回归:`339 passed, 3 skipped`。
- 分支覆盖率:`86.01%`,达到 `85%` 门槛。
- Ruff:`All checks passed!`。
- Mypy:`Success: no issues found in 30 source files`。
- 测试覆盖主模型短路、传输/限流/5xx 降级、永久错误不降级、请求不变、全部候选失败脱敏、观察器隔离、控制流传播、CLI 真实装配、候选名称终端安全与 Readiness URL 凭据拒绝。

当前 Windows 环境的 `3 skipped` 仍是缺少目录符号链接权限的安全测试;Ubuntu CI 将执行这些真实路径。该环境限制与模型路由无关。

### 6. 限制与后续边界

所有候选当前共享同一个 `OPENAI_BASE_URL`、API Key、超时和请求协议,只改变模型 ID。某些端点可能不支持所有候选模型,模型不存在通常返回 4xx,并按永久配置错误停止,而不会继续尝试掩盖配置问题。

瞬时失败请求可能在服务端已消耗资源,但若 Provider 没有返回 usage,本地无法准确计入阶段 12 的会话预算。路由是可用性保护而非免费重试保证;需要严格账单上限时必须同时配置本地输出/会话预算和 Provider 账户配额。

### 7. Git 计划

- 基线:阶段 12 的 PR #6 已合并至 `master`,合并提交为 `74460e6`。
- 分支:`codex/phase-13-model-routing`。
- 实现提交:`38cd0eb feat(phase-13): add model routing and fallback`,作者邮箱已核对为 GitHub 关联地址 `3226077178@qq.com`。
- 文档使用独立提交收口;分支推送后创建以 `master` 为基线的 Draft PR,不自动合并主分支。

### 8. 跨平台验证记录

阶段 13 分支推送后创建 Draft PR #7。GitHub Actions 运行 `32332645806` 的 Ubuntu 3.11、Ubuntu 3.13、Windows 3.11、Windows 3.13 四组任务全部通过;PR 以 `master` 为基线、状态为 `MERGEABLE`,保持 Draft 并等待用户审核合并。
32 changes: 30 additions & 2 deletions src/minicode_rebuild/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
from minicode_rebuild.core import ModelAdapter, ModelResponse, ToolCall
from minicode_rebuild.hooks import HookManager
from minicode_rebuild.memory import MemoryStoreError
from minicode_rebuild.models import MockModel
from minicode_rebuild.models import MockModel, ModelRoute, RoutingModelAdapter
from minicode_rebuild.models.openai_compatible import OpenAICompatibleAdapter
from minicode_rebuild.models.routing import RoutingEvent
from minicode_rebuild.observability import (
EventLog,
ObservabilityError,
Expand Down Expand Up @@ -201,11 +202,37 @@ def _select_model(
demo: bool,
environment: Mapping[str, str],
injected: ModelAdapter | None,
output: TextIO,
) -> ModelAdapter:
if demo:
return injected or _demo_model()
if injected is not None:
return injected
model_settings = ModelSettings.from_env(environment)
return injected or OpenAICompatibleAdapter(model_settings)
routes = tuple(
ModelRoute(
name=model_name,
adapter=OpenAICompatibleAdapter(
replace(model_settings, model=model_name, fallback_models=())
),
)
for model_name in model_settings.model_candidates
)
if len(routes) == 1:
return routes[0].adapter

def observe_route(event: RoutingEvent) -> None:
if event.status == "failed":
action = "trying next route" if event.retrying else "stopping"
output.write(
f"[model-route] {event.route} failed "
f"({event.error_type}); {action}\n"
)
elif event.status == "selected":
output.write(f"[model-route] selected fallback {event.route}\n")
output.flush()

return RoutingModelAdapter(routes, observer=observe_route)


def _print_result(
Expand Down Expand Up @@ -531,6 +558,7 @@ def main(
demo=args.demo,
environment=env,
injected=model,
output=output,
)
if args.interactive:
permission_prompt = make_permission_prompt(input_stream, output)
Expand Down
66 changes: 63 additions & 3 deletions src/minicode_rebuild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
DEFAULT_OPENAI_BASE_URL = "https://api.deepseek.com"
DEFAULT_MODEL_TIMEOUT_SECONDS = 120
DEFAULT_MAX_STEPS = 12
MAX_FALLBACK_MODELS = 4
MAX_MODEL_NAME_LENGTH = 256
DEFAULT_SYSTEM_PROMPT = (
"You are a careful local coding assistant. Inspect the workspace with tools "
"before making claims, and ask for permission before mutations."
Expand All @@ -24,6 +26,21 @@ class ModelConfigurationError(ValueError):
"""Raised when model settings are missing or invalid."""


def _model_name(value: object, variable: str) -> str:
if not isinstance(value, str):
raise ModelConfigurationError(f"{variable} must be text")
normalized = value.strip()
if not normalized:
raise ModelConfigurationError(f"{variable} must not be empty")
if len(normalized) > MAX_MODEL_NAME_LENGTH:
raise ModelConfigurationError(
f"{variable} must not exceed {MAX_MODEL_NAME_LENGTH} characters"
)
if not normalized.isprintable():
raise ModelConfigurationError(f"{variable} must not contain control characters")
return normalized


@dataclass(frozen=True, slots=True)
class ModelSettings:
"""Validated settings for an OpenAI-compatible model endpoint."""
Expand All @@ -32,21 +49,29 @@ class ModelSettings:
base_url: str
api_key: str = field(repr=False)
timeout_seconds: int = DEFAULT_MODEL_TIMEOUT_SECONDS
fallback_models: tuple[str, ...] = ()

def __post_init__(self) -> None:
model = self.model.strip()
model = _model_name(self.model, "MINICODE_MODEL")
base_url = self.base_url.rstrip("/")
api_key = self.api_key.strip()
if isinstance(self.fallback_models, str):
raise ModelConfigurationError(
"fallback_models must be a tuple of model names"
)
fallback_models = tuple(self.fallback_models)

if not model:
raise ModelConfigurationError("MINICODE_MODEL must not be empty")
if not api_key:
raise ModelConfigurationError(
"Set OPENAI_API_KEY or DEEPSEEK_API_KEY before using the real model adapter"
)
parsed_url = urlsplit(base_url)
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
raise ModelConfigurationError("OPENAI_BASE_URL must be an http(s) URL")
if parsed_url.username is not None or parsed_url.password is not None:
raise ModelConfigurationError(
"OPENAI_BASE_URL must not contain embedded credentials"
)
if parsed_url.query or parsed_url.fragment:
raise ModelConfigurationError(
"OPENAI_BASE_URL must not contain a query string or fragment"
Expand All @@ -55,10 +80,26 @@ def __post_init__(self) -> None:
raise ModelConfigurationError(
"MINICODE_MODEL_TIMEOUT must be greater than zero"
)
if len(fallback_models) > MAX_FALLBACK_MODELS:
raise ModelConfigurationError(
f"MINICODE_FALLBACK_MODELS may contain at most {MAX_FALLBACK_MODELS} models"
)
normalized_fallbacks: list[str] = []
for fallback in fallback_models:
normalized_fallbacks.append(
_model_name(fallback, "MINICODE_FALLBACK_MODELS")
)
if model in normalized_fallbacks or len(set(normalized_fallbacks)) != len(
normalized_fallbacks
):
raise ModelConfigurationError(
"MINICODE_FALLBACK_MODELS must be unique and exclude MINICODE_MODEL"
)

object.__setattr__(self, "model", model)
object.__setattr__(self, "base_url", base_url)
object.__setattr__(self, "api_key", api_key)
object.__setattr__(self, "fallback_models", tuple(normalized_fallbacks))

@classmethod
def from_env(
Expand All @@ -84,8 +125,27 @@ def from_env(
api_key=env.get("OPENAI_API_KEY")
or env.get("DEEPSEEK_API_KEY", ""),
timeout_seconds=timeout_seconds,
fallback_models=cls._fallback_models(env),
)

@staticmethod
def _fallback_models(environment: Mapping[str, str]) -> tuple[str, ...]:
raw = environment.get("MINICODE_FALLBACK_MODELS", "").strip()
if not raw:
return ()
values = tuple(item.strip() for item in raw.split(","))
if any(not item for item in values):
raise ModelConfigurationError(
"MINICODE_FALLBACK_MODELS must be a comma-separated list without empty entries"
)
return values

@property
def model_candidates(self) -> tuple[str, ...]:
"""Return primary then fallback model ids in deterministic order."""

return (self.model, *self.fallback_models)

@property
def chat_completions_url(self) -> str:
"""Return the full Chat Completions endpoint."""
Expand Down
3 changes: 2 additions & 1 deletion src/minicode_rebuild/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Model adapter implementations."""

from minicode_rebuild.models.mock import MockModel
from minicode_rebuild.models.routing import ModelRoute, RoutingModelAdapter

__all__ = ["MockModel"]
__all__ = ["MockModel", "ModelRoute", "RoutingModelAdapter"]
Loading
Loading