From d5a8233e76ff7aeeafbff493db1dda5ba7e77dcc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 16:22:09 -0700 Subject: [PATCH] feat(gateway,ui): agent-scoped model provider credentials (#111796) * feat: scope model provider credentials by agent * fix: reject unknown model auth agent ids * fix: discard stale model provider preload * fix(gateway): reject whitespace-only explicit agent ids * fix: scope model provider probes by agent * fix: complete model provider agent scoping * fix(ui): always clear the providers refresh flag on completion * fix(ui): keep probe epochs monotonic across agent switches * fix: derive agent scope for provider aborts * fix: satisfy model provider CI checks --- .../OpenClawProtocol/GatewayModels.swift | 68 +++++- .../src/gateway-error-details.ts | 12 +- packages/gateway-protocol/src/index.test.ts | 8 +- packages/gateway-protocol/src/index.ts | 6 + .../src/schema/agents-models-skills.test.ts | 29 +++ .../src/schema/agents-models-skills.ts | 16 ++ .../src/schema/error-codes.test.ts | 8 + .../src/schema/error-codes.ts | 7 + .../src/schema/protocol-schemas.ts | 6 + scripts/protocol-gen-swift.ts | 1 + src/gateway/chat-abort.test.ts | 21 ++ src/gateway/chat-abort.ts | 20 +- .../server-methods/model-auth-agent-scope.ts | 56 +++++ .../server-methods/models-auth-status.test.ts | 205 +++++++++++++++- .../server-methods/models-auth-status.ts | 46 +++- .../server-methods/models-probe.test.ts | 77 ++++++ src/gateway/server-methods/models-probe.ts | 14 ++ ui/src/components/agent-scope-control.test.ts | 34 +++ ui/src/components/agent-scope-control.ts | 21 +- ui/src/i18n/locales/en.ts | 5 +- ui/src/lib/model-auth.ts | 7 +- ui/src/pages/model-providers/load.test.ts | 36 +++ ui/src/pages/model-providers/load.ts | 2 +- .../model-providers-page.test.ts | 228 ++++++++++++++++++ .../model-providers/model-providers-page.ts | 128 ++++++++-- ui/src/pages/model-providers/route.ts | 14 +- ui/src/pages/model-providers/view.test.ts | 20 ++ ui/src/pages/model-providers/view.ts | 21 +- ui/src/pages/route-provenance.test.ts | 10 + ui/src/styles/model-providers.css | 13 + 30 files changed, 1076 insertions(+), 63 deletions(-) create mode 100644 src/gateway/server-methods/model-auth-agent-scope.ts create mode 100644 ui/src/pages/model-providers/load.test.ts create mode 100644 ui/src/pages/model-providers/model-providers-page.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index c1da5a87c2a2..7bfffcce4c1c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1374,6 +1374,24 @@ public struct McpAppViewExpiredErrorDetails: Codable, Sendable { } } +public struct UnknownAgentIdErrorDetails: Codable, Sendable { + public let code: String + public let agentid: String + + public init( + code: String, + agentid: String) + { + self.code = code + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case code + case agentid = "agentId" + } +} + public struct GatewaySuspendTaskBlocker: Codable, Sendable { public let taskid: String public let status: String @@ -10528,6 +10546,46 @@ public struct ModelChoice: Codable, Sendable { } } +public struct ModelsAuthLogoutParams: Codable, Sendable { + public let provider: String + public let profileids: [String]? + public let agentid: String? + + public init( + provider: String, + profileids: [String]? = nil, + agentid: String? = nil) + { + self.provider = provider + self.profileids = profileids + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case provider + case profileids = "profileIds" + case agentid = "agentId" + } +} + +public struct ModelsAuthStatusParams: Codable, Sendable { + public let refresh: Bool? + public let agentid: String? + + public init( + refresh: Bool? = nil, + agentid: String? = nil) + { + self.refresh = refresh + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case refresh + case agentid = "agentId" + } +} + public struct ModelsListParams: Codable, Sendable { public let includeprovidercapabilities: Bool? public let view: AnyCodable? @@ -10564,21 +10622,25 @@ public struct ModelsProbeParams: Codable, Sendable { public let provider: String public let profileid: String? public let timeoutms: Int? + public let agentid: String? public init( provider: String, profileid: String? = nil, - timeoutms: Int? = nil) + timeoutms: Int? = nil, + agentid: String? = nil) { self.provider = provider self.profileid = profileid self.timeoutms = timeoutms + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case provider case profileid = "profileId" case timeoutms = "timeoutMs" + case agentid = "agentId" } } @@ -15862,6 +15924,7 @@ public enum BoardCommand: Codable, Sendable { public enum GatewayErrorDetails: Codable, Sendable { case missingScope(MissingScopeErrorDetails) case mcpAppViewExpired(McpAppViewExpiredErrorDetails) + case unknownAgentId(UnknownAgentIdErrorDetails) public init(code: String, missingscope: String, requiredscopes: [String]) { self = .missingScope( @@ -15877,6 +15940,7 @@ public enum GatewayErrorDetails: Codable, Sendable { switch self { case .missingScope(let value): value.code case .mcpAppViewExpired(let value): value.code + case .unknownAgentId(let value): value.code } } @@ -15900,6 +15964,7 @@ public enum GatewayErrorDetails: Codable, Sendable { switch discriminator { case "MISSING_SCOPE": self = try .missingScope(MissingScopeErrorDetails(from: decoder)) case "MCP_APP_VIEW_EXPIRED": self = try .mcpAppViewExpired(McpAppViewExpiredErrorDetails(from: decoder)) + case "UNKNOWN_AGENT_ID": self = try .unknownAgentId(UnknownAgentIdErrorDetails(from: decoder)) default: throw DecodingError.dataCorruptedError( forKey: .discriminator, @@ -15913,6 +15978,7 @@ public enum GatewayErrorDetails: Codable, Sendable { switch self { case .missingScope(let value): try value.encode(to: encoder) case .mcpAppViewExpired(let value): try value.encode(to: encoder) + case .unknownAgentId(let value): try value.encode(to: encoder) } } } diff --git a/packages/gateway-protocol/src/gateway-error-details.ts b/packages/gateway-protocol/src/gateway-error-details.ts index a67597def2f4..1be72f4480d7 100644 --- a/packages/gateway-protocol/src/gateway-error-details.ts +++ b/packages/gateway-protocol/src/gateway-error-details.ts @@ -23,6 +23,7 @@ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; export const GatewayErrorDetailCodes = { MISSING_SCOPE: "MISSING_SCOPE", MCP_APP_VIEW_EXPIRED: "MCP_APP_VIEW_EXPIRED", + UNKNOWN_AGENT_ID: "UNKNOWN_AGENT_ID", } as const; /** Missing operator-scope details shared by WebSocket and HTTP responses. */ @@ -36,8 +37,17 @@ export type McpAppViewExpiredErrorDetails = { code: typeof GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED; }; +/** Unknown agent details carried by agent-scoped method validation failures. */ +export type UnknownAgentIdErrorDetails = { + code: typeof GatewayErrorDetailCodes.UNKNOWN_AGENT_ID; + agentId: string; +}; + /** Structured details emitted by method-level authorization failures. */ -export type GatewayErrorDetails = MissingScopeErrorDetails | McpAppViewExpiredErrorDetails; +export type GatewayErrorDetails = + | MissingScopeErrorDetails + | McpAppViewExpiredErrorDetails + | UnknownAgentIdErrorDetails; type GatewayErrorLike = { code?: unknown; diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 4b03a3bb253d..fec114950be0 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -734,8 +734,14 @@ describe("validateModelsProbeParams", () => { it("accepts one provider with optional profile and timeout", () => { expect(validateModelsProbeParams({ provider: "openai" })).toBe(true); expect( - validateModelsProbeParams({ provider: "OpenAI", profileId: "work", timeoutMs: 20_000 }), + validateModelsProbeParams({ + provider: "OpenAI", + profileId: "work", + timeoutMs: 20_000, + agentId: "writer", + }), ).toBe(true); + expect(validateModelsProbeParams({ provider: "openai", agentId: "" })).toBe(true); }); it("rejects missing providers, invalid timeouts, and extra fields", () => { diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 0591386946e9..94fbbda4a1ba 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -347,6 +347,8 @@ import { TerminalUploadParamsSchema, TerminalUploadResultSchema, UiCommandParamsSchema, + ModelsAuthLogoutParamsSchema, + ModelsAuthStatusParamsSchema, ModelsListParamsSchema, AuthProbeStatusSchema, ModelsProbeParamsSchema, @@ -861,6 +863,8 @@ export const validateChannelsStatusParams = lazyCompile(ChannelsStatusParamsSche export const validateChannelsStartParams = lazyCompile(ChannelsStartParamsSchema); export const validateChannelsStopParams = lazyCompile(ChannelsStopParamsSchema); export const validateChannelsLogoutParams = lazyCompile(ChannelsLogoutParamsSchema); +export const validateModelsAuthLogoutParams = lazyCompile(ModelsAuthLogoutParamsSchema); +export const validateModelsAuthStatusParams = lazyCompile(ModelsAuthStatusParamsSchema); export const validateModelsListParams = lazyCompile(ModelsListParamsSchema); export const validateSkillsStatusParams = lazyCompile(SkillsStatusParamsSchema); export const validateToolsCatalogParams = lazyCompile(ToolsCatalogParamsSchema); @@ -1319,6 +1323,8 @@ export { PluginsUiDescriptorsResultSchema, PluginsUninstallParamsSchema, PluginsUninstallResultSchema, + ModelsAuthLogoutParamsSchema, + ModelsAuthStatusParamsSchema, ModelsListParamsSchema, AuthProbeStatusSchema, ModelsProbeParamsSchema, diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index df56bd173fd8..3531cf85fe36 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -5,6 +5,8 @@ import { AgentsDeleteResultSchema, AgentsListResultSchema, AgentsUpdateParamsSchema, + ModelsAuthLogoutParamsSchema, + ModelsAuthStatusParamsSchema, ModelsListParamsSchema, ModelsListResultSchema, ModelsProbeParamsSchema, @@ -112,6 +114,31 @@ describe("ModelsListParamsSchema", () => { }); }); +describe("Models auth params schemas", () => { + it("accepts optional agent-scoped status and logout requests", () => { + expect(Value.Check(ModelsAuthStatusParamsSchema, {})).toBe(true); + expect(Value.Check(ModelsAuthStatusParamsSchema, { refresh: true, agentId: "writer" })).toBe( + true, + ); + expect(Value.Check(ModelsAuthStatusParamsSchema, { agentId: "" })).toBe(true); + + expect( + Value.Check(ModelsAuthLogoutParamsSchema, { + provider: "openai", + profileIds: ["openai:writer"], + agentId: "writer", + }), + ).toBe(true); + expect(Value.Check(ModelsAuthLogoutParamsSchema, { provider: "openai" })).toBe(true); + expect(Value.Check(ModelsAuthLogoutParamsSchema, { provider: "openai", agentId: "" })).toBe( + true, + ); + expect(Value.Check(ModelsAuthLogoutParamsSchema, { provider: "openai", profileIds: [] })).toBe( + false, + ); + }); +}); + describe("ModelsListResultSchema", () => { it("accepts stable public input capabilities", () => { const model = { @@ -143,8 +170,10 @@ describe("ModelsProbe schemas", () => { provider: "openai", profileId: "work", timeoutMs: 20_000, + agentId: "writer", }), ).toBe(true); + expect(Value.Check(ModelsProbeParamsSchema, { provider: "openai", agentId: "" })).toBe(true); expect( Value.Check(ModelsProbeResultSchema, { provider: "openai", diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index e60a0b26d5c7..90013ac46dcd 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -223,6 +223,19 @@ export const ModelsListParamsSchema = closedObject({ ), }); +/** Reads model-provider credential health for one configured agent. */ +export const ModelsAuthStatusParamsSchema = closedObject({ + refresh: Type.Optional(Type.Boolean()), + agentId: Type.Optional(Type.String()), +}); + +/** Removes saved model-provider credentials from one configured agent. */ +export const ModelsAuthLogoutParamsSchema = closedObject({ + provider: NonEmptyString, + profileIds: Type.Optional(Type.Array(NonEmptyString, { minItems: 1 })), + agentId: Type.Optional(Type.String()), +}); + /** Model catalog result. */ export const ModelsListResultSchema = closedObject({ models: Type.Array(ModelChoiceSchema), @@ -233,6 +246,7 @@ export const ModelsProbeParamsSchema = closedObject({ provider: NonEmptyString, profileId: Type.Optional(NonEmptyString), timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), + agentId: Type.Optional(Type.String()), }); export const AuthProbeStatusSchema = Type.Union([ @@ -931,6 +945,8 @@ export type AgentsListResult = Static; export type ModelChoice = Static; export type ModelsListParams = Static; export type ModelsListResult = Static; +export type ModelsAuthStatusParams = Static; +export type ModelsAuthLogoutParams = Static; export type AuthProbeStatus = Static; export type ModelsProbeParams = Static; export type ModelsProbeTargetResult = Static; diff --git a/packages/gateway-protocol/src/schema/error-codes.test.ts b/packages/gateway-protocol/src/schema/error-codes.test.ts index b6f4e16b36e6..3eb5cbcef6dc 100644 --- a/packages/gateway-protocol/src/schema/error-codes.test.ts +++ b/packages/gateway-protocol/src/schema/error-codes.test.ts @@ -10,6 +10,7 @@ import { missingScopeErrorShape, readMissingScopeError, readMissingScopeErrorDetails, + UnknownAgentIdErrorDetailsSchema, } from "./error-codes.js"; describe("gateway error details", () => { @@ -35,6 +36,13 @@ describe("gateway error details", () => { expect(isMcpAppViewExpiredError(new Error("upstream token expired"))).toBe(false); }); + it("validates unknown-agent details", () => { + const details = { code: GatewayErrorDetailCodes.UNKNOWN_AGENT_ID, agentId: "retired" }; + expect(Value.Check(UnknownAgentIdErrorDetailsSchema, details)).toBe(true); + expect(Value.Check(GatewayErrorDetailsSchema, details)).toBe(true); + expect(Value.Check(UnknownAgentIdErrorDetailsSchema, { ...details, agentId: "" })).toBe(false); + }); + it("builds a distinct forbidden missing-scope response", () => { expect( missingScopeErrorShape({ diff --git a/packages/gateway-protocol/src/schema/error-codes.ts b/packages/gateway-protocol/src/schema/error-codes.ts index d4c4fac426ce..cf0d77bbdac7 100644 --- a/packages/gateway-protocol/src/schema/error-codes.ts +++ b/packages/gateway-protocol/src/schema/error-codes.ts @@ -17,6 +17,7 @@ export { type GatewayErrorDetails, type McpAppViewExpiredErrorDetails, type MissingScopeErrorDetails, + type UnknownAgentIdErrorDetails, isMcpAppViewExpiredError, readMissingScopeError, readMissingScopeErrorDetails, @@ -33,10 +34,16 @@ export const McpAppViewExpiredErrorDetailsSchema = closedObject({ code: Type.Literal(GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED), }); +export const UnknownAgentIdErrorDetailsSchema = closedObject({ + code: Type.Literal(GatewayErrorDetailCodes.UNKNOWN_AGENT_ID), + agentId: NonEmptyString, +}); + /** Structured details emitted by method-level authorization failures. */ export const GatewayErrorDetailsSchema = Type.Union([ MissingScopeErrorDetailsSchema, McpAppViewExpiredErrorDetailsSchema, + UnknownAgentIdErrorDetailsSchema, ]); /** Builds the canonical gateway error payload while preserving optional retry metadata. */ diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index c4d1ff96c32b..0347ad04ed53 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -43,6 +43,8 @@ import { AgentsUpdateParamsSchema, AgentsUpdateResultSchema, ModelChoiceSchema, + ModelsAuthLogoutParamsSchema, + ModelsAuthStatusParamsSchema, ModelsListParamsSchema, ModelsListResultSchema, ModelsProbeParamsSchema, @@ -291,6 +293,7 @@ import { GatewayErrorDetailsSchema, McpAppViewExpiredErrorDetailsSchema, MissingScopeErrorDetailsSchema, + UnknownAgentIdErrorDetailsSchema, } from "./error-codes.js"; import { ExecApprovalsGetParamsSchema, @@ -648,6 +651,7 @@ export const ProtocolSchemas = { ErrorShape: ErrorShapeSchema, MissingScopeErrorDetails: MissingScopeErrorDetailsSchema, McpAppViewExpiredErrorDetails: McpAppViewExpiredErrorDetailsSchema, + UnknownAgentIdErrorDetails: UnknownAgentIdErrorDetailsSchema, GatewayErrorDetails: GatewayErrorDetailsSchema, GatewaySuspendTaskBlocker: GatewaySuspendTaskBlockerSchema, GatewaySuspendBlocker: GatewaySuspendBlockerSchema, @@ -973,6 +977,8 @@ export const ProtocolSchemas = { AgentsListParams: AgentsListParamsSchema, AgentsListResult: AgentsListResultSchema, ModelChoice: ModelChoiceSchema, + ModelsAuthLogoutParams: ModelsAuthLogoutParamsSchema, + ModelsAuthStatusParams: ModelsAuthStatusParamsSchema, ModelsListParams: ModelsListParamsSchema, ModelsListResult: ModelsListResultSchema, ModelsProbeParams: ModelsProbeParamsSchema, diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index a50eee74eaab..d6bb16160db0 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -610,6 +610,7 @@ function emitDiscriminatedUnionCompatibility(name: string): string[] { " switch self {", " case .missingScope(let value): value.code", " case .mcpAppViewExpired(let value): value.code", + " case .unknownAgentId(let value): value.code", " }", " }", "", diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 8d0853a9619b..94d311eeb395 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -565,6 +565,7 @@ describe("abortChatRunsForProvider", () => { authProviderId: "openrouter", }); const result = abortChatRunsForProvider(ops, { + cfg: {}, providerId: "openrouter", stopReason: "auth-revoked", }); @@ -582,6 +583,26 @@ describe("abortChatRunsForProvider", () => { { sessionKeys: [sessionKey] }, ); }); + + it("derives missing entry agent ids from canonical session keys", () => { + const writerEntry = createActiveEntry("agent:writer:main"); + writerEntry.providerId = "openrouter"; + const mainEntry = createActiveEntry("agent:main:main"); + mainEntry.providerId = "openrouter"; + const ops = createOps({ runId: "run-writer", entry: writerEntry }); + ops.chatAbortControllers.set("run-main", mainEntry); + + const result = abortChatRunsForProvider(ops, { + cfg: { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }, + providerId: "openrouter", + agentId: "writer", + stopReason: "auth-revoked", + }); + + expect(result.runIds).toEqual(["run-writer"]); + expect(writerEntry.controller.signal.aborted).toBe(true); + expect(mainEntry.controller.signal.aborted).toBe(false); + }); }); describe("resolveInFlightRunSnapshot", () => { diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index aab1a2cc45a8..8696d4a3d779 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -12,6 +12,7 @@ import { isAbortRequestText } from "../auto-reply/reply/abort-primitives.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { emitAgentEvent, getAgentEventLifecycleGeneration } from "../infra/agent-events.js"; import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { projectLiveAssistantBufferedText } from "./live-chat-projector.js"; import { createChatAbortMarker, @@ -634,19 +635,28 @@ export function updateChatRunProvider( export function abortChatRunsForProvider( ops: ChatAbortOps, params: { + cfg: OpenClawConfig; providerId: string; + agentId?: string; stopReason?: string; }, ): { runIds: string[] } { const providerId = normalizeProviderIdForActiveRun(params.providerId); + const agentId = normalizeActiveAgentId(params.agentId); + const defaultAgentId = resolveDefaultAgentId(params.cfg); if (!providerId) { return { runIds: [] }; } - const matches = [...ops.chatAbortControllers.entries()].filter( - ([, entry]) => - normalizeProviderIdForActiveRun(entry.authProviderId) === providerId || - normalizeProviderIdForActiveRun(entry.providerId) === providerId, - ); + const matches = [...ops.chatAbortControllers.entries()].filter(([, entry]) => { + const entryAgentId = normalizeActiveAgentId( + entry.agentId ?? parseAgentSessionKey(entry.sessionKey)?.agentId ?? defaultAgentId, + ); + return ( + (!agentId || entryAgentId === agentId) && + (normalizeProviderIdForActiveRun(entry.authProviderId) === providerId || + normalizeProviderIdForActiveRun(entry.providerId) === providerId) + ); + }); const runIds: string[] = []; for (const [runId, entry] of matches) { const result = abortChatRunById(ops, { diff --git a/src/gateway/server-methods/model-auth-agent-scope.ts b/src/gateway/server-methods/model-auth-agent-scope.ts new file mode 100644 index 000000000000..9371abf36f49 --- /dev/null +++ b/src/gateway/server-methods/model-auth-agent-scope.ts @@ -0,0 +1,56 @@ +import type { UnknownAgentIdErrorDetails } from "../../../packages/gateway-protocol/src/gateway-error-details.js"; +import { + ErrorCodes, + GatewayErrorDetailCodes, + errorShape, +} from "../../../packages/gateway-protocol/src/index.js"; +import { listAgentIds, resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; + +type ModelAuthAgentScopeResult = + | { ok: true; agentId: string; agentDir: string } + | { ok: false; agentId: string }; + +/** Resolves model-auth RPC scope without letting explicit garbage reach the default store. */ +export function resolveModelAuthAgentScope( + cfg: OpenClawConfig, + requestedAgentId: unknown, +): ModelAuthAgentScopeResult { + const defaultAgentId = resolveDefaultAgentId(cfg); + if (requestedAgentId === undefined || requestedAgentId === "") { + return { + ok: true, + agentId: defaultAgentId, + agentDir: resolveAgentDir(cfg, defaultAgentId), + }; + } + if (typeof requestedAgentId !== "string") { + return { + ok: false, + agentId: requestedAgentId === null ? "null" : typeof requestedAgentId, + }; + } + const rawAgentId = requestedAgentId.trim(); + // Only the literal empty string keeps the omitted-param default; a + // whitespace-only value is an explicit target and must not use default auth. + if (!rawAgentId) { + return { ok: false, agentId: requestedAgentId }; + } + const agentId = normalizeAgentId(rawAgentId); + // normalizeAgentId falls back to "main" when sanitization erases the entire + // input; explicit garbage must not inherit the default agent's credentials. + const collapsedToFallback = !/[A-Za-z0-9_]/u.test(rawAgentId); + if (collapsedToFallback || !listAgentIds(cfg).includes(agentId)) { + return { ok: false, agentId: rawAgentId }; + } + return { ok: true, agentId, agentDir: resolveAgentDir(cfg, agentId) }; +} + +export function unknownModelAuthAgentIdError(agentId: string) { + const details: UnknownAgentIdErrorDetails = { + code: GatewayErrorDetailCodes.UNKNOWN_AGENT_ID, + agentId, + }; + return errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${agentId}"`, { details }); +} diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 210bcb6e35fc..02b6cec4937e 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -24,7 +24,11 @@ const emptyUsageSummary = (): UsageSummary => ({ updatedAt: 0, providers: [] }); const mocks = vi.hoisted(() => ({ getRuntimeConfig: vi.fn(() => ({})), - resolveDefaultAgentDir: vi.fn(() => "/tmp/agent"), + listAgentIds: vi.fn(() => ["main"]), + resolveAgentDir: vi.fn((_cfg: unknown, agentId: string) => + agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`, + ), + resolveDefaultAgentId: vi.fn(() => "main"), ensureAuthProfileStore: vi.fn((agentDir?: string, options?: unknown): AuthProfileStore => { void agentDir; void options; @@ -59,7 +63,9 @@ vi.mock("../../config/config.js", () => ({ })); vi.mock("../../agents/agent-scope.js", () => ({ - resolveDefaultAgentDir: mocks.resolveDefaultAgentDir, + listAgentIds: mocks.listAgentIds, + resolveAgentDir: mocks.resolveAgentDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, })); vi.mock("../../agents/auth-profiles.js", async () => { @@ -133,11 +139,12 @@ const logoutHandler = expectDefined( 'modelsAuthStatusHandlers["models.authLogout"] test invariant', ); -function createActiveRun(providerId: string, authProviderId?: string) { +function createActiveRun(providerId: string, authProviderId?: string, agentId = "main") { return { controller: new AbortController(), sessionId: `session-${providerId}`, - sessionKey: `agent:main:${providerId}`, + sessionKey: `agent:${agentId}:${providerId}`, + agentId, startedAtMs: 1, expiresAtMs: 60_000, providerId, @@ -229,6 +236,11 @@ function resetAuthStatusMocks(): void { vi.clearAllMocks(); invalidateModelAuthStatusCache(); mocks.getRuntimeConfig.mockReturnValue({}); + mocks.listAgentIds.mockReturnValue(["main"]); + mocks.resolveAgentDir.mockImplementation((_cfg: unknown, agentId: string) => + agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`, + ); + mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ version: 1, @@ -322,6 +334,125 @@ describe("models.authStatus", () => { resetAuthStatusMocks(); }); + it.each([ + { name: "omitted", params: {}, expectedAgentId: "main" }, + { name: "empty", params: { agentId: "" }, expectedAgentId: "main" }, + { name: "valid", params: { agentId: "Writer" }, expectedAgentId: "writer" }, + ])( + "resolves an $name agentId against the configured roster", + async ({ params, expectedAgentId }) => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main", "writer"]); + + const opts = createOptions(params); + await handler(opts); + + expect(mocks.resolveAgentDir).toHaveBeenCalledWith(cfg, expectedAgentId); + expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith( + expectedAgentId === "main" ? "/tmp/agent" : "/tmp/agent-writer", + expect.any(Object), + ); + }, + ); + + it("rejects an explicit unknown agentId before reading auth state", async () => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main", "writer"]); + const opts = createOptions({ agentId: "retired", refresh: true }); + + await handler(opts); + + expect(mocks.resolveAgentDir).not.toHaveBeenCalled(); + expect(mocks.ensureAuthProfileStore).not.toHaveBeenCalled(); + expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).not.toHaveBeenCalled(); + const [ok, payload, error] = firstRespondCall(opts) ?? []; + expect(ok).toBe(false); + expect(payload).toBeUndefined(); + expect(error).toEqual({ + code: "INVALID_REQUEST", + message: 'unknown agent id "retired"', + details: { code: "UNKNOWN_AGENT_ID", agentId: "retired" }, + }); + }); + + it("accepts an explicitly configured normalized roster id", async () => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "_writer" }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main", "_writer"]); + const opts = createOptions({ agentId: "_writer" }); + + await handler(opts); + + expect(mocks.resolveAgentDir).toHaveBeenCalledWith(cfg, "_writer"); + expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith( + "/tmp/agent-_writer", + expect.any(Object), + ); + expect(firstRespondCall(opts)?.[0]).toBe(true); + }); + + it.each(["???", "ſ", " ", "\t"])( + "rejects explicit id %j when it collapses to the normalization fallback", + async (agentId) => { + const cfg = { agents: { list: [{ id: "main", default: true }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main"]); + const opts = createOptions({ agentId }); + + await handler(opts); + + expect(mocks.resolveAgentDir).not.toHaveBeenCalled(); + expect(mocks.ensureAuthProfileStore).not.toHaveBeenCalled(); + expect(firstRespondCall(opts)?.[2]).toEqual({ + code: "INVALID_REQUEST", + message: `unknown agent id "${agentId}"`, + details: { code: "UNKNOWN_AGENT_ID", agentId }, + }); + }, + ); + + it("keeps cached auth snapshots isolated by agent", async () => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main", "writer"]); + + await handler(createOptions({ agentId: "main" })); + await handler(createOptions({ agentId: "writer" })); + const cachedMain = createOptions({ agentId: "main" }); + await handler(cachedMain); + + expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith( + 1, + "/tmp/agent", + expect.any(Object), + ); + expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith( + 2, + "/tmp/agent-writer", + expect.any(Object), + ); + expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(2); + expect(firstRespondCall(cachedMain)?.[3]).toEqual({ cached: true }); + }); + + it("re-reads runtime config after an explicit auth refresh", async () => { + const before = { agents: { list: [{ id: "main", default: true }] } }; + const after = { + ...before, + models: { providers: { openai: { auth: "oauth" } } }, + }; + mocks.getRuntimeConfig.mockReturnValueOnce(before).mockReturnValue(after); + + await handler(createOptions({ refresh: true })); + + expect(mocks.getRuntimeConfig).toHaveBeenCalledTimes(2); + expect(mocks.buildAuthHealthSummary).toHaveBeenCalledWith( + expect.objectContaining({ cfg: after }), + ); + }); + it("returns a serialisable snapshot on first call", async () => { mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, @@ -1118,6 +1249,52 @@ describe("models.authLogout", () => { resetAuthStatusMocks(); }); + it.each([ + { name: "omitted", agentId: undefined, expectedAgentId: "main" }, + { name: "empty", agentId: "", expectedAgentId: "main" }, + { name: "valid", agentId: "Writer", expectedAgentId: "writer" }, + ])("targets the $name agentId auth store", async ({ agentId, expectedAgentId }) => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main", "writer"]); + const opts = createLogoutOptions({ + provider: "openrouter", + ...(agentId !== undefined ? { agentId } : {}), + }); + + await logoutHandler(opts); + + const expectedDir = expectedAgentId === "main" ? "/tmp/agent" : "/tmp/agent-writer"; + expect(mocks.resolveAgentDir).toHaveBeenCalledWith(cfg, expectedAgentId); + expect(mocks.ensureAuthProfileStoreWithoutExternalProfiles).toHaveBeenCalledWith(expectedDir); + expect(mocks.removeProviderAuthProfilesWithLock).toHaveBeenCalledWith({ + provider: "openrouter", + agentDir: expectedDir, + }); + }); + + it("rejects an explicit unknown agentId without touching the default auth store", async () => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main", "writer"]); + const opts = createLogoutOptions({ provider: "openrouter", agentId: "retired" }); + + await logoutHandler(opts); + + expect(mocks.resolveAgentDir).not.toHaveBeenCalled(); + expect(mocks.ensureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); + expect(mocks.removeProviderAuthProfilesWithLock).not.toHaveBeenCalled(); + expect(mocks.removeAuthProfilesWithLock).not.toHaveBeenCalled(); + const [ok, payload, error] = firstRespondCall(opts) ?? []; + expect(ok).toBe(false); + expect(payload).toBeUndefined(); + expect(error).toEqual({ + code: "INVALID_REQUEST", + message: 'unknown agent id "retired"', + details: { code: "UNKNOWN_AGENT_ID", agentId: "retired" }, + }); + }); + it("removes provider auth profiles and invalidates the status cache", async () => { mocks.listProfilesForProvider.mockReturnValue(["openrouter:default"]); await handler(createOptions()); @@ -1276,6 +1453,26 @@ describe("models.authLogout", () => { expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-openrouter"]); }); + it("aborts provider runs only for the logged-out agent", async () => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listAgentIds.mockReturnValue(["main", "writer"]); + const opts = createLogoutOptions({ provider: "openrouter", agentId: "writer" }); + const mainRun = createActiveRun("openrouter", undefined, "main"); + const writerRun = createActiveRun("openrouter", undefined, "writer"); + opts.context.chatAbortControllers.set("run-main", mainRun); + opts.context.chatAbortControllers.set("run-writer", writerRun); + + await logoutHandler(opts); + + expect(mainRun.controller.signal.aborted).toBe(false); + expect(writerRun.controller.signal.aborted).toBe(true); + expect(opts.context.chatAbortControllers.has("run-main")).toBe(true); + expect(opts.context.chatAbortControllers.has("run-writer")).toBe(false); + const [, payload] = firstRespondCall(opts) ?? []; + expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-writer"]); + }); + it("aborts provider runs but preserves config SecretRef auth", async () => { const cfg = { models: { diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index cacf99c59b42..4e1821a743ba 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -5,7 +5,6 @@ import { normalizeProviderId, } from "@openclaw/model-catalog-core/provider-id"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentDir } from "../../agents/agent-scope.js"; import { type AuthHealthSummary, type AuthProfileHealthStatus, @@ -60,6 +59,10 @@ import { refreshActiveProviderAuthRuntimeSnapshot } from "../../secrets/runtime. import { asDateTimestampMs } from "../../shared/number-coercion.js"; import { abortChatRunsForProvider, type ChatAbortOps } from "../chat-abort.js"; import { formatForLog } from "../ws-log.js"; +import { + resolveModelAuthAgentScope, + unknownModelAuthAgentIdError, +} from "./model-auth-agent-scope.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; const log = createSubsystemLogger("models-auth-status"); @@ -135,7 +138,7 @@ export type ModelAuthLogoutResult = { }; const CACHE_TTL_MS = 60_000; -let cached: { ts: number; result: ModelAuthStatusResult } | null = null; +const cachedByAgentId = new Map(); let cacheGeneration = 0; /** @@ -146,7 +149,7 @@ let cacheGeneration = 0; */ export function invalidateModelAuthStatusCache(): void { cacheGeneration += 1; - cached = null; + cachedByAgentId.clear(); // The prepared provider-auth map (model-provider-auth.ts) was built from // the pre-mutation auth state, so it must be invalidated alongside this // cache whenever an auth-profile mutation lands (logout, login, token @@ -562,7 +565,12 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { } try { const cfg = context.getRuntimeConfig(); - const agentDir = resolveDefaultAgentDir(cfg); + const scope = resolveModelAuthAgentScope(cfg, params.agentId); + if (!scope.ok) { + respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + return; + } + const { agentDir } = scope; const authProvider = resolveProviderIdForAuth(provider, { config: cfg }); const store = ensureAuthProfileStoreWithoutExternalProfiles(agentDir); const availableProfiles = listProfilesForProvider(store, provider); @@ -630,7 +638,9 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { const { runIds: abortedRunIds } = selection.profileIds ? { runIds: [] as string[] } : abortChatRunsForProvider(createAuthLogoutAbortOps(context), { + cfg, providerId: authProvider, + agentId: scope.agentId, stopReason: "auth-revoked", }); const result: ModelAuthLogoutResult = { @@ -645,18 +655,30 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { }, "models.authStatus": async ({ params, respond, context }) => { const now = Date.now(); - const bypassCache = Boolean((params as { refresh?: boolean } | undefined)?.refresh); - if (!bypassCache && cached && now - cached.ts < CACHE_TTL_MS) { - respond(true, cached.result, undefined, { cached: true }); - return; - } + const bypassCache = Boolean(params.refresh); try { + let cfg = context.getRuntimeConfig(); + let scope = resolveModelAuthAgentScope(cfg, params.agentId); + if (!scope.ok) { + respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + return; + } if (bypassCache) { await refreshModelAuthStatusRuntimeState(); + cfg = context.getRuntimeConfig(); + scope = resolveModelAuthAgentScope(cfg, params.agentId); + if (!scope.ok) { + respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + return; + } } const publishGeneration = cacheGeneration; - const cfg = context.getRuntimeConfig(); - const agentDir = resolveDefaultAgentDir(cfg); + const { agentId, agentDir } = scope; + const cached = cachedByAgentId.get(agentId); + if (!bypassCache && cached && now - cached.ts < CACHE_TTL_MS) { + respond(true, cached.result, undefined, { cached: true }); + return; + } // Use the external-profile-aware store for status reads so the dashboard // reflects CLI-discovered credentials without persisting them here. const store = ensureAuthProfileStore(agentDir, { @@ -750,7 +772,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { ); const result: ModelAuthStatusResult = { ts: now, providers }; if (publishGeneration === cacheGeneration) { - cached = { ts: now, result }; + cachedByAgentId.set(agentId, { ts: now, result }); } respond(true, result, undefined); } catch (err) { diff --git a/src/gateway/server-methods/models-probe.test.ts b/src/gateway/server-methods/models-probe.test.ts index 680fded878e4..c4ecdc6cb979 100644 --- a/src/gateway/server-methods/models-probe.test.ts +++ b/src/gateway/server-methods/models-probe.test.ts @@ -6,9 +6,20 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ + listAgentIds: vi.fn(() => ["main", "writer"]), + resolveAgentDir: vi.fn((_cfg: unknown, agentId: string) => `/tmp/agent-${agentId}`), + resolveDefaultAgentId: vi.fn(() => "main"), + resolveAgentWorkspaceDir: vi.fn((_cfg: unknown, agentId: string) => `/tmp/workspace-${agentId}`), runAuthProbes: vi.fn(), })); +vi.mock("../../agents/agent-scope.js", () => ({ + listAgentIds: mocks.listAgentIds, + resolveAgentDir: mocks.resolveAgentDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, +})); + vi.mock("../../commands/models/list.probe.js", async () => { const actual = await vi.importActual( "../../commands/models/list.probe.js", @@ -51,6 +62,18 @@ function createOptions(params: Record, cfg: OpenClawConfig = {} describe("models.probe", () => { beforeEach(() => { + mocks.listAgentIds.mockClear(); + mocks.listAgentIds.mockReturnValue(["main", "writer"]); + mocks.resolveAgentDir.mockClear(); + mocks.resolveAgentDir.mockImplementation( + (_cfg: unknown, agentId: string) => `/tmp/agent-${agentId}`, + ); + mocks.resolveDefaultAgentId.mockClear(); + mocks.resolveDefaultAgentId.mockReturnValue("main"); + mocks.resolveAgentWorkspaceDir.mockClear(); + mocks.resolveAgentWorkspaceDir.mockImplementation( + (_cfg: unknown, agentId: string) => `/tmp/workspace-${agentId}`, + ); mocks.runAuthProbes.mockReset(); mocks.runAuthProbes.mockResolvedValue(summary([])); }); @@ -82,6 +105,9 @@ describe("models.probe", () => { await handler(options); expect(mocks.runAuthProbes).toHaveBeenCalledWith({ cfg, + agentId: "main", + agentDir: "/tmp/agent-main", + workspaceDir: "/tmp/workspace-main", providers: ["openai"], modelCandidates: ["openai/gpt-5.6", "openai/gpt-5.5", "openai/gpt-5.6-luna"], options: { @@ -94,6 +120,57 @@ describe("models.probe", () => { }); }); + it.each([ + { name: "omitted", params: {} }, + { name: "empty", params: { agentId: "" } }, + ])("probes the default agent when agentId is $name", async ({ params }) => { + const cfg: OpenClawConfig = { agents: { list: [{ id: "main", default: true }] } }; + const { options } = createOptions({ provider: "openai", ...params }, cfg); + + await handler(options); + + expect(mocks.runAuthProbes).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "main", + agentDir: "/tmp/agent-main", + workspaceDir: "/tmp/workspace-main", + }), + ); + }); + + it("probes an explicit configured agent", async () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }, { id: "writer" }] }, + }; + const { options } = createOptions({ provider: "openai", agentId: "Writer" }, cfg); + + await handler(options); + + expect(mocks.runAuthProbes).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "writer", + agentDir: "/tmp/agent-writer", + workspaceDir: "/tmp/workspace-writer", + }), + ); + }); + + it.each(["retired", " "])("rejects explicit unknown agentId %j", async (agentId) => { + const cfg: OpenClawConfig = { agents: { list: [{ id: "main", default: true }] } }; + mocks.listAgentIds.mockReturnValue(["main"]); + const { options, respond } = createOptions({ provider: "openai", agentId }, cfg); + + await handler(options); + + expect(mocks.resolveAgentDir).not.toHaveBeenCalled(); + expect(mocks.runAuthProbes).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(false, undefined, { + code: "INVALID_REQUEST", + message: `unknown agent id "${agentId}"`, + details: { code: "UNKNOWN_AGENT_ID", agentId }, + }); + }); + it("probes the requested provider so overrides and model selection resolve", async () => { const cfg: OpenClawConfig = { models: { diff --git a/src/gateway/server-methods/models-probe.ts b/src/gateway/server-methods/models-probe.ts index c3c532069149..7efe8fe0ca59 100644 --- a/src/gateway/server-methods/models-probe.ts +++ b/src/gateway/server-methods/models-probe.ts @@ -8,12 +8,17 @@ import { type ModelsProbeResult, validateModelsProbeParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { type AuthProbeResult, type AuthProbeStatus, runAuthProbes, } from "../../commands/models/list.probe.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + resolveModelAuthAgentScope, + unknownModelAuthAgentIdError, +} from "./model-auth-agent-scope.js"; import type { GatewayRequestHandlers } from "./types.js"; const DEFAULT_TIMEOUT_MS = 20_000; @@ -119,12 +124,21 @@ export const modelsProbeHandlers: GatewayRequestHandlers = { ); try { const cfg = context.getRuntimeConfig(); + const scope = resolveModelAuthAgentScope(cfg, request.agentId); + if (!scope.ok) { + respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); + return; + } + const workspaceDir = resolveAgentWorkspaceDir(cfg, scope.agentId); // Probe under the requested provider so model selection, catalog rows, and // a models.providers. override resolve against the surface the client // asked about. The probe planner resolves credentials separately through // the provider's auth alias, matching normal agent runtime planning. const summary = await runAuthProbes({ cfg, + agentId: scope.agentId, + agentDir: scope.agentDir, + workspaceDir, providers: [provider], modelCandidates: modelCandidatesFromConfig(cfg), options: { diff --git a/ui/src/components/agent-scope-control.test.ts b/ui/src/components/agent-scope-control.test.ts index 8514567cd57b..1784bd591737 100644 --- a/ui/src/components/agent-scope-control.test.ts +++ b/ui/src/components/agent-scope-control.test.ts @@ -39,4 +39,38 @@ describe("renderAgentScopeControl", () => { select.dispatchEvent(new Event("change", { bubbles: true })); expect(setScope).toHaveBeenCalledWith("retired"); }); + + it("supports a concrete-agent selector without an all-agents option", () => { + const container = document.createElement("div"); + const setScope = vi.fn(); + const selection = { + state: { selectedId: "main", scopeId: null }, + set: vi.fn(), + setScope, + subscribe: vi.fn(), + } as unknown as AgentSelectionCapability; + + render( + renderAgentScopeControl({ + agents: [ + { id: "main", name: "Main agent" }, + { id: "writer", name: "Writer" }, + ], + selection, + allowAll: false, + selectedId: "writer", + }), + container, + ); + + const select = container.querySelector("select"); + expect(select?.value).toBe("writer"); + expect(Array.from(select?.options ?? []).map((option) => option.value)).toEqual([ + "main", + "writer", + ]); + select!.value = "main"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + expect(setScope).toHaveBeenCalledWith("main"); + }); }); diff --git a/ui/src/components/agent-scope-control.ts b/ui/src/components/agent-scope-control.ts index 07254ce82f87..7e5f22fdd43d 100644 --- a/ui/src/components/agent-scope-control.ts +++ b/ui/src/components/agent-scope-control.ts @@ -9,10 +9,13 @@ type AgentScopeControlParams = { agents: readonly GatewayAgentRow[]; additionalAgentIds?: readonly string[]; selection: AgentSelectionCapability; + allowAll?: boolean; + selectedId?: string | null; }; export function renderAgentScopeControl(params: AgentScopeControlParams) { - const selected = params.selection.state.scopeId ?? ""; + const selected = params.selectedId ?? params.selection.state.scopeId ?? ""; + const allowAll = params.allowAll !== false; const agentsById = new Map( params.agents.map((agent) => { const agentId = normalizeAgentId(agent.id); @@ -44,10 +47,20 @@ export function renderAgentScopeControl(params: AgentScopeControlParams) { params.selection.setScope(value || null); }} > - - ${selectedAgentMissing ? html`` : null} + ${allowAll + ? html`` + : null} + ${selectedAgentMissing + ? html`` + : null} ${agents.map( - (agent) => html``, + (agent) => html` + + `, )} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 9a699a280186..38330c227de1 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3167,14 +3167,15 @@ export const en: TranslationMap = { models: "{count} models", modelOne: "1 model", modelsAvailable: "{available} of {count} models available", + globalUsage: "Global usage and cost", noStats: "No live usage data reported by this provider.", - localCost: "Session spend · {days}d", + localCost: "Global session spend · {days}d", localCostDetail: "{tokens} tokens · {sessions} sessions", saving: "Saving…", requestFailed: "Request failed.", configUnavailable: "Configuration is unavailable. Refresh and try again.", credentials: { - label: "Credentials", + label: "Credentials for {agent}", oauth: "OAuth profiles: {count}", tokenProfiles: "Token profiles: {count}", configKey: "API key set in config", diff --git a/ui/src/lib/model-auth.ts b/ui/src/lib/model-auth.ts index cb42068ed10d..9f81c0c93b19 100644 --- a/ui/src/lib/model-auth.ts +++ b/ui/src/lib/model-auth.ts @@ -31,9 +31,12 @@ export function isMonitoredAuthProvider(p: ModelAuthStatusProvider): boolean { export async function loadModelAuthStatus( client: GatewayBrowserClient, - opts?: { refresh?: boolean }, + opts?: { refresh?: boolean; agentId?: string }, ): Promise { - const params = opts?.refresh ? { refresh: true } : {}; + const params = { + ...(opts?.refresh ? { refresh: true } : {}), + ...(opts?.agentId ? { agentId: opts.agentId } : {}), + }; return ( (await client.request("models.authStatus", params)) ?? EMPTY_AUTH_STATUS ); diff --git a/ui/src/pages/model-providers/load.test.ts b/ui/src/pages/model-providers/load.test.ts new file mode 100644 index 000000000000..dfd8788efbfb --- /dev/null +++ b/ui/src/pages/model-providers/load.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { loadModelProvidersData } from "./load.ts"; + +describe("loadModelProvidersData", () => { + it("scopes only credential status to the selected agent", async () => { + const request = vi.fn(async (method: string, _params?: unknown) => { + switch (method) { + case "models.authStatus": + return { ts: 1, providers: [] }; + case "models.list": + return { models: [] }; + case "config.get": + return { config: {}, hash: "hash" }; + case "usage.status": + return { updatedAt: 1, providers: [] }; + case "sessions.usage": + return { aggregates: { byProvider: [] } }; + default: + return {}; + } + }); + const client = { request } as unknown as GatewayBrowserClient; + + await loadModelProvidersData(client, { refresh: true, agentId: "writer" }); + + expect(request).toHaveBeenCalledWith("models.authStatus", { + refresh: true, + agentId: "writer", + }); + expect(request).toHaveBeenCalledWith("usage.status"); + const sessionUsageCall = request.mock.calls.find(([method]) => method === "sessions.usage"); + expect(sessionUsageCall?.[1]).not.toHaveProperty("agentId"); + expect(sessionUsageCall?.[1]).toHaveProperty("agentScope", "all"); + }); +}); diff --git a/ui/src/pages/model-providers/load.ts b/ui/src/pages/model-providers/load.ts index 171fe8769f78..6ebd05c0f073 100644 --- a/ui/src/pages/model-providers/load.ts +++ b/ui/src/pages/model-providers/load.ts @@ -57,7 +57,7 @@ function errorMessage(error: unknown): string { export async function loadModelProvidersData( client: GatewayBrowserClient, - opts?: { refresh?: boolean }, + opts?: { refresh?: boolean; agentId?: string }, ): Promise { const [authStatus, models, catalogModels, config, providerUsage, costByProvider] = await Promise.all([ diff --git a/ui/src/pages/model-providers/model-providers-page.test.ts b/ui/src/pages/model-providers/model-providers-page.test.ts new file mode 100644 index 000000000000..6f1dcb7606ff --- /dev/null +++ b/ui/src/pages/model-providers/model-providers-page.test.ts @@ -0,0 +1,228 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ModelsProbeResult } from "../../api/types.ts"; +import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; +import { EMPTY_MODEL_PROVIDERS_DATA, type ModelProvidersData } from "./load.ts"; +import type { ModelProvidersRouteData } from "./model-providers-page.ts"; +import "./model-providers-page.ts"; + +type ModelProvidersPageTestElement = HTMLElement & { + context: ApplicationContext; + updateComplete: Promise; + busy: Record; + data: ModelProvidersData | null; + probe: (cardId: string, providers: string[]) => Promise; + probeResults: Record; + refreshQueue: Promise; + refreshing: boolean; + routeData: ModelProvidersRouteData | undefined; + selectedAgentId: string; +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function createHarness(initialScopeId: string) { + let pendingAuthStatus: Promise | null = null; + let releaseAuthStatus: (() => void) | null = null; + const deferNextAuthStatus = () => { + pendingAuthStatus = new Promise((resolve) => { + releaseAuthStatus = resolve; + }); + return () => releaseAuthStatus?.(); + }; + const request = vi.fn(async (method: string): Promise => { + switch (method) { + case "models.authStatus": { + if (pendingAuthStatus) { + const gate = pendingAuthStatus; + pendingAuthStatus = null; + await gate; + } + return { ts: 1, providers: [] }; + } + case "models.list": + return { models: [] }; + case "config.get": + return { config: {}, hash: "hash" }; + case "usage.status": + return { updatedAt: 1, providers: [] }; + case "sessions.usage": + return { aggregates: { byProvider: [] } }; + default: + return {}; + } + }); + const snapshot: ApplicationGatewaySnapshot = { + client: { request } as unknown as GatewayBrowserClient, + connected: true, + reconnecting: false, + hello: null, + assistantAgentId: "main", + sessionKey: "main", + lastError: null, + lastErrorCode: null, + }; + let selectionListener: (() => void) | undefined; + const agentSelection = { + state: { selectedId: initialScopeId, scopeId: initialScopeId as string | null }, + set: vi.fn(), + setScope: vi.fn(), + subscribe(listener: () => void) { + selectionListener = listener; + return () => { + selectionListener = undefined; + }; + }, + }; + const subscribe = () => () => undefined; + const context = { + gateway: { snapshot, subscribe }, + agents: { + state: { + agentsList: { + defaultId: "main", + mainKey: "main", + scope: "project", + agents: [ + { id: "main", name: "Main" }, + { id: "writer", name: "Writer" }, + ], + }, + agentsLoading: false, + }, + ensureList: vi.fn(), + subscribe, + }, + agentSelection, + runtimeConfig: { state: {}, subscribe }, + navigate: vi.fn(), + } as unknown as ApplicationContext; + return { + agentSelection, + context, + deferNextAuthStatus, + notifySelection: () => selectionListener?.(), + request, + snapshot, + }; +} + +function appendPage(context: ApplicationContext) { + const page = document.createElement( + "openclaw-model-providers-page", + ) as ModelProvidersPageTestElement; + page.context = context; + document.body.append(page); + return page; +} + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe("ModelProvidersPage agent scope", () => { + it("reloads credential status when the agent selector changes", async () => { + const { agentSelection, context, notifySelection, request } = createHarness("main"); + const page = appendPage(context); + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "main" }), + ); + + request.mockClear(); + page.busy = { "logout:openai": true }; + agentSelection.state.scopeId = "writer"; + notifySelection(); + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "writer" }), + ); + await page.refreshQueue; + expect(request.mock.calls.filter(([method]) => method === "models.authStatus")).toHaveLength(1); + expect(page.busy).toEqual({}); + }); + + it("recovers when the agent changes while a refresh is in flight", async () => { + const { agentSelection, context, notifySelection, request, deferNextAuthStatus } = + createHarness("main"); + const release = deferNextAuthStatus(); + const page = appendPage(context); + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "main" }), + ); + // Invalidate the in-flight refresh mid-await; the stale completion must + // clear `refreshing` so the new agent's load can proceed. + agentSelection.state.scopeId = "writer"; + notifySelection(); + release(); + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "writer" }), + ); + await page.refreshQueue; + expect(page.refreshing).toBe(false); + }); + + it("discards stale route data when selection changes during preload", async () => { + const { context, request, snapshot } = createHarness("writer"); + const staleData = { ...EMPTY_MODEL_PROVIDERS_DATA, updatedAt: 1 }; + const page = document.createElement( + "openclaw-model-providers-page", + ) as ModelProvidersPageTestElement; + page.context = context; + page.routeData = { data: staleData, client: snapshot.client, agentId: "main" }; + document.body.append(page); + + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "writer" }), + ); + expect(page.selectedAgentId).toBe("writer"); + expect(page.data).not.toBe(staleData); + }); + + it("probes credentials in the selected agent scope", async () => { + const { context, request } = createHarness("writer"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + request.mockClear(); + + await page.probe("openai", ["openai"]); + + expect(request).toHaveBeenCalledWith("models.probe", { + provider: "openai", + agentId: "writer", + }); + }); + + it("discards an in-flight probe result after the selected agent changes", async () => { + const { context, request } = createHarness("main"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.data?.config).toEqual({})); + const pending = deferred(); + request.mockImplementationOnce(() => pending.promise); + + const probing = page.probe("openai", ["openai"]); + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith("models.probe", { + provider: "openai", + agentId: "main", + }), + ); + page.selectedAgentId = "writer"; + pending.resolve({ provider: "openai", status: "ok", results: [] }); + await probing; + + expect(page.probeResults).toEqual({}); + expect(page.busy).toEqual({}); + }); +}); diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index 75bee77bb4a1..6433457a9549 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -6,9 +6,12 @@ import type { ModelsProbeResult } from "../../api/types.ts"; import { titleForRoute } from "../../app-navigation.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; +import { renderAgentScopeControl } from "../../components/agent-scope-control.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { t } from "../../i18n/index.ts"; +import { normalizeAgentLabel } from "../../lib/agents/display.ts"; import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { @@ -36,6 +39,8 @@ export type ModelProvidersRouteData = { data: ModelProvidersData; /** Client the loader fetched from; null when it ran disconnected. */ client: GatewayBrowserClient | null; + /** Concrete agent whose credential store populated the auth snapshot. */ + agentId: string; }; function errorMessage(error: unknown): string { @@ -101,6 +106,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { @state() private addProviderId = ""; @state() private addProviderKey = ""; @state() private defaultsDraft: DefaultModelSelection | null = null; + @state() private selectedAgentId = "main"; /** Client the current data was loaded from; a new client means stale data. */ private dataClient: GatewayBrowserClient | null = null; @@ -109,10 +115,20 @@ export class ModelProvidersPage extends OpenClawLightDomElement { private refreshEpoch = 0; private refreshQueue: Promise = Promise.resolve(); private probeEpochs = new Map(); - private readonly subscriptions = new SubscriptionsController(this).watch( - () => this.context?.gateway, - (gateway, notify) => gateway.subscribe(notify), - ); + private readonly subscriptions = new SubscriptionsController(this) + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + ) + .watch( + () => this.context?.agents, + (agents, notify) => agents.subscribe(notify), + () => this.syncSelectedAgent(), + ) + .effect( + () => this.context?.agentSelection, + (selection) => selection.subscribe(() => this.syncSelectedAgent()), + ); override disconnectedCallback() { this.refreshEpoch += 1; @@ -122,8 +138,15 @@ export class ModelProvidersPage extends OpenClawLightDomElement { override willUpdate(changed: PropertyValues) { if (changed.has("routeData") && this.routeData) { - this.data = this.routeData.data; - this.dataClient = this.routeData.client; + const selectedAgentId = this.resolveSelectedAgentId(); + this.selectedAgentId = selectedAgentId; + if (this.routeData.agentId === selectedAgentId) { + this.data = this.routeData.data; + this.dataClient = this.routeData.client; + } else { + this.data = null; + this.dataClient = null; + } } } @@ -132,6 +155,9 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (snapshot.client !== this.observedClient) { this.resetClientState(snapshot.client); } + if (!this.context.agents.state.agentsList && !this.context.agents.state.agentsLoading) { + void this.context.agents.ensureList(); + } if (!snapshot.connected || !snapshot.client || this.refreshing) { return; } @@ -171,6 +197,35 @@ export class ModelProvidersPage extends OpenClawLightDomElement { ); } + private resolveSelectedAgentId(): string { + const agentsList = this.context.agents.state.agentsList; + const requested = this.context.agentSelection.state.scopeId; + const normalizedRequested = requested ? normalizeAgentId(requested) : null; + const rosterIds = new Set(agentsList?.agents.map((agent) => normalizeAgentId(agent.id)) ?? []); + if (normalizedRequested && rosterIds.has(normalizedRequested)) { + return normalizedRequested; + } + return normalizeAgentId(agentsList?.defaultId ?? agentsList?.agents[0]?.id ?? "main"); + } + + private syncSelectedAgent() { + const agentId = this.resolveSelectedAgentId(); + if (agentId === this.selectedAgentId) { + return; + } + this.selectedAgentId = agentId; + this.refreshEpoch += 1; + this.data = null; + this.busy = {}; + this.pendingLogoutProvider = null; + this.messages = {}; + this.probeResults = {}; + // probeEpochs stays: per-card counters must remain monotonic across agent + // switches, or an in-flight probe from the old agent can reuse an epoch + // and clobber a newer probe's state (A->B->A ABA race). + this.requestUpdate(); + } + private refresh(opts: { force: boolean }): Promise { const task = this.refreshQueue.then(() => this.performRefresh(opts)); this.refreshQueue = task.catch(() => undefined); @@ -182,18 +237,29 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!client) { return; } + const agentId = this.selectedAgentId; const epoch = ++this.refreshEpoch; this.refreshing = true; try { - const data = await loadModelProvidersData(client, opts.force ? { refresh: true } : undefined); - if (epoch === this.refreshEpoch && this.context.gateway.snapshot.client === client) { + const data = await loadModelProvidersData(client, { + agentId, + ...(opts.force ? { refresh: true } : {}), + }); + if ( + epoch === this.refreshEpoch && + this.selectedAgentId === agentId && + this.context.gateway.snapshot.client === client + ) { this.data = data; this.dataClient = client; } } finally { - if (epoch === this.refreshEpoch && this.context.gateway.snapshot.client === client) { - this.refreshing = false; - } + // refreshQueue serializes performRefresh calls, so this is always the + // only in-flight refresh: clear unconditionally. An epoch-guarded clear + // orphans `refreshing` when a selection change invalidates us mid-await, + // permanently blocking maybeRefresh for the new agent. + this.refreshing = false; + this.requestUpdate(); } } @@ -359,6 +425,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { return; } const clientEpoch = this.clientEpoch; + const agentId = this.selectedAgentId; const probeEpoch = (this.probeEpochs.get(cardId) ?? 0) + 1; this.probeEpochs.set(cardId, probeEpoch); this.setBusy(key, true); @@ -366,10 +433,13 @@ export class ModelProvidersPage extends OpenClawLightDomElement { try { const results: ModelsProbeResult[] = []; for (const provider of providers) { - results.push(await client.request("models.probe", { provider })); + results.push( + await client.request("models.probe", { provider, agentId }), + ); } if ( this.isCurrentClient(client, clientEpoch) && + this.selectedAgentId === agentId && this.probeEpochs.get(cardId) === probeEpoch ) { this.probeResults = { @@ -380,6 +450,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { } catch (error) { if ( !this.isCurrentClient(client, clientEpoch) || + this.selectedAgentId !== agentId || this.probeEpochs.get(cardId) !== probeEpoch ) { return; @@ -410,6 +481,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { return; } const clientEpoch = this.clientEpoch; + const agentId = this.selectedAgentId; this.clearProbe(cardId); this.setBusy(key, true); this.setMessage(cardId, null); @@ -417,16 +489,16 @@ export class ModelProvidersPage extends OpenClawLightDomElement { let firstError: unknown; for (const target of targets) { try { - await client.request("models.authLogout", target); + await client.request("models.authLogout", { ...target, agentId }); } catch (error) { firstError ??= error; } } - if (!this.isCurrentClient(client, clientEpoch)) { + if (!this.isCurrentClient(client, clientEpoch) || this.selectedAgentId !== agentId) { return; } await this.refresh({ force: true }); - if (!this.isCurrentClient(client, clientEpoch)) { + if (!this.isCurrentClient(client, clientEpoch) || this.selectedAgentId !== agentId) { return; } if (firstError) { @@ -436,11 +508,11 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.pendingLogoutProvider = null; this.setMessage(cardId, { kind: "success", text: t("modelProviders.logout.done") }); } catch (error) { - if (this.isCurrentClient(client, clientEpoch)) { + if (this.isCurrentClient(client, clientEpoch) && this.selectedAgentId === agentId) { this.setMessage(cardId, { kind: "error", text: errorMessage(error) }); } } finally { - if (this.isCurrentClient(client, clientEpoch)) { + if (this.isCurrentClient(client, clientEpoch) && this.selectedAgentId === agentId) { this.setBusy(key, false); } } @@ -488,6 +560,13 @@ export class ModelProvidersPage extends OpenClawLightDomElement { override render() { const gatewaySnapshot = this.context.gateway.snapshot; + const agents = this.context.agents.state.agentsList?.agents ?? []; + const selectedAgent = agents.find( + (agent) => normalizeAgentId(agent.id) === this.selectedAgentId, + ); + const selectedAgentLabel = selectedAgent + ? normalizeAgentLabel(selectedAgent) + : this.selectedAgentId; const data = this.data ?? EMPTY_MODEL_PROVIDERS_DATA; const config = readModelProviderConfig(data.config); const defaults = this.defaultsDraft ?? config.defaults; @@ -512,6 +591,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { error: data.error, updatedAt: data.updatedAt, costDays: MODEL_PROVIDERS_COST_DAYS, + credentialAgentLabel: selectedAgentLabel, cards, configuredModels: buildSelectableDefaultModels(data.models, defaults), defaultModels: defaults, @@ -587,9 +667,17 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
${titleForRoute("model-providers")}
- +
+ ${renderAgentScopeControl({ + agents, + selection: this.context.agentSelection, + allowAll: false, + selectedId: this.selectedAgentId, + })} + +
${renderSettingsWorkspace(body)} `; diff --git a/ui/src/pages/model-providers/route.ts b/ui/src/pages/model-providers/route.ts index 7d634342671b..2fe8bdf9255f 100644 --- a/ui/src/pages/model-providers/route.ts +++ b/ui/src/pages/model-providers/route.ts @@ -1,6 +1,7 @@ import { definePage } from "@openclaw/uirouter"; import { html } from "lit"; import type { ApplicationContext } from "../../app/context.ts"; +import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import type { ModelProvidersRouteData } from "./model-providers-page.ts"; async function loadModelProvidersRouteData( @@ -9,10 +10,19 @@ async function loadModelProvidersRouteData( const gatewaySnapshot = context.gateway.snapshot; const { EMPTY_MODEL_PROVIDERS_DATA, loadModelProvidersData } = await import("./load.ts"); const client = gatewaySnapshot.connected ? gatewaySnapshot.client : null; + const agentsList = + context.agents.state.agentsList ?? (client ? await context.agents.ensureList() : null); + const requestedAgentId = context.agentSelection.state.scopeId; + const normalizedRequested = requestedAgentId ? normalizeAgentId(requestedAgentId) : null; + const rosterIds = new Set(agentsList?.agents.map((agent) => normalizeAgentId(agent.id)) ?? []); + const agentId = + normalizedRequested && rosterIds.has(normalizedRequested) + ? normalizedRequested + : normalizeAgentId(agentsList?.defaultId ?? agentsList?.agents[0]?.id ?? "main"); if (!client) { - return { data: EMPTY_MODEL_PROVIDERS_DATA, client: null }; + return { data: EMPTY_MODEL_PROVIDERS_DATA, client: null, agentId }; } - return { data: await loadModelProvidersData(client), client }; + return { data: await loadModelProvidersData(client, { agentId }), client, agentId }; } export const page = definePage({ diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index e1c5d87be049..6ba4eff586b3 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -31,6 +31,7 @@ function props(overrides: Partial = {}): ModelProviders error: null, updatedAt: 1, costDays: 30, + credentialAgentLabel: "Writer", cards: [card()], configuredModels: [{ id: "openai/gpt-5", provider: "openai", name: "GPT-5", available: true }], defaultModels: { primary: "openai/gpt-5", fallbacks: [], utilityModel: null }, @@ -122,12 +123,31 @@ describe("renderModelProviders", () => { }), ); const provider = container.querySelector('[data-provider-id="openai"]'); + expect(text(provider)).toContain("Credentials for Writer"); + expect(text(provider)).toContain("Global usage and cost"); expect(text(provider)).toContain("API key from environment (OPENAI_API_KEY)"); expect(text(provider)).toContain("Connected"); expect(text(provider)).toContain("145 ms"); expect(text(provider)).toContain("Default profile"); }); + it("labels provider usage and session cost as global", () => { + const container = mount( + props({ + cards: [ + card({ + localCost: { totalCost: 12, totalTokens: 1_000, sessionCount: 2 }, + }), + ], + }), + ); + + const provider = container.querySelector('[data-provider-id="openai"]'); + expect(text(provider)).toContain("Credentials for Writer"); + expect(text(provider)).toContain("Global usage and cost"); + expect(text(provider)).toContain("Global session spend · 30d"); + }); + it("shows config key provenance when auth status is unavailable", () => { const container = mount( props({ diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 8545f846ecf1..88d3bfdf9e5d 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -34,6 +34,7 @@ type ModelProvidersViewProps = { error: string | null; updatedAt: number | null; costDays: number; + credentialAgentLabel: string; cards: ModelProviderCard[]; configuredModels: ModelPickerEntry[]; defaultModels: DefaultModelSelection; @@ -149,7 +150,7 @@ function renderLocalCost(card: ModelProviderCard, costDays: number) { `; } -function renderCredentialSummary(card: ModelProviderCard) { +function renderCredentialSummary(card: ModelProviderCard, agentLabel: string) { const oauthCount = card.profiles.filter((profile) => profile.type === "oauth").length; const tokenCount = card.profiles.filter((profile) => profile.type === "token").length; const apiProfileCount = card.profiles.filter((profile) => profile.type === "api_key").length; @@ -173,7 +174,7 @@ function renderCredentialSummary(card: ModelProviderCard) { } return html`
- ${t("modelProviders.credentials.label")} + ${t("modelProviders.credentials.label", { agent: agentLabel })} ${parts.length > 0 ? parts.join(" · ") : t("modelProviders.credentials.none")} @@ -373,12 +374,16 @@ function renderProviderRow(card: ModelProviderCard, props: ModelProvidersViewPro ${renderAuthStatus(card)}
- ${renderCredentialSummary(card)} - ${card.usage - ? renderProviderUsageDetails(card.usage) - : html`
${t("modelProviders.noStats")}
`} - ${renderLocalCost(card, props.costDays)} ${renderProviderActions(card, props)} - ${renderKeyEditor(card, props)} ${renderProbeResult(props.probeResults[card.id])} + ${renderCredentialSummary(card, props.credentialAgentLabel)} +
+
${t("modelProviders.globalUsage")}
+ ${card.usage + ? renderProviderUsageDetails(card.usage) + : html`
${t("modelProviders.noStats")}
`} + ${renderLocalCost(card, props.costDays)} +
+ ${renderProviderActions(card, props)} ${renderKeyEditor(card, props)} + ${renderProbeResult(props.probeResults[card.id])} ${message ? html`
${message.text}
` : nothing} diff --git a/ui/src/pages/route-provenance.test.ts b/ui/src/pages/route-provenance.test.ts index 690eec04c951..84d394e6fda6 100644 --- a/ui/src/pages/route-provenance.test.ts +++ b/ui/src/pages/route-provenance.test.ts @@ -166,6 +166,16 @@ describe("route preload gateway provenance", () => { const mutable = mutableGateway(snapshot(originalClient, true)); const request = loadRoute(modelProvidersPage, { gateway: mutable.gateway, + agents: { + state: { agentsList: null }, + ensureList: vi.fn(async () => ({ + defaultId: "main", + mainKey: "main", + scope: "project", + agents: [{ id: "main" }], + })), + }, + agentSelection: { state: { selectedId: null, scopeId: null } }, } as unknown as ApplicationContext); mutable.replaceSnapshot(snapshot(replacementClient, true)); diff --git a/ui/src/styles/model-providers.css b/ui/src/styles/model-providers.css index 4ce71bab775b..e93c793f3e4f 100644 --- a/ui/src/styles/model-providers.css +++ b/ui/src/styles/model-providers.css @@ -40,6 +40,19 @@ padding-top: var(--space-3); } +.model-providers__global-metrics { + display: grid; + gap: var(--space-3); +} + +.model-providers__global-metrics-title { + color: var(--muted); + font-size: var(--control-ui-text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + .model-providers__layout, .model-providers__fallbacks, .model-providers__inline-form,