模型网关 · New-API

在 vLLM 前面加 New-API:渠道、令牌额度和邀请奖励

在 vLLM 前面加一层网关:建渠道指向本地 vLLM,发令牌、设额度,再把邀请奖励结算并划转到用户余额。

在 vLLM 前面加 New-API:渠道、令牌额度和邀请奖励

目录

  1. 一、安装配置
  2. 二、功能操作案例
  3. 写文档时核对到的数据

服务器:116.62.10.183 网关目录:/opt/new-api 本文对应的程序在同级目录:

文件作用
docker-compose.ymlNew-API 编排,已部署
scripts/01-启动New-API.sh拉取镜像并启动
scripts/02-创建vLLM渠道.py把本机 vLLM 加为渠道
scripts/03-开启自用模式并测试渠道.py打开自用模式并测通渠道
scripts/04-修复过期令牌并测试对话.py修复 api-key-v1 并打一次对话
scripts/05-确认支付合规.py打开兑换码所需的合规确认
scripts/06-设置邀请奖励并补记.py设置邀请奖励,并补记第一位被邀请用户
scripts/07-邀请奖励划转到余额.py把待确认奖励划进可用余额
config/cline-qwen3.6-27b.jsonCline 模型上下文改动
config/vllm-客户场景参数.txt100 个客户场景的建议参数,未改现网

脚本在服务器上执行。Windows 上传后如果报 $'\r',先执行 sed -i 's/\r$//' 脚本名。 02 到 06 使用初始化时设置的管理员账号 admin / L4o4986784$。不要把本文发到公网。


一、安装配置

1. 已有环境

动手之前,这台机器上已经有:

vLLM 当时的启动参数:

text
serve /model
  --trust-remote-code
  --dtype bfloat16
  --tensor-parallel-size 1
  --enable-auto-tool-choice
  --tool-call-parser qwen3_xml
  --reasoning-parser qwen3
  --max-model-len 65536
  --gpu-memory-utilization 0.75
  --max-num-seqs 8
  --served-model-name qwen3.6-27b
  --enable-prefix-caching
  --host 0.0.0.0
  --port 8000

模型列表接口不校验密钥,下面这条在主机上返回 200:

bash
curl -sS http://127.0.0.1:8000/v1/models

本机 SSH 公钥在 C:\Users\DCKJ\.ssh\id_ed25519.pub,用来登录 root@116.62.10.183。第一次连接需要接受主机指纹。

2. 编排文件

官方镜像是 calciumion/new-api:latest。不设置 SQL_DSN 时使用 SQLite,库文件在容器内 /data/one-api.db。工作目录也是 /data,所以必须把宿主机目录挂到 /data。

文件:docker-compose.yml,服务器路径 /opt/new-api/docker-compose.yml。

yaml
# New-API 大模型管理网关(SQLite 单机部署)
# 上游 vLLM:http://116.62.10.183:8000/v1
# 不设置 SQL_DSN 时,官方镜像默认使用 SQLite,库文件为 /data/one-api.db

services:
  new-api:
    image: calciumion/new-api:latest
    container_name: new-api
    restart: always
    command: --log-dir /app/logs
    ports:
      - "3000:3000"
    volumes:
      # SQLite 数据库、会话与用户数据
      - ./data:/data
      # 运行日志
      - ./logs:/app/logs
    environment:
      - TZ=Asia/Shanghai
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 20s

3. 启动

文件:scripts/01-启动New-API.sh

bash
#!/bin/bash
# 在 116.62.10.183 上执行。数据目录会保留 SQLite 和日志。
set -eu
mkdir -p /opt/new-api/data /opt/new-api/logs
cd /opt/new-api
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail 50

启动成功后的结果:

这版不会自动创建 root / 123456。第一次打开后台会进入初始化向导,密码至少 8 位。当时设置的管理员是 admin / L4o4986784$。

停止容器但保留数据:

bash
cd /opt/new-api
docker compose down

4. 添加 vLLM 渠道

