← 返回部落格
·7 min 閱讀·技術筆記

用 Go 打造 OpenAI Codex API Proxy:OAuth 認證與 SSE 串流轉譯

記錄如何用純 Go 標準庫實作一個 OpenAI 相容的 API Proxy,讓 ChatGPT Plus 訂閱直接驅動 Cursor、aider 等開發工具,涵蓋 OAuth Device Flow、Responses API 格式轉譯與 SSE 串流處理。

GoAPI ProxyOAuthSSE開發工具
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

動機

OpenAI 的 Codex CLI 用的是 ChatGPT 帳號的 OAuth 認證,而不是付費的 API key。這意味著如果你有 ChatGPT Plus/Pro 訂閱,理論上可以透過同樣的認證方式使用 Codex 的模型。

問題是:Cursor、aider、Continue 這些開發工具都只支援標準的 OpenAI Chat Completions API 格式。但 Codex 後端用的是一個不同的 Responses API 格式。

解法很明確:寫一個 proxy,對外提供標準的 OpenAI API 介面,對內轉譯成 Codex Responses API。

為什麼用 Go?

  • 零外部依賴:純標準庫就能處理 HTTP server、JSON、SSE
  • 單一 binarygo build 後一個執行檔搞定,不需要 runtime
  • 並發效能:goroutine 天生適合 proxy 場景
  • 交叉編譯:一行指令 build macOS/Linux/Windows 版本

整個專案不到 1000 行 Go 程式碼,沒有引入任何第三方 package。

OAuth Device Flow

Codex 使用 OAuth 2.0 的 Device Authorization Grant(RFC 8628),這是專為 CLI/無瀏覽器環境設計的流程:

1. 應用向 auth server 請求 device_code
2. 使用者收到一個 URL 和 user_code
3. 使用者在瀏覽器中打開 URL,輸入 code 授權
4. 應用持續 polling,直到使用者完成授權
5. 收到 access_token + refresh_token

實作

func (a *Auth) Login() error {
    // 1. 請求 device code
    resp, _ := http.PostForm("https://auth.openai.com/codex/device/code", url.Values{
        "client_id": {clientID},
        "scope":     {"openid profile email"},
    })

    var deviceResp DeviceCodeResponse
    json.NewDecoder(resp.Body).Decode(&deviceResp)

    // 2. 告訴使用者去瀏覽器授權
    fmt.Printf("請前往 %s 並輸入代碼: %s\n",
        deviceResp.VerificationURI, deviceResp.UserCode)

    // 3. Polling 等待授權完成
    for {
        time.Sleep(time.Duration(deviceResp.Interval) * time.Second)

        tokenResp, err := pollForToken(deviceResp.DeviceCode)
        if err == ErrPending {
            continue // 使用者還沒授權,繼續等
        }
        if err != nil {
            return err
        }

        // 4. 存儲 tokens
        return saveTokens(tokenResp)
    }
}

自動刷新 Token

Access token 有效期短(通常 1 小時)。Proxy 在每次轉發請求前檢查 token 是否過期,過期則自動用 refresh token 換新的:

func (a *Auth) GetValidToken() (string, error) {
    if time.Now().Before(a.ExpiresAt) {
        return a.AccessToken, nil
    }

    // Token 過期,用 refresh token 換新的
    newToken, err := a.refreshToken()
    if err != nil {
        return "", err
    }

    a.AccessToken = newToken.AccessToken
    a.ExpiresAt = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second)
    a.save()

    return a.AccessToken, nil
}

API 格式轉譯

Chat Completions → Responses API

外部工具發送的是標準 Chat Completions 格式:

{
  "model": "gpt-5.4",
  "messages": [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Explain goroutines"}
  ],
  "stream": true
}

需要轉成 Codex Responses API 格式:

{
  "model": "gpt-5.4",
  "instructions": "You are a helpful assistant.",
  "input": [
    {"role": "user", "content": "Explain goroutines"}
  ],
  "stream": true
}

關鍵差異:

  • system message 變成 instructions 欄位
  • 其餘 messages 變成 input 陣列
  • toolstool_choice 直接傳遞
  • temperature 等參數被忽略(Codex 不支援)

回應格式轉譯

Codex 回傳的 Responses API 格式也需要轉回 Chat Completions 格式,讓外部工具能正確解析。

