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 7fa7c5a  feat(provider): give a provider an icon, notes, and a 
connectivity check before it is saved
7fa7c5a is described below

commit 7fa7c5a75d55bd88d070facead78be7cd84c77ae
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 00:58:13 2026 +0800

    feat(provider): give a provider an icon, notes, and a connectivity check 
before it is saved
---
 controllers/provider.go                     |  83 ++++++++++-----
 object/provider.go                          |  26 ++---
 web/src/backend/ProviderBackend.ts          |   6 +-
 web/src/components/ProviderCard.tsx         |   2 +
 web/src/components/ProviderIcon.tsx         | 153 ++++++++++++++++++++++++++
 web/src/components/ProviderSourcePicker.tsx |   7 +-
 web/src/components/ProviderTestField.tsx    | 159 ++++++++++++++++++++++++++++
 web/src/locales/en/data.json                |  10 +-
 web/src/locales/zh/data.json                |  10 +-
 web/src/pages/ProviderEditPage.tsx          |  88 ++++++---------
 web/src/pages/ProviderListPage.tsx          |  71 ++++++++++---
 web/src/types.ts                            |   5 +
 12 files changed, 509 insertions(+), 111 deletions(-)

diff --git a/controllers/provider.go b/controllers/provider.go
index eb71d46..c654de8 100644
--- a/controllers/provider.go
+++ b/controllers/provider.go
@@ -227,21 +227,9 @@ func (c *ApiController) GetProviderModels() {
                c.ResponseError("unauthorized")
                return
        }
