NanoClaw 替換底層 AI 模型(MiniMax、第三方 API)

NanoClaw 原本只支援 Claude API。為了節省成本,我們擴充了內建的 credential-proxy,新增 OVERRIDE_MODEL 機制,讓 agent 可以透明地切換到任何相容 OpenAI API 格式的第三方模型(如 MiniMax)。


架構

1
2
3
4
容器 agent (Claude Agent SDK)
→ credential-proxy (host:3001)
→ 原始 Claude API (預設)
→ 第三方 API (設定 OVERRIDE_MODEL 後)

agent 程式碼完全不知道底下換了模型,credential-proxy 在中間做所有轉換。


實作過程

為什麼需要動 credential-proxy

Claude Agent SDK 在發出請求時,會把 model 名稱(如 claude-sonnet-4-6)一起帶在 request body 裡。如果只是把 ANTHROPIC_BASE_URL 換成 MiniMax 的網址,SDK 還是會送 claude-sonnet-4-6 這個 model 名,MiniMax 不認識,請求就會失敗。

所以需要在中間攔截請求,把 model 名稱換掉。credential-proxy 本來就是 agent 和 API 之間的代理,改這裡最乾淨,不需要動 agent 程式碼。

實作的三個部分

1. 攔截訊息請求,替換 model 名稱

proxy 收到 /messages 請求時,把 body 解析成 JSON,把 model 欄位換成 OVERRIDE_MODEL 的值,再轉發出去:

1
2
3
4
5
6
7
if (secrets.OVERRIDE_MODEL && req.url?.includes('/messages')) {
const parsed = JSON.parse(body.toString());
if (parsed.model) {
parsed.model = secrets.OVERRIDE_MODEL;
body = Buffer.from(JSON.stringify(parsed));
}
}

2. 注入 placeholder model 到 /v1/models

Claude Code SDK 啟動時會呼叫 /v1/models 確認設定的 model 存在。MiniMax 的回應只有自己的 model 清單,沒有任何 Claude model,SDK 就會報錯說「找不到這個 model」。

解法:攔截 /v1/models 的回應,把 placeholder model name(claude-haiku-4-5-20251001)注入進去,讓 SDK 以為 Claude model 存在、通過驗證,實際請求再換掉。

1
2
3
4
5
6
7
if (!alreadyPresent) {
models.push({
id: 'claude-haiku-4-5-20251001',
type: 'model',
display_name: 'claude-haiku-4-5-20251001',
});
}

3. 修正 upstream path 拼接

原本的 proxy 在拼接 upstream URL 時沒有去掉結尾的 /,導致路徑出現雙斜線(例如 /v1//messages),部分 API 服務不接受。加了 .replace(/\/$/, '') 修正。

設定方式

.env 或 launchd plist 加入:

1
2
3
OVERRIDE_MODEL=MiniMax-M2.7
ANTHROPIC_BASE_URL=https://api.minimax.io/v1
ANTHROPIC_API_KEY=your_minimax_api_key

proxy 啟動時讀取這三個變數,之後所有請求都自動走第三方 API,無需修改容器或 agent 程式碼。


Apple Container 的特殊設定

問題背景

容器內的 agent 需要連回 host 上的 credential-proxy,才能發出 API 請求。但「容器裡的 localhost」指的是容器本身,不是 Mac,所以需要一個辦法讓容器知道 host 的 IP 是多少。

Docker 內建了固定 hostname host.docker.internal,容器直接用這個名稱就能找到 host。

Apple Container(macOS 原生) 沒有這個機制。它透過 macOS 的 bridge100 虛擬網路介面把容器和 host 連起來,host 在這個網路裡有一個 IP(通常是 192.168.64.1 之類),但這個 IP 不固定,必須動態取得。

解法

container-runtime.ts 在啟動容器前自動偵測平台:

  • macOS(Apple Container):讀取 bridge100 介面的實際 IP,把這個 IP 傳給容器當作 proxy 位址;credential-proxy 也同時綁定到這個 IP,讓容器能連進來
  • 其他平台(Docker):直接用 host.docker.internal

整個過程對 agent 透明,agent 只知道 proxy 在某個位址,不需要知道底層平台。

⚠️ Docker 有 --add-host 參數可以手動把 hostname 對應到 IP,但 Apple Container 不支援,所以只能走動態偵測 bridge100 IP 這條路。