Skip to content

Commit 28f5b17

Browse files
Lxr-maxcursoragent
authored andcommitted
feat(providers): formalize model discovery modes
Declare modelDiscoveryMode on provider capabilities so listing, Settings pickers, imports, and diagnostics stop treating GET /models as universal. Fixes #210
1 parent 26c8498 commit 28f5b17

34 files changed

Lines changed: 1178 additions & 54 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@open-codesign/desktop": patch
3+
"@open-codesign/i18n": patch
4+
"@open-codesign/shared": patch
5+
---
6+
7+
Formalize provider `modelDiscoveryMode` (`models`, `static-hint`, `manual`, `infer-only`) so listing, Settings pickers, imports, and diagnostics no longer treat GET `/models` as universal.

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

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ vi.mock('./tls-override', () => ({
1414
}));
1515

1616
import { createHash } from 'node:crypto';
17+
import { resolveListForProviderPlan } from '@open-codesign/shared';
1718
import {
1819
_clearModelsCache,
1920
buildAuthHeaders,
2021
buildAuthHeadersForWire,
2122
CONNECTION_FETCH_TIMEOUT_MS,
23+
type ConnectionTestResponse,
2224
classifyHttpError,
2325
classifyNetworkTarget,
2426
extractIds,
@@ -27,6 +29,7 @@ import {
2729
getCacheKey,
2830
handleConfigV1TestEndpoint,
2931
handleOllamaV1Probe,
32+
type ModelsListResponse,
3033
normalizeBaseUrl,
3134
normalizeOllamaBaseUrl,
3235
runProviderTest,
@@ -39,8 +42,6 @@ import { withTlsBypass } from './tls-override';
3942
// network responses without hitting the network.
4043
// ---------------------------------------------------------------------------
4144

42-
import type { ConnectionTestResponse, ModelsListResponse } from './connection-ipc';
43-
4445
// ---------------------------------------------------------------------------
4546
// connection:v1:test test helper
4647
// ---------------------------------------------------------------------------
@@ -555,6 +556,31 @@ describe('models:v1:list-for-provider input validation', () => {
555556
const result = validateListForProviderInput('claude-code-anthropic');
556557
expect(result).toBeNull();
557558
});
559+
560+
it('respects declared discovery modes instead of always fetching /models', () => {
561+
expect(
562+
resolveListForProviderPlan('chatgpt-codex', {
563+
wire: 'openai-codex-responses',
564+
defaultModel: 'gpt-5.5',
565+
modelsHint: ['gpt-5.5', 'gpt-5.4'],
566+
requiresApiKey: false,
567+
}).action,
568+
).toBe('return');
569+
expect(
570+
resolveListForProviderPlan('glm', {
571+
wire: 'openai-chat',
572+
defaultModel: 'glm-4.6',
573+
capabilities: { modelDiscoveryMode: 'infer-only' },
574+
}),
575+
).toMatchObject({ action: 'return', source: 'local', models: ['glm-4.6'] });
576+
expect(
577+
resolveListForProviderPlan('openai', {
578+
wire: 'openai-chat',
579+
defaultModel: 'gpt-4o',
580+
capabilities: { modelDiscoveryMode: 'models' },
581+
}).action,
582+
).toBe('fetch-remote');
583+
});
558584
});
559585

560586
// ---------------------------------------------------------------------------
@@ -1188,6 +1214,88 @@ describe('runProviderTest degrade-probe (issue #179)', () => {
11881214
});
11891215
});
11901216

