forked from boos/BoosAPI
[codex] rename site API request wording (#423)
* rename site api request wording * replace real site example domains
This commit is contained in:
+1
-1
@@ -28,7 +28,7 @@ npm run docs:build
|
|||||||
|------|--------|------------|
|
|------|--------|------------|
|
||||||
| 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 |
|
| 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 |
|
||||||
| 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 |
|
| 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 |
|
||||||
| 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、官方预设、AI 请求地址池、自动识别规则变化时 |
|
| 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、官方预设、API 请求地址池、自动识别规则变化时 |
|
||||||
| Provider OAuth 授权 | [OAuth 管理](./oauth.md) | 支持的 OAuth provider、授权流程、回调方式或自动重绑能力变化时 |
|
| Provider OAuth 授权 | [OAuth 管理](./oauth.md) | 支持的 OAuth provider、授权流程、回调方式或自动重绑能力变化时 |
|
||||||
| 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 |
|
| 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 |
|
||||||
| K3s / Helm 高级升级面板 | [K3s 更新中心(高级)](./k3s-update-center.md) | 需要说明谁适合使用更新中心、helper 怎么配、K3s/Helm 发布链路怎么接入时 |
|
| K3s / Helm 高级升级面板 | [K3s 更新中心(高级)](./k3s-update-center.md) | 需要说明谁适合使用更新中心、helper 怎么配、K3s/Helm 发布链路怎么接入时 |
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ npm run dev
|
|||||||
- 平台通常可自动检测;如果因为防护页、反向代理或特殊路径导致检测失败,再手动选择。
|
- 平台通常可自动检测;如果因为防护页、反向代理或特殊路径导致检测失败,再手动选择。
|
||||||
- 可选是否开启系统代理,方便国内机器访问国外中转站。
|
- 可选是否开启系统代理,方便国内机器访问国外中转站。
|
||||||
- 可选站点权重,站点权重越大,路由将更加频繁使用这个站点的模型。
|
- 可选站点权重,站点权重越大,路由将更加频繁使用这个站点的模型。
|
||||||
- 如果这个站点的控制台 URL 和真实 AI 请求地址不同,不要直接把主站点 URL 改掉,而是在表单下方补「AI 请求地址池」。
|
- 如果这个站点的控制台 URL 和真实 API 请求地址不同,不要直接把主站点 URL 改掉,而是在表单下方补「API 请求地址池」。
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 通用平台常见写法是填控制台地址或 provider base URL;但如果你选的是**官方预设**,请保留预设自动带出的完整路径,哪怕它本来就包含 `/v1`、`/anthropic` 或 `/api/coding/...`。
|
> 通用平台常见写法是填控制台地址或 provider base URL;但如果你选的是**官方预设**,请保留预设自动带出的完整路径,哪怕它本来就包含 `/v1`、`/anthropic` 或 `/api/coding/...`。
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ features:
|
|||||||
details: 从部署到第一条请求,按步骤完成最小可用环境搭建。
|
details: 从部署到第一条请求,按步骤完成最小可用环境搭建。
|
||||||
link: /getting-started
|
link: /getting-started
|
||||||
- title: 上游接入
|
- title: 上游接入
|
||||||
details: 按平台类型、官方预设和 AI 请求地址池的现状,快速判断站点该怎么接。
|
details: 按平台类型、官方预设和 API 请求地址池的现状,快速判断站点该怎么接。
|
||||||
link: /upstream-integration
|
link: /upstream-integration
|
||||||
- title: OAuth 管理
|
- title: OAuth 管理
|
||||||
details: 直接接入 Codex、Claude、Gemini CLI、Antigravity 等 provider 授权账号。
|
details: 直接接入 Codex、Claude、Gemini CLI、Antigravity 等 provider 授权账号。
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ curl -sS "${METAPI_ADMIN_BASE_URL}/api/sites/detect" \
|
|||||||
| `isPinned` | `boolean` | 否 | 是否置顶 |
|
| `isPinned` | `boolean` | 否 | 是否置顶 |
|
||||||
| `sortOrder` | `number` | 否 | 排序值,非负整数 |
|
| `sortOrder` | `number` | 否 | 排序值,非负整数 |
|
||||||
| `globalWeight` | `number` | 否 | 站点全局权重,必须为正数 |
|
| `globalWeight` | `number` | 否 | 站点全局权重,必须为正数 |
|
||||||
| `apiEndpoints` | `array` | 否 | 仅用于 AI 请求的数据面地址池;不替代主站点 URL |
|
| `apiEndpoints` | `array` | 否 | 仅用于 API 请求的数据面地址池;不替代主站点 URL |
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
|
|||||||
@@ -443,18 +443,18 @@ CPA 这类站点推荐直接使用 **API Key**:
|
|||||||
2. Metapi 会向该 URL 发送 POST 请求执行签到
|
2. Metapi 会向该 URL 发送 POST 请求执行签到
|
||||||
3. 请求头自动携带账号凭证
|
3. 请求头自动携带账号凭证
|
||||||
|
|
||||||
### AI 请求地址池
|
### API 请求地址池
|
||||||
|
|
||||||
现在不少站点已经不是“一个 URL 同时负责后台面板和 `/v1/*` AI 请求”。
|
现在不少站点已经不是“一个 URL 同时负责后台面板和 `/v1/*` API 请求”。
|
||||||
|
|
||||||
Metapi 当前支持把这两层拆开:
|
Metapi 当前支持把这两层拆开:
|
||||||
|
|
||||||
- **主站点 URL**:用于登录、签到、后台接口、系统访问令牌管理
|
- **主站点 URL**:用于登录、签到、后台接口、系统访问令牌管理
|
||||||
- **AI 请求地址池**:用于真正转发 `/v1/*`、`/chat/completions`、`/responses` 等模型请求
|
- **API 请求地址池**:用于真正转发 `/v1/*`、`/chat/completions`、`/responses` 等模型请求
|
||||||
|
|
||||||
适合填写 AI 请求地址池的场景:
|
适合填写 API 请求地址池的场景:
|
||||||
|
|
||||||
1. 控制台地址和 AI 网关地址不同
|
1. 控制台地址和 API 网关地址不同
|
||||||
2. 同一个站点有多个 API host,需要健康切换
|
2. 同一个站点有多个 API host,需要健康切换
|
||||||
3. 你想保留面板地址不变,只把模型流量导向专用网关
|
3. 你想保留面板地址不变,只把模型流量导向专用网关
|
||||||
|
|
||||||
@@ -462,7 +462,7 @@ Metapi 当前支持把这两层拆开:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
主站点 URL = 控制面
|
主站点 URL = 控制面
|
||||||
AI 请求地址池 = 数据面
|
API 请求地址池 = 数据面
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -572,7 +572,7 @@ Metapi 会定期抓取已接入站点的公告,并在首次发现时写入站
|
|||||||
- 官方预设则相反,**不要**把预设自动带出的 `/v1` / `/anthropic` / `/api/coding/...` 手动删掉
|
- 官方预设则相反,**不要**把预设自动带出的 `/v1` / `/anthropic` / `/api/coding/...` 手动删掉
|
||||||
2. 网络不通(检查代理配置或防火墙)
|
2. 网络不通(检查代理配置或防火墙)
|
||||||
3. 凭证无效(重新验证账号密码、Session 或 API Key)
|
3. 凭证无效(重新验证账号密码、Session 或 API Key)
|
||||||
4. 主站点 URL 和真实 AI 请求地址不同,但还没配置 AI 请求地址池
|
4. 主站点 URL 和真实 API 请求地址不同,但还没配置 API 请求地址池
|
||||||
|
|
||||||
**解决方法:**
|
**解决方法:**
|
||||||
|
|
||||||
@@ -676,7 +676,7 @@ Metapi 会定期抓取已接入站点的公告,并在首次发现时写入站
|
|||||||
- **每周检查:** 账号健康状态、余额预警
|
- **每周检查:** 账号健康状态、余额预警
|
||||||
- **每月清理:** 禁用长期不可用的站点和账号
|
- **每月清理:** 禁用长期不可用的站点和账号
|
||||||
- **凭证轮换:** 定期更新 Access Token 和 API Key
|
- **凭证轮换:** 定期更新 Access Token 和 API Key
|
||||||
- **官方预设站点:** 新增模型时记得补充推荐模型和 AI 请求地址池
|
- **官方预设站点:** 新增模型时记得补充推荐模型和 API 请求地址池
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'Nihao Panel',
|
name: 'Nihao Panel',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
await db.insert(schema.siteApiEndpoints).values({
|
await db.insert(schema.siteApiEndpoints).values({
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api.nih.cc',
|
url: 'https://api.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
}).run();
|
}).run();
|
||||||
@@ -90,7 +90,7 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
tokenType: 'apikey',
|
tokenType: 'apikey',
|
||||||
modelCount: 1,
|
modelCount: 1,
|
||||||
});
|
});
|
||||||
expect(getModelsMock).toHaveBeenCalledWith('https://api.nih.cc', 'sk-nihao', undefined);
|
expect(getModelsMock).toHaveBeenCalledWith('https://api.example.com', 'sk-nihao', undefined);
|
||||||
expect(verifyTokenMock).not.toHaveBeenCalled();
|
expect(verifyTokenMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'Nihao Pool',
|
name: 'Nihao Pool',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
@@ -109,13 +109,13 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
await db.insert(schema.siteApiEndpoints).values([
|
await db.insert(schema.siteApiEndpoints).values([
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-a.nih.cc',
|
url: 'https://api-a.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-b.nih.cc',
|
url: 'https://api-b.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
},
|
},
|
||||||
@@ -137,12 +137,12 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
tokenType: 'apikey',
|
tokenType: 'apikey',
|
||||||
modelCount: 1,
|
modelCount: 1,
|
||||||
});
|
});
|
||||||
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-a.nih.cc', 'sk-rotate', undefined);
|
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-a.example.com', 'sk-rotate', undefined);
|
||||||
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-b.nih.cc', 'sk-rotate', undefined);
|
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-b.example.com', 'sk-rotate', undefined);
|
||||||
|
|
||||||
const endpoints = await db.select().from(schema.siteApiEndpoints).all();
|
const endpoints = await db.select().from(schema.siteApiEndpoints).all();
|
||||||
const firstEndpoint = endpoints.find((item) => item.url === 'https://api-a.nih.cc');
|
const firstEndpoint = endpoints.find((item) => item.url === 'https://api-a.example.com');
|
||||||
const secondEndpoint = endpoints.find((item) => item.url === 'https://api-b.nih.cc');
|
const secondEndpoint = endpoints.find((item) => item.url === 'https://api-b.example.com');
|
||||||
expect(firstEndpoint?.cooldownUntil).toBeTruthy();
|
expect(firstEndpoint?.cooldownUntil).toBeTruthy();
|
||||||
expect(firstEndpoint?.lastFailureReason).toContain('HTTP 502');
|
expect(firstEndpoint?.lastFailureReason).toContain('HTTP 502');
|
||||||
expect(secondEndpoint?.lastSelectedAt).toBeTruthy();
|
expect(secondEndpoint?.lastSelectedAt).toBeTruthy();
|
||||||
@@ -158,14 +158,14 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'Nihao Panel',
|
name: 'Nihao Panel',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
await db.insert(schema.siteApiEndpoints).values({
|
await db.insert(schema.siteApiEndpoints).values({
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api.nih.cc',
|
url: 'https://api.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
}).run();
|
}).run();
|
||||||
@@ -186,7 +186,7 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
tokenType: 'session',
|
tokenType: 'session',
|
||||||
apiToken: 'sk-derived',
|
apiToken: 'sk-derived',
|
||||||
});
|
});
|
||||||
expect(verifyTokenMock).toHaveBeenCalledWith('https://nih.cc', 'session-token', undefined);
|
expect(verifyTokenMock).toHaveBeenCalledWith('https://console.example.com', 'session-token', undefined);
|
||||||
expect(getModelsMock).not.toHaveBeenCalled();
|
expect(getModelsMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,14 +195,14 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'Nihao Panel',
|
name: 'Nihao Panel',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
await db.insert(schema.siteApiEndpoints).values({
|
await db.insert(schema.siteApiEndpoints).values({
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api.nih.cc',
|
url: 'https://api.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
}).run();
|
}).run();
|
||||||
@@ -221,7 +221,7 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
expect(response.json()).toMatchObject({
|
expect(response.json()).toMatchObject({
|
||||||
tokenType: 'apikey',
|
tokenType: 'apikey',
|
||||||
});
|
});
|
||||||
expect(getModelsMock).toHaveBeenCalledWith('https://api.nih.cc', 'sk-nihao-create', undefined);
|
expect(getModelsMock).toHaveBeenCalledWith('https://api.example.com', 'sk-nihao-create', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rotates API key account creation across configured ai endpoints after a retryable failure', async () => {
|
it('rotates API key account creation across configured ai endpoints after a retryable failure', async () => {
|
||||||
@@ -231,7 +231,7 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'Nihao Create Pool',
|
name: 'Nihao Create Pool',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
@@ -239,13 +239,13 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
await db.insert(schema.siteApiEndpoints).values([
|
await db.insert(schema.siteApiEndpoints).values([
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-create-a.nih.cc',
|
url: 'https://api-create-a.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-create-b.nih.cc',
|
url: 'https://api-create-b.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
},
|
},
|
||||||
@@ -265,7 +265,7 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
|||||||
expect(response.json()).toMatchObject({
|
expect(response.json()).toMatchObject({
|
||||||
tokenType: 'apikey',
|
tokenType: 'apikey',
|
||||||
});
|
});
|
||||||
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.nih.cc', 'sk-nihao-create-rotate', undefined);
|
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.example.com', 'sk-nihao-create-rotate', undefined);
|
||||||
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.nih.cc', 'sk-nihao-create-rotate', undefined);
|
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.example.com', 'sk-nihao-create-rotate', undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ describe('chat proxy site api endpoint rotation', () => {
|
|||||||
it('rotates to the next configured ai endpoint for retryable /v1/chat/completions failures', async () => {
|
it('rotates to the next configured ai endpoint for retryable /v1/chat/completions failures', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'nihao-panel',
|
name: 'nihao-panel',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'openai',
|
platform: 'openai',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
@@ -168,13 +168,13 @@ describe('chat proxy site api endpoint rotation', () => {
|
|||||||
await db.insert(schema.siteApiEndpoints).values([
|
await db.insert(schema.siteApiEndpoints).values([
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-a.nih.cc',
|
url: 'https://api-a.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-b.nih.cc',
|
url: 'https://api-b.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
},
|
},
|
||||||
@@ -222,10 +222,10 @@ describe('chat proxy site api endpoint rotation', () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()?.choices?.[0]?.message?.content).toBe('ok via api-b');
|
expect(response.json()?.choices?.[0]?.message?.content).toBe('ok via api-b');
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.nih.cc/v1/chat/completions');
|
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.example.com/v1/chat/completions');
|
||||||
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-a.nih.cc/v1/responses');
|
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-a.example.com/v1/responses');
|
||||||
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toBe('https://api-a.nih.cc/v1/messages');
|
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toBe('https://api-a.example.com/v1/messages');
|
||||||
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toBe('https://api-b.nih.cc/v1/chat/completions');
|
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toBe('https://api-b.example.com/v1/chat/completions');
|
||||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||||
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -235,7 +235,7 @@ describe('chat proxy site api endpoint rotation', () => {
|
|||||||
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
|
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
|
||||||
.all();
|
.all();
|
||||||
expect(storedEndpoints[0]).toMatchObject({
|
expect(storedEndpoints[0]).toMatchObject({
|
||||||
url: 'https://api-a.nih.cc',
|
url: 'https://api-a.example.com',
|
||||||
lastFailureReason: 'HTTP 502: [upstream:/v1/messages] Upstream returned HTTP 502: bad gateway via messages',
|
lastFailureReason: 'HTTP 502: [upstream:/v1/messages] Upstream returned HTTP 502: bad gateway via messages',
|
||||||
});
|
});
|
||||||
expect(storedEndpoints[0]?.cooldownUntil).toBeTruthy();
|
expect(storedEndpoints[0]?.cooldownUntil).toBeTruthy();
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe('/v1/completions site api endpoint rotation', () => {
|
|||||||
it('cools down a retryable failed endpoint and retries the next endpoint within the same site', async () => {
|
it('cools down a retryable failed endpoint and retries the next endpoint within the same site', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'nihao-panel',
|
name: 'nihao-panel',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
@@ -138,13 +138,13 @@ describe('/v1/completions site api endpoint rotation', () => {
|
|||||||
await db.insert(schema.siteApiEndpoints).values([
|
await db.insert(schema.siteApiEndpoints).values([
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-a.nih.cc',
|
url: 'https://api-a.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api-b.nih.cc',
|
url: 'https://api-b.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
},
|
},
|
||||||
@@ -194,8 +194,8 @@ describe('/v1/completions site api endpoint rotation', () => {
|
|||||||
object: 'text_completion',
|
object: 'text_completion',
|
||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.nih.cc/v1/completions');
|
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.example.com/v1/completions');
|
||||||
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-b.nih.cc/v1/completions');
|
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-b.example.com/v1/completions');
|
||||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||||
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -205,12 +205,12 @@ describe('/v1/completions site api endpoint rotation', () => {
|
|||||||
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
|
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
|
||||||
.all();
|
.all();
|
||||||
expect(storedEndpoints[0]).toMatchObject({
|
expect(storedEndpoints[0]).toMatchObject({
|
||||||
url: 'https://api-a.nih.cc',
|
url: 'https://api-a.example.com',
|
||||||
lastFailureReason: 'HTTP 502: bad gateway',
|
lastFailureReason: 'HTTP 502: bad gateway',
|
||||||
});
|
});
|
||||||
expect(storedEndpoints[0]?.cooldownUntil).toBeTruthy();
|
expect(storedEndpoints[0]?.cooldownUntil).toBeTruthy();
|
||||||
expect(storedEndpoints[1]).toMatchObject({
|
expect(storedEndpoints[1]).toMatchObject({
|
||||||
url: 'https://api-b.nih.cc',
|
url: 'https://api-b.example.com',
|
||||||
});
|
});
|
||||||
expect(storedEndpoints[1]?.lastSelectedAt).toBeTruthy();
|
expect(storedEndpoints[1]?.lastSelectedAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ describe('responses websocket transport', () => {
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
status: 408,
|
status: 408,
|
||||||
error: {
|
error: {
|
||||||
message: '当前站点的 AI 请求地址均不可用',
|
message: '当前站点的 API 请求地址均不可用',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(0);
|
expect(fetchMock).toHaveBeenCalledTimes(0);
|
||||||
|
|||||||
@@ -138,21 +138,21 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
it('uses the configured ai endpoint for direct model discovery credentials', async () => {
|
it('uses the configured ai endpoint for direct model discovery credentials', async () => {
|
||||||
getApiTokenMock.mockResolvedValue(null);
|
getApiTokenMock.mockResolvedValue(null);
|
||||||
getModelsMock.mockImplementation(async (baseUrl: string, token: string) => (
|
getModelsMock.mockImplementation(async (baseUrl: string, token: string) => (
|
||||||
baseUrl === 'https://api.nih.cc' && token === 'session-token'
|
baseUrl === 'https://api.example.com' && token === 'session-token'
|
||||||
? ['gpt-4.1']
|
? ['gpt-4.1']
|
||||||
: []
|
: []
|
||||||
));
|
));
|
||||||
|
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'nihao-panel',
|
name: 'nihao-panel',
|
||||||
url: 'https://nih.cc',
|
url: 'https://console.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
await db.insert(schema.siteApiEndpoints).values({
|
await db.insert(schema.siteApiEndpoints).values({
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
url: 'https://api.nih.cc',
|
url: 'https://api.example.com',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
}).run();
|
}).run();
|
||||||
@@ -175,7 +175,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
modelCount: 1,
|
modelCount: 1,
|
||||||
modelsPreview: ['gpt-4.1'],
|
modelsPreview: ['gpt-4.1'],
|
||||||
});
|
});
|
||||||
expect(getModelsMock).toHaveBeenCalledWith('https://api.nih.cc', 'session-token', undefined);
|
expect(getModelsMock).toHaveBeenCalledWith('https://api.example.com', 'session-token', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates discovered model names before writing availability rows', async () => {
|
it('deduplicates discovered model names before writing availability rows', async () => {
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function requireSiteApiBaseUrl(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const baseUrl = await resolveSiteApiBaseUrl(site, now);
|
const baseUrl = await resolveSiteApiBaseUrl(site, now);
|
||||||
if (baseUrl) return baseUrl;
|
if (baseUrl) return baseUrl;
|
||||||
throw new Error('当前站点的 AI 请求地址均不可用');
|
throw new Error('当前站点的 API 请求地址均不可用');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recordSiteApiEndpointFailure(
|
export async function recordSiteApiEndpointFailure(
|
||||||
@@ -258,11 +258,11 @@ export async function runWithSiteApiEndpointPool<T>(
|
|||||||
const target = await selectSiteApiEndpointTarget(site);
|
const target = await selectSiteApiEndpointTarget(site);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
if (lastError) throw lastError;
|
if (lastError) throw lastError;
|
||||||
throw new Error('当前站点的 AI 请求地址均不可用');
|
throw new Error('当前站点的 API 请求地址均不可用');
|
||||||
}
|
}
|
||||||
if (target.endpointId && attemptedEndpointIds.has(target.endpointId)) {
|
if (target.endpointId && attemptedEndpointIds.has(target.endpointId)) {
|
||||||
if (lastError) throw lastError;
|
if (lastError) throw lastError;
|
||||||
throw new Error('当前站点的 AI 请求地址均不可用');
|
throw new Error('当前站点的 API 请求地址均不可用');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ export default function Sites() {
|
|||||||
}
|
}
|
||||||
const serializedApiEndpoints = serializeSiteApiEndpoints(form.apiEndpoints);
|
const serializedApiEndpoints = serializeSiteApiEndpoints(form.apiEndpoints);
|
||||||
if (!serializedApiEndpoints.valid) {
|
if (!serializedApiEndpoints.valid) {
|
||||||
toast.error(serializedApiEndpoints.error || 'AI 请求地址格式不正确');
|
toast.error(serializedApiEndpoints.error || 'API 请求地址格式不正确');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,7 +1028,7 @@ export default function Sites() {
|
|||||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||||
<input
|
<input
|
||||||
data-testid="site-primary-url-input"
|
data-testid="site-primary-url-input"
|
||||||
placeholder="站点 URL(面板/登录/签到地址,如 https://nih.cc)"
|
placeholder="站点 URL(面板/登录/签到地址,如 https://console.example.com)"
|
||||||
value={form.url}
|
value={form.url}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -1102,7 +1102,7 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||||
主站点 URL 用于登录、签到、面板接口和系统访问令牌管理;如果 AI 请求地址和主站点不同,请在下面的 AI 请求地址池里填写。
|
主站点 URL 用于登录、签到、面板接口和系统访问令牌管理;如果 API 请求地址和主站点不同,请在下面的 API 请求地址池里填写。
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -1117,7 +1117,7 @@ export default function Sites() {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
AI 请求地址池
|
API 请求地址池
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1125,7 +1125,7 @@ export default function Sites() {
|
|||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)' }}
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
+ 添加 AI 地址
|
+ 添加 API 地址
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
|
||||||
@@ -1146,7 +1146,7 @@ export default function Sites() {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||||
<input
|
<input
|
||||||
placeholder="AI 请求地址(如 https://api.nih.cc)"
|
placeholder="API 请求地址(如 https://api.example.com)"
|
||||||
value={endpoint.url}
|
value={endpoint.url}
|
||||||
onChange={(e) => updateApiEndpointRow(index, { url: e.target.value })}
|
onChange={(e) => updateApiEndpointRow(index, { url: e.target.value })}
|
||||||
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
||||||
@@ -1405,7 +1405,7 @@ export default function Sites() {
|
|||||||
style={formInputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
这里只是 HTTP/SOCKS 代理地址,不是上游 AI API 地址。填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
|
这里只是 HTTP/SOCKS 代理地址,不是上游 API 请求地址。填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label style={{
|
<label style={{
|
||||||
@@ -1556,7 +1556,7 @@ export default function Sites() {
|
|||||||
) : '-'}
|
) : '-'}
|
||||||
/>
|
/>
|
||||||
<MobileField
|
<MobileField
|
||||||
label="AI 请求地址"
|
label="API 请求地址"
|
||||||
stacked
|
stacked
|
||||||
value={(
|
value={(
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
@@ -1716,7 +1716,7 @@ export default function Sites() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={`badge ${getConfiguredSiteApiEndpoints(site).length > 0 ? 'badge-warning' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
<span className={`badge ${getConfiguredSiteApiEndpoints(site).length > 0 ? 'badge-warning' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
AI 地址: {buildSiteApiEndpointSummary(site)}
|
API 地址: {buildSiteApiEndpointSummary(site)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
])).toEqual({
|
])).toEqual({
|
||||||
valid: false,
|
valid: false,
|
||||||
apiEndpoints: [],
|
apiEndpoints: [],
|
||||||
error: 'AI 请求地址 "https://api.example.com" 重复了',
|
error: 'API 请求地址 "https://api.example.com" 重复了',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function serializeSiteApiEndpoints(fields: SiteApiEndpointField[]): {
|
|||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
apiEndpoints: [],
|
apiEndpoints: [],
|
||||||
error: `AI 请求地址 "${normalizedUrl}" 重复了`,
|
error: `API 请求地址 "${normalizedUrl}" 重复了`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
seen.add(normalizedUrl);
|
seen.add(normalizedUrl);
|
||||||
|
|||||||
@@ -10,4 +10,21 @@ describe('Sites centered modal adoption', () => {
|
|||||||
expect(source).toContain('<CenteredModal');
|
expect(source).toContain('<CenteredModal');
|
||||||
expect(source).not.toContain('editorPresence.shouldRender && activeEditor && (');
|
expect(source).not.toContain('editorPresence.shouldRender && activeEditor && (');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses API request wording for dedicated site endpoint copy', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Sites.tsx'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain('API 请求地址池');
|
||||||
|
expect(source).toContain('+ 添加 API 地址');
|
||||||
|
expect(source).toContain('站点 URL(面板/登录/签到地址,如 https://console.example.com)');
|
||||||
|
expect(source).toContain('API 请求地址(如 https://api.example.com)');
|
||||||
|
expect(source).toContain('label="API 请求地址"');
|
||||||
|
expect(source).toContain('API 地址: {buildSiteApiEndpointSummary(site)}');
|
||||||
|
expect(source).not.toContain('nih.cc');
|
||||||
|
expect(source).not.toContain('api.nih.cc');
|
||||||
|
expect(source).not.toContain('AI 请求地址池');
|
||||||
|
expect(source).not.toContain('+ 添加 AI 地址');
|
||||||
|
expect(source).not.toContain('label="AI 请求地址"');
|
||||||
|
expect(source).not.toContain('AI 地址: {buildSiteApiEndpointSummary(site)}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user