my-freecode
§ 00 · Source-level Observatory

my-free-code 多 Provider 编码网关

hkqr/my-free-code 把 Claude Code、Codex、OpenCode、Cline 等客户端指向一个本地 FastAPI 网关,再把 Anthropic Messages、OpenAI Chat Completions 和 Responses 之间的协议差异压进一组轻量适配器。

★ 411
GitHub Stars
1.3K
Python LOC
65
Tracked Files
3fea11e
Latest commit
Claude/Codex CLI -> local FastAPI proxy -> model alias router -> provider adapter -> OpenAI-compatible or Anthropic upstream -> normalized stream/response
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

这个项目的核心不是重新发明一个 Agent,而是把本地编码客户端接到多个免费或低成本模型供应商上。入口在 sources/my-free-code/my_free_code/__init__.py:4 创建 FastAPI app,sources/my-free-code/my_free_code/api/routes.py:17 初始化共享 ProviderRuntime,sources/my-free-code/my_free_code/cli/launchers.py:31 和 sources/my-free-code/my_free_code/cli/launchers.py:32 再把客户端环境变量指向本地代理。

它值得拆的原因在于边界很小但覆盖真实问题:鉴权在 sources/my-free-code/my_free_code/api/routes.py:19,模型别名在 sources/my-free-code/my_free_code/core/router.py:3,上游 provider 目录在 sources/my-free-code/my_free_code/providers/catalog.py:10,协议翻译在 sources/my-free-code/my_free_code/core/anthropic.py:11 与 sources/my-free-code/my_free_code/core/responses.py:1。这些点合在一起,就是一个编码 Agent 网关最小可行内核。

▸核心数据

架构模式
本地 FastAPI 反向代理 + provider adapter + 模型别名路由。
核心抽象
Settings、ProviderRuntime、ProviderContext、OpenAIChatAdapter、协议转换函数。
性能/韧性
每 provider 独立 semaphore、滑动窗口限流、短暂 unhealthy backoff 和 fallback candidate 列表。
下游依赖
FastAPI、uvicorn、httpx、python-dotenv,以及外部编码客户端的 base URL 环境变量。

▸源码阅读路线

建议先读 sources/my-free-code/my_free_code/api/routes.py:49 的 Messages 入口,因为这里能同时看到鉴权、模型选择、非流式 fallback 和流式分支。然后顺着 sources/my-free-code/my_free_code/providers/runtime.py:45 到 sources/my-free-code/my_free_code/providers/adapters.py:23,最后回到 sources/my-free-code/my_free_code/core/anthropic.py:85 看响应如何还原为 Anthropic 形状。

§ 01 · ARCHITECTURE

网关拓扑:客户端无感,网关接管模型出口

运行时的主干是一个常驻 FastAPI app。sources/my-free-code/my_free_code/__init__.py:1 到 sources/my-free-code/my_free_code/__init__.py:5 只做应用装配,真正的 HTTP 端点在 sources/my-free-code/my_free_code/api/routes.py:30、sources/my-free-code/my_free_code/api/routes.py:34、sources/my-free-code/my_free_code/api/routes.py:49 和 sources/my-free-code/my_free_code/api/routes.py:117。这种拆法让服务入口非常薄,方便后续替换路由或 provider runtime。

▸系统拓扑

mfc-client / mfc-claude | sets ANTHROPIC_BASE_URL and OPENAI_BASE_URL v FastAPI routes | require_auth + select_model v ProviderRuntime | admit + adapter + context v Provider adapters | OpenAI-compatible, specialized profiles, local model endpoints v OpenAI-shape response | anthropic/openai normalization v Claude Code / Codex client stream

▸应用装配代码

sources/my-free-code/my_free_code/__init__.py:1 - sources/my-free-code/my_free_code/__init__.py:10Python
1from fastapi import FastAPI
2from .api.routes import router
3
4app = FastAPI(title="My Free Code", version="0.8.0")
5app.include_router(router)
6
7def main():
8    import uvicorn
9    from .config import settings
10    uvicorn.run(app, host=settings.host, port=settings.port)

