@@ -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 {
100107export type ConnectionTestResponse = ConnectionTestResult | ConnectionTestError ;
101108
102109export 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
111124function 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
497513function 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+
669740async 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
10081087async function fetchModelListResponse (
0 commit comments