New-API 容器访问公网 116.62.10.183:8000 会绕不回去。容器里能通的地址是 Docker 网桥 http://172.17.0.1:8000。地址不要带 /v1,网关会自己拼 /v1/chat/completions。

vLLM 不校验密钥,但 New-API 要求密钥非空,所以填了 sk-local-vllm。

文件:scripts/02-创建vLLM渠道.py

python
#!/usr/bin/env python3
"""在 New-API 中创建指向本机 vLLM 的 OpenAI 兼容渠道。重复执行会再新增一条渠道。"""
import json
import urllib.request

BASE = "http://127.0.0.1:3000"


def req(method, path, body=None, headers=None, timeout=120):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(BASE + path, data=data, method=method)
    request.add_header("Content-Type", "application/json")
    for key, value in (headers or {}).items():
        request.add_header(key, value)
    try:
        with urllib.request.urlopen(request, timeout=timeout) as resp:
            raw = resp.read().decode()
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode()
        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError:
            parsed = {"raw": raw[:500]}
        return exc.code, parsed


_, login = req(
    "POST",
    "/api/user/login",
    {"username": "admin", "password": "L4o4986784$"},
)
auth = {
    "Authorization": "Bearer " + login["data"]["access_token"],
    "New-Api-User": "1",
}
status, created = req(
    "POST",
    "/api/channel/",
    {
        "mode": "single",
        "channel": {
            "name": "vLLM-Qwen3.6-27B",
            "type": 1,
            "key": "sk-local-vllm",
            "base_url": "http://172.17.0.1:8000",
            "models": "qwen3.6-27b",
            "groups": ["default"],
            "group": "default",
            "priority": 0,
            "weight": 0,
            "status": 1,
        },
    },
    auth,
)
print("create", status, json.dumps(created, ensure_ascii=False)[:400])
status, channels = req("GET", "/api/channel/?p=1&page_size=20", headers=auth)
print("list", status, json.dumps(channels, ensure_ascii=False)[:800])

创建结果:

项值
编号1
名称vLLM-Qwen3.6-27B
类型1,OpenAI 兼容
模型qwen3.6-27b
分组default
上游http://172.17.0.1:8000
密钥sk-local-vllm
状态已启用

渠道刚建好时,测试接口返回:

text
模型 qwen3.6-27b 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格

这是自己的模型,打开自用模式即可,不必再配售价。

文件:scripts/03-开启自用模式并测试渠道.py

python
#!/usr/bin/env python3
"""开启自用模式,并用管理员接口测试渠道 1。自用模式下未定价模型也可以调用。"""
import json
import urllib.request

BASE = "http://127.0.0.1:3000"


def req(method, path, body=None, headers=None, timeout=120):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(BASE + path, data=data, method=method)
    request.add_header("Content-Type", "application/json")
    for key, value in (headers or {}).items():
        request.add_header(key, value)
    try:
        with urllib.request.urlopen(request, timeout=timeout) as resp:
            raw = resp.read().decode()
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode()
        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError:
            parsed = {"raw": raw[:500]}
        return exc.code, parsed


_, login = req(
    "POST",
    "/api/user/login",
    {"username": "admin", "password": "L4o4986784$"},
)
auth = {
    "Authorization": "Bearer " + login["data"]["access_token"],
    "New-Api-User": "1",
}
status, updated = req(
    "PUT",
    "/api/option/",
    {"key": "SelfUseModeEnabled", "value": True},
    auth,
)
print("self-use", status, json.dumps(updated, ensure_ascii=False))
status, test = req(
    "GET",
    "/api/channel/test/1?model=qwen3.6-27b",
    headers=auth,
)
print("test", status, json.dumps(test, ensure_ascii=False)[:500])

测试结果:success: true,耗时 0.299 秒。

对外调用:


二、功能操作案例

案例 1:令牌显示过期,但 Cline 还能用