这段代码的关键在 sources/my-free-code/my_free_code/__init__.py:4 和 sources/my-free-code/my_free_code/__init__.py:5:app 版本和路由包含关系被集中在包入口,sources/my-free-code/my_free_code/__init__.py:8 到 sources/my-free-code/my_free_code/__init__.py:10 则让命令行启动直接复用同一个 app,而不是另起一套服务实现。

▸ProviderRuntime 作为故障域

sources/my-free-code/my_free_code/providers/runtime.py:10 - sources/my-free-code/my_free_code/providers/runtime.py:28Python
10class ProviderRuntime:
11    def __init__(self, settings: Settings):
12        self.settings = settings
13        self.semaphores = {p.id: asyncio.Semaphore(settings.max_concurrency) for p in PROVIDER_MAP.values()}
14        self.calls = {p.id: deque() for p in PROVIDER_MAP.values()}
15        self.unhealthy_until = {}
16
17    def context(self, provider_id, remote_model):
18        p = PROVIDER_MAP[provider_id]
19        base = p.base_url
20        if provider_id == "ollama":
21            base = self.settings.key("OLLAMA_BASE_URL") or base
22        elif provider_id == "lmstudio":
23            base = self.settings.key("LM_STUDIO_BASE_URL") or base
24        elif provider_id == "llamacpp":
25            base = self.settings.key("LLAMACPP_BASE_URL") or base
26        if not base:
27            raise RuntimeError(f"{p.name} requires a specialized adapter")
28        return ProviderContext(provider_id, remote_model, self.settings.key(p.key), base, self.settings.http_timeout)

ProviderRuntime 初始化时为每个 provider 建立独立并发阀门,sources/my-free-code/my_free_code/providers/runtime.py:13 的 semaphore 防止单个 provider 被打满,sources/my-free-code/my_free_code/providers/runtime.py:14 的调用队列为限流准备状态,sources/my-free-code/my_free_code/providers/runtime.py:15 则保存临时不健康窗口。

深度阅读 · 路由与运行时限流
§ 02 · ROUTING

模型路由:把 Claude 档位映射到真实 provider/model

▸别名选择很短,但决定所有请求走向

sources/my-free-code/my_free_code/core/router.py:3 - sources/my-free-code/my_free_code/core/router.py:24Python
3def select_model(requested: str | None, s: Settings) -> str:
4    value = (requested or "").lower()
5    if "/" in value:
6        return requested or s.model
7    if "opus" in value and s.opus:
8        return s.opus
9    if "sonnet" in value and s.sonnet:
10        return s.sonnet
11    if "haiku" in value and s.haiku:
12        return s.haiku
13    if "fable" in value and s.fable:
14        return s.fable
15    return s.model
16
17def candidates(primary: str, s: Settings) -> list[str]:
18    return list(dict.fromkeys([primary, *s.fallbacks]))
19
20def split_model(model: str) -> tuple[str, str]:
21    provider, sep, remote = model.partition("/")
22    if not sep or not provider or not remote:
23        raise ValueError("model must have the form provider/model")
24    return provider, remote

路由器先保留显式 provider/model:sources/my-free-code/my_free_code/core/router.py:5 到 sources/my-free-code/my_free_code/core/router.py:6 确保用户指定 groq/foo 这类模型不会被别名覆盖。随后 sources/my-free-code/my_free_code/core/router.py:7、sources/my-free-code/my_free_code/core/router.py:9、sources/my-free-code/my_free_code/core/router.py:11 和 sources/my-free-code/my_free_code/core/router.py:13 才把 opus、sonnet、haiku、fable 映射到配置中的真实模型。

▸Fallback 列表去重

