独立开发者:搭建一个兼容 OpenAI 协议的本地 AI 路由(基于 OpenClaw 与 Codex OAuth)

0 阅读10分钟

“给我六个小时砍倒一棵树,我会先用四个小时磨利斧头。”——亚伯拉罕·林肯

独立开发并非事必躬亲,而是选择在哪里磨利你的斧头。把所有脚本、编辑器插件和副业项目直接绑死一个 API 密钥就草草完工,实在太容易了。但这种捷径会悄悄把复杂度扩散到整个技术栈。一旦你想切换模型、修改认证、做沙箱执行,或是运行简单文本生成之外的本地操作,这些“胶水代码”就会开始崩裂。

而本地 AI 路由,正是破局点。

在本地启动一个兼容 OpenAI 协议的端点(底层由 Codex/OpenClaw/你自己的服务驱动),你就能继续使用已有的所有工具,同时把控制权集中起来。你的 AI 不再只是一个写作助手,而是变成了本地算子、一个可编程的后端,能无缝集成到任何地方。同一套接口,更清晰的边界,更强的掌控力。

我已经记不清有多少个副业项目死于同样的结局:

原型快速上线

“直接用 OpenAI API”的阶段维持一周

然后我发现自己的笔记本电脑变成了一个吞 Token 机器

于是我开始像对待开发中的数据库一样对待 AI:在它前面加一层接口。

不是庞大的平台,不是 SaaS。而是一个轻量“本地 AI 路由”

一侧对外提供 OpenAI 风格的 HTTP 接口

另一侧对接你现有的 Codex 登录态(OAuth、订阅授权)

不再把长期有效的 API 密钥散落在终端、dotfiles、CI 或各种插件里。

本文会介绍两种实现方案:

一种“开箱即用”(OpenClaw)

一种“通读一遍就能懂的微型网关”

并提供 Python 和 TypeScript/JavaScript 可直接运行的示例。


核心思路:本机上的个人 AI 端点

你想要的架构大概长这样:

Image

一旦这个路由存在,所有事情都会变得“平淡且好用”:

你的编辑器插件可以继续以为自己在调用 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 访问”从每个应用各自一堆密钥、令牌、配置的混乱局面,变成一套统一的本地契约

一个端点

一个认证入口

一个添加策略的地方

一个调试入口

对独立开发者而言,这几乎就是全部的核心价值。

-------------------------------------------------------------

微信公众号:算子之心