Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
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 { normalizeProviderId } from "../../agents/provider-id.js";
import { loadConfig, type OpenClawConfig } from "../../config/config.js";
import { isSecretRef } from "../../config/types.secrets.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>();
// Providers with a resolvable apiKey (inline or SecretRef pointing at a
// set env var) are treated as env-backed and skipped from the "missing"
// synthesis. Captured once up front so both the models.providers scan
// and the auth.profiles scan apply the escape hatch consistently.
const envBacked = new Set<string>();
for (const [id, provider] of Object.entries(cfg.models?.providers ?? {})) {
const apiKey = provider?.apiKey;
if (!id || apiKey === undefined || apiKey === null) {
continue;
}
// Treat as env-backed when the credential is currently resolvable:
// - inline string literal → always resolvable (satisfies auth today)
// - env SecretRef → check process.env for the referenced id (the only
// source we can cheaply verify synchronously on a dashboard read)
// - file/exec SecretRef → conservatively treat as env-backed; we can't
// read files or run commands here without making this a heavy async
// path, and the alternative is crying wolf on valid configs
// A SecretRef pointing at an unset env var falls through to the normal
// "missing" synthesis so the dashboard surfaces the broken config.
let resolvable = false;
if (typeof apiKey === "string" && apiKey.length > 0) {
resolvable = true;
} else if (isSecretRef(apiKey)) {
if (apiKey.source === "env") {
const envValue = process.env[apiKey.id];
resolvable = typeof envValue === "string" && envValue.length > 0;
} else {
resolvable = true;
}
}
if (resolvable) {
envBacked.add(normalizeProviderId(id));
}
}
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;
}
if (envBacked.has(normalizeProviderId(id))) {
continue;
}
out.add(id);
if (mode === "oauth") {
// Store normalized id so lookups against `AuthProviderHealth.provider`
// (which is already normalized by buildAuthHealthSummary) match even
// when the config uses an alias like `z.ai` that normalizes to `zai`.
expectsOAuth.add(normalizeProviderId(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")
) {
continue;
}
if (envBacked.has(normalizeProviderId(provider))) {
continue;
}
out.add(provider);
if (mode === "oauth") {
expectsOAuth.add(normalizeProviderId(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)));
}
},
};
¤ Dauer der Verarbeitung: 0.20 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|