sources/my-free-code/my_free_code/config.py:7 - sources/my-free-code/my_free_code/config.py:26Python
7@dataclass(frozen=True)
8class Settings:
9    host: str = os.getenv("HOST", "127.0.0.1")
10    port: int = int(os.getenv("PORT", "8082"))
11    auth_token: str = os.getenv("PROXY_AUTH_TOKEN", "local")
12    model: str = os.getenv("MODEL", "open_router/openrouter/free")
13    fable: str = os.getenv("MODEL_FABLE", "")
14    opus: str = os.getenv("MODEL_OPUS", "")
15    sonnet: str = os.getenv("MODEL_SONNET", "")
16    haiku: str = os.getenv("MODEL_HAIKU", "")
17    fallbacks: list[str] = field(default_factory=lambda: [
18        x.strip() for x in os.getenv("FALLBACK_MODELS", "").split(",") if x.strip()
19    ])
20    max_concurrency: int = int(os.getenv("PROVIDER_MAX_CONCURRENCY", "5"))
21    rate_limit: int = int(os.getenv("PROVIDER_RATE_LIMIT", "10"))
22    rate_window: float = float(os.getenv("PROVIDER_RATE_WINDOW", "3"))
23    http_timeout: float = float(os.getenv("HTTP_TIMEOUT", "180"))
24
25    def key(self, env_name: str | None) -> str:
26        return os.getenv(env_name, "").strip() if env_name else ""

配置层用环境变量表达默认模型、档位模型和 fallback。sources/my-free-code/my_free_code/config.py:12 定义默认出口,sources/my-free-code/my_free_code/config.py:17 到 sources/my-free-code/my_free_code/config.py:19 从逗号列表加载 fallback,而 sources/my-free-code/my_free_code/core/router.py:17 到 sources/my-free-code/my_free_code/core/router.py:18 用 dict 去重保序,避免同一个上游被重复请求。

▸公开模型目录

sources/my-free-code/my_free_code/core/model_catalog.py:3 - sources/my-free-code/my_free_code/core/model_catalog.py:20Python
3def build_gateway_models(config):
4    return [
5        GatewayModel("claude-opus", "opus", True),
6        GatewayModel("claude-opus-no-thinking", "opus", False),
7        GatewayModel("claude-sonnet", "sonnet", True),
8        GatewayModel("claude-sonnet-no-thinking", "sonnet", False),
9        GatewayModel("claude-haiku", "haiku", True),
10        GatewayModel("claude-haiku-no-thinking", "haiku", False),
11        GatewayModel("claude-fable", "fable", True),
12    ]
13
14def public_catalog(config):
15    direct = [x for x in {
16        config.model, config.fable, config.opus, config.sonnet, config.haiku,
17        *config.fallbacks
18    } if x and "/" in x]
19    gateways = [x.id for x in build_gateway_models(config)]
20    return list(dict.fromkeys(gateways + direct))

模型目录把 Claude 风格档位和真实 provider/model 同时暴露。sources/my-free-code/my_free_code/core/model_catalog.py:4 到 sources/my-free-code/my_free_code/core/model_catalog.py:12 定义 gateway model,sources/my-free-code/my_free_code/core/model_catalog.py:14 到 sources/my-free-code/my_free_code/core/model_catalog.py:20 再合并配置中的直连模型,所以客户端的模型选择既能用别名,也能绕过别名。

深度阅读 · 协议转换与流式输出
§ 03 · PROTOCOL

协议转换:Anthropic、OpenAI Chat 与 Responses 的薄适配

▸Anthropic body 转 OpenAI payload

sources/my-free-code/my_free_code/core/anthropic.py:11 - sources/my-free-code/my_free_code/core/anthropic.py:83Python
11def anthropic_to_openai(body: dict, remote_model: str) -> dict:
12    messages = []
13    system = body.get("system")
14    if system:
15        messages.append({"role": "system", "content": content_text(system)})
16
17    for msg in body.get("messages", []):
18        role = msg.get("role", "user")
19        content = msg.get("content", "")
20        if isinstance(content, str):
21            messages.append({"role": role, "content": content})
22            continue
23
24        parts = []
25        tool_calls = []
26        for block in content:
27            kind = block.get("type")
28            if kind == "text":
29                parts.append({"type": "text", "text": block.get("text", "")})
30            elif kind == "image":
31                src = block.get("source", {})
32                if src.get("type") == "base64":
33                    parts.append({
34                        "type": "image_url",
35                        "image_url": {
36                            "url": f"data:{src.get('media_type','image/png')};base64,{src.get('data','')}"
37                        }
38                    })
39            elif kind == "tool_use":
40                tool_calls.append({
41                    "id": block.get("id"),
42                    "type": "function",
43                    "function": {
44                        "name": block.get("name"),
45                        "arguments": json.dumps(block.get("input", {}))
46                    }
47                })
48            elif kind == "tool_result":
49                messages.append({
50                    "role": "tool",
51                    "tool_call_id": block.get("tool_use_id"),
52                    "content": content_text(block.get("content", ""))
53                })
54
55        item = {"role": role, "content": parts}
56        if tool_calls:
57            item["tool_calls"] = tool_calls
58        messages.append(item)
59
60    out = {
61        "model": remote_model,
62        "messages": messages,
63        "stream": bool(body.get("stream")),
64        "max_tokens": body.get("max_tokens", 4096),
65    }
66    for key in ("temperature", "top_p", "stop"):
67        if key in body:
68            out[key] = body[key]
69
70    if body.get("tools"):
71        out["tools"] = [{
72            "type": "function",
73            "function": {
74                "name": t["name"],
75                "description": t.get("description", ""),
76                "parameters": t.get("input_schema", {}),
77            }
78        } for t in body["tools"]]
79
80    if body.get("tool_choice"):
81        out["tool_choice"] = body["tool_choice"]
82
83    return out

