Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@
"sslError": "SSL / certificate error (self-signed cert on relay?).",
"gatewayIncompatible": "The gateway accepted the connection but does not implement this provider's API. Try switching wire (e.g. openai-chat).",
"openaiResponsesMisconfigured": "The endpoint rejected the request shape. The wire may be wrong — try switching to openai-chat.",
"relayStreamingBug": "The gateway may mishandle OpenAI Responses API SSE events (older sub2api / claude2api / anyrouter builds cut the stream short).",
"serverError": "Upstream server error. May be transient — try again.",
"unknown": "Unknown error — check the full log for details."
},
Expand All @@ -798,7 +799,8 @@
"checkVpn": "Check VPN / firewall",
"reportBug": "Report this bug",
"disableTls": "Disable TLS verify",
"switchWire": "Switch wire in Settings"
"switchWire": "Switch wire in Settings",
"relayStreamingBug": "Upgrade the relay, switch wire to openai-chat, or use api.openai.com directly"
},
"applyFix": "Apply this fix",
"setBaseUrlFirst": "Set a Base URL first",
Expand Down
4 changes: 3 additions & 1 deletion packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,7 @@
"sslError": "SSL / 证书错误(中转服务使用了自签证书?)。",
"gatewayIncompatible": "网关接受了连接但没有实现该 Provider 的 API。尝试切换 wire(例如改为 openai-chat)。",
"openaiResponsesMisconfigured": "端点拒绝了请求格式。wire 可能配错了——尝试切换到 openai-chat。",
"relayStreamingBug": "网关可能错误处理了 OpenAI Responses API 的 SSE 事件(老版本 sub2api / claude2api / anyrouter 会把流提前截断)。",
"serverError": "上游服务错误。可能是暂时性的,稍后重试。",
"unknown": "未知错误——请查看完整日志以获取详情。"
},
Expand All @@ -794,7 +795,8 @@
"checkVpn": "检查 VPN / 防火墙",
"reportBug": "报告此 Bug",
"disableTls": "禁用 TLS 验证",
"switchWire": "到设置页切换 wire"
"switchWire": "到设置页切换 wire",
"relayStreamingBug": "升级中转服务;或把 wire 切到 openai-chat;或改用 api.openai.com"
},
"applyFix": "应用此修复",
"setBaseUrlFirst": "请先填写 Base URL",
Expand Down
65 changes: 65 additions & 0 deletions packages/shared/src/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,69 @@ describe('diagnoseGenerateFailure', () => {
const result = diagnoseGenerateFailure({ ...ctx, message: 'something odd' });
expect(result[0]?.cause).toBe('diagnostics.cause.unknown');
});

describe('relay streaming bug (#180)', () => {
it('openai-responses + custom baseUrl + "terminated" → relayStreamingBug', () => {
const result = diagnoseGenerateFailure({
provider: 'openai',
baseUrl: 'https://relay.example.com/v1',
wire: 'openai-responses',
message: 'fetch failed: terminated',
});
expect(result[0]?.cause).toBe('diagnostics.cause.relayStreamingBug');
expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.relayStreamingBug');
});

it('openai-responses + api.openai.com + "terminated" → NOT relayStreamingBug', () => {
const result = diagnoseGenerateFailure({
provider: 'openai',
baseUrl: 'https://api.openai.com/v1',
wire: 'openai-responses',
message: 'fetch failed: terminated',
});
expect(result[0]?.cause).not.toBe('diagnostics.cause.relayStreamingBug');
});

it('openai-responses + custom baseUrl + 500 HTTP error → NOT relayStreamingBug', () => {
const result = diagnoseGenerateFailure({
provider: 'openai',
baseUrl: 'https://relay.example.com/v1',
wire: 'openai-responses',
status: 500,
message: 'internal server error',
});
expect(result[0]?.cause).not.toBe('diagnostics.cause.relayStreamingBug');
expect(result[0]?.cause).toBe('diagnostics.cause.serverError');
});

it('anthropic wire + "terminated" → NOT relayStreamingBug', () => {
const result = diagnoseGenerateFailure({
provider: 'anthropic',
baseUrl: 'https://relay.example.com/v1',
wire: 'anthropic',
message: 'stream terminated',
});
expect(result[0]?.cause).not.toBe('diagnostics.cause.relayStreamingBug');
});

it('matches "premature close" message shape', () => {
const result = diagnoseGenerateFailure({
provider: 'openai',
baseUrl: 'https://relay.example.com/v1',
wire: 'openai-responses',
message: 'Error: Premature close',
});
expect(result[0]?.cause).toBe('diagnostics.cause.relayStreamingBug');
});

it('matches ECONNRESET message shape', () => {
const result = diagnoseGenerateFailure({
provider: 'openai',
baseUrl: 'https://relay.example.com/v1',
wire: 'openai-responses',
message: 'read ECONNRESET',
});
expect(result[0]?.cause).toBe('diagnostics.cause.relayStreamingBug');
});
});
});
40 changes: 40 additions & 0 deletions packages/shared/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,52 @@ export interface GenerateFailureContext {
* - 401 / 402 / 403 / 429 → delegates to diagnose(String(status))
* - 404-shaped message even when no status is attached (e.g. a raw
* "404 page not found" body surfaced as message text)
* - openai-responses + custom baseUrl + truncated-stream error shape →
* relayStreamingBug (third-party gateway mishandles response.* SSE events, #180)
* - Everything else → generic unknown hypothesis
*/

function looksLikeTruncatedStream(message: string): boolean {
return (
/stream\s*(ended|closed)/i.test(message) ||
/premature\s*close/i.test(message) ||
/\bterminated\b/i.test(message) ||
/ECONNRESET/i.test(message) ||
/aborted/i.test(message)
);
}

function isCustomBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false;
try {
const host = new URL(baseUrl).hostname.toLowerCase();
return host !== 'api.openai.com' && !host.endsWith('.openai.com');
} catch {
return false;
}
}

export function diagnoseGenerateFailure(ctx: GenerateFailureContext): DiagnosticHypothesis[] {
const message = (ctx.message ?? '').toLowerCase();
const status = ctx.status;

// Third-party relay bug: openai-responses wire pointed at a custom gateway
// that mishandles `response.*` SSE events, causing the stream to die with
// no HTTP status — only a transport-level "terminated" / "premature close".
if (
status === undefined &&
ctx.wire === 'openai-responses' &&
isCustomBaseUrl(ctx.baseUrl) &&
looksLikeTruncatedStream(message)
) {
return [
{
cause: 'diagnostics.cause.relayStreamingBug',
suggestedFix: { label: 'diagnostics.fix.relayStreamingBug' },
},
];
}

if (status === 400 && message.includes('instructions')) {
return [
{
Expand Down
Loading