编写 provider adapter

本页面向要给网关接入新 provider 的贡献者。如果只是想用网关已经支持的 provider 再提供一个模型,完全不用写代码;见添加新模型。

大多数 provider 也不需要新写 adapter。如果 API 兼容 OpenAI,一个 profile 加上 registry.py 里的几行改动就够了,见第 1 步。只有协议格式自成一套的 provider,才需要专门的 adapter。

第 1 步:先判断到底需不需要 adapter

大多数新 provider 都提供 OpenAI 风格的 /chat/completions 端点。对这类 provider,不要写 adapter 类。先注册一个 provider profile,再在 apps/backend/serving/servers/registry.py 的 _make_adapter 里改两处:一是加一个选中该 profile 的分支,二是在同一函数靠后的 OpenAI 兼容元组里加上这个 kind:

# ...among the per-kind arms of _make_adapter:
elif kind == "your_provider":
    cfg = {**cfg, "provider_profile": "your_provider"}

# ...further down in the same function:
if kind in (
    "vllm",
    "sglang",
    "chutes",
    "featherless",
    "ollama",
    "cliproxy",
    "openai_compat",
    "staging",
    "deepseek",
    "zai",
    "kimi",
    "minimax",
    "your_provider",  # <-- add it here
):
    return OpenAICompatAdapter(model_cfg)

还要把这个 kind 加进同一模块的 RESERVED_PROVIDER_LABELS,防止路由把它拿去当自己的标签;如果可分派的 kind 没有加进这个集合,单元测试会失败。

deepseek、zai、kimi 和 minimax 目前都是这样接入的:每个 provider 在 apps/backend/serving/adapters/profiles.py 里有一份 profile,记录用量指标或路径上的特殊处理,其余都交给 OpenAICompatAdapter。

只有 provider 用的确实不是 OpenAI 的协议格式时,才需要写专门的 adapter,例如 Gemini 的 generateContent、Anthropic Messages API,以及 OpenRouter 用来锁定 sub-provider 的请求体字段。gemini、claude、anthropic 和 openrouter 都是这种情况,各自有独立的分派分支:

if kind == "your_provider":
    return YourProviderAdapter(model_cfg)

第 2 步:编写 adapter(仅限自定义协议)

在 apps/backend/serving/adapters/ 下新建一个文件,例如 apps/backend/serving/adapters/your_provider.py。后端的包根目录是 apps/backend,因此 import 写成 serving.… / routing.…:

import json
from collections.abc import AsyncGenerator
from typing import Any

from serving.stream import done_sentinel, make_final_usage_chunk
from serving.utils.tokens import estimate_prompt_tokens, estimate_text_tokens
from .base import BaseAdapter, UsageInfo


class YourProviderAdapter(BaseAdapter):
    """Adapter for YourProvider API.

    This adapter translates OpenAI-compatible requests to YourProvider's
    API format and normalizes responses back to OpenAI format.
    """

    async def chat_completion(
        self, messages: list[dict[str, Any]], **params
    ) -> dict[str, Any]:
        """Execute a non-streaming chat completion request.

        Args:
            messages: List of chat messages in OpenAI format.
            **params: Additional parameters (temperature, max_tokens, etc.).

        Returns:
            OpenAI-compatible response dictionary.
        """
        # Validate and clamp parameters against this model's declared support.
        validated_params = self.validate_params(params)

        # Build the provider-specific request payload.
        payload = {
            "model": self.config.provider_model_id or self.config.id,
            "messages": messages,
            **validated_params,
        }

        if params.get("tools"):
            payload["tools"] = params["tools"]

        if params.get("response_format", {}).get("type") == "json_object":
            payload["response_format"] = {"type": "json_object"}

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.config.api_key}",
        }

        data = await self.http.json_post_with_retry(
            f"{self.config.base_url}/chat/completions",
            json=payload,
            headers=headers,
        )

        usage = UsageInfo(
            prompt_tokens=data.get("usage", {}).get("prompt_tokens", 0),
            completion_tokens=data.get("usage", {}).get("completion_tokens", 0),
            total_tokens=data.get("usage", {}).get("total_tokens", 0),
        )

        # Fall back to estimation when the provider reports no usage.
        if usage.total_tokens == 0:
            content = data["choices"][0]["message"].get("content", "")
            prompt_tokens = estimate_prompt_tokens(messages)
            completion_tokens = estimate_text_tokens(content)
            usage = UsageInfo(
                prompt_tokens=int(prompt_tokens),
                completion_tokens=int(completion_tokens),
                total_tokens=int(prompt_tokens + completion_tokens),
            )

        tool_calls = None
        if "tool_calls" in data["choices"][0]["message"]:
            tool_calls = data["choices"][0]["message"]["tool_calls"]

        return self.format_response(
            content=data["choices"][0]["message"].get("content", ""),
            model=self.config.id,
            usage=usage,
            tool_calls=tool_calls,
            finish_reason=data["choices"][0].get("finish_reason", "stop"),
        )

    async def stream_chat_completion(
        self, messages: list[dict[str, Any]], **params
    ) -> AsyncGenerator[str, None]:
        """Execute a streaming chat completion request.

        Args:
            messages: List of chat messages in OpenAI format.
            **params: Additional parameters.

        Yields:
            Server-sent event formatted strings.
        """
        validated_params = self.validate_params(params)

        payload = {
            "model": self.config.provider_model_id or self.config.id,
            "messages": messages,
            "stream": True,
            **validated_params,
        }

        if params.get("tools"):
            payload["tools"] = params["tools"]

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.config.api_key}",
        }

        total_content = ""
        prompt_tokens = 0

        async for line in self.http.stream_post(
            f"{self.config.base_url}/chat/completions",
            json=payload,
            headers=headers,
        ):
            if not line.startswith("data: "):
                continue

            if line == "data: [DONE]":
                # Emit the final usage chunk with the shared helper.
                yield make_final_usage_chunk(
                    model=self.config.id,
                    messages=messages,
                    total_content=total_content,
                    prompt_tokens_override=prompt_tokens or None,
                    finish_reason="stop",
                )
                yield done_sentinel()
                break

            try:
                chunk_data = json.loads(line[6:])

                if "usage" in chunk_data:
                    prompt_tokens = chunk_data["usage"].get("prompt_tokens", prompt_tokens)

                if chunk_data["choices"][0]["delta"].get("content"):
                    content = chunk_data["choices"][0]["delta"]["content"]
                    total_content += content
                    yield self.format_stream_chunk(content, self.config.id)
            except json.JSONDecodeError:
                continue