转换器保留 system、text、image、tool_use 和 tool_result。sources/my-free-code/my_free_code/core/anthropic.py:13 到 sources/my-free-code/my_free_code/core/anthropic.py:16 处理 system,sources/my-free-code/my_free_code/core/anthropic.py:39 到 sources/my-free-code/my_free_code/core/anthropic.py:47 把 Anthropic tool_use 变成 OpenAI function call,sources/my-free-code/my_free_code/core/anthropic.py:70 到 sources/my-free-code/my_free_code/core/anthropic.py:78 则把 Anthropic tools 改写成 OpenAI tools。

▸OpenAI 响应回到 Anthropic content blocks

sources/my-free-code/my_free_code/core/anthropic.py:85 - sources/my-free-code/my_free_code/core/anthropic.py:126Python
85def openai_to_anthropic(data: dict, requested_model: str) -> dict:
86    choice = (data.get("choices") or [{}])[0]
87    message = choice.get("message") or {}
88    blocks = []
89
90    if message.get("content"):
91        blocks.append({"type": "text", "text": message["content"]})
92
93    for call in message.get("tool_calls") or []:
94        fn = call.get("function") or {}
95        raw = fn.get("arguments", "{}")
96        try:
97            args = json.loads(raw)
98        except (ValueError, TypeError):
99            args = {"raw": raw}
100        blocks.append({
101            "type": "tool_use",
102            "id": call.get("id"),
103            "name": fn.get("name"),
104            "input": args,
105        })
106
107    reason = {
108        "stop": "end_turn",
109        "length": "max_tokens",
110        "tool_calls": "tool_use",
111    }.get(choice.get("finish_reason"), "end_turn")
112
113    usage = data.get("usage") or {}
114    return {
115        "id": data.get("id", "msg_proxy"),
116        "type": "message",
117        "role": "assistant",
118        "model": requested_model,
119        "content": blocks or [{"type": "text", "text": ""}],
120        "stop_reason": reason,
121        "stop_sequence": None,
122        "usage": {
123            "input_tokens": usage.get("prompt_tokens", 0),
124            "output_tokens": usage.get("completion_tokens", 0),
125        },
126    }

回程转换同样克制:sources/my-free-code/my_free_code/core/anthropic.py:90 到 sources/my-free-code/my_free_code/core/anthropic.py:92 保留文本,sources/my-free-code/my_free_code/core/anthropic.py:93 到 sources/my-free-code/my_free_code/core/anthropic.py:105 还原 tool_use,sources/my-free-code/my_free_code/core/anthropic.py:107 到 sources/my-free-code/my_free_code/core/anthropic.py:111 将 finish_reason 映射成 Anthropic stop_reason。

▸Responses API 只是另一个入口