现象:令牌 api-key-v1 状态仍是「已启用」,过期列显示红色「刚刚」。Cline 却能继续回答。

原因有两层。

  1. 这把令牌 14:17 创建,过期时间被设成 14:20,有效期大约 3 分钟。New-API 到点后不会立刻把状态改成禁用,所以列表里还能看到「已启用」。真正调用 3000 端口时会返回 401 Invalid token。库里这把令牌当时的已用额度是 0,说明它还没被成功调用过。
  2. Cline 当时的地址是 http://10.0.1.47:8000/v1,这是 vLLM 自己的端口。vLLM 不校验 New-API 令牌,所以令牌过期不影响它。

后来 Cline 改成 http://10.0.1.47:3000/v1,页面上开始出现一串 Invalid token (request id: ...)。这时请求已经进了 New-API。令牌状态停在「已过期」(数据库 status = 3),只改过期时间不会自动变回可用。另外这把令牌的分组当时是 vip,渠道在 default,分组不对也调不通。

修复要分两步:先把过期时间改成 -1、分组改成 default、额度改成无限;再单独把状态改回启用。文件:scripts/04-修复过期令牌并测试对话.py

python
#!/usr/bin/env python3
"""把令牌 1 改为永不过期、default 分组、无限额度,并重新启用。

更新接口默认不改 status。过期状态要在过期时间改成 -1 之后,
再单独调用 /api/token/?status_only=true。
"""
import json
import sqlite3
import urllib.request

BASE = "http://127.0.0.1:3000"


def req(method, path, body=None, headers=None, timeout=120):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(BASE + path, data=data, method=method)
    request.add_header("Content-Type", "application/json")
    for key, value in (headers or {}).items():
        request.add_header(key, value)
    try:
        with urllib.request.urlopen(request, timeout=timeout) as resp:
            raw = resp.read().decode()
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode()
        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError:
            parsed = {"raw": raw[:500]}
        return exc.code, parsed


_, login = req(
    "POST",
    "/api/user/login",
    {"username": "admin", "password": "L4o4986784$"},
)
auth = {
    "Authorization": "Bearer " + login["data"]["access_token"],
    "New-Api-User": "1",
}
status, updated = req(
    "PUT",
    "/api/token/",
    {
        "id": 1,
        "name": "api-key-v1",
        "status": 1,
        "expired_time": -1,
        "remain_quota": 500000,
        "unlimited_quota": True,
        "model_limits_enabled": False,
        "model_limits": "",
        "allow_ips": "",
        "group": "default",
    },
    auth,
)
print("update", status, json.dumps(updated, ensure_ascii=False)[:400])
status, enabled = req(
    "PUT",
    "/api/token/?status_only=true",
    {"id": 1, "status": 1},
    auth,
)
print("enable", status, json.dumps(enabled, ensure_ascii=False)[:400])

con = sqlite3.connect("/opt/new-api/data/one-api.db")
key = con.execute("SELECT key FROM tokens WHERE id=1").fetchone()[0]
status, chat = req(
    "POST",
    "/v1/chat/completions",
    {
        "model": "qwen3.6-27b",
        "messages": [{"role": "user", "content": "只回复:ok"}],
        "max_tokens": 16,
    },
    {"Authorization": "Bearer sk-" + key},
)
print("chat", status)
if "error" in chat:
    print(json.dumps(chat["error"], ensure_ascii=False)[:400])
else:
    choice = (chat.get("choices") or [{}])[0]
    message = choice.get("message") or {}
    print("finish", choice.get("finish_reason"))
    print("content", repr((message.get("content") or "")[:120]))

修复后用这把令牌请求 /v1/chat/completions,返回 HTTP 200。

Cline 要改两个地方:

页面上自己改令牌时:编辑令牌,过期时间选永不过期,分组选 default,再把状态拨回启用。已经过期的令牌,必须先改过期时间,才能重新启用。

案例 2:上下文超长,Cline 报 65537

密钥已经能通过网关。下一条报错是:

text
This model's maximum context length is 65536 tokens.
However, you requested 32000 output tokens and your prompt contains at least 33537 input tokens,
for a total of at least 65537.

vLLM 的 --max-model-len 是 65536。Cline 把这个模型当成 128000 上下文,并且按这个窗口预留 32000 输出。33537 + 32000 = 65537,刚好多 1。

改的是本机 Cline 模型目录,不是 New-API。文件:config/cline-qwen3.6-27b.json 实际被修改的文件:C:\Users\DCKJ\.cline\data\settings\models.json

json
{
  "说明": "这是 Cline 模型目录里 qwen3.6-27b 改过之后的片段。实际文件是 C:\\Users\\DCKJ\\.cline\\data\\settings\\models.json。请求地址不在这个文件里,而在 providers.json 的 openai-compatible.settings.baseUrl。",
  "qwen3.6-27b": {
    "id": "qwen3.6-27b",
    "name": "qwen3.6-27b",
    "contextWindow": 65536,
    "maxInputTokens": 57344,
    "maxTokens": 8192,
    "capabilities": ["streaming", "tools", "images"]
  },
  "cline实际请求": {
    "baseUrl": "http://10.0.1.47:3000/v1",
    "model": "qwen3.6-27b",
    "配置文件": "C:\\Users\\DCKJ\\.cline\\data\\settings\\providers.json"
  }
}

改完后重载 VS Code。上下文条应从 128.0k 变成 65.5k。单次输出上限 8192,输入上限 57344,两者相加不超过 65536。对话继续变长并再次报同样的错时,新开一个任务。

这件事和当时使用的 wanyan 账号无关,请求已经到达模型,失败原因只是长度。

案例 3:打开在线充值和兑换码

钱包页原文:

text
尚未启用在线充值。请使用兑换码或联系管理员。

这版 New-API 里,兑换码和在线充值都先被支付合规声明锁住。确认合规用的接口是 POST /api/option/payment_compliance。

文件:scripts/05-确认支付合规.py

python
#!/usr/bin/env python3
"""确认支付合规声明。确认后兑换码可用;在线充值仍要另配支付商户。"""
import json
import urllib.request

BASE = "http://127.0.0.1:3000"


def req(method, path, body=None, headers=None, timeout=30):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(BASE + path, data=data, method=method)
    request.add_header("Content-Type", "application/json")
    for key, value in (headers or {}).items():
        request.add_header(key, value)
    with urllib.request.urlopen(request, timeout=timeout) as resp:
        return json.loads(resp.read().decode())


login = req(
    "POST",
    "/api/user/login",
    {"username": "admin", "password": "L4o4986784$"},
)
auth = {
    "Authorization": "Bearer " + login["data"]["access_token"],
    "New-Api-User": "1",
}
confirmed = req("POST", "/api/option/payment_compliance", {"confirmed": True}, auth)
print("compliance", json.dumps(confirmed, ensure_ascii=False)[:400])
info = req("GET", "/api/user/topup/info", headers=auth)
data = info.get("data") or {}
print(
    json.dumps(
        {
            "enable_online_topup": data.get("enable_online_topup"),
            "enable_redemption": data.get("enable_redemption"),
            "payment_compliance_confirmed": data.get("payment_compliance_confirmed"),
            "pay_methods": data.get("pay_methods"),
        },
        ensure_ascii=False,
    )
)

执行结果:

项结果
payment_compliance_confirmedtrue,条款版本 v1
enable_redemptiontrue,兑换码输入框出现
enable_online_topupfalse
pay_methods支付宝、微信、自定义1

兑换码已经可用。管理员在后台「兑换码」里生成,用户在钱包页「有兑换码吗?」输入后点兑换。

在线充值不是一个独立开关。enable_online_topup 只有在易支付三项都填了之后才会变成 true:

这三项当时是空的,所以页面仍显示「尚未启用在线充值」。没有真实商户信息时不能填假密钥,否则按钮会出现,但付款会失败。后台入口是「系统设置 → 支付网关 → 易支付」。

