“给我六个小时砍倒一棵树,我会先用四个小时磨利斧头。”——亚伯拉罕·林肯
独立开发并非事必躬亲,而是选择在哪里磨利你的斧头。把所有脚本、编辑器插件和副业项目直接绑死一个 API 密钥就草草完工,实在太容易了。但这种捷径会悄悄把复杂度扩散到整个技术栈。一旦你想切换模型、修改认证、做沙箱执行,或是运行简单文本生成之外的本地操作,这些“胶水代码”就会开始崩裂。
而本地 AI 路由,正是破局点。
在本地启动一个兼容 OpenAI 协议的端点(底层由 Codex/OpenClaw/你自己的服务驱动),你就能继续使用已有的所有工具,同时把控制权集中起来。你的 AI 不再只是一个写作助手,而是变成了本地算子、一个可编程的后端,能无缝集成到任何地方。同一套接口,更清晰的边界,更强的掌控力。
我已经记不清有多少个副业项目死于同样的结局:
•
原型快速上线
•
“直接用 OpenAI API”的阶段维持一周
•
然后我发现自己的笔记本电脑变成了一个吞 Token 机器
于是我开始像对待开发中的数据库一样对待 AI:在它前面加一层接口。
不是庞大的平台,不是 SaaS。而是一个轻量“本地 AI 路由”:
•
一侧对外提供 OpenAI 风格的 HTTP 接口
•
另一侧对接你现有的 Codex 登录态(OAuth、订阅授权)
不再把长期有效的 API 密钥散落在终端、dotfiles、CI 或各种插件里。
本文会介绍两种实现方案:
•
一种“开箱即用”(OpenClaw)
•
一种“通读一遍就能懂的微型网关”
并提供 Python 和 TypeScript/JavaScript 可直接运行的示例。
核心思路:本机上的个人 AI 端点
你想要的架构大概长这样:
一旦这个路由存在,所有事情都会变得“平淡且好用”:
•
你的编辑器插件可以继续以为自己在调用 OpenAI
•
认证体验保持人性化:一次登录,全程可用
•
你可以添加安全护栏:仅本地访问、Bearer Token、沙箱执行
两套方案,一套接口规范
仓库里两种方案都刻意暴露完全相同的接口格式:POST /v1/chat/completions
方案 A:OpenClaw 网关(本地 OpenAI 兼容路由)
OpenClaw 本身就支持在本地提供 OpenAI 兼容接口,并且可以通过 Codex 订阅 OAuth 提供商完成认证。
可以把它理解为:带配置、智能体路由、清爽 UX 的本地 AI 网关。
方案 B:迷你 Codex 网关(封装 codex exec)
极简路线:运行一个超轻量服务器,接收 OpenAI 风格的对话请求,然后调用 codex exec。
可以理解为:一个你能完全掌控、嵌入自己技术栈的微型适配器。
为什么 Codex OAuth 对独立开发者体验极佳
最关键的体验细节是:
•
Codex CLI 只需交互式登录一次(codex login)
•
登录后,本机就保留有效会话
•
你的路由可以“借用”这个会话
•
直接通过 OpenClaw 提供商
•
或间接通过 codex exec(它本身就用会话)
这意味着:
•
不用把 API 密钥复制进 .env
•
不会意外提交到代码仓库
•
不会出现“为啥我的密钥出现在 Shell 历史里”
•
对那些强制要求 OpenAI 兼容端点的本地工具非常友好
仓库里的基础提醒:把本地认证文件(如 ~/.codex/auth.json)当作机密处理,即使它们不是 API 密钥。
方案 A:用 OpenClaw 做本地 OpenAI 兼容路由
这是**“本地生产级”**选项。
1)通过 Codex OAuth 认证 OpenClaw
仓库里的流程:
openclaw onboard --auth-choice openai-codex
# 或
openclaw models auth login --provider openai-codex
这就是关键“桥梁”:明确告诉 OpenClaw 使用 openai-codex 认证提供商。
2)在 OpenClaw 中启用 /v1/chat/completions
示例配置开启对话补全接口,并设置基于 Codex 的默认模型。
示例(openclaw.json.example):
{
"agents": {
"defaults": {
"model": {
"primary": "openai-codex/gpt-5.3-codex"
}
}
},
"gateway": {
"http": {
"endpoints": {
"chatCompletions": {
"enabled": true
}
}
}
}
}
注意模型名称:openai-codex/gpt-5.3-codex。OpenClaw 可以把“模型选择”变成配置问题,客户端只需要传简单参数。
3)启动 OpenClaw 网关
默认示例使用 127.0.0.1:18789。
4)冒烟测试(curl)
提供的测试会 POST 到 /v1/chat/completions,包含:
•
x-openclaw-agent-id 请求头
•
可选 Authorization: Bearer ...(如果开启网关认证)
5)可运行客户端(Node + Python)
Node(fetch)客户端
#!/usr/bin/env node
const baseUrl = process.env.OPENCLAW_GATEWAY_URL ?? "http://127.0.0.1:18789";
const token = process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const agent = process.env.OPENCLAW_AGENT_ID ?? "main";
async function chat(prompt) {
const headers = {
"Content-Type": "application/json",
"x-openclaw-agent-id": agent,
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
return data.choices?.[0]?.message?.content ?? "";
}
chat("Draft a README for a small CLI tool.")
.then(console.log)
.catch((err) => {
console.error(String(err));
process.exit(1);
});
Python 客户端
#!/usr/bin/env python3
"""Simple OpenClaw chat completions client."""
import os
import requests
BASE_URL = os.getenv("OPENCLAW_GATEWAY_URL", "http://127.0.0.1:18789")
TOKEN = os.getenv("OPENCLAW_GATEWAY_TOKEN", "")
AGENT_ID = os.getenv("OPENCLAW_AGENT_ID", "main")
def chat(prompt: str) -> str:
headers = {
"Content-Type": "application/json",
"x-openclaw-agent-id": AGENT_ID,
}
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
response = requests.post(
f"{BASE_URL}/v1/chat/completions",
headers=headers,
json={
"model": "openclaw",
"messages": [{"role": "user", "content": prompt}],
},
timeout=120,
)
response.raise_for_status()
body = response.json()
return body["choices"][0]["message"]["content"]
if __name__ == "__main__":
print(chat("Write a tiny palindrome checker in Python."))
OpenClaw 到底在做什么(通俗解释)
OpenClaw 本质是职责分离:
•
客户端契约:类 OpenAI HTTP(/v1/chat/completions)
•
用户身份:本地登录 OpenClaw 的用户(Codex OAuth)
•
模型选择:在智能体/默认配置层面指定
•
本地访问控制:可选网关 Bearer Token + 绑定本地
这种分离让体验无比流畅:你得到稳定的本地端点,而认证、模型复杂度都藏在后面。
方案 B:搭建一个封装 codex exec 的迷你本地网关
有时候你不想要框架,只想要一个能读懂、能修改、能随仓库一起发布的文件。
这个方案就是:本地 OpenAI 风格端点
1
接收 messages[]
2
转成提示词
3
执行 codex exec ...
4
返回类 OpenAI JSON 响应
仓库的 README 对默认值写得非常清楚:本地绑定、可选 Token、Codex 参数、模型默认值等。
你要模拟的 OpenAI 风格接口
你的工具发送类似结构:
{
"model": "gpt-5-codex",
"messages": [
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Write a Makefile for a small TypeScript project." }
]
}
返回类似结构:
{
"id": "chatcmpl-local-123",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-5-codex",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "..." },
"finish_reason": "stop"
}
]
}
对本地开发来说,Token 计算是可选的,大多数工具并不真的需要。
安全基线(别跳过)
仓库给出的基线是极佳默认配置:
•
除非明确需要远程访问,否则绑定 127.0.0.1
•
建议启用 Bearer Token(MINI_ROUTER_TOKEN)
•
把本地认证文件当作机密
这样才能避免“本地便利”变成“不小心在 Wi‑Fi 上暴露 AI 端点”。
迷你网关:Python(FastAPI)
可运行、极简、易读
•
默认主机/端口:127.0.0.1:8787
•
可选 Authorization: Bearer <MINI_ROUTER_TOKEN>
•
以沙箱模式执行 codex exec,无需人工确认
requirements.txt
fastapi>=0.110,<1.0
uvicorn[standard]>=0.23,<1.0
mini_gateway.py
#!/usr/bin/env python3
"""
Mini Codex Gateway (FastAPI)
- Exposes POST /v1/chat/completions (OpenAI-ish)
- Runs `codex exec` under the hood, relying on your existing `codex login` session
"""
from __future__ import annotations
import os
import time
import uuid
import subprocess
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
HOST = os.getenv("MINI_ROUTER_HOST", "127.0.0.1")
PORT = int(os.getenv("MINI_ROUTER_PORT", "8787"))
TOKEN = os.getenv("MINI_ROUTER_TOKEN", "") # 为空则不鉴权
CODEX_BIN = os.getenv("CODEX_BIN", "codex")
CODEX_DEFAULT_MODEL = os.getenv("CODEX_DEFAULT_MODEL", "gpt-5-codex")
CODEX_REASONING_EFFORT = os.getenv("CODEX_REASONING_EFFORT", "high")
CODEX_EXEC_TIMEOUT_MS = int(os.getenv("CODEX_EXEC_TIMEOUT_MS", "180000"))
def _require_token(auth_header: Optional[str]) -> None:
if not TOKEN:
return
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
if auth_header.removeprefix("Bearer ").strip() != TOKEN:
raise HTTPException(status_code=403, detail="Invalid bearer token")
def _messages_to_prompt(messages: List[Dict[str, Any]]) -> str:
parts: List[str] = []
for m in messages:
role = (m.get("role") or "").strip()
content = (m.get("content") or "").strip()
if not content:
continue
if role "system":
parts.append(f"[SYSTEM]\n{content}\n")
elif role "user":
parts.append(f"[USER]\n{content}\n")
elif role == "assistant":
parts.append(f"[ASSISTANT]\n{content}\n")
else:
parts.append(f"[{role.upper() or 'MESSAGE'}]\n{content}\n")
return "\n".join(parts).strip() + "\n"
def _run_codex_exec(prompt: str, model: str) -> str:
base_args = [
CODEX_BIN,
"exec",
"--sandbox",
"read-only",
"--ask-for-approval",
"never",
"-c",
f'model_reasoning_effort="{CODEX_REASONING_EFFORT}"',
"--skip-git-repo-check",
]
candidates = [
base_args + ["--model", model, prompt],
base_args + ["-m", model, prompt],
base_args + [prompt],
]
last_err = None
for args in candidates:
try:
completed = subprocess.run(
args,
capture_output=True,
text=True,
timeout=CODEX_EXEC_TIMEOUT_MS / 1000.0,
check=False,
)
except Exception as e:
last_err = str(e)
continue
if completed.returncode == 0:
return (completed.stdout or "").strip()
stderr = (completed.stderr or "").lower()
if "unexpected argument" in stderr or "unknown option" in stderr:
last_err = completed.stderr
continue
raise HTTPException(
status_code=502,
detail=f"codex exec failed ({completed.returncode}): {completed.stderr or completed.stdout}",
)
raise HTTPException(status_code=502, detail=f"codex exec failed: {last_err or 'unknown error'}")
@app.post("/v1/chat/completions")
async def chat_completions(request: Request, authorization: Optional[str] = Header(default=None)):
_require_token(authorization)
body = await request.json()
messages = body.get("messages")
if not isinstance(messages, list) or not messages:
raise HTTPException(status_code=400, detail="`messages` must be a non-empty array")
model = (body.get("model") or "").strip() or CODEX_DEFAULT_MODEL
prompt = _messages_to_prompt(messages)
text = _run_codex_exec(prompt=prompt, model=model)
resp = {
"id": f"chatcmpl-local-{uuid.uuid4().hex[:12]}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text},
"finish_reason": "stop",
}
],
}
return JSONResponse(resp)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=HOST, port=PORT)
快速运行
cd python
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export MINI_ROUTER_TOKEN="supersecret"
python mini_gateway.py
迷你网关:Node(Express + TypeScript)
行为与上面完全一致,适合 Node/TS 技术栈。
package.json
{
"name": "mini-codex-gateway",
"private": true,
"type": "module",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
src/server.ts
import express from "express";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import crypto from "node:crypto";
const execFileAsync = promisify(execFile);
const HOST = process.env.MINI_ROUTER_HOST ?? "127.0.0.1";
const PORT = Number(process.env.MINI_ROUTER_PORT ?? "8787");
const TOKEN = process.env.MINI_ROUTER_TOKEN ?? "";
const CODEX_BIN = process.env.CODEX_BIN ?? "codex";
const CODEX_DEFAULT_MODEL = process.env.CODEX_DEFAULT_MODEL ?? "gpt-5-codex";
const CODEX_REASONING_EFFORT = process.env.CODEX_REASONING_EFFORT ?? "high";
const CODEX_EXEC_TIMEOUT_MS = Number(process.env.CODEX_EXEC_TIMEOUT_MS ?? "180000");
function requireToken(authHeader: string | undefined) {
if (!TOKEN) return;
if (!authHeader?.startsWith("Bearer ")) {
const err = new Error("Missing bearer token");
(err as any).status = 401;
throw err;
}
const got = authHeader.slice("Bearer ".length).trim();
if (got !== TOKEN) {
const err = new Error("Invalid bearer token");
(err as any).status = 403;
throw err;
}
}
function messagesToPrompt(messages: any[]): string {
const parts: string[] = [];
for (const m of messages) {
const role = String(m?.role ?? "").trim();
const content = String(m?.content ?? "").trim();
if (!content) continue;
if (role = "system") parts.push(`[SYSTEM]\n${content}\n`);
else if (role = "user") parts.push(`[USER]\n${content}\n`);
else if (role === "assistant") parts.push(`[ASSISTANT]\n${content}\n`);
else parts.push(`[${(role || "message").toUpperCase()}]\n${content}\n`);
}
return parts.join("\n").trim() + "\n";
}
async function runCodexExec(prompt: string, model: string): Promise<string> {
const baseArgs = [
"exec",
"--sandbox",
"read-only",
"--ask-for-approval",
"never",
"-c",
`model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
"--skip-git-repo-check",
];
const candidates: string[][] = [
[...baseArgs, "--model", model, prompt],
[...baseArgs, "-m", model, prompt],
[...baseArgs, prompt],
];
let lastErr: any = null;
for (const args of candidates) {
try {
const { stdout } = await execFileAsync(CODEX_BIN, args, {
timeout: CODEX_EXEC_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
return String(stdout ?? "").trim();
} catch (e: any) {
lastErr = e;
const msg = String(e?.stderr ?? e?.message ?? "").toLowerCase();
if (msg.includes("unexpected argument") || msg.includes("unknown option")) {
continue;
}
throw e;
}
}
throw new Error(`codex exec failed: ${String(lastErr?.stderr ?? lastErr?.message ?? lastErr)}`);
}
const app = express();
app.use(express.json({ limit: "1mb" }));
app.post("/v1/chat/completions", async (req, res) => {
try {
requireToken(req.header("authorization"));
const body = req.body ?? {};
const messages = body.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: "`messages` must be a non-empty array" });
}
const model = String(body.model ?? "").trim() || CODEX_DEFAULT_MODEL;
const prompt = messagesToPrompt(messages);
const text = await runCodexExec(prompt, model);
return res.json({
id: `chatcmpl-local-${crypto.randomBytes(6).toString("hex")}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: { role: "assistant", content: text },
finish_reason: "stop",
},
],
});
} catch (err: any) {
const status = Number(err?.status ?? 502);
return res.status(status).json({ error: String(err?.message ?? err) });
}
});
app.listen(PORT, HOST, () => {
console.log(`Mini Codex Gateway listening on http://${HOST}:${PORT}`);
});
快速运行
cd node
npm install
export MINI_ROUTER_TOKEN="supersecret"
npm run dev
极简冒烟测试(两种迷你网关通用)
export MINI_ROUTER_TOKEN="supersecret"
curl -sS "http://127.0.0.1:8787/v1/chat/completions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${MINI_ROUTER_TOKEN}" \
-d '{
"model":"gpt-5-codex",
"messages":[{"role":"user","content":"Say hi in one sentence."}]
}' | jq .
没有 jq 就去掉管道。
该选哪种方案
选 OpenClaw(方案 A),如果:
•
你想要功能更丰富的本地网关,带智能体、配置系统
•
不介意引入 OpenClaw 依赖
•
想快速得到“开箱即用的 OpenAI 端点”体验
选迷你网关(方案 B),如果:
•
你想要尽可能少的外部依赖
•
想完全掌控请求格式与策略
•
只想依赖 Codex CLI + 单个文件
•
能接受“本地开发够用”的语义(无流式、简单用量统计)
几个能让体验更“正式”的优化
如果你想让它像个人“自托管 ChatGPT 端点”,几个小细节提升巨大:
•
无缝兼容 OpenAI:让路由支持 OPENAI_BASE_URL 规范
•
Token 可选但不烦人:设了就校验,不设就只允许本地访问
•
一致的默认值:固定本地地址与端口,方便工具固化配置
•
严格沙箱:默认以只读沙箱运行 codex exec,无需人工确认
代码仓库
两种方案(OpenClaw 路由 + 迷你 Codex 网关)的所有代码、冒烟测试、可运行客户端都放在公开仓库的 local-routers/ 目录下。
总结
本地 AI 路由是一件小事,但能改变一切。
它把“AI 访问”从每个应用各自一堆密钥、令牌、配置的混乱局面,变成一套统一的本地契约:
•
一个端点
•
一个认证入口
•
一个添加策略的地方
•
一个调试入口
对独立开发者而言,这几乎就是全部的核心价值。
-------------------------------------------------------------