
Cliproxy Newapi Stack
- 11 installs
- 253 repo stars
- Updated August 4, 2026
- majiayu000/claude-arsenal
Helps with ai & agent building tasks during AI-assisted development.
About
cliproxy-newapi-stack is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- cliproxy-newapi-stack
- AI & Agent Building
- AI-coding skill
Cliproxy Newapi Stack by the numbers
- 11 all-time installs (skills.sh)
- Ranked #11,740 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/majiayu000/claude-arsenal --skill cliproxy-newapi-stackAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 253 |
| Last updated | August 4, 2026 |
| Repository | majiayu000/claude-arsenal ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
CLIProxyAPI + NewAPI Metering Stack
把 OAuth 订阅账号 (codex/claude/gemini/qwen/iflow) 通过 CLIProxyAPI 暴露为 OpenAI 兼容 API,再用 NewAPI (calciumion/new-api) 在前面套一层计费/限流/多用户 token。 姊妹 skill cliproxy-deploy 只负责裸 CLIProxyAPI;本 skill 在其上加 metering 层并补全踩过的坑。
所有"完成"结论必须基于本会话命令输出(W-16)。价格和额度变更后必须真发一次请求并查 logs.quota 不为 0。
---
0. 前置确认(必问)
- SSH 目标:
root@HOST是否能免密 - 端口分配:
CLIPROXY_PORT(默认8317)、NEWAPI_PORT(默认8200) - 裸部署是否就绪:CLIProxyAPI 已经在
<HOST>:<CLIPROXY_PORT>跑通?没就先跑
cliproxy-deploy skill
- 登录账号供应商:
codex/claude/qwen/iflow/gemini - 价格输入格式:每个虚拟模型给我 input / cached / output 三个 USD per 1M 数字
- 客户端机器:要在哪些机器上落
BASE_URL环境变量
降级路径:默认走"本地 OAuth + scp 同步 + UFW 开 NewAPI 端口 + SQLite 直写价格"。
---
Phase 1 — CLIProxyAPI(委托给姊妹 skill)
裸部署已在 cliproxy-deploy skill 完成;本 skill 不重复其内容,只补丁两个点:
1. 加稳定性开关到 /root/CLIProxyAPI/config.yaml:
disable-cooling: true原因见 references/troubleshooting.md "CLIProxyAPI cooldown 原理"。 若漏改,30 并发 5KB payload 会出现混合 ~50% 503。
2. 不要把 CLIProxyAPI 端口暴露到公网(与裸部署不同):
host: ""仍可,但仅给容器访问,外部公网走 NewAPI 入口即可- 如果之前已经
ufw allow <CLIPROXY_PORT>想保留作为 admin 后门也行,但要换强 key
---
Phase 2 — 部署 NewAPI 容器
SSH_TARGET=root@<HOST> SSH_KEY=~/.ssh/id_ed25519 NEWAPI_PORT=8200 \
scripts/deploy_newapi.sh脚本完成后 UFW 放行:
ssh -i <KEY> <SSH_TARGET> "ufw allow <NEWAPI_PORT>/tcp && ufw status | grep <NEWAPI_PORT>"首次访问 http://<HOST>:<NEWAPI_PORT> 完成 root 账号注册(NewAPI 不预置默认密码)。 保存账号密码到密码管理器(不要写进 skill / 日志 / 提交)。
---
Phase 3 — 渠道 + Token
3a. 创建渠道(CLIProxyAPI as upstream)
NewAPI 后台 → "渠道" → 新建 → OpenAI 类型:
- base_url:
http://172.17.0.1:<CLIPROXY_PORT>
⚠️ 不能写 `127.0.0.1` — 容器里的 127.0.0.1 是容器自己。详见 references/troubleshooting.md "容器网络速记"。
- 密钥:CLIProxyAPI 的
cpa_xxxkey - 模型:以逗号分隔填 CLIProxyAPI 暴露的虚拟模型名,例如:
gpt-5.4,gpt-5.3-codex,gpt-5.3-codex-spark,gpt-5.4-mini,gpt-5.2
- 测试按钮应当 200 OK;失败先查
references/troubleshooting.md。
3b. 创建 Token(客户端用)
NewAPI 后台 → "令牌" → 新建:
- 名称:
client-default之类标识 - 额度:先放
unlimited或一个大数(实际计费由 user.quota 控制) - 可用模型:勾上你给客户端开放的模型
- 复制生成的
sk-xxx(这是客户端的 BASE_API_KEY)
---
Phase 4 — 写价格 + 充额度
4a. 写价格
用 scripts/set_pricing.py(基于实测 USD/1M 自动算出三个倍率):
SSH_TARGET=root@<HOST> SSH_KEY=~/.ssh/id_ed25519 \
scripts/set_pricing.py \
--model gpt-5.4 --input 2.5 --cached 0.25 --output 15 \
--model gpt-5.3-codex --input 1.75 --cached 0.175 --output 14脚本会:合并写入 options 表的 ModelRatio / CacheRatio / CompletionRatio,重启容器。
⚠️ 倍率语义见 references/newapi-pricing.md —— CacheRatio / CompletionRatio 是相对 输入价的倍数,不是绝对单价。ModelRatio 在不同 fork 里可能除以 2,第一次配置务必发请求 看 logs.quota 实际值匹配预期。
4b. 充额度
NewAPI 在线充值通常未配,最快走 SQLite 直写:
SSH_TARGET=root@<HOST> SSH_KEY=~/.ssh/id_ed25519 \
scripts/topup.sh <user_id> <quota>
# 例:1 1000000000 → 1B quota ≈ USD 2000 (默认 QuotaPerUnit=500000)4c. 配置在线充值(可选)
如果要让用户自助充值,NewAPI 后台 → 系统 → 支付:
- Stripe / 易支付 / 自定义 → 填
TopUpLink等字段 - 不配也行,管理员手动加额度 (
scripts/topup.sh) 是合法主路径
---
Phase 5 — 客户端环境变量
把以下三行追加到客户端的 ~/.zshrc 或 ~/.bashrc:
export BASE_URL="http://<HOST>:<NEWAPI_PORT>/v1"
export BASE_API_KEY="<NEWAPI_TOKEN>" # sk-xxx
export BASE_MODEL="<虚拟模型名>" # 如 gpt-5.4source 一下生效。
跨多台机器同步时(W-14 文件归属):单台单台手动 SSH 改各自的 rc 文件,避免并行写覆盖。
---
Phase 6 — 加新账号(OpenAI/Anthropic/etc)
不需要重启服务,CLIProxyAPI 有 file watcher。
最简单:
PROVIDER=codex \
SSH_TARGET=root@<HOST> SSH_KEY=~/.ssh/id_ed25519 \
CLIPROXY_LOCAL=<本地 CLIProxyAPI 仓库路径> \
scripts/add_codex_account.sh脚本流程: 1. 本地启 go run ./cmd/server -<provider>-login -config config.yaml 2. 浏览器用新账号登录(先在浏览器登出旧账号或用无痕窗口 — 否则会复用旧 session 把旧凭据 覆盖回去) 3. 自动 diff ~/.cli-proxy-api/<provider>-*.json,把新文件 scp 到 VPS 4. tail VPS 日志确认 auth file changed (CREATE)
详见 references/multi-account.md(含订阅条件、轮询语义、删除账号、验证方法)。
---
验证(Phase 4/5/6 之后必跑)
HOST=<HOST> SSH_TARGET=root@<HOST> SSH_KEY=~/.ssh/id_ed25519 \
CLIPROXY_PORT=<CLIPROXY_PORT> NEWAPI_PORT=<NEWAPI_PORT> \
CLIPROXY_KEY=<cpa_xxx> NEWAPI_TOKEN=<sk-xxx> MODEL=<虚拟模型> \
scripts/verify_stack.sh通过判定:
- 直连 CLIProxyAPI HTTP 200
- 经 NewAPI HTTP 200
- 最近
logs里对应request_id行quota > 0
任一不满足都不得声称"部署完成"。
---
资源索引
| 文件 | 用途 |
|---|---|
scripts/deploy_newapi.sh | NewAPI 容器一键部署 + 健康自检 |
scripts/set_pricing.py | 用 USD/1M 三参数自动写 NewAPI ratios |
scripts/topup.sh | 直接 SQLite 改 users.quota |
scripts/verify_stack.sh | 双路径 + 计费日志验证 |
scripts/add_codex_account.sh | OAuth 登录 + 同步凭据 + watcher 校验 |
agents/openai.yaml | 需要第二模型复核计费/部署方案时的 agent 配置 |
references/newapi-pricing.md | ModelRatio / CacheRatio / CompletionRatio / QuotaPerUnit 完整语义 + 计算示例 |
references/troubleshooting.md | 容器网络、cooldown、PUT 不生效、UFW 等踩坑表 |
references/multi-account.md | 多账号轮询语义 + 加号 / 删号 / 订阅条件 |
---
不要做的事
- ❌ NewAPI 渠道 base_url 写
http://127.0.0.1:<port>(容器内自指) - ❌ 把
CacheRatio当绝对单价(实际是相对输入的倍数) - ❌ 价格只走
/api/option/PUT 不验证(已知该接口可能静默失败) - ❌ 远端 OAuth 隧道折腾(5 分钟窗口 + 隧道配合,已踩过坑,统一用本地登录 + scp)
- ❌ 把 CLIProxyAPI 公网端口的
cpa_key 和 NewAPI 的sk-token 共享给同一类客户端 - ❌ 跨会话声称"价格生效"——必须本会话发请求 + 看
logs.quota - ❌ 在没有
disable-cooling: true的情况下做高并发压测
interface:
display_name: "CLIProxyAPI + NewAPI Stack"
short_description: "部署 CLIProxyAPI + NewAPI 计费层组合栈"
default_prompt: "部署 CLIProxyAPI 和 NewAPI 到 VPS,把 OAuth 订阅账号包装成可计费的 OpenAI 兼容 API"
Multi-Account Operations (CLIProxyAPI)
CLIProxyAPI auto-rotates across all credential JSON files in auth-dir. Adding accounts is file-system level — no code, no restart.
Add a new OAuth account
Use scripts/add_codex_account.sh (provider configurable) or do it manually:
1. Local OAuth (browser must be on the local machine):
cd <CLIProxyAPI-local-clone>
go run ./cmd/server -<provider>-login -config config.yaml- For each provider use the matching flag:
-codex-login -claude-login -qwen-login -iflow-login -antigravity-login -login(Gemini)
- Critical: log out of any previous account in the browser first, or use an incognito
window — otherwise OAuth will silently re-bind to the existing session and overwrite the same credential file.
2. Capture filename:
ls -lt ~/.cli-proxy-api/<provider>-*.json | headNew file is named <provider>-<email>-[ts].json.
3. Sync to VPS:
scp -i <KEY> ~/.cli-proxy-api/<file> root@<HOST>:/root/.cli-proxy-api/4. Watcher hot-loads it — log lines:
auth file changed (CREATE): <file>, processing incrementally
auth file changed (WRITE): <file>, processing incrementallyNo service restart needed.
How load-balancing actually works
- Selector ranks accounts by availability + freshness; does not keep a strict round-robin.
- A single account can serve many requests in a row if others are cooled / unavailable.
- 429 / quota exhaustion marks an account temporarily unavailable; selector switches to peer.
- Routing distribution is therefore load-aware, not fair-share.
Verify both accounts are recognized
After adding, scan the log:
grep -oE 'codex-[A-Za-z0-9._@+-]+\.json' /root/CLIProxyAPI/cli-proxy-api.log \
| sort | uniq -cYou may see only the new account during the first batch — that means the older account is under-quota or cooling. Force the older one to fire by waiting (cooldown expiry) or running many requests; both should appear over time.
Remove an account
1. rm /root/.cli-proxy-api/<provider>-<email>.json 2. Watcher logs auth file changed (REMOVE): ... 3. The selector drops it from the rotation immediately.
Subscription required
OAuth login success ≠ usable. The account must hold a valid subscription:
- Codex / GPT-5 family → ChatGPT Plus or Pro
- Claude
claude-*→ Claude Pro or Max (depending on model tier) - Gemini → corresponding Google AI Pro tier
Without subscription, OAuth completes and tokens save, but /v1/chat/completions returns 401 / 402. Always probe with one real request after sync.
NewAPI Pricing Semantics (calciumion/new-api v0.12.x)
NewAPI persists pricing as deep-mergeable JSON blobs in the options table (SQLite at /data/one-api.db). Default values live in code; the persisted rows override on top.
Three pricing keys
| Key | Type | Meaning |
|---|---|---|
ModelRatio | {model: float} | Per-1M-token input price (USD), often divided by an internal scale on some forks. Verify with a probe request. |
CacheRatio | {model: float} | Multiplier on input price. cached_input = input * CacheRatio. |
CompletionRatio | {model: float} | Multiplier on input price. output = input * CompletionRatio. |
QuotaPerUnit | int | Default 500000. Means 1 USD = 500000 quota. Used to convert model price to internal quota integer recorded in logs.quota. |
GroupRatio / group_ratio_setting.group_ratio | {group: float} | Per-user-group multiplier. default=1 typical. |
Worked example
Target prices (USD per 1M tokens):
- input
2.5 - cached
0.25← 0.1× of input - output
15← 6× of input
Write:
"ModelRatio": {"gpt-5.4": 2.5}
"CacheRatio": {"gpt-5.4": 0.1}
"CompletionRatio": {"gpt-5.4": 6}Probe with a real request and check logs.quota:
SELECT id, model_name, prompt_tokens, completion_tokens, quota
FROM logs ORDER BY id DESC LIMIT 3;Compute expected quota:
expected = (prompt_tokens * input + completion_tokens * output) * (QuotaPerUnit / 1e6)
= (prompt_tokens * 2.5 + completion_tokens * 15) * 0.5If observed roughly matches, mapping is correct. If observed is ~30× higher, the fork uses an internal /2 scale for ModelRatio — write 1.25 instead of 2.5 and re-probe.
Pitfalls
- CacheRatio is NOT a price — writing
0.25means cached = 0.25× input, not "cached costs 0.25 USD/1M". - CompletionRatio is NOT a price — writing
15means output = 15× input, ie a huge multiplier. - PUT via /api/option/ may silently no-op on some installs. Direct SQLite write + container
restart is the canonical fallback.
- Defaults bleed through: missing keys mean NewAPI uses its built-in default, which may be
wildly wrong (e.g. gpt-4-class default applied to a gpt-5.4 virtual model).
- Scope to virtual models only: CLIProxyAPI exposes virtual model names (e.g.
gpt-5.4,
gpt-5.3-codex). Do not write ratios for upstream raw names — they will not be billed.
Restart requirement
After writing rows directly to SQLite, restart the container:
docker restart new-apiThe merged in-memory map is rebuilt at startup.
Related options
MinTopUp,StripeMinTopUp,StripeUnitPrice— affect the user-facing top-up UI; do not
change billing math.
general_setting.quota_display_type=USD— only changes UI rendering; rawquotafield stays
integer in logs.
Stack Troubleshooting (踩过的坑)
| 症状 | 根因 | 修复 |
|---|---|---|
NewAPI 渠道测试失败:upstream connection refused | 容器里的 127.0.0.1 是容器自己 | base_url 改成 http://172.17.0.1:<CLIPROXY_PORT> |
写价格用 /api/option/ PUT 返回 success 但实际不生效 | 这版接口幂等行为不一致 | 直接 sqlite3 UPDATE options ... + docker restart new-api |
调用经 NewAPI 后 quota=0 入 logs | 模型名没在 ModelRatio 里登记,走 default 0 | 必须显式写 ratio 给 CLIProxyAPI 暴露的虚拟模型名(如 gpt-5.4) |
| 30 并发 5KB payload 时混合 200/503 (~50%) | CLIProxyAPI conductor 把 408 当 5xx 触发 1 分钟冷却,selector 在窗口期同步拒所有请求 | config.yaml 加 disable-cooling: true,改 internal/config/config.go 触达的开关 |
| 直连 :8317 返回 408 但经 NewAPI 变 503 | NewAPI 把 auth_unavailable 包装成 503 | 看 /root/CLIProxyAPI/cli-proxy-api.log 里的真实上游码 |
Empty reply from server 公网调用 | UFW 默认 deny incoming,端口未放行 | ufw allow <PORT>/tcp |
| 加新 OAuth 账号后日志显示老号还在被路由 | 多账号轮询正常 | 老号 429/quota 用尽时 selector 才把它 mark unavailable,新号自动顶上 |
NewAPI /api/option/ 401 | 未登录或漏 New-Api-User: 1 头 | 先 POST /api/user/login 拿 cookie,再调 GET /api/option/ -H 'New-Api-User: 1' |
用户看到 quota=-N 但前端没反应 | 持续消费导致负余额 | 直接 UPDATE users SET quota=N WHERE id=? + 重启容器 |
| 老 PID 还监听端口,新启动不生效 | 旧 go run 进程没退 | lsof -i:<PORT> → 精确 kill <pid>;不要 pkill -9 go |
OAuth 卡 Authentication timed out | 5 分钟窗口 + 隧道未就位 | 改走本地 OAuth + scp |
| Codex login 通过但实际请求 401 | 账号无 ChatGPT Plus/Pro 订阅 | 换有效订阅账号;不要假设 login 成功 = 可用 |
容器网络速记
container 127.0.0.1 ≠ host
container -> host = http://172.17.0.1:<port> (default bridge)
host -> container = http://127.0.0.1:<port> (port-published)
external -> host = http://<HOST>:<port> (UFW must allow)CLIProxyAPI cooldown 原理
- 文件:
sdk/cliproxy/auth/conductor.go - 触发:
case 408, 500, 502, 503, 504→state.NextRetryAfter = now+1min - 后果:单次 408 把整账号冻 60s;30 并发期间 99% 请求落在窗口内
- 解药:
config.yaml: disable-cooling: true(internal/config/config.go的开关,运行时读)
NewAPI 计费日志
SELECT id, datetime(created_at,'unixepoch','localtime') AS t,
model_name, prompt_tokens, completion_tokens,
quota, channel_id, request_id
FROM logs ORDER BY id DESC LIMIT 20;quota=0通常意味着模型名漏了 ratio 配置或走了 default 0。request_id与响应头X-Oneapi-Request-Id对应,可跨层追踪。
#!/usr/bin/env bash
# Add a new OAuth account (codex/claude/qwen/iflow/gemini) to an existing
# CLIProxyAPI deployment by running OAuth locally and syncing the credential
# JSON to the VPS auth-dir. CLIProxyAPI watcher hot-loads the new file —
# no restart needed.
#
# Usage:
# PROVIDER=codex SSH_TARGET=root@host SSH_KEY=~/.ssh/id_ed25519 \
# CLIPROXY_LOCAL=/path/to/CLIProxyAPI \
# ./add_codex_account.sh
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=skills/cliproxy-newapi-stack/scripts/lib/validation.sh
. "$SCRIPT_DIR/lib/validation.sh"
: "${PROVIDER:=codex}" # one of: codex|claude|qwen|iflow|antigravity|gemini
: "${SSH_TARGET:?SSH_TARGET required}"
: "${SSH_KEY:=$HOME/.ssh/id_ed25519}"
: "${CLIPROXY_LOCAL:=$PWD}"
: "${LOCAL_AUTH_DIR:=$HOME/.cli-proxy-api}"
: "${REMOTE_AUTH_DIR:=/root/.cli-proxy-api}"
case "$PROVIDER" in
codex) FLAG=-codex-login ;;
claude) FLAG=-claude-login ;;
qwen) FLAG=-qwen-login ;;
iflow) FLAG=-iflow-login ;;
antigravity) FLAG=-antigravity-login ;;
gemini) FLAG=-login ;;
*) echo "unknown PROVIDER=$PROVIDER" >&2; exit 2 ;;
esac
require_safe_ssh_target SSH_TARGET "$SSH_TARGET"
require_absolute_safe_path REMOTE_AUTH_DIR "$REMOTE_AUTH_DIR"
mkdir -p "$LOCAL_AUTH_DIR"
echo ">> Capturing existing credentials before login"
before=$(find "$LOCAL_AUTH_DIR" -maxdepth 1 -type f -name "${PROVIDER}-*.json" -print | sort)
echo ">> Run OAuth in browser for the NEW account (logout/incognito as needed)"
( cd "$CLIPROXY_LOCAL" && go run ./cmd/server "$FLAG" -config config.yaml )
after=$(find "$LOCAL_AUTH_DIR" -maxdepth 1 -type f -name "${PROVIDER}-*.json" -print | sort)
new=$(comm -13 <(printf '%s\n' "$before" | sed '/^$/d') <(printf '%s\n' "$after" | sed '/^$/d') || true)
if [ -z "$new" ]; then
echo "no new credential file detected; aborting"
exit 1
fi
echo ">> New credential(s):"
echo "$new"
echo ">> Syncing to $SSH_TARGET:$REMOTE_AUTH_DIR"
ssh -i "$SSH_KEY" -- "$SSH_TARGET" bash -s -- "$REMOTE_AUTH_DIR" <<'REMOTE'
set -euo pipefail
remote_auth_dir=$1
mkdir -p -- "$remote_auth_dir"
REMOTE
while IFS= read -r f; do
[ -n "$f" ] && scp -i "$SSH_KEY" -- "$f" "$SSH_TARGET:$REMOTE_AUTH_DIR/"
done <<< "$new"
echo ">> Watcher should auto-load. Tailing log for confirmation..."
ssh -i "$SSH_KEY" -- "$SSH_TARGET" \
"tail -n 100 /root/CLIProxyAPI/cli-proxy-api.log | grep -iE 'auth file changed|added' | tail -n 5"
echo ">> Listing remote credentials"
ssh -i "$SSH_KEY" -- "$SSH_TARGET" bash -s -- "$REMOTE_AUTH_DIR" "$PROVIDER" <<'REMOTE'
set -euo pipefail
remote_auth_dir=$1
provider=$2
shopt -s nullglob
files=("${remote_auth_dir}/${provider}-"*.json)
if ((${#files[@]} == 0)); then
echo "no remote credentials found for provider ${provider}" >&2
exit 1
fi
ls -l -- "${files[@]}"
REMOTE
#!/usr/bin/env bash
# Deploy NewAPI (calciumion/new-api) Docker container on a Linux VPS.
#
# Usage:
# SSH_TARGET=root@1.2.3.4 SSH_KEY=~/.ssh/id_ed25519 PORT=8200 ./deploy_newapi.sh
#
# Idempotent: safe to re-run. Pulls latest image, recreates container if exists.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=skills/cliproxy-newapi-stack/scripts/lib/validation.sh
. "$SCRIPT_DIR/lib/validation.sh"
: "${SSH_TARGET:?SSH_TARGET required (e.g. root@1.2.3.4)}"
: "${SSH_KEY:=$HOME/.ssh/id_ed25519}"
: "${PORT:=8200}"
: "${DATA_DIR:=/root/newapi/data}"
: "${LOGS_DIR:=/root/newapi/logs}"
: "${IMAGE:=calciumion/new-api:latest}"
: "${TZ_VAL:=Asia/Shanghai}"
require_safe_ssh_target SSH_TARGET "$SSH_TARGET"
require_port PORT "$PORT"
require_absolute_safe_path DATA_DIR "$DATA_DIR"
require_absolute_safe_path LOGS_DIR "$LOGS_DIR"
require_safe_docker_image IMAGE "$IMAGE"
require_safe_timezone TZ_VAL "$TZ_VAL"
ssh -i "$SSH_KEY" -- "$SSH_TARGET" bash -s -- "$PORT" "$DATA_DIR" "$LOGS_DIR" "$IMAGE" "$TZ_VAL" <<'REMOTE'
set -euo pipefail
port=$1
data_dir=$2
logs_dir=$3
image=$4
tz_val=$5
command -v docker >/dev/null || { apt-get update && apt-get install -y docker.io; }
systemctl enable --now docker
mkdir -p -- "$data_dir" "$logs_dir"
docker pull "$image"
docker rm -f new-api 2>/dev/null || true
docker run -d --name new-api --restart unless-stopped \
-p "${port}:3000" \
-e "TZ=${tz_val}" \
-v "${data_dir}:/data" \
-v "${logs_dir}:/app/logs" \
"$image"
for i in 1 2 3 4 5 6 7 8 9 10; do
sleep 2
if curl -fsS -o /dev/null "http://127.0.0.1:${port}/api/setup"; then
echo "NewAPI ready on port ${port}"
exit 0
fi
done
echo NewAPI did not become ready, recent logs: >&2
docker logs --tail 50 new-api >&2
exit 1
REMOTE
#!/usr/bin/env bash
die() {
printf 'error: %s\n' "$*" >&2
exit 2
}
require_uint() {
local name=$1
local value=$2
if [[ ! "$value" =~ ^[0-9]+$ ]]; then
die "$name must be an unsigned integer"
fi
}
require_positive_uint() {
local name=$1
local value=$2
require_uint "$name" "$value"
if (( 10#$value < 1 )); then
die "$name must be greater than zero"
fi
}
require_port() {
local name=$1
local value=$2
require_uint "$name" "$value"
if (( 10#$value < 1 || 10#$value > 65535 )); then
die "$name must be between 1 and 65535"
fi
}
require_absolute_safe_path() {
local name=$1
local value=$2
if [[ -z "$value" || "$value" != /* ]]; then
die "$name must be an absolute path"
fi
if [[ ! "$value" =~ ^/[A-Za-z0-9._/:+-]+$ ]]; then
die "$name contains unsupported characters"
fi
if [[ "$value" == *"/../"* || "$value" == *"/.." || "$value" == *"//"* ]]; then
die "$name must not contain path traversal or duplicate slashes"
fi
}
require_safe_container_name() {
local name=$1
local value=$2
if [[ "$value" == -* || ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
die "$name must be a Docker container name"
fi
}
require_safe_docker_image() {
local name=$1
local value=$2
if [[ "$value" == -* || ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]]; then
die "$name must be a Docker image reference"
fi
}
require_safe_host() {
local name=$1
local value=$2
if [[ "$value" == -* || ! "$value" =~ ^[A-Za-z0-9._:-]+$ ]]; then
die "$name must be a host name or IP address"
fi
}
require_safe_model_name() {
local name=$1
local value=$2
if [[ "$value" == -* || ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._:+/@-]*$ ]]; then
die "$name must be a model name"
fi
}
require_safe_ssh_target() {
local name=$1
local value=$2
if [[ "$value" == -* || ! "$value" =~ ^([A-Za-z0-9._~-]+@)?[A-Za-z0-9._:-]+$ ]]; then
die "$name must be an SSH host or user@host target"
fi
}
require_safe_timezone() {
local name=$1
local value=$2
if [[ "$value" == -* || ! "$value" =~ ^[A-Za-z0-9_+./-]+$ ]]; then
die "$name must be an IANA-style timezone"
fi
}
#!/usr/bin/env python3
"""Write/merge ModelRatio / CacheRatio / CompletionRatio overrides into NewAPI SQLite.
NewAPI v0.12.x merges the persisted `options` rows on top of the in-memory defaults.
This script reads the current values, deep-merges patches, writes them back,
then restarts the container so the new values are loaded.
Pricing semantics (CRITICAL — easy to get wrong):
ModelRatio = USD per 1M input tokens / 2 (NewAPI internal scale)
User-facing rule: if your target input price is X USD/1M,
write X / 2 e.g. input 2.5 -> ModelRatio 1.25.
BUT: many docs treat ModelRatio as the input price directly
for OpenAI-compatible providers. Verify with a test request
and check logs.quota matches expectation.
CacheRatio = cached_input / input (relative multiplier)
If cached input is 0.1x of input, write 0.1.
CompletionRatio = output / input (relative multiplier)
If output is 6x of input, write 6.
Usage:
SSH_TARGET=root@host SSH_KEY=~/.ssh/id_ed25519 ./set_pricing.py \
--model gpt-5.4 --input 2.5 --cached 0.25 --output 15 \
--model gpt-5.3-codex --input 1.75 --cached 0.175 --output 14
The script computes the three ratios from raw USD/1M prices.
"""
import argparse
import json
import os
import shlex
import subprocess
import sys
def run_ssh(target: str, key: str, command: str) -> str:
cmd = ["ssh", "-i", key, target, command]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
sys.stderr.write(result.stderr)
raise SystemExit(f"ssh failed: {' '.join(shlex.quote(c) for c in cmd)}")
return result.stdout
def build_remote_python(updates: dict, db_path: str) -> str:
"""Return a heredoc Python program executed on the VPS."""
payload = json.dumps(updates, ensure_ascii=False)
program = f"""
import sqlite3, json, sys
DB = {json.dumps(db_path)}
patches = json.loads({json.dumps(payload)})
conn = sqlite3.connect(DB)
cur = conn.cursor()
for key, patch in patches.items():
row = cur.execute("SELECT value FROM options WHERE key=?", (key,)).fetchone()
data = json.loads(row[0]) if row and row[0] else {{}}
data.update(patch)
value = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
cur.execute(
"INSERT INTO options(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
conn.commit()
for key in ("ModelRatio", "CacheRatio", "CompletionRatio"):
row = cur.execute("SELECT value FROM options WHERE key=?", (key,)).fetchone()
if row:
sample = {{m: json.loads(row[0]).get(m) for m in patches.get(key, {{}}).keys()}}
print(key, sample)
conn.close()
"""
return f"python3 - <<'PY'{program}\nPY"
def main() -> int:
p = argparse.ArgumentParser(description="NewAPI pricing override writer")
p.add_argument(
"--model",
action="append",
required=True,
dest="models",
help="Model name (repeatable). Each --model must be followed by --input/--cached/--output.",
)
p.add_argument("--input", type=float, action="append", required=True, dest="inputs",
help="Input price USD per 1M tokens (repeatable, paired with --model)")
p.add_argument("--cached", type=float, action="append", default=[], dest="cacheds",
help="Cached input price USD per 1M tokens")
p.add_argument("--output", type=float, action="append", required=True, dest="outputs",
help="Output price USD per 1M tokens")
p.add_argument("--ratio-divisor", type=float, default=2.0,
help="ModelRatio = input / divisor. Default 2 for NewAPI v0.12.x "
"(OneAPI internal-scale convention: ModelRatio*2 == USD/1M). "
"Set 1 if your fork uses raw USD/1M.")
p.add_argument("--db", default="/root/newapi/data/one-api.db")
p.add_argument("--restart", default="new-api",
help="docker container name to restart after write (set empty to skip)")
args = p.parse_args()
target = os.environ.get("SSH_TARGET")
key = os.environ.get("SSH_KEY", os.path.expanduser("~/.ssh/id_ed25519"))
if not target:
sys.stderr.write("SSH_TARGET env required\n")
return 2
n = len(args.models)
if not (len(args.inputs) == len(args.outputs) == n):
sys.stderr.write("--model / --input / --output must appear the same number of times\n")
return 2
if args.cacheds and len(args.cacheds) != n:
sys.stderr.write("--cached must appear 0 times or once per --model\n")
return 2
model_ratio: dict = {}
cache_ratio: dict = {}
completion_ratio: dict = {}
for i, model in enumerate(args.models):
inp = args.inputs[i]
out = args.outputs[i]
cached = args.cacheds[i] if args.cacheds else None
model_ratio[model] = inp / args.ratio_divisor
completion_ratio[model] = out / inp if inp else 0
if cached is not None and inp:
cache_ratio[model] = cached / inp
updates = {"ModelRatio": model_ratio, "CompletionRatio": completion_ratio}
if cache_ratio:
updates["CacheRatio"] = cache_ratio
print("Computed ratios:")
print(json.dumps(updates, ensure_ascii=False, indent=2))
print(f"Applying to {target}:{args.db}")
out = run_ssh(target, key, build_remote_python(updates, args.db))
print(out)
if args.restart:
print(f"Restarting container {args.restart}")
run_ssh(target, key, f"docker restart {shlex.quote(args.restart)} >/dev/null")
print("Done. Verify with verify_stack.sh.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=skills/cliproxy-newapi-stack/scripts/lib/validation.sh
. "$SCRIPT_DIR/lib/validation.sh"
failures=0
assert_pass() {
local name=$1
shift
if ! ("$@"); then
printf 'FAIL expected pass: %s\n' "$name" >&2
failures=$((failures + 1))
fi
}
assert_fail() {
local name=$1
shift
if ("$@") >/dev/null 2>&1; then
printf 'FAIL expected rejection: %s\n' "$name" >&2
failures=$((failures + 1))
fi
}
assert_pass port_valid require_port PORT 8200
assert_fail port_shell_meta require_port PORT '8200;id'
assert_fail port_out_of_range require_port PORT 70000
assert_pass path_valid require_absolute_safe_path DB /root/newapi/data/one-api.db
assert_fail path_quote require_absolute_safe_path DB "/root/newapi/data/one-api.db';id'"
assert_fail path_space require_absolute_safe_path DB '/root/newapi/data/one api.db'
assert_fail path_traversal require_absolute_safe_path DB /root/newapi/../etc/passwd
assert_pass ssh_target_valid require_safe_ssh_target SSH_TARGET root@1.2.3.4
assert_fail ssh_target_option require_safe_ssh_target SSH_TARGET '-oProxyCommand=id'
assert_fail ssh_target_shell_meta require_safe_ssh_target SSH_TARGET 'root@host;id'
assert_pass docker_image_valid require_safe_docker_image IMAGE calciumion/new-api:latest
assert_fail docker_image_option require_safe_docker_image IMAGE '-bad'
assert_fail docker_image_shell_meta require_safe_docker_image IMAGE 'calciumion/new-api:latest;id'
assert_pass container_valid require_safe_container_name CONTAINER new-api
assert_fail container_shell_meta require_safe_container_name CONTAINER 'new-api;id'
assert_pass model_valid require_safe_model_name MODEL gpt-5.4
assert_fail model_json_breakout require_safe_model_name MODEL 'gpt";id'
if ((failures > 0)); then
exit 1
fi
echo "security validation tests passed"
#!/usr/bin/env bash
# Top up a NewAPI user's quota directly in SQLite (admin API does not always persist).
#
# Usage:
# SSH_TARGET=root@host SSH_KEY=~/.ssh/id_ed25519 ./topup.sh <user_id> <quota>
# Example: ./topup.sh 1 1000000000 # 1B quota = USD 2000 at QuotaPerUnit=500000
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=skills/cliproxy-newapi-stack/scripts/lib/validation.sh
. "$SCRIPT_DIR/lib/validation.sh"
: "${SSH_TARGET:?SSH_TARGET required}"
: "${SSH_KEY:=$HOME/.ssh/id_ed25519}"
: "${DB:=/root/newapi/data/one-api.db}"
: "${CONTAINER:=new-api}"
USER_ID="${1:?user_id required}"
QUOTA="${2:?quota integer required}"
require_safe_ssh_target SSH_TARGET "$SSH_TARGET"
require_positive_uint USER_ID "$USER_ID"
require_uint QUOTA "$QUOTA"
require_absolute_safe_path DB "$DB"
require_safe_container_name CONTAINER "$CONTAINER"
ssh -i "$SSH_KEY" -- "$SSH_TARGET" bash -s -- "$USER_ID" "$QUOTA" "$DB" "$CONTAINER" <<'REMOTE'
set -euo pipefail
user_id=$1
quota=$2
db=$3
container=$4
sqlite3 "$db" "UPDATE users SET quota=$quota WHERE id=$user_id;"
sqlite3 -header -column "$db" "SELECT id, username, quota, used_quota FROM users WHERE id=$user_id;"
docker restart "$container" >/dev/null
echo "restarted ${container}"
REMOTE
#!/usr/bin/env bash
# Two-path verification: direct CLIProxyAPI (cpa_ key) and via NewAPI (sk- token).
# Both must return HTTP 200 AND NewAPI must record a non-zero quota log entry.
# Provides W-16 fresh-session evidence for "stack is healthy".
#
# Usage:
# HOST=1.2.3.4 \
# SSH_TARGET=root@1.2.3.4 \
# SSH_KEY=~/.ssh/id_ed25519 \
# CLIPROXY_PORT=8317 NEWAPI_PORT=8200 \
# CLIPROXY_KEY=cpa_xxx NEWAPI_TOKEN=sk-xxx \
# MODEL=gpt-5.4 \
# ./verify_stack.sh
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=skills/cliproxy-newapi-stack/scripts/lib/validation.sh
. "$SCRIPT_DIR/lib/validation.sh"
: "${HOST:?HOST required}"
: "${SSH_TARGET:?SSH_TARGET required}"
: "${SSH_KEY:=$HOME/.ssh/id_ed25519}"
: "${CLIPROXY_PORT:=8317}"
: "${NEWAPI_PORT:=8200}"
: "${CLIPROXY_KEY:?CLIPROXY_KEY required}"
: "${NEWAPI_TOKEN:?NEWAPI_TOKEN required}"
: "${MODEL:?MODEL required}"
: "${DB:=/root/newapi/data/one-api.db}"
require_safe_host HOST "$HOST"
require_safe_ssh_target SSH_TARGET "$SSH_TARGET"
require_port CLIPROXY_PORT "$CLIPROXY_PORT"
require_port NEWAPI_PORT "$NEWAPI_PORT"
require_safe_model_name MODEL "$MODEL"
require_absolute_safe_path DB "$DB"
payload='{"model":"'"$MODEL"'","messages":[{"role":"user","content":"reply OK"}],"stream":false,"max_tokens":4}'
echo "[1/3] Direct CLIProxyAPI :$CLIPROXY_PORT"
http_a=$(curl -s -o /tmp/_cpa.json -w "%{http_code}" \
-H "Authorization: Bearer $CLIPROXY_KEY" -H "Content-Type: application/json" \
--data "$payload" \
"http://$HOST:$CLIPROXY_PORT/v1/chat/completions")
echo " HTTP $http_a"
[ "$http_a" = "200" ] || { echo "FAIL direct CLIProxyAPI"; cat /tmp/_cpa.json; exit 1; }
echo "[2/3] Via NewAPI :$NEWAPI_PORT"
http_b=$(curl -s -o /tmp/_newapi.json -w "%{http_code}" \
-H "Authorization: Bearer $NEWAPI_TOKEN" -H "Content-Type: application/json" \
--data "$payload" \
"http://$HOST:$NEWAPI_PORT/v1/chat/completions")
echo " HTTP $http_b"
[ "$http_b" = "200" ] || { echo "FAIL via NewAPI"; cat /tmp/_newapi.json; exit 1; }
echo "[3/3] NewAPI billing log (latest 3)"
ssh -i "$SSH_KEY" -- "$SSH_TARGET" bash -s -- "$DB" <<'REMOTE'
set -euo pipefail
db=$1
sqlite3 -header -column "$db" <<'SQL'
SELECT id, model_name, prompt_tokens, completion_tokens, quota, request_id
FROM logs
ORDER BY id DESC
LIMIT 3;
SQL
REMOTE
echo OK both paths returned 200, latest billing log printed above