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 ec4eab2  feat(provider): offer 27 vendors to pick from, and reach the 
ones not served under /v1
ec4eab2 is described below

commit ec4eab28c324ff31afe69a3083981c663cb66493
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 10:53:30 2026 +0800

    feat(provider): offer 27 vendors to pick from, and reach the ones not 
served under /v1
---
 object/provider.go                          |  29 +++-
 web/src/components/ProviderModelsField.tsx  |   6 +
 web/src/components/ProviderSourcePicker.tsx | 134 ++++++++++++----
 web/src/lib/providers.ts                    | 231 +++++++++++++++++++++++++++-
 web/src/locales/en/data.json                |   7 +
 web/src/locales/zh/data.json                |   7 +
 web/src/pages/ProviderEditPage.tsx          |   7 +-
 web/src/pages/ProviderListPage.tsx          |  31 +++-
 8 files changed, 406 insertions(+), 46 deletions(-)

diff --git a/object/provider.go b/object/provider.go
index 16d7190..b9e1fdf 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -21,6 +21,7 @@ import (
        "io"
        "net/http"
        "net/url"
+       "regexp"
        "strings"
        "sync"
        "time"
@@ -387,9 +388,31 @@ func validateBaseUrl(baseUrl string) error {
        return nil
 }
 
+// apiVersionSegment matches a path segment that names an API version, which is
+// how a base URL says it is already the root of the API: v1, v3, v1beta.
+var apiVersionSegment = regexp.MustCompile(`^v[0-9]`)
+
+// namesApiVersion reports whether any segment of the path is a version. The
+// version is not always last: an OpenAI-compatible API served beside a native
+// one lives at /v1beta/openai or /v3/openai.
+func namesApiVersion(path string) bool {
+       for _, segment := range strings.Split(path, "/") {
+               if apiVersionSegment.MatchString(segment) {
+                       return true
+               }
+       }
+       return false
+}
+
 // BuildOpenAiUrl joins an OpenAI-compatible endpoint onto a provider base URL.
-// The base URL may be bare, already carry the /v1 prefix or already end with
-// the endpoint itself; none of those forms are doubled.
+// The base URL may be bare, already carry the version prefix or already end
+// with the endpoint itself; none of those forms are doubled.
+//
+// The /v1 is only supplied for a base URL that names no version of its own,
+// because plenty of vendors do not serve their OpenAI-compatible API under
+// /v1: Gemini uses /v1beta/openai, Zhipu /api/paas/v4, DeepInfra /v1/openai.
+// A path that names no version at all still gets one, so a relay configured as
+// https://relay.example.com/openai keeps working.
 func BuildOpenAiUrl(baseUrl string, endpoint string) (string, error) {
        u, err := url.Parse(baseUrl)
        if err != nil {
@@ -397,7 +420,7 @@ func BuildOpenAiUrl(baseUrl string, endpoint string) 
(string, error) {
        }
 
        path := strings.TrimSuffix(strings.TrimRight(u.Path, "/"), endpoint)
-       if !strings.HasSuffix(path, "/v1") {
+       if !namesApiVersion(path) {
                path += "/v1"
        }
 
diff --git a/web/src/components/ProviderModelsField.tsx 
b/web/src/components/ProviderModelsField.tsx
index ed12b57..c932ee8 100644
--- a/web/src/components/ProviderModelsField.tsx
+++ b/web/src/components/ProviderModelsField.tsx
@@ -111,6 +111,12 @@ export function ProviderModelsField({
         suggestions={fetched ?? modelPresets(provider.type)}
         onChange={onChange}
       />
+      {/* An empty list only means "any model" for a provider that forwards the
+          caller's login. Anywhere else it means the router will never pick 
this
+          provider, which is worth saying before the form is submitted. */}
+      {models.length === 0 && !usesClientAuth(provider) ? (
+        <p className="text-warning text-xs">{i18next.t("provider:No models 
hint")}</p>
+      ) : null}
       {fetched === null ? null : (
         <div className="grid gap-2 rounded-md border p-2">
           <div className="flex items-center justify-between gap-2">
diff --git a/web/src/components/ProviderSourcePicker.tsx 
b/web/src/components/ProviderSourcePicker.tsx
index cfc49ed..cab35fd 100644
--- a/web/src/components/ProviderSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -12,11 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {KeyRound, LogIn, Settings2} from "lucide-react";
+import * as React from "react";
+import {KeyRound, LogIn, Search, Settings2} from "lucide-react";
 import i18next from "i18next";
 
 import {ProviderIcon} from "@/components/ProviderIcon";
-import {providerSources, customSource, subscriptionSource, type 
ProviderSource} from "@/lib/providers";
+import {Input} from "@/components/ui/input";
+import {
+  providerSources,
+  customSource,
+  subscriptionSource,
+  vendorCategories,
+  type ProviderSource,
+  type VendorCategory,
+} from "@/lib/providers";
 
 /**
  * The title and the line under it. A vendor card is named after the vendor and
@@ -43,45 +52,106 @@ function sourceDetail(source: ProviderSource) {
   return source.provider.baseUrl ?? "";
 }
 
-function sourceIcon(source: ProviderSource) {
-  if (source.key === subscriptionSource) {
-    return LogIn;
-  }
-  if (source.key === customSource) {
-    return Settings2;
-  }
-  return KeyRound;
+function categoryTitle(category: VendorCategory) {
+  return {
+    official: i18next.t("provider:Model vendors"),
+    platform: i18next.t("provider:Inference platforms"),
+    aggregator: i18next.t("provider:Aggregators"),
+  }[category];
+}
+
+function SourceCard({source, onPick}: {source: ProviderSource; onPick: 
(source: ProviderSource) => void}) {
+  // Only reached when the vendor's own mark will not load.
+  const Icon =
+    source.key === subscriptionSource ? LogIn : source.key === customSource ? 
Settings2 : KeyRound;
+
+  return (
+    <button
+      type="button"
+      onClick={() => onPick(source)}
+      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">
+        <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>
+    </button>
+  );
 }
 
 /**
  * The first step of creating a provider: where its credentials come from. 
Picking
  * a card fills in the type, base URL, models and authentication mode, so a
  * subscription needs nothing typed and a vendor needs only its key.
+ *
+ * The vendors are grouped by who they are — the maker of the models, a 
platform
+ * hosting other people's, a site reselling many — because that is the thing
+ * worth knowing about one, and there are too many of them to read as one list.
  */
 export function ProviderSourcePicker({onPick}: {onPick: (source: 
ProviderSource) => void}) {
+  const [query, setQuery] = React.useState("");
+
+  const needle = query.trim().toLowerCase();
+  const matches = (source: ProviderSource) =>
+    needle === "" ||
+    sourceTitle(source).toLowerCase().includes(needle) ||
+    (source.provider.baseUrl ?? "").toLowerCase().includes(needle);
+
+  // The two cards that are not a vendor lead, without a heading of their own:
+  // they are the answers for a vendor that is not on the list at all.
+  const leading = providerSources.filter(source => source.category === 
undefined).filter(matches);
+  const groups = vendorCategories
+    .map(category => ({
+      category: category,
+      sources: providerSources.filter(source => source.category === 
category).filter(matches),
+    }))
+    .filter(group => group.sources.length > 0);
+
   return (
-    <div className="grid gap-3 sm:grid-cols-2">
-      {providerSources.map(source => {
-        const Icon = sourceIcon(source);
-        return (
-          <button
-            key={source.key}
-            type="button"
-            onClick={() => onPick(source)}
-            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">
-              <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>
-          </button>
-        );
-      })}
+    <div className="grid gap-4">
+      <div className="bg-background sticky top-0 z-10 pb-1">
+        <div className="relative">
+          <Search className="text-muted-foreground pointer-events-none 
absolute left-3 top-1/2 size-4 -translate-y-1/2" />
+          <Input
+            value={query}
+            onChange={event => setQuery(event.target.value)}
+            placeholder={i18next.t("provider:Search vendors")}
+            className="pl-9"
+          />
+        </div>
+      </div>
+
+      {leading.length === 0 ? null : (
+        <div className="grid gap-3 sm:grid-cols-2">
+          {leading.map(source => (
+            <SourceCard key={source.key} source={source} onPick={onPick} />
+          ))}
+        </div>
+      )}
+
+      {groups.map(group => (
+        <div key={group.category} className="grid gap-2">
+          <span className="text-muted-foreground text-xs font-medium uppercase 
tracking-wide">
+            {categoryTitle(group.category)}
+          </span>
+          <div className="grid gap-3 sm:grid-cols-2">
+            {group.sources.map(source => (
+              <SourceCard key={source.key} source={source} onPick={onPick} />
+            ))}
+          </div>
+        </div>
+      ))}
+
+      {leading.length === 0 && groups.length === 0 ? (
+        <p className="text-muted-foreground py-4 text-center text-sm">
+          {i18next.t("provider:No vendor matches")}
+        </p>
+      ) : null}
     </div>
   );
 }
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index fde0d32..d6efa3e 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -83,44 +83,247 @@ export function localShell(): Shell {
   return navigator.userAgent.includes("Windows") ? "PowerShell" : "bash";
 }
 
+/**
+ * Who a vendor is, which is the thing worth knowing when picking one: the 
maker
+ * of the models, a platform that hosts models it did not make, or a site that
+ * resells many vendors behind one key.
+ */
+export const vendorCategories = ["official", "platform", "aggregator"] as 
const;
+export type VendorCategory = (typeof vendorCategories)[number];
+
 /** A vendor the provider forms can fill themselves in from. */
 export interface ProviderPreset {
   label: string;
   type: string;
   baseUrl: string;
   models: string[];
+  category: VendorCategory;
+  /** Where the vendor hands out API keys, so the form can point at it. */
+  website: string;
 }
 
+/**
+ * Every base URL below was checked against the live API rather than assumed.
+ * Models are listed only where a short list is the whole catalogue: a platform
+ * serving hundreds is left empty for the Fetch models button, which asks the
+ * upstream itself and so never goes stale.
+ */
 export const providerPresets: ProviderPreset[] = [
   {
     label: "OpenAI",
     type: "openai",
     baseUrl: "https://api.openai.com/v1";,
     models: ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "o4-mini"],
+    category: "official",
+    website: "https://platform.openai.com/api-keys";,
   },
   {
     label: "Anthropic",
     type: "anthropic",
     baseUrl: "https://api.anthropic.com";,
     models: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
+    category: "official",
+    website: "https://console.anthropic.com/settings/keys";,
+  },
+  {
+    label: "Google Gemini",
+    type: "custom",
+    baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai";,
+    models: [],
+    category: "official",
+    website: "https://aistudio.google.com/apikey";,
+  },
+  {
+    label: "xAI",
+    type: "custom",
+    baseUrl: "https://api.x.ai/v1";,
+    models: [],
+    category: "official",
+    website: "https://console.x.ai";,
+  },
+  {
+    label: "Mistral",
+    type: "custom",
+    baseUrl: "https://api.mistral.ai/v1";,
+    models: [],
+    category: "official",
+    website: "https://console.mistral.ai/api-keys";,
+  },
+  {
+    label: "Cohere",
+    type: "custom",
+    baseUrl: "https://api.cohere.ai/compatibility/v1";,
+    models: [],
+    category: "official",
+    website: "https://dashboard.cohere.com/api-keys";,
   },
   {
     label: "DeepSeek",
     type: "custom",
     baseUrl: "https://api.deepseek.com/v1";,
     models: ["deepseek-chat", "deepseek-reasoner"],
+    category: "official",
+    website: "https://platform.deepseek.com/api_keys";,
   },
   {
     label: "Moonshot",
     type: "custom",
     baseUrl: "https://api.moonshot.cn/v1";,
-    models: ["moonshot-v1-8k", "moonshot-v1-32k"],
+    models: [],
+    category: "official",
+    website: "https://platform.moonshot.cn/console/api-keys";,
+  },
+  {
+    label: "Zhipu GLM",
+    type: "custom",
+    baseUrl: "https://open.bigmodel.cn/api/paas/v4";,
+    models: [],
+    category: "official",
+    website: "https://open.bigmodel.cn/usercenter/apikeys";,
   },
   {
     label: "Qwen",
     type: "custom",
     baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1";,
     models: ["qwen-max", "qwen-plus"],
+    category: "official",
+    website: "https://bailian.console.aliyun.com";,
+  },
+  {
+    label: "MiniMax",
+    type: "custom",
+    baseUrl: "https://api.minimaxi.com/v1";,
+    models: [],
+    category: "official",
+    website: "https://platform.minimaxi.com";,
+  },
+  {
+    label: "Baichuan",
+    type: "custom",
+    baseUrl: "https://api.baichuan-ai.com/v1";,
+    models: [],
+    category: "official",
+    website: "https://platform.baichuan-ai.com/console/apikey";,
+  },
+  {
+    label: "StepFun",
+    type: "custom",
+    baseUrl: "https://api.stepfun.com/v1";,
+    models: [],
+    category: "official",
+    website: "https://platform.stepfun.com/interface-key";,
+  },
+  {
+    label: "Volcengine Ark",
+    type: "custom",
+    baseUrl: "https://ark.cn-beijing.volces.com/api/v3";,
+    models: [],
+    category: "official",
+    website: "https://console.volcengine.com/ark";,
+  },
+  {
+    label: "Tencent Hunyuan",
+    type: "custom",
+    baseUrl: "https://api.hunyuan.cloud.tencent.com/v1";,
+    models: [],
+    category: "official",
+    website: "https://console.cloud.tencent.com/hunyuan/api-key";,
+  },
+  {
+    label: "01.AI",
+    type: "custom",
+    baseUrl: "https://api.lingyiwanwu.com/v1";,
+    models: [],
+    category: "official",
+    website: "https://platform.lingyiwanwu.com/apikeys";,
+  },
+  {
+    label: "SiliconFlow",
+    type: "custom",
+    baseUrl: "https://api.siliconflow.cn/v1";,
+    models: [],
+    category: "platform",
+    website: "https://cloud.siliconflow.cn/account/ak";,
+  },
+  {
+    label: "Groq",
+    type: "custom",
+    baseUrl: "https://api.groq.com/openai/v1";,
+    models: [],
+    category: "platform",
+    website: "https://console.groq.com/keys";,
+  },
+  {
+    label: "Together AI",
+    type: "custom",
+    baseUrl: "https://api.together.xyz/v1";,
+    models: [],
+    category: "platform",
+    website: "https://api.together.xyz/settings/api-keys";,
+  },
+  {
+    label: "Fireworks AI",
+    type: "custom",
+    baseUrl: "https://api.fireworks.ai/inference/v1";,
+    models: [],
+    category: "platform",
+    website: "https://fireworks.ai";,
+  },
+  {
+    label: "Novita AI",
+    type: "custom",
+    baseUrl: "https://api.novita.ai/v3/openai";,
+    models: [],
+    category: "platform",
+    website: "https://novita.ai/settings/key-management";,
+  },
+  {
+    label: "DeepInfra",
+    type: "custom",
+    baseUrl: "https://api.deepinfra.com/v1/openai";,
+    models: [],
+    category: "platform",
+    website: "https://deepinfra.com/dash/api_keys";,
+  },
+  {
+    label: "ModelScope",
+    type: "custom",
+    baseUrl: "https://api-inference.modelscope.cn/v1";,
+    models: [],
+    category: "platform",
+    website: "https://modelscope.cn/my/myaccesstoken";,
+  },
+  {
+    label: "PPIO",
+    type: "custom",
+    baseUrl: "https://api.ppinfra.com/v3/openai";,
+    models: [],
+    category: "platform",
+    website: "https://ppinfra.com/settings/key-management";,
+  },
+  {
+    label: "OpenRouter",
+    type: "custom",
+    baseUrl: "https://openrouter.ai/api/v1";,
+    models: [],
+    category: "aggregator",
+    website: "https://openrouter.ai/keys";,
+  },
+  {
+    label: "AiHubMix",
+    type: "custom",
+    baseUrl: "https://aihubmix.com/v1";,
+    models: [],
+    category: "aggregator",
+    website: "https://aihubmix.com/token";,
+  },
+  {
+    label: "302.AI",
+    type: "custom",
+    baseUrl: "https://api.302.ai/v1";,
+    models: [],
+    category: "aggregator",
+    website: "https://dash.302.ai";,
   },
 ];
 
@@ -130,6 +333,10 @@ export interface ProviderSource {
   key: string;
   /** The vendor's own name, empty when the picker titles the card itself. */
   label: string;
+  /** Absent on the two cards that are not a vendor, which lead the picker. */
+  category?: VendorCategory;
+  /** Where the vendor hands out API keys. */
+  website?: string;
   /** What the form starts from once the card is picked. */
   provider: Partial<Provider>;
 }
@@ -141,7 +348,8 @@ export const customSource = "custom";
  * The sign-in comes first: it is the only source that needs nothing filled in,
  * and the one people with a subscription and no API key are here for. 
Anthropic
  * is the vendor of the clients that mode works with; Codex signs in against an
- * API Gateway does not relay.
+ * API Gateway does not relay. Custom follows it, because the two of them are
+ * the answers for a vendor that is not on the list at all.
  */
 export const providerSources: ProviderSource[] = [
   {
@@ -155,9 +363,16 @@ export const providerSources: ProviderSource[] = [
       authMode: authClient,
     },
   },
+  {
+    key: customSource,
+    label: "",
+    provider: {type: "custom", baseUrl: "", models: [], authMode: 
authProvider},
+  },
   ...providerPresets.map(preset => ({
-    key: preset.label.toLowerCase(),
+    key: preset.label.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
     label: preset.label,
+    category: preset.category,
+    website: preset.website,
     provider: {
       type: preset.type,
       baseUrl: preset.baseUrl,
@@ -165,13 +380,13 @@ export const providerSources: ProviderSource[] = [
       authMode: authProvider,
     },
   })),
-  {
-    key: customSource,
-    label: "",
-    provider: {type: "custom", baseUrl: "", models: [], authMode: 
authProvider},
-  },
 ];
 
+/** The vendor a source came from, for the fields a picked card does not 
carry. */
+export function presetOfSource(source: ProviderSource) {
+  return providerPresets.find(preset => preset.label === source.label);
+}
+
 /** The base URLs and models offered for a provider type, from the vendors of 
it. */
 export function baseUrlPresets(type: string) {
   return providerPresets.filter(preset => preset.type === type).map(preset => 
preset.baseUrl);
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 7f0f654..202773a 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -471,6 +471,7 @@
     "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",
+    "Aggregators": "Aggregators",
     "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.",
     "Ask an endpoint of my own": "Ask an endpoint of my own",
@@ -517,20 +518,25 @@
     "Fetch models": "Fetch models",
     "Fetched {count} models": "Fetched {count} models",
     "Fill in from": "Fill in from a preset",
+    "Get a key from {vendor}": "Get a key from {vendor}",
     "Granted": "granted",
     "Granted path": "Granted path",
     "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.",
+    "Inference platforms": "Inference platforms",
     "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.",
+    "Model vendors": "Model vendors",
     "Models": "Models",
     "Models from upstream": "Models from upstream",
     "Models hint": "The model names this provider serves, used to route 
requests that arrive by model name.",
     "New Provider": "New Provider",
     "No balance endpoint": "No balance endpoint",
+    "No models hint": "With no model named, nothing that arrives by model name 
will be routed here. Press Fetch models, or type the names this provider should 
serve. An empty list only means \"any model\" for a provider that forwards the 
caller's own login.",
     "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.",
+    "No vendor matches": "No vendor matches that. Pick Custom to enter one by 
hand.",
     "Not asked yet": "Not asked yet",
     "Not used yet": "Not used yet",
     "Notes": "Notes",
@@ -556,6 +562,7 @@
     "Save anyway hint": "The upstream did not accept this provider. Fix it, or 
press {action} again to store it as it is.",
     "Scale": "Scale",
     "Scale hint": "Divides every number read, for a vendor that counts in an 
internal unit of its own. New API counts in 1/500000 of a dollar, so its scale 
is 500000.",
+    "Search vendors": "Search vendors",
     "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 f0bc82c..a95a5d2 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -471,6 +471,7 @@
     "API Key hint": "已保存的密钥不会返回给浏览器。保持 *** 不变即可沿用当前密钥,清空该字段则删除密钥。",
     "API Key ownership hint": "由 Gateway 保存,不会交给任何 Agent。",
     "Add provider": "添加供应商",
+    "Aggregators": "聚合站",
     "Any model": "任意模型",
     "Any model hint": "留空表示接受任何模型名,转发调用方登录的供应商一般都这样配。",
     "Ask an endpoint of my own": "使用自定义接口",
@@ -517,20 +518,25 @@
     "Fetch models": "自动获取",
     "Fetched {count} models": "共获取到 {count} 个模型",
     "Fill in from": "从预设填入",
+    "Get a key from {vendor}": "去 {vendor} 获取密钥",
     "Granted": "总额",
     "Granted path": "总额字段路径",
     "Health": "健康状态",
     "Healthy": "正常",
     "Icon": "图标",
     "Icon hint": "取图标的站点域名,或一个图片地址。留空则从 Base URL 推断。",
+    "Inference platforms": "推理平台",
     "Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority 
值最小的已启用供应商。如果想让某个 Agent 的请求不管模型名一律走这个供应商,请到该 Agent 的页面上绑定。",
+    "Model vendors": "模型厂商",
     "Models": "模型",
     "Models from upstream": "上游返回的模型",
     "Models hint": "这个供应商提供的模型名,按模型名路由的请求靠它匹配。",
     "New Provider": "新建供应商",
     "No balance endpoint": "无余额接口",
+    "No models hint": 
"没有填写模型名,按模型名路由的请求就不会走到这个供应商。点「获取模型」,或手动填写它该服务的模型名。只有转发调用方自身登录态的供应商,留空才表示「任意模型」。",
     "No providers yet": "还没有供应商",
     "No providers yet detail": "一个供应商就是你自己的厂商账号:base URL、它提供的模型,以及一个 API 
Key,或者转发调用方自己的登录。先建一个,再把它绑定到 Agent,该 Agent 的请求就会走它。",
+    "No vendor matches": "没有匹配的厂商。可以选「自定义」手动填写。",
     "Not asked yet": "尚未查询",
     "Not used yet": "尚未使用",
     "Notes": "备注",
@@ -556,6 +562,7 @@
     "Save anyway hint": "上游没有接受这个供应商。修正后重试,或再次点击「{action}」按当前内容保存。",
     "Scale": "换算系数",
     "Scale hint": "读到的数字都除以它,用于按内部单位计数的厂商。New API 以 1/500000 美元计数,系数就是 
500000。",
+    "Search vendors": "搜索厂商",
     "Select all": "全选",
     "Source": "来源",
     "Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base URL 和模型:订阅登录什么都不用填,厂商只用填一个 
Key。",
diff --git a/web/src/pages/ProviderEditPage.tsx 
b/web/src/pages/ProviderEditPage.tsx
index 81bf903..358f7da 100644
--- a/web/src/pages/ProviderEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -45,6 +45,11 @@ import {
 } from "@/lib/providers";
 import type {Provider, QuotaConfig} from "@/types";
 
+// Mirrors object.namesApiVersion on the server.
+function namesApiVersion(path: string) {
+  return path.split("/").some(segment => /^v[0-9]/.test(segment));
+}
+
 // Mirrors object.BuildOpenAiUrl on the server.
 function buildOpenAiUrl(baseUrl: string, endpoint: string) {
   let url: URL;
@@ -61,7 +66,7 @@ function buildOpenAiUrl(baseUrl: string, endpoint: string) {
   if (path.endsWith(endpoint)) {
     path = path.slice(0, path.length - endpoint.length);
   }
-  if (!path.endsWith("/v1")) {
+  if (!namesApiVersion(path)) {
     path += "/v1";
   }
 
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index 7c6e62c..9e5f881 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -14,7 +14,18 @@
 
 import * as React from "react";
 import {Link, useNavigate} from "react-router-dom";
-import {ChevronDown, CircleCheck, CircleX, Pencil, Plug, Plus, RefreshCw, 
Trash2, Wallet} from "lucide-react";
+import {
+  ChevronDown,
+  CircleCheck,
+  CircleX,
+  ExternalLink,
+  Pencil,
+  Plug,
+  Plus,
+  RefreshCw,
+  Trash2,
+  Wallet,
+} from "lucide-react";
 import i18next from "i18next";
 
 import * as ProviderBackend from "@/backend/ProviderBackend";
@@ -547,7 +558,23 @@ export default function ProviderListPage({account}: 
{account: Account}) {
             </Field>
             {usesClientAuth(form) ? null : (
               <Field
-                label={i18next.t("provider:API Key")}
+                label={
+                  <span className="flex flex-wrap items-center gap-2">
+                    {i18next.t("provider:API Key")}
+                    {/* The form is asking for a key, so it says where to get 
one. */}
+                    {source.website ? (
+                      <a
+                        href={source.website}
+                        target="_blank"
+                        rel="noreferrer"
+                        className="text-primary inline-flex items-center gap-1 
text-xs font-normal hover:underline"
+                      >
+                        <ExternalLink className="size-3" />
+                        {i18next.t("provider:Get a key from 
{vendor}").replace("{vendor}", source.label)}
+                      </a>
+                    ) : null}
+                  </span>
+                }
                 htmlFor="provider-api-key"
                 hint={i18next.t("provider:API Key ownership hint")}
               >


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

Reply via email to