恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Windsurf API Proxy 实战:用 FastAPI 统一接入 OpenAI 与 gRPC 模型服务
首页
资讯中心
/
Windsurf API Proxy 实战:用 FastAPI 统一接入 OpenAI 与 gRPC 模型服务
Windsurf API Proxy 实战:用 FastAPI 统一接入 OpenAI 与 gRPC 模型服务
发布时间:2026/10/9 22:14:31
1. 为什么要在 Windsurf 里自建 API ProxyWindsurf 编辑器本身对模型调用做了不少封装但当你需要把编辑器里的请求转发到自建服务、或者想让本地跑的 gRPC 推理后端也能被 OpenAI 兼容工具调用时直接改编辑器配置往往行不通。我遇到的实际场景是这样的团队内部有一个基于 gRPC 的模型推理服务接口协议是 protobuf而 Windsurf、Cursor、Aider 这些工具只认 OpenAI 的/v1/chat/completions。两边协议对不上工具就没法直接用。这时候需要一个中间层把 OpenAI 格式的 HTTP 请求翻译成 gRPC 调用再把 gRPC 的流式响应翻译回 SSE 格式。这个中间层就是 API Proxy。它本质上是一个本地 HTTP 服务对外暴露 OpenAI 兼容接口对内通过 gRPC 连接真正的模型后端。Windsurf 只需要把 Base URL 指向这个本地服务就能透明地访问 gRPC 模型。适合谁来做这件事三类人比较典型。第一类是需要统一管理多个模型后端的开发者不想在每个工具里重复配置不同的 API Key 和地址。第二类是把模型部署在内网 gRPC 服务上、但希望用现成 AI 编程工具调用的团队。第三类是需要在本地做请求日志、限流、鉴权等治理动作的工程团队。如果你只是想让 Windsurf 连一个公开的 OpenAI 兼容服务那不需要自己写 Proxy直接填地址就行。但一旦涉及协议转换、内网服务桥接、多后端路由自建 Proxy 就是绕不开的一步。这篇文章会从零搭一个 FastAPI 服务实现/v1/models和/v1/chat/completions两个核心路由其中 chat 路由同时支持普通响应和流式响应底层通过 gRPC 调用模型服务。我会给出完整的可复制代码、proto 定义、启动命令以及用 curl 验证流式输出的具体动作。过程中也会说明如何把 TaoToken 作为上游 OpenAI 兼容服务接入方便你在没有自建 gRPC 后端时也能先跑通链路。先明确一下整体数据流Windsurf 发出 HTTP POST 到http://localhost:8000/v1/chat/completionsFastAPI 接收后解析 JSON把 messages 转成 gRPC 请求对象通过 grpc.aio 通道发给后端后端返回流式 chunkProxy 再把每个 chunk 包装成data: {...}\n\n的 SSE 事件推回给 Windsurf。整条链路的关键在于协议转换的字段映射和流式响应的生命周期管理。2. TaoToken 前置准备与 gRPC 后端约定在写 Proxy 之前需要先把两件事定下来上游模型服务从哪来以及 gRPC 后端的接口长什么样。这两件事决定了 Proxy 的配置结构和 proto 文件。先说上游。如果你的模型服务已经是 gRPC 形式那 Proxy 直接连它就行。但很多情况下你可能想先用一个稳定的 OpenAI 兼容服务把链路跑通再替换成自建 gRPC 后端。这时候可以用 TaoToken 作为上游。它的 API 地址是https://taotoken.net/api提供 OpenAI 兼容接口你可以在控制台创建 API Key然后在 Proxy 里把非 gRPC 的请求转发过去。这样做的好处是你可以先用同一套 Proxy 代码验证 HTTP 路由和流式逻辑等 gRPC 后端就绪后只需要替换转发层路由层不用动。具体操作上先到 TaoToken 控制台生成一个 API Key记下 Key 字符串。然后在 Proxy 的环境变量里配置UPSTREAM_BASE_URLhttps://taotoken.net/api和UPSTREAM_API_KEY你的Key。当请求的 model 命中 gRPC 后端白名单时走 gRPC否则走 HTTP 转发。这样一套代码同时支持两种上游调试起来方便很多。模型对话入口可以用来确认 Key 是否可用API Keys 页面用来管理密钥接入文档里有各语言的调用示例。再说 gRPC 后端约定。为了让 Proxy 的 proto 定义有依据我们约定后端提供一个ChatService包含一个Chat方法接收ChatRequest返回stream ChatResponse。ChatRequest里包含model、messagesrepeated Message、temperature、max_tokens等字段。ChatResponse里包含delta字符串和finish_reason。这个约定和 OpenAI 的流式语义对齐转换逻辑最直接。proto 文件我放在proto/chat.proto内容如下syntax proto3; package chat; service ChatService { rpc Chat (ChatRequest) returns (stream ChatResponse); } message Message { string role 1; string content 2; } message ChatRequest { string model 1; repeated Message messages 2; float temperature 3; int32 max_tokens 4; } message ChatResponse { string delta 1; string finish_reason 2; }用python -m grpc_tools.protoc -I proto --python_out. --grpc_python_out. proto/chat.proto生成chat_pb2.py和chat_pb2_grpc.py。生成后放在项目根目录Proxy 代码里直接 import。环境依赖方面需要安装fastapi、uvicorn、grpcio、grpcio-tools、httpx、pydantic。可以用一条命令装齐pip install fastapi uvicorn grpcio grpcio-tools httpx pydantic如果你打算用 TaoToken 作为 HTTP 上游httpx用来发异步请求如果只走 gRPChttpx可以留着备用。配置项统一放在config.py里用环境变量覆盖默认值方便本地和容器部署切换。这里有一个容易忽略的点gRPC 的流式响应和 HTTP 的 SSE 在节奏上不完全一样。gRPC 的stream ChatResponse是服务端推一个你收一个而 SSE 需要你主动 flush。在 FastAPI 里用StreamingResponse配合 async generator 就能解决generator 里每收到一个 gRPC chunk 就 yield 一个 SSE 事件字符串。下一节会给出完整实现。3. 可复制配置FastAPI 路由与 gRPC 转接这一节给出完整的 Proxy 代码。项目结构如下windsurf-proxy/ ├── main.py ├── config.py ├── grpc_client.py ├── proto/ │ └── chat.proto ├── chat_pb2.py ├── chat_pb2_grpc.py └── requirements.txt先看config.pyimport os class Settings: HOST os.getenv(PROXY_HOST, 0.0.0.0) PORT int(os.getenv(PROXY_PORT, 8000)) GRPC_TARGET os.getenv(GRPC_TARGET, localhost:50051) UPSTREAM_BASE_URL os.getenv(UPSTREAM_BASE_URL, https://taotoken.net/api) UPSTREAM_API_KEY os.getenv(UPSTREAM_API_KEY, ) LOCAL_API_KEY os.getenv(LOCAL_API_KEY, sk-windsurf-change-me) GRPC_MODELS set(os.getenv(GRPC_MODELS, local-grpc-model).split(,)) settings Settings()LOCAL_API_KEY是 Windsurf 连 Proxy 时用的 Key和上游 Key 分开避免混用。GRPC_MODELS定义哪些 model 名走 gRPC其余走 HTTP 上游。grpc_client.py封装 gRPC 调用import grpc import chat_pb2 import chat_pb2_grpc from config import settings async def grpc_chat_stream(model, messages, temperature, max_tokens): async with grpc.aio.insecure_channel(settings.GRPC_TARGET) as channel: stub chat_pb2_grpc.ChatServiceStub(channel) req chat_pb2.ChatRequest( modelmodel, messages[chat_pb2.Message(rolem[role], contentm[content]) for m in messages], temperaturetemperature, max_tokensmax_tokens, ) async for resp in stub.Chat(req): yield respmain.py是核心包含路由和流式处理import json import time import uuid import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import StreamingResponse, JSONResponse from config import settings from grpc_client import grpc_chat_stream app FastAPI() def check_auth(request: Request): auth request.headers.get(Authorization, ) if not auth.startswith(Bearer ): raise HTTPException(status_code401, detailmissing bearer token) token auth.split( , 1)[1] if token ! settings.LOCAL_API_KEY: raise HTTPException(status_code401, detailinvalid api key) app.get(/v1/models) async def list_models(request: Request): check_auth(request) models [{id: m, object: model, created: int(time.time()), owned_by: local} for m in settings.GRPC_MODELS] models.append({id: gpt-4o-mini, object: model, created: int(time.time()), owned_by: upstream}) return {object: list, data: models} app.post(/v1/chat/completions) async def chat_completions(request: Request): check_auth(request) body await request.json() model body.get(model, ) messages body.get(messages, []) stream body.get(stream, False) temperature body.get(temperature, 0.7) max_tokens body.get(max_tokens, 1024) if model in settings.GRPC_MODELS: if stream: return StreamingResponse(grpc_sse_generator(model, messages, temperature, max_tokens), media_typetext/event-stream) else: return await grpc_full_response(model, messages, temperature, max_tokens) else: return await proxy_upstream(body, stream) async def grpc_sse_generator(model, messages, temperature, max_tokens): chat_id fchatcmpl-{uuid.uuid4().hex[:12]} created int(time.time()) async for resp in grpc_chat_stream(model, messages, temperature, max_tokens): chunk { id: chat_id, object: chat.completion.chunk, created: created, model: model, choices: [{index: 0, delta: {content: resp.delta}, finish_reason: resp.finish_reason or None}], } yield fdata: {json.dumps(chunk, ensure_asciiFalse)}\n\n yield data: [DONE]\n\n async def grpc_full_response(model, messages, temperature, max_tokens): content finish_reason stop async for resp in grpc_chat_stream(model, messages, temperature, max_tokens): content resp.delta if resp.finish_reason: finish_reason resp.finish_reason return JSONResponse({ id: fchatcmpl-{uuid.uuid4().hex[:12]}, object: chat.completion, created: int(time.time()), model: model, choices: [{index: 0, message: {role: assistant, content: content}, finish_reason: finish_reason}], usage: {prompt_tokens: 0, completion_tokens: 0, total_tokens: 0}, }) async def proxy_upstream(body, stream): headers { Authorization: fBearer {settings.UPSTREAM_API_KEY}, Content-Type: application/json, } url f{settings.UPSTREAM_BASE_URL}/v1/chat/completions if stream: async def upstream_sse(): async with httpx.AsyncClient(timeout60) as client: async with client.stream(POST, url, jsonbody, headersheaders) as resp: async for line in resp.aiter_lines(): if line: yield line \n\n return StreamingResponse(upstream_sse(), media_typetext/event-stream) else: async with httpx.AsyncClient(timeout60) as client: resp await client.post(url, jsonbody, headersheaders) return JSONResponse(resp.json(), status_coderesp.status_code)启动命令uvicorn main:app --host 0.0.0.0 --port 8000如果你用 Docker 部署Dockerfile 里把 proto 生成步骤加进去启动命令保持一致。注意GRPC_TARGET在容器里要指向 gRPC 服务的容器名或 IP不能写 localhost。Windsurf 侧的配置在设置里找到 OpenAI 兼容端点填 Base URLhttp://localhost:8000/v1API Key 填sk-windsurf-change-me模型名填local-grpc-model或你白名单里的名字。这样 Windsurf 的请求就会先到 Proxy再由 Proxy 决定走 gRPC 还是 HTTP 上游。4. 验证请求curl 测试 /v1/chat/completions 与流式响应配置写完后先别急着开 Windsurf用 curl 把两个核心路由验证一遍。这样出问题时能快速定位是 Proxy 的问题还是编辑器的问题。先测/v1/modelscurl http://localhost:8000/v1/models \ -H Authorization: Bearer sk-windsurf-change-me预期返回一个 JSONdata数组里包含local-grpc-model和gpt-4o-mini。如果返回 401说明 Authorization 头没带对或者 Key 不匹配。如果返回 500检查config.py里的GRPC_MODELS解析是否正常。再测非流式 chatcurl http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-windsurf-change-me \ -d { model: local-grpc-model, messages: [{role: user, content: 用一句话说明什么是 gRPC}], stream: false }如果 gRPC 后端正常你会收到一个完整的chat.completion对象choices[0].message.content里是模型输出。如果 gRPC 后端没启动这里会报连接错误日志里能看到failed to connect to all addresses。这时候先确认GRPC_TARGET指向的地址和端口是否正确以及 gRPC 服务是否在监听。重点测流式响应curl -N http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-windsurf-change-me \ -d { model: local-grpc-model, messages: [{role: user, content: 数到五}], stream: true }-N参数关闭 curl 的缓冲这样你能实时看到 SSE 事件逐条打印。预期输出类似data: {id:chatcmpl-abc123,object:chat.completion.chunk,created:1710000000,model:local-grpc-model,choices:[{index:0,delta:{content:1},finish_reason:null}]} data: {id:chatcmpl-abc123,object:chat.completion.chunk,created:1710000000,model:local-grpc-model,choices:[{index:0,delta:{content:2},finish_reason:null}]} ... data: [DONE]每个data:行之间有空行这是 SSE 规范要求的。如果所有 chunk 一次性打印出来而不是逐条出现说明 gRPC 后端没有真正流式返回或者 Proxy 的 async generator 被缓冲了。检查 gRPC 服务端是否用了stream关键字以及 Proxy 里StreamingResponse的 media_type 是否为text/event-stream。再测 HTTP 上游转发把 model 换成gpt-4o-minicurl -N http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-windsurf-change-me \ -d { model: gpt-4o-mini, messages: [{role: user, content: 你好}], stream: true }这条请求会走proxy_upstream把请求转发到UPSTREAM_BASE_URL。如果UPSTREAM_API_KEY没配会返回 401。如果返回正常说明 HTTP 转发链路也通了。这时候你可以在 TaoToken 的模型对话页面确认调用记录看请求是否到达。验证通过后打开 Windsurf在聊天窗口里发一条消息观察 Proxy 的终端日志。正常情况下能看到 uvicorn 的访问日志以及 gRPC 调用的耗时。如果 Windsurf 报连接错误先确认 Base URL 是否带了/v1以及端口是否被占用。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth实际跑的时候报错集中在几个地方。我按错误信息分类说明方便你对照日志定位。401 Unauthorized。这个最常见分两种。一种是 Windsurf 连 Proxy 时 401说明LOCAL_API_KEY和编辑器里填的 Key 不一致。检查config.py里的默认值是否被环境变量覆盖以及编辑器里有没有多余空格。另一种是 Proxy 连上游时 401说明UPSTREAM_API_KEY无效或过期。到 TaoToken 控制台重新生成一个 Key更新环境变量后重启 Proxy。注意UPSTREAM_BASE_URL不要带末尾斜杠否则拼接出来会变成//v1/chat/completions有些服务会拒绝。local proxy failed。这个报错通常出现在 Windsurf 侧意思是编辑器无法连接到本地 Proxy。先确认 Proxy 进程还在跑curl http://localhost:8000/v1/models能不能通。如果 curl 通但编辑器不通检查编辑器是否在容器或远程环境里localhost 指向的不是你的宿主机。这种情况下把 Base URL 改成宿主机的局域网 IP并确保防火墙放行 8000 端口。另外如果 Proxy 绑定了127.0.0.1外部访问不了启动时用--host 0.0.0.0。reading choices 相关报错。这个一般出现在流式响应解析阶段报错信息类似error reading choices或invalid choice delta。原因是 Proxy 返回的 SSE chunk 结构不符合 OpenAI 规范。重点检查grpc_sse_generator里delta字段的构造流式 chunk 里delta应该是{content: ...}而不是{role: assistant, content: ...}。role 只在第一个 chunk 里出现后续 chunk 只带 content。另外finish_reason在中间 chunk 里应该是null只有最后一个 chunk 才带stop或length。如果 gRPC 后端每个 chunk 都带 finish_reason需要在 Proxy 里做过滤只在最后一个 chunk 透传。OAuth 相关报错。如果你在 Windsurf 里配置的是 OAuth 类型的连接而不是 API Key可能会遇到OAuth token exchange failed。Windsurf 的某些版本对 OpenAI 兼容端点的鉴权方式有要求优先用 API Key 模式。在设置里把认证方式从 OAuth 切换成 API Key填入sk-windsurf-change-me。如果编辑器只提供 OAuth 选项那说明它不支持自定义 OpenAI 端点需要换用支持 Base URL 配置的工具比如 Cline 或 Aider。还有一个隐蔽的坑gRPC 的max_tokens字段类型是 int32而 OpenAI 请求里可能是字符串或浮点数。在grpc_chat_stream里做一次int(max_tokens)转换避免 protobuf 序列化报错。同理temperature是 float如果请求里传了整数也要转成 float。如果日志里出现grpc._channel._MultiThreadedRendezvous或StatusCode.UNAVAILABLE说明 gRPC 通道没建起来。检查GRPC_TARGET的格式必须是host:port不能带http://前缀。gRPC 用的是 HTTP/2和普通 HTTP 端口不通用确认后端监听的是 gRPC 端口而不是 REST 端口。6. 把 Proxy 接入 Windsurf 与长期编码方案Proxy 跑通之后Windsurf 的配置就很简单了。在编辑器的模型设置里选择自定义 OpenAI 兼容端点Base URL 填http://localhost:8000/v1API Key 填sk-windsurf-change-me模型名填local-grpc-model。保存后新建一个对话发一条消息如果 Proxy 终端出现 POST 日志并且返回 200说明整条链路已经通了。如果你需要长期用这套方案做编码和 Agent 任务建议把 Proxy 做成后台服务用 systemd 或 supervisor 管理避免终端关闭后进程退出。同时把LOCAL_API_KEY和UPSTREAM_API_KEY放到环境变量文件里不要硬编码在代码中。日志方面可以在 FastAPI 里加一个中间件记录每次请求的 model、耗时、token 数方便后续做用量分析。对于需要多模型切换的场景可以在 Proxy 里加一层路由表把不同 model 名映射到不同的 gRPC 后端或 HTTP 上游。比如local-grpc-model走内网 gRPCgpt-4o-mini走 TaoTokenclaude-sonnet走另一个上游。路由表用 JSON 配置改完重启即可不用动代码。这样 Windsurf 里只需要切换模型名Proxy 自动决定后端。如果你还没有自建 gRPC 后端可以先用 TaoToken 的 Coding Plan 把编码链路跑起来等 gRPC 服务就绪后再把 model 白名单切过去。Coding Plan 适合长期编码和 Agent 场景接入文档里有 Base URL 和 Key 的配置说明。API Keys 页面用来管理密钥模型对话页面用来快速验证模型可用性。最后提醒一点Proxy 的流式响应要确保[DONE]事件在最后发出否则 Windsurf 会一直等待流结束。在grpc_sse_generator里async for循环结束后必须 yielddata: [DONE]\n\n这个不能漏。如果 gRPC 后端提前断开要在异常处理里也补发[DONE]避免编辑器卡住。