From 95d26bb877bd04ca946d03aac28f1a5d2a9506d5 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Thu, 23 Apr 2026 10:31:11 +0800 Subject: [PATCH] feat(diagnostics): add hint for 3rd-party relay SSE truncation (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some gateways (older sub2api / claude2api / anyrouter builds) mishandle OpenAI Responses API SSE events and treat `response.output_text.delta` / `response.completed` as `[DONE]`, cutting the stream short with no HTTP status — the error surfaces as a transport-level "terminated" / "premature close" / ECONNRESET. diagnoseGenerateFailure() now recognises this pattern and returns a `relayStreamingBug` hypothesis with actionable fix copy (upgrade the relay, switch wire to openai-chat, or use api.openai.com directly). Detection signal (b): wire=openai-responses AND baseUrl host is not *.openai.com AND no HTTP status attached AND the message matches a truncated-stream shape. HTTP-status errors still route to the existing gatewayIncompatible / serverError / keyInvalid paths. Refs #167. Stacked on #165 (introduces diagnoseGenerateFailure). Signed-off-by: hqhq1025 <1506751656@qq.com> --- packages/i18n/src/locales/en.json | 4 +- packages/i18n/src/locales/zh-CN.json | 4 +- packages/shared/src/diagnostics.test.ts | 65 +++++++++++++++++++++++++ packages/shared/src/diagnostics.ts | 40 +++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index a51bfcbd..6e38339b 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -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." }, @@ -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", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 3e91442d..332ad3fc 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -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": "未知错误——请查看完整日志以获取详情。" }, @@ -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", diff --git a/packages/shared/src/diagnostics.test.ts b/packages/shared/src/diagnostics.test.ts index faaa54f5..e01a86ca 100644 --- a/packages/shared/src/diagnostics.test.ts +++ b/packages/shared/src/diagnostics.test.ts @@ -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'); + }); + }); }); diff --git a/packages/shared/src/diagnostics.ts b/packages/shared/src/diagnostics.ts index 7eeb0d2a..584da747 100644 --- a/packages/shared/src/diagnostics.ts +++ b/packages/shared/src/diagnostics.ts @@ -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 [ {