Skip to content

Commit a245761

Browse files
authored
feat(diagnostics): hint for 3rd-party relay SSE truncation (#180) (#181)
## Summary Fix #180 (implementation of #167): give users an actionable diagnostic when a third-party relay (older sub2api / claude2api / anyrouter builds) mishandles OpenAI Responses API SSE events and cuts the stream short with no HTTP status. ## What changed - `packages/shared/src/diagnostics.ts` — new `relayStreamingBug` hypothesis in `diagnoseGenerateFailure()`. Triggers when: - `wire === 'openai-responses'` - `baseUrl` host is **not** `*.openai.com` - No HTTP status is attached (transport-level failure, not a 4xx/5xx) - Message matches a truncated-stream shape: `stream ended`, `premature close`, `terminated`, `ECONNRESET`, `aborted` - `packages/i18n/src/locales/en.json` + `zh-CN.json` — `diagnostics.cause.relayStreamingBug` + `diagnostics.fix.relayStreamingBug`. - `packages/shared/src/diagnostics.test.ts` — 6 new tests covering positive + negative cases: - openai-responses + custom baseUrl + "terminated" → triggers - openai-responses + `api.openai.com` + "terminated" → does NOT trigger (official endpoint isn't the culprit) - openai-responses + custom baseUrl + 500 status → does NOT trigger (routes to `serverError`) - anthropic wire + "terminated" → does NOT trigger (wrong wire) - "premature close" + "ECONNRESET" message shapes both matched Detection signal used: **(b)** from the task spec — pattern-matching on baseUrl + wire + message, since the existing error path (`packages/providers/src/codex/client.ts`, `retry.ts`) does not yet attach a "stream closed without response.completed" context field. ## Stacked on #165 This PR builds on the `diagnoseGenerateFailure()` function introduced in #165 (branch `worktree-agent-a75d65c7`). **Merge #165 first**, then this PR. Rebasing onto main after #165 lands will be a no-op. ## Four-principle check - Compatibility: green — pure addition, no signature changes - Upgradeability: green — hypothesis code-path is additive, falls through to existing `serverError` / `unknown` when signals don't match - No bloat: green — ~25 LOC + 2 i18n strings per locale - Elegance: green — same shape as existing hypotheses, reuses the DiagnosticHypothesis surface ## Test plan - [x] `pnpm --filter @open-codesign/shared test` — 174 passed (8 files) - [x] `pnpm typecheck` — 10 packages green - [x] `pnpm lint` — biome clean (360 files) - [ ] Manual: reproduce with a gateway that truncates `response.*` SSE events, confirm the diagnostic panel renders the new cause + fix strings Refs #167, closes #180. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 022e1b6 commit a245761

4 files changed

Lines changed: 111 additions & 2 deletions

File tree

packages/i18n/src/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,7 @@
785785
"sslError": "SSL / certificate error (self-signed cert on relay?).",
786786
"gatewayIncompatible": "The gateway accepted the connection but does not implement this provider's API. Try switching wire (e.g. openai-chat).",
787787
"openaiResponsesMisconfigured": "The endpoint rejected the request shape. The wire may be wrong — try switching to openai-chat.",
788+
"relayStreamingBug": "The gateway may mishandle OpenAI Responses API SSE events (older sub2api / claude2api / anyrouter builds cut the stream short).",
788789
"serverError": "Upstream server error. May be transient — try again.",
789790
"unknown": "Unknown error — check the full log for details."
790791
},
@@ -798,7 +799,8 @@
798799
"checkVpn": "Check VPN / firewall",
799800
"reportBug": "Report this bug",
800801
"disableTls": "Disable TLS verify",
801-
"switchWire": "Switch wire in Settings"
802+
"switchWire": "Switch wire in Settings",
803+
"relayStreamingBug": "Upgrade the relay, switch wire to openai-chat, or use api.openai.com directly"
802804
},
803805
"applyFix": "Apply this fix",
804806
"setBaseUrlFirst": "Set a Base URL first",

packages/i18n/src/locales/zh-CN.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,7 @@
781781
"sslError": "SSL / 证书错误(中转服务使用了自签证书?)。",
782782
"gatewayIncompatible": "网关接受了连接但没有实现该 Provider 的 API。尝试切换 wire(例如改为 openai-chat)。",
783783
"openaiResponsesMisconfigured": "端点拒绝了请求格式。wire 可能配错了——尝试切换到 openai-chat。",
784+
"relayStreamingBug": "网关可能错误处理了 OpenAI Responses API 的 SSE 事件(老版本 sub2api / claude2api / anyrouter 会把流提前截断)。",
784785
"serverError": "上游服务错误。可能是暂时性的,稍后重试。",
785786
"unknown": "未知错误——请查看完整日志以获取详情。"
786787
},
@@ -794,7 +795,8 @@
794795
"checkVpn": "检查 VPN / 防火墙",
795796
"reportBug": "报告此 Bug",
796797
"disableTls": "禁用 TLS 验证",
797-
"switchWire": "到设置页切换 wire"
798+
"switchWire": "到设置页切换 wire",
799+
"relayStreamingBug": "升级中转服务;或把 wire 切到 openai-chat;或改用 api.openai.com"
798800
},
799801
"applyFix": "应用此修复",
800802
"setBaseUrlFirst": "请先填写 Base URL",

