Skip to content

Commit 0c69ab1

Browse files
committed
fix(desktop): degrade-probe test connection when /models is unavailable on OpenAI-compat endpoints (#179)
Some OpenAI-compatible gateways (Zhipu GLM at /api/paas/v4 is the reported case, but any proxy that omits a public /models endpoint fits) return HTTP 404 for GET /models even though /chat/completions works fine. The "Test connection" button was reporting a hard 404 failure for those providers and the diagnostics panel was suggesting "add /v1", which would corrupt a correct baseUrl. Two changes: 1. runProviderTest now falls back to POST /chat/completions with a minimal probe request when GET /models returns 404 on openai-chat or openai-responses wires. Any 2xx or API-originated 4xx (400/402/422/ 429) counts as "endpoint reachable". 401/403 is surfaced as an auth error instead of the misleading 404 hint. Anthropic wires do not degrade — /v1/models is standard there. Success now carries a probeMethod field so the renderer can distinguish a full pass from a degraded pass. 2. diagnose() in @open-codesign/shared skips the missingV1 hypothesis when the baseUrl already carries a /v\d+ segment (GLM /v4, AI Studio /v1beta, Cloudflare Workers AI /v1, ...) and returns the generic unknown cause instead, so users are never pushed into duplicating version segments. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 803efab commit 0c69ab1

4 files changed

Lines changed: 308 additions & 8 deletions

File tree

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

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
getCacheKey,
1919
normalizeBaseUrl,
2020
normalizeOllamaBaseUrl,
21+
runProviderTest,
2122
} from './connection-ipc';
2223

