This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 4d3f5cc  fix(provider): test connectivity with a real completion
4d3f5cc is described below

commit 4d3f5ccfc08431e429bc59638cdb1eec69181229
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 3 23:13:59 2026 +0800

    fix(provider): test connectivity with a real completion
---
 object/provider.go                       | 98 +++++++++++++++++++++++++++-----
 web/src/components/ProviderTestField.tsx | 13 +++--
 2 files changed, 91 insertions(+), 20 deletions(-)

diff --git a/object/provider.go b/object/provider.go
index 41eea2b..3eb8eb6 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -649,18 +649,24 @@ func (probe *providerProbe) ok() bool {
        return probe.statusCode >= 200 && probe.statusCode < 300
 }
 
-// probeProvider performs the read-only GET against the provider's models
-// endpoint. The provider is used as given rather than read back from the
-// database, so a provider that is not saved yet can be probed too.
-func probeProvider(provider *Provider) (*providerProbe, error) {
+// checkProviderTarget rejects what cannot be asked anything at all, so an
+// unsupported type or an unusable base URL is reported as itself rather than 
as
+// whatever the request layer makes of it.
+func checkProviderTarget(provider *Provider) error {
        if !IsProviderTypeSupported(provider) {
-               return nil, fmt.Errorf("the %s provider type is not supported", 
provider.Type)
+               return fmt.Errorf("the %s provider type is not supported", 
provider.Type)
        }
-
        if provider.BaseUrl == "" {
-               return nil, errors.New("the base URL is empty")
+               return errors.New("the base URL is empty")
        }
-       if err := validateBaseUrl(provider.BaseUrl); err != nil {
+       return validateBaseUrl(provider.BaseUrl)
+}
+
+// probeProvider performs the read-only GET against the provider's models
+// endpoint. The provider is used as given rather than read back from the
+// database, so a provider that is not saved yet can be probed too.
+func probeProvider(provider *Provider) (*providerProbe, error) {
+       if err := checkProviderTarget(provider); err != nil {
                return nil, err
        }
 
@@ -713,18 +719,48 @@ func probeProvider(provider *Provider) (*providerProbe, 
error) {
        return &providerProbe{statusCode: resp.StatusCode, status: resp.Status, 
body: body}, nil
 }
 
-// TestProviderConnectivity performs a read-only probe against the provider's
-// upstream. It returns whether the probe succeeded, the upstream HTTP status
-// code (0 when no response was received) and a human-readable message. The
-// provider is probed as given, so a form can be checked before it is saved.
+// testCompletionMaxTokens is the shortest answer worth asking for: what is
+// being checked is that the model answers, not what it says.
+const testCompletionMaxTokens = 16
+
+// TestProviderConnectivity checks that the provider can actually serve a
+// completion. It returns whether it did, the upstream HTTP status code (0 when
+// no response was received) and a human-readable message. The provider is used
+// as given, so a form can be checked before it is saved.
+//
+// A models list is not enough to answer this: a vendor that lists a model it
+// will not serve - or serves only under another name, as the endpoint-id
+// vendors do - answers that list exactly like one that works, and the person
+// filling in the form finds out at the first real request instead.
 func TestProviderConnectivity(provider *Provider) (bool, int, string) {
+       if err := checkProviderTarget(provider); err != nil {
+               return false, 0, err.Error()
+       }
+
+       // A client-auth provider carries no credential of its own, so a 
completion
+       // would only ever measure the missing key. All that can be checked is 
that
+       // the endpoint is there.
+       if UsesClientAuth(provider) || provider.ApiKey == "" {
+               return testProviderReachable(provider)
+       }
+
+       model, err := probeModelOf(provider)
+       if err != nil {
+               return false, 0, err.Error()
+       }
+       return testProviderCompletion(provider, model)
+}
+
+// testProviderReachable is the read-only GET against the models endpoint, 
which
+// is all there is to ask when there is no key to ask with.
+func testProviderReachable(provider *Provider) (bool, int, string) {
        probe, err := probeProvider(provider)
        if err != nil {
                return false, 0, err.Error()
        }
 
-       // A client-auth provider has no key to probe with, so an upstream that
-       // rejects the unauthenticated probe has still proven it is reachable.
+       // An upstream that rejects the unauthenticated probe has still proven 
it is
+       // reachable, which is the whole of what a client-auth provider can 
show.
        if UsesClientAuth(provider) && (probe.statusCode == 
http.StatusUnauthorized || probe.statusCode == http.StatusForbidden) {
                return true, probe.statusCode, "reachable, and authenticated 
with the caller's own credentials"
        }
@@ -735,6 +771,40 @@ func TestProviderConnectivity(provider *Provider) (bool, 
int, string) {
        return true, probe.statusCode, probe.status
 }
 
+// testProviderCompletion asks the model the shortest question its API defines.
+func testProviderCompletion(provider *Provider, model string) (bool, int, 
string) {
+       body := map[string]any{
+               "model":      model,
+               "max_tokens": testCompletionMaxTokens,
+               "messages":   []any{map[string]any{"role": "user", "content": 
probeStreamPrompt}},
+       }
+
+       answer, err := sendProbe(provider, body, false)
+       if err != nil {
+               var urlErr *url.Error
+               if errors.As(err, &urlErr) {
+                       err = urlErr.Err
+               }
+               return false, 0, err.Error()
+       }
+
+       // An error envelope is read before the status code: several vendors 
answer
+       // a refused model with 200 and the reason in the body.
+       if _, message := readProbeError(answer.body); message != "" {
+               return false, answer.status, model + " was refused" + 
probeDetail([]byte(message))
+       }
+       if !answer.ok() {
+               return false, answer.status, model + " was refused" + 
probeDetail(answer.body)
+       }
+
+       parsed := probeBody{}
+       if err := json.Unmarshal(answer.body, &parsed); err != nil ||
+               (parsed.Model == "" && len(parsed.Choices) == 0 && 
len(parsed.Content) == 0) {
+               return false, answer.status, model + " did not answer with a 
completion" + probeDetail(answer.body)
+       }
+       return true, answer.status, model + " answered"
+}
+
 // FetchProviderModels lists what the provider's upstream reports at its models
 // endpoint, so the model names do not have to be typed by hand.
 func FetchProviderModels(provider *Provider) ([]string, error) {
diff --git a/web/src/components/ProviderTestField.tsx 
b/web/src/components/ProviderTestField.tsx
index 4dc9b0a..d13ab5c 100644
--- a/web/src/components/ProviderTestField.tsx
+++ b/web/src/components/ProviderTestField.tsx
@@ -40,19 +40,20 @@ export function testMessage(result: ProviderTestResult) {
 }
 
 /**
- * The connectivity probe behind both provider forms. The probe is a read-only
- * GET against the upstream's models endpoint, so it costs nothing and answers
- * the one question a new provider raises: does this key work.
+ * The connectivity probe behind both provider forms. It asks the first model
+ * for one short answer, because that is the question a new provider raises:
+ * not whether the endpoint is there, but whether it will serve the model that
+ * requests are going to be routed to.
  */
 export function useProviderTest(provider: Provider | null | undefined): 
ProviderTest {
   const [testing, setTesting] = React.useState(false);
   const [result, setResult] = React.useState<ProviderTestResult | null>(null);
   const [overridden, setOverridden] = React.useState(false);
 
-  // The answer belongs to one upstream and one key, so it stops being an 
answer
-  // as soon as either changes.
+  // The answer belongs to one upstream, one key and the model that was asked,
+  // so it stops being an answer as soon as any of them changes.
   const identity = provider
-    ? 
`${provider.type}|${provider.baseUrl}|${provider.apiKey}|${provider.authMode}`
+    ? 
`${provider.type}|${provider.baseUrl}|${provider.apiKey}|${provider.authMode}|${(provider.models
 ?? []).join(",")}`
     : "";
   React.useEffect(() => {
     setResult(null);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to