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]