-
-       // The browser only ever sees the mask, so an untouched key field means 
the
-       // probe has to use the one the provider already has stored.
-       if provider.ApiKey == object.ApiKeyMask {
-               provider.ApiKey = ""
-               if provider.Name != "" {
-                       stored, err := object.GetProvider(provider.GetId())
-                       if err != nil {
-                               c.ResponseError(err.Error())
-                               return
-                       }
-                       if stored != nil {
-                               provider.ApiKey = stored.ApiKey
-                       }
-               }
+       if err := resolveProviderApiKey(&provider); err != nil {
+               c.ResponseError(err.Error())
+               return
        }
 
        models, err := object.FetchProviderModels(&provider)
@@ -253,31 +241,72 @@ func (c *ApiController) GetProviderModels() {
        c.ResponseOk(models)
 }
 
-// TestProvider tests connectivity to an upstream provider.
+// resolveProviderApiKey fills in the key a probe has to be made with. The
+// browser only ever sees the mask, so an untouched key field means the one the
+// provider already has stored; a provider that is not saved yet has none.
+func resolveProviderApiKey(provider *object.Provider) error {
+       if provider.ApiKey != object.ApiKeyMask {
+               return nil
+       }
+
+       provider.ApiKey = ""
+       if _, _, ok := getProviderOwnerAndName(provider.GetId()); !ok {
+               return nil
+       }
+
+       stored, err := object.GetProvider(provider.GetId())
+       if err != nil {
+               return err
+       }
+       if stored != nil {
+               provider.ApiKey = stored.ApiKey
+       }
+       return nil
+}
+
+// TestProvider tests connectivity to an upstream provider. The provider comes
+// from the request body so that a form can be checked before it is saved; a
+// body carrying only an id falls back to the stored provider.
 func (c *ApiController) TestProvider() {
        if c.RequireSignedIn() {
                return
        }
 
-       var request struct {
-               Owner string `json:"owner"`
-               Name  string `json:"name"`
-       }
-       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+       var provider object.Provider
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &provider); err != 
nil {
                c.ResponseError(err.Error())
                return
        }
 
-       if request.Owner == "" || request.Name == "" {
-               c.ResponseError("the provider owner and name cannot be empty")
-               return
+       if provider.Owner == "" {
+               provider.Owner = c.GetSessionUsername()
        }
-       if !c.providerAccess(request.Owner) {
+       if !c.providerAccess(provider.Owner) {
                c.ResponseError("unauthorized")
                return
        }
 
-       provider := &object.Provider{Owner: request.Owner, Name: request.Name}
-       success, statusCode, message := 
object.TestProviderConnectivity(provider)
+       if provider.BaseUrl == "" {
+               id := provider.GetId()
+               if _, _, ok := getProviderOwnerAndName(id); !ok {
+                       c.ResponseError("invalid provider ID: " + id)
+                       return
+               }
+               stored, err := object.GetProvider(id)
+               if err != nil {
+                       c.ResponseError(err.Error())
+                       return
+               }
+               if stored == nil {
+                       c.ResponseError("the provider does not exist")
+                       return
+               }
+               provider = *stored
+       } else if err := resolveProviderApiKey(&provider); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       success, statusCode, message := 
object.TestProviderConnectivity(&provider)
        c.ResponseOk(map[string]interface{}{"success": success, "statusCode": 
statusCode, "message": message})
 }
diff --git a/object/provider.go b/object/provider.go
index 2d4073e..f45b207 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -200,6 +200,12 @@ type Provider struct {
        // Models is JSON-serialized by xorm, so it needs a text column rather 
than
        // a varchar: the serialized form is longer than the joined model names.
        Models []string `xorm:"mediumtext" json:"models"`
+       // Icon is a site the vendor's favicon is taken from, or an image URL. 
Empty
+       // means the icon is derived from BaseUrl.
+       Icon string `xorm:"varchar(255)" json:"icon"`
+       // Notes is whatever the person who added the provider wants to remember
+       // about it: which account the key belongs to, when it expires, who 
pays.
+       Notes string `xorm:"varchar(500)" json:"notes"`
        // TODO(1.2): Priority routing strategy will be defined in milestone 
1.2.
        Priority int    `xorm:"int" json:"priority"`
        Status   string `xorm:"varchar(100)" json:"status"`
@@ -562,28 +568,24 @@ func probeProvider(provider *Provider) (*providerProbe, 
error) {
 
 // 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.
+// 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.
 func TestProviderConnectivity(provider *Provider) (bool, int, string) {
-       stored, err := getProvider(provider.Owner, provider.Name)
-       if err != nil {
-               return false, 0, err.Error()
-       }
-       if stored == nil {
-               return false, 0, "the provider does not exist"
-       }
-
-       probe, err := probeProvider(stored)
+       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.
-       if UsesClientAuth(stored) && (probe.statusCode == 
http.StatusUnauthorized || probe.statusCode == http.StatusForbidden) {
+       if UsesClientAuth(provider) && (probe.statusCode == 
http.StatusUnauthorized || probe.statusCode == http.StatusForbidden) {
                return true, probe.statusCode, "reachable, and authenticated 
with the caller's own credentials"
        }
 
-       return probe.ok(), probe.statusCode, probe.status
+       if !probe.ok() {
+               return false, probe.statusCode, probe.status + 
probeDetail(probe.body)
+       }
+       return true, probe.statusCode, probe.status
 }
 
 // FetchProviderModels lists what the provider's upstream reports at its models
diff --git a/web/src/backend/ProviderBackend.ts 
b/web/src/backend/ProviderBackend.ts
index 4e40bbd..8936165 100644
--- a/web/src/backend/ProviderBackend.ts
+++ b/web/src/backend/ProviderBackend.ts
@@ -55,8 +55,10 @@ export function getProviderModels(provider: Provider) {
   return request<string[]>("/api/get-provider-models", "POST", provider);
 }
 
-export function testProvider(owner: string, name: string) {
-  return request<ProviderTestResult>("/api/test-provider", "POST", {owner: 
owner, name: name});
+/** Probes the provider's upstream. The whole provider is posted rather than an
+ * id, so a form can be checked before it is saved. */
+export function testProvider(provider: Provider) {
+  return request<ProviderTestResult>("/api/test-provider", "POST", provider);
 }
 
 /** What the proxy has seen of each provider, which is what says why a request
diff --git a/web/src/components/ProviderCard.tsx 
b/web/src/components/ProviderCard.tsx
index 8f57b89..07c89ad 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -18,6 +18,7 @@ import i18next from "i18next";
 
 import * as AgentBackend from "@/backend/AgentBackend";
 import {EnvSnippet} from "@/components/EnvSnippet";
+import {ProviderIcon} from "@/components/ProviderIcon";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {Loading} from "@/components/shared/loading";
 import {MessageAlert} from "@/components/ui/alert";
@@ -227,6 +228,7 @@ export function ProviderCard({
             </SelectItem>
             {providers.map(provider => (
               <SelectItem key={providerIdOf(provider)} 
value={providerIdOf(provider)}>
+                <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} 
size={16} />
                 {provider.displayName || provider.name}
                 {/* The type is the wire format, which has to match the one 
the agent speaks. */}
                 <span className="ml-2 text-xs 
text-muted-foreground">{provider.type}</span>
diff --git a/web/src/components/ProviderIcon.tsx 
b/web/src/components/ProviderIcon.tsx
new file mode 100644
index 0000000..e736a43
--- /dev/null
+++ b/web/src/components/ProviderIcon.tsx
@@ -0,0 +1,153 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Plug} from "lucide-react";
+import i18next from "i18next";
+
+import {Field} from "@/components/shared/form-dialog";
+import {Input} from "@/components/ui/input";
+import {cn} from "@/lib/utils";
+import type {Provider} from "@/types";
+
+const imageUrl = /\.(png|jpe?g|svg|gif|webp|ico|avif)$/i;
+
+/**
+ * The site a vendor serves its icon from, which is the registrable domain
+ * rather than the host the API answers on: https://api.deepseek.com/v1 is
+ * deepseek.com, and api.deepseek.com has no favicon of its own.
+ */
+export function providerSite(value: string) {
+  const trimmed = value.trim();
+  if (trimmed === "") {
+    return "";
+  }
+
+  let host = "";
+  try {
+    host = new URL(/^[a-z]+:\/\//i.test(trimmed) ? trimmed : 
`https://${trimmed}`).hostname;
+  } catch {
+    return "";
+  }
+
+  // An address has no domain to shorten, and shortening it would leave a
+  // meaningless fragment of it behind.
+  if (/^[\d.]+$/.test(host) || !host.includes(".")) {
+    return host;
+  }
+
+  const labels = host.split(".").filter(Boolean);
+  if (labels.length < 3) {
+    return labels.join(".");
+  }
+  // Two labels, unless the second-to-last is a suffix that carries no name of
+  // its own, as in example.com.cn.
+  const suffixes = ["com", "net", "org", "gov", "edu", "ac", "co"];
+  return labels.slice(suffixes.includes(labels[labels.length - 2]) ? -3 : 
-2).join(".");
+}
+
+/** The image a provider is shown with, or "" when there is nothing to show. */
+export function providerIconUrl(icon: string | undefined, baseUrl: string | 
undefined, size = 64) {
+  const value = (icon ?? "").trim();
+  if (value !== "" && /^https?:\/\//i.test(value)) {
+    try {
+      if (imageUrl.test(new URL(value).pathname)) {
+        return value;
+      }
+    } catch {
+      // Not a URL after all, so it is treated as a site below.
+    }
+  }
+
+  const site = providerSite(value === "" ? (baseUrl ?? "") : value);
+  return site === "" ? "" : 
`https://www.google.com/s2/favicons?domain=${site}&sz=${size}`;
+}
+
+/**
+ * The vendor's own mark, so a list of providers is scanned by logo rather than
+ * read. It is taken from the base URL unless the provider names an icon of its
+ * own, which is what a self-hosted or aggregated endpoint needs.
+ */
+export function ProviderIcon({
+  icon,
+  baseUrl,
+  alt,
+  size = 20,
+  className,
+  fallback,
+}: {
+  icon?: string;
+  baseUrl?: string;
+  alt?: string;
+  size?: number;
+  className?: string;
+  fallback?: React.ReactNode;
+}) {
+  const [broken, setBroken] = React.useState(false);
+  const src = providerIconUrl(icon, baseUrl, 64);
+
+  React.useEffect(() => {
+    setBroken(false);
+  }, [src]);
+
+  if (src === "" || broken) {
+    return (
+      <>
+        {fallback ?? (
+          <Plug className={cn("text-muted-foreground shrink-0", className)} 
style={{width: size, height: size}} />
+        )}
+      </>
+    );
+  }
+
+  return (
+    <img
+      src={src}
+      alt={alt ?? ""}
+      width={size}
+      height={size}
+      onError={() => setBroken(true)}
+      className={cn("block shrink-0 rounded", className)}
+    />
+  );
+}
+
+/** The Icon field of both provider forms, with what it resolves to beside it. 
*/
+export function ProviderIconField({
+  provider,
+  onChange,
+}: {
+  provider: Provider;
+  onChange: (icon: string) => void;
+}) {
+  return (
+    <Field label={i18next.t("provider:Icon")} htmlFor="provider-icon" 
hint={i18next.t("provider:Icon hint")}>
+      <div className="flex items-center gap-2">
+        <span className="bg-muted/40 flex size-9 shrink-0 items-center 
justify-center rounded-md border">
+          <ProviderIcon
+            icon={provider.icon}
+            baseUrl={provider.baseUrl}
+            alt={provider.displayName || provider.name}
+          />
+        </span>
+        <Input
+          id="provider-icon"
+          placeholder={providerSite(provider.baseUrl ?? "") || "openai.com"}
+          value={provider.icon ?? ""}
+          onChange={event => onChange(event.target.value)}
+        />
+      </div>
+    </Field>
+  );
+}
diff --git a/web/src/components/ProviderSourcePicker.tsx 
b/web/src/components/ProviderSourcePicker.tsx
index 8556d03..cfc49ed 100644
--- a/web/src/components/ProviderSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -15,6 +15,7 @@
 import {KeyRound, LogIn, Settings2} from "lucide-react";
 import i18next from "i18next";
 
+import {ProviderIcon} from "@/components/ProviderIcon";
 import {providerSources, customSource, subscriptionSource, type 
ProviderSource} from "@/lib/providers";
 
 /**
@@ -70,7 +71,11 @@ export function ProviderSourcePicker({onPick}: {onPick: 
(source: ProviderSource)
             className="hover:border-primary hover:bg-accent/40 flex flex-col 
items-start gap-1 rounded-lg border p-4 text-left transition-colors"
           >
             <span className="flex items-center gap-2 text-sm font-medium">
-              <Icon className="size-4 shrink-0" />
+              <ProviderIcon
+                baseUrl={source.provider.baseUrl}
+                size={16}
+                fallback={<Icon className="size-4 shrink-0" />}
+              />
               {sourceTitle(source)}
             </span>
             <span className="text-muted-foreground break-all 
text-xs">{sourceDetail(source)}</span>
diff --git a/web/src/components/ProviderTestField.tsx 
b/web/src/components/ProviderTestField.tsx
new file mode 100644
index 0000000..4dc9b0a
--- /dev/null
+++ b/web/src/components/ProviderTestField.tsx
@@ -0,0 +1,159 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {CircleCheck, CircleX, Zap} from "lucide-react";
+import i18next from "i18next";
+
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import {Field} from "@/components/shared/form-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import type {Provider, ProviderTestResult} from "@/types";
+
+export interface ProviderTest {
+  testing: boolean;
+  result: ProviderTestResult | null;
+  /** False while the form has no upstream to probe yet. */
+  canTest: boolean;
+  /** True once a failed probe has been shown, i.e. the next save goes 
through. */
+  overridden: boolean;
+  run: () => Promise<ProviderTestResult>;
+  /** Probes the upstream, then saves if it answered. See the comment on 
guard. */
+  guard: (save: () => void) => void;
+}
+
+/** What the probe found, in the one line a form has room for. */
+export function testMessage(result: ProviderTestResult) {
+  return result.statusCode ? `HTTP ${result.statusCode} - ${result.message}` : 
result.message;
+}
+
+/**
+ * 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.
+ */
+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.
+  const identity = provider
+    ? 
`${provider.type}|${provider.baseUrl}|${provider.apiKey}|${provider.authMode}`
+    : "";
+  React.useEffect(() => {
+    setResult(null);
+    setOverridden(false);
+  }, [identity]);
+
+  const run = () => {
+    if (!provider) {
+      const failed = {success: false, message: i18next.t("provider:Nothing to 
test")};
+      setResult(failed);
+      return Promise.resolve(failed);
+    }
+
+    setTesting(true);
+    setResult(null);
+    // A rejected request is a failed probe, not a broken page: "no such host"
+    // is exactly what the person filling in the form needs to read.
+    return ProviderBackend.testProvider(provider)
+      .then(res => (res.status === "ok" && res.data ? res.data : {success: 
false, message: res.msg}))
+      .catch(error => ({success: false, message: String(error)}))
+      .then(data => {
+        setTesting(false);
+        setResult(data);
+        return data;
+      });
+  };
+
+  // A failing upstream is a warning and not a verdict: a key that is not live
+  // yet, or a vendor that is briefly down, is still worth saving. So the first
+  // attempt stops to show what went wrong and the second one saves anyway.
+  const guard = (save: () => void) => {
+    if (!provider || provider.baseUrl === "" || overridden) {
+      save();
+      return;
+    }
+
+    run().then(data => {
+      if (data.success) {
+        save();
+        return;
+      }
+      setOverridden(true);
+    });
+  };
+
+  return {
+    testing: testing,
+    result: result,
+    canTest: (provider?.baseUrl ?? "") !== "",
+    overridden: overridden,
+    run: run,
+    guard: guard,
+  };
+}
+
+/** What the probe answered, as a badge and the upstream's own words under it. 
*/
+export function ProviderTestOutcome({test}: {test: ProviderTest}) {
+  if (!test.result) {
+    return null;
+  }
+
+  return (
+    <div className="grid gap-1">
+      <Badge variant={test.result.success ? "success" : "destructive"} 
className="w-fit">
+        {test.result.success ? <CircleCheck /> : <CircleX />}
+        {test.result.success
+          ? i18next.t("provider:Connection Successful")
+          : i18next.t("provider:Connection Failed")}
+      </Badge>
+      <p className={test.result.success ? "text-muted-foreground text-xs" : 
"text-destructive text-xs"}>
+        {testMessage(test.result)}
+      </p>
+    </div>
+  );
+}
+
+/** The "does this key work" row of a provider form. */
+export function ProviderTestField({test, submitText}: {test: ProviderTest; 
submitText: string}) {
+  return (
+    <Field
+      label={i18next.t("provider:Connectivity")}
+      hint={
+        test.overridden
+          ? i18next.t("provider:Save anyway hint").replace("{action}", 
submitText)
+          : i18next.t("provider:Connectivity hint")
+      }
+    >
+      <div className="flex flex-wrap items-center gap-3">
+        <Button
+          type="button"
+          size="sm"
+          variant="outline"
+          loading={test.testing}
+          disabled={!test.canTest}
+          onClick={() => test.run()}
+        >
+          <Zap />
+          {i18next.t("provider:Test Connectivity")}
+        </Button>
+        <ProviderTestOutcome test={test} />
+      </div>
+    </Field>
+  );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 15a6868..ddc13ad 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -470,6 +470,7 @@
     "API Key": "API Key",
     "API Key hint": "The stored key is never sent back to the browser. Leave 
*** as it is to keep the current key, or clear the field to remove it.",
     "API Key ownership hint": "Stored by Gateway and never handed to an 
agent.",
+    "Add provider": "Add provider",
     "Any model": "Any model",
     "Any model hint": "Leave this empty to accept every model name, which is 
what a provider that forwards the caller's login usually wants.",
     "Authentication": "Authentication",
@@ -484,6 +485,8 @@
     "Client auth usage hint": "Only the base URL is set: a token here would 
replace the sign-in the client already has.",
     "Connection Failed": "Connection Failed",
     "Connection Successful": "Connection Successful",
+    "Connectivity": "Connectivity",
+    "Connectivity hint": "A read-only probe against the upstream, which costs 
nothing and answers whether this key works. It also runs once when the form is 
submitted.",
     "Cooling down": "Cooling down",
     "Custom vendor": "Custom",
     "Custom vendor detail": "Any OpenAI- or Anthropic-compatible API. You give 
it a base URL and a key.",
@@ -496,11 +499,12 @@
     "Failed to get provider": "Failed to get provider",
     "Failed to get providers": "Failed to get providers",
     "Failed to save": "Failed to save",
-    "Failed to test": "Failed to test",
     "Fetch models": "Fetch models",
     "Fetched {count} models": "Fetched {count} models",
     "Health": "Health",
     "Healthy": "Healthy",
+    "Icon": "Icon",
+    "Icon hint": "The site the vendor's icon is taken from, or an image URL. 
Leave it empty to take it from the base URL.",
     "Model routing hint": "Requests that arrive here are routed by the model 
name they carry, to the enabled provider with the smallest priority value that 
serves it. To send one agent's requests to this provider whatever model they 
name, bind the provider on the agent's page instead.",
     "Models": "Models",
     "Models from upstream": "Models from upstream",
@@ -509,6 +513,9 @@
     "No providers yet": "No providers yet",
     "No providers yet detail": "A provider is your own vendor account: a base 
URL, the models it serves, and either an API key or the caller's own login 
forwarded upstream. Add one, then bind it to an agent to route that agent's 
requests through it.",
     "Not used yet": "Not used yet",
+    "Notes": "Notes",
+    "Notes hint": "For whoever reads this later: which account the key belongs 
to, when it expires, who pays for it.",
+    "Nothing to test": "There is nothing to test yet",
     "Page description": "One endpoint in front of your model vendors. Gateway 
holds the API key, so the agents never have it — or forwards the agent's own 
sign-in and holds nothing.",
     "Priority": "Priority",
     "Priority hint": "Smaller number means higher priority, 0 is the highest; 
when multiple providers serve the same model, the one with the smaller value is 
used first.",
@@ -520,6 +527,7 @@
     "Providers": "Providers",
     "Recovering": "Recovering",
     "Retried at": "retried at",
+    "Save anyway hint": "The upstream did not accept this provider. Fix it, or 
press {action} again to store it as it is.",
     "Select all": "Select all",
     "Source": "Source",
     "Source hint": "Where this provider gets its credentials. Picking one 
fills in the type, base URL and models, so a subscription needs nothing typed 
and a vendor needs only its key.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 533f699..ac504fb 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -470,6 +470,7 @@
     "API Key": "API 密钥",
     "API Key hint": "已保存的密钥不会返回给浏览器。保持 *** 不变即可沿用当前密钥,清空该字段则删除密钥。",
     "API Key ownership hint": "由 Gateway 保存,不会交给任何 Agent。",
+    "Add provider": "添加供应商",
     "Any model": "任意模型",
     "Any model hint": "留空表示接受任何模型名,转发调用方登录的供应商一般都这样配。",
     "Authentication": "认证方式",
@@ -484,6 +485,8 @@
     "Client auth usage hint": "只设置 base URL:这里再设一个 token 会覆盖客户端已有的登录。",
     "Connection Failed": "连接失败",
     "Connection Successful": "连接成功",
+    "Connectivity": "连通性",
+    "Connectivity hint": "对上游做一次只读探测,不产生费用,用来确认这个密钥是否可用。提交表单时也会自动探测一次。",
     "Cooling down": "冷却中",
     "Custom vendor": "自定义",
     "Custom vendor detail": "任何兼容 OpenAI 或 Anthropic 的 API,由你填写 base URL 和 
Key。",
@@ -496,11 +499,12 @@
     "Failed to get provider": "获取供应商失败",
     "Failed to get providers": "获取供应商列表失败",
     "Failed to save": "保存失败",
-    "Failed to test": "测试失败",
     "Fetch models": "自动获取",
     "Fetched {count} models": "共获取到 {count} 个模型",
     "Health": "健康状态",
     "Healthy": "正常",
+    "Icon": "图标",
+    "Icon hint": "取图标的站点域名,或一个图片地址。留空则从 Base URL 推断。",
     "Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority 
值最小的已启用供应商。如果想让某个 Agent 的请求不管模型名一律走这个供应商,请到该 Agent 的页面上绑定。",
     "Models": "模型",
     "Models from upstream": "上游返回的模型",
@@ -509,6 +513,9 @@
     "No providers yet": "还没有供应商",
     "No providers yet detail": "一个供应商就是你自己的厂商账号:base URL、它提供的模型,以及一个 API 
Key,或者转发调用方自己的登录。先建一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
     "Not used yet": "尚未使用",
+    "Notes": "备注",
+    "Notes hint": "写给以后的自己:这个密钥属于哪个账号、什么时候过期、由谁付费。",
+    "Nothing to test": "暂时没有可探测的内容",
     "Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 保管,Agent 拿不到;也可以转发 
Agent 自己的登录,什么都不保管。",
     "Priority": "优先级",
     "Priority hint": "数字越小优先级越高,0 为最高;同一模型有多个供应商时优先使用数值小的那个。",
@@ -520,6 +527,7 @@
     "Providers": "供应商",
     "Recovering": "恢复中",
     "Retried at": "将于",
+    "Save anyway hint": "上游没有接受这个供应商。修正后重试,或再次点击「{action}」按当前内容保存。",
     "Select all": "全选",
     "Source": "来源",
     "Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base URL 和模型:订阅登录什么都不用填,厂商只用填一个 
Key。",
diff --git a/web/src/pages/ProviderEditPage.tsx 
b/web/src/pages/ProviderEditPage.tsx
index 27159e5..b427ac9 100644
--- a/web/src/pages/ProviderEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -14,12 +14,14 @@
 
 import * as React from "react";
 import {useNavigate, useParams} from "react-router-dom";
-import {CircleCheck, CircleX, Save, Zap} from "lucide-react";
+import {CircleCheck, CircleX, Save} from "lucide-react";
 import i18next from "i18next";
 
 import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
+import {ProviderIcon, ProviderIconField} from "@/components/ProviderIcon";
 import {ProviderModelsField} from "@/components/ProviderModelsField";
+import {ProviderTestField, useProviderTest} from 
"@/components/ProviderTestField";
 import {EnvSnippet} from "@/components/EnvSnippet";
 import {Field} from "@/components/shared/form-dialog";
 import {Loading} from "@/components/shared/loading";
@@ -28,9 +30,9 @@ import {NumberInput} from "@/components/shared/number-input";
 import {PageContainer, PageHeader, Section} from 
"@/components/shared/page-header";
 import {PasswordInput} from "@/components/shared/password-input";
 import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
-import {MessageAlert} from "@/components/ui/alert";
 import {Button} from "@/components/ui/button";
 import {Input} from "@/components/ui/input";
+import {Textarea} from "@/components/ui/textarea";
 import {
   authClient,
   baseUrlPlaceholder,
@@ -40,7 +42,7 @@ import {
   localShell,
   usesClientAuth,
 } from "@/lib/providers";
-import type {Provider, ProviderTestResult} from "@/types";
+import type {Provider} from "@/types";
 
 // Mirrors object.BuildOpenAiUrl on the server.
 function buildOpenAiUrl(baseUrl: string, endpoint: string) {
@@ -101,8 +103,7 @@ export default function ProviderEditPage() {
   const navigate = useNavigate();
   // undefined while loading, null when the provider could not be loaded.
   const [provider, setProvider] = React.useState<Provider | null | 
undefined>(undefined);
-  const [testing, setTesting] = React.useState(false);
-  const [result, setResult] = React.useState<ProviderTestResult | null>(null);
+  const test = useProviderTest(provider);
 
   React.useEffect(() => {
     ProviderBackend.getProvider(owner, providerName)
@@ -126,44 +127,19 @@ export default function ProviderEditPage() {
 
   const save = () => {
     if (!provider) {
-      return Promise.resolve(false);
+      return;
     }
 
-    return ProviderBackend.updateProvider(owner, providerName, provider)
+    ProviderBackend.updateProvider(owner, providerName, provider)
       .then(res => {
         if (res.status === "error") {
           Setting.showMessage("error", `${i18next.t("provider:Failed to 
save")}: ${res.msg}`);
-          return false;
+          return;
         }
         Setting.showMessage("success", i18next.t("provider:Provider saved"));
-        return true;
       })
       .catch(error => {
         Setting.showMessage("error", `${i18next.t("provider:Failed to save")}: 
${error}`);
-        return false;
-      });
-  };
-
-  // The test probes the stored provider, so the edits have to be saved first.
-  const test = () => {
-    setTesting(true);
-    setResult(null);
-    save()
-      .then(saved => (saved ? ProviderBackend.testProvider(owner, 
providerName) : null))
-      .then(res => {
-        setTesting(false);
-        if (res === null) {
-          return;
-        }
-        if (res.status === "error") {
-          Setting.showMessage("error", `${i18next.t("provider:Failed to 
test")}: ${res.msg}`);
-          return;
-        }
-        setResult(res.data);
-      })
-      .catch(error => {
-        setTesting(false);
-        Setting.showMessage("error", `${i18next.t("provider:Failed to test")}: 
${error}`);
       });
   };
 
@@ -186,32 +162,21 @@ export default function ProviderEditPage() {
   return (
     <PageContainer>
       <PageHeader
-        title={i18next.t("provider:Edit Provider")}
+        title={
+          <span className="flex items-center gap-2">
+            <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} 
alt={provider.name} size={22} />
+            {i18next.t("provider:Edit Provider")}
+          </span>
+        }
         description={`${provider.owner} / ${provider.name}`}
         actions={
-          <>
-            <Button variant="outline" onClick={test} loading={testing}>
-              <Zap />
-              {i18next.t("provider:Test Connectivity")}
-            </Button>
-            <Button onClick={save}>
-              <Save />
-              {i18next.t("general:Save")}
-            </Button>
-          </>
+          <Button onClick={() => test.guard(save)} loading={test.testing}>
+            <Save />
+            {i18next.t("general:Save")}
+          </Button>
         }
       />
 
-      {result ? (
-        <MessageAlert
-          variant={result.success ? "success" : "destructive"}
-          title={
-            result.success ? i18next.t("provider:Connection Successful") : 
i18next.t("provider:Connection Failed")
-          }
-          description={result.statusCode ? `HTTP ${result.statusCode} - 
${result.message}` : result.message}
-        />
-      ) : null}
-
       <Section title={i18next.t("provider:Provider")}>
         <Field label={i18next.t("general:Display name")} 
htmlFor="provider-display-name">
           <Input
@@ -314,6 +279,21 @@ export default function ProviderEditPage() {
             ]}
           />
         </Field>
+        <ProviderIconField provider={provider} onChange={value => 
setField("icon", value)} />
+        <ProviderTestField test={test} submitText={i18next.t("general:Save")} 
/>
+        <Field
+          label={i18next.t("provider:Notes")}
+          htmlFor="provider-notes"
+          hint={i18next.t("provider:Notes hint")}
+          className="md:col-span-2 lg:col-span-3"
+        >
+          <Textarea
+            id="provider-notes"
+            rows={3}
+            value={provider.notes ?? ""}
+            onChange={event => setField("notes", event.target.value)}
+          />
+        </Field>
       </Section>
 
       <Section title={i18next.t("provider:Usage")} 
description={i18next.t("provider:Usage hint")} columns={1}>
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index 51d1c31..f933356 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -19,8 +19,10 @@ import i18next from "i18next";
 
 import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
+import {ProviderIcon, ProviderIconField} from "@/components/ProviderIcon";
 import {ProviderModelsField} from "@/components/ProviderModelsField";
 import {ProviderSourcePicker, sourceTitle} from 
"@/components/ProviderSourcePicker";
+import {ProviderTestField, useProviderTest} from 
"@/components/ProviderTestField";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {DataTable, type Column, type SortOrder} from 
"@/components/shared/data-table";
 import {Field, FormDialog} from "@/components/shared/form-dialog";
@@ -32,6 +34,7 @@ import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {Card, CardContent, CardDescription, CardHeader, CardTitle} from 
"@/components/ui/card";
 import {Input} from "@/components/ui/input";
+import {Textarea} from "@/components/ui/textarea";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {
   authProvider,
@@ -58,6 +61,8 @@ function newProvider(owner: string, label = "New Provider"): 
Provider {
     baseUrl: "",
     apiKey: "",
     authMode: authProvider,
+    icon: "",
+    notes: "",
   };
 }
 
@@ -108,6 +113,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
   const [source, setSource] = React.useState<ProviderSource | null>(null);
   const [nameError, setNameError] = React.useState("");
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
+  const test = useProviderTest(form);
 
   const fetchProviders = React.useCallback(
     (nextPage = page, nextPageSize = pageSize, nextSort = sort) => {
@@ -177,12 +183,18 @@ export default function ProviderListPage({account}: 
{account: Account}) {
     setSource(picked);
   };
 
-  const addProvider = () => {
-    const name = form.name.trim();
-    if (name === "") {
+  // The upstream is probed before the provider is stored, so a key that was
+  // pasted wrong is caught here rather than by the first agent that uses it.
+  const submitProvider = () => {
+    if (form.name.trim() === "") {
       setNameError(i18next.t("general:Name cannot be empty"));
       return;
     }
+    test.guard(addProvider);
+  };
+
+  const addProvider = () => {
+    const name = form.name.trim();
     setAdding(true);
     ProviderBackend.addProvider({...form, name: name})
       .then(res => {
@@ -226,14 +238,17 @@ export default function ProviderListPage({account}: 
{account: Account}) {
       // the current page.
       sorter: true,
       render: (text: string, record) => (
-        <SimpleTooltip title={text}>
-          <Link
-            to={`/providers/${record.owner}/${record.name}`}
-            className="text-primary block truncate font-medium hover:underline"
-          >
-            {text}
-          </Link>
-        </SimpleTooltip>
+        <div className="flex min-w-0 items-center gap-2">
+          <ProviderIcon icon={record.icon} baseUrl={record.baseUrl} alt={text} 
size={18} />
+          <SimpleTooltip title={text}>
+            <Link
+              to={`/providers/${record.owner}/${record.name}`}
+              className="text-primary block truncate font-medium 
hover:underline"
+            >
+              {text}
+            </Link>
+          </SimpleTooltip>
+        </div>
       ),
     },
     {
@@ -244,6 +259,21 @@ export default function ProviderListPage({account}: 
{account: Account}) {
       sorter: true,
       render: (text: string) => (text ? <span className="block 
truncate">{text}</span> : "-"),
     },
+    {
+      title: i18next.t("provider:Notes"),
+      key: "notes",
+      dataIndex: "notes",
+      width: "180px",
+      ellipsis: true,
+      render: (text: string) =>
+        text ? (
+          <SimpleTooltip title={text}>
+            <span className="text-muted-foreground block 
truncate">{text}</span>
+          </SimpleTooltip>
+        ) : (
+          "-"
+        ),
+    },
     {
       title: i18next.t("provider:Type"),
       key: "type",
@@ -431,8 +461,9 @@ export default function ProviderListPage({account}: 
{account: Account}) {
         title={i18next.t("provider:New Provider")}
         description={source === null ? i18next.t("provider:Source hint") : 
undefined}
         size={source === null ? "lg" : "default"}
-        submitting={adding}
-        onSubmit={addProvider}
+        submitting={adding || test.testing}
+        submitText={i18next.t("provider:Add provider")}
+        onSubmit={submitProvider}
         // Nothing is filled in yet while the source is still being picked, so
         // there is nothing to submit.
         footer={
@@ -494,6 +525,19 @@ export default function ProviderListPage({account}: 
{account: Account}) {
               hint={usesClientAuth(form) ? i18next.t("provider:Any model 
hint") : i18next.t("provider:Models hint")}
               onChange={value => setFormField("models", value)}
             />
+            <ProviderTestField test={test} submitText={i18next.t("provider:Add 
provider")} />
+            <Field
+              label={i18next.t("provider:Notes")}
+              htmlFor="provider-notes"
+              hint={i18next.t("provider:Notes hint")}
+            >
+              <Textarea
+                id="provider-notes"
+                rows={2}
+                value={form.notes}
+                onChange={event => setFormField("notes", event.target.value)}
+              />
+            </Field>
             <Advanced key={source.key} defaultOpen={source.key === 
customSource}>
               <Field label={i18next.t("provider:Type")}>
                 <SimpleSelect
@@ -530,6 +574,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                   ]}
                 />
               </Field>
+              <ProviderIconField provider={form} onChange={value => 
setFormField("icon", value)} />
             </Advanced>
           </>
         )}
diff --git a/web/src/types.ts b/web/src/types.ts
index 651dec8..f38a5fc 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -157,6 +157,11 @@ export interface Provider {
   baseUrl: string;
   apiKey: string;
   authMode: string;
+  /** A site the vendor's icon is taken from, or an image URL. Empty derives it
+   * from the base URL. */
+  icon: string;
+  /** Whatever the person who added the provider wants to remember about it. */
+  notes: string;
 }
 
 export interface ProviderTestResult {


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

Reply via email to