sources/my-free-code/my_free_code/core/responses.py:1 - sources/my-free-code/my_free_code/core/responses.py:48Python
1def responses_to_messages(body: dict) -> dict:
2    # Minimal normalization for Responses clients. Rich response items are
3    # intentionally preserved as text/tool content rather than discarded.
4    input_value = body.get("input", "")
5    messages = []
6    if isinstance(input_value, str):
7        messages.append({"role": "user", "content": input_value})
8    elif isinstance(input_value, list):
9        for item in input_value:
10            if isinstance(item, dict):
11                role = item.get("role", "user")
12                content = item.get("content", "")
13                messages.append({"role": role, "content": content})
14            else:
15                messages.append({"role": "user", "content": str(item)})
16
17    return {
18        "messages": messages,
19        "model": body.get("model", ""),
20        "max_tokens": body.get("max_output_tokens", 4096),
21        "stream": bool(body.get("stream")),
22    }
23
24def messages_to_response(message: dict) -> dict:
25    text = "".join(
26        b.get("text", "")
27        for b in message.get("content", [])
28        if isinstance(b, dict) and b.get("type") == "text"
29    )
30    return {
31        "id": message.get("id"),
32        "object": "response",
33        "status": "completed",
34        "model": message.get("model"),
35        "output": [{
36            "type": "message",
37            "role": "assistant",
38            "content": [{"type": "output_text", "text": text}],
39        }],
40        "usage": {
41            "input_tokens": message.get("usage", {}).get("input_tokens", 0),
42            "output_tokens": message.get("usage", {}).get("output_tokens", 0),
43            "total_tokens": (
44                message.get("usage", {}).get("input_tokens", 0)
45                + message.get("usage", {}).get("output_tokens", 0)
46            ),
47        },
48    }

Responses 入口没有另写 provider 调用,而是在 sources/my-free-code/my_free_code/api/routes.py:117 到 sources/my-free-code/my_free_code/api/routes.py:124 转成 Messages 再复用同一条路径。sources/my-free-code/my_free_code/core/responses.py:17 到 sources/my-free-code/my_free_code/core/responses.py:22 生成 messages body,sources/my-free-code/my_free_code/core/responses.py:24 到 sources/my-free-code/my_free_code/core/responses.py:48 再把 Anthropic 结果包装回 response 对象。

深度阅读 · 客户端入口与配置边界
§ 04 · STREAMING

流式回放:fallback 只能在未开始输出前切换

▸非流式 fallback 很直接

sources/my-free-code/my_free_code/api/routes.py:49 - sources/my-free-code/my_free_code/api/routes.py:67Python
49@router.post("/v1/messages")
50async def messages(body: dict, authorization: str | None = Header(None)):
51    require_auth(authorization)
52    selected = select_model(body.get("model"), settings)
53
54    if not body.get("stream"):
55        last = None
56        for model in candidates(selected, settings):
57            try:
58                return await runtime.complete(body, model)
59            except Exception as exc:
60                last = exc
61        raise HTTPException(status_code=502, detail=str(last))
62
63    return StreamingResponse(
64        stream_messages(body, selected),
65        media_type="text/event-stream",
66        headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
67    )

Messages 端点先在 sources/my-free-code/my_free_code/api/routes.py:51 鉴权,再用 sources/my-free-code/my_free_code/api/routes.py:52 选择模型。非流式模式从 sources/my-free-code/my_free_code/api/routes.py:54 到 sources/my-free-code/my_free_code/api/routes.py:61 遍历 candidates,所有 provider 都失败才返回 502。

▸流式模式的 committed 边界

sources/my-free-code/my_free_code/api/routes.py:69 - sources/my-free-code/my_free_code/api/routes.py:115Python
69async def stream_messages(body, selected):
70    message_id = "msg_" + uuid.uuid4().hex[:20]
71    start = {
72        "type": "message_start",
73        "message": {
74            "id": message_id,
75            "type": "message",
76            "role": "assistant",
77            "content": [],
78            "model": selected,
79            "stop_reason": None,
80            "usage": {"input_tokens": 0, "output_tokens": 0},
81        },
82    }
83    yield f"event: message_start\ndata: {json.dumps(start)}\n\n"
84    yield 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n'
85
86    started = False
87    error = None
88    for model in candidates(selected, settings):
89        try:
90            async for chunk in runtime.stream(body, model):
91                delta = ((chunk.get("choices") or [{}])[0].get("delta") or {})
92                text = delta.get("content")
93                if text:
94                    started = True
95                    event = {
96                        "type": "content_block_delta",
97                        "index": 0,
98                        "delta": {"type": "text_delta", "text": text},
99                    }
100                    yield f"event: content_block_delta\ndata: {json.dumps(event)}\n\n"
101            error = None
102            break
103        except Exception as exc:
104            error = exc
105            if started:
106                break
107
108    if error and not started:
109        event = {"type":"error","error":{"type":"api_error","message":str(error)}}
110        yield f"event: error\ndata: {json.dumps(event)}\n\n"
111        return
112
113    yield 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n'
114    yield 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n'
115    yield 'event: message_stop\ndata: {"type":"message_stop"}\n\n'

