Skip to content

Commit 5a93aaf

Browse files
committed
fix(desktop): degrade-probe matches wire endpoint (#179 follow-up)
Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 0c69ab1 commit 5a93aaf

2 files changed

Lines changed: 89 additions & 24 deletions

File tree

apps/desktop/src/main/connection-ipc.test.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,10 +1012,11 @@ describe('runProviderTest degrade-probe (issue #179)', () => {
10121012
}
10131013
});
10141014

1015-
it('openai-responses: /models 404 + /chat/completions 2xx → degrade pass', async () => {
1016-
const { restore } = installFakeFetch((url) => {
1015+
it('openai-responses: /models 404 + /responses 2xx → probeMethod=responses_degraded', async () => {
1016+
const { calls, restore } = installFakeFetch((url) => {
10171017
if (url.endsWith('/models')) return { status: 404 };
1018-
return { status: 200, body: { ok: true } };
1018+
if (url.endsWith('/responses')) return { status: 200, body: { ok: true } };
1019+
return { status: 500 };
10191020
});
10201021
try {
10211022
const res = await runProviderTest({
@@ -1025,7 +1026,48 @@ describe('runProviderTest degrade-probe (issue #179)', () => {
10251026
baseUrl: 'https://gateway.example.com/v1',
10261027
});
10271028
expect(res.ok).toBe(true);
1028-
if (res.ok) expect(res.probeMethod).toBe('chat_completion_degraded');
1029+
if (res.ok) expect(res.probeMethod).toBe('responses_degraded');
1030+
expect(calls).toHaveLength(2);
1031+
expect(calls[0]?.url).toMatch(/\/models$/);
1032+
expect(calls[1]?.url).toMatch(/\/responses$/);
1033+
expect(calls[1]?.method).toBe('POST');
1034+
const body = JSON.parse(calls[1]?.body ?? '{}');
1035+
// Responses API shape — must NOT look like /chat/completions payload.
1036+
expect(body.max_output_tokens).toBe(1);
1037+
expect(Array.isArray(body.input)).toBe(true);
1038+
expect(body.messages).toBeUndefined();
1039+
} finally {
1040+
restore();
1041+
}
1042+
});
1043+
1044+
it('openai-responses: /models 404 + /responses 404 → preserves original 404 (no /chat/completions false-positive)', async () => {
1045+
// Regression: the previous implementation probed /chat/completions for
1046+
// every OpenAI-compat wire. A gateway that only implements /chat/completions
1047+
// would then report the connection healthy even though real inference (on
1048+
// /responses) would 404 at generate-time. We want the opposite: if the
1049+
// wire's real endpoint is dead, the test must fail.
1050+
const { calls, restore } = installFakeFetch((url) => {
1051+
if (url.endsWith('/models')) return { status: 404 };
1052+
if (url.endsWith('/responses')) return { status: 404 };
1053+
// A gateway that only has /chat/completions — must not be consulted.
1054+
if (url.endsWith('/chat/completions')) return { status: 200, body: { id: 'wrong-probe' } };
1055+
return { status: 500 };
1056+
});
1057+
try {
1058+
const res = await runProviderTest({
1059+
provider: 'chat-only-gateway',
1060+
wire: 'openai-responses',
1061+
apiKey: 'sk-test',
1062+
baseUrl: 'https://gateway.example.com/v1',
1063+
});
1064+
expect(res.ok).toBe(false);
1065+
if (!res.ok) {
1066+
expect(res.code).toBe('404');
1067+
expect(res.message).toBe('HTTP 404');
1068+
}
1069+
// /chat/completions must NOT have been probed for an openai-responses wire.
1070+
expect(calls.some((c) => c.url.endsWith('/chat/completions'))).toBe(false);
10291071
} finally {
10301072
restore();
10311073
}

apps/desktop/src/main/connection-ipc.ts

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ export interface ConnectionTestResult {
4242
/**
4343
* `models` when the standard GET /models probe succeeded.
4444
* `chat_completion_degraded` when /models 404'd but POST /chat/completions
45-
* proved the endpoint is alive (e.g. Zhipu GLM's gateway — no public /models
46-
* but /chat/completions works fine). The renderer surfaces this so users
47-
* know /models is unavailable even though generation will work.
45+
* proved the openai-chat wire is alive (e.g. Zhipu GLM — no public /models).
46+
* `responses_degraded` when /models 404'd but POST /responses proved the
47+
* openai-responses wire is alive. We probe the wire's real inference
48+
* endpoint so a gateway that only implements /chat/completions can't
49+
* false-positive for a user whose provider is on the Responses API.
4850
*/
49-
probeMethod?: 'models' | 'chat_completion_degraded';
51+
probeMethod?: 'models' | 'chat_completion_degraded' | 'responses_degraded';
5052
}
5153

5254
export interface ConnectionTestError {
@@ -494,15 +496,19 @@ export async function runProviderTest(
494496
// not degrade anthropic — its /v1/models is standard, and skipping it
495497
// would mask real path-shape mistakes.
496498
if (res.status === 404 && (creds.wire === 'openai-chat' || creds.wire === 'openai-responses')) {
497-
const probe = await probeChatCompletion(normalizedBaseUrl, headers);
499+
const probe = await probeInferenceEndpoint(creds.wire, normalizedBaseUrl, headers);
498500
if (probe.kind === 'pass') {
499-
return { ok: true, probeMethod: 'chat_completion_degraded' };
501+
return {
502+
ok: true,
503+
probeMethod:
504+
creds.wire === 'openai-responses' ? 'responses_degraded' : 'chat_completion_degraded',
505+
};
500506
}
501507
if (probe.kind === 'http' && probe.status !== 404) {
502508
const { code, hint } = classifyHttpError(probe.status);
503509
return { ok: false, code, message: `HTTP ${probe.status}`, hint };
504510
}
505-
// /chat/completions also 404'd (or the network dropped) — fall through
511+
// Inference endpoint also 404'd (or the network dropped) — fall through
506512
// and report the original /models 404.
507513
}
508514
const { code, hint } = classifyHttpError(res.status);
@@ -517,28 +523,45 @@ type ProbeResult =
517523
| { kind: 'network'; message: string };
518524

519525
/**
520-
* POST a minimal chat-completion request to verify the endpoint is alive
521-
* when GET /models returned 404. A 2xx response or any API-originated 4xx
522-
* (400 model_unknown, 402 insufficient credits, 422, 429 — and 401/403 too,
523-
* which we surface as an auth error instead of the misleading 404 hint)
524-
* counts as "endpoint reachable". Only 404 and 5xx count as a real failure.
526+
* POST a minimal inference request to verify the endpoint is alive when GET
527+
* /models returned 404. We dispatch by wire so that providers on the
528+
* Responses API (which may not implement /chat/completions at all) can't
529+
* false-positive via a gateway that only speaks the other shape. A 2xx
530+
* response or any API-originated 4xx (400 model_unknown, 402 insufficient
531+
* credits, 422, 429 — and 401/403 too, which we surface as auth) counts as
532+
* "endpoint reachable". Only 404 and 5xx count as a real failure. The
533+
* request body is intentionally minimal; if the gateway rejects the payload
534+
* shape with a 4xx we still know the route exists.
525535
*/
526-
async function probeChatCompletion(
536+
async function probeInferenceEndpoint(
537+
wire: 'openai-chat' | 'openai-responses',
527538
normalizedBaseUrl: string,
528539
headers: Record<string, string>,
529540
): Promise<ProbeResult> {
530-
const url = `${normalizedBaseUrl}/chat/completions`;
541+
const url =
542+
wire === 'openai-responses'
543+
? `${normalizedBaseUrl}/responses`
544+
: `${normalizedBaseUrl}/chat/completions`;
545+
const body =
546+
wire === 'openai-responses'
547+
? JSON.stringify({
548+
model: 'probe',
549+
input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
550+
max_output_tokens: 1,
551+
stream: false,
552+
})
553+
: JSON.stringify({
554+
model: 'probe',
555+
messages: [{ role: 'user', content: 'ping' }],
556+
max_tokens: 1,
557+
stream: false,
558+
});
531559
let res: Response;
532560
try {
533561
res = await fetchWithTimeout(url, {
534562
method: 'POST',
535563
headers: { ...headers, 'content-type': 'application/json' },
536-
body: JSON.stringify({
537-
model: 'probe',
538-
messages: [{ role: 'user', content: 'ping' }],
539-
max_tokens: 1,
540-
stream: false,
541-
}),
564+
body,
542565
});
543566
} catch (err) {
544567
return { kind: 'network', message: err instanceof Error ? err.message : String(err) };

0 commit comments

Comments
 (0)