案例 4:邀请注册成功,推荐计划数字没增加

wanyan 的邀请链接是:

text
http://116.62.10.183:3000/sign-up?aff=Bk4J

zhenjiang 注册后,数据库里 inviter_id = 2,说明邀请关系已经绑上。但 wanyan 的邀请人数、待确认、总收入都还是 0。

注册发生时同时缺了两个条件:

  1. 支付合规是在这次注册之后大约 26 秒才确认的。确认前,邀请奖励不会发放。
  2. QuotaForInviter 和 QuotaForInvitee 都是 0。奖励为 0 时,程序连邀请人数也不加。被邀请人的奖励会直接进余额;邀请人的奖励只进「待确认」,而且只有奖励大于 0 才会记账。

站点额度单位是 500000 = 页面上的 $1。把两边奖励都设成 500000,并给这次已经发生的注册补记。文件:scripts/06-设置邀请奖励并补记.py

python
#!/usr/bin/env python3
"""把邀请奖励设为 500000(页面上的 $1),并补记当时已经注册但没发奖的 zhenjiang。

只在 wanyan.aff_count 仍为 0 时补记,避免重复加奖。
"""
import json
import sqlite3
import urllib.request

BASE = "http://127.0.0.1:3000"
REWARD = 500000


def req(method, path, body=None, headers=None, timeout=30):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(BASE + path, data=data, method=method)
    request.add_header("Content-Type", "application/json")
    for key, value in (headers or {}).items():
        request.add_header(key, value)
    with urllib.request.urlopen(request, timeout=timeout) as resp:
        return json.loads(resp.read().decode())


login = req(
    "POST",
    "/api/user/login",
    {"username": "admin", "password": "L4o4986784$"},
)
auth = {
    "Authorization": "Bearer " + login["data"]["access_token"],
    "New-Api-User": "1",
}
for key in ("QuotaForInviter", "QuotaForInvitee"):
    result = req("PUT", "/api/option/", {"key": key, "value": str(REWARD)}, auth)
    print(key, result.get("success"), result.get("message"))

con = sqlite3.connect("/opt/new-api/data/one-api.db")
con.execute("BEGIN")
inviter = con.execute(
    "SELECT aff_count FROM users WHERE id=2 AND username='wanyan'"
).fetchone()
invitee = con.execute(
    "SELECT inviter_id FROM users WHERE id=3 AND username='zhenjiang'"
).fetchone()
if inviter and inviter[0] == 0 and invitee and invitee[0] == 2:
    con.execute(
        "UPDATE users SET aff_count=aff_count+1, aff_quota=aff_quota+?, aff_history=aff_history+? WHERE id=2",
        (REWARD, REWARD),
    )
    con.execute("UPDATE users SET quota=quota+? WHERE id=3", (REWARD,))
    print("backfilled zhenjiang -> wanyan")
else:
    print("skip backfill", inviter, invitee)
con.commit()

补记之后:

以后每成功邀请一人,邀请人待确认增加 $1,新用户余额直接增加 $1。奖励为 0 时人数不会增加,所以这两项不要再改回 0。

案例 5:邀请奖励不会自动进余额,以及自己怎么划转

待确认对应数据库字段 aff_quota。当前余额对应 quota。总收入对应 aff_history,只增不减。

系统不会把待确认自动加进可用余额。要调用划转,或者在页面上点「转入余额」。当时按同样规则,把 wanyan 当时的 $1 从待确认划进了余额。文件:scripts/07-邀请奖励划转到余额.py

python
#!/usr/bin/env python3
"""把 wanyan 待确认的邀请奖励全部划进可用余额。总收入 aff_history 不变。"""
import sqlite3

con = sqlite3.connect("/opt/new-api/data/one-api.db")
con.execute("BEGIN IMMEDIATE")
row = con.execute(
    "SELECT quota, aff_quota, aff_history, aff_count FROM users WHERE id=2 AND username='wanyan'"
).fetchone()
print("before", row)
if row is None or row[1] <= 0:
    con.rollback()
    raise SystemExit("没有待划转的邀请奖励")
