You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/03 15:14:07 UTC

(casbin-gateway) branch master updated: fix(provider): test connectivity with a real completion

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 <hs...@gmail.com>
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: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org