packages/shared/src/diagnostics.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,69 @@ describe('diagnoseGenerateFailure', () => {
191191
const result = diagnoseGenerateFailure({ ...ctx, message: 'something odd' });
192192
expect(result[0]?.cause).toBe('diagnostics.cause.unknown');
193193
});
194+
195+
describe('relay streaming bug (#180)', () => {
196+
it('openai-responses + custom baseUrl + "terminated" → relayStreamingBug', () => {
197+
const result = diagnoseGenerateFailure({
198+
provider: 'openai',
199+
baseUrl: 'https://relay.example.com/v1',
200+
wire: 'openai-responses',
201+
message: 'fetch failed: terminated',
202+
});
203+
expect(result[0]?.cause).toBe('diagnostics.cause.relayStreamingBug');
204+
expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.relayStreamingBug');
205+
});
206+
207+
it('openai-responses + api.openai.com + "terminated" → NOT relayStreamingBug', () => {
208+
const result = diagnoseGenerateFailure({
209+
provider: 'openai',
210+
baseUrl: 'https://api.openai.com/v1',
211+
wire: 'openai-responses',
212+
message: 'fetch failed: terminated',
213+
});
214+
expect(result[0]?.cause).not.toBe('diagnostics.cause.relayStreamingBug');
215+
});
216+
217+
it('openai-responses + custom baseUrl + 500 HTTP error → NOT relayStreamingBug', () => {
218+
const result = diagnoseGenerateFailure({
219+
provider: 'openai',
220+
baseUrl: 'https://relay.example.com/v1',
221+
wire: 'openai-responses',
222+
status: 500,
223+
message: 'internal server error',
224+
});
225+
expect(result[0]?.cause).not.toBe('diagnostics.cause.relayStreamingBug');
226+
expect(result[0]?.cause).toBe('diagnostics.cause.serverError');
227+
});
228+
229+
it('anthropic wire + "terminated" → NOT relayStreamingBug', () => {
230+
const result = diagnoseGenerateFailure({
231+
provider: 'anthropic',
232+
baseUrl: 'https://relay.example.com/v1',
233+
wire: 'anthropic',
234+
message: 'stream terminated',
235+
});
236+
expect(result[0]?.cause).not.toBe('diagnostics.cause.relayStreamingBug');
237+
});
238+
239+
it('matches "premature close" message shape', () => {
240+
const result = diagnoseGenerateFailure({
241+
provider: 'openai',
242+
baseUrl: 'https://relay.example.com/v1',
243+
wire: 'openai-responses',
244+
message: 'Error: Premature close',
245+
});
246+
expect(result[0]?.cause).toBe('diagnostics.cause.relayStreamingBug');
247+
});
248+
249+
it('matches ECONNRESET message shape', () => {
250+
const result = diagnoseGenerateFailure({
251+
provider: 'openai',
252+
baseUrl: 'https://relay.example.com/v1',
253+
wire: 'openai-responses',
254+
message: 'read ECONNRESET',
255+
});
256+
expect(result[0]?.cause).toBe('diagnostics.cause.relayStreamingBug');
257+
});
258+
});
194259
});

packages/shared/src/diagnostics.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,52 @@ export interface GenerateFailureContext {
174174
* - 401 / 402 / 403 / 429 → delegates to diagnose(String(status))
175175
* - 404-shaped message even when no status is attached (e.g. a raw
176176
* "404 page not found" body surfaced as message text)
177+
* - openai-responses + custom baseUrl + truncated-stream error shape →
178+
* relayStreamingBug (third-party gateway mishandles response.* SSE events, #180)
177179
* - Everything else → generic unknown hypothesis
178180
*/
181+
182+
function looksLikeTruncatedStream(message: string): boolean {
183+
return (
184+
/stream\s*(ended|closed)/i.test(message) ||
185+
/premature\s*close/i.test(message) ||
186+
/\bterminated\b/i.test(message) ||
187+
/ECONNRESET/i.test(message) ||
188+
/aborted/i.test(message)
189+
);
190+
}
191+
192+
function isCustomBaseUrl(baseUrl: string | undefined): boolean {
193+
if (!baseUrl) return false;
194+
try {
195+
const host = new URL(baseUrl).hostname.toLowerCase();
196+
return host !== 'api.openai.com' && !host.endsWith('.openai.com');
197+
} catch {
198+
return false;
199+
}
200+
}
201+
179202
export function diagnoseGenerateFailure(ctx: GenerateFailureContext): DiagnosticHypothesis[] {
180203
const message = (ctx.message ?? '').toLowerCase();
181204
const status = ctx.status;
182205

206+
// Third-party relay bug: openai-responses wire pointed at a custom gateway
207+
// that mishandles `response.*` SSE events, causing the stream to die with
208+
// no HTTP status — only a transport-level "terminated" / "premature close".
209+
if (
210+
status === undefined &&
211+
ctx.wire === 'openai-responses' &&
212+
isCustomBaseUrl(ctx.baseUrl) &&
213+
looksLikeTruncatedStream(message)
214+
) {
215+
return [
216+
{
217+
cause: 'diagnostics.cause.relayStreamingBug',
218+
suggestedFix: { label: 'diagnostics.fix.relayStreamingBug' },
219+
},
220+
];
221+
}
222+
183223
if (status === 400 && message.includes('instructions')) {
184224
return [
185225
{

0 commit comments

Comments
 (0)