con.execute(
    "UPDATE users SET quota = quota + aff_quota, aff_quota = 0 WHERE id=2 AND username='wanyan'"
)
con.commit()
print(
    "after",
    con.execute(
        "SELECT quota, aff_quota, aff_history, aff_count FROM users WHERE id=2"
    ).fetchone(),
)

页面上自己操作:

  1. 用邀请人账号登录,打开 http://116.62.10.183:3000/wallet 。
  2. 找到「推荐计划」。待确认大于 $0 时,右侧出现「转入余额」。待确认为 0 时按钮不出现。
  3. 点「转入余额」,填写金额。可以只转一部分,不能超过待确认,也不能低于 $1。
  4. 确认后当前余额增加,待确认减少,总收入不变。

接口是 POST /api/user/aff_transfer,请求体是 {"quota": 500000}。500000 等于页面上的 $1。必须用邀请人自己的登录状态,不能用管理员代替点击。

案例 6:100 个客户、两张 4090,怎样避免同时提问把显卡打挂

100 个 Token 都可以发放。不能让 100 个请求同时占 GPU。多出来的请求要在进显存之前被限制并发,或者在引擎里排队。

现网 vLLM 已经有 --max-num-seqs 8,但上下文仍是 65536。27B 的 KV 缓存按长度增长,8 路满上下文叠在两张 4090 上仍可能把显存打满。当前 New-API 的渠道表没有单独的「最大并发」字段,并发上限以 vLLM 为准。New-API 的「模型请求速率限制」是按每个用户计次,挡不住 100 个人在同一秒各发 1 次。

客户场景建议参数在 config/vllm-客户场景参数.txt。这些参数没有写进正在运行的容器。

text
--max-num-seqs 4
--max-model-len 8192
--max-num-batched-tokens 8192
--gpu-memory-utilization 0.80
--tensor-parallel-size 2
--enable-prefix-caching
--served-model-name qwen3.6-27b
--host 0.0.0.0
--port 8000

三层配置:

  1. 显卡。--max-num-seqs 4,同时只跑 4 路。--max-model-len 8192,不要继续用 65536。--max-num-batched-tokens 8192,避免 100 个长提示词同时预填充。现在的 --tensor-parallel-size 1 只用了一张卡。两张 4090 可以改成张量并行为 2,或者一张卡一个 vLLM、New-API 建两条渠道按权重分流。max-num-seqs 按整机合计计算。
  2. New-API。100 个令牌放进独立分组 customer,不要和 default 混用。每个令牌单独设额度。打开「系统设置 → 速率限制 → 模型请求速率限制」,例如每个用户每分钟最多 6 次请求、其中最多 2 次成功。
  3. 单次输出。在渠道参数覆盖里把 max_tokens 限制到 1024 或 2048,避免客户端一次申请 32000 输出。

这样 100 个客户都有自己的 Token,同时占 GPU 的只有 4 路,其余排队。高峰时会多等几秒到几十秒,这是两张 4090 跑 27B 的容量,不是故障。


写文档时核对到的数据

额度换算:500000 = $1。

用户id邀请人邀请码邀请人数待确认总收入说明
admin1无空000初始化管理员
wanyan2无Bk4J201000000($2)两笔奖励都已划进余额
zhenjiang32dGGj000第一位被邀请用户,余额里有 $1 注册奖励
dashan423DxL000奖励配置生效之后注册的第二位,余额里有 $1 注册奖励

令牌:

id所属用户名称状态过期时间分组
1adminapi-key-v11,已启用-1,永不过期default,无限额度
2wanyan完颜测试3,已过期有具体时间戳default,无限额度

渠道仍是一条:vLLM-Qwen3.6-27B,模型 qwen3.6-27b,上游 http://172.17.0.1:8000,分组 default,状态启用。

选项: