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 f9abe13  fix(agent): stop offering a provider the agent cannot speak to
f9abe13 is described below

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

    fix(agent): stop offering a provider the agent cannot speak to
---
 agentprovider/provider.go               | 35 ++++++++++++++++++++++-----------
 controllers/agent.go                    |  5 +++++
 controllers/agent_provider.go           | 29 +++++++++++++++++++++++++++
 web/src/components/ProviderCard.tsx     | 30 +++++++++++++++++++++++++---
 web/src/components/ProviderGridCard.tsx |  5 ++++-
 web/src/lib/agents.ts                   | 18 +++++++++++++++++
 web/src/locales/en/data.json            |  1 +
 web/src/locales/zh/data.json            |  1 +
 web/src/types.ts                        |  2 ++
 9 files changed, 111 insertions(+), 15 deletions(-)

diff --git a/agentprovider/provider.go b/agentprovider/provider.go
index 9859c01..f85e05b 100644
--- a/agentprovider/provider.go
+++ b/agentprovider/provider.go
@@ -70,14 +70,17 @@ type File struct {
 // Status is what the UI shows beside an installation: whether Gateway owns its
 // provider configuration, and which one it points at.
 type Status struct {
-       Supported bool     `json:"supported"`
-       Applied   bool     `json:"applied"`
-       Provider  string   `json:"provider"`
-       Mode      string   `json:"mode"`
-       BaseUrl   string   `json:"baseUrl"`
-       Time      string   `json:"time"`
-       Files     []string `json:"files"`
-       Detail    string   `json:"detail"`
+       Supported bool `json:"supported"`
+       // Protocol is the wire format this agent's client speaks, empty for an
+       // agent Gateway does not know.
+       Protocol string   `json:"protocol"`
+       Applied  bool     `json:"applied"`
+       Provider string   `json:"provider"`
+       Mode     string   `json:"mode"`
+       BaseUrl  string   `json:"baseUrl"`
+       Time     string   `json:"time"`
+       Files    []string `json:"files"`
+       Detail   string   `json:"detail"`
 }
 
 type writer interface {
@@ -193,7 +196,7 @@ func StatusOf(target Target) Status {
                }
        }
 
-       status := Status{Supported: true, Files: []string{}}
+       status := Status{Supported: true, Protocol: value.Protocol(), Files: 
[]string{}}
        saved, err := loadState(target)
        if err != nil {
                status.Detail = err.Error()
@@ -232,6 +235,16 @@ func StatusOf(target Target) Status {
        return status
 }
 
+// ProtocolOf is the wire format one agent's client speaks, empty for an agent
+// Gateway has no writer for.
+func ProtocolOf(agentId string) string {
+       value, ok := writers[agentId]
+       if !ok {
+               return ""
+       }
+       return value.Protocol()
+}
+
 func writerFor(target Target, endpoint Endpoint) (writer, error) {
        if target.AgentId == "" {
                return nil, errors.New("agentId is required")
@@ -248,8 +261,8 @@ func writerFor(target Target, endpoint Endpoint) (writer, 
error) {
                return nil, fmt.Errorf("%s: %w", target.AgentId, 
ErrNotSupported)
        }
        if value.Protocol() != endpoint.Protocol {
-               return nil, fmt.Errorf("%s speaks the %s API, but provider %s 
speaks %s",
-                       target.AgentId, value.Protocol(), endpoint.Provider, 
endpoint.Protocol)
+               return nil, fmt.Errorf("%s speaks the %s API, but provider %s 
speaks %s: bind a provider that speaks %s instead",
+                       target.AgentId, value.Protocol(), endpoint.Provider, 
endpoint.Protocol, value.Protocol())
        }
        return value, nil
 }
diff --git a/controllers/agent.go b/controllers/agent.go
index 165383d..06c47a4 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -115,6 +115,11 @@ func (c *ApiController) UpdateAgentRouting() {
                return
        }
 
+       if err := checkAgentProtocol(form.AgentId, 
append([]string{form.Provider}, form.Fallbacks...)); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
        if err := object.SetAgentRouting(form.AgentId, form.Provider, 
form.Fallbacks, form.Mode); err != nil {
                c.ResponseError(err.Error())
                return
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index 87aa0c9..ec1093c 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -100,6 +100,35 @@ func (c *ApiController) GetProviderHealth() {
        c.ResponseOk(object.GetProviderHealth())
 }
 
+// checkAgentProtocol rejects providers the agent could never talk to. The 
proxy
+// relays a request in the wire format it arrived in, so an agent speaking one
+// API cannot be served by a provider speaking the other.
+func checkAgentProtocol(agentId string, providerIds []string) error {
+       protocol := agentprovider.ProtocolOf(agentId)
+       if protocol == "" {
+               return nil
+       }
+
+       for _, id := range providerIds {
+               if id == "" {
+                       continue
+               }
+               provider, err := object.GetProvider(id)
+               if err != nil {
+                       return err
+               }
+               // A provider that does not exist is reported by the routing 
itself.
+               if provider == nil {
+                       continue
+               }
+               if spoken := object.ProviderProtocol(provider); spoken != 
protocol {
+                       return fmt.Errorf("%s speaks the %s API, but provider 
%s speaks %s: bind a provider that speaks %s instead",
+                               agentId, protocol, id, spoken, protocol)
+               }
+       }
+       return nil
+}
+
 // agentEndpoint resolves where one agent should be pointed. In gateway mode
 // that is the local proxy, which is what makes a later switch take effect
 // without rewriting a file; in direct mode it is the provider's own upstream.
diff --git a/web/src/components/ProviderCard.tsx 
b/web/src/components/ProviderCard.tsx
index 92e632e..ab08101 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -186,6 +186,7 @@ export function ProviderCard({
   // still has to render for the parts that do not depend on it.
   const providerConfig = agent.providerConfig ?? {
     supported: false,
+    protocol: "",
     applied: false,
     provider: "",
     mode: "",
@@ -198,11 +199,25 @@ export function ProviderCard({
   const healthOf = (id: string) => health.find(item => item.provider === id);
   const boundHealth = healthOf(agent.provider);
 
+  // The agent's client speaks one wire format and the proxy relays a request 
as
+  // it arrived, so a provider serving the other API can never answer it. A
+  // backend that does not report the format yet filters nothing out.
+  const protocol = providerConfig.protocol ?? "";
+  const speaksAgentApi = (provider: Provider) =>
+    protocol === "" || providerProtocol(provider.type) === protocol;
+  // A provider bound before this was checked stays in the list, so the select
+  // shows what the agent is on rather than an empty box.
+  const options = providers.filter(
+    provider => speaksAgentApi(provider) || providerIdOf(provider) === 
agent.provider,
+  );
+  const mismatched = bound !== undefined && !speaksAgentApi(bound);
+
   // A provider can only take over for one that speaks the same wire format: 
the
   // proxy relays the request as it arrived.
   const candidates = providers.filter(
     provider =>
       providerIdOf(provider) !== agent.provider &&
+      speaksAgentApi(provider) &&
       (bound === undefined || providerProtocol(provider.type) === 
providerProtocol(bound.type)),
   );
 
@@ -237,7 +252,7 @@ export function ProviderCard({
             <SelectItem value={noProvider}>
               <span className="text-muted-foreground">{i18next.t("agent:No 
provider")}</span>
             </SelectItem>
-            {providers.map(provider => (
+            {options.map(provider => (
               <SelectItem key={providerIdOf(provider)} 
value={providerIdOf(provider)}>
                 <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} 
size={16} />
                 {provider.displayName || provider.name}
@@ -248,6 +263,12 @@ export function ProviderCard({
           </SelectContent>
         </Select>
 
+        {mismatched ? (
+          <p className="text-sm text-warning">
+            {i18next.t("agent:Provider speaks another 
API").replace("{protocol}", protocol)}
+          </p>
+        ) : null}
+
         {boundHealth && !boundHealth.healthy ? (
           <p className="text-sm text-warning">
             {`${i18next.t("agent:Bound provider cooling down")}: 
${boundHealth.lastError}`}
@@ -338,14 +359,17 @@ export function ProviderCard({
                     confirmText={i18next.t("agent:Write configuration")}
                     onConfirm={() => onWrite(false)}
                   >
-                    <Button variant={providerConfig.applied ? "outline" : 
"default"} disabled={busy}>
+                    <Button
+                      variant={providerConfig.applied ? "outline" : "default"}
+                      disabled={busy || mismatched}
+                    >
                       {providerConfig.applied
                         ? i18next.t("agent:Rewrite configuration")
                         : i18next.t("agent:Write configuration")}
                     </Button>
                   </ConfirmDialog>
 
-                  <Button variant="outline" disabled={busy} onClick={() => 
setPreview(true)}>
+                  <Button variant="outline" disabled={busy || mismatched} 
onClick={() => setPreview(true)}>
                     {i18next.t("agent:Preview")}
                   </Button>
 
diff --git a/web/src/components/ProviderGridCard.tsx 
b/web/src/components/ProviderGridCard.tsx
index ca44055..d646e7f 100644
--- a/web/src/components/ProviderGridCard.tsx
+++ b/web/src/components/ProviderGridCard.tsx
@@ -29,6 +29,7 @@ import {
   DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 import {SimpleTooltip} from "@/components/ui/tooltip";
+import {agentSpeaks} from "@/lib/agents";
 import {providerProtocol, usesClientAuth} from "@/lib/providers";
 import {cn} from "@/lib/utils";
 import type {Agent, Provider, ProviderHealth, ProviderQuota} from "@/types";
@@ -97,7 +98,9 @@ export function ProviderGridCard({
   const protocol = providerProtocol(provider.type);
   const models = provider.models ?? [];
   const users = agents.filter(agent => agent.provider === id);
-  const others = agents.filter(agent => agent.provider !== id);
+  // An agent whose client speaks the other wire format could never read this
+  // provider's answers, so it is not offered.
+  const others = agents.filter(agent => agent.provider !== id && 
agentSpeaks(agent, protocol));
 
   return (
     <Card className="gap-0 py-0">
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 2afaac4..d99db9a 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -74,6 +74,24 @@ export function agentNeedsResponsesApi(agentId: string) {
   return agentId.startsWith("codex");
 }
 
+/**
+ * The wire format an agent's client speaks, as the server reports it. Empty 
for
+ * an agent Gateway does not know, which is then not filtered on.
+ */
+export function agentProtocol(agent: Agent) {
+  return agent.providerConfig?.protocol ?? "";
+}
+
+/**
+ * Whether an agent can be pointed at a provider serving this wire format. The
+ * proxy relays a request in the format it arrived in, so the two have to speak
+ * the same API.
+ */
+export function agentSpeaks(agent: Agent, protocol: string) {
+  const spoken = agentProtocol(agent);
+  return spoken === "" || spoken === protocol;
+}
+
 /**
  * The detail page route for one installation. The path is only spelled out 
when
  * the same agent is installed more than once, which is what makes the id alone
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 4e523df..fe14544 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -106,6 +106,7 @@
     "Provider cleared": "Provider cleared",
     "Provider hint": "With no provider selected, this agent still talks to its 
own upstream and Gateway only watches it.",
     "Provider saved": "Provider saved",
+    "Provider speaks another API": "This agent speaks the {protocol} API, 
which the bound provider does not. Pick a provider that speaks it, or the agent 
configuration cannot be written.",
     "Records": "Records",
     "Records are kept in memory only and are lost when Gateway restarts": 
"Records are kept in memory only and are lost when Gateway restarts",
     "Reported from": "Reported from",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 2626b5e..acd0ccb 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -106,6 +106,7 @@
     "Provider cleared": "已清除供应商",
     "Provider hint": "未选择供应商时,该 Agent 仍直连自己的上游,网关只做观测。",
     "Provider saved": "供应商已保存",
+    "Provider speaks another API": "该 Agent 使用 {protocol} 
API,而绑定的供应商不支持。请改选支持该 API 的供应商,否则无法写入 Agent 配置。",
     "Records": "记录数",
     "Records are kept in memory only and are lost when Gateway restarts": 
"记录仅保存在内存中,Gateway 重启后会丢失",
     "Reported from": "上报来源",
diff --git a/web/src/types.ts b/web/src/types.ts
index 5f4965b..b27fc99 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -210,6 +210,8 @@ export interface AgentProviderFile {
 /** The state of the agent's own configuration file, written by the 
orchestrator. */
 export interface AgentProviderConfig {
   supported: boolean;
+  /** The wire format the agent's client speaks, empty when Gateway cannot 
tell. */
+  protocol: string;
   applied: boolean;
   provider: string;
   mode: string;


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

Reply via email to