2324
// ---------------------------------------------------------------------------
@@ -840,3 +841,193 @@ describe('normalizeOllamaBaseUrl', () => {
840841
expect(() => normalizeOllamaBaseUrl('http://')).toThrow(/not a valid URL/);
841842
});
842843
});
844+
845+
// ---------------------------------------------------------------------------
846+
// runProviderTest — degrade-probe when /models 404s on OpenAI-compat endpoints
847+
// (regression for Zhipu GLM and similar gateways that don't expose /models).
848+
// ---------------------------------------------------------------------------
849+
850+
interface FakeFetchCall {
851+
url: string;
852+
method: string;
853+
body: string | undefined;
854+
}
855+
856+
function installFakeFetch(
857+
handler: (url: string, init: RequestInit) => { status: number; body?: unknown },
858+
): { calls: FakeFetchCall[]; restore: () => void } {
859+
const calls: FakeFetchCall[] = [];
860+
const originalFetch = globalThis.fetch;
861+
const fake = (async (url: string, init: RequestInit = {}) => {
862+
calls.push({
863+
url,
864+
method: typeof init.method === 'string' ? init.method : 'GET',
865+
body: typeof init.body === 'string' ? init.body : undefined,
866+
});
867+
const { status, body } = handler(url, init);
868+
return new Response(body === undefined ? null : JSON.stringify(body), {
869+
status,
870+
headers: { 'content-type': 'application/json' },
871+
});
872+
}) as unknown as typeof fetch;
873+
(globalThis as { fetch: typeof fetch }).fetch = fake;
874+
return {
875+
calls,
876+
restore: () => {
877+
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
878+
},
879+
};
880+
}
881+
882+
describe('runProviderTest degrade-probe (issue #179)', () => {
883+
beforeEach(() => {
884+
// Use real timers so fetchWithTimeout's AbortController doesn't get stuck
885+
// behind vi.useFakeTimers() from the outer beforeEach.
886+
vi.useRealTimers();
887+
});
888+
889+
it('openai-chat: /models 404 but /chat/completions 200 → ok, probeMethod=chat_completion_degraded (GLM case)', async () => {
890+
const { calls, restore } = installFakeFetch((url) => {
891+
if (url.endsWith('/models')) return { status: 404, body: { error: 'not found' } };
892+
if (url.endsWith('/chat/completions')) return { status: 200, body: { id: 'probe-response' } };
893+
return { status: 500 };
894+
});
895+
try {
896+
const res = await runProviderTest({
897+
provider: 'glm',
898+
wire: 'openai-chat',
899+
apiKey: 'sk-glm-test',
900+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
901+
});
902+
expect(res.ok).toBe(true);
903+
if (res.ok) expect(res.probeMethod).toBe('chat_completion_degraded');
904+
expect(calls).toHaveLength(2);
905+
expect(calls[0]?.url).toMatch(/\/models$/);
906+
expect(calls[1]?.url).toMatch(/\/chat\/completions$/);
907+
expect(calls[1]?.method).toBe('POST');
908+
expect(calls[1]?.body).toBeTruthy();
909+
const body = JSON.parse(calls[1]?.body ?? '{}');
910+
expect(body.max_tokens).toBe(1);
911+
expect(body.stream).toBe(false);
912+
expect(Array.isArray(body.messages)).toBe(true);
913+
} finally {
914+
restore();
915+
}
916+
});
917+
918+
it('openai-chat: /models 404 and /chat/completions also 404 → preserves original 404', async () => {
919+
const { restore } = installFakeFetch(() => ({ status: 404 }));
920+
try {
921+
const res = await runProviderTest({
922+
provider: 'broken-gateway',
923+
wire: 'openai-chat',
924+
apiKey: 'sk-test',
925+
baseUrl: 'https://broken.example.com/v1',
926+
});
927+
expect(res.ok).toBe(false);
928+
if (!res.ok) {
929+
expect(res.code).toBe('404');
930+
expect(res.message).toBe('HTTP 404');
931+
}
932+
} finally {
933+
restore();
934+
}
935+
});
936+
937+
it('openai-chat: /models 404 + /chat/completions 400 (model_unknown) → still pass (endpoint alive)', async () => {
938+
const { restore } = installFakeFetch((url) => {
939+
if (url.endsWith('/models')) return { status: 404 };
940+
return { status: 400, body: { error: { message: 'model_not_found' } } };
941+
});
942+
try {
943+
const res = await runProviderTest({
944+
provider: 'glm',
945+
wire: 'openai-chat',
946+
apiKey: 'sk-glm-test',
947+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
948+
});
949+
expect(res.ok).toBe(true);
950+
if (res.ok) expect(res.probeMethod).toBe('chat_completion_degraded');
951+
} finally {
952+
restore();
953+
}
954+
});
955+
956+
it('openai-chat: /models 404 + /chat/completions 401 → surface auth error, not 404', async () => {
957+
const { restore } = installFakeFetch((url) => {
958+
if (url.endsWith('/models')) return { status: 404 };
959+
return { status: 401 };
960+
});
961+
try {
962+
const res = await runProviderTest({
963+
provider: 'glm',
964+
wire: 'openai-chat',
965+
apiKey: 'wrong-key',
966+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
967+
});
968+
expect(res.ok).toBe(false);
969+
if (!res.ok) {
970+
expect(res.code).toBe('401');
971+
expect(res.message).toBe('HTTP 401');
972+
}
973+
} finally {
974+
restore();
975+
}
976+
});
977+
978+
it('openai-chat: /models 200 → no degrade probe, probeMethod=models', async () => {
979+
const { calls, restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } }));
980+
try {
981+
const res = await runProviderTest({
982+
provider: 'openai',
983+
wire: 'openai-chat',
984+
apiKey: 'sk-test',
985+
baseUrl: 'https://api.openai.com/v1',
986+
});
987+
expect(res.ok).toBe(true);
988+
if (res.ok) expect(res.probeMethod).toBe('models');
989+
expect(calls).toHaveLength(1);
990+
expect(calls[0]?.method).toBe('GET');
991+
} finally {
992+
restore();
993+
}
994+
});
995+
996+
it('anthropic: /models 404 does NOT degrade (standard endpoint must stay authoritative)', async () => {
997+
const { calls, restore } = installFakeFetch(() => ({ status: 404 }));
998+
try {
999+
const res = await runProviderTest({
1000+
provider: 'anthropic-like',
1001+
wire: 'anthropic',
1002+
apiKey: 'sk-ant-test',
1003+
baseUrl: 'https://api.anthropic.com',
1004+
});
1005+
expect(res.ok).toBe(false);
1006+
if (!res.ok) expect(res.code).toBe('404');
1007+
// Only /v1/models should have been probed — no /v1/messages degrade.
1008+
expect(calls).toHaveLength(1);
1009+
expect(calls[0]?.url).toMatch(/\/v1\/models$/);
1010+
} finally {
1011+
restore();
1012+
}
1013+
});
1014+
1015+
it('openai-responses: /models 404 + /chat/completions 2xx → degrade pass', async () => {
1016+
const { restore } = installFakeFetch((url) => {
1017+
if (url.endsWith('/models')) return { status: 404 };
1018+
return { status: 200, body: { ok: true } };
1019+
});
1020+
try {
1021+
const res = await runProviderTest({
1022+
provider: 'responses-gateway',
1023+
wire: 'openai-responses',
1024+
apiKey: 'sk-test',
1025+
baseUrl: 'https://gateway.example.com/v1',
1026+
});
1027+
expect(res.ok).toBe(true);
1028+
if (res.ok) expect(res.probeMethod).toBe('chat_completion_degraded');
1029+
} finally {
1030+
restore();
1031+
}
1032+
});
1033+
});

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

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ interface ModelsListPayloadV1 {
3939

4040
export interface ConnectionTestResult {
4141
ok: true;
42+
/**
43+
* `models` when the standard GET /models probe succeeded.
44+
* `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.
48+
*/
49+
probeMethod?: 'models' | 'chat_completion_degraded';
4250
}
4351

4452
export interface ConnectionTestError {
@@ -345,7 +353,7 @@ export function _getModelsCache(): Map<string, CacheEntry> {
345353
// IPC registration
346354
// ---------------------------------------------------------------------------
347355

348-
interface ActiveProviderCredentials {
356+
export interface ActiveProviderCredentials {
349357
provider: string;
350358
wire: WireApi;
351359
apiKey: string;
@@ -446,7 +454,9 @@ async function testChatGPTCodexOAuth(): Promise<ConnectionTestResponse> {
446454
return { ok: true };
447455
}
448456

449-
async function runProviderTest(creds: ActiveProviderCredentials): Promise<ConnectionTestResponse> {
457+
export async function runProviderTest(
458+
creds: ActiveProviderCredentials,
459+
): Promise<ConnectionTestResponse> {
450460
// ChatGPT subscription uses OAuth + ChatGPT-Account-Id headers; its host
451461
// has no `/models` endpoint that a generic Bearer probe can reach. A plain
452462
// HTTP probe would return 401 here and render as the misleading "API key
@@ -456,7 +466,7 @@ async function runProviderTest(creds: ActiveProviderCredentials): Promise<Connec
456466
return testChatGPTCodexOAuth();
457467
}
458468

459-
const { url } = buildEndpointForWire(creds.wire, creds.baseUrl);
469+
const { url, normalizedBaseUrl } = buildEndpointForWire(creds.wire, creds.baseUrl);
460470
const headers = buildAuthHeadersForWire(
461471
creds.wire,
462472
creds.apiKey,
@@ -477,10 +487,69 @@ async function runProviderTest(creds: ActiveProviderCredentials): Promise<Connec
477487
};
478488
}
479489
if (!res.ok) {
490+
// Some OpenAI-compatible gateways (Zhipu GLM, a handful of self-hosted
491+
// proxies) don't expose /models but their /chat/completions works fine.
492+
// If the primary probe 404s on those wires, degrade-probe with a tiny
493+
// chat request before declaring the endpoint dead. We intentionally do
494+
// not degrade anthropic — its /v1/models is standard, and skipping it
495+
// would mask real path-shape mistakes.
496+
if (res.status === 404 && (creds.wire === 'openai-chat' || creds.wire === 'openai-responses')) {
497+
const probe = await probeChatCompletion(normalizedBaseUrl, headers);
498+
if (probe.kind === 'pass') {
499+
return { ok: true, probeMethod: 'chat_completion_degraded' };
500+
}
501+
if (probe.kind === 'http' && probe.status !== 404) {
502+
const { code, hint } = classifyHttpError(probe.status);
503+
return { ok: false, code, message: `HTTP ${probe.status}`, hint };
504+
}
505+
// /chat/completions also 404'd (or the network dropped) — fall through
506+
// and report the original /models 404.
507+
}
480508
const { code, hint } = classifyHttpError(res.status);
481509
return { ok: false, code, message: `HTTP ${res.status}`, hint };
482510
}
483-
return { ok: true };
511+
return { ok: true, probeMethod: 'models' };
512+
}
513+
514+
type ProbeResult =
515+
| { kind: 'pass' }
516+
| { kind: 'http'; status: number }
517+
| { kind: 'network'; message: string };
518+
519+
/**
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.
525+
*/
526+
async function probeChatCompletion(
527+
normalizedBaseUrl: string,
528+
headers: Record<string, string>,
529+
): Promise<ProbeResult> {
530+
const url = `${normalizedBaseUrl}/chat/completions`;
531+
let res: Response;
532+
try {
533+
res = await fetchWithTimeout(url, {
534+
method: 'POST',
535+
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+
}),
542+
});
543+
} catch (err) {
544+
return { kind: 'network', message: err instanceof Error ? err.message : String(err) };
545+
}
546+
if (res.ok) return { kind: 'pass' };
547+
if (res.status === 404 || res.status >= 500) return { kind: 'http', status: res.status };
548+
// 401/403 — endpoint alive but auth rejected; surface as auth error so the
549+
// diagnostics panel shows the key-invalid hint instead of the 404 one.
550+
if (res.status === 401 || res.status === 403) return { kind: 'http', status: res.status };
551+
// 400/402/422/429 etc. — endpoint alive, request-level rejection.
552+
return { kind: 'pass' };
484553
}
485554

486555
export function registerConnectionIpc(): void {

packages/shared/src/diagnostics.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,40 @@ describe('diagnose', () => {
5454
expect(fix?.baseUrlTransform?.('https://api.example.com')).toBe('https://api.example.com/v1');
5555
});
5656

57-
it('404 transform is idempotent when /v1 already present', () => {
58-
const result = diagnose('404', { ...baseCtx, baseUrl: 'https://api.example.com/v1' });
59-
const transform = result[0]?.suggestedFix?.baseUrlTransform;
60-
expect(transform?.('https://api.example.com/v1')).toBe('https://api.example.com/v1');
57+
// Regression: Zhipu GLM (issue #179) — baseUrl is /api/paas/v4, /models 404
58+
// is because GLM does not expose /models, NOT because /v1 is missing.
59+
// Auto-suggesting "add /v1" would corrupt a correct baseUrl.
60+
it('404 skips missingV1 when baseUrl already has /v4 (GLM)', () => {
61+
const result = diagnose('404', {
62+
...baseCtx,
63+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
64+
});
65+
expect(result[0]?.cause).toBe('diagnostics.cause.unknown');
66+
expect(result[0]?.suggestedFix).toBeUndefined();
67+
});
68+
69+
it('404 skips missingV1 when baseUrl already has /v1 (e.g. Cloudflare Workers AI)', () => {
70+
const result = diagnose('404', {
71+
...baseCtx,
72+
baseUrl: 'https://gateway.ai.cloudflare.com/v1/account/foo/openai',
73+
});
74+
expect(result[0]?.cause).toBe('diagnostics.cause.unknown');
75+
expect(result[0]?.suggestedFix).toBeUndefined();
76+
});
77+
78+
it('404 skips missingV1 when baseUrl already has /v1beta (AI Studio)', () => {
79+
const result = diagnose('404', {
80+
...baseCtx,
81+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
82+
});
83+
expect(result[0]?.cause).toBe('diagnostics.cause.unknown');
84+
expect(result[0]?.suggestedFix).toBeUndefined();
85+
});
86+
87+
it('404 still suggests missingV1 when baseUrl has NO version segment', () => {
88+
const result = diagnose('404', { ...baseCtx, baseUrl: 'https://api.example.com' });
89+
expect(result[0]?.cause).toBe('diagnostics.cause.missingV1');
90+
expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.addV1');
6191
});
6292

6393
it('maps 429 to rateLimit with waitAndRetry fix', () => {

packages/shared/src/diagnostics.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ export function diagnose(code: ErrorCode, ctx: DiagnoseContext): DiagnosticHypot
8181
}
8282

8383
if (normalised === '404') {
84+
// If the baseUrl already encodes a version segment (/v1, /v4, /v1beta,
85+
// etc.), suggesting "add /v1" is wrong — Zhipu GLM uses /v4, AI Studio
86+
// uses /v1beta, and some Cloudflare Workers AI gateways already carry
87+
// /v1. A 404 on such endpoints usually means /models simply isn't
88+
// exposed, not that the path is malformed. Fall back to the generic
89+
// hypothesis so the user isn't pushed into corrupting a correct baseUrl.
90+
const hasVersionSegment = /\/v\d+[a-z]*(?:\/|$)/i.test(ctx.baseUrl);
91+
if (hasVersionSegment) {
92+
return [{ cause: 'diagnostics.cause.unknown' }];
93+
}
8494
return [
8595
{
8696
cause: 'diagnostics.cause.missingV1',

0 commit comments

Comments
 (0)