feat: Add support for OpenAI Responses endpoint and enhance proxy retry logic

- Updated proxyRetryPolicy to retry on all 4xx and 5xx status codes.
- Introduced 'responses' target format in TestChatRequestPayload.
- Implemented extractResponsesContent function to handle responses format in ModelTester.
- Enhanced ModelTester to support responses target format and display appropriate labels.
- Updated ProxyLogs to extract and display upstream paths from error messages.
- Added tests to ensure responses target format is preserved during payload sync.
- Created responses proxy route to handle requests to the new OpenAI Responses endpoint.
- Updated upstreamEndpoint logic to include responses endpoint handling.
- Added modelService tests to validate token route rebuilding logic.
This commit is contained in:
cita
2026-03-01 15:21:16 +08:00
parent 44af9a91cf
commit 4e22b379a8
21 changed files with 1794 additions and 56 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ async function request(url: string, options: RequestOptions = {}) {
type TestChatRequestPayload = {
model: string;
messages: Array<{ role: string; content: string }>;
targetFormat?: 'openai' | 'claude';
targetFormat?: 'openai' | 'claude' | 'responses';
stream?: boolean;
temperature?: number;
top_p?: number;
+147 -1
View File
@@ -93,6 +93,78 @@ const extractClaudeMessageContent = (result: any): { content: string; reasoningC
};
};
const extractResponsesContent = (result: any): { content: string; reasoningContent: string } => {
const contentParts: string[] = [];
const reasoningParts: string[] = [];
const pushContent = (value: unknown) => {
if (typeof value === 'string' && value.length > 0) contentParts.push(value);
};
const pushReasoning = (value: unknown) => {
if (typeof value === 'string' && value.length > 0) reasoningParts.push(value);
};
const directOutputText = result?.output_text;
if (typeof directOutputText === 'string') {
pushContent(directOutputText);
} else if (Array.isArray(directOutputText)) {
for (const item of directOutputText) {
if (typeof item === 'string') {
pushContent(item);
continue;
}
if (item && typeof item === 'object' && typeof (item as any).text === 'string') {
pushContent((item as any).text);
}
}
}
const outputs = Array.isArray(result?.output)
? result.output
: (result && typeof result === 'object' && (Array.isArray(result?.content) || typeof result?.type === 'string'))
? [result]
: [];
for (const item of outputs) {
if (!item || typeof item !== 'object') continue;
const itemType = typeof item.type === 'string' ? item.type : '';
if (itemType === 'output_text') {
pushContent(item.text);
continue;
}
if (itemType === 'reasoning') {
if (typeof item.summary_text === 'string') pushReasoning(item.summary_text);
if (typeof item.reasoning === 'string') pushReasoning(item.reasoning);
if (Array.isArray(item.summary)) {
for (const summaryItem of item.summary) {
if (summaryItem && typeof summaryItem === 'object' && typeof (summaryItem as any).text === 'string') {
pushReasoning((summaryItem as any).text);
}
}
}
}
const content = Array.isArray((item as any).content) ? (item as any).content : [];
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const blockType = typeof (block as any).type === 'string' ? (block as any).type : '';
if (blockType === 'output_text' || blockType === 'text') {
pushContent((block as any).text);
continue;
}
if (blockType.includes('reasoning')) {
pushReasoning((block as any).text);
}
}
}
return {
content: contentParts.join(''),
reasoningContent: reasoningParts.join('\n\n'),
};
};
const extractAssistantResult = (result: unknown): { content: string; reasoningContent: string } => {
const data = result as any;
let content = '';
@@ -113,6 +185,10 @@ const extractAssistantResult = (result: unknown): { content: string; reasoningCo
const parsedClaude = extractClaudeMessageContent(data);
content = parsedClaude.content;
reasoning = parsedClaude.reasoningContent;
} else if (data?.object === 'response' || Array.isArray(data?.output) || typeof data?.output_text === 'string') {
const parsedResponses = extractResponsesContent(data);
content = parsedResponses.content;
reasoning = parsedResponses.reasoningContent;
} else if (Array.isArray(data?.candidates)) {
const parts = data?.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
@@ -306,6 +382,71 @@ const parseAnyStreamDelta = (eventPayload: any): {
}
if (typeof eventPayload.type === 'string') {
if (eventPayload.type === 'response.output_item.added' || eventPayload.type === 'response.output_item.done') {
const parsed = extractResponsesContent(eventPayload.item || eventPayload.output_item || eventPayload.response || eventPayload);
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoningContent || undefined,
};
}
if (eventPayload.type === 'response.content_part.added' || eventPayload.type === 'response.content_part.done') {
const part = eventPayload.part || eventPayload.content_part || eventPayload;
const parsed = extractResponsesContent(part);
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoningContent || undefined,
};
}
if (eventPayload.type === 'response.content_part.delta') {
const delta = eventPayload.delta;
if (typeof delta === 'string') return { contentDelta: delta || undefined };
if (delta && typeof delta === 'object') {
const parsed = extractResponsesContent(delta);
if (parsed.content || parsed.reasoningContent) {
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoningContent || undefined,
};
}
const text = typeof (delta as any).text === 'string' ? (delta as any).text : '';
return { contentDelta: text || undefined };
}
}
if (eventPayload.type === 'response.output_text.delta') {
const text = typeof eventPayload.delta === 'string'
? eventPayload.delta
: typeof eventPayload.text === 'string'
? eventPayload.text
: '';
return { contentDelta: text || undefined };
}
if (eventPayload.type === 'response.reasoning_summary_text.delta' || eventPayload.type === 'response.reasoning.delta') {
const text = typeof eventPayload.delta === 'string'
? eventPayload.delta
: typeof eventPayload.text === 'string'
? eventPayload.text
: '';
return { reasoningDelta: text || undefined };
}
if (eventPayload.type === 'response.output_text.done') {
const text = typeof eventPayload.text === 'string' ? eventPayload.text : '';
return { contentDelta: text || undefined };
}
if (eventPayload.type === 'response.completed' || eventPayload.type === 'response.failed') {
const parsed = extractResponsesContent(eventPayload.response || eventPayload);
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoningContent || undefined,
done: true,
};
}
if (eventPayload.type === 'content_block_delta') {
const delta = eventPayload.delta || {};
const deltaType = typeof delta.type === 'string' ? delta.type : '';
@@ -649,6 +790,7 @@ export default function ModelTester() {
);
const targetFormatOptions = useMemo<Array<{ value: TestTargetFormat; label: string }>>(() => ([
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'responses', label: 'OpenAI Responses (/v1/responses)' },
{ value: 'claude', label: 'Claude (/v1/messages)' },
]), []);
@@ -1198,7 +1340,11 @@ export default function ModelTester() {
<div className="stat-summary-card-value" style={{ fontSize: 14 }}>
{(customRequestMode ? '自定义请求' : (inputs.stream ? '流式' : '任务模式'))}
{' / '}
{inputs.targetFormat === 'claude' ? 'Claude' : 'OpenAI'}
{inputs.targetFormat === 'claude'
? 'Claude'
: inputs.targetFormat === 'responses'
? 'OpenAI Responses'
: 'OpenAI'}
</div>
</div>
</div>
+29 -11
View File
@@ -48,6 +48,17 @@ function latencyBgColor(ms: number) {
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
}
function extractUpstreamPath(errorMessage?: string): string | null {
if (!errorMessage) return null;
const matched = errorMessage.match(/\[upstream:(\/v1\/[^\]]+)\]/i);
return matched?.[1] || null;
}
function stripUpstreamPrefix(errorMessage?: string): string {
if (!errorMessage) return '';
return errorMessage.replace(/^\[upstream:\/v1\/[^\]]+\]\s*/i, '');
}
export default function ProxyLogs() {
const [logs, setLogs] = useState<ProxyLog[]>([]);
const [loading, setLoading] = useState(true);
@@ -174,7 +185,10 @@ export default function ProxyLogs() {
</tr>
</thead>
<tbody>
{paged.map(log => (
{paged.map(log => {
const upstreamPath = extractUpstreamPath(log.errorMessage);
const errorMessageDisplay = stripUpstreamPrefix(log.errorMessage);
return (
<React.Fragment key={log.id}>
<tr
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
@@ -280,20 +294,24 @@ export default function ProxyLogs() {
{/* 请求路径 */}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>请求路径</span>
<code style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
border: '1px solid var(--color-border-light)',
}}>
/v1/chat/completions
</code>
{upstreamPath ? (
<code style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
border: '1px solid var(--color-border-light)',
}}>
{upstreamPath}
</code>
) : (
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
)}
</div>
{/* 错误信息 */}
{log.errorMessage && (
{errorMessageDisplay.trim().length > 0 && (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
<span style={{ color: 'var(--color-danger)' }}>{log.errorMessage}</span>
<span style={{ color: 'var(--color-danger)' }}>{errorMessageDisplay}</span>
</div>
)}
</div>
@@ -301,7 +319,7 @@ export default function ProxyLogs() {
</tr>
)}
</React.Fragment>
))}
)})}
</tbody>
</table>
)}
@@ -199,6 +199,17 @@ describe('modelTesterSession', () => {
expect(syncedJson.messages).toEqual([{ role: 'user', content: 'new' }]);
});
it('keeps responses target format in custom payload sync', () => {
const synced = syncMessagesToCustomRequestBody(
'{"model":"gpt-5.2","targetFormat":"responses"}',
[{ id: '1', role: 'user', content: 'hello', createAt: 1 }],
{ ...DEFAULT_INPUTS, model: 'gpt-5.2', targetFormat: 'openai' },
);
const syncedJson = JSON.parse(synced);
expect(syncedJson.targetFormat).toBe('responses');
});
it('merges marketplace models with exact enabled route models for tester options', () => {
const modelNames = collectModelTesterModelNames(
{
+17 -5
View File
@@ -14,7 +14,7 @@ export const DEBUG_TABS = {
type MessageStatus = typeof MESSAGE_STATUS[keyof typeof MESSAGE_STATUS];
export type DebugTab = typeof DEBUG_TABS[keyof typeof DEBUG_TABS];
type ChatRole = 'user' | 'assistant' | 'system';
export type TestTargetFormat = 'openai' | 'claude';
export type TestTargetFormat = 'openai' | 'claude' | 'responses';
export type ChatMessage = {
id: string;
@@ -256,7 +256,11 @@ const parseInputs = (value: unknown, fallbackModel = ''): ModelTesterInputs => {
return {
model,
targetFormat: value.targetFormat === 'claude' ? 'claude' : DEFAULT_INPUTS.targetFormat,
targetFormat: value.targetFormat === 'claude'
? 'claude'
: value.targetFormat === 'responses'
? 'responses'
: DEFAULT_INPUTS.targetFormat,
temperature: toFiniteNumber(value.temperature, DEFAULT_INPUTS.temperature),
top_p: toFiniteNumber(value.top_p, DEFAULT_INPUTS.top_p),
max_tokens: toFiniteNumber(value.max_tokens, DEFAULT_INPUTS.max_tokens),
@@ -298,7 +302,7 @@ const parsePendingPayload = (value: unknown): TestChatPayload | null => {
messages: payloadMessages,
};
if (value.targetFormat === 'claude' || value.targetFormat === 'openai') {
if (value.targetFormat === 'claude' || value.targetFormat === 'openai' || value.targetFormat === 'responses') {
payload.targetFormat = value.targetFormat;
}
if (typeof value.stream === 'boolean') payload.stream = value.stream;
@@ -385,7 +389,11 @@ const buildFallbackInputsFromLegacy = (value: Record<string, unknown>): ModelTes
const inputs = parseInputs(value.inputs, legacyModel);
if (!value.inputs) {
inputs.targetFormat = value.targetFormat === 'claude' ? 'claude' : inputs.targetFormat;
inputs.targetFormat = value.targetFormat === 'claude'
? 'claude'
: value.targetFormat === 'responses'
? 'responses'
: inputs.targetFormat;
inputs.temperature = toFiniteNumber(value.temperature, inputs.temperature);
}
@@ -511,7 +519,11 @@ export const syncMessagesToCustomRequestBody = (
payload.model = typeof payload.model === 'string' && payload.model.trim().length > 0
? payload.model
: inputs.model;
payload.targetFormat = payload.targetFormat === 'claude' ? 'claude' : inputs.targetFormat;
payload.targetFormat = payload.targetFormat === 'claude'
? 'claude'
: payload.targetFormat === 'responses'
? 'responses'
: inputs.targetFormat;
payload.stream = payload.stream !== undefined ? payload.stream : inputs.stream;
payload.messages = toApiMessages(messages);