1118 lines
52 KiB
TypeScript
1118 lines
52 KiB
TypeScript
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 数组或字符串生成 embedding,API Key 需要 embedding 权限。' },
|
||
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API 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,并为它分配实际需要的 chat、embedding、rerank、image 或 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>触发 RPM、TPM 或并发限制;按响应和运行策略退避重试。</p>
|
||
<code>5xx provider_failed</code><p>Gateway 或上游供应商暂时失败;结合 retryable、attempts 和 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;
|
||
}
|