流式路径先发 Anthropic SSE 起始事件,sources/my-free-code/my_free_code/api/routes.py:83 和 sources/my-free-code/my_free_code/api/routes.py:84 已经把客户端带入输出状态。随后 sources/my-free-code/my_free_code/api/routes.py:88 到 sources/my-free-code/my_free_code/api/routes.py:106 只有在 started 仍为 false 时才安全 fallback;一旦发过 token,就不能静默切到另一个模型。

▸Provider stream 释放 semaphore

sources/my-free-code/my_free_code/providers/runtime.py:58 - sources/my-free-code/my_free_code/providers/runtime.py:73Python
58    async def stream(self, anthropic_body, public_model):
59        provider_id, remote = public_model.split("/", 1)
60        sem = await self.admit(provider_id)
61        await sem.acquire()
62        try:
63            adapter = self.adapter(provider_id)
64            async for item in adapter.stream(
65                self.context(provider_id, remote),
66                anthropic_to_openai(anthropic_body, remote)
67            ):
68                yield item
69        except Exception:
70            self.unhealthy_until[provider_id] = time.monotonic() + 2
71            raise
72        finally:
73            sem.release()

runtime.stream 手动 acquire semaphore,是因为 async generator 需要跨 yield 保持占用。sources/my-free-code/my_free_code/providers/runtime.py:60 到 sources/my-free-code/my_free_code/providers/runtime.py:61 获取许可,sources/my-free-code/my_free_code/providers/runtime.py:69 到 sources/my-free-code/my_free_code/providers/runtime.py:71 记录短暂 backoff,sources/my-free-code/my_free_code/providers/runtime.py:72 到 sources/my-free-code/my_free_code/providers/runtime.py:73 确保流结束或异常都释放许可。

§ 05 · TESTS

测试证据:覆盖了契约,还没覆盖所有失败模式

▸路由测试

sources/my-free-code/tests/test_router.py:1 - sources/my-free-code/tests/test_router.py:14Python
1from my_free_code.core.router import select_model, candidates
2from my_free_code.config import Settings
3
4def test_model_routing():
5    s = Settings(model="open_router/free", sonnet="deepseek/deepseek-chat")
6    assert select_model("sonnet", s) == "deepseek/deepseek-chat"
7
8def test_explicit_model():
9    s = Settings(model="open_router/free")
10    assert select_model("groq/foo", s) == "groq/foo"
11
12def test_fallbacks_are_unique():
13    s = Settings(model="a/b", fallbacks=["c/d","a/b","c/d"])
14    assert candidates("a/b", s) == ["a/b","c/d"]

测试能证明三个重要契约:sources/my-free-code/tests/test_router.py:4 到 sources/my-free-code/tests/test_router.py:7 验证 sonnet 别名映射,sources/my-free-code/tests/test_router.py:8 到 sources/my-free-code/tests/test_router.py:10 验证显式 provider/model 直通,sources/my-free-code/tests/test_router.py:12 到 sources/my-free-code/tests/test_router.py:14 验证 fallback 去重。

▸协议测试

