一句話結論:把 CLI 接上 HTTP,核心是輸入、程序生命週期和輸出格式;路徑叫 /v1/chat/completions,不代表已經支援整份協定。

遇到的問題

終端機裡已經有一個能回答問題的 AI CLI,但想接的程式只會送 HTTP 請求,兩邊講的是不同語言。

最先想到的是收到問題後跑一次 CLI,再把答案回傳。真的開始接才發現,容易出錯的地方全在中間:長 prompt 要怎麼傳?CLI 印出的 JSON 哪一段才是答案?指令卡住時,HTTP 要等多久?

這篇把實際包裝服務用到的做法縮成一個能在本機跑的範例。先用假的 CLI 驗證程序與協定,不需要模型金鑰,也不會消耗模型用量。

有實測的是 Python 包裝流程,沒有在這次測試中呼叫真實模型。 換成真實 CLI 時,登入、旗標與輸出格式仍要另外核對。

先決定相容到哪裡

範例只接受一則文字 user 訊息,回傳一次完整 JSON。model 固定用 cli-demo,只是這個服務的識別名稱。

串流、工具呼叫、圖片、多輪歷史與其他參數,都明確拒絕。這樣呼叫端才不會以為參數已生效,實際卻被悄悄忽略。

OpenAI 的 Chat Completions 文件 定義了請求與回應欄位;這裡只實作其中一小部分,目的是讓需要「送一段文字、拿一段答案」的 HTTP 客戶端能接進來。

手把手步驟

1. 先準備一支假 CLI

把下面存成 demo_cli.py。它從 stdin 讀文字,回一個含 result 的 JSON,讓我們能檢查收到多少字元。

import json import sys prompt = sys.stdin.read() print(json.dumps({"result": f"收到 {len(prompt)} 個字元:{prompt[:20]}"}, ensure_ascii=False)) 

這個 result 是假 CLI 的格式,跟 HTTP 回應裡的 choices 是兩層不同的東西。包裝服務要負責轉換。

2. 加上 HTTP 包裝

把下面存成同一個資料夾裡的 gateway.py:

import json import os import subprocess import sys import time import uuid from http.server import BaseHTTPRequestHandler, HTTPServer COMMAND = json.loads(os.environ.get("CLI_COMMAND_JSON", json.dumps([sys.executable, "demo_cli.py"]))) TIMEOUT = float(os.environ.get("CLI_TIMEOUT", "30")) class Handler(BaseHTTPRequestHandler): def reply(self, status, body): data = json.dumps(body, ensure_ascii=False).encode() self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def error(self, status, message): self.reply(status, {"error": {"message": message, "type": "wrapper_error"}}) def do_POST(self): if self.path != "/v1/chat/completions": return self.error(404, "Unknown endpoint") try: size = int(self.headers.get("Content-Length", "0")) if not 0 < size <= 1024 * 1024: return self.error(413, "Body must be between 1 byte and 1 MiB") body = json.loads(self.rfile.read(size)) if not isinstance(body, dict): raise ValueError() if set(body) - {"model", "messages", "stream"}: return self.error(400, "Only model, messages and stream are supported") if body.get("model") != "cli-demo" or body.get("stream", False) is not False: return self.error(400, "Use model=cli-demo and stream=false") messages = body.get("messages") if not isinstance(messages, list) or len(messages) != 1: raise ValueError() message = messages[0] if not isinstance(message, dict) or set(message) != {"role", "content"}: raise ValueError() if message["role"] != "user" or not isinstance(message["content"], str): raise ValueError() except (ValueError, UnicodeError): return self.error(400, "Expected one user message with string content") try: process = subprocess.run( COMMAND, input=message["content"], capture_output=True, text=True, encoding="utf-8", timeout=TIMEOUT, check=True, ) output = json.loads(process.stdout) if not isinstance(output, dict) or output.get("is_error"): raise ValueError() answer = output.get("result") if not isinstance(answer, str) or not answer.strip(): raise ValueError() except subprocess.TimeoutExpired: return self.error(504, "CLI timed out") except (OSError, subprocess.CalledProcessError): return self.error(502, "CLI failed") except (ValueError, UnicodeError): return self.error(502, "CLI returned invalid output") self.reply(200, { "id": "chatcmpl-" + uuid.uuid4().hex, "object": "chat.completion", "created": int(time.time()), "model": "cli-demo", "choices": [{"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}], }) def log_message(self, *args): pass if __name__ == "__main__": HTTPServer(("127.0.0.1", 8080), Handler).serve_forever() 

流程很直接:檢查請求,將文字送進 CLI 的 stdin,確認程序成功退出,再解析 result,最後包進 choices[0].message.content。

這裡刻意把幾種失敗分開:

狀況 HTTP 回應
請求格式錯誤或要求未支援功能 400
宣告的 request body 超過上限 413
CLI 無法啟動、非零退出,或輸出格式錯誤 502
CLI 執行逾時 504

不把 CLI 的完整 stderr 直接回傳,避免把本機路徑或診斷細節送給呼叫端。實際服務可在受控的伺服器日誌記錄錯誤種類與耗時,不必把 prompt 全文一起記下來。

3. 啟動並送一個請求

在放這兩個檔案的資料夾執行:

python3 gateway.py 

再開一個終端機:

curl -sS http://127.0.0.1:8080/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"cli-demo","messages":[{"role":"user","content":"你好"}],"stream":false}' 

回傳的 ID 與時間每次不同,答案應該在這裡:

{ "choices": [{ "index": 0, "message": {"role": "assistant", "content": "收到 2 個字元:你好"}, "finish_reason": "stop" }] } 

上面是回應節錄。完整回應還有 id、object、created 與 model。沒有模型用量資料,就不捏造 usage。

踩到的坑

長 prompt 不要塞在命令列參數裡

原本的服務曾把很長的上下文塞進 argv,碰到 Argument list too long。命令列有系統限制,不適合拿來搬大量文字。

範例用的是:

subprocess.run(COMMAND, input=message["content"], capture_output=True, text=True, encoding="utf-8", timeout=TIMEOUT, check=True) 

input 透過 stdin 傳入,命令本身用參數陣列,也沒有開 shell=True。所以文字中的 $() 或反引號會當成普通內容傳給 CLI,不會先被包裝層當 shell 指令執行。這不代表 CLI 本身不能使用工具,兩者要分開看。Python subprocess 文件 有各參數的行為。

這次以 20 萬個 ASCII 字元實測,假 CLI 回報收到完整的 200,000 個字元。它驗證的是傳輸方式,不代表真實模型的 context window 一定裝得下。

stdout 不一定就是答案

有的 CLI 回一整個 JSON,有的回 JSONL,也就是一行一個事件。啟動訊息、工具結果與最後答案,不能全部原封不動塞進 content。

範例只接受 {"result":"文字"},缺欄位、空答案或 is_error: true 都回 502。換成 JSONL 型 CLI,就要另外寫事件解析器,找出最後的 assistant 訊息,不能只拿最後一行。

例如 Claude Code 非互動模式 提供 -p 與結構化輸出;如果要接它,可將命令改成參數陣列 ['claude', '-p', '--output-format', 'json'],再依所用版本的輸出確認解析器。這是接入示意,本文沒有實測這個真實 CLI 的呼叫,也沒有處理它的登入流程。

HTTP 成功,不代表 CLI 成功

CLI 非零退出時,不能照樣回 200,再把「發生錯誤」當成模型答案。就算退出碼是零,也要檢查應用層錯誤與答案格式。

check=True 負責非零退出,後面的 JSON 驗證負責輸出內容。兩關都過了,才回成功。

逾時也是同樣的道理。模型可能慢,但請求不能無限等下去;呼叫端的等待時間也要比這裡的 CLI timeout 留出一些餘裕。若 CLI 還會產生其他子程序,則要另做整組程序的清理,不能假設直接子程序結束就全部結束。

實測結果與限制

這次在 Python 3.14.4,以本機 HTTP 請求與假 CLI 跑過 17 個情境:

  • 中文回覆、20 萬字元 stdin,以及含 shell 語法的文字都正確傳遞。
  • 壞掉的 JSON、非物件請求、多輪訊息、工具、串流、未知模型與非文字內容都回 400。
  • 超大 Content-Length 在送出 body 前就被拒絕,回 413。
  • CLI 非零退出、無效 JSON、缺少或空白答案、應用層錯誤都回 502。
  • 假 CLI 刻意拖延,回 504。

這份範例只綁 127.0.0.1,使用標準函式庫的簡單 HTTP server,依序處理請求。它沒有驗證身分、請求讀取期限、輸出大小限制或正式的並行管理,適合本機理解流程,不能直接當成公開服務。

每次 HTTP 請求也都是新 CLI 呼叫,不會自動保留前一輪歷史。要支援多輪、工具或串流,必須逐項定義轉換與錯誤行為,不能只把請求欄位加進來就算完成。

為什麼這樣選

當時的需求是讓原本只認 HTTP 的程式能呼叫既有 CLI,所以先加一層薄薄的轉接,保留已經能工作的執行方式。

若你需要的是模型供應商完整的 API 功能,直接使用原生 API 會少掉一層格式轉換。CLI 包裝的價值,是讓既有工具接得上;代價則是 CLI 旗標、輸出格式與程序生命週期,都變成這層服務要維護的事。

延伸閱讀