forked from boos/BoosAPI
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:
@@ -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
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user