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
This commit is contained in:
Peter Steinberger
2026-07-20 16:22:09 -07:00
committed by GitHub
parent 7cafe35bd5
commit d5a8233e76
30 changed files with 1076 additions and 63 deletions
@@ -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)
}
}
}
@@ -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;
+7 -1
View File
@@ -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", () => {
+6
View File
@@ -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,
@@ -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",
@@ -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<typeof AgentsListResultSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
export type ModelsAuthStatusParams = Static<typeof ModelsAuthStatusParamsSchema>;
export type ModelsAuthLogoutParams = Static<typeof ModelsAuthLogoutParamsSchema>;
export type AuthProbeStatus = Static<typeof AuthProbeStatusSchema>;
export type ModelsProbeParams = Static<typeof ModelsProbeParamsSchema>;
export type ModelsProbeTargetResult = Static<typeof ModelsProbeTargetResultSchema>;
@@ -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({
@@ -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. */
@@ -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,
+1
View File
@@ -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",
" }",
" }",
"",
+21
View File
@@ -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", () => {
+15 -5
View File
@@ -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, {
@@ -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 });
}
@@ -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: {
@@ -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<string, { ts: number; result: ModelAuthStatusResult }>();
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) {
@@ -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<typeof import("../../commands/models/list.probe.js")>(
"../../commands/models/list.probe.js",
@@ -51,6 +62,18 @@ function createOptions(params: Record<string, unknown>, 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: {
@@ -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.<id> 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: {
@@ -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");
});
});
+17 -4
View File
@@ -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);
}}
>
<option value="">${t("agentScope.allAgents")}</option>
${selectedAgentMissing ? html`<option value=${selected}>${selected}</option>` : null}
${allowAll
? html`<option value="" ?selected=${selected === ""}>
${t("agentScope.allAgents")}
</option>`
: null}
${selectedAgentMissing
? html`<option value=${selected} selected>${selected}</option>`
: null}
${agents.map(
(agent) => html`<option value=${agent.id}>${normalizeAgentLabel(agent)}</option>`,
(agent) => html`
<option value=${agent.id} ?selected=${agent.id === selected}>
${normalizeAgentLabel(agent)}
</option>
`,
)}
</select>
</label>
+3 -2
View File
@@ -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",
+5 -2
View File
@@ -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<ModelAuthStatusResult> {
const params = opts?.refresh ? { refresh: true } : {};
const params = {
...(opts?.refresh ? { refresh: true } : {}),
...(opts?.agentId ? { agentId: opts.agentId } : {}),
};
return (
(await client.request<ModelAuthStatusResult>("models.authStatus", params)) ?? EMPTY_AUTH_STATUS
);
+36
View File
@@ -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");
});
});
+1 -1
View File
@@ -57,7 +57,7 @@ function errorMessage(error: unknown): string {
export async function loadModelProvidersData(
client: GatewayBrowserClient,
opts?: { refresh?: boolean },
opts?: { refresh?: boolean; agentId?: string },
): Promise<ModelProvidersData> {
const [authStatus, models, catalogModels, config, providerUsage, costByProvider] =
await Promise.all([
@@ -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<boolean>;
busy: Record<string, boolean>;
data: ModelProvidersData | null;
probe: (cardId: string, providers: string[]) => Promise<void>;
probeResults: Record<string, ModelsProbeResult>;
refreshQueue: Promise<void>;
refreshing: boolean;
routeData: ModelProvidersRouteData | undefined;
selectedAgentId: string;
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createHarness(initialScopeId: string) {
let pendingAuthStatus: Promise<void> | null = null;
let releaseAuthStatus: (() => void) | null = null;
const deferNextAuthStatus = () => {
pendingAuthStatus = new Promise<void>((resolve) => {
releaseAuthStatus = resolve;
});
return () => releaseAuthStatus?.();
};
const request = vi.fn(async (method: string): Promise<unknown> => {
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<ModelsProbeResult>();
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({});
});
});
@@ -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<void> = Promise.resolve();
private probeEpochs = new Map<string, number>();
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<void> {
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<ModelsProbeResult>("models.probe", { provider }));
results.push(
await client.request<ModelsProbeResult>("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 {
<div>
<div class="page-title">${titleForRoute("model-providers")}</div>
</div>
<button class="btn" @click=${() => this.context.navigate("model-setup")}>
${t("modelSetup.heading")}
</button>
<div class="page-header-actions">
${renderAgentScopeControl({
agents,
selection: this.context.agentSelection,
allowAll: false,
selectedId: this.selectedAgentId,
})}
<button class="btn" @click=${() => this.context.navigate("model-setup")}>
${t("modelSetup.heading")}
</button>
</div>
</section>
${renderSettingsWorkspace(body)}
`;
+12 -2
View File
@@ -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({
+20
View File
@@ -31,6 +31,7 @@ function props(overrides: Partial<ModelProvidersViewProps> = {}): 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({
+13 -8
View File
@@ -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`
<div class="model-providers__credentials">
<span>${t("modelProviders.credentials.label")}</span>
<span>${t("modelProviders.credentials.label", { agent: agentLabel })}</span>
<strong
>${parts.length > 0 ? parts.join(" · ") : t("modelProviders.credentials.none")}</strong
>
@@ -373,12 +374,16 @@ function renderProviderRow(card: ModelProviderCard, props: ModelProvidersViewPro
${renderAuthStatus(card)}
</div>
</div>
${renderCredentialSummary(card)}
${card.usage
? renderProviderUsageDetails(card.usage)
: html`<div class="model-providers__no-stats">${t("modelProviders.noStats")}</div>`}
${renderLocalCost(card, props.costDays)} ${renderProviderActions(card, props)}
${renderKeyEditor(card, props)} ${renderProbeResult(props.probeResults[card.id])}
${renderCredentialSummary(card, props.credentialAgentLabel)}
<div class="model-providers__global-metrics">
<div class="model-providers__global-metrics-title">${t("modelProviders.globalUsage")}</div>
${card.usage
? renderProviderUsageDetails(card.usage)
: html`<div class="model-providers__no-stats">${t("modelProviders.noStats")}</div>`}
${renderLocalCost(card, props.costDays)}
</div>
${renderProviderActions(card, props)} ${renderKeyEditor(card, props)}
${renderProbeResult(props.probeResults[card.id])}
${message
? html`<div class="callout ${message.kind}" role="status">${message.text}</div>`
: nothing}
+10
View File
@@ -166,6 +166,16 @@ describe("route preload gateway provenance", () => {
const mutable = mutableGateway(snapshot(originalClient, true));
const request = loadRoute<ModelProvidersRouteData>(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));
+13
View File
@@ -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,