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 7c10b3a  feat(channel): fetch the model list from the upstream
7c10b3a is described below

commit 7c10b3adcf1cac67181cedbd35f44bc8af294b4c
Author: Yang Luo <[email protected]>
AuthorDate: Sat Aug 22 16:08:29 2026 +0800

    feat(channel): fetch the model list from the upstream
---
 controllers/channel.go                    |  47 +++++++++
 object/channel.go                         | 152 +++++++++++++++++++++++------
 routers/router.go                         |   1 +
 web/src/backend/ChannelBackend.ts         |   6 ++
 web/src/components/ChannelModelsField.tsx | 155 ++++++++++++++++++++++++++++++
 web/src/locales/en/data.json              |   6 ++
 web/src/locales/zh/data.json              |   6 ++
 web/src/pages/ChannelEditPage.tsx         |  18 +---
 web/src/pages/ChannelListPage.tsx         |  18 +---
 9 files changed, 354 insertions(+), 55 deletions(-)

diff --git a/controllers/channel.go b/controllers/channel.go
index d278fff..3f8dc7f 100644
--- a/controllers/channel.go
+++ b/controllers/channel.go
@@ -206,6 +206,53 @@ func (c *ApiController) DeleteChannel() {
        c.ServeJSON()
 }
 
+// GetChannelModels lists the models a channel's upstream reports. The channel
+// comes from the request body rather than from the database: the new-channel
+// form has nothing saved yet.
+func (c *ApiController) GetChannelModels() {
+       if c.RequireSignedIn() {
+               return
+       }
+
+       var channel object.Channel
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &channel); err != nil 
{
+               c.ResponseError(err.Error())
+               return
+       }
+
+       if channel.Owner == "" {
+               channel.Owner = c.GetSessionUsername()
+       }
+       if !c.channelAccess(channel.Owner) {
+               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 channel already has stored.
+       if channel.ApiKey == object.ApiKeyMask {
+               channel.ApiKey = ""
+               if channel.Name != "" {
+                       stored, err := object.GetChannel(channel.GetId())
+                       if err != nil {
+                               c.ResponseError(err.Error())
+                               return
+                       }
+                       if stored != nil {
+                               channel.ApiKey = stored.ApiKey
+                       }
+               }
+       }
+
+       models, err := object.FetchChannelModels(&channel)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(models)
+}
+
 // TestChannel tests connectivity to an upstream channel.
 func (c *ApiController) TestChannel() {
        if c.RequireSignedIn() {
diff --git a/object/channel.go b/object/channel.go
index 0df2cb3..fcec6eb 100644
--- a/object/channel.go
+++ b/object/channel.go
@@ -15,6 +15,7 @@
 package object
 
 import (
+       "encoding/json"
        "errors"
        "fmt"
        "io"
@@ -457,49 +458,58 @@ func DeleteChannel(channel *Channel) (bool, error) {
        return affected != 0, err
 }
 
-// TestChannelConnectivity performs a read-only probe against the channel's
-// upstream. It returns whether the probe succeeded, the upstream HTTP status
-// code (0 when no response was received) and a human-readable message.
-func TestChannelConnectivity(channel *Channel) (bool, int, string) {
-       stored, err := getChannel(channel.Owner, channel.Name)
-       if err != nil {
-               return false, 0, err.Error()
-       }
-       if stored == nil {
-               return false, 0, "the channel does not exist"
-       }
+// maxProbeBody caps what is read back from an upstream: a model list from an
+// aggregator is long, and an error page can be arbitrarily long.
+const maxProbeBody = 1 << 20
+
+// channelProbe is what a read-only GET against a channel's models endpoint
+// returned. The same probe answers both "is this upstream reachable" and
+// "which models does it serve".
+type channelProbe struct {
+       statusCode int
+       status     string
+       body       []byte
+}
+
+func (probe *channelProbe) ok() bool {
+       return probe.statusCode >= 200 && probe.statusCode < 300
+}
 
-       if !IsChannelTypeSupported(stored) {
-               return false, 0, fmt.Sprintf("the %s channel type is not 
supported", stored.Type)
+// probeChannel performs the read-only GET against the channel's models
+// endpoint. The channel is used as given rather than read back from the
+// database, so a channel that is not saved yet can be probed too.
+func probeChannel(channel *Channel) (*channelProbe, error) {
+       if !IsChannelTypeSupported(channel) {
+               return nil, fmt.Errorf("the %s channel type is not supported", 
channel.Type)
        }
 
-       if stored.BaseUrl == "" {
-               return false, 0, "the base URL is empty"
+       if channel.BaseUrl == "" {
+               return nil, errors.New("the base URL is empty")
        }
-       if err = validateBaseUrl(stored.BaseUrl); err != nil {
-               return false, 0, err.Error()
+       if err := validateBaseUrl(channel.BaseUrl); err != nil {
+               return nil, err
        }
 
-       protocol := ChannelProtocol(stored)
+       protocol := ChannelProtocol(channel)
        probeEndpoint := "/models"
        if protocol == ProtocolAnthropic {
                probeEndpoint = "/v1/models"
        }
 
-       probeUrl, err := BuildChannelUrl(stored.BaseUrl, protocol, 
probeEndpoint)
+       probeUrl, err := BuildChannelUrl(channel.BaseUrl, protocol, 
probeEndpoint)
        if err != nil {
-               return false, 0, err.Error()
+               return nil, err
        }
 
        req, err := http.NewRequest(http.MethodGet, probeUrl, nil)
        if err != nil {
-               return false, 0, err.Error()
+               return nil, err
        }
        if protocol == ProtocolAnthropic {
                req.Header.Set("Anthropic-Version", AnthropicVersion)
        }
-       if stored.ApiKey != "" {
-               SetChannelAuth(req.Header, stored)
+       if channel.ApiKey != "" {
+               SetChannelAuth(req.Header, channel)
        }
 
        client := &http.Client{
@@ -515,21 +525,105 @@ func TestChannelConnectivity(channel *Channel) (bool, 
int, string) {
                // Unwrap the *url.Error so the reason is not buried behind the 
URL.
                var urlErr *url.Error
                if errors.As(err, &urlErr) {
-                       return false, 0, urlErr.Err.Error()
+                       return nil, urlErr.Err
                }
-               return false, 0, err.Error()
+               return nil, err
        }
        defer resp.Body.Close()
 
-       _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+       body, err := io.ReadAll(io.LimitReader(resp.Body, maxProbeBody))
+       if err != nil {
+               return nil, err
+       }
+
+       return &channelProbe{statusCode: resp.StatusCode, status: resp.Status, 
body: body}, nil
+}
+
+// TestChannelConnectivity performs a read-only probe against the channel's
+// upstream. It returns whether the probe succeeded, the upstream HTTP status
+// code (0 when no response was received) and a human-readable message.
+func TestChannelConnectivity(channel *Channel) (bool, int, string) {
+       stored, err := getChannel(channel.Owner, channel.Name)
+       if err != nil {
+               return false, 0, err.Error()
+       }
+       if stored == nil {
+               return false, 0, "the channel does not exist"
+       }
+
+       probe, err := probeChannel(stored)
+       if err != nil {
+               return false, 0, err.Error()
+       }
 
        // A client-auth channel has no key to probe with, so an upstream that
        // rejects the unauthenticated probe has still proven it is reachable.
-       if UsesClientAuth(stored) && (resp.StatusCode == 
http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
-               return true, resp.StatusCode, "reachable, and authenticated 
with the caller's own credentials"
+       if UsesClientAuth(stored) && (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
+}
+
+// FetchChannelModels lists what the channel's upstream reports at its models
+// endpoint, so the model names do not have to be typed by hand.
+func FetchChannelModels(channel *Channel) ([]string, error) {
+       probe, err := probeChannel(channel)
+       if err != nil {
+               return nil, err
+       }
+       if !probe.ok() {
+               return nil, fmt.Errorf("the upstream answered %s%s", 
probe.status, probeDetail(probe.body))
        }
 
-       return resp.StatusCode >= 200 && resp.StatusCode < 300, 
resp.StatusCode, resp.Status
+       models, err := parseModelList(probe.body)
+       if err != nil {
+               return nil, err
+       }
+       if len(models) == 0 {
+               return nil, errors.New("the upstream did not report any model")
+       }
+       return models, nil
+}
+
+// parseModelList reads the model ids out of a models response. OpenAI and
+// Anthropic both answer {"data": [{"id": ...}]}, and an OpenAI-compatible
+// vendor follows OpenAI.
+func parseModelList(body []byte) ([]string, error) {
+       var payload struct {
+               Data []struct {
+                       Id string `json:"id"`
+               } `json:"data"`
+       }
+       if err := json.Unmarshal(body, &payload); err != nil {
+               return nil, fmt.Errorf("the upstream did not answer with a 
model list: %s", err.Error())
+       }
+
+       models := []string{}
+       seen := map[string]bool{}
+       for _, item := range payload.Data {
+               id := strings.TrimSpace(item.Id)
+               if id == "" || len(id) > maxChannelModelChars || seen[id] {
+                       continue
+               }
+               seen[id] = true
+               models = append(models, id)
+       }
+       // The upstream order is kept: Anthropic returns its newest model first.
+       return models, nil
+}
+
+// probeDetail is the upstream's own error text, trimmed to what fits in a
+// toast: a status code alone rarely says which of key, URL or plan is wrong.
+func probeDetail(body []byte) string {
+       text := strings.Join(strings.Fields(string(body)), " ")
+       if text == "" {
+               return ""
+       }
+       if runes := []rune(text); len(runes) > 200 {
+               text = string(runes[:200]) + "..."
+       }
+       return ": " + text
 }
 
 // GetChannelsByModel returns every enabled channel that supports the given
diff --git a/routers/router.go b/routers/router.go
index 2b99a8d..c9d45ba 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -107,6 +107,7 @@ func initAPI() {
        beego.Router("/api/add-channel", &controllers.ApiController{}, 
"POST:AddChannel")
        beego.Router("/api/update-channel", &controllers.ApiController{}, 
"POST:UpdateChannel")
        beego.Router("/api/delete-channel", &controllers.ApiController{}, 
"POST:DeleteChannel")
+       beego.Router("/api/get-channel-models", &controllers.ApiController{}, 
"POST:GetChannelModels")
        beego.Router("/api/test-channel", &controllers.ApiController{}, 
"POST:TestChannel")
        beego.Router("/api/get-channel-health", &controllers.ApiController{}, 
"GET:GetChannelHealth")
 
diff --git a/web/src/backend/ChannelBackend.ts 
b/web/src/backend/ChannelBackend.ts
index 2a83d3c..ce8e288 100644
--- a/web/src/backend/ChannelBackend.ts
+++ b/web/src/backend/ChannelBackend.ts
@@ -49,6 +49,12 @@ export function deleteChannel(channel: Channel) {
   return request("/api/delete-channel", "POST", channel);
 }
 
+/** The models the channel's upstream reports. The whole channel is posted, not
+ * an id: the new-channel form has nothing saved to look up yet. */
+export function getChannelModels(channel: Channel) {
+  return request<string[]>("/api/get-channel-models", "POST", channel);
+}
+
 export function testChannel(owner: string, name: string) {
   return request<ChannelTestResult>("/api/test-channel", "POST", {owner: 
owner, name: name});
 }
diff --git a/web/src/components/ChannelModelsField.tsx 
b/web/src/components/ChannelModelsField.tsx
new file mode 100644
index 0000000..2c9135a
--- /dev/null
+++ b/web/src/components/ChannelModelsField.tsx
@@ -0,0 +1,155 @@
+// 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 {Check, RefreshCw, X} from "lucide-react";
+import i18next from "i18next";
+
+import * as ChannelBackend from "@/backend/ChannelBackend";
+import * as Setting from "@/Setting";
+import {Field} from "@/components/shared/form-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {TagsInput} from "@/components/ui/tags-input";
+import {modelPresets, modelsPlaceholder, usesClientAuth} from "@/lib/channels";
+import type {Channel} from "@/types";
+
+/**
+ * The Models field of both channel forms: the names can be typed, or asked of
+ * the upstream itself, which every OpenAI- and Anthropic-compatible API 
answers
+ * at its models endpoint. What comes back is offered as chips to pick from
+ * rather than written straight into the field: an aggregator lists hundreds of
+ * models, most of which this channel is not meant to serve.
+ */
+export function ChannelModelsField({
+  channel,
+  hint,
+  onChange,
+}: {
+  channel: Channel;
+  hint?: React.ReactNode;
+  onChange: (models: string[]) => void;
+}) {
+  const [fetching, setFetching] = React.useState(false);
+  const [fetched, setFetched] = React.useState<string[] | null>(null);
+
+  // The list belongs to one upstream, so it stops being an answer as soon as
+  // the form points somewhere else.
+  React.useEffect(() => {
+    setFetched(null);
+  }, [channel.baseUrl, channel.type]);
+
+  const models = channel.models ?? [];
+  // A channel that forwards the caller's login sends no key upstream, so there
+  // is nothing to list models with, and an empty list already means "any 
model".
+  const canFetch = !usesClientAuth(channel) && channel.baseUrl !== "";
+
+  const fetchModels = () => {
+    setFetching(true);
+    ChannelBackend.getChannelModels(channel)
+      .then(res => {
+        setFetching(false);
+        if (res.status === "error") {
+          Setting.showMessage("error", `${i18next.t("channel:Failed to fetch 
models")}: ${res.msg}`);
+          return;
+        }
+        const list = res.data ?? [];
+        setFetched(list);
+        Setting.showMessage(
+          "success",
+          i18next.t("channel:Fetched {count} models").replace("{count}", 
`${list.length}`),
+        );
+      })
+      .catch(error => {
+        setFetching(false);
+        Setting.showMessage("error", `${i18next.t("channel:Failed to fetch 
models")}: ${error}`);
+      });
+  };
+
+  const toggle = (model: string) => {
+    onChange(models.includes(model) ? models.filter(item => item !== model) : 
[...models, model]);
+  };
+
+  const selectAll = () => {
+    onChange([...models, ...(fetched ?? []).filter(model => 
!models.includes(model))]);
+  };
+
+  // Only the fetched names are dropped, so anything typed by hand survives.
+  const clearFetched = () => {
+    onChange(models.filter(model => !(fetched ?? []).includes(model)));
+  };
+
+  return (
+    <Field
+      label={
+        <span className="flex items-center gap-2">
+          {i18next.t("channel:Models")}
+          {canFetch ? (
+            <Button type="button" size="xs" variant="outline" 
loading={fetching} onClick={fetchModels}>
+              <RefreshCw />
+              {i18next.t("channel:Fetch models")}
+            </Button>
+          ) : null}
+        </span>
+      }
+      hint={hint}
+    >
+      <TagsInput
+        value={models}
+        placeholder={modelsPlaceholder(channel.type)}
+        suggestions={fetched ?? modelPresets(channel.type)}
+        onChange={onChange}
+      />
+      {fetched === null ? null : (
+        <div className="grid gap-2 rounded-md border p-2">
+          <div className="flex items-center justify-between gap-2">
+            <span className="text-muted-foreground text-xs">
+              {i18next.t("channel:Models from upstream")} ({fetched.length})
+            </span>
+            <div className="flex items-center gap-1">
+              <Button type="button" size="xs" variant="ghost" 
onClick={selectAll}>
+                {i18next.t("channel:Select all")}
+              </Button>
+              <Button type="button" size="xs" variant="ghost" 
onClick={clearFetched}>
+                {i18next.t("channel:Clear")}
+              </Button>
+              <Button
+                type="button"
+                size="icon-xs"
+                variant="ghost"
+                aria-label={i18next.t("general:Close")}
+                onClick={() => setFetched(null)}
+              >
+                <X />
+              </Button>
+            </div>
+          </div>
+          <div className="scrollbar-thin flex max-h-40 flex-wrap gap-1 
overflow-y-auto">
+            {fetched.map(model => {
+              const selected = models.includes(model);
+              return (
+                <button key={model} type="button" onClick={() => 
toggle(model)}>
+                  <Badge variant={selected ? "success" : "muted"} 
className="cursor-pointer">
+                    {selected ? <Check /> : null}
+                    {model}
+                  </Badge>
+                </button>
+              );
+            })}
+          </div>
+        </div>
+      )}
+    </Field>
+  );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index bd22b0c..4a88a95 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -287,6 +287,7 @@
     "Channel not found": "Channel not found",
     "Channel saved": "Channel saved",
     "Channels": "Channels",
+    "Clear": "Clear",
     "Client auth hint": "Requests are forwarded with the credentials the 
client sent, so an agent already signed in with a subscription keeps its own 
login and no key is stored here. Codex is the exception: its ChatGPT sign-in 
speaks a different API.",
     "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",
@@ -297,14 +298,18 @@
     "Enabled": "Enabled",
     "Failed to add": "Failed to add",
     "Failed to delete": "Failed to delete",
+    "Failed to fetch models": "Failed to fetch models",
     "Failed to get channel": "Failed to get channel",
     "Failed to get channels": "Failed to get channels",
     "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",
     "Model routing hint": "Requests that arrive here are routed by the model 
name they carry, to the enabled channel with the smallest priority value that 
serves it. To send one agent's requests to this channel whatever model they 
name, bind the channel on the agent's page instead.",
     "Models": "Models",
+    "Models from upstream": "Models from upstream",
     "Models hint": "The model names this channel serves, used to route 
requests that arrive by model name.",
     "New Channel": "New Channel",
     "No channels yet": "No channels yet",
@@ -315,6 +320,7 @@
     "Priority hint": "Smaller number means higher priority, 0 is the highest; 
when multiple channels serve the same model, the one with the smaller value is 
used first.",
     "Recovering": "Recovering",
     "Retried at": "retried at",
+    "Select all": "Select all",
     "Start from a sign-in": "Use the sign-in my agent has",
     "Start from a sign-in detail": "No API key. Every request keeps the 
credentials the agent sent, so a ChatGPT or Claude subscription goes on 
working. Codex is the exception: it signs in against an API Gateway does not 
relay.",
     "Start from an API key": "Paste a vendor API key",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 0ffd49a..66955b0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -287,6 +287,7 @@
     "Channel not found": "渠道不存在",
     "Channel saved": "渠道已保存",
     "Channels": "渠道",
+    "Clear": "清空",
     "Client auth hint": "请求会带着客户端自己的凭据转发上游,已经用订阅登录过的 Agent 可以继续用自己的登录,这里不保存任何 
Key。Codex 除外:它的 ChatGPT 登录走的是另一套 API。",
     "Client auth usage hint": "只设置 base URL:这里再设一个 token 会覆盖客户端已有的登录。",
     "Connection Failed": "连接失败",
@@ -297,14 +298,18 @@
     "Enabled": "已启用",
     "Failed to add": "添加失败",
     "Failed to delete": "删除失败",
+    "Failed to fetch models": "获取模型列表失败",
     "Failed to get channel": "获取渠道失败",
     "Failed to get channels": "获取渠道列表失败",
     "Failed to save": "保存失败",
     "Failed to test": "测试失败",
+    "Fetch models": "自动获取",
+    "Fetched {count} models": "共获取到 {count} 个模型",
     "Health": "健康状态",
     "Healthy": "正常",
     "Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority 
值最小的已启用渠道。如果想让某个 Agent 的请求不管模型名一律走这个渠道,请到该 Agent 的页面上绑定。",
     "Models": "模型",
+    "Models from upstream": "上游返回的模型",
     "Models hint": "这个 Channel 提供的模型名,按模型名路由的请求靠它匹配。",
     "New Channel": "新建渠道",
     "No channels yet": "还没有 Channel",
@@ -315,6 +320,7 @@
     "Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
     "Recovering": "恢复中",
     "Retried at": "将于",
+    "Select all": "全选",
     "Start from a sign-in": "沿用 Agent 已有的登录",
     "Start from a sign-in detail": "不需要 API Key。每个请求都带着 Agent 自己发来的凭据,ChatGPT 
或 Claude 订阅照常可用。Codex 除外:它登录的那套 API 不在 Gateway 的转发范围内。",
     "Start from an API key": "填一个厂商的 API Key",
diff --git a/web/src/pages/ChannelEditPage.tsx 
b/web/src/pages/ChannelEditPage.tsx
index b7a02f2..6f2d638 100644
--- a/web/src/pages/ChannelEditPage.tsx
+++ b/web/src/pages/ChannelEditPage.tsx
@@ -19,6 +19,7 @@ import i18next from "i18next";
 
 import * as ChannelBackend from "@/backend/ChannelBackend";
 import * as Setting from "@/Setting";
+import {ChannelModelsField} from "@/components/ChannelModelsField";
 import {EnvSnippet} from "@/components/EnvSnippet";
 import {Field} from "@/components/shared/form-dialog";
 import {Loading} from "@/components/shared/loading";
@@ -30,7 +31,6 @@ 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 {TagsInput} from "@/components/ui/tags-input";
 import {
   authClient,
   baseUrlPlaceholder,
@@ -38,8 +38,6 @@ import {
   channelProtocol,
   gatewayBaseUrl,
   localShell,
-  modelPresets,
-  modelsPlaceholder,
   usesClientAuth,
 } from "@/lib/channels";
 import type {Channel, ChannelTestResult} from "@/types";
@@ -282,17 +280,11 @@ export default function ChannelEditPage() {
             />
           </Field>
         )}
-        <Field
-          label={i18next.t("channel:Models")}
+        <ChannelModelsField
+          channel={channel}
           hint={usesClientAuth(channel) ? i18next.t("channel:Any model hint") 
: undefined}
-        >
-          <TagsInput
-            value={channel.models}
-            placeholder={modelsPlaceholder(channel.type)}
-            suggestions={modelPresets(channel.type)}
-            onChange={value => setField("models", value)}
-          />
-        </Field>
+          onChange={value => setField("models", value)}
+        />
         <Field label={i18next.t("channel:Priority")} 
hint={i18next.t("channel:Priority hint")}>
           <NumberInput min={0} value={channel.priority} onChange={value => 
setField("priority", value)} />
         </Field>
diff --git a/web/src/pages/ChannelListPage.tsx 
b/web/src/pages/ChannelListPage.tsx
index 8727a18..eef2e78 100644
--- a/web/src/pages/ChannelListPage.tsx
+++ b/web/src/pages/ChannelListPage.tsx
@@ -19,6 +19,7 @@ import i18next from "i18next";
 
 import * as ChannelBackend from "@/backend/ChannelBackend";
 import * as Setting from "@/Setting";
+import {ChannelModelsField} from "@/components/ChannelModelsField";
 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";
@@ -30,7 +31,6 @@ 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 {TagsInput} from "@/components/ui/tags-input";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {
   authClient,
@@ -38,8 +38,6 @@ import {
   baseUrlPresets,
   channelPresets,
   clientAuthDefaults,
-  modelPresets,
-  modelsPlaceholder,
   usesClientAuth,
   type ChannelPreset,
 } from "@/lib/channels";
@@ -525,17 +523,11 @@ export default function ChannelListPage({account}: 
{account: Account}) {
             />
           </Field>
         )}
-        <Field
-          label={i18next.t("channel:Models")}
+        <ChannelModelsField
+          channel={form}
           hint={usesClientAuth(form) ? i18next.t("channel:Any model hint") : 
i18next.t("channel:Models hint")}
-        >
-          <TagsInput
-            value={form.models}
-            placeholder={modelsPlaceholder(form.type)}
-            suggestions={modelPresets(form.type)}
-            onChange={value => setFormField("models", value)}
-          />
-        </Field>
+          onChange={value => setFormField("models", value)}
+        />
       </FormDialog>
     </PageContainer>
   );


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

Reply via email to