Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ TRUST_PROXY=false
JSON_BODY_LIMIT=32kb
USAGE_JSON_BODY_LIMIT=256kb
CLIPROXY_USAGE_LOG_DIR=/Users/wujianxiang/CodeSpace/CLIProxyAPI/logs/usage
SUB2API_PUBLIC_URL=http://localhost:18080
SHOP_LEGACY_KEY_ISSUANCE_DISABLED=false

# 生成邀请码:
# curl -X POST http://localhost:4173/api/admin/invites \
Expand Down
64 changes: 57 additions & 7 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,26 @@ ON CONFLICT(id) DO UPDATE SET
return db;
}

function escapeHtmlAttribute(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}

function normalizePublicHttpUrl(value, fallback) {
const raw = String(value || '').trim();
if (!raw) return fallback;
try {
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return fallback;
return url.toString().replace(/\/+$/, '');
} catch {
return fallback;
}
}

function createShopApp(options = {}) {
rateLimitBuckets.clear();
authPhoneFailureBuckets.clear();
Expand Down Expand Up @@ -877,6 +897,13 @@ function createShopApp(options = {}) {
const modelListFetch = options.modelListFetch || globalThis.fetch;
const cliproxyConfigPath = String(options.cliproxyConfigPath ?? process.env.CLIPROXY_CONFIG_PATH ?? '').trim();
const cliproxyConfigBackupDir = String(options.cliproxyConfigBackupDir ?? process.env.CLIPROXY_CONFIG_BACKUP_DIR ?? '').trim();
const legacyKeyIssuanceDisabled = String(
options.legacyKeyIssuanceDisabled ?? process.env.SHOP_LEGACY_KEY_ISSUANCE_DISABLED ?? ''
).trim().toLowerCase() === 'true';
const sub2apiPublicUrl = normalizePublicHttpUrl(
options.sub2apiPublicUrl ?? process.env.SUB2API_PUBLIC_URL,
'http://localhost:18080'
);
if (apiKeyEncryptionSecret) {
assertStrongSecret('SHOP_API_KEY_ENCRYPTION_SECRET', apiKeyEncryptionSecret, { production });
}
Expand Down Expand Up @@ -1479,6 +1506,14 @@ function createShopApp(options = {}) {
return Boolean(req.header('x-admin-token')) && !getAccountSessionToken(req);
}

function rejectLegacyKeyIssuanceWhenDisabled(req, res, next) {
if (!legacyKeyIssuanceDisabled) return next();
return res.status(410).json({
code: 'SHOP_LEGACY_KEY_ISSUANCE_DISABLED',
message: '旧 Shop API key 发放已停止,请使用 Sub2API 用户中心。'
});
}

function originFromURL(value) {
try {
return new URL(value).origin;
Expand Down Expand Up @@ -3236,6 +3271,12 @@ ORDER BY ak.created_at DESC, ak.api_key_preview ASC
}

const shopPublicPagePaths = new Set([
'/shop',
'/shop/',
'/shop/index.html',
'/shop/guide',
'/shop/guide/',
'/shop/guide/index.html',
'/shop/login',
'/shop/login/',
'/shop/login/index.html',
Expand Down Expand Up @@ -3271,6 +3312,15 @@ ORDER BY ak.created_at DESC, ak.api_key_preview ASC
return res.redirect(302, '/shop/account/');
}

function renderShopHomePage(req, res) {
const htmlPath = path.join(rootDir, 'shop/index.html');
const html = fs.readFileSync(htmlPath, 'utf8').replace(
/href="[^"]*"([^>]*\sdata-sub2api-link)/,
`href="${escapeHtmlAttribute(sub2apiPublicUrl)}"$1`
);
res.type('html').send(html);
}

function requireShopHtmlPage(req, res, next) {
const requestPath = path.posix.normalize(decodeURIComponent(req.path || '/'));
if (!isShopHtmlPagePath(requestPath) || shopPublicPagePaths.has(requestPath)) {
Expand Down Expand Up @@ -4144,7 +4194,7 @@ ORDER BY ak.created_at DESC, ak.api_key_preview ASC
return res.json(await accountModelOverview(req.account.phone));
});

app.post('/api/account/invites/redeem', limitRedeemApi, requireAccount, requireSameOrigin, requireAccountCsrf, (req, res) => {
app.post('/api/account/invites/redeem', limitRedeemApi, requireAccount, rejectLegacyKeyIssuanceWhenDisabled, requireSameOrigin, requireAccountCsrf, (req, res) => {
const code = String(req.body.code || '').trim().toUpperCase();
if (!code) {
return res.status(400).json({ code: 'INVALID_INVITE_CODE', message: '请输入邀请码。' });
Expand All @@ -4163,13 +4213,13 @@ ORDER BY ak.created_at DESC, ak.api_key_preview ASC
}
});

app.post('/api/admin/invites', limitAdminApi, requireAdminToken, (req, res) => {
app.post('/api/admin/invites', limitAdminApi, requireAdminToken, rejectLegacyKeyIssuanceWhenDisabled, (req, res) => {
const count = Math.min(Math.max(Number(req.body.count || 1), 1), 50);
const invites = createInvites(count);
return res.status(201).json({ invites });
});

app.post('/api/admin/api-keys', limitAdminApi, requireAdminToken, (req, res) => {
app.post('/api/admin/api-keys', limitAdminApi, requireAdminToken, rejectLegacyKeyIssuanceWhenDisabled, (req, res) => {
const apiKeys = Array.isArray(req.body.apiKeys)
? req.body.apiKeys.map((apiKey) => String(apiKey || '').trim()).filter(Boolean)
: [];
Expand Down Expand Up @@ -4301,13 +4351,13 @@ ORDER BY ak.created_at DESC, ak.api_key_preview ASC
return res.json({ charges });
});

app.post('/api/admin/session-invites', limitAdminApi, requireSameOrigin, requireAdminAccount, requireAccountCsrf, (req, res) => {
app.post('/api/admin/session-invites', limitAdminApi, requireSameOrigin, requireAdminAccount, rejectLegacyKeyIssuanceWhenDisabled, requireAccountCsrf, (req, res) => {
const count = Math.min(Math.max(Number(req.body.count || 1), 1), 50);
const invites = createInvites(count);
return res.status(201).json({ invites });
});

app.post('/api/admin/session-api-keys', limitAdminApi, requireSameOrigin, requireAdminAccount, requireAccountCsrf, (req, res) => {
app.post('/api/admin/session-api-keys', limitAdminApi, requireSameOrigin, requireAdminAccount, rejectLegacyKeyIssuanceWhenDisabled, requireAccountCsrf, (req, res) => {
const textKeys = String(req.body.apiKeysText || req.body.api_keys_text || '')
.split(/\r?\n/)
.map((apiKey) => apiKey.trim())
Expand Down Expand Up @@ -4478,7 +4528,7 @@ ORDER BY ak.created_at DESC, ak.api_key_preview ASC
}
});

app.post('/api/invites/redeem', limitRedeemApi, (req, res) => {
app.post('/api/invites/redeem', limitRedeemApi, rejectLegacyKeyIssuanceWhenDisabled, (req, res) => {
const phone = String(req.body.phone || '').trim();
const code = String(req.body.code || '').trim().toUpperCase();
if (!isPhone(phone)) {
Expand Down Expand Up @@ -4635,7 +4685,7 @@ ORDER BY ak.created_at DESC, ak.api_key_preview ASC
}
});

app.get(['/shop', '/shop/', '/shop/index.html'], redirectAccountHomePage);
app.get(['/shop', '/shop/', '/shop/index.html'], renderShopHomePage);
app.get(['/shop/query', '/shop/query/', '/shop/query/index.html'], redirectQueryPage);
app.get(['/shop/login', '/shop/login/', '/shop/login/index.html'], (req, res, next) => next());
app.get(['/shop/register', '/shop/register/', '/shop/register/index.html'], (req, res, next) => next());
Expand Down
99 changes: 19 additions & 80 deletions shop/guide/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,112 +22,51 @@
</header>
<main class="flex-1 max-w-[960px] mx-auto px-6 md:px-12 py-14 md:py-20 w-full">
<section class="mb-8">
<p class="text-xs uppercase tracking-[0.28em] text-text-muted dark:text-dark-text-muted">Usage</p>
<h1 class="mt-4 font-display text-4xl md:text-6xl">Codex 配置使用方法</h1>
<p class="mt-5 text-text-muted dark:text-dark-text-muted leading-relaxed">兑换完成后,把下面的 `sk-xx` 替换成你获得的 API key,再把整段文字发给另一个 AI,让它帮你修改 Codex 配置文件。公网请求必须使用 Authorization Bearer 认证。</p>
<p class="text-xs uppercase tracking-[0.28em] text-text-muted dark:text-dark-text-muted">Sub2API</p>
<h1 class="mt-4 font-display text-4xl md:text-6xl">Sub2API 配置使用方法</h1>
<p class="mt-5 text-text-muted dark:text-dark-text-muted leading-relaxed">朋友只使用 Sub2API 用户 key 和 Sub2API Base URL。不要使用 CLIProxyAPI 的内部 key;CLIProxyAPI 只保留在内网给 Sub2API 作为上游。</p>
</section>
<section class="border border-border-subtle dark:border-dark-border rounded-lg bg-white dark:bg-dark-card p-5 md:p-6">
<pre class="max-h-[680px] overflow-auto whitespace-pre-wrap rounded-md border border-border-subtle dark:border-dark-border bg-background-soft dark:bg-dark-surface p-4 text-sm leading-7 text-primary dark:text-dark-text"><code>把下面这段文字发给另外一个 AI,让它根据文字内容去修改 Codex 配置目录里的 config.toml 和 auth.json。改好之后重启 Codex 就能使用。

用这个 API key:sk-xx
export OPENAI_BASE_URL="https://你的-sub2api-域名/v1"
export OPENAI_API_KEY="Sub2API 分配给你的 API key"

# Codex CLI 使用公网 API 配置说明(中文)
# Codex CLI 使用 Sub2API 配置说明

本文档用于把本地 Codex CLI 改为使用公网 API。请严格按下面的鉴权方式配置:请求必须通过 HTTP Header `Authorization: Bearer &lt;API Key&gt;` 发送。不要把 API key 放到 URL、query 参数、x-api-key 或其他 header 里。
请求统一发到 Sub2API,不要直接访问 CLIProxyAPI。认证方式只使用 HTTP Header

## 目标配置
Authorization: Bearer $OPENAI_API_KEY

- Base URL:https://api.aaccx.pw/v1
- API Key:sk-xx
- 认证方式:Authorization: Bearer sk-xx

## 适用场景

适用于 Windows / Linux / macOS 环境下,Codex CLI 使用 ~/.codex/config.toml 与 ~/.codex/auth.json 作为本地配置文件的情况。

## 修改步骤

### 1. 定位 Codex 配置目录

Windows 通常在:

C:\Users\&lt;你的用户名&gt;\.codex

Linux / macOS 通常在:

~/.codex

对应文件为:

- ~/.codex/config.toml
- ~/.codex/auth.json

### 2. 修改 config.toml

打开 config.toml,确保(或新增)以下片段,其中 base_url 改为公网地址。model 可以保留用户原来的可用模型;下面只是示例:
## config.toml

```toml
model_provider = "cliproxyapi"
model_provider = "sub2api"
model = "gpt-5.4"
model_reasoning_effort = "medium"

[model_providers.cliproxyapi]
name = "cliproxyapi"
base_url = "https://api.aaccx.pw/v1"
[model_providers.sub2api]
name = "sub2api"
base_url = "https://你的-sub2api-域名/v1"
wire_api = "responses"
```

如果文件里原来是:

base_url = "http://127.0.0.1:8317/v1"

请替换为:

base_url = "https://api.aaccx.pw/v1"

### 3. 修改 auth.json

打开 auth.json,将 OPENAI_API_KEY 设置为拿到的 API key。Codex CLI 会读取这个值,并在请求时组装成 `Authorization: Bearer sk-xx`:
## auth.json

```json
{
"OPENAI_API_KEY": "sk-xx"
"OPENAI_API_KEY": "Sub2API 分配给你的 API key"
}
```

### 4. Windows 验证方式

可用 PowerShell 快速验证:

```powershell
$base='https://api.aaccx.pw/v1'
$key='sk-xx'
$headers=@{ Authorization = "Bearer $key" }
Invoke-RestMethod -Method Get -Uri "$base/models" -Headers $headers -TimeoutSec 30
```

成功时会返回模型列表(data 数组)。

如果这里返回 401 Invalid API key,优先检查:

- auth.json 里是否仍然是 sk-xx、占位 key 或旧 key。
- API key 前后是否多了空格、换行或中文标点。
- 请求头是否是 Authorization: Bearer sk-xx。
- 不要使用 x-api-key,不要把 key 放在 URL 后面。

### 5. Linux / macOS 验证方式
## 验证

```bash
curl -sS https://api.aaccx.pw/v1/models \
-H "Authorization: Bearer sk-xx"
curl -sS "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY"
```

## 注意事项

- 该 API Key 属于敏感信息,建议只在受控范围内使用。
- 不要将含真实密钥的 auth.json 上传到公开仓库。
- 如果未来更换 URL 或 Key,只需按本文档替换同一位置字段即可。
- 修改完成后必须重启 Codex 或重新打开终端,让配置重新加载。</code></pre>
不要使用 CLIProxyAPI 的内部 key;朋友只使用 Sub2API 用户 key。不要把 API key 放到 URL、query 参数、x-api-key 或其他 header 里。</code></pre>
</section>
</main>
</body>
Expand Down
29 changes: 14 additions & 15 deletions shop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,40 +75,39 @@
<section class="max-w-[1200px] mx-auto px-6 md:px-12 py-16 md:py-24">
<div class="grid lg:grid-cols-[1.05fr_0.95fr] gap-10 lg:gap-16 items-start">
<div class="pt-4">
<p class="text-xs uppercase tracking-[0.28em] text-text-muted dark:text-dark-text-muted">Codex usage billing</p>
<h1 class="mt-5 font-display text-5xl md:text-7xl leading-[1.05] text-primary dark:text-dark-text">Codex<br/><span class="italic text-text-muted dark:text-dark-text-muted">按量计费</span></h1>
<p class="mt-6 max-w-xl text-lg font-light leading-relaxed text-text-muted dark:text-dark-text-muted">私下开通后,我会发给你一个邀请码。登录后输入邀请码即可兑换 API key;每次请求都会按实际 token 记录用量,登录后可以查看自己的用量和账单。</p>
<div class="mt-10 grid grid-cols-1 sm:grid-cols-3 gap-3 max-w-[640px]">
<a class="btn-primary h-14 px-4 text-center justify-center whitespace-nowrap" href="/shop/login/">登录账户</a>
<a class="btn-secondary h-14 px-4 text-center justify-center whitespace-nowrap dark:bg-dark-card dark:border-dark-border dark:text-dark-text" href="/shop/redeem/">兑换 API key</a>
<a class="btn-secondary h-14 px-4 text-center justify-center whitespace-nowrap border-gray-200 bg-gray-100 text-primary hover:bg-gray-200 dark:border-dark-border dark:bg-dark-card dark:text-dark-text dark:hover:bg-dark-border" href="/shop/guide/">使用方法</a>
<p class="text-xs uppercase tracking-[0.28em] text-text-muted dark:text-dark-text-muted">Sub2API gateway</p>
<h1 class="mt-5 font-display text-5xl md:text-7xl leading-[1.05] text-primary dark:text-dark-text">Codex<br/><span class="italic text-text-muted dark:text-dark-text-muted">统一入口</span></h1>
<p class="mt-6 max-w-xl text-lg font-light leading-relaxed text-text-muted dark:text-dark-text-muted">新的 API key、套餐、余额和用量记录都在 Sub2API 中管理。这里保留使用说明和入口说明,正式调用请使用 Sub2API 分配给你的 Base URL 和 API key。</p>
<div class="mt-10 grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-[520px]">
<a class="btn-primary h-14 px-4 text-center justify-center whitespace-nowrap" href="/shop/guide/">查看使用方法</a>
<a class="btn-secondary h-14 px-4 text-center justify-center whitespace-nowrap dark:bg-dark-card dark:border-dark-border dark:text-dark-text" href="http://localhost:18080" data-sub2api-link>打开 Sub2API</a>
</div>
</div>
<div class="grid gap-4">
<div class="border border-border-subtle dark:border-dark-border rounded-lg bg-white dark:bg-dark-card p-5">
<div class="flex items-start gap-4">
<span class="material-symbols-outlined text-3xl text-primary dark:text-dark-text">payments</span>
<span class="material-symbols-outlined text-3xl text-primary dark:text-dark-text">key</span>
<div>
<h2 class="font-display text-2xl text-primary dark:text-dark-text">私下开通</h2>
<p class="mt-2 text-sm leading-relaxed text-text-muted dark:text-dark-text-muted">确认开通后,我会生成一个邀请码并发给你。</p>
<h2 class="font-display text-2xl text-primary dark:text-dark-text">Sub2API 发 Key</h2>
<p class="mt-2 text-sm leading-relaxed text-text-muted dark:text-dark-text-muted">朋友使用 Sub2API 中生成的 API key,不再直接使用 CLIProxyAPI key。</p>
</div>
</div>
</div>
<div class="border border-border-subtle dark:border-dark-border rounded-lg bg-white dark:bg-dark-card p-5">
<div class="flex items-start gap-4">
<span class="material-symbols-outlined text-3xl text-primary dark:text-dark-text">password</span>
<span class="material-symbols-outlined text-3xl text-primary dark:text-dark-text">route</span>
<div>
<h2 class="font-display text-2xl text-primary dark:text-dark-text">兑换 API key</h2>
<p class="mt-2 text-sm leading-relaxed text-text-muted dark:text-dark-text-muted">登录账户后输入邀请码,领取用于请求的 API key。</p>
<h2 class="font-display text-2xl text-primary dark:text-dark-text">本地账号池</h2>
<p class="mt-2 text-sm leading-relaxed text-text-muted dark:text-dark-text-muted">Sub2API 会把请求转发到本机 CLIProxyAPI,再由 CLIProxyAPI 使用本地账号池处理。</p>
</div>
</div>
</div>
<div class="border border-border-subtle dark:border-dark-border rounded-lg bg-white dark:bg-dark-card p-5">
<div class="flex items-start gap-4">
<span class="material-symbols-outlined text-3xl text-primary dark:text-dark-text">monitoring</span>
<div>
<h2 class="font-display text-2xl text-primary dark:text-dark-text">按量记录</h2>
<p class="mt-2 text-sm leading-relaxed text-text-muted dark:text-dark-text-muted">请求用量会按 token 汇总,登录后查看自己的使用记录。</p>
<h2 class="font-display text-2xl text-primary dark:text-dark-text">用量归 Sub2API</h2>
<p class="mt-2 text-sm leading-relaxed text-text-muted dark:text-dark-text-muted">新入口的余额、订阅和用量记录以 Sub2API 为准。</p>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion styles/site.css

Large diffs are not rendered by default.

Loading
Loading