1217+
describe('runProviderTest discovery modes (issue #210)', () => {
1218+
beforeEach(() => {
1219+
vi.useRealTimers();
1220+
});
1221+
1222+
it('infer-only skips GET /models and treats inference success as compatible', async () => {
1223+
const { calls, restore } = installFakeFetch((url) => {
1224+
if (url.endsWith('/models')) return { status: 404 };
1225+
if (url.endsWith('/chat/completions')) return { status: 200, body: { id: 'ok' } };
1226+
return { status: 500 };
1227+
});
1228+
try {
1229+
const res = await runProviderTest({
1230+
provider: 'glm',
1231+
wire: 'openai-chat',
1232+
apiKey: 'sk-test',
1233+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
1234+
modelDiscoveryMode: 'infer-only',
1235+
});
1236+
expect(res.ok).toBe(true);
1237+
if (res.ok) {
1238+
expect(res.probeMethod).toBe('inference');
1239+
expect(res.compatibility).toBe('compatible');
1240+
}
1241+
expect(calls).toHaveLength(1);
1242+
expect(calls[0]?.url).toMatch(/\/chat\/completions$/);
1243+
expect(calls.some((c) => c.url.endsWith('/models'))).toBe(false);
1244+
} finally {
1245+
restore();
1246+
}
1247+
});
1248+
1249+
it('manual skips GET /models', async () => {
1250+
const { calls, restore } = installFakeFetch((url) => {
1251+
if (url.endsWith('/responses')) return { status: 200, body: { id: 'ok' } };
1252+
return { status: 404 };
1253+
});
1254+
try {
1255+
const res = await runProviderTest({
1256+
provider: 'custom',
1257+
wire: 'openai-responses',
1258+
apiKey: 'sk-test',
1259+
baseUrl: 'https://proxy.example.com/v1',
1260+
modelDiscoveryMode: 'manual',
1261+
});
1262+
expect(res.ok).toBe(true);
1263+
if (res.ok) {
1264+
expect(res.probeMethod).toBe('inference');
1265+
expect(res.compatibility).toBe('compatible');
1266+
}
1267+
expect(calls.some((c) => c.url.endsWith('/models'))).toBe(false);
1268+
} finally {
1269+
restore();
1270+
}
1271+
});
1272+
1273+
it('models mode still degrade-probes /models 404 as before', async () => {
1274+
const { calls, restore } = installFakeFetch((url) => {
1275+
if (url.endsWith('/models')) return { status: 404 };
1276+
if (url.endsWith('/chat/completions')) return { status: 200, body: { id: 'ok' } };
1277+
return { status: 500 };
1278+
});
1279+
try {
1280+
const res = await runProviderTest({
1281+
provider: 'glm',
1282+
wire: 'openai-chat',
1283+
apiKey: 'sk-test',
1284+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
1285+
modelDiscoveryMode: 'models',
1286+
});
1287+
expect(res.ok).toBe(true);
1288+
if (res.ok) {
1289+
expect(res.probeMethod).toBe('chat_completion_degraded');
1290+
expect(res.compatibility).toBe('degraded');
1291+
}
1292+
expect(calls[0]?.url).toMatch(/\/models$/);
1293+
} finally {
1294+
restore();
1295+
}
1296+
});
1297+
});
1298+
11911299
describe('config:v1:test-endpoint response parsing', () => {
11921300
beforeEach(() => {
11931301
vi.useRealTimers();
@@ -1212,6 +1320,25 @@ describe('config:v1:test-endpoint response parsing', () => {
12121320
}
12131321
});
12141322

1323+
it('treats GET /models 404 as listing-unavailable rather than a hard failure', async () => {
1324+
const { restore } = installFakeFetch(() => ({ status: 404 }));
1325+
try {
1326+
await expect(
1327+
handleConfigV1TestEndpoint({
1328+
wire: 'openai-chat',
1329+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
1330+
apiKey: 'sk-test',
1331+
}),
1332+
).resolves.toEqual({
1333+
ok: false,
1334+
error: 'not-a-model-endpoint',
1335+
message: 'HTTP 404',
1336+
});
1337+
} finally {
1338+
restore();
1339+
}
1340+
});
1341+
12151342
it('classifies private and metadata network targets', () => {
12161343
expect(classifyNetworkTarget('https://provider.example/v1')).toBe('public');
12171344
expect(classifyNetworkTarget('http://localhost:8317')).toBe('loopback');

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

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ import {
44
BUILTIN_PROVIDERS,
55
CodesignError,
66
canonicalBaseUrl,
7+
connectionTestProbesModelsEndpoint,
78
type DiagnosticCategory,
89
ERROR_CODES,
910
ensureVersionedBase,
1011
isSupportedOnboardingProvider,
1112
type ProviderEntry,
13+
type ProviderModelDiscoveryMode,
14+
resolveListForProviderPlan,
15+
resolveProviderCapabilities,
1216
type SupportedOnboardingProvider,
1317
stripInferenceEndpointSuffix,
1418
type WireApi,
@@ -72,6 +76,8 @@ export interface ConnectionTestResult {
7276
ok: true;
7377
/**
7478
* `models` when the standard GET /models probe succeeded.
79+
* `inference` when the provider's discovery mode does not expect /models
80+
* and the real invoke path answered.
7581
* `chat_completion_degraded` when /models 404'd but POST /chat/completions
7682
* proved the openai-chat wire is alive (e.g. Zhipu GLM — no public /models).
7783
* `responses_degraded` when /models 404'd but POST /responses proved the
@@ -81,6 +87,7 @@ export interface ConnectionTestResult {
8187
*/
8288
probeMethod?:
8389
| 'models'
90+
| 'inference'
8491
| 'chat_completion_degraded'
8592
| 'responses_degraded'
8693
| 'anthropic_messages_degraded';
@@ -100,12 +107,18 @@ export interface ConnectionTestError {
100107
export type ConnectionTestResponse = ConnectionTestResult | ConnectionTestError;
101108

102109
export type ModelsListResponse =
103-
| { ok: true; models: string[] }
110+
| {
111+
ok: true;
112+
models: string[];
113+
discoveryMode?: ProviderModelDiscoveryMode;
114+
source?: 'remote' | 'static-hint' | 'local';
115+
}
104116
| {
105117
ok: false;
106118
code: 'IPC_BAD_INPUT' | 'NETWORK' | 'HTTP' | 'PARSE';
107119
message: string;
108120
hint: string;
121+
discoveryMode?: ProviderModelDiscoveryMode;
109122
};
110123

111124
function parseConnectionTestPayload(raw: unknown): ConnectionTestPayloadV1 {
@@ -492,6 +505,9 @@ export interface ActiveProviderCredentials {
492505
builtin?: boolean;
493506
/** Opt-in TLS verification bypass; only honored when `builtin === false`. */
494507
tlsRejectUnauthorized?: boolean;
508+
/** Listing strategy for this provider. Defaults to `models` when omitted so
509+
* existing tests and older callers keep probing GET /models. */
510+
modelDiscoveryMode?: ProviderModelDiscoveryMode;
495511
}
496512

497513
function resolveCredentialsForProvider(
@@ -539,6 +555,7 @@ function resolveCredentialsForProvider(
539555
apiKey,
540556
baseUrl: entry.baseUrl,
541557
builtin: entry.builtin === true,
558+
modelDiscoveryMode: resolveProviderCapabilities(providerId, entry).modelDiscoveryMode,
542559
...(entry.httpHeaders !== undefined ? { httpHeaders: entry.httpHeaders } : {}),
543560
...(entry.tlsRejectUnauthorized !== undefined
544561
? { tlsRejectUnauthorized: entry.tlsRejectUnauthorized }
@@ -607,10 +624,15 @@ export async function runProviderTest(
607624
return testChatGPTCodexOAuth();
608625
}
609626

627+
const discoveryMode = creds.modelDiscoveryMode ?? 'models';
628+
const bypass = creds.builtin !== true && creds.tlsRejectUnauthorized === true;
629+
if (!connectionTestProbesModelsEndpoint(discoveryMode)) {
630+
return withTlsBypass(bypass, () => probeDeclaredInference(creds));
631+
}
632+
610633
// Bypass is the per-provider opt-in, force-gated so a tampered config can
611634
// never weaken TLS for built-in providers. Wrapping the whole body covers
612635
// both the GET /models probe and the inner POST inside tryDegradeProbe.
613-
const bypass = creds.builtin !== true && creds.tlsRejectUnauthorized === true;
614636
return withTlsBypass(bypass, async () => {
615637
const { url, normalizedBaseUrl } = buildEndpointForWire(creds.wire, creds.baseUrl);
616638
const headers = buildAuthHeadersForWire(
@@ -666,6 +688,55 @@ export async function runProviderTest(
666688
});
667689
}
668690

691+
async function probeDeclaredInference(
692+
creds: ActiveProviderCredentials,
693+
): Promise<ConnectionTestResponse> {
694+
if (
695+
creds.wire !== 'openai-chat' &&
696+
creds.wire !== 'openai-responses' &&
697+
creds.wire !== 'anthropic'
698+
) {
699+
return {
700+
ok: false,
701+
code: 'IPC_BAD_INPUT',
702+
message: `Discovery mode does not probe /models for wire "${creds.wire}"`,
703+
hint: 'Use a listed model id or switch this provider to a listing-capable endpoint',
704+
compatibility: 'incompatible',
705+
reasonCategory: 'unknown',
706+
};
707+
}
708+
const { normalizedBaseUrl } = buildEndpointForWire(creds.wire, creds.baseUrl);
709+
const headers = buildAuthHeadersForWire(
710+
creds.wire,
711+
creds.apiKey,
712+
creds.httpHeaders,
713+
creds.baseUrl,
714+
);
715+
const probe = await probeInferenceEndpoint(creds.wire, normalizedBaseUrl, headers);
716+
if (probe.kind === 'pass') {
717+
return { ok: true, probeMethod: 'inference', compatibility: 'compatible' };
718+
}
719+
if (probe.kind === 'network') {
720+
return {
721+
ok: false,
722+
code: 'NETWORK',
723+
message: probe.message,
724+
hint: 'Cannot reach provider inference endpoint',
725+
compatibility: 'incompatible',
726+
reasonCategory: 'unknown',
727+
};
728+
}
729+
const { code, hint } = classifyHttpError(probe.status);
730+
return {
731+
ok: false,
732+
code,
733+
message: `HTTP ${probe.status}`,
734+
hint,
735+
compatibility: 'incompatible',
736+
reasonCategory: connectionCategoryForStatus(probe.status, normalizedBaseUrl),
737+
};
738+
}
739+
669740
async function tryDegradeProbe(
670741
wire: 'openai-chat' | 'openai-responses' | 'anthropic',
671742
normalizedBaseUrl: string,
@@ -977,19 +1048,24 @@ async function handleModelsV1ListForProvider(raw: unknown): Promise<ModelsListRe
9771048
if ('ok' in resolved) return resolved;
9781049
const { providerId, entry } = resolved;
9791050

980-
// Providers that expose a static hint (e.g. chatgpt-codex, whose /models
981-
// endpoint requires OAuth bearer + ChatGPT-Account-Id headers that this
982-
// keyless discovery path cannot supply) short-circuit with modelsHint.
983-
if (entry.modelsHint !== undefined && entry.modelsHint.length > 0) {
984-
return { ok: true, models: entry.modelsHint };
1051+
const plan = resolveListForProviderPlan(providerId, entry);
1052+
if (plan.action === 'return') {
1053+
return {
1054+
ok: true,
1055+
models: plan.models,
1056+
discoveryMode: plan.discoveryMode,
1057+
source: plan.source,
1058+
};
9851059
}
9861060

9871061
const keyResult = resolveApiKeyForListing(providerId, entry);
9881062
if ('ok' in keyResult) return keyResult;
9891063
const { apiKey } = keyResult;
9901064

9911065
const cached = getCachedModels(providerId, entry.baseUrl, apiKey);
992-
if (cached !== null) return { ok: true, models: cached };
1066+
if (cached !== null) {
1067+
return { ok: true, models: cached, discoveryMode: 'models', source: 'remote' };
1068+
}
9931069

9941070
const { url } = buildEndpointForWire(entry.wire, entry.baseUrl);
9951071
const headers = buildAuthHeadersForWire(entry.wire, apiKey, entry.httpHeaders, entry.baseUrl);
@@ -1001,8 +1077,11 @@ async function handleModelsV1ListForProvider(raw: unknown): Promise<ModelsListRe
10011077
hint: 'Check provider /models endpoint compatibility',
10021078
}),
10031079
);
1004-
if (result.ok) setCachedModels(providerId, entry.baseUrl, apiKey, result.models);
1005-
return result;
1080+
if (result.ok) {
1081+
setCachedModels(providerId, entry.baseUrl, apiKey, result.models);
1082+
return { ...result, discoveryMode: 'models', source: 'remote' };
1083+
}
1084+
return { ...result, discoveryMode: 'models' };
10061085
}
10071086

10081087
async function fetchModelListResponse(

0 commit comments

Comments
 (0)