mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
feat(ui): add Model Auth status card to Overview dashboard (#66211)
* feat(gateway,ui): add Model Auth status card to Overview Adds a new `models.authStatus` gateway endpoint that combines `buildAuthHealthSummary()` (token expiry/status) with `loadProviderUsageSummary()` (rate limits) into a single response suitable for UI rendering. Strips credentials - only ships status, expiry, remaining time, and rate-limit windows. Adds a corresponding "Model Auth" card to the Overview dashboard showing provider token status and rate limits at a glance. Attention items are raised when OAuth tokens are expiring or expired. Also catches the OAuth token sink class of bug: if multiple profiles exist per provider/account and tokens are drifting out of sync, this surfaces it immediately in the dashboard instead of silently falling back to a different provider. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * CHANGELOG: note Model Auth status card on Overview * UI/Overview: render Model Auth card during load with N/A placeholder * models.authStatus: env-backed OAuth escape hatch + expectsOAuth missing signal --------- Co-authored-by: Lobster <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.
|
||||
- Agents/local models: add `agents.defaults.localModelMode: "lean"` to drop heavyweight default tools like `browser`, `cron`, and `message`, reducing prompt size for weaker local-model setups without changing the normal path. Thanks @ImLukeF.
|
||||
- QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.
|
||||
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"tts.providers",
|
||||
"commands.list",
|
||||
"models.list",
|
||||
"models.authStatus",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"agents.list",
|
||||
|
||||
@@ -50,6 +50,7 @@ const BASE_METHODS = [
|
||||
"talk.mode",
|
||||
"commands.list",
|
||||
"models.list",
|
||||
"models.authStatus",
|
||||
"tools.catalog",
|
||||
"tools.effective",
|
||||
"agents.list",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { doctorHandlers } from "./server-methods/doctor.js";
|
||||
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
|
||||
import { healthHandlers } from "./server-methods/health.js";
|
||||
import { logsHandlers } from "./server-methods/logs.js";
|
||||
import { modelsAuthStatusHandlers } from "./server-methods/models-auth-status.js";
|
||||
import { modelsHandlers } from "./server-methods/models.js";
|
||||
import { nodePendingHandlers } from "./server-methods/nodes-pending.js";
|
||||
import { nodeHandlers } from "./server-methods/nodes.js";
|
||||
@@ -80,6 +81,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...execApprovalsHandlers,
|
||||
...webHandlers,
|
||||
...modelsHandlers,
|
||||
...modelsAuthStatusHandlers,
|
||||
...configHandlers,
|
||||
...wizardHandlers,
|
||||
...talkHandlers,
|
||||
|
||||
495
src/gateway/server-methods/models-auth-status.test.ts
Normal file
495
src/gateway/server-methods/models-auth-status.test.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthHealthSummary } from "../../agents/auth-health.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })),
|
||||
buildAuthHealthSummary: vi.fn(
|
||||
(): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }),
|
||||
),
|
||||
loadProviderUsageSummary: vi.fn(async () => ({ updatedAt: 0, providers: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-profiles.js", () => ({
|
||||
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-health.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../agents/auth-health.js")>(
|
||||
"../../agents/auth-health.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
buildAuthHealthSummary: mocks.buildAuthHealthSummary,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/provider-usage.load.js", () => ({
|
||||
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
|
||||
}));
|
||||
|
||||
import {
|
||||
aggregateOAuthStatus,
|
||||
invalidateModelAuthStatusCache,
|
||||
modelsAuthStatusHandlers,
|
||||
type ModelAuthStatusResult,
|
||||
} from "./models-auth-status.js";
|
||||
|
||||
function createOptions(
|
||||
params: Record<string, unknown> = {},
|
||||
): GatewayRequestHandlerOptions & { respond: ReturnType<typeof vi.fn> } {
|
||||
const respond = vi.fn();
|
||||
return {
|
||||
req: { type: "req", id: "req-1", method: "models.authStatus", params },
|
||||
params,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {} as unknown,
|
||||
} as unknown as GatewayRequestHandlerOptions & { respond: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
const handler = modelsAuthStatusHandlers["models.authStatus"];
|
||||
|
||||
describe("models.authStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateModelAuthStatusCache();
|
||||
mocks.loadConfig.mockReturnValue({});
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({ profiles: {} });
|
||||
mocks.buildAuthHealthSummary.mockReturnValue({
|
||||
now: 0,
|
||||
warnAfterMs: 0,
|
||||
profiles: [],
|
||||
providers: [],
|
||||
});
|
||||
mocks.loadProviderUsageSummary.mockResolvedValue({ updatedAt: 0, providers: [] });
|
||||
});
|
||||
|
||||
it("returns a serialisable snapshot on first call", async () => {
|
||||
mocks.buildAuthHealthSummary.mockReturnValue({
|
||||
now: 0,
|
||||
warnAfterMs: 0,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai-codex:default",
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
status: "ok",
|
||||
expiresAt: 1_000_000,
|
||||
remainingMs: 60_000,
|
||||
source: "store",
|
||||
label: "openai-codex:default",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "ok",
|
||||
expiresAt: 1_000_000,
|
||||
remainingMs: 60_000,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai-codex:default",
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
status: "ok",
|
||||
expiresAt: 1_000_000,
|
||||
remainingMs: 60_000,
|
||||
source: "store",
|
||||
label: "openai-codex:default",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const opts = createOptions();
|
||||
await handler(opts);
|
||||
|
||||
expect(opts.respond).toHaveBeenCalledTimes(1);
|
||||
const [ok, payload, error] = opts.respond.mock.calls[0] ?? [];
|
||||
expect(ok).toBe(true);
|
||||
expect(error).toBeUndefined();
|
||||
const result = payload as ModelAuthStatusResult;
|
||||
expect(result.providers).toHaveLength(1);
|
||||
expect(result.providers[0].provider).toBe("openai-codex");
|
||||
expect(result.providers[0].status).toBe("ok");
|
||||
expect(result.providers[0].expiry?.at).toBe(1_000_000);
|
||||
expect(result.providers[0].profiles[0].type).toBe("oauth");
|
||||
});
|
||||
|
||||
it("serves cached response within TTL and marks it as cached", async () => {
|
||||
const opts1 = createOptions();
|
||||
await handler(opts1);
|
||||
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
const opts2 = createOptions();
|
||||
await handler(opts2);
|
||||
|
||||
// Auth health should NOT be re-queried on the cached call.
|
||||
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
const lastCall = opts2.respond.mock.calls.at(-1);
|
||||
expect(lastCall?.[3]).toEqual(expect.objectContaining({ cached: true }));
|
||||
});
|
||||
|
||||
it("bypasses cache when params.refresh is set", async () => {
|
||||
await handler(createOptions());
|
||||
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
await handler(createOptions({ refresh: true }));
|
||||
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("invalidateModelAuthStatusCache() clears the cached response", async () => {
|
||||
await handler(createOptions());
|
||||
invalidateModelAuthStatusCache();
|
||||
await handler(createOptions());
|
||||
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not query usage for api-key-only providers", async () => {
|
||||
mocks.buildAuthHealthSummary.mockReturnValue({
|
||||
now: 0,
|
||||
warnAfterMs: 0,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
type: "api_key",
|
||||
status: "static",
|
||||
source: "store",
|
||||
label: "anthropic:default",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
status: "static",
|
||||
profiles: [
|
||||
{
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
type: "api_key",
|
||||
status: "static",
|
||||
source: "store",
|
||||
label: "anthropic:default",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await handler(createOptions());
|
||||
expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still returns providers when usage fetch fails", async () => {
|
||||
mocks.buildAuthHealthSummary.mockReturnValue({
|
||||
now: 0,
|
||||
warnAfterMs: 0,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai-codex:default",
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
status: "ok",
|
||||
expiresAt: 1_000_000,
|
||||
remainingMs: 60_000,
|
||||
source: "store",
|
||||
label: "openai-codex:default",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "ok",
|
||||
expiresAt: 1_000_000,
|
||||
remainingMs: 60_000,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai-codex:default",
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
status: "ok",
|
||||
expiresAt: 1_000_000,
|
||||
remainingMs: 60_000,
|
||||
source: "store",
|
||||
label: "openai-codex:default",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.loadProviderUsageSummary.mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const opts = createOptions();
|
||||
await handler(opts);
|
||||
|
||||
const [ok, payload] = opts.respond.mock.calls[0] ?? [];
|
||||
expect(ok).toBe(true);
|
||||
const result = payload as ModelAuthStatusResult;
|
||||
expect(result.providers).toHaveLength(1);
|
||||
expect(result.providers[0].usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not leak secret-looking fields from upstream profile data", async () => {
|
||||
mocks.buildAuthHealthSummary.mockReturnValue({
|
||||
now: 0,
|
||||
warnAfterMs: 0,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai-codex:default",
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
status: "ok",
|
||||
expiresAt: 1,
|
||||
remainingMs: 1,
|
||||
source: "store",
|
||||
label: "openai-codex:default",
|
||||
// Simulate a future profile shape that includes an access token —
|
||||
// the handler must NOT forward this, since it field-maps explicitly.
|
||||
access: "sk-SECRET-TOKEN",
|
||||
refresh: "rt-SECRET-REFRESH",
|
||||
} as never,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "ok",
|
||||
expiresAt: 1,
|
||||
remainingMs: 1,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai-codex:default",
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
status: "ok",
|
||||
expiresAt: 1,
|
||||
remainingMs: 1,
|
||||
source: "store",
|
||||
label: "openai-codex:default",
|
||||
access: "sk-SECRET-TOKEN",
|
||||
refresh: "rt-SECRET-REFRESH",
|
||||
} as never,
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const opts = createOptions();
|
||||
await handler(opts);
|
||||
const [, payload] = opts.respond.mock.calls[0] ?? [];
|
||||
const serialised = JSON.stringify(payload);
|
||||
expect(serialised).not.toContain("sk-SECRET-TOKEN");
|
||||
expect(serialised).not.toContain("rt-SECRET-REFRESH");
|
||||
});
|
||||
|
||||
it("skips env-backed OAuth providers (apiKey set in config) from missing synthesis", async () => {
|
||||
// Provider configured `auth: "oauth"` with `apiKey` present (env-backed)
|
||||
// must not be forwarded to buildAuthHealthSummary — doing so would flag
|
||||
// it as missing even though env auth already satisfies it.
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": { auth: "oauth", apiKey: { env: "OPENAI_OAUTH_TOKEN" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
await handler(createOptions());
|
||||
// When the only configured provider is env-backed, we pass `undefined`
|
||||
// (meaning "no filter"), not a filter containing it.
|
||||
const call = mocks.buildAuthHealthSummary.mock.calls[0] as unknown as
|
||||
| [{ providers?: string[] }]
|
||||
| undefined;
|
||||
expect(call?.[0]?.providers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags provider configured auth:oauth but with only api_key profile as missing", async () => {
|
||||
// Config says provider should use OAuth; store has only an api_key
|
||||
// credential (e.g. operator switched modes but forgot to login).
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
models: { providers: { anthropic: { auth: "oauth" } } },
|
||||
});
|
||||
mocks.buildAuthHealthSummary.mockReturnValue({
|
||||
now: 0,
|
||||
warnAfterMs: 0,
|
||||
profiles: [],
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
status: "static",
|
||||
profiles: [
|
||||
{
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
type: "api_key",
|
||||
status: "static",
|
||||
source: "store",
|
||||
label: "anthropic:default",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const opts = createOptions();
|
||||
await handler(opts);
|
||||
const [, payload] = opts.respond.mock.calls[0] ?? [];
|
||||
const result = payload as ModelAuthStatusResult;
|
||||
expect(result.providers[0]?.status).toBe("missing");
|
||||
});
|
||||
|
||||
it("responds with UNAVAILABLE when buildAuthHealthSummary throws", async () => {
|
||||
mocks.buildAuthHealthSummary.mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const opts = createOptions();
|
||||
await handler(opts);
|
||||
const [ok, payload, error] = opts.respond.mock.calls[0] ?? [];
|
||||
expect(ok).toBe(false);
|
||||
expect(payload).toBeUndefined();
|
||||
expect(error).toEqual(expect.objectContaining({ code: expect.stringMatching(/unavailable/i) }));
|
||||
});
|
||||
});
|
||||
|
||||
// Direct unit tests for aggregateOAuthStatus — this helper was introduced to
|
||||
// prevent a specific regression (mixed OAuth+token rollup mis-reporting
|
||||
// providers). Pinning its behavior here so refactors can't silently re-break
|
||||
// the same bug.
|
||||
describe("aggregateOAuthStatus", () => {
|
||||
const NOW = 1_000_000;
|
||||
const expiring = NOW + 60_000; // 1 min in future
|
||||
|
||||
function oauth(status: "ok" | "expiring" | "expired" | "missing", expiresAt?: number) {
|
||||
return {
|
||||
profileId: `p-${status}`,
|
||||
provider: "openai-codex",
|
||||
type: "oauth" as const,
|
||||
status,
|
||||
expiresAt,
|
||||
remainingMs: expiresAt !== undefined ? expiresAt - NOW : undefined,
|
||||
source: "store" as const,
|
||||
label: `p-${status}`,
|
||||
};
|
||||
}
|
||||
|
||||
function token(status: "ok" | "expired") {
|
||||
return {
|
||||
profileId: `t-${status}`,
|
||||
provider: "openai-codex",
|
||||
type: "token" as const,
|
||||
status,
|
||||
expiresAt: status === "expired" ? NOW - 1 : undefined,
|
||||
remainingMs: status === "expired" ? -1 : undefined,
|
||||
source: "store" as const,
|
||||
label: `t-${status}`,
|
||||
};
|
||||
}
|
||||
|
||||
it("ignores token profiles — healthy OAuth + expired token stays ok", () => {
|
||||
const result = aggregateOAuthStatus(
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "expired",
|
||||
profiles: [oauth("ok", expiring + 10_000_000), token("expired")],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(result.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("falls back to prov.status when no OAuth profiles exist", () => {
|
||||
const result = aggregateOAuthStatus(
|
||||
{
|
||||
provider: "anthropic",
|
||||
status: "static",
|
||||
profiles: [
|
||||
{
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
type: "api_key",
|
||||
status: "static",
|
||||
source: "store",
|
||||
label: "anthropic:default",
|
||||
},
|
||||
],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(result.status).toBe("static");
|
||||
});
|
||||
|
||||
it("expired + missing both map to 'expired'", () => {
|
||||
const expiredResult = aggregateOAuthStatus(
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "expired",
|
||||
profiles: [oauth("expired", NOW - 1)],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(expiredResult.status).toBe("expired");
|
||||
|
||||
const missingResult = aggregateOAuthStatus(
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "missing",
|
||||
profiles: [oauth("missing")],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(missingResult.status).toBe("expired");
|
||||
});
|
||||
|
||||
it("precedence: expired/missing > expiring > ok > static", () => {
|
||||
// expiring + ok → expiring (expired-marker absent)
|
||||
const res1 = aggregateOAuthStatus(
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "expiring",
|
||||
profiles: [oauth("expiring", expiring), oauth("ok", expiring + 10_000_000)],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(res1.status).toBe("expiring");
|
||||
|
||||
// expired beats expiring
|
||||
const res2 = aggregateOAuthStatus(
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "expired",
|
||||
profiles: [oauth("expired", NOW - 1), oauth("expiring", expiring)],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(res2.status).toBe("expired");
|
||||
});
|
||||
|
||||
it("picks the earliest expiresAt across OAuth profiles", () => {
|
||||
const earlier = NOW + 1_000;
|
||||
const later = NOW + 99_999;
|
||||
const result = aggregateOAuthStatus(
|
||||
{
|
||||
provider: "openai-codex",
|
||||
status: "ok",
|
||||
profiles: [oauth("ok", later), oauth("ok", earlier)],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(result.expiresAt).toBe(earlier);
|
||||
expect(result.remainingMs).toBe(1_000);
|
||||
});
|
||||
});
|
||||
303
src/gateway/server-methods/models-auth-status.ts
Normal file
303
src/gateway/server-methods/models-auth-status.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
type AuthHealthSummary,
|
||||
type AuthProfileHealthStatus,
|
||||
type AuthProviderHealth,
|
||||
type AuthProviderHealthStatus,
|
||||
buildAuthHealthSummary,
|
||||
formatRemainingShort,
|
||||
} from "../../agents/auth-health.js";
|
||||
import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../../config/config.js";
|
||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js";
|
||||
import { PROVIDER_LABELS, resolveUsageProviderId } from "../../infra/provider-usage.shared.js";
|
||||
import type { UsageProviderId, UsageWindow } from "../../infra/provider-usage.types.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("models-auth-status");
|
||||
|
||||
/** The `ts` sentinel the UI uses to distinguish "never loaded" from "load failed". */
|
||||
export const MODEL_AUTH_STATUS_NEVER_LOADED = 0;
|
||||
|
||||
/**
|
||||
* Models-auth status wire types. Mirrored in ui/src/ui/types.ts via an
|
||||
* `import(...)` re-export — edit here and the UI picks up the change.
|
||||
*
|
||||
* Expiry fields are grouped into a sub-object so they're present together or
|
||||
* not at all: a profile either has a time-bounded credential or it doesn't.
|
||||
*/
|
||||
export type ModelAuthExpiry = {
|
||||
/** Absolute expiry timestamp, ms since epoch. */
|
||||
at: number;
|
||||
/** Remaining time in ms (negative if already expired). */
|
||||
remainingMs: number;
|
||||
/** Human-readable remaining time (e.g. "10d", "2h", "45m"). */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ModelAuthStatusProfile = {
|
||||
profileId: string;
|
||||
type: "oauth" | "token" | "api_key";
|
||||
status: AuthProfileHealthStatus;
|
||||
expiry?: ModelAuthExpiry;
|
||||
};
|
||||
|
||||
export type ModelAuthStatusProvider = {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
status: AuthProviderHealthStatus;
|
||||
expiry?: ModelAuthExpiry;
|
||||
profiles: ModelAuthStatusProfile[];
|
||||
usage?: {
|
||||
windows: UsageWindow[];
|
||||
plan?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ModelAuthStatusResult = {
|
||||
/** Snapshot build time, ms since epoch. 0 = never loaded (UI fallback sentinel). */
|
||||
ts: number;
|
||||
providers: ModelAuthStatusProvider[];
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
let cached: { ts: number; result: ModelAuthStatusResult } | null = null;
|
||||
|
||||
/**
|
||||
* Invalidate the in-memory cache. Reserved for future gateway-side auth
|
||||
* mutation handlers (login, logout, token rotation) so the next read returns
|
||||
* fresh data. Today those mutations happen via the CLI and the 60s TTL plus
|
||||
* `{refresh: true}` param cover the stale-data window.
|
||||
*/
|
||||
export function invalidateModelAuthStatusCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
function buildExpiry(
|
||||
remainingMs: number | undefined,
|
||||
expiresAt: number | undefined,
|
||||
): ModelAuthExpiry | undefined {
|
||||
if (
|
||||
typeof expiresAt !== "number" ||
|
||||
!Number.isFinite(expiresAt) ||
|
||||
typeof remainingMs !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { at: expiresAt, remainingMs, label: formatRemainingShort(remainingMs) };
|
||||
}
|
||||
|
||||
function providerDisplayName(provider: string): string {
|
||||
const usageId = resolveUsageProviderId(provider);
|
||||
if (usageId && PROVIDER_LABELS[usageId]) {
|
||||
return PROVIDER_LABELS[usageId];
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate provider status from OAuth profiles only. `buildAuthHealthSummary`
|
||||
* rolls up across both OAuth and token profiles, which mis-reports providers
|
||||
* where a healthy OAuth sits alongside an expired/missing bearer token.
|
||||
* For the dashboard's OAuth-health signal, token profiles are a separate
|
||||
* concern — we want "is OAuth healthy?", not "is every credential healthy?"
|
||||
*
|
||||
* `expectsOAuth` surfaces the configured-OAuth-but-no-oauth-profile case as
|
||||
* `missing` instead of silently falling back to the provider's rollup (which
|
||||
* would report `static` if only api_key credentials exist). Without this,
|
||||
* switching a provider from api_key to oauth in config but forgetting to
|
||||
* login hides behind the residual api_key profile until runtime fails.
|
||||
*
|
||||
* Exported for direct unit testing of the rollup rules.
|
||||
*/
|
||||
export function aggregateOAuthStatus(
|
||||
prov: AuthProviderHealth,
|
||||
now: number = Date.now(),
|
||||
expectsOAuth = false,
|
||||
): {
|
||||
status: AuthProviderHealthStatus;
|
||||
expiresAt?: number;
|
||||
remainingMs?: number;
|
||||
} {
|
||||
const oauth = prov.profiles.filter((p) => p.type === "oauth");
|
||||
if (oauth.length === 0) {
|
||||
if (expectsOAuth) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
return { status: prov.status, expiresAt: prov.expiresAt, remainingMs: prov.remainingMs };
|
||||
}
|
||||
const statuses = new Set<AuthProfileHealthStatus>(oauth.map((p) => p.status));
|
||||
// Priority: expired/missing > expiring > ok > static. Exhaustive — if a
|
||||
// new AuthProfileHealthStatus variant is added, the `never` check fires.
|
||||
let status: AuthProviderHealthStatus;
|
||||
if (statuses.has("expired") || statuses.has("missing")) {
|
||||
status = "expired";
|
||||
} else if (statuses.has("expiring")) {
|
||||
status = "expiring";
|
||||
} else if (statuses.has("ok")) {
|
||||
status = "ok";
|
||||
} else if (statuses.has("static")) {
|
||||
status = "static";
|
||||
} else {
|
||||
// Compile-time guard: exhaustiveness over AuthProfileHealthStatus. If
|
||||
// auth-health ever adds a new variant without updating this rollup,
|
||||
// TypeScript will fail the `never` assignment.
|
||||
const _exhaustive: never = Array.from(statuses)[0] as never;
|
||||
void _exhaustive;
|
||||
status = "static";
|
||||
}
|
||||
const expirable = oauth
|
||||
.map((p) => p.expiresAt)
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
||||
const expiresAt = expirable.length > 0 ? Math.min(...expirable) : undefined;
|
||||
const remainingMs = expiresAt !== undefined ? expiresAt - now : undefined;
|
||||
return { status, expiresAt, remainingMs };
|
||||
}
|
||||
|
||||
function mapProvider(
|
||||
prov: AuthProviderHealth,
|
||||
usageByProvider: Map<string, { windows: UsageWindow[]; plan?: string }>,
|
||||
expectsOAuthSet: Set<string>,
|
||||
): ModelAuthStatusProvider {
|
||||
const usageKey = resolveUsageProviderId(prov.provider);
|
||||
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
||||
const rollup = aggregateOAuthStatus(prov, Date.now(), expectsOAuthSet.has(prov.provider));
|
||||
return {
|
||||
provider: prov.provider,
|
||||
displayName: providerDisplayName(prov.provider),
|
||||
status: rollup.status,
|
||||
expiry: buildExpiry(rollup.remainingMs, rollup.expiresAt),
|
||||
profiles: prov.profiles.map((prof) => ({
|
||||
profileId: prof.profileId,
|
||||
type: prof.type,
|
||||
status: prof.status,
|
||||
expiry: buildExpiry(prof.remainingMs, prof.expiresAt),
|
||||
})),
|
||||
usage: usage ? { windows: usage.windows, plan: usage.plan } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect provider IDs with refreshable credentials (OAuth or bearer token)
|
||||
* so a configured-but-not-logged-in provider surfaces as `missing` rather
|
||||
* than being silently absent. API-key and AWS-SDK providers are excluded —
|
||||
* their credentials don't expire on a schedule this endpoint can meaningfully
|
||||
* monitor, and surfacing them here would flash a red alert on a healthy
|
||||
* API-key setup.
|
||||
*
|
||||
* Providers with `models.providers.<id>.apiKey` set (commonly via a
|
||||
* SecretRef env binding) are excluded from the "missing" synthesis even
|
||||
* when their `auth` mode is `oauth` or `token` — an env-backed credential
|
||||
* is already present, so flagging the dashboard as missing would cry wolf
|
||||
* for a working auth path. They can still show up with real status if the
|
||||
* profile store has an entry for them.
|
||||
*/
|
||||
function resolveConfiguredProviders(cfg: OpenClawConfig): {
|
||||
providers: string[];
|
||||
expectsOAuth: Set<string>;
|
||||
} {
|
||||
const out = new Set<string>();
|
||||
const expectsOAuth = new Set<string>();
|
||||
for (const [id, provider] of Object.entries(cfg.models?.providers ?? {})) {
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
// Only include providers whose configured auth mode is refreshable.
|
||||
// `undefined` / "api-key" / "aws-sdk" are deliberately skipped.
|
||||
const mode = provider?.auth;
|
||||
if (mode !== "oauth" && mode !== "token") {
|
||||
continue;
|
||||
}
|
||||
// Env-backed credential escape hatch — see JSDoc.
|
||||
const hasEnvCredential = provider?.apiKey !== undefined && provider?.apiKey !== null;
|
||||
if (hasEnvCredential) {
|
||||
continue;
|
||||
}
|
||||
out.add(id);
|
||||
if (mode === "oauth") {
|
||||
expectsOAuth.add(id);
|
||||
}
|
||||
}
|
||||
// auth.profiles entries explicitly opt into the refreshable set via
|
||||
// `mode: oauth | token`. api_key profiles are excluded (no lifecycle).
|
||||
for (const profile of Object.values(cfg.auth?.profiles ?? {})) {
|
||||
const provider = profile?.provider;
|
||||
const mode = profile?.mode;
|
||||
if (
|
||||
typeof provider === "string" &&
|
||||
provider.length > 0 &&
|
||||
(mode === "oauth" || mode === "token")
|
||||
) {
|
||||
out.add(provider);
|
||||
if (mode === "oauth") {
|
||||
expectsOAuth.add(provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { providers: Array.from(out), expectsOAuth };
|
||||
}
|
||||
|
||||
export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
"models.authStatus": async ({ params, respond }) => {
|
||||
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;
|
||||
}
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
const configured = resolveConfiguredProviders(cfg);
|
||||
const authHealth: AuthHealthSummary = buildAuthHealthSummary({
|
||||
store,
|
||||
cfg,
|
||||
providers: configured.providers.length > 0 ? configured.providers : undefined,
|
||||
});
|
||||
|
||||
// Usage queries only for refreshable credentials.
|
||||
const usageProviderIds = [
|
||||
...new Set(
|
||||
authHealth.profiles
|
||||
.filter((p) => p.type === "oauth" || p.type === "token")
|
||||
.map((p) => resolveUsageProviderId(p.provider))
|
||||
.filter((id): id is UsageProviderId => Boolean(id)),
|
||||
),
|
||||
];
|
||||
|
||||
const usageByProvider = new Map<string, { windows: UsageWindow[]; plan?: string }>();
|
||||
if (usageProviderIds.length > 0) {
|
||||
try {
|
||||
const usage = await loadProviderUsageSummary({
|
||||
providers: usageProviderIds,
|
||||
agentDir,
|
||||
timeoutMs: 3500,
|
||||
});
|
||||
for (const snap of usage.providers) {
|
||||
usageByProvider.set(snap.provider, { windows: snap.windows, plan: snap.plan });
|
||||
}
|
||||
} catch (err) {
|
||||
// Usage data is auxiliary — failing here must not block auth status,
|
||||
// but log at debug so a silently-broken usage endpoint is still
|
||||
// diagnosable in gateway logs.
|
||||
log.debug(
|
||||
`usage enrichment failed (auth status still returned): providers=${usageProviderIds.join(",")} error=${formatForLog(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const providers = authHealth.providers.map((prov) =>
|
||||
mapProvider(prov, usageByProvider, configured.expectsOAuth),
|
||||
);
|
||||
const result: ModelAuthStatusResult = { ts: now, providers };
|
||||
cached = { ts: now, result };
|
||||
respond(true, result, undefined);
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:26:57.674Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:04.069Z",
|
||||
"locale": "de",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:28:16.557Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:04.731Z",
|
||||
"locale": "es",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:28:13.496Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:06.978Z",
|
||||
"locale": "fr",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:29:33.233Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:09.524Z",
|
||||
"locale": "id",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:28:10.192Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:05.503Z",
|
||||
"locale": "ja-JP",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:28:20.721Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:06.233Z",
|
||||
"locale": "ko",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:29:40.606Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:10.285Z",
|
||||
"locale": "pl",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:27:05.714Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:03.307Z",
|
||||
"locale": "pt-BR",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:29:27.753Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:07.840Z",
|
||||
"locale": "tr",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:29:25.077Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:08.719Z",
|
||||
"locale": "uk",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:26:59.723Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:01.309Z",
|
||||
"locale": "zh-CN",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-04-12T05:27:02.892Z",
|
||||
"fallbackKeys": [
|
||||
"overview.cards.modelAuth",
|
||||
"overview.cards.modelAuthAttentionExpiredDesc",
|
||||
"overview.cards.modelAuthAttentionExpiredTitle",
|
||||
"overview.cards.modelAuthAttentionExpiringEntry",
|
||||
"overview.cards.modelAuthAttentionExpiringTitle",
|
||||
"overview.cards.modelAuthExpired",
|
||||
"overview.cards.modelAuthExpiresIn",
|
||||
"overview.cards.modelAuthExpiring",
|
||||
"overview.cards.modelAuthOk",
|
||||
"overview.cards.modelAuthProviders",
|
||||
"overview.cards.modelAuthUsageLeft"
|
||||
],
|
||||
"generatedAt": "2026-04-15T03:34:02.509Z",
|
||||
"locale": "zh-TW",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"sourceHash": "d343f3d591f8ed3b1500b6ec717c1a2149aaeea4b4bc0b18e1250217206dc189",
|
||||
"totalKeys": 695,
|
||||
"sourceHash": "34089e99b81e81139add4dca8eb47a80da365bd90534ac5e5e042474f3816ad6",
|
||||
"totalKeys": 706,
|
||||
"translatedKeys": 695,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
@@ -261,6 +261,17 @@ export const de: TranslationMap = {
|
||||
cost: "Kosten",
|
||||
skills: "Skills",
|
||||
recentSessions: "Letzte Sitzungen",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Aufmerksamkeit",
|
||||
|
||||
@@ -252,6 +252,17 @@ export const en: TranslationMap = {
|
||||
cost: "Cost",
|
||||
skills: "Skills",
|
||||
recentSessions: "Recent Sessions",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Attention",
|
||||
|
||||
@@ -256,6 +256,17 @@ export const es: TranslationMap = {
|
||||
cost: "Costo",
|
||||
skills: "Skills",
|
||||
recentSessions: "Sesiones recientes",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Atención",
|
||||
|
||||
@@ -259,6 +259,17 @@ export const fr: TranslationMap = {
|
||||
cost: "Coût",
|
||||
skills: "Skills",
|
||||
recentSessions: "Sessions récentes",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Attention",
|
||||
|
||||
@@ -256,6 +256,17 @@ export const id: TranslationMap = {
|
||||
cost: "Biaya",
|
||||
skills: "Skills",
|
||||
recentSessions: "Sesi Terbaru",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Perhatian",
|
||||
|
||||
@@ -260,6 +260,17 @@ export const ja_JP: TranslationMap = {
|
||||
cost: "コスト",
|
||||
skills: "Skills",
|
||||
recentSessions: "最近のセッション",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "注意",
|
||||
|
||||
@@ -255,6 +255,17 @@ export const ko: TranslationMap = {
|
||||
cost: "비용",
|
||||
skills: "Skills",
|
||||
recentSessions: "최근 세션",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "주의",
|
||||
|
||||
@@ -257,6 +257,17 @@ export const pl: TranslationMap = {
|
||||
cost: "Koszt",
|
||||
skills: "Skills",
|
||||
recentSessions: "Ostatnie sesje",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Uwaga",
|
||||
|
||||
@@ -256,6 +256,17 @@ export const pt_BR: TranslationMap = {
|
||||
cost: "Custo",
|
||||
skills: "Habilidades",
|
||||
recentSessions: "Sessões Recentes",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Atenção",
|
||||
|
||||
@@ -260,6 +260,17 @@ export const tr: TranslationMap = {
|
||||
cost: "Maliyet",
|
||||
skills: "Skills",
|
||||
recentSessions: "Son Oturumlar",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Dikkat",
|
||||
|
||||
@@ -258,6 +258,17 @@ export const uk: TranslationMap = {
|
||||
cost: "Вартість",
|
||||
skills: "Навички",
|
||||
recentSessions: "Нещодавні сеанси",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "Увага",
|
||||
|
||||
@@ -252,6 +252,17 @@ export const zh_CN: TranslationMap = {
|
||||
cost: "费用",
|
||||
skills: "技能",
|
||||
recentSessions: "最近会话",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "注意事项",
|
||||
|
||||
@@ -252,6 +252,17 @@ export const zh_TW: TranslationMap = {
|
||||
cost: "費用",
|
||||
skills: "技能",
|
||||
recentSessions: "最近會話",
|
||||
modelAuth: "Model Auth",
|
||||
modelAuthOk: "{count} ok",
|
||||
modelAuthExpired: "{count} expired",
|
||||
modelAuthExpiring: "{count} expiring",
|
||||
modelAuthProviders: "{count} providers",
|
||||
modelAuthUsageLeft: "{pct}% left",
|
||||
modelAuthExpiresIn: "expires {when}",
|
||||
modelAuthAttentionExpiredTitle: "Model auth expired",
|
||||
modelAuthAttentionExpiringTitle: "Model auth expiring soon",
|
||||
modelAuthAttentionExpiredDesc: "{providers} — re-authenticate with openclaw models auth",
|
||||
modelAuthAttentionExpiringEntry: "{provider} ({when})",
|
||||
},
|
||||
attention: {
|
||||
title: "注意事項",
|
||||
|
||||
@@ -1098,6 +1098,7 @@ export function renderApp(state: AppViewState) {
|
||||
cronNext,
|
||||
lastChannelsRefresh: state.channelsLastSuccess,
|
||||
warnQueryToken,
|
||||
modelAuthStatus: state.modelAuthStatusResult,
|
||||
usageResult: state.usageResult,
|
||||
sessionsResult: state.sessionsResult,
|
||||
skillsReport: state.skillsReport,
|
||||
@@ -1132,9 +1133,9 @@ export function renderApp(state: AppViewState) {
|
||||
state.overviewShowGatewayPassword = !state.overviewShowGatewayPassword;
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
onRefresh: () => state.loadOverview({ refresh: true }),
|
||||
onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab),
|
||||
onRefreshLogs: () => state.loadOverview(),
|
||||
onRefreshLogs: () => state.loadOverview({ refresh: true }),
|
||||
})
|
||||
: nothing}
|
||||
${state.tab === "channels"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import {
|
||||
startLogsPolling,
|
||||
@@ -34,11 +35,16 @@ import {
|
||||
} from "./controllers/dreaming.ts";
|
||||
import { loadExecApprovals, type ExecApprovalsState } from "./controllers/exec-approvals.ts";
|
||||
import { loadLogs, type LogsState } from "./controllers/logs.ts";
|
||||
import {
|
||||
loadModelAuthStatusState,
|
||||
type ModelAuthStatusState,
|
||||
} from "./controllers/model-auth-status.ts";
|
||||
import { loadNodes, type NodesState } from "./controllers/nodes.ts";
|
||||
import { loadPresence, type PresenceState } from "./controllers/presence.ts";
|
||||
import { loadSessions, type SessionsState } from "./controllers/sessions.ts";
|
||||
import { loadSkills, type SkillsState } from "./controllers/skills.ts";
|
||||
import { loadUsage, type UsageState } from "./controllers/usage.ts";
|
||||
import { isMonitoredAuthProvider } from "./model-auth-helpers.ts";
|
||||
import {
|
||||
inferBasePathFromPathname,
|
||||
normalizeBasePath,
|
||||
@@ -104,6 +110,7 @@ type SettingsAppHost = SettingsHost &
|
||||
PresenceState &
|
||||
SessionsState &
|
||||
SkillsState &
|
||||
ModelAuthStatusState &
|
||||
UsageState & {
|
||||
overviewLogCursor: number | null;
|
||||
overviewLogLines: string[];
|
||||
@@ -550,7 +557,7 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
|
||||
updateBrowserHistory(url, replace);
|
||||
}
|
||||
|
||||
export async function loadOverview(host: SettingsHost) {
|
||||
export async function loadOverview(host: SettingsHost, opts?: { refresh?: boolean }) {
|
||||
const app = host as SettingsAppHost;
|
||||
await Promise.allSettled([
|
||||
loadChannels(app, false),
|
||||
@@ -562,6 +569,9 @@ export async function loadOverview(host: SettingsHost) {
|
||||
loadSkills(app),
|
||||
loadUsage(app),
|
||||
loadOverviewLogs(app),
|
||||
// `refresh: true` bypasses the gateway's 60s auth-status cache so a
|
||||
// user-initiated refresh surfaces post-re-auth state immediately.
|
||||
loadModelAuthStatusState(app, { refresh: opts?.refresh }),
|
||||
]);
|
||||
buildAttentionItems(app);
|
||||
}
|
||||
@@ -687,6 +697,43 @@ function buildAttentionItems(host: SettingsAppHost) {
|
||||
});
|
||||
}
|
||||
|
||||
const modelAuth = host.modelAuthStatusResult;
|
||||
if (modelAuth) {
|
||||
// Use the same predicate as the Overview card so the two stay in sync.
|
||||
// Without this, a `missing` provider shows up on the card but never
|
||||
// produces the re-auth attention callout.
|
||||
const monitored = modelAuth.providers.filter(isMonitoredAuthProvider);
|
||||
const expiredProviders = monitored.filter(
|
||||
(p) => p.status === "expired" || p.status === "missing",
|
||||
);
|
||||
if (expiredProviders.length > 0) {
|
||||
items.push({
|
||||
severity: "error",
|
||||
icon: "key",
|
||||
title: t("overview.cards.modelAuthAttentionExpiredTitle"),
|
||||
description: t("overview.cards.modelAuthAttentionExpiredDesc", {
|
||||
providers: expiredProviders.map((p) => p.displayName).join(", "),
|
||||
}),
|
||||
});
|
||||
}
|
||||
const expiringProviders = monitored.filter((p) => p.status === "expiring");
|
||||
if (expiringProviders.length > 0) {
|
||||
items.push({
|
||||
severity: "warning",
|
||||
icon: "key",
|
||||
title: t("overview.cards.modelAuthAttentionExpiringTitle"),
|
||||
description: expiringProviders
|
||||
.map((p) =>
|
||||
t("overview.cards.modelAuthAttentionExpiringEntry", {
|
||||
provider: p.displayName,
|
||||
when: p.expiry?.label ?? "soon",
|
||||
}),
|
||||
)
|
||||
.join(", "),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
host.attentionItems = items;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
ChatModelOverride,
|
||||
ModelAuthStatusResult,
|
||||
ModelCatalogEntry,
|
||||
NostrProfile,
|
||||
PresenceEntry,
|
||||
@@ -328,6 +329,9 @@ export type AppViewState = {
|
||||
healthLoading: boolean;
|
||||
healthResult: HealthSummary | null;
|
||||
healthError: string | null;
|
||||
modelAuthStatusLoading: boolean;
|
||||
modelAuthStatusResult: ModelAuthStatusResult | null;
|
||||
modelAuthStatusError: string | null;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSummary | null;
|
||||
@@ -368,7 +372,7 @@ export type AppViewState = {
|
||||
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
setBorderRadius: (value: number) => void;
|
||||
applySettings: (next: UiSettings) => void;
|
||||
loadOverview: () => Promise<void>;
|
||||
loadOverview: (opts?: { refresh?: boolean }) => Promise<void>;
|
||||
loadAssistantIdentity: () => Promise<void>;
|
||||
loadCron: () => Promise<void>;
|
||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||
|
||||
@@ -92,6 +92,7 @@ import type {
|
||||
HealthSummary,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
ModelAuthStatusResult,
|
||||
ModelCatalogEntry,
|
||||
PresenceEntry,
|
||||
ChannelsStatusSnapshot,
|
||||
@@ -466,6 +467,10 @@ export class OpenClawApp extends LitElement {
|
||||
@state() healthResult: HealthSummary | null = null;
|
||||
@state() healthError: string | null = null;
|
||||
|
||||
@state() modelAuthStatusLoading = false;
|
||||
@state() modelAuthStatusResult: ModelAuthStatusResult | null = null;
|
||||
@state() modelAuthStatusError: string | null = null;
|
||||
|
||||
@state() debugLoading = false;
|
||||
@state() debugStatus: StatusSummary | null = null;
|
||||
@state() debugHealth: HealthSummary | null = null;
|
||||
@@ -657,8 +662,8 @@ export class OpenClawApp extends LitElement {
|
||||
return [active, ...rest];
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
|
||||
async loadOverview(opts?: { refresh?: boolean }) {
|
||||
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0], opts);
|
||||
}
|
||||
|
||||
async loadCron() {
|
||||
|
||||
52
ui/src/ui/controllers/model-auth-status.ts
Normal file
52
ui/src/ui/controllers/model-auth-status.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ModelAuthStatusResult } from "../types.ts";
|
||||
|
||||
const FALLBACK: ModelAuthStatusResult = { ts: 0, providers: [] };
|
||||
|
||||
export type ModelAuthStatusState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
modelAuthStatusLoading: boolean;
|
||||
modelAuthStatusResult: ModelAuthStatusResult | null;
|
||||
modelAuthStatusError: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the current auth-status snapshot. Rethrows transport errors so the
|
||||
* state wrapper can distinguish "not loaded yet" (ts === 0) from "load failed"
|
||||
* (error set).
|
||||
*
|
||||
* Pass `{ refresh: true }` to bypass the gateway's 60s cache — useful after
|
||||
* a user-initiated refresh, where serving a minute-old snapshot would
|
||||
* contradict the affordance.
|
||||
*/
|
||||
export async function loadModelAuthStatus(
|
||||
client: GatewayBrowserClient,
|
||||
opts?: { refresh?: boolean },
|
||||
): Promise<ModelAuthStatusResult> {
|
||||
const params = opts?.refresh ? { refresh: true } : {};
|
||||
const result = await client.request<ModelAuthStatusResult>("models.authStatus", params);
|
||||
return result ?? FALLBACK;
|
||||
}
|
||||
|
||||
export async function loadModelAuthStatusState(
|
||||
state: ModelAuthStatusState,
|
||||
opts?: { refresh?: boolean },
|
||||
): Promise<void> {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.modelAuthStatusLoading) {
|
||||
return;
|
||||
}
|
||||
state.modelAuthStatusLoading = true;
|
||||
state.modelAuthStatusError = null;
|
||||
try {
|
||||
state.modelAuthStatusResult = await loadModelAuthStatus(state.client, opts);
|
||||
} catch (err) {
|
||||
state.modelAuthStatusError = err instanceof Error ? err.message : String(err);
|
||||
state.modelAuthStatusResult = FALLBACK;
|
||||
} finally {
|
||||
state.modelAuthStatusLoading = false;
|
||||
}
|
||||
}
|
||||
26
ui/src/ui/model-auth-helpers.ts
Normal file
26
ui/src/ui/model-auth-helpers.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ModelAuthStatusProvider } from "./types.ts";
|
||||
|
||||
/**
|
||||
* True when a provider's auth should be actively monitored on the dashboard.
|
||||
*
|
||||
* Includes:
|
||||
* - Providers with at least one OAuth or bearer-token profile (refreshable
|
||||
* credentials that can expire and need rotation)
|
||||
* - Providers with status="missing" (configured-but-not-logged-in — the
|
||||
* server synthesizes these so the UI can prompt for login)
|
||||
*
|
||||
* Excludes API-key-only providers — their credentials don't expire on a
|
||||
* schedule the dashboard can meaningfully monitor.
|
||||
*
|
||||
* Single source of truth for both the Overview card and the attention-items
|
||||
* panel. Keep the two in sync by always routing through this helper.
|
||||
*/
|
||||
export function isMonitoredAuthProvider(p: ModelAuthStatusProvider): boolean {
|
||||
if (p.status === "missing") {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(p.profiles)) {
|
||||
return false;
|
||||
}
|
||||
return p.profiles.some((prof) => prof.type === "oauth" || prof.type === "token");
|
||||
}
|
||||
@@ -728,6 +728,15 @@ export type ToolsEffectiveGroup =
|
||||
export type ToolsEffectiveResult =
|
||||
import("../../../src/gateway/protocol/schema/types.js").ToolsEffectiveResult;
|
||||
|
||||
export type ModelAuthExpiry =
|
||||
import("../../../src/gateway/server-methods/models-auth-status.js").ModelAuthExpiry;
|
||||
export type ModelAuthStatusProfile =
|
||||
import("../../../src/gateway/server-methods/models-auth-status.js").ModelAuthStatusProfile;
|
||||
export type ModelAuthStatusProvider =
|
||||
import("../../../src/gateway/server-methods/models-auth-status.js").ModelAuthStatusProvider;
|
||||
export type ModelAuthStatusResult =
|
||||
import("../../../src/gateway/server-methods/models-auth-status.js").ModelAuthStatusResult;
|
||||
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
|
||||
export type LogEntry = {
|
||||
|
||||
@@ -227,6 +227,7 @@ function createOverviewProps(overrides: Partial<OverviewProps> = {}): OverviewPr
|
||||
cronEnabled: null,
|
||||
cronNext: null,
|
||||
lastChannelsRefresh: null,
|
||||
modelAuthStatus: null,
|
||||
usageResult: null,
|
||||
sessionsResult: null,
|
||||
skillsReport: null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing, type TemplateResult } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts";
|
||||
import { isMonitoredAuthProvider } from "../model-auth-helpers.ts";
|
||||
import { formatNextRun } from "../presenter.ts";
|
||||
import type {
|
||||
SessionsUsageResult,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
SkillStatusReport,
|
||||
CronJob,
|
||||
CronStatus,
|
||||
ModelAuthStatusResult,
|
||||
} from "../types.ts";
|
||||
|
||||
export type OverviewCardsProps = {
|
||||
@@ -17,6 +19,7 @@ export type OverviewCardsProps = {
|
||||
skillsReport: SkillStatusReport | null;
|
||||
cronJobs: CronJob[];
|
||||
cronStatus: CronStatus | null;
|
||||
modelAuthStatus: ModelAuthStatusResult | null;
|
||||
presenceCount: number;
|
||||
onNavigate: (tab: string) => void;
|
||||
};
|
||||
@@ -48,6 +51,11 @@ function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) {
|
||||
}
|
||||
|
||||
function renderSkeletonCards() {
|
||||
// Render 4 skeletons — matching the always-present cards (cost, sessions,
|
||||
// skills, cron). The Model Auth card is conditional on OAuth providers
|
||||
// existing, so rendering it in the skeleton would cause a layout shift
|
||||
// when real data arrives for a setup without OAuth. Accept a brief empty
|
||||
// slot instead for setups that DO have OAuth.
|
||||
return html`
|
||||
<section class="ov-cards">
|
||||
${[0, 1, 2, 3].map(
|
||||
@@ -131,6 +139,97 @@ export function renderOverviewCards(props: OverviewCardsProps) {
|
||||
},
|
||||
];
|
||||
|
||||
// Model auth card — show providers whose auth needs monitoring.
|
||||
// See isMonitoredAuthProvider for the exact predicate.
|
||||
//
|
||||
// Rendered while loading (modelAuthStatus === null) so the card slot stays
|
||||
// in the grid instead of snapping in on data arrival, matching the cron
|
||||
// card's N/A-placeholder pattern. Still hidden entirely for api-key-only
|
||||
// setups post-load (nothing to monitor), which accepts a one-time hide
|
||||
// rather than the recurring load-time layout shift.
|
||||
const authLoading = props.modelAuthStatus === null;
|
||||
const authProviders = props.modelAuthStatus?.providers ?? [];
|
||||
const monitoredProviders = authProviders.filter(isMonitoredAuthProvider);
|
||||
if (authLoading) {
|
||||
cards.push({
|
||||
kind: "auth",
|
||||
tab: "overview",
|
||||
label: t("overview.cards.modelAuth"),
|
||||
value: t("common.na"),
|
||||
hint: "",
|
||||
});
|
||||
} else if (monitoredProviders.length > 0) {
|
||||
const expired = monitoredProviders.filter(
|
||||
(p) => p.status === "expired" || p.status === "missing",
|
||||
).length;
|
||||
const expiring = monitoredProviders.filter((p) => p.status === "expiring").length;
|
||||
const authValue =
|
||||
expired > 0
|
||||
? html`<span class="danger"
|
||||
>${t("overview.cards.modelAuthExpired", { count: String(expired) })}</span
|
||||
>`
|
||||
: expiring > 0
|
||||
? html`<span class="warn"
|
||||
>${t("overview.cards.modelAuthExpiring", { count: String(expiring) })}</span
|
||||
>`
|
||||
: t("overview.cards.modelAuthOk", { count: String(monitoredProviders.length) });
|
||||
|
||||
// Format a window reset time compactly (e.g. "2:43 PM", "Apr 16").
|
||||
// Hidden for windows with plenty of headroom to keep the hint readable;
|
||||
// shown when a window is below 25% to signal urgency.
|
||||
const formatReset = (resetAt: number | undefined, pctLeft: number): string | null => {
|
||||
if (!resetAt || !Number.isFinite(resetAt) || pctLeft >= 25) {
|
||||
return null;
|
||||
}
|
||||
const d = new Date(resetAt);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const withinADay = resetAt - Date.now() < 24 * 60 * 60 * 1000;
|
||||
return withinADay
|
||||
? d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
||||
: d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const hintParts = monitoredProviders
|
||||
.map((p) => {
|
||||
const bits: string[] = [];
|
||||
for (const w of p.usage?.windows ?? []) {
|
||||
// Clamp to [0, 100] — providers can report usedPercent > 100 when
|
||||
// fully exhausted, which would render as "-5% left" without this.
|
||||
const pctLeft = Math.max(0, Math.min(100, Math.round(100 - w.usedPercent)));
|
||||
const label = (w.label || "").trim();
|
||||
const prefix = label ? `${label} ` : "";
|
||||
const pctStr = t("overview.cards.modelAuthUsageLeft", { pct: String(pctLeft) });
|
||||
const resetStr = formatReset(w.resetAt, pctLeft);
|
||||
bits.push(resetStr ? `${prefix}${pctStr} (${resetStr})` : `${prefix}${pctStr}`);
|
||||
}
|
||||
if (
|
||||
p.expiry &&
|
||||
Number.isFinite(p.expiry.at) &&
|
||||
p.status !== "static" &&
|
||||
p.expiry.label &&
|
||||
p.expiry.label !== "unknown"
|
||||
) {
|
||||
bits.push(t("overview.cards.modelAuthExpiresIn", { when: p.expiry.label }));
|
||||
}
|
||||
return bits.length > 0 ? `${p.displayName}: ${bits.join(", ")}` : null;
|
||||
})
|
||||
.filter((s): s is string => s !== null)
|
||||
.slice(0, 2);
|
||||
const authHint =
|
||||
hintParts.join(" · ") ||
|
||||
t("overview.cards.modelAuthProviders", { count: String(monitoredProviders.length) });
|
||||
|
||||
cards.push({
|
||||
kind: "auth",
|
||||
tab: "overview",
|
||||
label: t("overview.cards.modelAuth"),
|
||||
value: authValue,
|
||||
hint: authHint,
|
||||
});
|
||||
}
|
||||
|
||||
const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? [];
|
||||
|
||||
return html`
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AttentionItem,
|
||||
CronJob,
|
||||
CronStatus,
|
||||
ModelAuthStatusResult,
|
||||
SessionsListResult,
|
||||
SessionsUsageResult,
|
||||
SkillStatusReport,
|
||||
@@ -40,6 +41,7 @@ export type OverviewProps = {
|
||||
lastChannelsRefresh: number | null;
|
||||
warnQueryToken: boolean;
|
||||
// New dashboard data
|
||||
modelAuthStatus: ModelAuthStatusResult | null;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
@@ -416,6 +418,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
skillsReport: props.skillsReport,
|
||||
cronJobs: props.cronJobs,
|
||||
cronStatus: props.cronStatus,
|
||||
modelAuthStatus: props.modelAuthStatus,
|
||||
presenceCount: props.presenceCount,
|
||||
onNavigate: props.onNavigate,
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user