在 apps/backend/serving/adapters/__init__.py 里导出它:

from .your_provider import YourProviderAdapter

__all__ = [
    # ... existing exports
    "YourProviderAdapter",
]

并在 apps/backend/serving/servers/registry.py 顶部导入它:

from serving.adapters import (
    # ... existing imports
    YourProviderAdapter,
)

第 3 步:把模型加进注册表

models:
  - id: your-model-id
    name: Your Model Name
    provider: your_provider
    provider_model_id: "actual-model-id"
    base_url: ${YOUR_PROVIDER_BASE_URL}
    api_key: ${YOUR_PROVIDER_API_KEY}
    quantization: "bf16"
    input_modalities: ["text"]
    output_modalities: ["text"]
    context_length: 8192
    max_output_length: 4096
    supports_tools: true
    supports_structured_output: true
    supported_params: [temperature, top_p, max_tokens, stop]
    aliases: []  # Optional alternative names
    pricing:
      prompt: "0"
      completion: "0"
      image: "0"
      request: "0"
      input_cache_reads: "0"
      input_cache_writes: "0"
    route:
      - kind: your_provider
        weight: 1.0
        base_url: ${YOUR_PROVIDER_BASE_URL}
        api_key: ${YOUR_PROVIDER_API_KEY}

第 4 步:配置环境变量

加进 .env:

YOUR_PROVIDER_BASE_URL=https://api.yourprovider.example/v1
YOUR_PROVIDER_API_KEY=your-api-key-here

如果某条路由的 api_key、api_keys 或 base_url 引用的 ${VAR} 解析为空,这条路由就不会注册。默认情况下整个模型都会被丢弃,后端日志会写明跳过了哪些模型、哪些变量没设置。给路由加上 optional: true,就只跳过这一条路由,模型的其他路由照常注册。

第 5 步:验证

用包含新 kind 的注册表启动网关,再按通过网关验证里的方法检查。

部署自带的 adapter

部署可以注册自己的 adapter 工厂,不用改内置的分派逻辑。做法是在一个模块里提供一个同步、无参数的 register() 函数:

from serving.adapters import ModelConfig, OpenAICompatAdapter
from serving.servers.registry import register_adapter_factory


def make_example_adapter(cfg):
    return OpenAICompatAdapter(ModelConfig(**cfg))


def register():
    register_adapter_factory("example_service", make_example_adapter)

把 BACKEND_EXTENSIONS 设为这个模块的导入名,并在模型注册表里使用 kind: example_service。工厂接收路由的配置字典,返回一个 adapter。它在内置 provider 默认值生效之前运行,所以需要的 profile 或路径默认值都得自己提供。重复注册会失败;要替换内置 kind,必须调用 register_adapter_factory(kind, factory, override=True),并且会记一条日志。启动和部署方面的要求见后端扩展。

BaseAdapter API 参考

所有 adapter 都继承自 BaseAdapter(apps/backend/serving/adapters/base.py),并实现:

async def chat_completion(
    self, messages: list[dict[str, Any]], **params
) -> dict[str, Any]:
    """Execute non-streaming chat completion."""

async def stream_chat_completion(
    self, messages: list[dict[str, Any]], **params
) -> AsyncGenerator[str, None]:
    """Execute streaming chat completion."""

基类提供的工具方法:

def validate_params(self, params: dict[str, Any]) -> dict[str, Any]:
    """Validate and clamp parameters to supported ranges."""

def format_response(
    self,
    content: str | None,
    model: str,
    usage: UsageInfo | None = None,
    tool_calls: list[dict] | None = None,
    reasoning_content: str | None = None,
    finish_reason: str = "stop",
) -> dict[str, Any]:
    """Format response in OpenAI-compatible format."""

def format_stream_chunk(
    self,
    content: str,
    model: str,
    finish_reason: str | None = None,
    role: str | None = None,
) -> str:
    """Format an SSE chunk for streaming responses."""

def format_tool_chunk(self, tool_calls: list[dict[str, Any]], model: str) -> str:
    """Format tool calls into an OpenAI-compatible streaming chunk."""

可用的属性:

self.config       # ModelConfig instance
self.http         # AsyncHTTPClient (apps/backend/serving/http.py), shared

进阶功能

多模态支持

对于接受图像输入的模型:

input_modalities: ["text", "image"]

在 adapter 的 chat_completion 里处理图像内容块。单条路由可以声明比模型更窄的 input_modalities,这样纯文本的回退路由就永远不会收到媒体内容。

工具 / function calling

supports_tools: true

把 provider 返回的工具调用解析成 OpenAI 的结构再透传出去:

tool_calls = []
if "function_call" in data:
    tool_calls.append({
        "id": f"call_{int(time.time() * 1000)}",
        "type": "function",
        "function": {
            "name": data["function_call"]["name"],
            "arguments": data["function_call"]["arguments"],
        },
    })

return self.format_response(
    content=content,
    model=self.config.id,
    usage=usage,
    tool_calls=tool_calls,
)

结构化输出(JSON mode)

supports_structured_output: true

处理 response_format 参数:

if params.get("response_format", {}).get("type") == "json_object":
    payload["response_format"] = {"type": "json_object"}

限制并发请求

adapter 不需要自己做限流。网关已经限制了每个 provider key 同时在途的请求数,provider 返回 429 时还会调低这个上限;见出站并发。新 adapter 只要像内置 adapter 那样,把对上游的调用包在从 apps/backend/serving/adapters/upstream_limiter.py 获取的槽位里,就能纳入这套机制。

示例

  • OpenAI 兼容的 provider。DeepSeek 没有 adapter 文件。apps/backend/serving/servers/registry.py 里的 _make_adapter 设置 provider_profile = "deepseek" 并返回 OpenAICompatAdapter;profile 本身在 apps/backend/serving/adapters/profiles.py。

  • 自定义 API 格式。按非 OpenAI 协议格式转换消息的做法,可以参考 apps/backend/serving/adapters/gemini.py。

  • 本地部署。vLLM 和 SGLang 复用 apps/backend/serving/adapters/openai_compat.py。vllm 和 sglang 这两个 kind 分发到同一个类;本地与远程的行为差异来自 base_url 和路由层,而不是来自专门的 adapter。

故障排查

响应格式错误

  • 确保 format_response() 返回 OpenAI 兼容的结构。

  • 校验 UsageInfo 的各字段都是整数。

  • 检查 finish_reason 是 stop、length、content_filter、tool_calls 之一。

  • 流式场景下,第一个非空内容分片一可用就立刻发出,这样首 token 时延(time-to-first-token)才会被准确记录。

流式问题

  • 确保分片是 SSE 格式:data: {json}\n\n。

  • 在 data: [DONE] 之前发送最后那个 usage 分片。

  • 妥善处理 JSON 解析错误。

最佳实践

  1. 错误处理——使用 self.http.json_post_with_retry();provider 出错时,要给出有用的错误信息。

  2. 用量记账——优先采用 provider 上报的 usage;拿不到再回退到 estimate_prompt_tokens() / estimate_text_tokens()。

  3. 流式辅助函数——用 format_stream_chunk()、make_final_usage_chunk() 和 done_sentinel() 保证 SSE 输出一致。

  4. 类型安全——写完整的类型标注,并让请求/响应的结构与 apps/backend/serving/schemas.py 保持一致。

  5. 测试——流式和非流式两条路径都要测到,并用大 prompt 验证 token 数会被正确截断。

  6. 文档与风格——用英文写 Google 风格的 docstring;不要把 provider 专属的逻辑塞进共享代码。

  7. 密钥——在 YAML 里用 ${ENV_VAR},不要硬编码 key 或端点,取值放在 .env 中。

另见