forked from boos/BoosAPI
feat: enhance response serialization for compact mode
- Added support for preserving native response.compaction payloads when compact serialization is requested in `serializeResponsesFinalPayload`. - Implemented serialization of compact mode as `response.compaction` instead of an ordinary response object. - Introduced a new type `ResponsesFinalSerializationMode` to manage serialization modes. - Refactored `serializeResponsesFinalPayload` to handle different serialization modes and usage payloads. - Added tests to ensure correct behavior for compact serialization and preservation of upstream payloads. - Updated favicon assets and documentation configuration for VitePress.
This commit is contained in:
@@ -14,6 +14,7 @@ tmp/*
|
|||||||
/.tmp-*
|
/.tmp-*
|
||||||
/main.js
|
/main.js
|
||||||
/NUL
|
/NUL
|
||||||
|
/.playwright-cli/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -208,21 +208,60 @@
|
|||||||
<p><sub>模型广场 — 一站式浏览所有可用模型的覆盖率、定价和性能指标</sub></p>
|
<p><sub>模型广场 — 一站式浏览所有可用模型的覆盖率、定价和性能指标</sub></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### ✅ 自动签到 · 💰 余额管理 · 🔔 告警通知 · 📊 数据看板
|
### ✅ 自动签到
|
||||||
|
|
||||||
- **自动签到**:Cron 定时执行,智能解析奖励金额,签到失败自动通知
|
- Cron 定时执行(默认每日 08:00)
|
||||||
- **余额管理**:定时刷新,收入追踪,余额兜底估算,凭证过期自动重登录
|
- 智能解析奖励金额,签到失败自动通知
|
||||||
- **告警通知**:支持 Webhook / Bark / Server酱 / Telegram Bot / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景
|
- 按账号启用/禁用控制
|
||||||
- **数据看板**:余额分布饼图、消费趋势图表、全局搜索、代理请求日志
|
- 完整签到日志与历史查询
|
||||||
|
- 并发锁防止重复签到
|
||||||
|
|
||||||
|
### 💰 余额管理
|
||||||
|
|
||||||
|
- 定时余额刷新(默认每小时),批量更新所有活跃账号
|
||||||
|
- 收入追踪:每日/累计收入与消费趋势分析
|
||||||
|
- 余额兜底估算:API 不可用时通过代理日志推算余额变动
|
||||||
|
- 凭证过期自动重新登录
|
||||||
|
|
||||||
|
### 🔔 告警通知
|
||||||
|
|
||||||
|
支持五种通知渠道:
|
||||||
|
|
||||||
|
| 渠道 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| **Webhook** | 自定义 HTTP 推送 |
|
||||||
|
| **Bark** | iOS 推送通知 |
|
||||||
|
| **Server酱** | 微信通知 |
|
||||||
|
| **Telegram Bot** | Telegram 消息通知 |
|
||||||
|
| **SMTP 邮件** | 标准邮件通知 |
|
||||||
|
|
||||||
|
告警场景:余额不足预警、站点/账号异常、签到失败、代理请求失败、Token 过期提醒、每日摘要报告。告警冷却机制(默认 300 秒)防止重复通知。
|
||||||
|
|
||||||
|
### 📊 数据看板
|
||||||
|
|
||||||
|
- 站点余额饼图、每日消费趋势图
|
||||||
|
- 全局搜索(站点、账号、模型)
|
||||||
|
- 系统事件日志、代理请求日志(模型、状态、延迟、Token 用量、成本估算)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/dashboard.png" alt="dashboard-detail" width="700"/>
|
<img src="docs/screenshots/dashboard.png" alt="dashboard-detail" width="700"/>
|
||||||
<p><sub>数据看板 — 余额分布、消费趋势、系统健康状态一目了然</sub></p>
|
<p><sub>数据看板 — 余额分布、消费趋势、系统健康状态一目了然</sub></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### 🎮 模型操练场
|
||||||
|
|
||||||
|
- 交互式聊天测试,即时验证模型可用性与响应质量
|
||||||
|
- 选择任意路由模型,对比不同通道输出
|
||||||
|
- 流式 / 非流式双模式测试
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/playground.png" alt="playground-detail" width="700"/>
|
||||||
|
<p><sub>模型操练场 — 在线交互测试,验证模型可用性与响应质量</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
### 📦 轻量部署
|
### 📦 轻量部署
|
||||||
|
|
||||||
- **单 Docker 容器**,默认本地数据目录部署,开箱即用
|
- **单 Docker 容器**,默认本地数据目录部署,支持外接 MySQL / PostgreSQL 运行时数据库
|
||||||
- Alpine 基础镜像,体积精简
|
- Alpine 基础镜像,体积精简
|
||||||
- 数据完整导入导出,迁移无忧
|
- 数据完整导入导出,迁移无忧
|
||||||
|
|
||||||
@@ -234,24 +273,67 @@
|
|||||||
<img alt="Deploy on Zeabur" src="https://zeabur.com/button.svg" height="28">
|
<img alt="Deploy on Zeabur" src="https://zeabur.com/button.svg" height="28">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
### Docker Compose(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name metapi -p 4000:4000 \
|
mkdir metapi && cd metapi
|
||||||
|
|
||||||
|
cat > docker-compose.yml << 'EOF'
|
||||||
|
services:
|
||||||
|
metapi:
|
||||||
|
image: 1467078763/metapi:latest
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
|
||||||
|
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
|
||||||
|
CHECKIN_CRON: "0 8 * * *"
|
||||||
|
BALANCE_REFRESH_CRON: "0 * * * *"
|
||||||
|
PORT: ${PORT:-4000}
|
||||||
|
DATA_DIR: /app/data
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
restart: unless-stopped
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 设置 Token 并启动
|
||||||
|
# AUTH_TOKEN = 管理后台登录令牌(登录时输入此值)
|
||||||
|
export AUTH_TOKEN=your-admin-token
|
||||||
|
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
|
||||||
|
export PROXY_TOKEN=your-proxy-sk-token
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>一行 Docker 命令</strong></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name metapi \
|
||||||
|
-p 4000:4000 \
|
||||||
-e AUTH_TOKEN=your-admin-token \
|
-e AUTH_TOKEN=your-admin-token \
|
||||||
-e PROXY_TOKEN=your-proxy-sk-token \
|
-e PROXY_TOKEN=your-proxy-sk-token \
|
||||||
-v ./data:/app/data --restart unless-stopped \
|
-e TZ=Asia/Shanghai \
|
||||||
|
-v ./data:/app/data \
|
||||||
|
--restart unless-stopped \
|
||||||
1467078763/metapi:latest
|
1467078763/metapi:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
|
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。
|
||||||
|
|
||||||
📖 **[完整部署文档](https://metapi.cita777.me/deployment)** — Zeabur 一键部署 / Docker Compose / 桌面版安装包 / 反向代理 / 升级回滚
|
> [!TIP]
|
||||||
|
> 初始管理员令牌即启动时配置的 `AUTH_TOKEN`。
|
||||||
|
> 若在 Compose 外运行且未显式设置 `AUTH_TOKEN`,默认为 `change-me-admin-token`(仅用于本地调试)。
|
||||||
|
> 如果在「设置」面板中修改了管理员令牌,后续登录请使用新令牌。
|
||||||
|
|
||||||
📖 **[环境变量与配置](https://metapi.cita777.me/configuration)** — 全部环境变量、路由参数、通知渠道
|
Docker Compose、桌面安装包、反向代理、升级与数据库选项等详见 [部署指南](https://metapi.cita777.me/deployment)。
|
||||||
|
|
||||||
📖 **[客户端接入指南](https://metapi.cita777.me/client-integration)** — Cursor / Claude Code / Codex / Open WebUI 等配置方法
|
📖 **[环境变量与配置](https://metapi.cita777.me/configuration)** · **[客户端接入指南](https://metapi.cita777.me/client-integration)** · **[常见问题](https://metapi.cita777.me/faq)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -349,7 +431,7 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均
|
|||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/?repos=cita-777%2Fmetapi&type=timeline&legend=top-left)
|
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -27,7 +27,10 @@ into <strong>one API Key, one endpoint</strong>, with automatic model discovery,
|
|||||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
||||||
</a><!--
|
</a><!--
|
||||||
--><img alt="Node.js" src="https://img.shields.io/badge/Node.js-20+-339933?logo=node.js&style=flat"><!--
|
--><img alt="Node.js" src="https://img.shields.io/badge/Node.js-20+-339933?logo=node.js&style=flat"><!--
|
||||||
--><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
|
--><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat"><!--
|
||||||
|
--><a href="https://zeabur.com/templates/DOX5PR">
|
||||||
|
<img alt="Deploy on Zeabur" src="https://zeabur.com/button.svg" height="28">
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -36,11 +39,12 @@ into <strong>one API Key, one endpoint</strong>, with automatic model discovery,
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="docs/index.md"><strong>Docs</strong></a> ·
|
<a href="https://metapi.cita777.me"><strong>Docs</strong></a> ·
|
||||||
<a href="docs/getting-started.md">Quick Start</a> ·
|
<a href="https://metapi.cita777.me/getting-started">Quick Start</a> ·
|
||||||
<a href="docs/deployment.md">Deployment</a> ·
|
<a href="https://metapi.cita777.me/deployment">Deployment</a> ·
|
||||||
<a href="docs/configuration.md">Configuration</a> ·
|
<a href="https://metapi.cita777.me/configuration">Configuration</a> ·
|
||||||
<a href="docs/faq.md">FAQ</a> ·
|
<a href="https://metapi.cita777.me/client-integration">Client Integration</a> ·
|
||||||
|
<a href="https://metapi.cita777.me/faq">FAQ</a> ·
|
||||||
<a href="CONTRIBUTING.md">Contributing</a>
|
<a href="CONTRIBUTING.md">Contributing</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -575,6 +579,7 @@ If you discover a security issue, please refer to [SECURITY.md](SECURITY.md) and
|
|||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { existsSync, globSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import config from './config';
|
||||||
|
|
||||||
|
const getAliasEntry = (aliasConfig: unknown, specifier: string) => {
|
||||||
|
if (!aliasConfig) return undefined;
|
||||||
|
|
||||||
|
if (Array.isArray(aliasConfig)) {
|
||||||
|
return aliasConfig.find(
|
||||||
|
(entry) =>
|
||||||
|
entry &&
|
||||||
|
typeof entry === 'object' &&
|
||||||
|
'find' in entry &&
|
||||||
|
((typeof entry.find === 'string' && entry.find === specifier) ||
|
||||||
|
(entry.find instanceof RegExp && entry.find.test(specifier))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aliasConfig === 'object' && aliasConfig !== null && specifier in aliasConfig) {
|
||||||
|
return {
|
||||||
|
find: specifier,
|
||||||
|
replacement: String((aliasConfig as Record<string, unknown>)[specifier]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlias = (aliasConfig: unknown, specifier: string): string | undefined => {
|
||||||
|
const aliasEntry = getAliasEntry(aliasConfig, specifier);
|
||||||
|
return aliasEntry && typeof aliasEntry === 'object' && 'replacement' in aliasEntry ? String(aliasEntry.replacement) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const repoRoot = fileURLToPath(new URL('../..', import.meta.url));
|
||||||
|
|
||||||
|
const getExpectedEntry = (hoistedRelativePath: string, pnpmPattern: string) => {
|
||||||
|
const hoistedEntry = resolve(repoRoot, hoistedRelativePath);
|
||||||
|
if (existsSync(hoistedEntry)) return hoistedEntry;
|
||||||
|
|
||||||
|
const [pnpmEntry] = globSync(resolve(repoRoot, pnpmPattern));
|
||||||
|
return pnpmEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('docs vitepress config', () => {
|
||||||
|
it('ships copied main-app favicon assets for docs', () => {
|
||||||
|
expect(existsSync(resolve(repoRoot, 'docs/public/favicon.png'))).toBe(true);
|
||||||
|
expect(existsSync(resolve(repoRoot, 'docs/public/favicon-64.png'))).toBe(true);
|
||||||
|
expect(existsSync(resolve(repoRoot, 'docs/public/favicon.ico'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ships a fallback favicon.ico for browsers that probe the default path', () => {
|
||||||
|
expect(existsSync(resolve(repoRoot, 'docs/public/favicon.ico'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares the main-app favicon assets in docs head tags', () => {
|
||||||
|
const iconLinks =
|
||||||
|
config.head?.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry[0] === 'link' &&
|
||||||
|
typeof entry[1] === 'object' &&
|
||||||
|
entry[1] !== null &&
|
||||||
|
'rel' in entry[1] &&
|
||||||
|
(entry[1].rel === 'icon' || entry[1].rel === 'shortcut icon'),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
expect(iconLinks.some((entry) => typeof entry[1] === 'object' && entry[1] !== null && 'href' in entry[1] && entry[1].href === '/favicon.png')).toBe(true);
|
||||||
|
expect(iconLinks.some((entry) => typeof entry[1] === 'object' && entry[1] !== null && 'href' in entry[1] && entry[1].href === '/favicon-64.png')).toBe(true);
|
||||||
|
expect(iconLinks.some((entry) => typeof entry[1] === 'object' && entry[1] !== null && 'href' in entry[1] && entry[1].href === '/favicon.ico')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aliases dayjs to the ESM entry for mermaid browser compatibility', () => {
|
||||||
|
const aliasConfig = config.vite?.resolve?.alias;
|
||||||
|
const alias = getAlias(aliasConfig, 'dayjs');
|
||||||
|
const aliasEntry = getAliasEntry(aliasConfig, 'dayjs');
|
||||||
|
|
||||||
|
expect(alias).toBe(getExpectedEntry('node_modules/dayjs/esm/index.js', 'node_modules/.pnpm/dayjs@*/node_modules/dayjs/esm/index.js'));
|
||||||
|
expect(alias && existsSync(alias)).toBe(true);
|
||||||
|
expect(Array.isArray(aliasConfig)).toBe(true);
|
||||||
|
expect(aliasEntry && typeof aliasEntry === 'object' && 'find' in aliasEntry && aliasEntry.find instanceof RegExp).toBe(true);
|
||||||
|
expect(aliasEntry && typeof aliasEntry === 'object' && 'find' in aliasEntry && aliasEntry.find instanceof RegExp && aliasEntry.find.test('dayjs/plugin/duration.js')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aliases sanitize-url to a browser-loadable source entry', () => {
|
||||||
|
const alias = getAlias(config.vite?.resolve?.alias, '@braintree/sanitize-url');
|
||||||
|
|
||||||
|
expect(alias).toBe(
|
||||||
|
getExpectedEntry(
|
||||||
|
'node_modules/@braintree/sanitize-url/src/index.ts',
|
||||||
|
'node_modules/.pnpm/@braintree+sanitize-url@*/node_modules/@braintree/sanitize-url/src/index.ts',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(alias && existsSync(alias)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,56 @@
|
|||||||
|
import { existsSync, globSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vitepress';
|
import { defineConfig } from 'vitepress';
|
||||||
import { withMermaid } from 'vitepress-plugin-mermaid';
|
import { withMermaid } from 'vitepress-plugin-mermaid';
|
||||||
|
|
||||||
|
const repoRoot = fileURLToPath(new URL('../..', import.meta.url));
|
||||||
|
const resolveDependencyEntry = (hoistedRelativePath: string, pnpmPattern: string) => {
|
||||||
|
const hoistedEntry = resolve(repoRoot, hoistedRelativePath);
|
||||||
|
if (existsSync(hoistedEntry)) return hoistedEntry;
|
||||||
|
|
||||||
|
const [pnpmEntry] = globSync(resolve(repoRoot, pnpmPattern));
|
||||||
|
return pnpmEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayjsEsmEntry = resolveDependencyEntry(
|
||||||
|
'node_modules/dayjs/esm/index.js',
|
||||||
|
'node_modules/.pnpm/dayjs@*/node_modules/dayjs/esm/index.js',
|
||||||
|
);
|
||||||
|
const sanitizeUrlSourceEntry = resolveDependencyEntry(
|
||||||
|
'node_modules/@braintree/sanitize-url/src/index.ts',
|
||||||
|
'node_modules/.pnpm/@braintree+sanitize-url@*/node_modules/@braintree/sanitize-url/src/index.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dayjsEsmEntry) {
|
||||||
|
throw new Error('Unable to resolve the dayjs ESM entry required by vitepress-plugin-mermaid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitizeUrlSourceEntry) {
|
||||||
|
throw new Error('Unable to resolve the sanitize-url source entry required by vitepress-plugin-mermaid.');
|
||||||
|
}
|
||||||
|
|
||||||
export default withMermaid(
|
export default withMermaid(
|
||||||
defineConfig({
|
defineConfig({
|
||||||
lang: 'zh-CN',
|
lang: 'zh-CN',
|
||||||
title: 'Metapi 文档',
|
title: 'Metapi 文档',
|
||||||
description: 'Metapi 使用文档、FAQ 与维护协作指南',
|
description: 'Metapi 使用文档、FAQ 与维护协作指南',
|
||||||
|
head: [
|
||||||
|
['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon.png' }],
|
||||||
|
['link', { rel: 'icon', type: 'image/png', sizes: '64x64', href: '/favicon-64.png' }],
|
||||||
|
['link', { rel: 'shortcut icon', href: '/favicon.ico' }],
|
||||||
|
],
|
||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
ignoreDeadLinks: true,
|
ignoreDeadLinks: true,
|
||||||
|
vite: {
|
||||||
|
resolve: {
|
||||||
|
alias: [
|
||||||
|
{ find: /^dayjs$/, replacement: dayjsEsmEntry },
|
||||||
|
{ find: /^@braintree\/sanitize-url$/, replacement: sanitizeUrlSourceEntry },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
siteTitle: 'Metapi Docs',
|
siteTitle: 'Metapi Docs',
|
||||||
logo: '/logos/logo-icon-512.png',
|
logo: '/logos/logo-icon-512.png',
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端通常只需
|
|||||||
| 配置项 | 值 |
|
| 配置项 | 值 |
|
||||||
|--------|-----|
|
|--------|-----|
|
||||||
| **Base URL** | 按客户端字段行为填写:会自动补 `/v1` 的字段填 `https://your-domain.com`;要求完整 API URL 的字段填 `https://your-domain.com/v1` |
|
| **Base URL** | 按客户端字段行为填写:会自动补 `/v1` 的字段填 `https://your-domain.com`;要求完整 API URL 的字段填 `https://your-domain.com/v1` |
|
||||||
| **API Key** | 你设置的 `PROXY_TOKEN` 值,或创建的下游 API Key |
|
| **API Key** | 你设置的 `PROXY_TOKEN` 值,或创建的[下游 API Key](./configuration.md#下游-api-key-策略) |
|
||||||
|
|
||||||
模型列表自动从 `GET /v1/models` 获取,无需手动配置。
|
模型列表自动从 `GET /v1/models` 获取,无需手动配置。
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 如果不确定客户端是否自动补 `/v1`,先试根域名,报 404 再改成带 `/v1` 的完整路径。
|
||||||
|
|
||||||
## 支持的接口
|
## 支持的接口
|
||||||
|
|
||||||
| 接口 | 方法 | 说明 |
|
| 接口 | 方法 | 说明 |
|
||||||
@@ -82,9 +85,19 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端通常只需
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 说明:`ANTHROPIC_BASE_URL` 这里填根域名,不要手动拼接 `/v1`;不同版本客户端可能读取 `ANTHROPIC_API_KEY` 或 `ANTHROPIC_AUTH_TOKEN`,建议同时设置。
|
**环境变量说明:**
|
||||||
> 上述变量由 Claude Code 客户端读取,属于客户端行为开关;
|
|
||||||
> `metapi` 服务端无法替代客户端自行设置这些变量,只能处理已经发来的请求。
|
| 变量 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `ANTHROPIC_BASE_URL` | 指向 Metapi 的根域名,Claude Code 会自动拼接 `/v1/messages` |
|
||||||
|
| `ANTHROPIC_API_KEY` | 认证令牌,填 `PROXY_TOKEN` 值 |
|
||||||
|
| `ANTHROPIC_AUTH_TOKEN` | 部分版本读取此变量而非 `ANTHROPIC_API_KEY`,建议两个都设 |
|
||||||
|
| `CLAUDE_CODE_ATTRIBUTION_HEADER` | 设为 `0` 禁用 Attribution 头,避免部分上游不支持该头导致报错 |
|
||||||
|
| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | 设为 `1` 减少非必要请求(遥测等),降低 Token 消耗 |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> `ANTHROPIC_BASE_URL` 填根域名即可,**不要**手动拼接 `/v1`。
|
||||||
|
> 上述变量由 Claude Code 客户端读取,属于客户端行为开关;Metapi 服务端只能处理已经发来的请求。
|
||||||
|
|
||||||
### Codex CLI
|
### Codex CLI
|
||||||
|
|
||||||
@@ -115,7 +128,17 @@ base_url = "https://your-domain.com/v1"
|
|||||||
|
|
||||||
### 其他客户端
|
### 其他客户端
|
||||||
|
|
||||||
所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可:如果客户端会自动补 `/v1`,填根域名;如果字段要求完整 OpenAI API URL,就填 `https://your-domain.com/v1`。
|
所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可:
|
||||||
|
|
||||||
|
| 你的客户端... | 填法 |
|
||||||
|
|--------------|------|
|
||||||
|
| 有 "Base URL" 字段,会自动补 `/v1` | 填 `https://your-domain.com` |
|
||||||
|
| 有 "OpenAI API URL" 字段,要求完整路径 | 填 `https://your-domain.com/v1` |
|
||||||
|
| 有 "Anthropic" 选项 | 填 `https://your-domain.com`,Metapi 自动处理 `/v1/messages` |
|
||||||
|
| 只能填 API Key | 填 `PROXY_TOKEN` 值即可 |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 不确定选哪种?先用 `/v1/models` 测试连通性,再配置模型。
|
||||||
|
|
||||||
## 下游 API Key 策略
|
## 下游 API Key 策略
|
||||||
|
|
||||||
@@ -188,5 +211,7 @@ curl -sS https://your-domain.com/v1/chat/completions \
|
|||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
- [配置说明](./configuration.md) — 环境变量详解
|
- [配置说明](./configuration.md) — 环境变量详解与下游 API Key 策略
|
||||||
|
- [上游接入](./upstream-integration.md) — 添加和管理上游平台
|
||||||
|
- [运维手册](./operations.md) — 日志排查与健康检查
|
||||||
- [常见问题](./faq.md) — 更多故障排查
|
- [常见问题](./faq.md) — 更多故障排查
|
||||||
|
|||||||
+15
-6
@@ -87,12 +87,19 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
|||||||
|
|
||||||
### 路由预设建议
|
### 路由预设建议
|
||||||
|
|
||||||
| 场景 | COST_WEIGHT | BALANCE_WEIGHT | USAGE_WEIGHT |
|
| 场景 | COST_WEIGHT | BALANCE_WEIGHT | USAGE_WEIGHT | 说明 |
|
||||||
|------|:-----------:|:--------------:|:------------:|
|
|------|:-----------:|:--------------:|:------------:|------|
|
||||||
| **成本优先** | 0.7 | 0.2 | 0.1 |
|
| **成本优先** | 0.7 | 0.2 | 0.1 | 适合预算敏感场景,总是选最便宜的通道 |
|
||||||
| **均衡(默认)** | 0.4 | 0.3 | 0.3 |
|
| **均衡(默认)** | 0.4 | 0.3 | 0.3 | 兼顾成本、余额和负载均衡,推荐大多数用户 |
|
||||||
| **稳定优先** | 0.2 | 0.5 | 0.3 |
|
| **稳定优先** | 0.2 | 0.5 | 0.3 | 适合生产环境,优先选余额充足的通道避免中断 |
|
||||||
| **轮转均匀** | 0.1 | 0.1 | 0.8 |
|
| **轮转均匀** | 0.1 | 0.1 | 0.8 | 适合测试场景,尽量让每个通道都被使用到 |
|
||||||
|
|
||||||
|
**调参建议:**
|
||||||
|
|
||||||
|
- 如果某个站点总是被选中导致余额很快耗尽 → 调高 `BALANCE_WEIGHT`
|
||||||
|
- 如果你有几个很便宜的站点想优先用 → 调高 `COST_WEIGHT`
|
||||||
|
- 如果你想让请求均匀分散到所有站点 → 调高 `USAGE_WEIGHT`
|
||||||
|
- 如果你需要对特定项目偏好某个站点 → 使用[下游 API Key 的 `siteWeightMultipliers`](#下游-api-key-策略) 而非调全局权重
|
||||||
|
|
||||||
## 代理日志保留
|
## 代理日志保留
|
||||||
|
|
||||||
@@ -209,3 +216,5 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
|||||||
|
|
||||||
- [部署指南](./deployment.md) — Docker Compose 与反向代理
|
- [部署指南](./deployment.md) — Docker Compose 与反向代理
|
||||||
- [客户端接入](./client-integration.md) — 对接下游应用
|
- [客户端接入](./client-integration.md) — 对接下游应用
|
||||||
|
- [上游接入](./upstream-integration.md) — 添加和管理上游平台
|
||||||
|
- [运维手册](./operations.md) — 备份、日志与健康检查
|
||||||
|
|||||||
+11
-8
@@ -87,6 +87,11 @@ docker compose up -d
|
|||||||
> - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。
|
> - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。
|
||||||
> - 如果你要把本机其他客户端接到桌面版内置后端,先到日志里查当前端口,不要写死 `4000`。
|
> - 如果你要把本机其他客户端接到桌面版内置后端,先到日志里查当前端口,不要写死 `4000`。
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **端口冲突排障:** 如果桌面版启动后报端口被占用,可能是 `4310..4399` 范围内的端口全被其他应用占用了。
|
||||||
|
> - 设置环境变量 `METAPI_DESKTOP_SERVER_PORT=<指定端口>` 固定到一个空闲端口
|
||||||
|
> - 或关闭占用这些端口的应用后重启 Metapi Desktop
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 服务器部署统一推荐 Docker / Docker Compose,不再提供裸 Node.js 的 Release 压缩包。
|
> 服务器部署统一推荐 Docker / Docker Compose,不再提供裸 Node.js 的 Release 压缩包。
|
||||||
|
|
||||||
@@ -127,15 +132,9 @@ npm run dev
|
|||||||
|
|
||||||
### 步骤 2:添加账号
|
### 步骤 2:添加账号
|
||||||
|
|
||||||
首先前往你想添加的公益站,进入下图界面:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
进入 **账号管理**,为每个站点添加已注册的账号:
|
进入 **账号管理**,为每个站点添加已注册的账号:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- 填入用户名和访问凭证
|
- 填入用户名和访问凭证
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ npm run dev
|
|||||||
|
|
||||||
- 系统会自动登录并获取余额信息
|
- 系统会自动登录并获取余额信息
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- 启用自动签到(如站点支持)
|
- 启用自动签到(如站点支持)
|
||||||
|
|
||||||
@@ -163,6 +162,10 @@ npm run dev
|
|||||||
|
|
||||||
- 系统会自动发现模型并生成路由规则
|
- 系统会自动发现模型并生成路由规则
|
||||||
- 可以手动调整通道的优先级和权重
|
- 可以手动调整通道的优先级和权重
|
||||||
|
- 关于路由权重参数调优,参考 [配置说明 → 智能路由](./configuration.md#智能路由)
|
||||||
|
|
||||||
|
<!-- TODO: 补充路由管理截图 -->
|
||||||
|
<!--  -->
|
||||||
|
|
||||||
### 步骤 5:验证代理
|
### 步骤 5:验证代理
|
||||||
|
|
||||||
|
|||||||
+43
-8
@@ -30,12 +30,35 @@ cp -r data/ data-backup-$(date +%Y%m%d)/
|
|||||||
|
|
||||||
### 方式二:数据库原生备份(MySQL / PostgreSQL)
|
### 方式二:数据库原生备份(MySQL / PostgreSQL)
|
||||||
|
|
||||||
如果当前运行库已经切到 MySQL / Postgres,请使用数据库自己的备份工具或云快照,例如 `mysqldump`、`pg_dump`、RDS 快照等。
|
如果当前运行库已经切到 MySQL / Postgres,请使用数据库自己的备份工具或云快照。
|
||||||
|
|
||||||
|
**MySQL 备份示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全量导出(替换为你的实际连接信息)
|
||||||
|
mysqldump -h <HOST> -u <USER> -p<PASSWORD> metapi > metapi-backup-$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# 自动备份(crontab,每天凌晨 3 点)
|
||||||
|
0 3 * * * mysqldump -h <HOST> -u <USER> -p<PASSWORD> metapi | gzip > /path/to/backups/metapi-$(date +\%Y\%m\%d).sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
**PostgreSQL 备份示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全量导出
|
||||||
|
pg_dump -h <HOST> -U <USER> -d metapi -F c -f metapi-backup-$(date +%Y%m%d).dump
|
||||||
|
|
||||||
|
# 自动备份(crontab,每天凌晨 3 点,使用 .pgpass 免交互密码)
|
||||||
|
0 3 * * * pg_dump -h <HOST> -U <USER> -d metapi -F c -f /path/to/backups/metapi-$(date +\%Y\%m\%d).dump
|
||||||
|
```
|
||||||
|
|
||||||
|
**云托管数据库:** RDS、PlanetScale、Neon 等可直接使用平台的自动快照功能。
|
||||||
|
|
||||||
建议:
|
建议:
|
||||||
- 升级、迁移、执行「重新初始化系统」前先做一次库级备份
|
- 升级、迁移、执行「重新初始化系统」前先做一次库级备份
|
||||||
- 备份对象是当前 metapi 正在使用的运行库,而不只是本地 `data/`
|
- 备份对象是当前 metapi 正在使用的运行库,而不只是本地 `data/`
|
||||||
- 恢复后重启 Metapi 一次,确认它重新连接到了正确的运行库
|
- 恢复后重启 Metapi 一次,确认它重新连接到了正确的运行库
|
||||||
|
- 建议保留最近 7~30 天的备份,定期清理过期文件
|
||||||
|
|
||||||
### 方式三:应用内导出
|
### 方式三:应用内导出
|
||||||
|
|
||||||
@@ -104,11 +127,16 @@ npm run dev
|
|||||||
|
|
||||||
| 关键词 | 含义 | 处理方式 |
|
| 关键词 | 含义 | 处理方式 |
|
||||||
|--------|------|----------|
|
|--------|------|----------|
|
||||||
| `auth failed` | 上游站点鉴权失败 | 检查账号凭证是否过期 |
|
| `auth failed` | 上游站点鉴权失败 | 检查账号凭证是否过期,系统会自动尝试重登录 |
|
||||||
| `no available channel` | 路由无可用通道 | 检查 Token 是否同步、通道是否被冷却 |
|
| `no available channel` | 路由无可用通道 | 检查 Token 是否同步、通道是否被冷却;可在路由页查看冷却状态 |
|
||||||
| `notify failed` | 通知发送失败 | 检查通知渠道配置 |
|
| `channel cooling` | 通道进入冷却期 | 通道在请求失败后自动冷却 10 分钟,期间不会被路由选中;无需干预,会自动恢复 |
|
||||||
|
| `upstream 429` | 上游限流 | 该上游站点触发了速率限制;路由引擎会自动切换其他通道,冷却期后重试 |
|
||||||
|
| `upstream 5xx` | 上游服务器错误 | 上游站点临时不可用,路由引擎会自动故障转移到其他通道 |
|
||||||
|
| `notify failed` | 通知发送失败 | 检查 [通知渠道配置](./configuration.md#通知渠道) |
|
||||||
| `checkin failed` | 签到失败 | 检查账号状态和站点连通性 |
|
| `checkin failed` | 签到失败 | 检查账号状态和站点连通性 |
|
||||||
| `balance refresh failed` | 余额刷新失败 | 检查账号凭证 |
|
| `balance refresh failed` | 余额刷新失败 | 检查账号凭证,可能需要重新登录 |
|
||||||
|
| `proxy timeout` | 代理请求超时 | 上游响应过慢;检查网络延迟或考虑切换其他通道 |
|
||||||
|
| `token expired` | Token 过期 | 系统会自动尝试续签;若反复出现,手动刷新 Token |
|
||||||
|
|
||||||
## 健康检查
|
## 健康检查
|
||||||
|
|
||||||
@@ -168,9 +196,14 @@ curl -sS http://localhost:4000/v1/chat/completions \
|
|||||||
|
|
||||||
### 清理缓存并重建路由
|
### 清理缓存并重建路由
|
||||||
|
|
||||||
- 「设置 → 清除缓存并重建路由」会清空模型发现缓存、自动路由和自动通道,再后台触发一次模型刷新与路由重建
|
当模型列表(`GET /v1/models`)、路由列表或选择概率明显滞后时,使用以下操作:
|
||||||
- 「TokenRoutes → 重建路由」适合在调整账号、Token、路由规则后手动重新生成自动路由
|
|
||||||
- 如果 `GET /v1/models`、路由列表或选择概率明显滞后,优先执行这一操作
|
| 操作 | 位置 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **清除缓存并重建路由** | 设置 → 清除缓存并重建路由 | 全局刷新:清空模型发现缓存、自动路由和自动通道,后台触发模型刷新与路由重建 |
|
||||||
|
| **重建路由** | TokenRoutes → 重建路由 | 局部刷新:调整账号、Token、路由规则后手动重新生成自动路由 |
|
||||||
|
|
||||||
|
优先使用「重建路由」做局部刷新,问题持续时再用全局清除。
|
||||||
|
|
||||||
### 批量操作
|
### 批量操作
|
||||||
|
|
||||||
@@ -206,3 +239,5 @@ curl -sS http://localhost:4000/v1/chat/completions \
|
|||||||
|
|
||||||
- [常见问题](./faq.md) — 常见报错与修复
|
- [常见问题](./faq.md) — 常见报错与修复
|
||||||
- [配置说明](./configuration.md) — 环境变量详解
|
- [配置说明](./configuration.md) — 环境变量详解
|
||||||
|
- [上游接入](./upstream-integration.md) — 平台特定的连接与排障
|
||||||
|
- [客户端接入](./client-integration.md) — 下游客户端对接
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,487 @@
|
|||||||
|
# 🔌 上游接入指南
|
||||||
|
|
||||||
|
本文档详细说明如何将不同类型的 AI 中转站接入 Metapi。
|
||||||
|
|
||||||
|
[返回文档中心](./README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Metapi 支持两大类上游站点:
|
||||||
|
|
||||||
|
1. **中转聚合平台** — New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等
|
||||||
|
2. **官方 API 端点** — OpenAI / Claude (Anthropic) / Gemini (Google) 直连
|
||||||
|
|
||||||
|
每种站点类型的接入方式略有不同,本文档按站点类型分别说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 快速接入流程
|
||||||
|
|
||||||
|
### 通用步骤
|
||||||
|
|
||||||
|
1. **登录管理后台** — 访问 `http://your-metapi-host:4000`,使用 `AUTH_TOKEN` 登录
|
||||||
|
2. **进入站点管理** — 点击左侧菜单「站点管理」
|
||||||
|
3. **添加站点** — 点击「添加站点」按钮
|
||||||
|
4. **填写站点信息** — 根据站点类型填写对应字段(见下文详细说明)
|
||||||
|
5. **添加账号** — 站点创建后,在站点详情页添加账号凭证
|
||||||
|
6. **验证连接** — 系统自动验证账号可用性并获取模型列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 中转聚合平台接入
|
||||||
|
|
||||||
|
### New API
|
||||||
|
|
||||||
|
**适用平台:** New API 及其衍生版本(Wong-Gongyi、VO-API、Super-API、RIX-API、Neo-API 等)
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称,便于识别 | `我的 New API` |
|
||||||
|
| **站点 URL** | New API 部署地址(**不含** `/v1` 后缀) | `https://api.example.com` |
|
||||||
|
| **平台类型** | 选择 `new-api` | - |
|
||||||
|
| **代理 URL** | (可选)该站点专用代理地址 | `http://proxy.example.com:7890` |
|
||||||
|
| **使用系统代理** | 是否使用全局 `SYSTEM_PROXY_URL` | 默认关闭 |
|
||||||
|
|
||||||
|
#### 账号凭证类型
|
||||||
|
|
||||||
|
New API 支持三种凭证类型:
|
||||||
|
|
||||||
|
##### 1. 用户名密码登录
|
||||||
|
|
||||||
|
- **适用场景:** 有完整账号权限,需要自动签到、余额查询、Token 管理
|
||||||
|
- **填写方式:**
|
||||||
|
- 用户名:`your-username`
|
||||||
|
- 密码:`your-password`
|
||||||
|
- **自动获取:** 系统自动登录并获取 Access Token 和 API Token
|
||||||
|
|
||||||
|
##### 2. Access Token / Session Cookie
|
||||||
|
|
||||||
|
- **适用场景:** 已有登录凭证,无需密码
|
||||||
|
- **填写方式:**
|
||||||
|
- 在「Access Token」字段填入以下任一格式:
|
||||||
|
- JWT Token:`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...`
|
||||||
|
- Session Cookie:`session=MTczNjQxMjM0NXxEdi1CQUFFQ180SUFBUkFCRUFBQVB2LUNBQUVHYzNSeWFXNW5EQThBRFhObGMzTnBiMjVmZEdGaWJHVUdjM1J5YVc1bkRBSUFBQT09fGRlYWRiZWVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWY=`
|
||||||
|
- Cookie 字符串:`session=xxx; token=yyy`
|
||||||
|
- **自动解析:** 系统自动识别凭证类型并提取用户信息
|
||||||
|
|
||||||
|
##### 3. API Token(仅代理)
|
||||||
|
|
||||||
|
- **适用场景:** 仅用于模型调用,不需要管理功能
|
||||||
|
- **填写方式:**
|
||||||
|
- 在「API Token」字段填入:`sk-xxxxxxxxxxxxxx`
|
||||||
|
- **限制:** 无法使用签到、余额刷新、Token 管理等功能
|
||||||
|
|
||||||
|
#### 特殊说明
|
||||||
|
|
||||||
|
**User ID 自动探测:** New API 的某些衍生版本(如 Wong-Gongyi、VO-API)需要在请求头中携带 `New-API-User` / `Veloera-User` / `voapi-user` 等字段。Metapi 会自动:
|
||||||
|
1. 从 JWT Token 中解码 User ID
|
||||||
|
2. 从 Session Cookie 中提取 User ID(支持 Gob 编码解析)
|
||||||
|
3. 通过探测常见 ID 范围验证可用性
|
||||||
|
|
||||||
|
**防护盾穿透:** 自动处理阿里云盾 / Cloudflare 等 JS 挑战(`acw_sc__v2` / `cdn_sec_tc`),无需手动配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### One API
|
||||||
|
|
||||||
|
**适用平台:** One API 原版及兼容分支
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `One API 主站` |
|
||||||
|
| **站点 URL** | One API 部署地址 | `https://oneapi.example.com` |
|
||||||
|
| **平台类型** | 选择 `one-api` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
One API 支持与 New API 相同的三种凭证类型(用户名密码 / Access Token / API Token),配置方式相同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OneHub
|
||||||
|
|
||||||
|
**适用平台:** OneHub(One API 增强分支)
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `OneHub 站点` |
|
||||||
|
| **站点 URL** | OneHub 部署地址 | `https://onehub.example.com` |
|
||||||
|
| **平台类型** | 选择 `one-hub` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
OneHub 继承 One API 的凭证体系,支持用户名密码、Access Token、API Token 三种方式。
|
||||||
|
|
||||||
|
**额外功能:** OneHub 支持 Token 分组(`token_group`),Metapi 会自动识别并保留分组信息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DoneHub
|
||||||
|
|
||||||
|
**适用平台:** DoneHub(OneHub 增强分支)
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `DoneHub 站点` |
|
||||||
|
| **站点 URL** | DoneHub 部署地址 | `https://donehub.example.com` |
|
||||||
|
| **平台类型** | 选择 `done-hub` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
DoneHub 完全兼容 OneHub 的凭证体系,配置方式相同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Veloera
|
||||||
|
|
||||||
|
**适用平台:** Veloera API 网关
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `Veloera 网关` |
|
||||||
|
| **站点 URL** | Veloera 部署地址 | `https://veloera.example.com` |
|
||||||
|
| **平台类型** | 选择 `veloera` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
Veloera 基于 New API 架构,支持相同的凭证类型。特别注意:
|
||||||
|
- Veloera 需要 `Veloera-User` 请求头,Metapi 会自动添加
|
||||||
|
- 支持 Session Cookie 和 JWT Token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AnyRouter
|
||||||
|
|
||||||
|
**适用平台:** AnyRouter 通用路由平台
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `AnyRouter 平台` |
|
||||||
|
| **站点 URL** | AnyRouter 部署地址 | `https://anyrouter.example.com` |
|
||||||
|
| **平台类型** | 选择 `anyrouter` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
AnyRouter 基于 New API 架构,凭证配置方式相同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Sub2API
|
||||||
|
|
||||||
|
**适用平台:** Sub2API 订阅制中转平台
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `Sub2API 订阅站` |
|
||||||
|
| **站点 URL** | Sub2API 部署地址 | `https://sub2api.example.com` |
|
||||||
|
| **平台类型** | 选择 `sub2api` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
Sub2API 支持:
|
||||||
|
- **用户名密码登录**
|
||||||
|
- **Access Token**
|
||||||
|
- **API Token**(订阅密钥)
|
||||||
|
|
||||||
|
配置方式与 New API 相同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 官方 API 端点接入
|
||||||
|
|
||||||
|
### OpenAI
|
||||||
|
|
||||||
|
**适用场景:** 直连 OpenAI 官方 API 或 OpenAI 兼容端点
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `OpenAI 官方` |
|
||||||
|
| **站点 URL** | OpenAI API 端点(**不含** `/v1` 后缀) | `https://api.openai.com` |
|
||||||
|
| **平台类型** | 选择 `openai` | - |
|
||||||
|
| **代理 URL** | (推荐)配置代理以访问 OpenAI | `http://proxy.example.com:7890` |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
**仅支持 API Key:**
|
||||||
|
- 在「API Token」字段填入:`sk-proj-xxxxxxxxxxxxxx`
|
||||||
|
- 不支持用户名密码登录(OpenAI 无此接口)
|
||||||
|
|
||||||
|
#### 功能限制
|
||||||
|
|
||||||
|
| 功能 | 支持情况 |
|
||||||
|
|------|----------|
|
||||||
|
| 模型列表获取 | ✅ 支持(`/v1/models`) |
|
||||||
|
| 代理调用 | ✅ 支持 |
|
||||||
|
| 余额查询 | ❌ 不支持(OpenAI 无公开接口) |
|
||||||
|
| 自动签到 | ❌ 不适用 |
|
||||||
|
| Token 管理 | ❌ 不适用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claude (Anthropic)
|
||||||
|
|
||||||
|
**适用场景:** 直连 Anthropic Claude API
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `Claude 官方` |
|
||||||
|
| **站点 URL** | Anthropic API 端点 | `https://api.anthropic.com` |
|
||||||
|
| **平台类型** | 选择 `claude` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
**仅支持 API Key:**
|
||||||
|
- 在「API Token」字段填入:`sk-ant-api03-xxxxxxxxxxxxxx`
|
||||||
|
|
||||||
|
#### 功能限制
|
||||||
|
|
||||||
|
| 功能 | 支持情况 |
|
||||||
|
|------|----------|
|
||||||
|
| 模型列表获取 | ✅ 支持(内置模型目录) |
|
||||||
|
| 代理调用 | ✅ 支持(自动转换 OpenAI ⇄ Claude 格式) |
|
||||||
|
| 余额查询 | ❌ 不支持 |
|
||||||
|
| 自动签到 | ❌ 不适用 |
|
||||||
|
| Token 管理 | ❌ 不适用 |
|
||||||
|
|
||||||
|
**协议转换:** Metapi 自动处理 OpenAI 格式与 Claude Messages API 格式的双向转换,下游客户端可使用 OpenAI SDK 调用 Claude 模型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Gemini (Google)
|
||||||
|
|
||||||
|
**适用场景:** 直连 Google Gemini API
|
||||||
|
|
||||||
|
#### 站点配置
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **站点名称** | 自定义名称 | `Gemini 官方` |
|
||||||
|
| **站点 URL** | Gemini API 端点 | `https://generativelanguage.googleapis.com` |
|
||||||
|
| **平台类型** | 选择 `gemini` | - |
|
||||||
|
|
||||||
|
#### 账号凭证
|
||||||
|
|
||||||
|
**仅支持 API Key:**
|
||||||
|
- 在「API Token」字段填入:`AIzaSyxxxxxxxxxxxxxx`
|
||||||
|
|
||||||
|
#### 功能限制
|
||||||
|
|
||||||
|
| 功能 | 支持情况 |
|
||||||
|
|------|----------|
|
||||||
|
| 模型列表获取 | ✅ 支持(`/v1beta/models`) |
|
||||||
|
| 代理调用 | ✅ 支持(自动转换 OpenAI ⇄ Gemini 格式) |
|
||||||
|
| 余额查询 | ❌ 不支持 |
|
||||||
|
| 自动签到 | ❌ 不适用 |
|
||||||
|
| Token 管理 | ❌ 不适用 |
|
||||||
|
|
||||||
|
**协议转换:** Metapi 自动处理 OpenAI 格式与 Gemini `generateContent` API 格式的双向转换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 高级配置
|
||||||
|
|
||||||
|
### 站点级代理
|
||||||
|
|
||||||
|
每个站点可单独配置代理,优先级高于全局 `SYSTEM_PROXY_URL`:
|
||||||
|
|
||||||
|
```
|
||||||
|
站点专用代理 > 全局系统代理 > 直连
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置方式:**
|
||||||
|
1. 在站点编辑页面填写「代理 URL」字段
|
||||||
|
2. 格式:`http://proxy-host:port` 或 `socks5://proxy-host:port`
|
||||||
|
3. 支持 HTTP / HTTPS / SOCKS5 代理
|
||||||
|
|
||||||
|
### 站点权重
|
||||||
|
|
||||||
|
**全局权重(`global_weight`):** 影响该站点下所有通道的路由概率。
|
||||||
|
|
||||||
|
- 默认值:`1.0`
|
||||||
|
- 范围:`0.1` ~ `10.0`
|
||||||
|
- 示例:
|
||||||
|
- 设置为 `2.0` — 该站点通道被选中的概率翻倍
|
||||||
|
- 设置为 `0.5` — 该站点通道被选中的概率减半
|
||||||
|
|
||||||
|
**配置位置:** 站点编辑页面 → 高级设置 → 全局权重
|
||||||
|
|
||||||
|
### 外部签到 URL
|
||||||
|
|
||||||
|
**适用场景:** 某些站点的签到接口非标准路径,或需要通过外部服务触发签到。
|
||||||
|
|
||||||
|
**配置方式:**
|
||||||
|
1. 在站点编辑页面填写「外部签到 URL」
|
||||||
|
2. Metapi 会向该 URL 发送 POST 请求执行签到
|
||||||
|
3. 请求头自动携带账号凭证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 站点自动检测
|
||||||
|
|
||||||
|
Metapi 支持自动识别站点类型,检测优先级如下:
|
||||||
|
|
||||||
|
### 1. URL 特征检测
|
||||||
|
|
||||||
|
根据 URL 中的关键字自动识别:
|
||||||
|
|
||||||
|
| URL 特征 | 识别为 |
|
||||||
|
|----------|--------|
|
||||||
|
| `api.openai.com` | OpenAI |
|
||||||
|
| `api.anthropic.com` | Claude |
|
||||||
|
| `generativelanguage.googleapis.com` | Gemini |
|
||||||
|
| `anyrouter` | AnyRouter |
|
||||||
|
| `donehub` / `done-hub` | DoneHub |
|
||||||
|
| `onehub` / `one-hub` | OneHub |
|
||||||
|
| `veloera` | Veloera |
|
||||||
|
| `sub2api` | Sub2API |
|
||||||
|
|
||||||
|
### 2. 页面标题检测
|
||||||
|
|
||||||
|
访问站点首页,解析 `<title>` 标签识别平台类型。
|
||||||
|
|
||||||
|
### 3. API 探测
|
||||||
|
|
||||||
|
依次尝试各平台的特征接口:
|
||||||
|
- New API:`/api/status` 返回 `system_name`
|
||||||
|
- One API:`/api/status` 返回特定结构
|
||||||
|
- OpenAI:`/v1/models` 返回模型列表
|
||||||
|
|
||||||
|
**手动指定:** 如果自动检测失败,可在添加站点时手动选择平台类型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 账号健康状态
|
||||||
|
|
||||||
|
Metapi 自动追踪每个账号的健康状态:
|
||||||
|
|
||||||
|
| 状态 | 说明 | 触发条件 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `healthy` | 健康 | 最近请求成功,余额充足 |
|
||||||
|
| `degraded` | 降级 | 部分模型不可用,或余额不足 |
|
||||||
|
| `unhealthy` | 不健康 | 连续失败,或凭证过期 |
|
||||||
|
| `disabled` | 已禁用 | 手动禁用或站点禁用 |
|
||||||
|
|
||||||
|
**自动恢复:** `unhealthy` 状态的账号会定期重试,成功后自动恢复为 `healthy`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 故障排查
|
||||||
|
|
||||||
|
### 问题:添加站点后无法获取模型列表
|
||||||
|
|
||||||
|
**可能原因:**
|
||||||
|
1. 站点 URL 填写错误(检查是否包含 `/v1` 后缀,应去除)
|
||||||
|
2. 网络不通(检查代理配置或防火墙)
|
||||||
|
3. 凭证无效(重新验证账号密码或 Token)
|
||||||
|
|
||||||
|
**解决方法:**
|
||||||
|
- 在站点详情页点击「测试连接」
|
||||||
|
- 查看「事件日志」中的错误信息
|
||||||
|
- 检查「代理日志」中的请求详情
|
||||||
|
|
||||||
|
### 问题:New API 账号提示「需要 New-API-User 头」
|
||||||
|
|
||||||
|
**原因:** 该站点是 New API 衍生版本,需要 User ID。
|
||||||
|
|
||||||
|
**解决方法:**
|
||||||
|
1. Metapi 会自动探测 User ID,通常无需手动配置
|
||||||
|
2. 如果自动探测失败,可在账号编辑页面的「额外配置」中手动填写:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"platformUserId": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题:签到失败
|
||||||
|
|
||||||
|
**可能原因:**
|
||||||
|
1. 凭证过期(Access Token 有效期到期)
|
||||||
|
2. 站点签到接口变更
|
||||||
|
3. 已经签到过(部分站点限制每日一次)
|
||||||
|
|
||||||
|
**解决方法:**
|
||||||
|
- 查看「签到记录」中的失败原因
|
||||||
|
- 尝试重新登录获取新凭证
|
||||||
|
- 配置「外部签到 URL」使用备用签到方式
|
||||||
|
|
||||||
|
### 问题:余额显示不准确
|
||||||
|
|
||||||
|
**原因:** 不同平台的余额单位不同。
|
||||||
|
|
||||||
|
**说明:**
|
||||||
|
- New API 系列:余额单位为「美元」,内部存储为 `quota / 500000`
|
||||||
|
- OpenAI / Claude / Gemini:官方 API 无余额查询接口,显示为 `N/A`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 最佳实践
|
||||||
|
|
||||||
|
### 1. 凭证选择建议
|
||||||
|
|
||||||
|
| 场景 | 推荐凭证类型 | 原因 |
|
||||||
|
|------|-------------|------|
|
||||||
|
| 个人站点,需要完整功能 | 用户名密码 | 支持自动签到、Token 管理 |
|
||||||
|
| 共享账号,只读权限 | Access Token | 避免密码泄露 |
|
||||||
|
| 仅用于模型调用 | API Token | 最小权限原则 |
|
||||||
|
|
||||||
|
### 2. 站点命名规范
|
||||||
|
|
||||||
|
建议使用清晰的命名规则,便于管理:
|
||||||
|
|
||||||
|
```
|
||||||
|
[平台类型] - [站点特征] - [用途]
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- `New API - 主站 - 生产环境`
|
||||||
|
- `OneHub - 备用 - 测试`
|
||||||
|
- `OpenAI - 官方 - 高优先级`
|
||||||
|
|
||||||
|
### 3. 代理配置策略
|
||||||
|
|
||||||
|
- **国内访问 OpenAI / Claude / Gemini:** 必须配置代理
|
||||||
|
- **国内中转站:** 通常不需要代理
|
||||||
|
- **海外中转站:** 根据网络情况选择
|
||||||
|
|
||||||
|
### 4. 定期维护
|
||||||
|
|
||||||
|
- **每周检查:** 账号健康状态、余额预警
|
||||||
|
- **每月清理:** 禁用长期不可用的站点和账号
|
||||||
|
- **凭证轮换:** 定期更新 Access Token 和 API Key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
- [配置说明](./configuration.md) — 环境变量与路由参数
|
||||||
|
- [客户端接入](./client-integration.md) — 下游应用配置
|
||||||
|
- [常见问题](./faq.md) — 故障排查与优化建议
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 提示
|
||||||
|
|
||||||
|
- 添加站点后,系统会自动发现可用模型并生成路由表,无需手动配置
|
||||||
|
- 支持同时接入多个相同类型的站点(如多个 New API 实例)
|
||||||
|
- 站点禁用后,关联的所有账号和路由通道会自动禁用
|
||||||
|
- 删除站点会级联删除所有关联账号、Token 和路由配置,请谨慎操作
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
|
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
|
||||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
|
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||||
expect(source).not.toContain('reply.raw.end();');
|
expect(source).not.toContain('reply.raw.end();');
|
||||||
|
expect(source).toContain('reply.hijack();');
|
||||||
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,4 +137,49 @@ describe('responses proxy compact upstream routing', () => {
|
|||||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||||
expect(targetUrl).toContain('/v1/responses/compact');
|
expect(targetUrl).toContain('/v1/responses/compact');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves native response.compaction payloads instead of coercing them into object=response', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'cmp_123',
|
||||||
|
object: 'response.compaction',
|
||||||
|
input_tokens: 1234,
|
||||||
|
output_tokens: 321,
|
||||||
|
total_tokens: 1555,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'rs_123',
|
||||||
|
type: 'compaction',
|
||||||
|
encrypted_content: 'enc-compact-payload',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses/compact',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
id: 'cmp_123',
|
||||||
|
object: 'response.compaction',
|
||||||
|
input_tokens: 1234,
|
||||||
|
output_tokens: 321,
|
||||||
|
total_tokens: 1555,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'rs_123',
|
||||||
|
type: 'compaction',
|
||||||
|
encrypted_content: 'enc-compact-payload',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,4 +29,24 @@ describe('responses proxy compact route', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects streaming /v1/responses/compact requests because compact is non-streaming', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses/compact',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
error: {
|
||||||
|
message: 'stream is not supported on /v1/responses/compact',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -502,4 +502,265 @@ describe('serializeConvertedResponsesEvents', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not synthesize duplicate message done events after original text completion already arrived', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 2,
|
||||||
|
completionTokens: 4,
|
||||||
|
totalTokens: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.output_item.added',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'in_progress',
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.content_part.added',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.content_part.added',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_1',
|
||||||
|
content_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'output_text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.output_text.delta',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_1',
|
||||||
|
delta: 'hello ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.output_text.done',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.output_text.done',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_1',
|
||||||
|
text: 'hello world',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.content_part.done',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.content_part.done',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_1',
|
||||||
|
content_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'hello world',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionLines = completeResponsesStream(state, streamContext, usage);
|
||||||
|
const events = parseSseEvents(completionLines);
|
||||||
|
|
||||||
|
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
|
||||||
|
'response.output_item.done',
|
||||||
|
'response.completed',
|
||||||
|
'[DONE]',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(events[1]?.payload).toMatchObject({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
status: 'completed',
|
||||||
|
output_text: 'hello world',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not synthesize duplicate reasoning done events after original reasoning completion already arrived', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 2,
|
||||||
|
completionTokens: 4,
|
||||||
|
totalTokens: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.output_item.added',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'rs_1',
|
||||||
|
type: 'reasoning',
|
||||||
|
status: 'in_progress',
|
||||||
|
summary: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.reasoning_summary_part.added',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.reasoning_summary_part.added',
|
||||||
|
item_id: 'rs_1',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'summary_text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.reasoning_summary_text.delta',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.reasoning_summary_text.delta',
|
||||||
|
item_id: 'rs_1',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
delta: 'plan ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.reasoning_summary_text.done',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.reasoning_summary_text.done',
|
||||||
|
item_id: 'rs_1',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
text: 'plan first',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.reasoning_summary_part.done',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.reasoning_summary_part.done',
|
||||||
|
item_id: 'rs_1',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'summary_text',
|
||||||
|
text: 'plan first',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionLines = completeResponsesStream(state, streamContext, usage);
|
||||||
|
const events = parseSseEvents(completionLines);
|
||||||
|
|
||||||
|
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
|
||||||
|
'response.output_item.done',
|
||||||
|
'response.completed',
|
||||||
|
'[DONE]',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'rs_1',
|
||||||
|
type: 'reasoning',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(events[1]?.payload).toMatchObject({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'rs_1',
|
||||||
|
type: 'reasoning',
|
||||||
|
status: 'completed',
|
||||||
|
summary: [
|
||||||
|
{
|
||||||
|
type: 'summary_text',
|
||||||
|
text: 'plan first',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ type ResponsesUsageSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AggregateOutputItem = Record<string, unknown>;
|
type AggregateOutputItem = Record<string, unknown>;
|
||||||
|
type AggregateOutputMetaCarrier = AggregateOutputItem & { [key: symbol]: true | undefined };
|
||||||
|
|
||||||
|
const outputTextDoneMarker = Symbol('responses.output_text.done');
|
||||||
|
const contentPartDoneMarker = Symbol('responses.content_part.done');
|
||||||
|
const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.done');
|
||||||
|
const reasoningSummaryPartDoneMarker = Symbol('responses.reasoning_summary_part.done');
|
||||||
|
|
||||||
export type OpenAiResponsesAggregateState = {
|
export type OpenAiResponsesAggregateState = {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
@@ -90,6 +96,14 @@ export function createOpenAiResponsesAggregateState(modelName: string): OpenAiRe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markTerminalMarker(item: AggregateOutputItem, marker: symbol): void {
|
||||||
|
(item as AggregateOutputMetaCarrier)[marker] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTerminalMarker(item: AggregateOutputItem, marker: symbol): boolean {
|
||||||
|
return (item as AggregateOutputMetaCarrier)[marker] === true;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeUsageExtras(
|
function mergeUsageExtras(
|
||||||
state: OpenAiResponsesAggregateState,
|
state: OpenAiResponsesAggregateState,
|
||||||
usagePayload: unknown,
|
usagePayload: unknown,
|
||||||
@@ -523,8 +537,20 @@ function applyOriginalResponsesPayload(
|
|||||||
const message = ensureMessageItem(state, outputIndex).item;
|
const message = ensureMessageItem(state, outputIndex).item;
|
||||||
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
|
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
|
||||||
if (!Array.isArray(message.content)) message.content = content;
|
if (!Array.isArray(message.content)) message.content = content;
|
||||||
const part = cloneRecord(payload.part);
|
const existingPart = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : null;
|
||||||
if (part) content[contentIndex] = part;
|
const incomingPart = cloneRecord(payload.part);
|
||||||
|
if (incomingPart) {
|
||||||
|
content[contentIndex] = existingPart
|
||||||
|
? {
|
||||||
|
...existingPart,
|
||||||
|
...incomingPart,
|
||||||
|
}
|
||||||
|
: incomingPart;
|
||||||
|
}
|
||||||
|
const part = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : existingPart;
|
||||||
|
if (eventType === 'response.content_part.done' && part) {
|
||||||
|
markTerminalMarker(part, contentPartDoneMarker);
|
||||||
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return serializeOriginalResponsesEvent(eventType, payload);
|
||||||
}
|
}
|
||||||
case 'response.output_text.delta':
|
case 'response.output_text.delta':
|
||||||
@@ -533,6 +559,7 @@ function applyOriginalResponsesPayload(
|
|||||||
const textPart = ensureMessageOutputTextPart(state, outputIndex);
|
const textPart = ensureMessageOutputTextPart(state, outputIndex);
|
||||||
if (eventType === 'response.output_text.done') {
|
if (eventType === 'response.output_text.done') {
|
||||||
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||||
|
markTerminalMarker(textPart.part, outputTextDoneMarker);
|
||||||
} else {
|
} else {
|
||||||
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||||
}
|
}
|
||||||
@@ -580,6 +607,11 @@ function applyOriginalResponsesPayload(
|
|||||||
...part,
|
...part,
|
||||||
};
|
};
|
||||||
summaryState.item.summary = summary;
|
summaryState.item.summary = summary;
|
||||||
|
if (eventType === 'response.reasoning_summary_part.done') {
|
||||||
|
markTerminalMarker(summary[summaryState.summaryIndex] as AggregateOutputItem, reasoningSummaryPartDoneMarker);
|
||||||
|
}
|
||||||
|
} else if (eventType === 'response.reasoning_summary_part.done') {
|
||||||
|
markTerminalMarker(summaryState.summary, reasoningSummaryPartDoneMarker);
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return serializeOriginalResponsesEvent(eventType, payload);
|
||||||
}
|
}
|
||||||
@@ -588,6 +620,7 @@ function applyOriginalResponsesPayload(
|
|||||||
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
||||||
if (eventType === 'response.reasoning_summary_text.done') {
|
if (eventType === 'response.reasoning_summary_text.done') {
|
||||||
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||||
|
markTerminalMarker(summaryState.summary, reasoningSummaryTextDoneMarker);
|
||||||
} else {
|
} else {
|
||||||
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||||
}
|
}
|
||||||
@@ -790,39 +823,51 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
const part = content[0];
|
const part = content[0];
|
||||||
if (isRecord(part)) {
|
if (isRecord(part)) {
|
||||||
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
||||||
lines.push(serializeSse('response.output_text.done', {
|
if (!hasTerminalMarker(part, outputTextDoneMarker)) {
|
||||||
type: 'response.output_text.done',
|
lines.push(serializeSse('response.output_text.done', {
|
||||||
output_index: index,
|
type: 'response.output_text.done',
|
||||||
item_id: itemId,
|
output_index: index,
|
||||||
text,
|
item_id: itemId,
|
||||||
}));
|
text,
|
||||||
lines.push(serializeSse('response.content_part.done', {
|
}));
|
||||||
type: 'response.content_part.done',
|
markTerminalMarker(part, outputTextDoneMarker);
|
||||||
output_index: index,
|
}
|
||||||
item_id: itemId,
|
if (!hasTerminalMarker(part, contentPartDoneMarker)) {
|
||||||
content_index: 0,
|
lines.push(serializeSse('response.content_part.done', {
|
||||||
part: cloneJson(part),
|
type: 'response.content_part.done',
|
||||||
}));
|
output_index: index,
|
||||||
|
item_id: itemId,
|
||||||
|
content_index: 0,
|
||||||
|
part: cloneJson(part),
|
||||||
|
}));
|
||||||
|
markTerminalMarker(part, contentPartDoneMarker);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (itemType === 'reasoning') {
|
} else if (itemType === 'reasoning') {
|
||||||
const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : [];
|
const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : [];
|
||||||
const part = summary[0];
|
const part = summary[0];
|
||||||
if (isRecord(part)) {
|
if (isRecord(part)) {
|
||||||
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
||||||
lines.push(serializeSse('response.reasoning_summary_text.done', {
|
if (!hasTerminalMarker(part, reasoningSummaryTextDoneMarker)) {
|
||||||
type: 'response.reasoning_summary_text.done',
|
lines.push(serializeSse('response.reasoning_summary_text.done', {
|
||||||
item_id: itemId,
|
type: 'response.reasoning_summary_text.done',
|
||||||
output_index: index,
|
item_id: itemId,
|
||||||
summary_index: 0,
|
output_index: index,
|
||||||
text,
|
summary_index: 0,
|
||||||
}));
|
text,
|
||||||
lines.push(serializeSse('response.reasoning_summary_part.done', {
|
}));
|
||||||
type: 'response.reasoning_summary_part.done',
|
markTerminalMarker(part, reasoningSummaryTextDoneMarker);
|
||||||
item_id: itemId,
|
}
|
||||||
output_index: index,
|
if (!hasTerminalMarker(part, reasoningSummaryPartDoneMarker)) {
|
||||||
summary_index: 0,
|
lines.push(serializeSse('response.reasoning_summary_part.done', {
|
||||||
part: cloneJson(part),
|
type: 'response.reasoning_summary_part.done',
|
||||||
}));
|
item_id: itemId,
|
||||||
|
output_index: index,
|
||||||
|
summary_index: 0,
|
||||||
|
part: cloneJson(part),
|
||||||
|
}));
|
||||||
|
markTerminalMarker(part, reasoningSummaryPartDoneMarker);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (itemType === 'function_call') {
|
} else if (itemType === 'function_call') {
|
||||||
const callId = asTrimmedString(item.call_id) || itemId;
|
const callId = asTrimmedString(item.call_id) || itemId;
|
||||||
|
|||||||
@@ -3,6 +3,112 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { serializeResponsesFinalPayload } from './outbound.js';
|
import { serializeResponsesFinalPayload } from './outbound.js';
|
||||||
|
|
||||||
describe('serializeResponsesFinalPayload', () => {
|
describe('serializeResponsesFinalPayload', () => {
|
||||||
|
it('preserves native response.compaction payloads when compact serialization is requested', () => {
|
||||||
|
const upstreamPayload = {
|
||||||
|
id: 'cmp_123',
|
||||||
|
object: 'response.compaction',
|
||||||
|
created_at: 1700000000,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'rs_123',
|
||||||
|
type: 'compaction',
|
||||||
|
encrypted_content: 'enc-compact-payload',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1234,
|
||||||
|
output_tokens: 321,
|
||||||
|
total_tokens: 1555,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = serializeResponsesFinalPayload({
|
||||||
|
upstreamPayload,
|
||||||
|
normalized: {
|
||||||
|
id: 'cmp_123',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 1700000000,
|
||||||
|
content: '',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
promptTokens: 1234,
|
||||||
|
completionTokens: 321,
|
||||||
|
totalTokens: 1555,
|
||||||
|
},
|
||||||
|
serializationMode: 'compact',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(payload).toEqual(upstreamPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes compact mode as response.compaction instead of an ordinary response object', () => {
|
||||||
|
const payload = serializeResponsesFinalPayload({
|
||||||
|
upstreamPayload: {
|
||||||
|
id: 'chatcmpl_compact',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1700000000,
|
||||||
|
model: 'gpt-5',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'hello',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
id: 'chatcmpl_compact',
|
||||||
|
model: 'gpt-5',
|
||||||
|
created: 1700000000,
|
||||||
|
content: 'hello',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
promptTokens: 11,
|
||||||
|
completionTokens: 7,
|
||||||
|
totalTokens: 18,
|
||||||
|
},
|
||||||
|
serializationMode: 'compact',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
id: 'resp_chatcmpl_compact',
|
||||||
|
object: 'response.compaction',
|
||||||
|
created_at: 1700000000,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_chatcmpl_compact',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'hello',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 11,
|
||||||
|
output_tokens: 7,
|
||||||
|
total_tokens: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(payload).not.toHaveProperty('created');
|
||||||
|
expect(payload).not.toHaveProperty('status');
|
||||||
|
expect(payload).not.toHaveProperty('model');
|
||||||
|
expect(payload).not.toHaveProperty('output_text');
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves top-level chat annotations on synthetic assistant messages', () => {
|
it('preserves top-level chat annotations on synthetic assistant messages', () => {
|
||||||
const payload = serializeResponsesFinalPayload({
|
const payload = serializeResponsesFinalPayload({
|
||||||
upstreamPayload: {
|
upstreamPayload: {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export type ResponsesToolCall = {
|
|||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResponsesFinalSerializationMode = 'response' | 'compact';
|
||||||
|
|
||||||
type ResponsesOutputItem = Record<string, unknown>;
|
type ResponsesOutputItem = Record<string, unknown>;
|
||||||
|
|
||||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||||
@@ -280,14 +282,45 @@ export function normalizeResponsesFinalPayload(
|
|||||||
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildResponsesUsagePayload(
|
||||||
|
usage: ResponsesUsageSummary,
|
||||||
|
syntheticUsageDetails: {
|
||||||
|
inputTokensDetails?: Record<string, unknown>;
|
||||||
|
outputTokensDetails?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
input_tokens: usage.promptTokens,
|
||||||
|
output_tokens: usage.completionTokens,
|
||||||
|
total_tokens: usage.totalTokens,
|
||||||
|
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
|
||||||
|
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
|
||||||
|
: {}),
|
||||||
|
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
|
||||||
|
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function serializeResponsesFinalPayload(input: {
|
export function serializeResponsesFinalPayload(input: {
|
||||||
upstreamPayload: unknown;
|
upstreamPayload: unknown;
|
||||||
normalized: NormalizedFinalResponse;
|
normalized: NormalizedFinalResponse;
|
||||||
usage: ResponsesUsageSummary;
|
usage: ResponsesUsageSummary;
|
||||||
|
serializationMode?: ResponsesFinalSerializationMode;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const { upstreamPayload, normalized, usage } = input;
|
const {
|
||||||
if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') {
|
upstreamPayload,
|
||||||
return upstreamPayload;
|
normalized,
|
||||||
|
usage,
|
||||||
|
serializationMode = 'response',
|
||||||
|
} = input;
|
||||||
|
if (isRecord(upstreamPayload)) {
|
||||||
|
if (upstreamPayload.object === 'response.compaction') {
|
||||||
|
return upstreamPayload;
|
||||||
|
}
|
||||||
|
if (serializationMode === 'response' && upstreamPayload.object === 'response') {
|
||||||
|
return upstreamPayload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
|
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
|
||||||
@@ -371,6 +404,17 @@ export function serializeResponsesFinalPayload(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usagePayload = buildResponsesUsagePayload(usage, syntheticUsageDetails);
|
||||||
|
if (serializationMode === 'compact') {
|
||||||
|
return {
|
||||||
|
id: responseId,
|
||||||
|
object: 'response.compaction',
|
||||||
|
created_at: normalized.created,
|
||||||
|
output,
|
||||||
|
usage: usagePayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: 'response',
|
object: 'response',
|
||||||
@@ -379,17 +423,7 @@ export function serializeResponsesFinalPayload(input: {
|
|||||||
model: normalized.model,
|
model: normalized.model,
|
||||||
output,
|
output,
|
||||||
output_text: normalized.content || collectOutputTextFromItems(output),
|
output_text: normalized.content || collectOutputTextFromItems(output),
|
||||||
usage: {
|
usage: usagePayload,
|
||||||
input_tokens: usage.promptTokens,
|
|
||||||
output_tokens: usage.completionTokens,
|
|
||||||
total_tokens: usage.totalTokens,
|
|
||||||
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
|
|
||||||
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
|
|
||||||
: {}),
|
|
||||||
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
|
|
||||||
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user