sources/my-free-code/tests/test_protocol.py:1 - sources/my-free-code/tests/test_protocol.py:26Python
1from my_free_code.core.anthropic import anthropic_to_openai, openai_to_anthropic, rough_token_count
2
3def test_anthropic_conversion():
4    body = {
5        "model": "sonnet",
6        "system": "You are helpful",
7        "messages": [{"role":"user","content":"hello"}],
8        "max_tokens": 100,
9    }
10    out = anthropic_to_openai(body, "test-model")
11    assert out["model"] == "test-model"
12    assert out["messages"][0]["role"] == "system"
13    assert out["messages"][1]["content"] == "hello"
14
15def test_response_conversion():
16    data = {
17        "id": "x",
18        "choices": [{"message":{"content":"hello"},"finish_reason":"stop"}],
19        "usage":{"prompt_tokens":3,"completion_tokens":2},
20    }
21    out = openai_to_anthropic(data, "provider/test")
22    assert out["content"][0]["text"] == "hello"
23    assert out["usage"]["output_tokens"] == 2
24
25def test_token_counter():
26    assert rough_token_count({"messages":[{"role":"user","content":"hello"}]}) > 0

协议测试覆盖了转换的最低契约:sources/my-free-code/tests/test_protocol.py:3 到 sources/my-free-code/tests/test_protocol.py:13 检查 Anthropic 到 OpenAI 的 system/message 顺序,sources/my-free-code/tests/test_protocol.py:15 到 sources/my-free-code/tests/test_protocol.py:23 检查响应 token usage,sources/my-free-code/tests/test_protocol.py:25 到 sources/my-free-code/tests/test_protocol.py:26 保证粗略 token counter 不返回 0。

▸v0.8 行为测试

sources/my-free-code/tests/test_v08.py:1 - sources/my-free-code/tests/test_v08.py:21Python
1from my_free_code.core.reasoning import resolve_reasoning
2from my_free_code.core.model_catalog import public_catalog 
3from my_free_code.core.streaming import openai_delta_to_anthropic
4from my_free_code.config import Settings
5
6def test_reasoning_disabled():
7    p = resolve_reasoning({"thinking":{"type":"disabled"}})
8    assert p.mode == "off"
9
10def test_reasoning_budget():
11    p = resolve_reasoning({"thinking":{"type":"enabled","budget_tokens":1234}})
12    assert p.budget_tokens == 1234
13
14def test_catalog_has_gateway_models():
15    data = public_catalog(Settings(model="x/y"))
16    assert "claude-sonnet" in data
17    assert "x/y" in data
18
19def test_stream_delta():
20    events = openai_delta_to_anthropic({"content":"hi"})
21    assert "text_delta" in events[0]

v0.8 测试把 thinking、catalog 和 stream delta 放在一起:sources/my-free-code/tests/test_v08.py:6 到 sources/my-free-code/tests/test_v08.py:12 验证 reasoning policy,sources/my-free-code/tests/test_v08.py:14 到 sources/my-free-code/tests/test_v08.py:17 验证目录包含 gateway 与直连模型,sources/my-free-code/tests/test_v08.py:19 到 sources/my-free-code/tests/test_v08.py:21 检查 streaming delta 输出 text_delta。

§ 06 · RISKS

技术亮点之外,别忽略这些

▸已确认的代码风险

最直接的问题在 sources/my-free-code/my_free_code/cli/mfc.py:3:源码写成了无效的 import . import point,所以 sources/my-free-code/pyproject.toml:22 暴露的 mfc-client entrypoint 很可能无法导入。这个风险不影响 routes/runtime 的分析,但会影响“多客户端 launcher”这条产品路径。

鉴权默认值
sources/my-free-code/my_free_code/config.py:11 默认 auth_token 为 local,适合本地代理,但部署到共享机器时必须显式改强 token。
错误分类未闭环
sources/my-free-code/my_free_code/core/failures.py:16 定义了 classify_status,但 adapter 仍直接 RuntimeError,重试语义没有完全接入。
Admin loopback 判断偏薄
sources/my-free-code/my_free_code/api/routes.py:126 到 sources/my-free-code/my_free_code/api/routes.py:128 依赖 Host header 判断本地访问,生产代理前需要更强约束。
流式 reasoning 不完整
sources/my-free-code/my_free_code/core/streaming.py:14 到 sources/my-free-code/my_free_code/core/streaming.py:21 保留 reasoning 字段,但 routes 目前只转 text delta。
Generated · 2026-08-31 Source · hkqr/my-free-code ← 返回 Trending 索引