SSE 串流處理

串流模式是最複雜的部分。Codex 的 SSE 事件格式和 OpenAI Chat Completions 的 SSE 格式不同,需要即時轉譯。

挑戰

  1. Codex SSE 的事件類型是 response.output_text.delta
  2. Chat Completions 的事件類型是 chat.completion.chunk
  3. 需要在接收 Codex 串流的同時,即時產生 Chat Completions 格式的串流
  4. 最後要送一個 [DONE] 信號

實作

func (p *Proxy) handleStreaming(w http.ResponseWriter, codexResp *http.Response) {
    flusher, _ := w.(http.Flusher)
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")

    scanner := bufio.NewScanner(codexResp.Body)
    for scanner.Scan() {
        line := scanner.Text()

        if !strings.HasPrefix(line, "data: ") {
            continue
        }

        data := line[6:]
        if data == "[DONE]" {
            fmt.Fprintf(w, "data: [DONE]\n\n")
            flusher.Flush()
            return
        }

        // 解析 Codex 事件
        var event CodexEvent
        json.Unmarshal([]byte(data), &event)

        // 轉譯為 Chat Completions chunk
        chunk := translateToCompletionChunk(event)
        chunkJSON, _ := json.Marshal(chunk)

        fmt.Fprintf(w, "data: %s\n\n", chunkJSON)
        flusher.Flush()
    }
}

http.Flusher 是關鍵——每寫入一個 SSE event 就 flush 一次,確保客戶端能即時收到。不 flush 的話,Go 的 HTTP server 會 buffer 輸出,導致串流效果消失。

模型列表

Proxy 也需要實作 /v1/models endpoint,因為有些工具(如 Cursor)會先查詢可用模型:

func (p *Proxy) handleModels(w http.ResponseWriter) {
    models := []Model{
        {ID: "gpt-5.4", Object: "model"},
        {ID: "gpt-5.4-mini", Object: "model"},
        {ID: "gpt-5.3-codex", Object: "model"},
        {ID: "gpt-5.2-codex", Object: "model"},
        // ...
    }

    json.NewEncoder(w).Encode(ModelList{
        Object: "list",
        Data:   models,
    })
}

使用方式

對任何支援 OpenAI API 的工具,只需要設定:

  • Base URL: http://localhost:8787/v1
  • API Key: 任意值(proxy 用 OAuth,不需要 key)
  • Model: gpt-5.4
# Python OpenAI SDK
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8787/v1", api_key="anything")
resp = client.chat.completions.create(
    model="gpt-5.4",
    messages=[{"role": "user", "content": "hello"}],
)

Cursor、aider、Continue 等工具只要改 base_url 就能直接使用。

安全考量

本地認證

預設只監聽 localhost。如果需要開放給區網內其他機器使用,可以設定 CODEX_LOCAL_AUTH 環境變數作為 Bearer token:

CODEX_LOCAL_AUTH=my-secret-token ./codex-service

Token 儲存

OAuth token 存在 ~/.codex-service/tokens.json,權限設為 600(只有本人可讀)。

學到的經驗

Go 標準庫真的夠用

HTTP server、JSON 編解碼、SSE 串流——全部用標準庫完成。不需要 gin、echo 或任何 web framework。對這種單一用途的 proxy 來說,標準庫的 net/http 就是最好的選擇。

SSE 的坑

  • 每個事件後面要有兩個 \n(不是一個)
  • http.Flusher 不是所有 ResponseWriter 都支援(要做型別斷言)
  • 客戶端斷開時要正確處理 context cancellation,避免 goroutine 洩漏

API 相容性不只是格式轉譯

不同工具對 OpenAI API 的使用方式有微妙的差異。有些工具會送 max_tokens,有些會送 max_completion_tokens;有些會在 header 帶 Organization。Proxy 需要對這些「不認識的欄位」做安全的忽略,而不是報錯。

結語

這個專案的核心價值在於把一個認證方式轉換成工具生態系能理解的標準介面。技術上不複雜,但解決了一個實際的痛點:讓 ChatGPT Plus 訂閱的價值最大化,不侷限於網頁版。

Go 的簡潔性在這裡體現得淋漓盡致——一個 binary、零依賴、一行指令 build,就是一個穩定運作的 API proxy。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →