easyai-ai-gateway/apps/web/src/pages/ApiDocsPage.tsx

1118 lines
52 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, useMemo, 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 type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types';
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
interface ApiDocItem {
group: string;
key: ApiDocSection;
kind?: TaskKind;
lead: string;
method?: 'GET' | 'POST';
path?: string;
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: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'videoGeneration', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
{ key: 'asyncMode', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body只需增加 X-Async: true。' },
{ key: 'taskRetrieve', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试queued、running、submitting 为进行中状态。' },
{ key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
{ key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
];
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-docs-json',
apiDocsYamlPath: '/api-docs-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>) => 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 isFileDoc = currentApiDoc?.key === 'files';
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
const bodyExample = useMemo(
() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'),
[currentApiDoc?.key, props.taskForm],
);
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(() => {
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>) {
if (!runnerAvailable) {
event.preventDefault();
return;
}
if (!props.canRun) {
event.preventDefault();
props.onLogin();
return;
}
props.onSubmitTask(event);
}
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 placeholder="搜索" />
</label>
<DocsGroup
title="指南"
items={guideItems.map((item) => ({
active: item.key === props.activeDocSection,
title: item.title,
onClick: () => handleGuideClick(item),
}))}
/>
{groupDocs(apiDocs).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),
}))}
/>
))}
</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>
{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 || (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 ? (
<>
<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>
) : (
<label className="shLabel">
Body
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
</label>
)}
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
<Play size={15} />
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
</Button>
</>
) : (
<div className="docsRunnerUnavailable">线使 Swagger </div>
)}
</form> : (
<section className="docsGuideAside">
<header><strong>{isAsyncModeDoc ? '能力说明' : '指南导航'}</strong></header>
<div>
<span></span>
<strong>{current.title}</strong>
<p>{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 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>Base URL Gateway Base URL </p>
<pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com"\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: NonNullable<ApiDocItem['method']> }) {
return <Badge variant={props.method === 'GET' ? 'success' : 'warning'}>{props.method}</Badge>;
}
function groupDocs(items: typeof apiDocs) {
const groups = new Map<string, typeof apiDocs>();
for (const item of items) {
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
}
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
}
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) {
const body = task.kind === 'chat.completions'
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], runMode: 'simulation', simulation: true, stream: false }
: task.kind === 'responses'
? {
model: task.model,
input: task.prompt,
instructions: task.instructions,
previous_response_id: task.previousResponseId || undefined,
store: true,
stream: false,
runMode: 'simulation',
simulation: true,
}
: task.kind === 'embeddings'
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4, runMode: 'simulation', simulation: true }
: task.kind === 'reranks'
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2, runMode: 'simulation', simulation: true }
: task.kind === 'images.edits'
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask, runMode: 'simulation', simulation: true }
: 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,
runMode: 'simulation',
simulation: 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', runMode: 'simulation', simulation: true, size: '1024x1024' };
return JSON.stringify(body, null, 2);
}
function parseBody(value: string, current: TaskForm): TaskForm {
try {
const body = JSON.parse(value) as {
image?: string;
aspect_ratio?: string;
audio?: boolean;
content?: Array<{ text?: string; type?: string }>;
duration?: number;
instructions?: string;
mask?: string;
messages?: Array<{ content?: string }>;
model?: string;
previous_response_id?: string;
prompt?: string;
input?: unknown;
query?: string;
documents?: string[];
resolution?: string;
top_n?: number;
dimensions?: number;
};
return {
...current,
aspectRatio: body.aspect_ratio ?? current.aspectRatio,
dimensions: numberOrCurrent(body.dimensions, current.dimensions),
documents: Array.isArray(body.documents) ? body.documents.join('\n') : current.documents,
duration: numberOrCurrent(body.duration, current.duration),
image: body.image ?? current.image,
instructions: body.instructions ?? current.instructions,
mask: body.mask ?? current.mask,
model: body.model ?? current.model,
outputAudio: typeof body.audio === 'boolean' ? body.audio : current.outputAudio,
previousResponseId: body.previous_response_id ?? current.previousResponseId,
prompt: body.prompt ?? body.query ?? inputText(body.input) ?? contentText(body.content) ?? body.messages?.[0]?.content ?? current.prompt,
resolution: body.resolution ?? current.resolution,
topN: numberOrCurrent(body.top_n, current.topN),
};
} catch {
return current;
}
}
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);
}
function inputText(value: unknown) {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
const texts = value.map((item) => typeof item === 'string' ? item : '').filter(Boolean);
return texts.length ? texts.join('\n') : undefined;
}
return undefined;
}
function contentText(value?: Array<{ text?: string; type?: string }>) {
return value?.find((item) => item.type === 'text' && item.text)?.text;
}
function numberOrCurrent(value: unknown, current?: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : current;
}