不要把
TAPAPI_API_KEY 暴露成 NEXT_PUBLIC_ 变量。下面示例默认运行在服务端 Route Handler 中,浏览器只调用你自己的 /api/* 接口。推荐结构
Browser -> /api/tapapi/chat -> Next.js server -> Tapapi
Browser -> /api/tapapi/chat/stream -> Next.js server -> Tapapi
Browser -> /api/tapapi/image -> Next.js server -> Tapapi
环境变量
TAPAPI_API_KEY=sk-xxx
TAPAPI_BASE_URL=https://tapapi.ai/v1
通用 Helper
建议先把 Tapapi 请求、错误解析和模型白名单放到一个服务端工具文件里:// lib/tapapi.ts
const tapapiBaseUrl = process.env.TAPAPI_BASE_URL || "https://tapapi.ai/v1";
const textModels = new Set(["gpt-5.4"]);
const imageModels = new Set(["nano-banana-pro"]);
const imageSizes = new Set(["1024x1024", "1536x1024", "1024x1536"]);
export function pickTextModel(model?: string) {
return model && textModels.has(model) ? model : "gpt-5.4";
}
export function pickImageModel(model?: string) {
return model && imageModels.has(model) ? model : "nano-banana-pro";
}
export function pickImageSize(size?: string) {
return size && imageSizes.has(size) ? size : "1024x1024";
}
export function tapapiUrl(path: string) {
return `${tapapiBaseUrl.replace(/\/$/, "")}${path}`;
}
export async function safeJson(response: Response) {
try {
return await response.json();
} catch {
return {};
}
}
export function parseTapapiError(response: Response, payload: any) {
const error = payload?.error || {};
return {
status: response.status,
request_id: response.headers.get("X-Oneapi-Request-Id"),
type: error.type,
code: error.code || payload?.code,
message: error.message || payload?.message || response.statusText,
};
}
model、size、n、messages 或 prompt。至少做服务端白名单、长度限制和用户权限校验。
文本代理
// app/api/tapapi/chat/route.ts
import { NextRequest, NextResponse } from "next/server";
import { parseTapapiError, pickTextModel, safeJson, tapapiUrl } from "@/lib/tapapi";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const body = await request.json();
if (!Array.isArray(body.messages)) {
return NextResponse.json({ message: "messages must be an array" }, { status: 400 });
}
const response = await fetch(tapapiUrl("/chat/completions"), {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TAPAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: pickTextModel(body.model),
messages: body.messages,
}),
});
const data = await safeJson(response);
if (!response.ok) {
const error = parseTapapiError(response, data);
console.error("tapapi_chat_error", error);
return NextResponse.json({ message: error.message, request_id: error.request_id }, { status: response.status });
}
return NextResponse.json({
text: data.choices?.[0]?.message?.content || "",
request_id: response.headers.get("X-Oneapi-Request-Id"),
});
}
const response = await fetch("/api/tapapi/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [{ role: "user", content: "Hello" }],
}),
});
const data = await response.json();
console.log(data.text);
流式文本代理
流式接口不要先response.json(),成功时直接转发 Tapapi 的 SSE body:
// app/api/tapapi/chat/stream/route.ts
import { NextRequest, NextResponse } from "next/server";
import { parseTapapiError, pickTextModel, safeJson, tapapiUrl } from "@/lib/tapapi";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const body = await request.json();
if (!Array.isArray(body.messages)) {
return NextResponse.json({ message: "messages must be an array" }, { status: 400 });
}
const response = await fetch(tapapiUrl("/chat/completions"), {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TAPAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: pickTextModel(body.model),
stream: true,
stream_options: { include_usage: true },
messages: body.messages,
}),
});
if (!response.ok) {
const payload = await safeJson(response);
const error = parseTapapiError(response, payload);
console.error("tapapi_stream_error", error);
return NextResponse.json({ message: error.message, request_id: error.request_id }, { status: response.status });
}
return new Response(response.body, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Tapapi-Request-Id": response.headers.get("X-Oneapi-Request-Id") || "",
},
});
}
choices[0].delta.content,并跳过空内容、保活事件和没有普通文本 delta 的 chunk。
Responses 代理
/v1/chat/completions 是 Next.js 代理的默认主线。需要 Responses API 时,可以单独开一个路由,不要和 chat parser 共用:
// app/api/tapapi/responses/route.ts
import { NextRequest, NextResponse } from "next/server";
import { parseTapapiError, pickTextModel, safeJson, tapapiUrl } from "@/lib/tapapi";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const body = await request.json();
const response = await fetch(tapapiUrl("/responses"), {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TAPAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: pickTextModel(body.model),
input: body.input,
}),
});
const data = await safeJson(response);
if (!response.ok) {
const error = parseTapapiError(response, data);
console.error("tapapi_responses_error", error);
return NextResponse.json({ message: error.message, request_id: error.request_id }, { status: response.status });
}
return NextResponse.json({
output_text: data.output_text || "",
request_id: response.headers.get("X-Oneapi-Request-Id"),
});
}
图片代理
// app/api/tapapi/image/route.ts
import { NextRequest, NextResponse } from "next/server";
import { parseTapapiError, pickImageModel, pickImageSize, safeJson, tapapiUrl } from "@/lib/tapapi";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.prompt || typeof body.prompt !== "string") {
return NextResponse.json({ message: "prompt is required" }, { status: 400 });
}
const response = await fetch(tapapiUrl("/images/generations"), {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TAPAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: pickImageModel(body.model),
prompt: body.prompt,
n: 1,
size: pickImageSize(body.size),
response_format: "url",
}),
});
const data = await safeJson(response);
if (!response.ok) {
const error = parseTapapiError(response, data);
console.error("tapapi_image_error", error);
return NextResponse.json({ message: error.message, request_id: error.request_id }, { status: response.status });
}
if (!data.data?.length) {
return NextResponse.json(
{
message: "Tapapi returned empty image data",
request_id: response.headers.get("X-Oneapi-Request-Id"),
},
{ status: 502 }
);
}
const item = data.data[0];
return NextResponse.json({
image: item.url || item.b64_json || null,
request_id: response.headers.get("X-Oneapi-Request-Id"),
metadata: data.metadata || null,
});
}
n、实际 data.length 和成功转存数量;如果响应包含 metadata.tapapi_partial,按实际返回张数处理。
视频代理
视频 API 当前不是默认全量开放能力。只有控制台可见模型、价格已确认、账号有权限时,才适合在 Next.js 后端代理里开放。 推荐流程:POST /v1/videos
-> GET /v1/videos/{task_id}
-> completed 后 GET /v1/videos/{task_id}/content
seconds、size、aspect_ratio、参考图字段直接复用到所有视频模型。更多见 视频 API 和 任务与输出。
生产增强
| 项目 | 建议 |
|---|---|
| 用户鉴权 | 调 Tapapi 前先确认当前用户是否有权限 |
| 参数白名单 | 在服务端限制 model、size、n 和输入长度 |
| 限流 | 对你的业务用户做限流,不要只依赖 Tapapi 限流 |
| 队列 | 批量图片、视频和长任务进入队列,不要让浏览器请求长时间挂住 |
| 幂等 | 保存业务 task_id,避免刷新、重复点击、worker 重启造成重复请求 |
| 图片保存 | 后端拿到图片后转存到自己的对象存储 |
| 日志 | 记录业务用户、模型、耗时、状态码、request_id 和错误字段 |
| 错误返回 | 给前端返回脱敏错误,不要暴露 API Key 或上游细节 |
| 超时 | 遇到 504、524 或函数超时,先查业务状态和账单再决定是否补跑 |
不要这样做
// 不要在浏览器端直接写 Tapapi Key
fetch("https://tapapi.ai/v1/chat/completions", {
headers: {
Authorization: "Bearer sk-xxx",
},
});
下一步
| 场景 | 文档 |
|---|---|
| Node.js 服务端接入 | Node.js / TypeScript |
| 底层 HTTP 规则 | HTTP REST |
| 图片生成参数 | 参数说明 |
| 视频 API | 视频 API |
| 错误与重试 | 错误码与重试 |
| 生产上线检查 | 上线 Checklist |