> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tapapi.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Next.js 代理接入

> 避免在前端暴露 API Key

不要在浏览器端直接调用 Tapapi 并暴露 API Key。Next.js 项目建议用 Route Handler 或 Server Action 做后端代理。

<Warning>不要把 `TAPAPI_API_KEY` 暴露成 `NEXT_PUBLIC_` 变量。下面示例默认运行在服务端 Route Handler 中，浏览器只调用你自己的 `/api/*` 接口。</Warning>

## 推荐结构

```text theme={null}
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 只保存在服务端环境变量里。

## 环境变量

```bash theme={null}
TAPAPI_API_KEY=sk-xxx
TAPAPI_BASE_URL=https://tapapi.ai/v1
```

## 通用 Helper

建议先把 Tapapi 请求、错误解析和模型白名单放到一个服务端工具文件里：

```typescript theme={null}
// 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`。至少做服务端白名单、长度限制和用户权限校验。

## 文本代理

```typescript theme={null}
// 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"),
  });
}
```

前端只调用自己的接口：

```typescript theme={null}
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：

```typescript theme={null}
// 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") || "",
    },
  });
}
```

前端解析 SSE 时读取每个 chunk 的 `choices[0].delta.content`，并跳过空内容、保活事件和没有普通文本 delta 的 chunk。

## Responses 代理

`/v1/chat/completions` 是 Next.js 代理的默认主线。需要 Responses API 时，可以单独开一个路由，不要和 chat parser 共用：

```typescript theme={null}
// 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"),
  });
}
```

Responses 的返回结构和 Chat Completions 不完全一样。接入前先用真实模型测试字段，再写入生产解析逻辑。

## 图片代理

```typescript theme={null}
// 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,
  });
}
```

生产环境建议后端拿到图片后立刻转存到自己的 S3、R2、OSS、COS 或其他对象存储，再把自有 URL 返回给前端。批量生成时记录请求 `n`、实际 `data.length` 和成功转存数量；如果响应包含 `metadata.tapapi_partial`，按实际返回张数处理。

## 视频代理

视频 API 当前不是默认全量开放能力。只有控制台可见模型、价格已确认、账号有权限时，才适合在 Next.js 后端代理里开放。

推荐流程：

```text theme={null}
POST /v1/videos
  -> GET /v1/videos/{task_id}
  -> completed 后 GET /v1/videos/{task_id}/content
```

视频请求字段会随模型变化，不要把某个模型的 `seconds`、`size`、`aspect_ratio`、参考图字段直接复用到所有视频模型。更多见 [视频 API](/video-generation/overview) 和 [任务与输出](/video-generation/tasks-and-output)。

## 生产增强

| 项目    | 建议                                       |
| ----- | ---------------------------------------- |
| 用户鉴权  | 调 Tapapi 前先确认当前用户是否有权限                   |
| 参数白名单 | 在服务端限制 `model`、`size`、`n` 和输入长度          |
| 限流    | 对你的业务用户做限流，不要只依赖 Tapapi 限流               |
| 队列    | 批量图片、视频和长任务进入队列，不要让浏览器请求长时间挂住            |
| 幂等    | 保存业务 `task_id`，避免刷新、重复点击、worker 重启造成重复请求 |
| 图片保存  | 后端拿到图片后转存到自己的对象存储                        |
| 日志    | 记录业务用户、模型、耗时、状态码、`request_id` 和错误字段      |
| 错误返回  | 给前端返回脱敏错误，不要暴露 API Key 或上游细节             |
| 超时    | 遇到 504、524 或函数超时，先查业务状态和账单再决定是否补跑        |

## 不要这样做

```typescript theme={null}
// 不要在浏览器端直接写 Tapapi Key
fetch("https://tapapi.ai/v1/chat/completions", {
  headers: {
    Authorization: "Bearer sk-xxx",
  },
});
```

浏览器代码、Source Map、网络面板和错误日志都可能泄露 API Key。

## 下一步

| 场景            | 文档                                                    |
| ------------- | ----------------------------------------------------- |
| Node.js 服务端接入 | [Node.js / TypeScript](/integrations/node-typescript) |
| 底层 HTTP 规则    | [HTTP REST](/integrations/http-rest)                  |
| 图片生成参数        | [参数说明](/image-generation/parameters)                  |
| 视频 API        | [视频 API](/video-generation/overview)                  |
| 错误与重试         | [错误码与重试](/errors)                                     |
| 生产上线检查        | [上线 Checklist](/production/launch-checklist)          |
