Files
easyai-ai-gateway/apps/web/src/pages/ApiDocsPage.tsx
T
easyai 3de26d5157 feat(web): 分类展示公开 API 文档
将通用开放接口与兼容接口分层展示,补充完整接口目录、分类路由、搜索能力和接入约定。\n\n验证:pnpm nx run web:test;pnpm nx run web:build
2026-07-22 17:12:48 +08:00

1394 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useEffect, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
import type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts';
import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react';
import { Badge, Button, Input, Select, Textarea } from '../components/ui';
import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
import { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task';
import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types';
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
import {
publicApiCatalogGroups,
publicApiEndpointCount,
type PublicApiFamily,
type PublicApiMethod,
type PublicApiSurface,
} from './api-docs-catalog';
interface ApiDocItem {
catalogFamily?: PublicApiFamily;
catalogSurface?: PublicApiSurface;
group: string;
key: ApiDocSection;
kind?: TaskKind;
lead: string;
method?: PublicApiMethod;
path?: string;
surface: PublicApiSurface;
title: string;
}
interface ApiDocParam {
children?: ApiDocParam[];
name: string;
required?: boolean;
type: string;
value: string;
}
type ApiGuideSection = Extract<ApiDocSection, 'guideBaseUrl' | 'guideWebhook' | 'guideErrors' | 'guideTesting'>;
interface ApiGuideItem {
group: '指南';
key: ApiGuideSection;
lead: string;
title: string;
}
export const apiDocs: ApiDocItem[] = [
{ key: 'openApiOverview', surface: 'open', catalogSurface: 'open', group: '接口总览', title: '通用开放接口', lead: '统一的 Gateway 接口面向新业务接入,覆盖账号、模型、生成、异步任务、真人资产和安全集成;生产 Base URL 固定为 https://ai.51easyai.com/api/v1。' },
{ key: 'chat', surface: 'open', group: '文本', kind: 'chat.completions', method: 'POST', path: '/api/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', surface: 'open', group: '文本', kind: 'responses', method: 'POST', path: '/api/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', surface: 'open', group: '文本', kind: 'embeddings', method: 'POST', path: '/api/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', surface: 'open', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', surface: 'open', group: '图片', kind: 'images.generations', method: 'POST', path: '/api/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', surface: 'open', group: '图片', kind: 'images.edits', method: 'POST', path: '/api/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'videoGeneration', surface: 'open', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
{ key: 'asyncMode', surface: 'open', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' },
{ key: 'taskRetrieve', surface: 'open', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' },
{ key: 'pricing', surface: 'open', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
{ key: 'files', surface: 'open', group: '文件', method: 'POST', path: '/api/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
{ key: 'compatApiOverview', surface: 'compatibility', catalogSurface: 'compatibility', group: '接口总览', title: '兼容接口', lead: '为已有厂商 SDK 和存量业务保留请求、响应及任务协议;调用方只需替换 Base URL 和 Gateway API Key。' },
{ key: 'compatGemini', surface: 'compatibility', catalogFamily: 'gemini', group: '协议分类', title: 'Gemini 兼容接口', lead: '保留 generateContent 和 Gemini Files 请求结构,统一通过 /api/v1 前缀接入。' },
{ key: 'compatKling', surface: 'compatibility', catalogFamily: 'kling', group: '协议分类', title: '可灵兼容接口', lead: '覆盖中国区可灵官方 V1 Omni、网关 V1 和 API 2.0;上游 AK/SK 只保存在网关服务端。' },
{ key: 'compatVolces', surface: 'compatibility', catalogFamily: 'volces', group: '协议分类', title: '火山兼容接口', lead: '保留火山内容生成任务的创建、列表、查询和取消协议。' },
{ key: 'compatServerMain', surface: 'compatibility', catalogFamily: 'serverMain', group: '协议分类', title: 'server-main 兼容接口', lead: '保留 server-main 的视频提交与结果轮询响应结构。' },
];
const guideItems: ApiGuideItem[] = [
{ key: 'guideBaseUrl', group: '指南', title: '获取 Base URL 和 API Key', lead: '确认当前部署环境的网关地址,创建 API Key,并使用 Bearer 鉴权调用公开接口。' },
{ key: 'guideWebhook', group: '指南', title: '通知设置-WebHook 参数介绍', lead: '了解任务进度 WebHook 的启用方式、回调结构、可靠投递机制和公开 API 的状态取回方式。' },
{ key: 'guideErrors', group: '指南', title: '错误码', lead: '根据 HTTP 状态、error.code、requestId 和异步任务错误字段快速定位请求失败原因。' },
{ key: 'guideTesting', group: '指南', title: '测试模式', lead: '使用 simulation 完整验证鉴权、路由、队列、限流、进度和结果归一,而不向真实供应商提交任务。' },
];
const taskKindOptions = [
['chat.completions', 'Chat'],
['responses', 'Responses'],
['embeddings', '文本向量'],
['reranks', '重排序'],
['images.generations', '生图'],
['images.edits', '图像编辑'],
['videos.generations', '视频生成'],
['tasks.retrieve', '取回任务'],
] as const;
const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = {
name: 'ai-gateway-ops-management',
version: '',
displayName: 'AI Gateway 运维管理',
modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api/v1/openapi.json',
apiDocsYamlPath: '/api/v1/openapi.yaml',
};
export function ApiDocsPage(props: {
activeDocSection: ApiDocSection;
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
canRun: boolean;
coreMessage: string;
coreState: LoadState;
selectedApiKeyId: string;
taskForm: TaskForm;
taskResult: GatewayTask | null;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onLogin: () => void;
onDocSectionChange: (value: ApiDocSection) => void;
onSubmitTask: (event: FormEvent<HTMLFormElement>, options?: RunTaskOptions) => void;
onTaskFormChange: (value: TaskForm) => void;
}) {
const activeGuide = guideItems.find((item) => item.key === props.activeDocSection);
const currentApiDoc = apiDocs.find((item) => item.key === props.activeDocSection) ?? (activeGuide ? undefined : apiDocs[0]);
const current = activeGuide ?? currentApiDoc ?? apiDocs[0];
const [opsSkillMetadata, setOpsSkillMetadata] = useState<GatewaySkillBundleMetadata>(defaultOpsSkillMetadata);
const [docsSearch, setDocsSearch] = useState('');
const normalizedDocsSearch = docsSearch.trim().toLowerCase();
const visibleGuideItems = guideItems.filter((item) => guideMatchesSearch(item, normalizedDocsSearch));
const visibleApiDocs = apiDocs.filter((item) => apiDocMatchesSearch(item, normalizedDocsSearch));
const isFileDoc = currentApiDoc?.key === 'files';
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
const isCatalogDoc = Boolean(currentApiDoc?.catalogSurface || currentApiDoc?.catalogFamily);
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET');
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
const [submissionMode, setSubmissionMode] = useState<TaskSubmissionMode>('simulation');
const [bodyDraft, setBodyDraft] = useState(() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
const [bodyError, setBodyError] = useState('');
const runnerPath = currentApiDoc?.path
? isTaskRetrieveDoc
? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}')
: currentApiDoc.path
: '';
useEffect(() => {
if (currentApiDoc?.kind && props.taskForm.kind !== currentApiDoc.kind) {
props.onTaskFormChange(defaultTaskForDoc(currentApiDoc.kind, props.taskForm, props.taskResult));
}
}, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]);
useEffect(() => {
setSubmissionMode('simulation');
setBodyDraft(requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
setBodyError('');
}, [currentApiDoc?.key, props.taskForm]);
useEffect(() => {
let active = true;
getOpsManagementSkillMetadata()
.then((metadata) => {
if (active) setOpsSkillMetadata(metadata);
})
.catch(() => {
// Keep stable public fallback paths visible when metadata is temporarily unavailable.
});
return () => {
active = false;
};
}, []);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!runnerAvailable) {
return;
}
if (!props.canRun) {
props.onLogin();
return;
}
if (runnerModeEnabled) {
const parsed = parseEditableRequestBody(bodyDraft);
if (parsed.error || !parsed.body) {
setBodyError(parsed.error);
return;
}
const requestBody = applyTaskSubmissionMode(parsed.body, submissionMode);
setBodyDraft(JSON.stringify(requestBody, null, 2));
setBodyError('');
props.onSubmitTask(event, { requestBody, submissionMode });
return;
}
props.onSubmitTask(event);
}
function handleBodyChange(value: string) {
setBodyDraft(value);
setBodyError(parseEditableRequestBody(value).error);
}
function handleBodyBlur() {
const parsed = parseEditableRequestBody(bodyDraft);
if (parsed.error || !parsed.body) {
setBodyError(parsed.error);
return;
}
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, submissionMode), null, 2));
setBodyError('');
}
function handleSubmissionModeChange(nextMode: TaskSubmissionMode) {
const parsed = parseEditableRequestBody(bodyDraft);
if (parsed.error || !parsed.body) {
setBodyError('请先修正请求 Body 的 JSON 格式,再切换运行模式。');
return;
}
setSubmissionMode(nextMode);
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, nextMode), null, 2));
setBodyError('');
}
function handleDocClick(item: ApiDocItem) {
if (item.kind) {
props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult));
}
props.onDocSectionChange(item.key);
}
function handleGuideClick(item: ApiGuideItem) {
props.onDocSectionChange(item.key);
}
function handleKindChange(kind: TaskKind) {
props.onTaskFormChange(defaultTaskForDoc(kind, props.taskForm, props.taskResult));
const nextSection = docSectionForKind(kind);
if (nextSection !== props.activeDocSection) {
props.onDocSectionChange(nextSection);
}
}
return (
<div className="apiDocsShell">
<aside className="docsSidebar">
<div className="docsBrand">
<BookOpen size={19} />
<strong>API Docs</strong>
</div>
<label className="docsSearch">
<Search size={15} />
<input
aria-label="搜索 API 文档"
placeholder="搜索名称或路径"
value={docsSearch}
onChange={(event) => setDocsSearch(event.target.value)}
/>
</label>
{visibleGuideItems.length > 0 && (
<DocsGroup
title="指南"
items={visibleGuideItems.map((item) => ({
active: item.key === props.activeDocSection,
title: item.title,
onClick: () => handleGuideClick(item),
}))}
/>
)}
{groupDocsBySurface(visibleApiDocs).map((surface) => (
<section className="docsSurface" data-surface={surface.key} key={surface.key}>
<div className="docsSurfaceHeader">
<strong>{apiSurfaceTitle(surface.key)}</strong>
<span>{publicApiEndpointCount(surface.key)} </span>
</div>
{groupDocs(surface.items).map((group) => (
<DocsGroup
key={group.title}
title={group.title}
items={group.items.map((item) => ({
active: item.key === props.activeDocSection,
method: item.method,
title: item.title,
onClick: () => handleDocClick(item),
}))}
/>
))}
</section>
))}
{normalizedDocsSearch && visibleGuideItems.length === 0 && visibleApiDocs.length === 0 && (
<div className="docsSearchEmpty">没有找到匹配的接口或指南</div>
)}
</aside>
<main className="docsArticle">
<p className="eyebrow">{current.group}</p>
<h1>{current.title}</h1>
{currentApiDoc?.method && currentApiDoc.path && (
<div className="endpointBar">
<MethodBadge method={currentApiDoc.method} />
<code>{currentApiDoc.path}</code>
</div>
)}
<p className="docsLead">{current.lead}</p>
{isCatalogDoc && currentApiDoc ? (
<ApiCatalogDetails
family={currentApiDoc.catalogFamily}
search={normalizedDocsSearch}
surface={currentApiDoc.catalogSurface ?? currentApiDoc.surface}
/>
) : isAsyncModeDoc ? (
<DocDetails section="asyncMode" />
) : currentApiDoc ? (
<>
{!isTaskRetrieveDoc && <OpsManagementSkillCard metadata={opsSkillMetadata} />}
<section className="paramCard">
<header>
<h2>Header 参数</h2>
<Button type="button" variant="secondary" size="sm">生成代码</Button>
</header>
{currentApiDoc.method !== 'GET' && <ParamRow name="Content-Type" type="string" required value={isFileDoc ? 'multipart/form-data' : 'application/json'} />}
<ParamRow name="Accept" type="string" required value="application/json" />
<ParamRow name="Authorization" type="string" required value={authorizationDescription(currentApiDoc.key)} />
{supportsAsyncMode(currentApiDoc.key) && (
<ParamRow name="X-Async" type="boolean" value="设为 true 后立即返回 202 和 taskId;省略时按接口默认方式执行。" />
)}
</section>
<section className="paramCard">
<header>
<h2>{isTaskRetrieveDoc ? 'Path 参数' : 'Body 参数'}</h2>
{!isTaskRetrieveDoc && <Badge variant="outline">{isFileDoc ? 'multipart/form-data' : 'application/json'}</Badge>}
</header>
{isTaskRetrieveDoc ? (
<ParamRow name="taskID" type="string" required value="异步提交响应中的 taskId,也可直接使用 next.detail 返回的地址。" />
) : isFileDoc ? (
<>
<ParamRow name="file" type="file" required value="multipart 文件字段" />
<ParamRow name="source" type="string" value="上传来源标记" />
</>
) : (
<ParamRows rows={bodyParamRows(currentApiDoc.key)} />
)}
</section>
{isTaskRetrieveDoc && <TaskResponseSchema />}
<DocDetails section={currentApiDoc.key} />
</>
) : activeGuide ? (
<GuideDetails onCreateApiKey={props.onCreateApiKey} section={activeGuide.key} />
) : null}
</main>
<aside className="docsRunner">
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc ? <form onSubmit={handleSubmit}>
<header>
<strong>在线运行</strong>
<Button type="submit" size="sm" disabled={!runnerAvailable || Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
<Send size={14} />
{!runnerAvailable ? '暂不支持' : props.canRun ? '发送' : '登录'}
</Button>
</header>
<div className="runnerEndpoint">
<MethodBadge method={currentApiDoc.method} />
<span>{runnerPath}</span>
</div>
<label className="shLabel">
API Key
<ApiKeySelect
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
selectedApiKeyId={activeApiKeyId}
onApiKeyChange={props.onApiKeyChange}
/>
</label>
{apiKeyNotice && (
<div className="docsKeyNotice">
<span>{apiKeyNotice}</span>
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
<KeyRound size={14} />
创建 Key
</Button>
</div>
)}
{runnerAvailable ? (
<>
{runnerModeEnabled && (
<div className="runnerModeField">
<span className="runnerModeLabel">运行模式</span>
<div className="runnerModeToggle" role="group" aria-label="运行模式">
<button
type="button"
aria-pressed={submissionMode === 'simulation'}
data-active={submissionMode === 'simulation'}
onClick={() => handleSubmissionModeChange('simulation')}
>
测试模式
</button>
<button
type="button"
aria-pressed={submissionMode === 'production'}
data-active={submissionMode === 'production'}
onClick={() => handleSubmissionModeChange('production')}
>
真实提交
</button>
</div>
<p className="runnerModeHint" data-mode={submissionMode}>
{submissionMode === 'simulation'
? '不触达真实供应商,不消耗上游额度。'
: '请求将真实提交给供应商,可能产生费用。'}
</p>
</div>
)}
<label className="shLabel">
能力
<Select value={props.taskForm.kind} onChange={(event) => handleKindChange(event.target.value as TaskKind)}>
{taskKindOptions.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</Select>
</label>
{isTaskRetrieveDoc ? (
<label className="shLabel">
Task ID
<Input
placeholder="输入异步提交返回的 taskId"
value={props.taskForm.taskId ?? ''}
onChange={(event) => props.onTaskFormChange({ ...props.taskForm, taskId: event.target.value })}
/>
</label>
) : (
<div className="runnerBodyField">
<label className="shLabel">
请求 Body
<Textarea
aria-describedby={bodyError ? 'docs-runner-body-error' : undefined}
aria-invalid={Boolean(bodyError)}
value={bodyDraft}
onBlur={handleBodyBlur}
onChange={(event) => handleBodyChange(event.target.value)}
/>
</label>
{bodyError && <span className="runnerBodyError" id="docs-runner-body-error" role="alert">{bodyError}</span>}
</div>
)}
<Button type="submit" disabled={Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
<Play size={15} />
{!props.canRun
? '登录后运行'
: props.coreState === 'loading'
? '运行中'
: submissionMode === 'simulation' || !runnerModeEnabled
? '运行测试'
: '真实提交'}
</Button>
</>
) : (
<div className="docsRunnerUnavailable">该接口暂未接入在线运行,请使用上方 Swagger 定义生成调用代码。</div>
)}
</form> : (
<section className="docsGuideAside">
<header><strong>{isCatalogDoc ? '接口分类' : isAsyncModeDoc ? '能力说明' : '指南导航'}</strong></header>
<div>
<span>当前章节</span>
<strong>{current.title}</strong>
<p>{isCatalogDoc
? '所有新接入路径统一以 /api/v1 开头。选择兼容协议时只替换 Base URL 和 Gateway API Key。'
: isAsyncModeDoc
? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。'
: '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}</p>
</div>
</section>
)}
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc && <section className="runnerResult">
<strong>返回结果</strong>
{props.taskResult ? (
<pre>{JSON.stringify(isTaskRetrieveDoc ? props.taskResult : props.taskResult.result ?? props.taskResult, null, 2)}</pre>
) : (
<div className="emptyState">
<span>点击发送获取返回结果</span>
</div>
)}
{props.coreMessage && <p>{props.coreMessage}</p>}
</section>}
</aside>
</div>
);
}
function ApiCatalogDetails(props: { family?: PublicApiFamily; search: string; surface: PublicApiSurface }) {
const catalogGroups = publicApiCatalogGroups.filter((group) => (
group.surface === props.surface && (!props.family || group.family === props.family)
));
const visibleGroups = catalogGroups
.map((group) => {
const groupMatches = searchText([group.title, group.description, group.family], props.search);
const endpoints = groupMatches
? group.endpoints
: group.endpoints.filter((endpoint) => searchText([endpoint.method, endpoint.path, endpoint.description], props.search));
return { ...group, endpoints };
})
.filter((group) => group.endpoints.length > 0);
const endpointCount = catalogGroups.reduce((total, group) => total + group.endpoints.length, 0);
const visibleEndpointCount = visibleGroups.reduce((total, group) => total + group.endpoints.length, 0);
const isCompatibility = props.surface === 'compatibility';
const baseUrl = props.family === 'kling'
? 'https://ai.51easyai.com/api/v1/kling'
: 'https://ai.51easyai.com/api/v1';
return (
<>
<section className="docsCatalogSummary" aria-label="API 接入摘要">
<article>
<span>{props.family === 'kling' ? '可灵客户端 Base URL' : '生产 Base URL'}</span>
<code>{baseUrl}</code>
</article>
<article>
<span>生成接口鉴权</span>
<code>Authorization: Bearer {'<Gateway API Key>'}</code>
</article>
<article>
<span>接口数量</span>
<strong>{endpointCount}</strong>
</article>
</section>
<section className="docsCatalogNotice" data-surface={props.surface}>
<strong>{isCompatibility ? '存量协议接入规则' : '新业务接入规则'}</strong>
<p>{isCompatibility
? '保留对应厂商或存量系统的请求字段、响应包络和任务语义。调用方替换 Base URL 与 Gateway API Key 即可,上游凭据不会下发。'
: '优先使用通用开放接口。所有路径统一位于 /api/v1;模型能力由模型目录与服务端配置决定,不要求额外传入 modelType。'}</p>
</section>
{props.surface === 'open' && <OpenApiUsageNotes />}
{isCompatibility && <CompatibilityUsageNotes family={props.family} />}
{props.search && (
<p className="docsCatalogSearchResult">当前筛选显示 {visibleEndpointCount} / {endpointCount} 项接口</p>
)}
{visibleGroups.map((group) => (
<section className="paramCard docsCatalogGroup" key={group.family}>
<header>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<Badge variant="outline">{group.endpoints.length} </Badge>
</header>
<div className="docsCatalogEndpoints">
{group.endpoints.map((endpoint) => (
<div className="docsCatalogEndpoint" key={`${endpoint.method}-${endpoint.path}`}>
<MethodBadge method={endpoint.method} />
<code>{endpoint.path}</code>
<span>{endpoint.description}</span>
</div>
))}
</div>
</section>
))}
{visibleGroups.length === 0 && (
<div className="docsCatalogEmpty">当前分类中没有匹配“{props.search}”的接口。</div>
)}
</>
);
}
function OpenApiUsageNotes() {
return (
<section className="paramCard docsDetailCard">
<header><h2>统一调用约定</h2></header>
<div className="docsStatusGrid">
<code>Base URL</code><p>SDK 配置到 <code>https://ai.51easyai.com/api/v1</code>,请求资源路径时不要再次拼接 <code>/api/v1</code>。</p>
<code>同步 / 异步</code><p>生成接口保留原请求 Body;需要持久化异步任务时增加 <code>X-Async: true</code>,再通过 <code>/tasks/{'{taskID}'}</code> 取回结果。</p>
<code>全模态能力</code><p>模型可直接使用服务端声明的文生图、文生视频、图生视频等能力,不要求调用方显式传入 <code>modelType</code></p>
<code>公开端点</code><p>健康检查和 public 目录无需登录;账号管理使用登录会话,生成与任务接口使用 Gateway API Key</p>
</div>
</section>
);
}
function CompatibilityUsageNotes(props: { family?: PublicApiFamily }) {
if (props.family === 'kling') {
return (
<section className="paramCard docsDetailCard">
<header><h2>可灵 V1 / API 2.0 约定</h2></header>
<div className="docsStatusGrid">
<code>SDK Base URL</code><p>设置为 <code>https://ai.51easyai.com/api/v1/kling</code>,之后按原客户端继续请求 <code>/v1/...</code> 或 <code>/v2/...</code>。</p>
<code>O1</code><p>使用 <code>kling-video-o1</code>;兼容别名 <code>kling-o1</code> 会自动归一。</p>
<code>3.0 Omni</code><p>使用 <code>kling-v3-omni</code>;兼容 <code>kling-3.0-omni</code> <code>kling-3-omni</code></p>
<code>鉴权</code><p>客户端只发送 Gateway API Key。可灵中国区 AK/SK 保存在网关平台配置中。</p>
</div>
</section>
);
}
if (props.family === 'gemini') {
return (
<section className="paramCard docsDetailCard">
<header><h2>Gemini 接入约定</h2></header>
<div className="docsDetailContent">
<p><code>generateContent</code> 保留 Gemini 内容结构;Files 上传的 <code>version</code> 支持 <code>v1</code> <code>v1beta</code>。新接入路径仍统一放在 Gateway <code>/api/v1</code> 前缀下。</p>
</div>
</section>
);
}
if (props.family === 'volces') {
return (
<section className="paramCard docsDetailCard">
<header><h2>火山任务约定</h2></header>
<div className="docsDetailContent">
<p>创建、列表、查询和删除接口保留火山任务结构。旧 <code>/api/v3</code> 仅作为兼容别名,不应写入新客户端配置。</p>
</div>
</section>
);
}
if (props.family === 'serverMain') {
return (
<section className="paramCard docsDetailCard">
<header><h2>server-main 接入约定</h2></header>
<div className="docsDetailContent">
<p>提交接口返回兼容的 <code>submitted</code> <code>task_id</code>,现有调用方继续通过 <code>/ai/result/{'{taskID}'}</code> 轮询结果。</p>
</div>
</section>
);
}
return (
<section className="paramCard docsDetailCard">
<header><h2>兼容层边界</h2></header>
<div className="docsDetailContent">
<p>兼容接口用于无缝迁移已有 SDK 或存量调用。新业务优先选择通用开放接口,以获得统一的异步任务、结果归一、计费和模型能力语义。</p>
</div>
</section>
);
}
function OpsManagementSkillCard(props: { metadata: GatewaySkillBundleMetadata }) {
const modules = props.metadata.modules.map(skillModuleLabel).join('、');
return (
<section className="agentResourceCard">
<div className="agentResourceIcon">
<Wrench size={22} />
</div>
<div className="agentResourceContent">
<div className="agentResourceTitle">
<div>
<p className="eyebrow">Agent 运维资源</p>
<h2>{props.metadata.displayName}</h2>
</div>
<Badge variant="outline">{props.metadata.version ? `v${props.metadata.version}` : '最新版本'}</Badge>
</div>
<p>用于让 Agent 安全操作厂商、基础模型、定价、运行策略、平台、平台模型和 universal 自定义平台。</p>
<div className="agentResourceModules">
<span>当前模块</span>
<strong>{modules || '模型运行时'}</strong>
</div>
<div className="agentResourceActions">
<Button asChild size="sm">
<a href={resolveApiAssetUrl(props.metadata.downloadPath)}>
<Download size={14} />
下载 SKILL
</a>
</Button>
<Button asChild size="sm" variant="secondary">
<a href={resolveApiAssetUrl(props.metadata.apiDocsJsonPath)} rel="noreferrer" target="_blank">
<FileJson size={14} />
Swagger JSON
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a href={resolveApiAssetUrl(props.metadata.apiDocsYamlPath)} rel="noreferrer" target="_blank">
<ExternalLink size={14} />
Swagger YAML
</a>
</Button>
<a
className="agentResourceMetadataLink"
href={resolveApiAssetUrl('/api/v1/public/skills/ai-gateway-ops-management/metadata')}
rel="noreferrer"
target="_blank"
>
查看 metadata
</a>
</div>
</div>
</section>
);
}
function skillModuleLabel(module: string) {
if (module === 'model-runtime') return '模型运行时';
return module;
}
function DocsGroup(props: {
items: Array<{ active?: boolean; method?: ApiDocItem['method']; onClick?: () => void; title: string }>;
title: string;
}) {
return (
<section className="docsGroup">
<h3>{props.title}</h3>
{props.items.map((item) => (
<button type="button" data-active={item.active} key={item.title} onClick={item.onClick}>
<span>{item.title}</span>
{item.method && <MethodBadge method={item.method} />}
</button>
))}
</section>
);
}
function ParamRows(props: { depth?: number; parentPath?: string; rows: ApiDocParam[] }) {
const depth = props.depth ?? 0;
const parentPath = props.parentPath ?? '';
return props.rows.map((row) => {
const path = parentPath ? `${parentPath}.${row.name}` : row.name;
return (
<Fragment key={path}>
<ParamRow {...row} depth={depth} />
{row.children?.length ? (
<ParamRows depth={depth + 1} parentPath={path} rows={row.children} />
) : null}
</Fragment>
);
});
}
function ParamRow(props: ApiDocParam & { depth?: number }) {
const depth = props.depth ?? 0;
const nameStyle: CSSProperties = depth ? { paddingLeft: `${depth * 20}px` } : {};
const markerStyle: CSSProperties = depth ? { left: `${(depth - 1) * 20 + 3}px` } : {};
return (
<div className="paramRow" data-depth={depth} data-nested={depth > 0 ? 'true' : undefined}>
<div className="paramName" style={nameStyle}>
{depth > 0 && <span aria-hidden="true" className="paramTreeMarker" style={markerStyle} />}
<code>{props.name}</code>
</div>
<span>{props.type}</span>
<p>{props.value}</p>
<em>{props.required ? '必需' : '可选'}</em>
</div>
);
}
function GuideDetails(props: { onCreateApiKey: () => void; section: ApiGuideSection }) {
if (props.section === 'guideBaseUrl') {
return (
<>
<GuideSection title="1. 确认 Base URL">
<p>公开 API Base URL 固定以 <code>/api/v1</code> 结尾。接口卡片展示的是完整路径;SDK 配置 Base URL 后只追加资源路径,不要再次添加 <code>/api/v1</code></p>
<pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com/api/v1"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre>
</GuideSection>
<GuideSection title="2. 创建并使用 API Key">
<p>登录后在“用户工作台 API Key”创建 Key,并为它分配实际需要的 chatembeddingrerankimage video 权限。密钥只在创建或重置时完整展示,请立即安全保存。</p>
<div className="docsGuideActions">
<Button type="button" size="sm" onClick={props.onCreateApiKey}>
<KeyRound size={14} />
前往创建 API Key
</Button>
</div>
<pre>{`Authorization: Bearer sk-...\nContent-Type: application/json\nAccept: application/json`}</pre>
</GuideSection>
</>
);
}
if (props.section === 'guideWebhook') {
return (
<>
<GuideSection title="任务进度 WebHook">
<p>Gateway 的任务进度 WebHook 由部署管理员统一配置,用于把任务事件可靠投递到业务服务;它不是公开请求中可任意覆盖的回调地址。</p>
<pre>{`TASK_PROGRESS_CALLBACK_ENABLED=true\nTASK_PROGRESS_CALLBACK_URL=https://service.example.com/internal/task-progress`}</pre>
<p>每次状态变化会先写入任务事件和 callback outbox,再异步投递。失败不会阻塞任务执行,并会按 outbox 策略重试。</p>
</GuideSection>
<GuideSection title="回调 Body">
<pre>{`{\n "taskId": "9f4d8f3d-...",\n "seq": 3,\n "eventType": "progress",\n "status": "running",\n "phase": "generating",\n "progress": 0.6,\n "message": "任务处理中",\n "payload": {},\n "simulated": false,\n "createdAt": "2026-07-17T07:00:00Z"\n}`}</pre>
<p>公开 API 客户端无需依赖 WebHook:异步提交后可直接使用 <code>next.detail</code> 查询任务,或使用 <code>next.events</code> 订阅进度事件。</p>
</GuideSection>
</>
);
}
if (props.section === 'guideErrors') {
return (
<>
<GuideSection title="同步请求错误结构">
<pre>{`{\n "error": {\n "message": "api key scope does not allow this capability",\n "status": 403,\n "code": "forbidden"\n }\n}`}</pre>
<p>先判断 HTTP 状态,再读取 <code>error.code</code> <code>error.message</code>;响应中包含 <code>requestId</code> 时请一并保存,便于关联上游请求和运行日志。</p>
</GuideSection>
<section className="paramCard docsDetailCard">
<header><h2>常见状态与处理方式</h2></header>
<div className="docsStatusGrid">
<code>400 invalid_parameter</code><p>请求结构或参数不受支持,检查字段类型、必填项及模型能力。</p>
<code>401 unauthorized</code><p>缺少、失效或无法识别的 API Key</p>
<code>403 forbidden</code><p>API Key 权限范围不包含当前能力,或账号无权访问该模型。</p>
<code>404 not_found</code><p>任务、模型或其他目标资源不存在。</p>
<code>429 rate_limit</code><p>触发 RPMTPM 或并发限制;按响应和运行策略退避重试。</p>
<code>5xx provider_failed</code><p>Gateway 或上游供应商暂时失败;结合 retryableattempts requestId 排查。</p>
</div>
</section>
<GuideSection title="异步任务失败">
<p>异步提交成功不代表生成成功。任务进入 <code>failed</code> 后,读取任务详情中的 <code>errorCode</code><code>errorMessage</code><code>requestId</code> <code>attempts</code></p>
</GuideSection>
</>
);
}
return (
<>
<GuideSection title="启用请求级测试模式">
<p>在请求 Body 中同时设置 <code>runMode: simulation</code> <code>simulation: true</code>。在线运行示例默认使用该配置。</p>
<pre>{`{\n "model": "gpt-4o-mini",\n "messages": [{ "role": "user", "content": "你好" }],\n "runMode": "simulation",\n "simulation": true\n}`}</pre>
</GuideSection>
<GuideSection title="测试模式的执行边界">
<p>测试任务仍会执行真实鉴权、模型路由、持久化队列、限流、进度事件和结果归一,因此可用于验证完整 Gateway 链路。</p>
<p>它不会向真实供应商提交任务、不会创建真实供应商订单,也不会产生真实扣费;任务、事件和结算结果会带有 <code>simulated=true</code> 标记。</p>
</GuideSection>
</>
);
}
function GuideSection(props: { children: ReactNode; title: string }) {
return (
<section className="paramCard docsDetailCard">
<header><h2>{props.title}</h2></header>
<div className="docsDetailContent">{props.children}</div>
</section>
);
}
function TaskResponseSchema() {
return (
<section className="paramCard">
<header>
<h2>响应 Body</h2>
<Badge variant="outline">application/json</Badge>
</header>
<ParamRows rows={taskResponseParamRows()} />
</section>
);
}
function DocDetails(props: { section: ApiDocSection }) {
if (props.section === 'responses') {
return (
<section className="paramCard docsDetailCard">
<header><h2>连续对话与状态归属</h2></header>
<div className="docsDetailContent">
<p>首次请求不传 <code>previous_response_id</code>。后续请求如需由 Responses 链续接,传入上一轮返回的 <code>response.id</code></p>
<p>未传 <code>previous_response_id</code> 时,网关以本轮完整 input 为准,不自动追加本地历史;客户端自行管理的完整上下文始终优先。</p>
<pre>{`{
"model": "gpt-4o-mini",
"input": "继续上一轮回答",
"previous_response_id": "resp_..."
}`}</pre>
</div>
</section>
);
}
if (props.section === 'videoGeneration') {
return (
<section className="paramCard docsDetailCard">
<header><h2>参考素材角色</h2></header>
<div className="docsDetailContent">
<p><code>image_url</code> 可使用 <code>first_frame</code><code>last_frame</code><code>reference_image</code><code>video_url</code> <code>audio_url</code> 可作为视频、音频参考。实际数量和组合由所选模型能力约束。</p>
<p>视频生成通常耗时较长,生产调用建议设置 <code>X-Async: true</code>,再使用响应中的 <code>next.detail</code> 或任务取回接口查询结果。</p>
</div>
</section>
);
}
if (props.section === 'asyncMode') {
return (
<>
<section className="paramCard">
<header><h2>开启方式</h2></header>
<ParamRow name="X-Async" type="boolean" required value="在原请求 Header 中设置为 true;请求 URL、鉴权方式和 Body 均保持原接口定义不变。" />
</section>
<section className="paramCard docsDetailCard">
<header><h2>同步与异步的区别</h2></header>
<div className="docsStatusGrid">
<code>请求 Body</code><p>始终使用原接口的参数。异步模式没有独立的模型、提示词或媒体参数。</p>
<code>未设置 X-Async</code><p>连接等待任务执行,并按原接口协议返回同步 JSON SSE 流。</p>
<code>X-Async: true</code><p>任务进入持久化队列,接口立即返回 HTTP 202;客户端通过 taskId 取回状态和最终结果。</p>
<code>适用范围</code><p>作为 AI 任务创建接口的统一能力使用,不与某一种文本、图片、视频或音频 Body 绑定。</p>
</div>
</section>
<section className="paramCard docsDetailCard">
<header><h2>异步受理响应</h2></header>
<div className="docsDetailContent">
<p>保存 <code>taskId</code><code>next.detail</code> 用于取回完整任务结果,<code>next.events</code> 用于订阅进度事件。</p>
<pre>{`HTTP/1.1 202 Accepted
{
"taskId": "9f4d8f3d-...",
"task": {
"id": "9f4d8f3d-...",
"status": "queued",
"asyncMode": true
},
"next": {
"detail": "/api/v1/tasks/9f4d8f3d-...",
"events": "/api/v1/tasks/9f4d8f3d-.../events"
}
}`}</pre>
</div>
</section>
</>
);
}
if (props.section === 'taskRetrieve') {
return (
<>
<section className="paramCard docsDetailCard">
<header><h2>状态与结果读取</h2></header>
<div className="docsStatusGrid">
<code>queued / submitting / running</code><p>任务仍在进行,保留 taskId 并继续查询;此时 <code>result</code> 通常为空。</p>
<code>succeeded</code><p>任务成功,原能力的归一化响应完整保存在 <code>result</code>;用量与结算分别位于 <code>usage</code><code>billings</code></p>
<code>failed</code><p>任务失败,读取 <code>errorCode</code><code>errorMessage</code><code>requestId</code> <code>attempts</code></p>
<code>cancelled</code><p>任务已取消,不再继续执行;结合 <code>submitted</code> 判断是否曾提交到上游。</p>
</div>
</section>
<section className="paramCard docsDetailCard">
<header><h2>成功响应示例</h2></header>
<div className="docsDetailContent">
<p><code>result</code> 的内部字段与原始能力一致:文本、向量、重排序和媒体任务会保留各自的结果结构,外层任务字段保持统一。</p>
<pre>{`{
"id": "9f4d8f3d-...",
"kind": "images.generations",
"model": "image-model",
"asyncMode": true,
"status": "succeeded",
"result": {
"data": [{ "url": "https://example.com/result.png" }]
},
"usage": { "totalTokens": 0 },
"billings": [{
"resourceType": "image",
"unit": "image",
"quantity": 1,
"amount": 0.1,
"currency": "resource",
"simulated": false
}],
"billingSummary": {
"lineCount": 1,
"totalAmount": 0.1,
"currency": "resource",
"simulated": false
},
"finalChargeAmount": 0.1,
"attempts": [{
"attemptNo": 1,
"provider": "provider-name",
"status": "succeeded",
"retryable": false,
"responseDurationMs": 1280
}],
"createdAt": "2026-07-17T07:00:00Z",
"updatedAt": "2026-07-17T07:00:02Z",
"finishedAt": "2026-07-17T07:00:02Z"
}`}</pre>
</div>
</section>
</>
);
}
return null;
}
function authorizationDescription(section: ApiDocSection) {
const scope = section === 'embeddings'
? 'embedding'
: section === 'reranks'
? 'rerank'
: section === 'videoGeneration' || section === 'asyncMode'
? 'video'
: section === 'chat' || section === 'responses'
? 'chat'
: '';
return `Bearer {{YOUR_API_KEY}},支持本地 API Key;管理接口仍使用 JWT。${scope ? `当前接口需要 ${scope} 权限。` : ''}`;
}
function supportsAsyncMode(section: ApiDocSection) {
return section === 'chat'
|| section === 'responses'
|| section === 'embeddings'
|| section === 'reranks'
|| section === 'imageGeneration'
|| section === 'imageEdit'
|| section === 'videoGeneration';
}
function MethodBadge(props: { method: PublicApiMethod }) {
const variant = props.method === 'GET'
? 'success'
: props.method === 'DELETE'
? 'destructive'
: props.method === 'PATCH'
? 'secondary'
: 'warning';
return <Badge variant={variant}>{props.method}</Badge>;
}
function groupDocs(items: ApiDocItem[]) {
const groups = new Map<string, ApiDocItem[]>();
for (const item of items) {
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
}
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
}
function groupDocsBySurface(items: ApiDocItem[]) {
const groups = new Map<PublicApiSurface, ApiDocItem[]>();
for (const item of items) {
groups.set(item.surface, [...(groups.get(item.surface) ?? []), item]);
}
return Array.from(groups, ([key, surfaceItems]) => ({ key, items: surfaceItems }));
}
function apiSurfaceTitle(surface: PublicApiSurface) {
return surface === 'open' ? '通用开放接口' : '兼容接口';
}
function guideMatchesSearch(item: ApiGuideItem, search: string) {
return searchText([item.title, item.lead, item.group], search);
}
function apiDocMatchesSearch(item: ApiDocItem, search: string) {
if (searchText([item.title, item.lead, item.path, item.group, apiSurfaceTitle(item.surface)], search)) {
return true;
}
if (!item.catalogSurface && !item.catalogFamily) return false;
return publicApiCatalogGroups
.filter((group) => (
(!item.catalogSurface || group.surface === item.catalogSurface)
&& (!item.catalogFamily || group.family === item.catalogFamily)
))
.some((group) => searchText([
group.title,
group.description,
...group.endpoints.flatMap((endpoint) => [endpoint.method, endpoint.path, endpoint.description]),
], search));
}
function searchText(values: Array<string | undefined>, search: string) {
if (!search) return true;
return values.some((value) => value?.toLowerCase().includes(search));
}
function defaultTaskForDoc(kind: TaskForm['kind'], current: TaskForm, taskResult: GatewayTask | null): TaskForm {
const next = defaultTaskForKind(kind, current);
if (kind === 'tasks.retrieve' && !next.taskId && taskResult && !taskResult.id.startsWith('docs-')) {
return { ...next, taskId: taskResult.id };
}
return next;
}
function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm {
if (kind === 'chat.completions') return { ...current, kind, model: 'gpt-4o-mini' };
if (kind === 'responses') {
return {
...current,
instructions: current.instructions ?? '回答保持简洁。',
kind,
model: 'gpt-4o-mini',
prompt: current.kind === kind ? current.prompt : '用一句话说明 Responses API 的用途。',
};
}
if (kind === 'embeddings') {
return {
...current,
dimensions: current.dimensions ?? 4,
kind,
model: 'text-embedding-v4',
prompt: current.prompt || 'AI Gateway 提供 OpenAI 兼容接口。',
};
}
if (kind === 'reranks') {
return {
...current,
documents: current.documents ?? 'AI Gateway 提供 OpenAI 兼容接口。\n图片生成任务支持异步队列。\n文本向量接口返回 embedding 数组。',
kind,
model: 'qwen3-rerank',
prompt: current.prompt || 'OpenAI 兼容接口',
topN: current.topN ?? 2,
};
}
if (kind === 'images.edits') return { ...current, kind, image: current.image ?? 'https://example.com/source.png', mask: current.mask ?? 'https://example.com/mask.png', model: 'gpt-image-1' };
if (kind === 'images.generations') return { ...current, kind, model: 'gpt-image-1' };
if (kind === 'videos.generations') {
return {
...current,
aspectRatio: current.aspectRatio ?? '16:9',
duration: current.duration ?? 5,
kind,
model: '豆包Seedance-2.0',
outputAudio: current.outputAudio ?? true,
prompt: current.kind === kind ? current.prompt : '电影感航拍镜头掠过日出时的雪山。',
resolution: current.resolution ?? '720p',
};
}
return { ...current, kind, model: 'task' };
}
function requestBodyExample(task: TaskForm, section: ApiDocSection, submissionMode: TaskSubmissionMode) {
const body = task.kind === 'chat.completions'
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], stream: false }
: task.kind === 'responses'
? {
model: task.model,
input: task.prompt,
instructions: task.instructions,
previous_response_id: task.previousResponseId || undefined,
store: true,
stream: false,
}
: task.kind === 'embeddings'
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4 }
: task.kind === 'reranks'
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2 }
: task.kind === 'images.edits'
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask }
: task.kind === 'videos.generations'
? {
model: task.model,
content: [{ type: 'text', text: task.prompt }],
duration: task.duration ?? 5,
resolution: task.resolution ?? '720p',
aspect_ratio: task.aspectRatio ?? '16:9',
audio: task.outputAudio ?? true,
}
: section === 'pricing'
? { kind: 'chat.completions', model: 'gpt-4o-mini', messages: [{ role: 'user', content: '你好' }], max_tokens: 512 }
: { model: task.model, prompt: task.prompt, quality: 'medium', size: '1024x1024' };
return JSON.stringify(section === 'pricing' ? body : applyTaskSubmissionMode(body, submissionMode), null, 2);
}
function parseEditableRequestBody(value: string): { body: Record<string, unknown> | null; error: string } {
try {
const body = JSON.parse(value) as unknown;
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { body: null, error: '请求 Body 必须是 JSON 对象。' };
}
return { body: body as Record<string, unknown>, error: '' };
} catch (error) {
const detail = error instanceof SyntaxError ? error.message : '无法解析 JSON';
return { body: null, error: `JSON 格式有误:${detail}` };
}
}
function taskResponseParamRows(): ApiDocParam[] {
return [
{ name: 'id', type: 'string', required: true, value: 'Gateway 任务 ID,与异步受理响应中的 taskId 相同' },
{ name: 'kind', type: 'string', required: true, value: '原始任务类型,例如 chat.completions、images.generations 或 videos.generations' },
{ name: 'model', type: 'string', required: true, value: '请求使用的模型 ID 或别名' },
{ name: 'status', type: 'string', required: true, value: 'queued、submitting、running、succeeded、failed 或 cancelled' },
{ name: 'asyncMode', type: 'boolean', required: true, value: '是否以异步模式提交' },
{ name: 'cancellable', type: 'boolean', value: '当前状态是否允许取消' },
{ name: 'submitted', type: 'boolean', value: '任务是否已经提交到上游供应商' },
{ name: 'message', type: 'string', value: '当前状态或取消能力的补充说明' },
{ name: 'requestId', type: 'string', value: '上游请求 ID,用于日志和供应商问题定位' },
{ name: 'result', type: 'object', value: '任务成功后的归一化结果;内部结构与原始文本、向量、重排序、图片、视频或音频接口一致' },
{
name: 'usage',
type: 'object',
value: '归一化用量数据;媒体任务可能使用能力相关的用量字段',
children: [
{ name: 'inputTokens', type: 'number', value: '输入 token 数' },
{ name: 'outputTokens', type: 'number', value: '输出 token 数' },
{ name: 'totalTokens', type: 'number', value: '总 token 数' },
],
},
{
name: 'billings',
type: 'array<object>',
value: '逐项计费明细',
children: [
{ name: 'resourceType', type: 'string', required: true, value: '计费资源类型,例如 text_input、image 或 video' },
{ name: 'unit', type: 'string', required: true, value: '计费单位,例如 1k_tokens、image 或 5s_video' },
{ name: 'quantity', type: 'number', required: true, value: '计费数量' },
{ name: 'amount', type: 'number', required: true, value: '本计费项金额' },
{ name: 'currency', type: 'string', required: true, value: '金额单位,默认 resource' },
{ name: 'discountFactor', type: 'number', value: '实际应用的折扣系数' },
{ name: 'simulated', type: 'boolean', required: true, value: '是否为模拟计费' },
],
},
{
name: 'billingSummary',
type: 'object',
value: '计费汇总',
children: [
{ name: 'lineCount', type: 'number', required: true, value: '计费明细条数' },
{ name: 'totalAmount', type: 'number', required: true, value: '汇总金额' },
{ name: 'amountByCurrency', type: 'object', required: true, value: '按金额单位汇总的金额映射' },
{ name: 'currency', type: 'string', required: true, value: '单一金额单位或 mixed' },
{ name: 'simulated', type: 'boolean', required: true, value: '是否为模拟计费汇总' },
],
},
{ name: 'finalChargeAmount', type: 'number', required: true, value: '最终实际扣费金额;模拟任务不会真实扣费' },
{
name: 'attempts',
type: 'array<object>',
value: '执行尝试记录;发生重试或切换平台时可能包含多项',
children: [
{ name: 'attemptNo', type: 'number', required: true, value: '尝试序号,从 1 开始' },
{ name: 'provider', type: 'string', value: '供应商标识' },
{ name: 'modelName', type: 'string', value: 'Gateway 模型名称' },
{ name: 'providerModelName', type: 'string', value: '实际上游模型名称' },
{ name: 'status', type: 'string', required: true, value: '本次尝试状态' },
{ name: 'retryable', type: 'boolean', required: true, value: '失败后是否允许重试' },
{ name: 'simulated', type: 'boolean', required: true, value: '是否由 SimulationClient 执行' },
{ name: 'requestId', type: 'string', value: '本次尝试的上游请求 ID' },
{ name: 'responseDurationMs', type: 'number', required: true, value: '本次尝试的响应耗时(毫秒)' },
{ name: 'errorCode', type: 'string', value: '本次尝试失败时的错误码' },
{ name: 'errorMessage', type: 'string', value: '本次尝试失败时的错误信息' },
],
},
{ name: 'errorCode', type: 'string', value: '任务最终失败时的错误码' },
{ name: 'errorMessage', type: 'string', value: '任务最终失败时的错误信息' },
{ name: 'responseDurationMs', type: 'number', required: true, value: '任务响应耗时(毫秒)' },
{ name: 'createdAt', type: 'string', required: true, value: '任务创建时间,ISO 8601 格式' },
{ name: 'updatedAt', type: 'string', required: true, value: '任务最后更新时间,ISO 8601 格式' },
{ name: 'finishedAt', type: 'string', value: '任务进入终态的时间,ISO 8601 格式' },
];
}
function bodyParamRows(section: ApiDocSection): ApiDocParam[] {
if (section === 'responses') {
return [
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名;网关优先选择支持 openai_responses 的平台模型' },
{ name: 'input', type: 'string|array', required: true, value: '本轮输入,可为文本或 Responses input item 数组' },
{ name: 'instructions', type: 'string', value: '本轮系统级指令' },
{ name: 'previous_response_id', type: 'string', value: '上一轮 response.id;用于由客户端显式管理连续对话' },
{ name: 'store', type: 'boolean', value: '是否保存 Responses 链,默认 true' },
{ name: 'stream', type: 'boolean', value: '设为 true 时返回 Responses SSE 事件流' },
{ name: 'tools / tool_choice', type: 'array|object', value: '函数工具定义与选择策略;Chat 回退只支持 function tools' },
{ name: 'reasoning / text', type: 'object', value: '推理强度、文本格式和结构化输出配置' },
{ name: 'background', type: 'boolean', value: 'OpenAI 协议字段;Gateway 的持久化异步任务请使用 X-Async' },
];
}
if (section === 'embeddings') {
return [
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名,例如 text-embedding-v4' },
{ name: 'input', type: 'string|array', required: true, value: '需要向量化的文本或文本数组' },
{ name: 'dimensions', type: 'number', value: '可选向量维度,需模型支持' },
{ name: 'runMode / simulation', type: 'string|boolean', value: '在线测试时使用 simulation,不消耗真实上游额度' },
];
}
if (section === 'reranks') {
return [
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名,例如 qwen3-rerank' },
{ name: 'query', type: 'string', required: true, value: '用于相关性排序的查询文本' },
{ name: 'documents', type: 'array', required: true, value: '候选文档文本数组,至少 1 条' },
{ name: 'top_n', type: 'number', value: '返回前 N 条结果' },
{ name: 'runMode / simulation', type: 'string|boolean', value: '在线测试时使用 simulation,不消耗真实上游额度' },
];
}
if (section === 'videoGeneration') {
return [
{ name: 'model', type: 'string', required: true, value: '视频模型 ID 或别名,例如 豆包Seedance-2.0' },
{
name: 'content',
type: 'array<object>',
required: true,
value: '文本和参考素材对象数组;每一项由 type 决定应填写的内容字段和 role',
children: [
{ name: 'type', type: 'string', required: true, value: '内容类型:text、image_url、video_url、audio_url 或 element' },
{ name: 'text', type: 'string', value: 'type=text 时的提示词;配合 role=shot_prompt 可描述指定分镜' },
{
name: 'image_url',
type: 'object',
value: 'type=image_url 时的图片素材',
children: [
{ name: 'url', type: 'string', required: true, value: '可公开访问或已上传的图片 URL' },
],
},
{
name: 'video_url',
type: 'object',
value: 'type=video_url 时的视频素材及参考方式',
children: [
{ name: 'url', type: 'string', required: true, value: '可公开访问或已上传的视频 URL' },
{ name: 'mime_type', type: 'string', value: '视频 MIME 类型,例如 video/mp4' },
{ name: 'refer_type', type: 'string', value: '参考方式:feature(特征参考)或 base(基础视频)' },
{ name: 'keep_original_sound', type: 'string', value: '是否保留原声:yes 或 no' },
],
},
{
name: 'audio_url',
type: 'object',
value: 'type=audio_url 时的音频素材',
children: [
{ name: 'url', type: 'string', required: true, value: '可公开访问或已上传的音频 URL' },
{ name: 'mime_type', type: 'string', value: '音频 MIME 类型,例如 audio/mpeg' },
],
},
{ name: 'role', type: 'string', value: '素材角色,例如 first_frame、last_frame、reference_image、reference_video、reference_audio、element 或 shot_prompt' },
{ name: 'shot_index', type: 'number', value: '素材或提示词对应的分镜序号' },
{ name: 'duration', type: 'number', value: '当前分镜或素材的时长(秒)' },
{ name: 'name', type: 'string', value: '素材或元素名称' },
{
name: 'element',
type: 'object',
value: 'type=element 时使用的系统元素或内联元素',
children: [
{ name: 'system_element_id', type: 'string', value: '已存在的系统元素 ID' },
{
name: 'inline_element',
type: 'object',
value: '随请求提交的内联元素定义',
children: [
{ name: 'name', type: 'string', required: true, value: '内联元素名称' },
{ name: 'description', type: 'string', value: '内联元素描述' },
{ name: 'frontal_image_url', type: 'string', required: true, value: '元素正面参考图 URL' },
{
name: 'refer_images',
type: 'array<object>',
required: true,
value: '元素的补充参考图片对象数组',
children: [
{ name: 'url', type: 'string', required: true, value: '参考图片 URL' },
{ name: 'slot_key', type: 'string', value: '参考图槽位标识' },
],
},
{ name: 'tags', type: 'array<string>', value: '元素标签数组' },
],
},
],
},
],
},
{ name: 'duration', type: 'number', value: '视频时长(秒),允许范围取决于模型能力' },
{ name: 'resolution', type: 'string', value: '输出分辨率,例如 480p、720p、1080p' },
{ name: 'aspect_ratio', type: 'string', value: '画幅,例如 16:9、9:16、1:1 或 adaptive' },
{ name: 'audio', type: 'boolean', value: '是否生成或保留声音,需模型支持 output_audio' },
{
name: 'audio_list',
type: 'array<object>',
value: '额外音频素材对象数组',
children: [
{ name: 'url', type: 'string', value: '音频 URL' },
{ name: 'audio_url', type: 'string', value: '音频 URL 的兼容字段' },
{ name: 'name', type: 'string', value: '音频名称' },
],
},
{ name: 'prompt', type: 'string', value: '纯文生视频的快捷字段;网关会转换为 content 文本项' },
{ name: 'runMode / simulation', type: 'string|boolean', value: '在线测试时使用 simulation,不触达真实视频供应商' },
];
}
if (section === 'pricing') {
return [
{ name: 'kind', type: 'string', value: '任务类型,默认 chat.completions' },
{ name: 'model', type: 'string', required: true, value: '需要估价的模型 ID 或别名' },
{ name: 'messages / prompt', type: 'array|string', value: '用于估算输入规模的请求内容' },
{ name: 'max_tokens / n', type: 'number', value: '预期输出 token 或媒体数量' },
];
}
if (section === 'chat') {
return [
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名' },
{ name: 'messages', type: 'array', required: true, value: 'OpenAI Chat 消息数组' },
{ name: 'stream', type: 'boolean', value: '设为 true 时返回 Chat Completions SSE' },
{ name: 'tools / tool_choice', type: 'array|object', value: '函数工具定义与选择策略' },
{ name: 'simulation', type: 'boolean', value: '测试模式开关' },
];
}
return [
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名' },
{ name: 'prompt', type: 'string', required: true, value: '图片生成或编辑提示词' },
{ name: 'image / mask', type: 'string|array', value: '图像编辑输入与可选遮罩' },
{ name: 'simulation', type: 'boolean', value: '测试模式开关' },
];
}
function docSectionForKind(kind: TaskKind): ApiDocSection {
if (kind === 'responses') return 'responses';
if (kind === 'embeddings') return 'embeddings';
if (kind === 'reranks') return 'reranks';
if (kind === 'images.generations') return 'imageGeneration';
if (kind === 'images.edits') return 'imageEdit';
if (kind === 'videos.generations') return 'videoGeneration';
if (kind === 'tasks.retrieve') return 'taskRetrieve';
return 'chat';
}
function embeddingInputExample(prompt: string) {
const lines = splitLines(prompt);
return lines.length > 1 ? lines : (lines[0] ?? prompt);
}
function rerankDocumentsExample(value?: string) {
return splitLines(value ?? '').length
? splitLines(value ?? '')
: ['AI Gateway 提供 OpenAI 兼容接口。', '图片生成任务支持异步队列。'];
}
function splitLines(value: string) {
return value
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean);
}