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:
Omar Shahine
2026-04-14 22:40:42 -07:00
committed by GitHub
parent 3d2f51c0a4
commit 507b718917
41 changed files with 1391 additions and 54 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -50,6 +50,7 @@ const BASE_METHODS = [
"talk.mode",
"commands.list",
"models.list",
"models.authStatus",
"tools.catalog",
"tools.effective",
"agents.list",

View File

@@ -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,

View 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);
});
});

View 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)));
}
},
};

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: "注意",

View File

@@ -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: "주의",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: "Увага",

View File

@@ -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: "注意事项",

View File

@@ -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: "注意事項",

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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() {

View 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;
}
}

View 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");
}

View File

@@ -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 = {

View File

@@ -227,6 +227,7 @@ function createOverviewProps(overrides: Partial<OverviewProps> = {}): OverviewPr
cronEnabled: null,
cronNext: null,
lastChannelsRefresh: null,
modelAuthStatus: null,
usageResult: null,
sessionsResult: null,
skillsReport: null,

View File

@@ -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`

View File

@@ -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,
})}