diff --git a/CHANGELOG.md b/CHANGELOG.md index 627ec701afe..aa0b3dce56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index a831bd893d7..db728df75c5 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -78,6 +78,7 @@ const METHOD_SCOPE_GROUPS: Record = { "tts.providers", "commands.list", "models.list", + "models.authStatus", "tools.catalog", "tools.effective", "agents.list", diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index e9d31921368..6b9ba30a06b 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -50,6 +50,7 @@ const BASE_METHODS = [ "talk.mode", "commands.list", "models.list", + "models.authStatus", "tools.catalog", "tools.effective", "agents.list", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 9abbd55014d..ffcbd9a235a 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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, diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts new file mode 100644 index 00000000000..4986a27d7aa --- /dev/null +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -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( + "../../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 = {}, +): GatewayRequestHandlerOptions & { respond: ReturnType } { + 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 }; +} + +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); + }); +}); diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts new file mode 100644 index 00000000000..eb2743f7b21 --- /dev/null +++ b/src/gateway/server-methods/models-auth-status.ts @@ -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(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, + expectsOAuthSet: Set, +): 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..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; +} { + const out = new Set(); + const expectsOAuth = new Set(); + 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(); + 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))); + } + }, +}; diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index f30da68de5d..dca679ccbd7 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index a83d1969e6a..bfa32980abd 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 9700ff2834f..f53875ff732 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index 545d47e6dbd..b677e5a68ea 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index ad2cfa74914..2a97a745da8 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index b8f37f40acf..664744c4776 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index a22475611b5..68030f04e21 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 14af8bbb9de..83f83c8871e 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index c0702f835c6..e8545fc6845 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 47dc0c58dd3..93a459c2e99 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index aa3984ae8eb..8bc0a270a4c 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index 69f79c1074d..e1bf2c1b64a 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -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 } diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index f4fbda80e56..bc8f3c0ad43 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -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", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 3dd2952d950..5636386b13c 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 47e0d16c8dd..1fd64d1ccd2 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -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", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 0ba74f25d76..18dd28cda2b 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -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", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 1825a385b67..4a762361279 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -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", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index 750a6b62d23..bcaa3fdd6ab 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -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: "注意", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 3873ac620fa..f6f4a0c1d84 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -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: "주의", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index e08d49f2156..fffd7646e77 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -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", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 148625343ce..13d00eb8e96 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 891e47b8101..d2291903964 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -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", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 5e636866df8..7b460688033 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -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: "Увага", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 2e777b4df3c..293f53d26a1 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -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: "注意事项", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index a78d407748b..2f6d8f0a46e 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -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: "注意事項", diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index e5ecb0ee229..8ac9cb457f9 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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" diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index e9b27a1d422..63fdf2ec9cf 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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; } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 04e869a41a9..c6f75a01542 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; + loadOverview: (opts?: { refresh?: boolean }) => Promise; loadAssistantIdentity: () => Promise; loadCron: () => Promise; handleWhatsAppStart: (force: boolean) => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 03710ddc4f1..db46425a8fd 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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[0]); + async loadOverview(opts?: { refresh?: boolean }) { + await loadOverviewInternal(this as unknown as Parameters[0], opts); } async loadCron() { diff --git a/ui/src/ui/controllers/model-auth-status.ts b/ui/src/ui/controllers/model-auth-status.ts new file mode 100644 index 00000000000..3817384e758 --- /dev/null +++ b/ui/src/ui/controllers/model-auth-status.ts @@ -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 { + const params = opts?.refresh ? { refresh: true } : {}; + const result = await client.request("models.authStatus", params); + return result ?? FALLBACK; +} + +export async function loadModelAuthStatusState( + state: ModelAuthStatusState, + opts?: { refresh?: boolean }, +): Promise { + 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; + } +} diff --git a/ui/src/ui/model-auth-helpers.ts b/ui/src/ui/model-auth-helpers.ts new file mode 100644 index 00000000000..2409ce8d691 --- /dev/null +++ b/ui/src/ui/model-auth-helpers.ts @@ -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"); +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index b9fbe7bd3f9..defa2b6d819 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -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 = { diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 126b879b4a5..f3e3bab9bbc 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -227,6 +227,7 @@ function createOverviewProps(overrides: Partial = {}): OverviewPr cronEnabled: null, cronNext: null, lastChannelsRefresh: null, + modelAuthStatus: null, usageResult: null, sessionsResult: null, skillsReport: null, diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts index 65c09800777..2b731685a3b 100644 --- a/ui/src/ui/views/overview-cards.ts +++ b/ui/src/ui/views/overview-cards.ts @@ -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`
${[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`${t("overview.cards.modelAuthExpired", { count: String(expired) })}` + : expiring > 0 + ? html`${t("overview.cards.modelAuthExpiring", { count: String(expiring) })}` + : 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` diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index d40735e5662..74a94b60a96 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -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, })}