mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
7cafe35bd5
commit
d5a8233e76
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
`;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user