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]