forked from boos/BoosAPI
feat(proxy): enhance upstream endpoint handling and error summarization
- Added new utility functions for building minimal JSON headers and normalizing responses for compatibility. - Implemented detailed error handling for unsupported media types and endpoint downgrade scenarios. - Introduced a new endpoint flow mechanism to manage retries and downgrades between different upstream endpoints. - Enhanced tests for endpoint flow and error summarization to cover various edge cases and scenarios. - Updated CSS styles for accounts table to improve layout and responsiveness. - Refactored ModelTester component to handle incremental text updates more effectively.
This commit is contained in:
@@ -503,12 +503,21 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
|
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
|
||||||
|
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
|
||||||
|
// New-API family already runs shield-aware probing inside adapters.
|
||||||
|
// Raw fallback probe below does not include challenge-solving and can
|
||||||
|
// misclassify valid Cookie/Session flows as shield-blocked.
|
||||||
|
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
|
||||||
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
|
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
|
||||||
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
|
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
|
||||||
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
||||||
const text = bodyText || '';
|
const text = bodyText || '';
|
||||||
const ct = (contentType || '').toLowerCase();
|
const ct = (contentType || '').toLowerCase();
|
||||||
if (ct.includes('text/html') && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) {
|
if (
|
||||||
|
!skipRawShieldDetection
|
||||||
|
&& ct.includes('text/html')
|
||||||
|
&& /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)
|
||||||
|
) {
|
||||||
return 'shield-blocked';
|
return 'shield-blocked';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +525,9 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
const body = JSON.parse(text) as any;
|
const body = JSON.parse(text) as any;
|
||||||
const message = typeof body?.message === 'string' ? body.message : '';
|
const message = typeof body?.message === 'string' ? body.message : '';
|
||||||
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
|
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
|
||||||
if (/shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) return 'shield-blocked';
|
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
|
||||||
|
return 'shield-blocked';
|
||||||
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
const verifyTokenMock = vi.fn();
|
const verifyTokenMock = vi.fn();
|
||||||
const undiciFetchMock = vi.fn();
|
const undiciFetchMock = vi.fn();
|
||||||
|
let adapterPlatformName = 'new-api';
|
||||||
|
|
||||||
vi.mock('../../services/platforms/index.js', () => ({
|
vi.mock('../../services/platforms/index.js', () => ({
|
||||||
getAdapter: () => ({
|
getAdapter: () => ({
|
||||||
|
platformName: adapterPlatformName,
|
||||||
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
|
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -42,6 +44,7 @@ describe('accounts verify-token shield detection', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
verifyTokenMock.mockReset();
|
verifyTokenMock.mockReset();
|
||||||
undiciFetchMock.mockReset();
|
undiciFetchMock.mockReset();
|
||||||
|
adapterPlatformName = 'new-api';
|
||||||
|
|
||||||
db.delete(schema.proxyLogs).run();
|
db.delete(schema.proxyLogs).run();
|
||||||
db.delete(schema.checkinLogs).run();
|
db.delete(schema.checkinLogs).run();
|
||||||
@@ -84,7 +87,7 @@ describe('accounts verify-token shield detection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns shieldBlocked when upstream /api/user/self responds with challenge html', async () => {
|
it('avoids raw shieldBlocked misclassification for new-api when verifyToken returned tokenType unknown', async () => {
|
||||||
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||||
undiciFetchMock.mockResolvedValue({
|
undiciFetchMock.mockResolvedValue({
|
||||||
text: async () => '<html><script>var arg1="ABC123";</script></html>',
|
text: async () => '<html><script>var arg1="ABC123";</script></html>',
|
||||||
@@ -108,6 +111,75 @@ describe('accounts verify-token shield detection', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
message: 'Token invalid: cannot use it as session cookie or API key',
|
||||||
|
});
|
||||||
|
expect(undiciFetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses adapter platformName to skip raw shield detection for newapi alias site platform', async () => {
|
||||||
|
adapterPlatformName = 'new-api';
|
||||||
|
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||||
|
undiciFetchMock.mockResolvedValue({
|
||||||
|
text: async () => '<html><script>var arg1="ABC123";</script></html>',
|
||||||
|
headers: {
|
||||||
|
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'AnyRouter Alias',
|
||||||
|
url: 'https://anyrouter-alias.example.com',
|
||||||
|
platform: 'newapi',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts/verify-token',
|
||||||
|
payload: {
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'session-or-cookie-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
message: 'Token invalid: cannot use it as session cookie or API key',
|
||||||
|
});
|
||||||
|
expect(response.json()).not.toMatchObject({
|
||||||
|
shieldBlocked: true,
|
||||||
|
});
|
||||||
|
expect(undiciFetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns shieldBlocked for non-new-api platforms when challenge html is detected', async () => {
|
||||||
|
adapterPlatformName = 'one-api';
|
||||||
|
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||||
|
undiciFetchMock.mockResolvedValue({
|
||||||
|
text: async () => '<html><script>var arg1="ABC123";</script></html>',
|
||||||
|
headers: {
|
||||||
|
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'Legacy Shielded',
|
||||||
|
url: 'https://legacy-shield.example.com',
|
||||||
|
platform: 'one-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts/verify-token',
|
||||||
|
payload: {
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'session-or-cookie-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toMatchObject({
|
expect(response.json()).toMatchObject({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -448,6 +448,41 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(forwardedBody.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 });
|
expect(forwardedBody.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stops forwarding extra SSE events after message_stop on /v1/messages', async () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_stop_early","model":"claude-opus-4-6"}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: ping\ndata: {"type":"ping"}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
stream: true,
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toContain('event: message_stop');
|
||||||
|
expect(response.body).not.toContain('event: ping');
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => {
|
it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
id: 'msg_headers_adaptive',
|
id: 'msg_headers_adaptive',
|
||||||
@@ -655,7 +690,13 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(targetUrl).toContain('/v1/responses');
|
expect(targetUrl).toContain('/v1/responses');
|
||||||
const forwarded = JSON.parse(options.body);
|
const forwarded = JSON.parse(options.body);
|
||||||
expect(forwarded.model).toBe('upstream-gpt');
|
expect(forwarded.model).toBe('upstream-gpt');
|
||||||
expect(forwarded.input).toBe('hello');
|
expect(forwarded.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('continues downgrade to /v1/messages when /v1/chat/completions returns messages is required for /v1/responses', async () => {
|
it('continues downgrade to /v1/messages when /v1/chat/completions returns messages is required for /v1/responses', async () => {
|
||||||
@@ -921,6 +962,409 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(forwardedBody.reasoning).toEqual({ effort: 'high' });
|
expect(forwardedBody.reasoning).toEqual({ effort: 'high' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries /v1/responses without metadata when upstream returns empty upstream_error', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: '',
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'resp_retry_without_metadata',
|
||||||
|
object: 'response',
|
||||||
|
status: 'completed',
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
output_text: 'ok after stripping metadata',
|
||||||
|
output: [],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
metadata: { trace_id: 'req-1' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.output_text).toContain('ok after stripping metadata');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
expect(firstUrl).toContain('/v1/responses');
|
||||||
|
expect(secondUrl).toContain('/v1/responses');
|
||||||
|
|
||||||
|
const firstBody = JSON.parse(firstOptions.body);
|
||||||
|
const secondBody = JSON.parse(secondOptions.body);
|
||||||
|
expect(firstBody.metadata).toEqual({ trace_id: 'req-1' });
|
||||||
|
expect(secondBody.metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries /v1/responses with core body when upstream returns empty upstream_error', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: '',
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'resp_retry_core_body',
|
||||||
|
object: 'response',
|
||||||
|
status: 'completed',
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
output_text: 'ok after core retry',
|
||||||
|
output: [],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
store: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.output_text).toContain('ok after core retry');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [_firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [_secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
const firstBody = JSON.parse(firstOptions.body);
|
||||||
|
const secondBody = JSON.parse(secondOptions.body);
|
||||||
|
expect(firstBody.store).toBe(true);
|
||||||
|
expect(secondBody.store).toBeUndefined();
|
||||||
|
expect(secondBody.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries /v1/responses when upstream_error message is literal upstream_error and falls back to strict body', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'upstream_error',
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'upstream_error',
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'resp_retry_strict_body',
|
||||||
|
object: 'response',
|
||||||
|
status: 'completed',
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
output_text: 'ok after strict retry',
|
||||||
|
output: [],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
temperature: 0.7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.output_text).toContain('ok after strict retry');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
const [, thirdOptions] = fetchMock.mock.calls[2] as [string, any];
|
||||||
|
const firstBody = JSON.parse(firstOptions.body);
|
||||||
|
const secondBody = JSON.parse(secondOptions.body);
|
||||||
|
const thirdBody = JSON.parse(thirdOptions.body);
|
||||||
|
expect(firstBody.temperature).toBe(0.7);
|
||||||
|
expect(secondBody.temperature).toBe(0.7);
|
||||||
|
expect(thirdBody.temperature).toBeUndefined();
|
||||||
|
expect(thirdBody).toEqual({
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries generic 400 /v1/responses with minimal headers for strict compatibility fallback', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'request validation failed',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'request validation failed',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'resp_retry_minimal_headers',
|
||||||
|
object: 'response',
|
||||||
|
status: 'completed',
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
output_text: 'ok after minimal headers retry',
|
||||||
|
output: [],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
headers: {
|
||||||
|
'openai-beta': 'responses-2025-03-11',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
user: 'user-123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.output_text).toContain('ok after minimal headers retry');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
const [, thirdOptions] = fetchMock.mock.calls[2] as [string, any];
|
||||||
|
|
||||||
|
expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11');
|
||||||
|
expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11');
|
||||||
|
expect(thirdOptions.headers['openai-beta']).toBeUndefined();
|
||||||
|
|
||||||
|
const firstBody = JSON.parse(firstOptions.body);
|
||||||
|
const secondBody = JSON.parse(secondOptions.body);
|
||||||
|
const thirdBody = JSON.parse(thirdOptions.body);
|
||||||
|
expect(firstBody.user).toBe('user-123');
|
||||||
|
expect(secondBody.user).toBeUndefined();
|
||||||
|
expect(thirdBody.user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns concise Cloudflare host error on /v1/responses 502 html failures', async () => {
|
||||||
|
const html = '<!DOCTYPE html><html><head><title>qaq.al | 502: Bad gateway</title></head><body>Cloudflare Ray ID: 9d6f7c889ffbc8eb</body></html>';
|
||||||
|
fetchMock.mockImplementation(() => Promise.resolve(new Response(html, {
|
||||||
|
status: 502,
|
||||||
|
headers: { 'content-type': 'text/html; charset=UTF-8' },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(502);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.error?.type).toBe('upstream_error');
|
||||||
|
expect(body.error?.message).toContain('[upstream:');
|
||||||
|
expect(body.error?.message).toContain('Cloudflare 502: Bad gateway');
|
||||||
|
expect(body.error?.message).not.toContain('<!DOCTYPE html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downgrades /v1/responses to /v1/chat/completions when upstream returns 400 upstream_error', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: '',
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-fallback-upstream-error',
|
||||||
|
object: 'chat.completion',
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'ok via chat fallback from upstream_error' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
expect(firstUrl).toContain('/v1/responses');
|
||||||
|
expect(secondUrl).toContain('/v1/chat/completions');
|
||||||
|
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.object).toBe('response');
|
||||||
|
expect(body.output_text).toContain('ok via chat fallback from upstream_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downgrades /v1/responses to /v1/chat/completions when upstream responses endpoint returns 502', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(
|
||||||
|
'<!DOCTYPE html><html><head><title>qaq.al | 502: Bad gateway</title></head><body>Cloudflare</body></html>',
|
||||||
|
{
|
||||||
|
status: 502,
|
||||||
|
headers: { 'content-type': 'text/html; charset=UTF-8' },
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-fallback-502',
|
||||||
|
object: 'chat.completion',
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'ok via chat fallback' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
expect(firstUrl).toContain('/v1/responses');
|
||||||
|
expect(secondUrl).toContain('/v1/chat/completions');
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.object).toBe('response');
|
||||||
|
expect(body.output_text).toContain('ok via chat fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries /v1/chat/completions with minimal JSON headers on unsupported media type', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: "Unsupported Media Type: Only 'application/json' is allowed",
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 415,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-media-type-retry',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1_706_123_456,
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'ok after header retry' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
headers: {
|
||||||
|
'openai-beta': 'responses-2025-03-11',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
expect(firstUrl).toContain('/v1/chat/completions');
|
||||||
|
expect(secondUrl).toContain('/v1/chat/completions');
|
||||||
|
expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11');
|
||||||
|
expect(secondOptions.headers['openai-beta']).toBeUndefined();
|
||||||
|
expect(secondOptions.headers['content-type']).toBe('application/json');
|
||||||
|
expect(secondOptions.headers.accept).toBe('application/json');
|
||||||
|
});
|
||||||
|
|
||||||
it('sets stream accept header for /v1/responses when downstream omits accept', async () => {
|
it('sets stream accept header for /v1/responses when downstream omits accept', async () => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
@@ -952,6 +1396,99 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(options.headers.accept).toBe('text/event-stream');
|
expect(options.headers.accept).toBe('text/event-stream');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates cumulative text chunks when /v1/responses is converted from /v1/messages stream', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
supportedEndpointTypes: ['/v1/messages'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullText = "I'm Claude, an AI assistant made by Anthropic.";
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_dup_1","model":"upstream-gpt"}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode(`event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":${JSON.stringify(fullText)}}}\n\n`));
|
||||||
|
controller.enqueue(encoder.encode(`event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":${JSON.stringify(fullText)}}}\n\n`));
|
||||||
|
controller.enqueue(encoder.encode(`event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":${JSON.stringify(fullText)}}}\n\n`));
|
||||||
|
controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
stream: true,
|
||||||
|
input: 'whoru',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const deltaMatches = response.body.match(/event: response\.output_text\.delta/g) || [];
|
||||||
|
expect(deltaMatches.length).toBe(1);
|
||||||
|
const textMatches = response.body.match(/I'm Claude, an AI assistant made by Anthropic\./g) || [];
|
||||||
|
expect(textMatches.length).toBeGreaterThan(0);
|
||||||
|
expect(textMatches.length).toBeLessThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates overlapping text windows when /v1/responses is converted from /v1/messages stream', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
supportedEndpointTypes: ['/v1/messages'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_overlap_1","model":"upstream-gpt"}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"The user asked \\"whoru\\" which is a common"}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"which is a common internet slang"}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":" internet slang and shorthand for who are you."}}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
stream: true,
|
||||||
|
input: 'whoru',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toContain('"delta":" internet slang"');
|
||||||
|
expect(response.body).not.toContain('"delta":"which is a common internet slang"');
|
||||||
|
expect(response.body).toContain('"text":"The user asked \\"whoru\\" which is a common internet slang and shorthand for who are you."');
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves function_call/function_call_output when /v1/responses falls back to /v1/chat/completions', async () => {
|
it('preserves function_call/function_call_output when /v1/responses falls back to /v1/chat/completions', async () => {
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
models: [
|
models: [
|
||||||
|
|||||||
+141
-106
@@ -24,8 +24,10 @@ import {
|
|||||||
buildSyntheticOpenAiChunks,
|
buildSyntheticOpenAiChunks,
|
||||||
} from './chatFormats.js';
|
} from './chatFormats.js';
|
||||||
import {
|
import {
|
||||||
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
|
isUnsupportedMediaTypeError,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +36,7 @@ import {
|
|||||||
recordDownstreamCostUsage,
|
recordDownstreamCostUsage,
|
||||||
} from './downstreamPolicy.js';
|
} from './downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
|
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
||||||
@@ -47,10 +50,6 @@ const CLAUDE_SSE_EVENT_NAMES = new Set([
|
|||||||
'error',
|
'error',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function withUpstreamPath(path: string, message: string): string {
|
|
||||||
return `[upstream:${path}] ${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRetryClaudeMessagesWithNormalizedBody(
|
function shouldRetryClaudeMessagesWithNormalizedBody(
|
||||||
downstreamFormat: DownstreamFormat,
|
downstreamFormat: DownstreamFormat,
|
||||||
endpointPath: string,
|
endpointPath: string,
|
||||||
@@ -181,45 +180,13 @@ async function handleChatProxyRequest(
|
|||||||
let startTime = Date.now();
|
let startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let upstream: Awaited<ReturnType<typeof fetch>> | null = null;
|
const endpointResult = await executeEndpointFlow({
|
||||||
let successfulUpstreamPath: string | null = null;
|
siteUrl: selected.site.url,
|
||||||
let finalStatus = 0;
|
proxyUrl: selected.site.proxyUrl,
|
||||||
let finalErrText = 'unknown error';
|
endpointCandidates,
|
||||||
|
buildRequest: (endpoint) => {
|
||||||
for (let endpointIndex = 0; endpointIndex < endpointCandidates.length; endpointIndex += 1) {
|
const endpointRequest = buildUpstreamEndpointRequest({
|
||||||
const endpointRequest = buildUpstreamEndpointRequest({
|
endpoint,
|
||||||
endpoint: endpointCandidates[endpointIndex],
|
|
||||||
modelName,
|
|
||||||
stream: isStream,
|
|
||||||
tokenValue: selected.tokenValue,
|
|
||||||
sitePlatform: selected.site.platform,
|
|
||||||
siteUrl: selected.site.url,
|
|
||||||
openaiBody: upstreamBody,
|
|
||||||
downstreamFormat,
|
|
||||||
claudeOriginalBody,
|
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
|
|
||||||
startTime = Date.now();
|
|
||||||
|
|
||||||
const response = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: endpointRequest.headers,
|
|
||||||
body: JSON.stringify(endpointRequest.body),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
upstream = response;
|
|
||||||
successfulUpstreamPath = endpointRequest.path;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawErrText = await response.text().catch(() => 'unknown error');
|
|
||||||
|
|
||||||
if (shouldRetryClaudeMessagesWithNormalizedBody(downstreamFormat, endpointRequest.path, response.status, rawErrText)) {
|
|
||||||
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
|
|
||||||
endpoint: endpointCandidates[endpointIndex],
|
|
||||||
modelName,
|
modelName,
|
||||||
stream: isStream,
|
stream: isStream,
|
||||||
tokenValue: selected.tokenValue,
|
tokenValue: selected.tokenValue,
|
||||||
@@ -227,76 +194,117 @@ async function handleChatProxyRequest(
|
|||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
openaiBody: upstreamBody,
|
openaiBody: upstreamBody,
|
||||||
downstreamFormat,
|
downstreamFormat,
|
||||||
claudeOriginalBody: undefined,
|
claudeOriginalBody,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
|
return {
|
||||||
const normalizedResponse = await fetch(normalizedTargetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
endpoint,
|
||||||
|
path: endpointRequest.path,
|
||||||
|
headers: endpointRequest.headers,
|
||||||
|
body: endpointRequest.body as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
tryRecover: async (ctx) => {
|
||||||
|
if (shouldRetryClaudeMessagesWithNormalizedBody(
|
||||||
|
downstreamFormat,
|
||||||
|
ctx.request.path,
|
||||||
|
ctx.response.status,
|
||||||
|
ctx.rawErrText,
|
||||||
|
)) {
|
||||||
|
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
modelName,
|
||||||
|
stream: isStream,
|
||||||
|
tokenValue: selected.tokenValue,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
siteUrl: selected.site.url,
|
||||||
|
openaiBody: upstreamBody,
|
||||||
|
downstreamFormat,
|
||||||
|
claudeOriginalBody: undefined,
|
||||||
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
|
||||||
|
const normalizedResponse = await fetch(normalizedTargetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: normalizedClaudeRequest.headers,
|
||||||
|
body: JSON.stringify(normalizedClaudeRequest.body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (normalizedResponse.ok) {
|
||||||
|
return {
|
||||||
|
upstream: normalizedResponse,
|
||||||
|
upstreamPath: normalizedClaudeRequest.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.request = {
|
||||||
|
...ctx.request,
|
||||||
|
path: normalizedClaudeRequest.path,
|
||||||
|
headers: normalizedClaudeRequest.headers,
|
||||||
|
body: normalizedClaudeRequest.body as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
ctx.response = normalizedResponse;
|
||||||
|
ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
|
||||||
|
headers: ctx.request.headers,
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
stream: isStream,
|
||||||
|
});
|
||||||
|
const normalizedCurrentHeaders = Object.fromEntries(
|
||||||
|
Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||||
|
);
|
||||||
|
if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimalResponse = await fetch(ctx.targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: normalizedClaudeRequest.headers,
|
headers: minimalHeaders,
|
||||||
body: JSON.stringify(normalizedClaudeRequest.body),
|
body: JSON.stringify(ctx.request.body),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (normalizedResponse.ok) {
|
if (minimalResponse.ok) {
|
||||||
upstream = normalizedResponse;
|
return {
|
||||||
successfulUpstreamPath = normalizedClaudeRequest.path;
|
upstream: minimalResponse,
|
||||||
break;
|
upstreamPath: ctx.request.path,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedErrText = await normalizedResponse.text().catch(() => 'unknown error');
|
ctx.request = {
|
||||||
const shouldDowngradeEndpoint = (
|
...ctx.request,
|
||||||
endpointIndex < endpointCandidates.length - 1
|
headers: minimalHeaders,
|
||||||
&& (
|
};
|
||||||
isEndpointDowngradeError(normalizedResponse.status, normalizedErrText)
|
ctx.response = minimalResponse;
|
||||||
|| isMessagesRequiredError(normalizedErrText)
|
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
|
||||||
)
|
return null;
|
||||||
|
},
|
||||||
|
shouldDowngrade: (ctx) => (
|
||||||
|
isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
||||||
|
|| isMessagesRequiredError(ctx.rawErrText)
|
||||||
|
),
|
||||||
|
onDowngrade: (ctx) => {
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
ctx.response.status,
|
||||||
|
Date.now() - startTime,
|
||||||
|
ctx.errText,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (shouldDowngradeEndpoint) {
|
if (!endpointResult.ok) {
|
||||||
const errText = withUpstreamPath(
|
const status = endpointResult.status || 502;
|
||||||
normalizedClaudeRequest.path,
|
const errText = endpointResult.errText || 'unknown error';
|
||||||
`normalized-claude-body fallback failed: ${normalizedErrText}`,
|
|
||||||
);
|
|
||||||
logProxy(
|
|
||||||
selected,
|
|
||||||
requestedModel,
|
|
||||||
'failed',
|
|
||||||
normalizedResponse.status,
|
|
||||||
Date.now() - startTime,
|
|
||||||
errText,
|
|
||||||
retryCount,
|
|
||||||
downstreamPath,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalStatus = normalizedResponse.status;
|
|
||||||
finalErrText = withUpstreamPath(
|
|
||||||
normalizedClaudeRequest.path,
|
|
||||||
`normalized-claude-body fallback failed: ${normalizedErrText}`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errText = withUpstreamPath(endpointRequest.path, rawErrText);
|
|
||||||
const shouldDowngradeEndpoint = (
|
|
||||||
endpointIndex < endpointCandidates.length - 1
|
|
||||||
&& isEndpointDowngradeError(response.status, rawErrText)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldDowngradeEndpoint) {
|
|
||||||
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount, downstreamPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalStatus = response.status;
|
|
||||||
finalErrText = errText;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstream) {
|
|
||||||
const status = finalStatus || 502;
|
|
||||||
const errText = finalErrText || 'unknown error';
|
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
|
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
|
||||||
|
|
||||||
@@ -324,6 +332,9 @@ async function handleChatProxyRequest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upstream = endpointResult.upstream;
|
||||||
|
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||||
|
|
||||||
if (isStream) {
|
if (isStream) {
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
reply.raw.statusCode = 200;
|
reply.raw.statusCode = 200;
|
||||||
@@ -451,12 +462,14 @@ async function handleChatProxyRequest(
|
|||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let sseBuffer = '';
|
let sseBuffer = '';
|
||||||
|
let shouldTerminateEarly = false;
|
||||||
|
|
||||||
const consumeSseBuffer = (incoming: string): string => {
|
const consumeSseBuffer = (incoming: string): string => {
|
||||||
const pulled = pullSseEventsWithDone(incoming);
|
const pulled = pullSseEventsWithDone(incoming);
|
||||||
for (const eventBlock of pulled.events) {
|
for (const eventBlock of pulled.events) {
|
||||||
if (eventBlock.data === '[DONE]') {
|
if (eventBlock.data === '[DONE]') {
|
||||||
writeDone();
|
writeDone();
|
||||||
|
shouldTerminateEarly = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +499,10 @@ async function handleChatProxyRequest(
|
|||||||
claudeContext,
|
claudeContext,
|
||||||
);
|
);
|
||||||
reply.raw.write(serializeRawSseEvent(claudeEventName, eventBlock.data));
|
reply.raw.write(serializeRawSseEvent(claudeEventName, eventBlock.data));
|
||||||
|
if (claudeContext.doneSent) {
|
||||||
|
shouldTerminateEarly = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,6 +514,14 @@ async function handleChatProxyRequest(
|
|||||||
streamContext,
|
streamContext,
|
||||||
claudeContext,
|
claudeContext,
|
||||||
));
|
));
|
||||||
|
if (downstreamFormat === 'claude' && claudeContext.doneSent) {
|
||||||
|
shouldTerminateEarly = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (downstreamFormat === 'openai' && streamContext.doneSent) {
|
||||||
|
shouldTerminateEarly = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +531,10 @@ async function handleChatProxyRequest(
|
|||||||
writeLines(serializeNormalizedStreamEvent('claude', {
|
writeLines(serializeNormalizedStreamEvent('claude', {
|
||||||
contentDelta: eventBlock.data,
|
contentDelta: eventBlock.data,
|
||||||
}, streamContext, claudeContext));
|
}, streamContext, claudeContext));
|
||||||
|
if (claudeContext.doneSent) {
|
||||||
|
shouldTerminateEarly = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,10 +549,16 @@ async function handleChatProxyRequest(
|
|||||||
|
|
||||||
sseBuffer += decoder.decode(value, { stream: true });
|
sseBuffer += decoder.decode(value, { stream: true });
|
||||||
sseBuffer = consumeSseBuffer(sseBuffer);
|
sseBuffer = consumeSseBuffer(sseBuffer);
|
||||||
|
if (shouldTerminateEarly) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sseBuffer += decoder.decode();
|
if (!shouldTerminateEarly) {
|
||||||
if (sseBuffer.trim().length > 0) {
|
sseBuffer += decoder.decode();
|
||||||
|
}
|
||||||
|
if (!shouldTerminateEarly && sseBuffer.trim().length > 0) {
|
||||||
sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`);
|
sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fetch } from 'undici';
|
||||||
|
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||||
|
|
||||||
|
vi.mock('undici', () => ({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
|
||||||
|
function requestFor(path: string): BuiltEndpointRequest {
|
||||||
|
return {
|
||||||
|
endpoint: 'responses',
|
||||||
|
path,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { model: 'gpt-5.2', input: 'hello' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUndiciResponse(response: Response): Awaited<ReturnType<typeof fetch>> {
|
||||||
|
return response as unknown as Awaited<ReturnType<typeof fetch>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('executeEndpointFlow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the first successful upstream response', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await executeEndpointFlow({
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
endpointCandidates: ['responses'],
|
||||||
|
buildRequest: () => requestFor('/v1/responses'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.upstreamPath).toBe('/v1/responses');
|
||||||
|
}
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downgrades to next endpoint when policy allows', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
|
error: { message: 'unsupported endpoint', type: 'invalid_request_error' },
|
||||||
|
}), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})))
|
||||||
|
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const downgradedPaths: string[] = [];
|
||||||
|
const result = await executeEndpointFlow({
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
endpointCandidates: ['responses', 'chat'],
|
||||||
|
buildRequest: (endpoint) => endpoint === 'responses'
|
||||||
|
? requestFor('/v1/responses')
|
||||||
|
: { ...requestFor('/v1/chat/completions'), endpoint },
|
||||||
|
shouldDowngrade: () => true,
|
||||||
|
onDowngrade: (ctx) => {
|
||||||
|
downgradedPaths.push(ctx.request.path);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.upstreamPath).toBe('/v1/chat/completions');
|
||||||
|
}
|
||||||
|
expect(downgradedPaths).toEqual(['/v1/responses']);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts recovered response from tryRecover hook', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
|
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const recovered = toUndiciResponse(new Response(JSON.stringify({ ok: 'recovered' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await executeEndpointFlow({
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
endpointCandidates: ['responses'],
|
||||||
|
buildRequest: () => requestFor('/v1/responses'),
|
||||||
|
tryRecover: async () => ({
|
||||||
|
upstream: recovered,
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.upstreamPath).toBe('/v1/responses');
|
||||||
|
}
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns normalized final error when all endpoints fail', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
|
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await executeEndpointFlow({
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
endpointCandidates: ['responses'],
|
||||||
|
buildRequest: () => requestFor('/v1/responses'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.errText).toContain('[upstream:/v1/responses]');
|
||||||
|
expect(result.errText).toContain('Upstream returned HTTP 400');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { fetch } from 'undici';
|
||||||
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
|
import { summarizeUpstreamError } from './upstreamError.js';
|
||||||
|
import type { UpstreamEndpoint } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
|
export type BuiltEndpointRequest = {
|
||||||
|
endpoint: UpstreamEndpoint;
|
||||||
|
path: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndpointAttemptContext = {
|
||||||
|
endpointIndex: number;
|
||||||
|
endpointCount: number;
|
||||||
|
request: BuiltEndpointRequest;
|
||||||
|
targetUrl: string;
|
||||||
|
response: Awaited<ReturnType<typeof fetch>>;
|
||||||
|
rawErrText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndpointRecoverResult = {
|
||||||
|
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||||
|
upstreamPath: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type EndpointFlowResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||||
|
upstreamPath: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
status: number;
|
||||||
|
errText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withUpstreamPath(path: string, message: string): string {
|
||||||
|
return `[upstream:${path}] ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecuteEndpointFlowInput = {
|
||||||
|
siteUrl: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
|
endpointCandidates: UpstreamEndpoint[];
|
||||||
|
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
||||||
|
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
|
||||||
|
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
|
||||||
|
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
|
||||||
|
const endpointCount = input.endpointCandidates.length;
|
||||||
|
if (endpointCount <= 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 502,
|
||||||
|
errText: 'Upstream request failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalStatus = 0;
|
||||||
|
let finalErrText = 'unknown error';
|
||||||
|
|
||||||
|
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
|
||||||
|
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
|
||||||
|
const request = input.buildRequest(endpoint, endpointIndex);
|
||||||
|
const targetUrl = `${input.siteUrl}${request.path}`;
|
||||||
|
|
||||||
|
let response = await fetch(targetUrl, withExplicitProxyRequestInit(input.proxyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: request.headers,
|
||||||
|
body: JSON.stringify(request.body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
upstream: response,
|
||||||
|
upstreamPath: request.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawErrText = await response.text().catch(() => 'unknown error');
|
||||||
|
const baseContext: EndpointAttemptContext = {
|
||||||
|
endpointIndex,
|
||||||
|
endpointCount,
|
||||||
|
request,
|
||||||
|
targetUrl,
|
||||||
|
response,
|
||||||
|
rawErrText,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.tryRecover) {
|
||||||
|
const recovered = await input.tryRecover(baseContext);
|
||||||
|
if (recovered?.upstream?.ok) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
upstream: recovered.upstream,
|
||||||
|
upstreamPath: recovered.upstreamPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize again in case recoverer performed additional probes and updated the response text.
|
||||||
|
rawErrText = baseContext.rawErrText;
|
||||||
|
response = baseContext.response;
|
||||||
|
const errText = withUpstreamPath(
|
||||||
|
baseContext.request.path,
|
||||||
|
summarizeUpstreamError(response.status, rawErrText),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
||||||
|
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
||||||
|
if (shouldDowngrade) {
|
||||||
|
await input.onDowngrade?.({
|
||||||
|
...baseContext,
|
||||||
|
errText,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalStatus = response.status;
|
||||||
|
finalErrText = errText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: finalStatus || 502,
|
||||||
|
errText: finalErrText || 'unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,20 +18,19 @@ import {
|
|||||||
type NormalizedStreamEvent,
|
type NormalizedStreamEvent,
|
||||||
} from './chatFormats.js';
|
} from './chatFormats.js';
|
||||||
import {
|
import {
|
||||||
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
|
isUnsupportedMediaTypeError,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
type UpstreamEndpoint,
|
type UpstreamEndpoint,
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
|
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
function withUpstreamPath(path: string, message: string): string {
|
|
||||||
return `[upstream:${path}] ${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldDowngradeFromChatToMessagesForResponses(
|
function shouldDowngradeFromChatToMessagesForResponses(
|
||||||
endpointPath: string,
|
endpointPath: string,
|
||||||
status: number,
|
status: number,
|
||||||
@@ -42,6 +41,186 @@ function shouldDowngradeFromChatToMessagesForResponses(
|
|||||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUpstreamErrorShape(rawText: string): {
|
||||||
|
type: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawText) as Record<string, unknown>;
|
||||||
|
const error = (parsed.error && typeof parsed.error === 'object')
|
||||||
|
? parsed.error as Record<string, unknown>
|
||||||
|
: parsed;
|
||||||
|
return {
|
||||||
|
type: typeof error.type === 'string' ? error.type.trim().toLowerCase() : '',
|
||||||
|
code: typeof error.code === 'string' ? error.code.trim().toLowerCase() : '',
|
||||||
|
message: typeof error.message === 'string' ? error.message.trim() : '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { type: '', code: '', message: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripResponsesMetadata(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(body, 'metadata')) return null;
|
||||||
|
const next = { ...body };
|
||||||
|
delete next.metadata;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCoreResponsesBody(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||||
|
if (!model) return null;
|
||||||
|
if (body.input === undefined) return null;
|
||||||
|
|
||||||
|
const core: Record<string, unknown> = {
|
||||||
|
model,
|
||||||
|
input: body.input,
|
||||||
|
stream: body.stream === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxOutputTokens = toFiniteNumber(body.max_output_tokens);
|
||||||
|
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||||
|
core.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
const temperature = toFiniteNumber(body.temperature);
|
||||||
|
if (temperature !== null) core.temperature = temperature;
|
||||||
|
|
||||||
|
const topP = toFiniteNumber(body.top_p);
|
||||||
|
if (topP !== null) core.top_p = topP;
|
||||||
|
|
||||||
|
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||||
|
if (instructions) core.instructions = instructions;
|
||||||
|
|
||||||
|
return core;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStrictResponsesBody(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||||
|
if (!model) return null;
|
||||||
|
if (body.input === undefined) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
input: body.input,
|
||||||
|
stream: body.stream === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResponsesCompatibilityBodies(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const candidates: Record<string, unknown>[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
try {
|
||||||
|
const originalKey = JSON.stringify(body);
|
||||||
|
if (originalKey) seen.add(originalKey);
|
||||||
|
} catch {
|
||||||
|
// ignore non-serializable bodies
|
||||||
|
}
|
||||||
|
const push = (next: Record<string, unknown> | null) => {
|
||||||
|
if (!next) return;
|
||||||
|
let key = '';
|
||||||
|
try {
|
||||||
|
key = JSON.stringify(next);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
candidates.push(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(stripResponsesMetadata(body));
|
||||||
|
push(buildCoreResponsesBody(body));
|
||||||
|
push(buildStrictResponsesBody(body));
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResponsesCompatibilityHeaderCandidates(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
stream: boolean,
|
||||||
|
): Record<string, string>[] {
|
||||||
|
const candidates: Record<string, string>[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (next: Record<string, string>) => {
|
||||||
|
const normalizedEntries = Object.entries(next)
|
||||||
|
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0)
|
||||||
|
.map(([key, value]) => [key.toLowerCase(), value] as const)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const key = JSON.stringify(normalizedEntries);
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
candidates.push(Object.fromEntries(normalizedEntries));
|
||||||
|
};
|
||||||
|
|
||||||
|
push(headers);
|
||||||
|
|
||||||
|
const minimal: Record<string, string> = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||||
|
const key = rawKey.toLowerCase();
|
||||||
|
if (
|
||||||
|
key === 'authorization'
|
||||||
|
|| key === 'x-api-key'
|
||||||
|
|| key === 'content-type'
|
||||||
|
|| key === 'accept'
|
||||||
|
) {
|
||||||
|
minimal[key] = rawValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!minimal['content-type']) minimal['content-type'] = 'application/json';
|
||||||
|
if (stream && !minimal.accept) minimal.accept = 'text/event-stream';
|
||||||
|
push(minimal);
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryResponsesCompatibility(input: {
|
||||||
|
endpoint: UpstreamEndpoint;
|
||||||
|
status: number;
|
||||||
|
rawErrText: string;
|
||||||
|
}): boolean {
|
||||||
|
if (input.endpoint !== 'responses') return false;
|
||||||
|
if (input.status !== 400) return false;
|
||||||
|
const parsedError = parseUpstreamErrorShape(input.rawErrText);
|
||||||
|
const type = parsedError.type.trim().toLowerCase();
|
||||||
|
const code = parsedError.code.trim().toLowerCase();
|
||||||
|
const message = parsedError.message.trim().toLowerCase();
|
||||||
|
const compact = `${type} ${code} ${message}`.trim();
|
||||||
|
const rawCompact = (input.rawErrText || '').toLowerCase();
|
||||||
|
|
||||||
|
// Authentication/authorization failures should not enter compatibility retries.
|
||||||
|
if (
|
||||||
|
compact.includes('invalid_api_key')
|
||||||
|
|| compact.includes('authentication')
|
||||||
|
|| compact.includes('unauthorized')
|
||||||
|
|| compact.includes('forbidden')
|
||||||
|
|| compact.includes('insufficient_quota')
|
||||||
|
|| compact.includes('rate_limit')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'upstream_error' || code === 'upstream_error') return true;
|
||||||
|
if (message === 'upstream_error' || message === 'upstream request failed') return true;
|
||||||
|
if (rawCompact.includes('upstream_error')) return true;
|
||||||
|
|
||||||
|
// Many sub2api-compatible gateways return generic 400 for field incompatibilities.
|
||||||
|
// Retry with progressively stricter payload/header candidates to maximize compatibility.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
@@ -677,6 +856,29 @@ function ensureMessageItem(state: ResponsesStreamState): string[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toIncrementalText(existingText: string, incomingText: string): string {
|
||||||
|
if (!incomingText) return '';
|
||||||
|
if (!existingText) return incomingText;
|
||||||
|
|
||||||
|
if (incomingText === existingText) return '';
|
||||||
|
if (incomingText.startsWith(existingText)) {
|
||||||
|
return incomingText.slice(existingText.length);
|
||||||
|
}
|
||||||
|
if (existingText.endsWith(incomingText)) return '';
|
||||||
|
|
||||||
|
// Some upstreams emit overlapping windows instead of strict deltas.
|
||||||
|
// Keep only the non-overlapping suffix to avoid duplicated text.
|
||||||
|
const maxOverlap = Math.min(existingText.length, incomingText.length);
|
||||||
|
const MIN_OVERLAP = 8;
|
||||||
|
for (let overlap = maxOverlap; overlap >= MIN_OVERLAP; overlap -= 1) {
|
||||||
|
if (existingText.slice(-overlap) === incomingText.slice(0, overlap)) {
|
||||||
|
return incomingText.slice(overlap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incomingText;
|
||||||
|
}
|
||||||
|
|
||||||
function appendMessageDelta(state: ResponsesStreamState, delta: string): string[] {
|
function appendMessageDelta(state: ResponsesStreamState, delta: string): string[] {
|
||||||
if (!delta) return [];
|
if (!delta) return [];
|
||||||
|
|
||||||
@@ -684,12 +886,15 @@ function appendMessageDelta(state: ResponsesStreamState, delta: string): string[
|
|||||||
events.push(...ensureMessageItem(state));
|
events.push(...ensureMessageItem(state));
|
||||||
if (!state.messageItem) return events;
|
if (!state.messageItem) return events;
|
||||||
|
|
||||||
state.messageItem.text += delta;
|
const normalizedDelta = toIncrementalText(state.messageItem.text, delta);
|
||||||
|
if (!normalizedDelta) return events;
|
||||||
|
|
||||||
|
state.messageItem.text += normalizedDelta;
|
||||||
events.push(emitResponsesEvent(state, 'response.output_text.delta', {
|
events.push(emitResponsesEvent(state, 'response.output_text.delta', {
|
||||||
item_id: state.messageItem.itemId,
|
item_id: state.messageItem.itemId,
|
||||||
output_index: state.messageItem.outputIndex,
|
output_index: state.messageItem.outputIndex,
|
||||||
content_index: state.messageItem.contentIndex,
|
content_index: state.messageItem.contentIndex,
|
||||||
delta,
|
delta: normalizedDelta,
|
||||||
}));
|
}));
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
@@ -989,66 +1194,128 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let upstream: Awaited<ReturnType<typeof fetch>> | null = null;
|
const endpointResult = await executeEndpointFlow({
|
||||||
let successfulUpstreamPath: string | null = null;
|
siteUrl: selected.site.url,
|
||||||
let finalStatus = 0;
|
proxyUrl: selected.site.proxyUrl,
|
||||||
let finalErrText = 'unknown error';
|
endpointCandidates,
|
||||||
|
buildRequest: (endpoint) => {
|
||||||
|
const endpointRequest = buildUpstreamEndpointRequest({
|
||||||
|
endpoint,
|
||||||
|
modelName,
|
||||||
|
stream: isStream,
|
||||||
|
tokenValue: selected.tokenValue,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
siteUrl: selected.site.url,
|
||||||
|
openaiBody: openAiBody,
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
responsesOriginalBody: body,
|
||||||
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
path: endpointRequest.path,
|
||||||
|
headers: endpointRequest.headers,
|
||||||
|
body: endpointRequest.body as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
tryRecover: async (ctx) => {
|
||||||
|
if (shouldRetryResponsesCompatibility({
|
||||||
|
endpoint: ctx.request.endpoint,
|
||||||
|
status: ctx.response.status,
|
||||||
|
rawErrText: ctx.rawErrText,
|
||||||
|
})) {
|
||||||
|
const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body);
|
||||||
|
const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates(
|
||||||
|
ctx.request.headers,
|
||||||
|
isStream,
|
||||||
|
);
|
||||||
|
|
||||||
for (let endpointIndex = 0; endpointIndex < endpointCandidates.length; endpointIndex += 1) {
|
for (const compatibilityHeadersCandidate of compatibilityHeaders) {
|
||||||
const endpoint = endpointCandidates[endpointIndex] as UpstreamEndpoint;
|
for (const compatibilityBody of compatibilityBodies) {
|
||||||
const endpointRequest = buildUpstreamEndpointRequest({
|
const compatibilityResponse = await fetch(
|
||||||
endpoint,
|
ctx.targetUrl,
|
||||||
modelName,
|
withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
stream: isStream,
|
method: 'POST',
|
||||||
tokenValue: selected.tokenValue,
|
headers: compatibilityHeadersCandidate,
|
||||||
sitePlatform: selected.site.platform,
|
body: JSON.stringify(compatibilityBody),
|
||||||
siteUrl: selected.site.url,
|
}),
|
||||||
openaiBody: openAiBody,
|
);
|
||||||
downstreamFormat: 'responses',
|
if (compatibilityResponse.ok) {
|
||||||
responsesOriginalBody: body,
|
return {
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
upstream: compatibilityResponse,
|
||||||
});
|
upstreamPath: ctx.request.path,
|
||||||
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
ctx.request = {
|
||||||
method: 'POST',
|
...ctx.request,
|
||||||
headers: endpointRequest.headers,
|
headers: compatibilityHeadersCandidate,
|
||||||
body: JSON.stringify(endpointRequest.body),
|
body: compatibilityBody,
|
||||||
}));
|
};
|
||||||
|
ctx.response = compatibilityResponse;
|
||||||
|
ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
|
||||||
upstream = response;
|
return null;
|
||||||
successfulUpstreamPath = endpointRequest.path;
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawErrText = await response.text().catch(() => 'unknown error');
|
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
|
||||||
const errText = withUpstreamPath(endpointRequest.path, rawErrText);
|
headers: ctx.request.headers,
|
||||||
const shouldDowngradeEndpoint = (
|
endpoint: ctx.request.endpoint,
|
||||||
endpointIndex < endpointCandidates.length - 1
|
stream: isStream,
|
||||||
&& (
|
});
|
||||||
isEndpointDowngradeError(response.status, rawErrText)
|
const minimalResponse = await fetch(
|
||||||
|| shouldDowngradeFromChatToMessagesForResponses(
|
ctx.targetUrl,
|
||||||
endpointRequest.path,
|
withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
response.status,
|
method: 'POST',
|
||||||
rawErrText,
|
headers: minimalHeaders,
|
||||||
)
|
body: JSON.stringify(ctx.request.body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (minimalResponse.ok) {
|
||||||
|
return {
|
||||||
|
upstream: minimalResponse,
|
||||||
|
upstreamPath: ctx.request.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.request = {
|
||||||
|
...ctx.request,
|
||||||
|
headers: minimalHeaders,
|
||||||
|
};
|
||||||
|
ctx.response = minimalResponse;
|
||||||
|
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
shouldDowngrade: (ctx) => (
|
||||||
|
ctx.response.status >= 500
|
||||||
|
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
||||||
|
|| shouldDowngradeFromChatToMessagesForResponses(
|
||||||
|
ctx.request.path,
|
||||||
|
ctx.response.status,
|
||||||
|
ctx.rawErrText,
|
||||||
)
|
)
|
||||||
);
|
),
|
||||||
|
onDowngrade: (ctx) => {
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
ctx.response.status,
|
||||||
|
Date.now() - startTime,
|
||||||
|
ctx.errText,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (shouldDowngradeEndpoint) {
|
if (!endpointResult.ok) {
|
||||||
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount, downstreamPath);
|
const status = endpointResult.status || 502;
|
||||||
continue;
|
const errText = endpointResult.errText || 'unknown error';
|
||||||
}
|
|
||||||
|
|
||||||
finalStatus = response.status;
|
|
||||||
finalErrText = errText;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstream) {
|
|
||||||
const status = finalStatus || 502;
|
|
||||||
const errText = finalErrText || 'unknown error';
|
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
|
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
|
||||||
|
|
||||||
@@ -1073,6 +1340,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
|
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upstream = endpointResult.upstream;
|
||||||
|
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||||
|
|
||||||
if (isStream) {
|
if (isStream) {
|
||||||
reply.raw.statusCode = 200;
|
reply.raw.statusCode = 200;
|
||||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
|||||||
fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg),
|
fetchModelPricingCatalog: (arg: unknown) => fetchModelPricingCatalogMock(arg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isEndpointDowngradeError, resolveUpstreamEndpointCandidates } from './upstreamEndpoint.js';
|
import {
|
||||||
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
|
buildUpstreamEndpointRequest,
|
||||||
|
isUnsupportedMediaTypeError,
|
||||||
|
isEndpointDowngradeError,
|
||||||
|
resolveUpstreamEndpointCandidates,
|
||||||
|
} from './upstreamEndpoint.js';
|
||||||
|
|
||||||
const baseContext = {
|
const baseContext = {
|
||||||
site: {
|
site: {
|
||||||
@@ -58,6 +64,16 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
'responses',
|
'responses',
|
||||||
);
|
);
|
||||||
expect(responsesOrder).toEqual(['responses', 'chat', 'messages']);
|
expect(responsesOrder).toEqual(['responses', 'chat', 'messages']);
|
||||||
|
|
||||||
|
const claudeResponsesOrder = await resolveUpstreamEndpointCandidates(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
site: { ...baseContext.site, platform: 'new-api' },
|
||||||
|
},
|
||||||
|
'claude-haiku-4-5-20251001',
|
||||||
|
'responses',
|
||||||
|
);
|
||||||
|
expect(claudeResponsesOrder).toEqual(['messages', 'chat', 'responses']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prioritizes messages-first for claude-family models on openai downstream', async () => {
|
it('prioritizes messages-first for claude-family models on openai downstream', async () => {
|
||||||
@@ -186,6 +202,9 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
expect(isEndpointDowngradeError(404, '{"error":{"message":"Not Found","type":"not_found_error"}}')).toBe(true);
|
expect(isEndpointDowngradeError(404, '{"error":{"message":"Not Found","type":"not_found_error"}}')).toBe(true);
|
||||||
expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true);
|
expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true);
|
||||||
expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).toBe(true);
|
expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).toBe(true);
|
||||||
|
expect(isEndpointDowngradeError(400, '{"error":{"message":"","type":"upstream_error"}}')).toBe(true);
|
||||||
|
expect(isEndpointDowngradeError(400, '{"error":{"message":"openai_error","type":"bad_response_status_code"}}')).toBe(true);
|
||||||
|
expect(isEndpointDowngradeError(415, '{"error":{"message":"Unsupported Media Type: Only \\"application/json\\" is allowed"}}')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats Claude Code CLI-only restriction on responses as downgrade candidate', () => {
|
it('treats Claude Code CLI-only restriction on responses as downgrade candidate', () => {
|
||||||
@@ -199,4 +218,195 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
|
|
||||||
expect(isEndpointDowngradeError(400, upstreamError)).toBe(true);
|
expect(isEndpointDowngradeError(400, upstreamError)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects unsupported media type errors as compatibility retry candidates', () => {
|
||||||
|
expect(isUnsupportedMediaTypeError(415, '{"error":{"message":"Unsupported Media Type"}}')).toBe(true);
|
||||||
|
expect(isUnsupportedMediaTypeError(400, '{"error":{"message":"Only \\"application/json\\" is allowed"}}')).toBe(true);
|
||||||
|
expect(isUnsupportedMediaTypeError(400, '{"error":{"message":"messages is required"}}')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUpstreamEndpointRequest', () => {
|
||||||
|
it('builds minimal JSON compatibility headers for messages endpoint', () => {
|
||||||
|
const headers = buildMinimalJsonHeadersForCompatibility({
|
||||||
|
endpoint: 'messages',
|
||||||
|
stream: false,
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer sk-demo',
|
||||||
|
'X-Api-Key': 'sk-claude',
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||||
|
'OpenAI-Beta': 'responses-2025-03-11',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(headers).toEqual({
|
||||||
|
authorization: 'Bearer sk-demo',
|
||||||
|
'x-api-key': 'sk-claude',
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes single-message OpenAI requests to structured responses input', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'sub2api',
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
openaiBody: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1/responses');
|
||||||
|
expect(request.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes downstream responses input string before forwarding upstream', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'sub2api',
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
openaiBody: {},
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
responsesOriginalBody: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
metadata: { trace: 'abc123' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1/responses');
|
||||||
|
expect(request.body.metadata).toEqual({ trace: 'abc123' });
|
||||||
|
expect(request.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies global responses standardization and drops non-standard fields', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
openaiBody: {},
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
responsesOriginalBody: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
metadata: { trace: 'abc123' },
|
||||||
|
max_completion_tokens: 512,
|
||||||
|
custom_vendor_flag: 'drop-me',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1/responses');
|
||||||
|
expect(request.body.metadata).toEqual({ trace: 'abc123' });
|
||||||
|
expect(request.body.custom_vendor_flag).toBeUndefined();
|
||||||
|
expect(request.body.max_completion_tokens).toBeUndefined();
|
||||||
|
expect(request.body.max_output_tokens).toBe(512);
|
||||||
|
expect(request.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills responses input from legacy messages payload', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
openaiBody: {},
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
responsesOriginalBody: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
messages: [{ role: 'user', content: 'hello from messages' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1/responses');
|
||||||
|
expect(request.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello from messages' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills responses input from prompt field when input/messages are missing', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
openaiBody: {},
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
responsesOriginalBody: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
prompt: 'hello from prompt',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1/responses');
|
||||||
|
expect(request.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello from prompt' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks downstream content-type passthrough and forces json content-type upstream', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'new-api',
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
openaiBody: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
downstreamHeaders: {
|
||||||
|
'content-type': 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.headers['content-type']).toBeUndefined();
|
||||||
|
expect(request.headers['Content-Type']).toBe('application/json');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|||||||
|
|
||||||
const BLOCKED_PASSTHROUGH_HEADERS = new Set([
|
const BLOCKED_PASSTHROUGH_HEADERS = new Set([
|
||||||
'host',
|
'host',
|
||||||
|
'content-type',
|
||||||
'content-length',
|
'content-length',
|
||||||
'accept-encoding',
|
'accept-encoding',
|
||||||
'cookie',
|
'cookie',
|
||||||
@@ -382,6 +383,162 @@ function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
|
|||||||
return rawToolChoice;
|
return rawToolChoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const type = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (type === 'function_call' || type === 'function_call_output') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = asTrimmedString(item.role).toLowerCase();
|
||||||
|
const textContent = normalizeContentText(item.content ?? item.text).trim();
|
||||||
|
|
||||||
|
if (type === 'message') {
|
||||||
|
if (!textContent) return item;
|
||||||
|
const normalizedRole = role || 'user';
|
||||||
|
const textType = normalizedRole === 'assistant' ? 'output_text' : 'input_text';
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
role: normalizedRole,
|
||||||
|
content: [{ type: textType, text: textContent }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
if (!textContent) return item;
|
||||||
|
const textType = role === 'assistant' ? 'output_text' : 'input_text';
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
role,
|
||||||
|
content: [{ type: textType, text: textContent }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textContent) {
|
||||||
|
return toResponsesInputMessageFromText(textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponsesInputForCompatibility(input: unknown): unknown {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const normalized = input.trim();
|
||||||
|
if (!normalized) return input;
|
||||||
|
return [toResponsesInputMessageFromText(normalized)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input.map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const normalized = item.trim();
|
||||||
|
return normalized ? toResponsesInputMessageFromText(normalized) : item;
|
||||||
|
}
|
||||||
|
if (!isRecord(item)) return item;
|
||||||
|
return normalizeResponsesMessageItem(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(input)) {
|
||||||
|
return [normalizeResponsesMessageItem(input)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponsesBodyForCompatibility(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const nextInput = normalizeResponsesInputForCompatibility(body.input);
|
||||||
|
if (nextInput === body.input) return body;
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
input: nextInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||||
|
'model',
|
||||||
|
'input',
|
||||||
|
'instructions',
|
||||||
|
'max_output_tokens',
|
||||||
|
'max_completion_tokens',
|
||||||
|
'temperature',
|
||||||
|
'top_p',
|
||||||
|
'truncation',
|
||||||
|
'tools',
|
||||||
|
'tool_choice',
|
||||||
|
'parallel_tool_calls',
|
||||||
|
'metadata',
|
||||||
|
'reasoning',
|
||||||
|
'store',
|
||||||
|
'stream',
|
||||||
|
'user',
|
||||||
|
'previous_response_id',
|
||||||
|
'text',
|
||||||
|
'audio',
|
||||||
|
'include',
|
||||||
|
'response_format',
|
||||||
|
'service_tier',
|
||||||
|
'stop',
|
||||||
|
'n',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeResponsesBodyForProxy(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
modelName: string,
|
||||||
|
stream: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
let normalized = normalizeResponsesBodyForCompatibility({
|
||||||
|
...body,
|
||||||
|
model: modelName,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalized.input === undefined) {
|
||||||
|
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
|
||||||
|
const converted = convertOpenAiBodyToResponsesBody(normalized, modelName, stream);
|
||||||
|
normalized = normalizeResponsesBodyForCompatibility(converted);
|
||||||
|
} else {
|
||||||
|
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
|
||||||
|
if (prompt) {
|
||||||
|
normalized = {
|
||||||
|
...normalized,
|
||||||
|
input: [toResponsesInputMessageFromText(prompt)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(normalized)) {
|
||||||
|
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||||
|
if (key === 'max_completion_tokens') continue;
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
|
||||||
|
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||||
|
sanitized.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||||
|
} else {
|
||||||
|
const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens);
|
||||||
|
if (maxCompletionTokens !== null && maxCompletionTokens > 0) {
|
||||||
|
sanitized.max_output_tokens = Math.trunc(maxCompletionTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized.model = modelName;
|
||||||
|
sanitized.stream = stream;
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
function convertOpenAiBodyToMessagesBody(
|
function convertOpenAiBodyToMessagesBody(
|
||||||
openaiBody: Record<string, unknown>,
|
openaiBody: Record<string, unknown>,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
@@ -632,22 +789,6 @@ function convertOpenAiBodyToResponsesBody(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let input: unknown = inputItems;
|
|
||||||
if (systemContents.length === 0 && inputItems.length === 1) {
|
|
||||||
const first = inputItems[0];
|
|
||||||
const firstRole = asTrimmedString(first.role).toLowerCase();
|
|
||||||
const firstContent = Array.isArray(first.content) ? first.content : [];
|
|
||||||
if (
|
|
||||||
firstRole === 'user'
|
|
||||||
&& firstContent.length === 1
|
|
||||||
&& isRecord(firstContent[0])
|
|
||||||
&& asTrimmedString(firstContent[0].type).toLowerCase() === 'input_text'
|
|
||||||
&& asTrimmedString(firstContent[0].text)
|
|
||||||
) {
|
|
||||||
input = asTrimmedString(firstContent[0].text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxOutputTokens = (
|
const maxOutputTokens = (
|
||||||
toFiniteNumber(openaiBody.max_output_tokens)
|
toFiniteNumber(openaiBody.max_output_tokens)
|
||||||
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
||||||
@@ -659,7 +800,7 @@ function convertOpenAiBodyToResponsesBody(
|
|||||||
model: modelName,
|
model: modelName,
|
||||||
stream,
|
stream,
|
||||||
max_output_tokens: maxOutputTokens,
|
max_output_tokens: maxOutputTokens,
|
||||||
input,
|
input: inputItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (systemContents.length > 0) {
|
if (systemContents.length > 0) {
|
||||||
@@ -681,7 +822,7 @@ function convertOpenAiBodyToResponsesBody(
|
|||||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
||||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||||
|
|
||||||
return body;
|
return normalizeResponsesBodyForCompatibility(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
||||||
@@ -757,6 +898,11 @@ function preferredEndpointOrder(
|
|||||||
// Unknown/generic upstreams: prefer endpoint family that matches the
|
// Unknown/generic upstreams: prefer endpoint family that matches the
|
||||||
// downstream API surface, then degrade progressively.
|
// downstream API surface, then degrade progressively.
|
||||||
if (downstreamFormat === 'responses') {
|
if (downstreamFormat === 'responses') {
|
||||||
|
if (preferMessagesForClaudeModel) {
|
||||||
|
// Claude-family models on generic/new-api upstreams are commonly
|
||||||
|
// messages-first even when downstream API is /v1/responses.
|
||||||
|
return ['messages', 'chat', 'responses'];
|
||||||
|
}
|
||||||
return ['responses', 'chat', 'messages'];
|
return ['responses', 'chat', 'messages'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,7 +976,7 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
|
|
||||||
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
||||||
preferMessagesForClaudeModel
|
preferMessagesForClaudeModel
|
||||||
&& downstreamFormat !== 'responses'
|
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
|
||||||
);
|
);
|
||||||
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
||||||
return preferred;
|
return preferred;
|
||||||
@@ -980,7 +1126,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
const responsesHeaders = input.downstreamFormat === 'responses'
|
const responsesHeaders = input.downstreamFormat === 'responses'
|
||||||
? extractResponsesPassthroughHeaders(input.downstreamHeaders)
|
? extractResponsesPassthroughHeaders(input.downstreamHeaders)
|
||||||
: {};
|
: {};
|
||||||
const body = (
|
const rawBody = (
|
||||||
input.downstreamFormat === 'responses' && input.responsesOriginalBody
|
input.downstreamFormat === 'responses' && input.responsesOriginalBody
|
||||||
? {
|
? {
|
||||||
...input.responsesOriginalBody,
|
...input.responsesOriginalBody,
|
||||||
@@ -989,6 +1135,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
}
|
}
|
||||||
: convertOpenAiBodyToResponsesBody(input.openaiBody, input.modelName, input.stream)
|
: convertOpenAiBodyToResponsesBody(input.openaiBody, input.modelName, input.stream)
|
||||||
);
|
);
|
||||||
|
const body = sanitizeResponsesBodyForProxy(rawBody, input.modelName, input.stream);
|
||||||
|
|
||||||
const headers = ensureStreamAcceptHeader({
|
const headers = ensureStreamAcceptHeader({
|
||||||
...commonHeaders,
|
...commonHeaders,
|
||||||
@@ -1014,10 +1161,63 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderMap(headers: Record<string, string>): Record<string, string> {
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||||
|
const key = rawKey.trim().toLowerCase();
|
||||||
|
if (!key) continue;
|
||||||
|
const value = headerValueToString(rawValue);
|
||||||
|
if (!value) continue;
|
||||||
|
normalized[key] = value;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMinimalJsonHeadersForCompatibility(input: {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
endpoint: UpstreamEndpoint;
|
||||||
|
stream: boolean;
|
||||||
|
}): Record<string, string> {
|
||||||
|
const source = normalizeHeaderMap(input.headers);
|
||||||
|
const minimal: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (source.authorization) minimal.authorization = source.authorization;
|
||||||
|
if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key'];
|
||||||
|
|
||||||
|
if (input.endpoint === 'messages') {
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
if (!key.startsWith('anthropic-')) continue;
|
||||||
|
minimal[key] = value;
|
||||||
|
}
|
||||||
|
if (!minimal['anthropic-version']) {
|
||||||
|
minimal['anthropic-version'] = '2023-06-01';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
minimal['content-type'] = 'application/json';
|
||||||
|
minimal.accept = input.stream ? 'text/event-stream' : 'application/json';
|
||||||
|
return minimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean {
|
||||||
|
if (status < 400) return false;
|
||||||
|
if (status !== 400 && status !== 415) return false;
|
||||||
|
const text = (upstreamErrorText || '').toLowerCase();
|
||||||
|
if (!text) return status === 415;
|
||||||
|
|
||||||
|
return (
|
||||||
|
text.includes('unsupported media type')
|
||||||
|
|| text.includes("only 'application/json' is allowed")
|
||||||
|
|| text.includes('only "application/json" is allowed')
|
||||||
|
|| text.includes('application/json')
|
||||||
|
|| text.includes('content-type')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
|
||||||
if (status < 400) return false;
|
if (status < 400) return false;
|
||||||
const text = (upstreamErrorText || '').toLowerCase();
|
const text = (upstreamErrorText || '').toLowerCase();
|
||||||
if (status === 404 || status === 405 || status === 501) return true;
|
if (status === 404 || status === 405 || status === 415 || status === 501) return true;
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
|
|
||||||
let parsedCode = '';
|
let parsedCode = '';
|
||||||
@@ -1046,6 +1246,12 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
|
|||||||
|| text.includes('unrecognized request url')
|
|| text.includes('unrecognized request url')
|
||||||
|| text.includes('no route matched')
|
|| text.includes('no route matched')
|
||||||
|| text.includes('does not exist')
|
|| text.includes('does not exist')
|
||||||
|
|| text.includes('openai_error')
|
||||||
|
|| text.includes('upstream_error')
|
||||||
|
|| text.includes('bad_response_status_code')
|
||||||
|
|| text.includes('unsupported media type')
|
||||||
|
|| text.includes("only 'application/json' is allowed")
|
||||||
|
|| text.includes('only "application/json" is allowed')
|
||||||
|| (status === 400 && text.includes('unsupported'))
|
|| (status === 400 && text.includes('unsupported'))
|
||||||
|| text.includes('not implemented')
|
|| text.includes('not implemented')
|
||||||
|| text.includes('api not implemented')
|
|| text.includes('api not implemented')
|
||||||
@@ -1056,11 +1262,15 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
|
|||||||
|| parsedCode === 'unknown_endpoint'
|
|| parsedCode === 'unknown_endpoint'
|
||||||
|| parsedCode === 'unsupported_endpoint'
|
|| parsedCode === 'unsupported_endpoint'
|
||||||
|| parsedCode === 'bad_response_status_code'
|
|| parsedCode === 'bad_response_status_code'
|
||||||
|
|| parsedCode === 'openai_error'
|
||||||
|
|| parsedCode === 'upstream_error'
|
||||||
|| parsedType === 'not_found_error'
|
|| parsedType === 'not_found_error'
|
||||||
|| parsedType === 'invalid_request_error'
|
|| parsedType === 'invalid_request_error'
|
||||||
|| parsedType === 'unsupported_endpoint'
|
|| parsedType === 'unsupported_endpoint'
|
||||||
|| parsedType === 'unsupported_path'
|
|| parsedType === 'unsupported_path'
|
||||||
|| parsedType === 'bad_response_status_code'
|
|| parsedType === 'bad_response_status_code'
|
||||||
|
|| parsedType === 'openai_error'
|
||||||
|
|| parsedType === 'upstream_error'
|
||||||
|| parsedMessage.includes('unknown endpoint')
|
|| parsedMessage.includes('unknown endpoint')
|
||||||
|| parsedMessage.includes('unsupported endpoint')
|
|| parsedMessage.includes('unsupported endpoint')
|
||||||
|| parsedMessage.includes('unsupported path')
|
|| parsedMessage.includes('unsupported path')
|
||||||
@@ -1068,6 +1278,11 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
|
|||||||
|| parsedMessage.includes('no route matched')
|
|| parsedMessage.includes('no route matched')
|
||||||
|| parsedMessage.includes('does not exist')
|
|| parsedMessage.includes('does not exist')
|
||||||
|| parsedMessage.includes('bad_response_status_code')
|
|| parsedMessage.includes('bad_response_status_code')
|
||||||
|
|| parsedMessage === 'openai_error'
|
||||||
|
|| parsedMessage === 'upstream_error'
|
||||||
|
|| parsedMessage.includes('unsupported media type')
|
||||||
|
|| parsedMessage.includes("only 'application/json' is allowed")
|
||||||
|
|| parsedMessage.includes('only "application/json" is allowed')
|
||||||
|| (
|
|| (
|
||||||
status === 400
|
status === 400
|
||||||
&& parsedCode === 'invalid_request'
|
&& parsedCode === 'invalid_request'
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { summarizeUpstreamError } from './upstreamError.js';
|
||||||
|
|
||||||
|
describe('summarizeUpstreamError', () => {
|
||||||
|
it('extracts concise message from JSON error payload', () => {
|
||||||
|
const message = summarizeUpstreamError(400, JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'messages is required',
|
||||||
|
type: 'bad_request',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(message).toBe('Upstream returned HTTP 400: messages is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes Cloudflare 5xx HTML page without dumping full body', () => {
|
||||||
|
const html = `<!DOCTYPE html><html><head><title>qaq.al | 502: Bad gateway</title></head><body>Cloudflare Ray ID: abc</body></html>`;
|
||||||
|
const message = summarizeUpstreamError(502, html);
|
||||||
|
|
||||||
|
expect(message).toContain('Upstream returned HTTP 502');
|
||||||
|
expect(message).toContain('Cloudflare 502: Bad gateway');
|
||||||
|
expect(message).not.toContain('<!DOCTYPE html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates oversized plain text payloads', () => {
|
||||||
|
const longText = 'x'.repeat(800);
|
||||||
|
const message = summarizeUpstreamError(500, longText);
|
||||||
|
|
||||||
|
expect(message).toContain('Upstream returned HTTP 500:');
|
||||||
|
expect(message).toContain('...(truncated)');
|
||||||
|
expect(message.length).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseWhitespace(text: string): string {
|
||||||
|
return text.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonErrorMessage(rawText: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawText) as unknown;
|
||||||
|
const root = isRecord(parsed) ? parsed : null;
|
||||||
|
const error = (root && isRecord(root.error)) ? root.error : root;
|
||||||
|
if (!error) return '';
|
||||||
|
|
||||||
|
const message = typeof error.message === 'string' ? collapseWhitespace(error.message) : '';
|
||||||
|
if (message) return message;
|
||||||
|
|
||||||
|
const code = typeof error.code === 'string' ? collapseWhitespace(error.code) : '';
|
||||||
|
const type = typeof error.type === 'string' ? collapseWhitespace(error.type) : '';
|
||||||
|
return [type, code].filter((part) => part.length > 0).join('/');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHtmlTitle(rawText: string): string {
|
||||||
|
const match = rawText.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
|
if (!match?.[1]) return '';
|
||||||
|
return collapseWhitespace(match[1].replace(/<[^>]+>/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCloudflareHtmlSummary(rawText: string, status: number): string {
|
||||||
|
if (!/cloudflare/i.test(rawText)) return '';
|
||||||
|
const title = extractHtmlTitle(rawText);
|
||||||
|
const codeMatch = (
|
||||||
|
title.match(/\b(\d{3,4})\s*:\s*([^\|<]+)/i)
|
||||||
|
|| rawText.match(/Error code\s*(\d{3,4})/i)
|
||||||
|
);
|
||||||
|
const code = codeMatch?.[1] || (status > 0 ? String(status) : '');
|
||||||
|
const reason = collapseWhitespace(
|
||||||
|
(typeof codeMatch?.[2] === 'string' ? codeMatch[2] : '')
|
||||||
|
|| (status >= 500 ? 'origin host error' : 'request blocked')
|
||||||
|
);
|
||||||
|
if (code) return `Cloudflare ${code}: ${reason}`;
|
||||||
|
return `Cloudflare: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHtmlSummary(rawText: string, status: number): string {
|
||||||
|
if (!/(<!doctype|<html)/i.test(rawText)) return '';
|
||||||
|
|
||||||
|
const cloudflareSummary = extractCloudflareHtmlSummary(rawText, status);
|
||||||
|
if (cloudflareSummary) return cloudflareSummary;
|
||||||
|
|
||||||
|
const title = extractHtmlTitle(rawText);
|
||||||
|
if (title) return title;
|
||||||
|
|
||||||
|
const heading = rawText.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] || '';
|
||||||
|
return collapseWhitespace(heading.replace(/<[^>]+>/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeUpstreamError(status: number, rawErrorText: string): string {
|
||||||
|
const statusPrefix = status > 0
|
||||||
|
? `Upstream returned HTTP ${status}`
|
||||||
|
: 'Upstream request failed';
|
||||||
|
|
||||||
|
const raw = typeof rawErrorText === 'string' ? rawErrorText.trim() : '';
|
||||||
|
if (!raw) return statusPrefix;
|
||||||
|
|
||||||
|
const jsonMessage = extractJsonErrorMessage(raw);
|
||||||
|
if (jsonMessage) return `${statusPrefix}: ${jsonMessage}`;
|
||||||
|
|
||||||
|
const htmlMessage = extractHtmlSummary(raw, status);
|
||||||
|
if (htmlMessage) return `${statusPrefix}: ${htmlMessage}`;
|
||||||
|
|
||||||
|
const compact = collapseWhitespace(raw);
|
||||||
|
if (!compact) return statusPrefix;
|
||||||
|
if (compact.length <= 400) return `${statusPrefix}: ${compact}`;
|
||||||
|
return `${statusPrefix}: ${compact.slice(0, 400)}...(truncated)`;
|
||||||
|
}
|
||||||
|
|
||||||
+30
-4
@@ -1308,11 +1308,24 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accounts-table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accounts-table th,
|
||||||
|
.accounts-table td {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.accounts-actions-col {
|
.accounts-actions-col {
|
||||||
min-width: 220px;
|
width: 24%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accounts-actions-cell {
|
.accounts-actions-cell {
|
||||||
|
min-width: 0;
|
||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1320,7 +1333,12 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 2px;
|
gap: 4px 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accounts-row-actions .btn-link {
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sites-actions-col {
|
.sites-actions-col {
|
||||||
@@ -3125,7 +3143,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.accounts-actions-col {
|
.accounts-actions-col {
|
||||||
min-width: 180px;
|
width: 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accounts-actions-cell {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accounts-table th,
|
.accounts-table th,
|
||||||
@@ -3190,7 +3212,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.accounts-actions-col {
|
.accounts-actions-col {
|
||||||
min-width: 150px;
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accounts-actions-cell {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accounts-table th:nth-child(5),
|
.accounts-table th:nth-child(5),
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ export default function Accounts() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Accounts Table */}
|
{/* Accounts Table */}
|
||||||
<div className="card" style={{ overflowX: 'auto' }}>
|
<div className="card">
|
||||||
{accounts.length > 0 ? (
|
{accounts.length > 0 ? (
|
||||||
<table className="data-table accounts-table">
|
<table className="data-table accounts-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -296,20 +296,51 @@ const applyAssistantDelta = (
|
|||||||
status: MESSAGE_STATUS.INCOMPLETE,
|
status: MESSAGE_STATUS.INCOMPLETE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toIncrementalText = (existingText: string, incomingText?: string): string => {
|
||||||
|
if (!incomingText) return '';
|
||||||
|
if (!existingText) return incomingText;
|
||||||
|
|
||||||
|
if (incomingText === existingText) return '';
|
||||||
|
if (incomingText.startsWith(existingText)) {
|
||||||
|
return incomingText.slice(existingText.length);
|
||||||
|
}
|
||||||
|
if (existingText.endsWith(incomingText)) return '';
|
||||||
|
|
||||||
|
const maxOverlap = Math.min(existingText.length, incomingText.length);
|
||||||
|
const MIN_OVERLAP = 8;
|
||||||
|
for (let overlap = maxOverlap; overlap >= MIN_OVERLAP; overlap -= 1) {
|
||||||
|
if (existingText.slice(-overlap) === incomingText.slice(0, overlap)) {
|
||||||
|
return incomingText.slice(overlap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incomingText;
|
||||||
|
};
|
||||||
|
|
||||||
if (delta.reasoningDelta) {
|
if (delta.reasoningDelta) {
|
||||||
next = {
|
const existingReasoning = next.reasoningContent || '';
|
||||||
...next,
|
const reasoningAppend = toIncrementalText(existingReasoning, delta.reasoningDelta);
|
||||||
reasoningContent: (next.reasoningContent || '') + delta.reasoningDelta,
|
if (reasoningAppend) {
|
||||||
isThinkingComplete: false,
|
next = {
|
||||||
};
|
...next,
|
||||||
|
reasoningContent: existingReasoning + reasoningAppend,
|
||||||
|
isThinkingComplete: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delta.contentDelta) {
|
if (delta.contentDelta) {
|
||||||
|
const existingContent = next.content || '';
|
||||||
|
const contentAppend = toIncrementalText(existingContent, delta.contentDelta);
|
||||||
|
if (!contentAppend) {
|
||||||
|
return replaceMessageAt(messages, targetIndex, next);
|
||||||
|
}
|
||||||
|
|
||||||
const hasReasoning = Boolean(next.reasoningContent);
|
const hasReasoning = Boolean(next.reasoningContent);
|
||||||
const shouldAutoCollapse = hasReasoning && !next.hasAutoCollapsed;
|
const shouldAutoCollapse = hasReasoning && !next.hasAutoCollapsed;
|
||||||
next = {
|
next = {
|
||||||
...next,
|
...next,
|
||||||
content: (next.content || '') + delta.contentDelta,
|
content: existingContent + contentAppend,
|
||||||
isReasoningExpanded: shouldAutoCollapse ? false : next.isReasoningExpanded,
|
isReasoningExpanded: shouldAutoCollapse ? false : next.isReasoningExpanded,
|
||||||
hasAutoCollapsed: shouldAutoCollapse || next.hasAutoCollapsed,
|
hasAutoCollapsed: shouldAutoCollapse || next.hasAutoCollapsed,
|
||||||
};
|
};
|
||||||
@@ -383,21 +414,15 @@ const parseAnyStreamDelta = (eventPayload: any): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof eventPayload.type === 'string') {
|
if (typeof eventPayload.type === 'string') {
|
||||||
|
// Responses stream emits a full-text summary again in several "done" events
|
||||||
|
// (output_text.done/content_part.done/output_item.done/response.completed).
|
||||||
|
// Treat those as structural events only; otherwise UI appends duplicate text.
|
||||||
if (eventPayload.type === 'response.output_item.added' || eventPayload.type === 'response.output_item.done') {
|
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 {};
|
||||||
return {
|
|
||||||
contentDelta: parsed.content || undefined,
|
|
||||||
reasoningDelta: parsed.reasoningContent || undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventPayload.type === 'response.content_part.added' || eventPayload.type === 'response.content_part.done') {
|
if (eventPayload.type === 'response.content_part.added' || eventPayload.type === 'response.content_part.done') {
|
||||||
const part = eventPayload.part || eventPayload.content_part || eventPayload;
|
return {};
|
||||||
const parsed = extractResponsesContent(part);
|
|
||||||
return {
|
|
||||||
contentDelta: parsed.content || undefined,
|
|
||||||
reasoningDelta: parsed.reasoningContent || undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventPayload.type === 'response.content_part.delta') {
|
if (eventPayload.type === 'response.content_part.delta') {
|
||||||
@@ -434,18 +459,10 @@ const parseAnyStreamDelta = (eventPayload: any): {
|
|||||||
return { reasoningDelta: text || undefined };
|
return { reasoningDelta: text || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventPayload.type === 'response.output_text.done') {
|
if (eventPayload.type === 'response.output_text.done') return {};
|
||||||
const text = typeof eventPayload.text === 'string' ? eventPayload.text : '';
|
|
||||||
return { contentDelta: text || undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventPayload.type === 'response.completed' || eventPayload.type === 'response.failed') {
|
if (eventPayload.type === 'response.completed' || eventPayload.type === 'response.failed') {
|
||||||
const parsed = extractResponsesContent(eventPayload.response || eventPayload);
|
return { done: true };
|
||||||
return {
|
|
||||||
contentDelta: parsed.content || undefined,
|
|
||||||
reasoningDelta: parsed.reasoningContent || undefined,
|
|
||||||
done: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventPayload.type === 'content_block_delta') {
|
if (eventPayload.type === 'content_block_delta') {
|
||||||
|
|||||||
Reference in New Issue
Block a user