mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
fix(models): defer live catalog discovery
This commit is contained in:
@@ -80,7 +80,7 @@ Other selection rules:
|
||||
|
||||
- Changing `agents.defaults.model.primary` does not rewrite existing session pins. If status reports `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, run `/model default` to clear the pin.
|
||||
- CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing only `models.providers.*.models` instead of the full built-in catalog.
|
||||
- The Control UI model picker asks the Gateway for its configured model view. An explicit `modelPolicy.allow` filters it, including trailing prefix wildcard entries; otherwise it shows configured models plus providers with usable auth. Default and configured picker views hide catalog rows marked `deprecated` or `disabled` unless that exact model is configured as a primary, fallback, utility/tool model, alias/settings key, or exact policy entry. Hidden rows remain selectable by exact `provider/model` ref. The full built-in catalog, including hidden rows, is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`).
|
||||
- The Control UI starts from the Gateway's prepared configured model view, so opening chat does not start provider discovery. Opening or refreshing a model picker may discover models required by a trailing `provider/*` policy entry. Default and configured picker views hide catalog rows marked `deprecated` or `disabled` unless that exact model is configured as a primary, fallback, utility/tool model, alias/settings key, or exact policy entry. Hidden rows remain selectable by exact `provider/model` ref. The full built-in catalog, including hidden rows, is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`).
|
||||
- Provider inventory UIs use `models.list` with `view: "provider-config"` to show source-authored `models.providers.*.models` rows without applying picker allowlists.
|
||||
|
||||
Full mechanics: [Model failover](/concepts/model-failover).
|
||||
|
||||
@@ -1029,6 +1029,15 @@ context.
|
||||
- `"all"`: full gateway catalog, bypassing `agents.defaults.modelPolicy.allow`. Use for
|
||||
diagnostics/discovery UIs, not normal model pickers.
|
||||
|
||||
Two optional controls separate automatic reads from operator-requested discovery:
|
||||
|
||||
- `preparedOnly: true` reuses the current prepared catalog or a completed catalog for that
|
||||
runtime generation without starting provider discovery. Control UI startup and polling use
|
||||
this mode.
|
||||
- `refresh: true` replaces a completed full catalog when the selected view requires discovery.
|
||||
Concurrent refreshes share one build; a failed refresh leaves the previous completed catalog
|
||||
available and returns the failure to the caller.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
- When an exec request needs approval, the gateway broadcasts
|
||||
|
||||
@@ -1005,6 +1005,8 @@ describe("validateModelsListParams", () => {
|
||||
{ view: "default" },
|
||||
{ view: "configured" },
|
||||
{ view: "all" },
|
||||
{ view: "configured", preparedOnly: true },
|
||||
{ view: "all", refresh: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -206,6 +206,13 @@ describe("ModelsListParamsSchema", () => {
|
||||
agentId: "research",
|
||||
includeProviderCapabilities: true,
|
||||
},
|
||||
{
|
||||
preparedOnly: true,
|
||||
},
|
||||
{
|
||||
refresh: true,
|
||||
view: "all",
|
||||
},
|
||||
);
|
||||
expectRejected(ModelsListParamsSchema, { view: "provider-route" }, { agentId: "" });
|
||||
});
|
||||
|
||||
@@ -230,6 +230,10 @@ export const AgentsFilesSetResultSchema = closedObject({
|
||||
export const ModelsListParamsSchema = closedObject({
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
includeProviderCapabilities: Type.Optional(Type.Boolean()),
|
||||
/** Reuse prepared/cached facts without starting provider discovery. */
|
||||
preparedOnly: Type.Optional(Type.Boolean()),
|
||||
/** Force replacement of a completed full-catalog generation. */
|
||||
refresh: Type.Optional(Type.Boolean()),
|
||||
view: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("default"),
|
||||
|
||||
@@ -22,6 +22,7 @@ export type DiscoverAuthStorageOptions = {
|
||||
ambientCredentials?: Readonly<AgentCredentialMap>;
|
||||
externalCli?: ExternalCliAuthDiscovery;
|
||||
inheritedAuthDir?: string;
|
||||
preparedStore?: AuthProfileStore;
|
||||
readOnly?: boolean;
|
||||
skipExternalAuthProfiles?: boolean;
|
||||
skipCredentials?: boolean;
|
||||
@@ -95,8 +96,9 @@ export function resolveAgentDiscoveryAuthFacts(
|
||||
...(options?.externalCli ? { externalCli: options.externalCli } : {}),
|
||||
...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}),
|
||||
};
|
||||
const store =
|
||||
options?.skipExternalAuthProfiles === true
|
||||
const store = options?.preparedStore
|
||||
? options.preparedStore
|
||||
: options?.skipExternalAuthProfiles === true
|
||||
? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}),
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OAuthCredential } from "./auth-profiles/types.js";
|
||||
import type { ProviderAuthAliasLookupParams } from "./provider-auth-aliases.js";
|
||||
|
||||
const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({
|
||||
const { readCodexCliCredentialsCachedMock, resolveProviderIdForAuthMock } = vi.hoisted(() => ({
|
||||
readCodexCliCredentialsCachedMock: vi.fn<
|
||||
(options?: { allowKeychainPrompt?: boolean }) => OAuthCredential | null
|
||||
>(() => null),
|
||||
resolveProviderIdForAuthMock: vi.fn<(provider: string, params?: unknown) => string>(
|
||||
(provider: string) => (provider === "codex-cli" ? "openai" : provider),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./cli-credentials.js", () => ({
|
||||
@@ -20,7 +24,7 @@ vi.mock("./cli-credentials.js", () => ({
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
}));
|
||||
vi.mock("./provider-auth-aliases.js", () => ({
|
||||
resolveProviderIdForAuth: (provider: string) => (provider === "codex-cli" ? "openai" : provider),
|
||||
resolveProviderIdForAuth: resolveProviderIdForAuthMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -72,6 +76,10 @@ describe("buildAuthHealthSummary", () => {
|
||||
beforeEach(() => {
|
||||
readCodexCliCredentialsCachedMock.mockReset();
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(null);
|
||||
resolveProviderIdForAuthMock.mockReset();
|
||||
resolveProviderIdForAuthMock.mockImplementation((provider: string) =>
|
||||
provider === "codex-cli" ? "openai" : provider,
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies OAuth and API key profiles", () => {
|
||||
@@ -580,6 +588,50 @@ describe("buildAuthHealthSummary", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses caller-owned plugin metadata when resolving explicit auth order", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
resolveProviderIdForAuthMock.mockImplementation((provider: string, params?: unknown) => {
|
||||
const metadata = (params as { metadataSnapshot?: { plugins?: unknown[] } } | undefined)
|
||||
?.metadataSnapshot;
|
||||
return provider === "fixture-alias" && metadata?.plugins?.length
|
||||
? "fixture-provider"
|
||||
: provider;
|
||||
});
|
||||
const metadataSnapshot = {
|
||||
plugins: [
|
||||
{
|
||||
id: "fixture-auth-alias",
|
||||
origin: "bundled" as const,
|
||||
providerAuthAliases: { "fixture-alias": "fixture-provider" },
|
||||
},
|
||||
],
|
||||
} as unknown as NonNullable<ProviderAuthAliasLookupParams["metadataSnapshot"]>;
|
||||
const summary = buildAuthHealthSummary({
|
||||
cfg: { auth: { order: { "fixture-provider": [] } } },
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"fixture-alias:token": {
|
||||
type: "token",
|
||||
provider: "fixture-alias",
|
||||
token: "fake-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
authAliasLookupParams: {
|
||||
metadataSnapshot,
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.providers).toMatchObject([
|
||||
{ provider: "fixture-alias", status: "missing", effectiveProfiles: [] },
|
||||
]);
|
||||
expect(resolveProviderIdForAuthMock).toHaveBeenCalledWith(
|
||||
"fixture-alias",
|
||||
expect.objectContaining({ metadataSnapshot }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRemainingShort", () => {
|
||||
|
||||
@@ -20,7 +20,10 @@ import { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
|
||||
import { resolveEffectiveOAuthCredential } from "./auth-profiles/effective-oauth.js";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles/order.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
|
||||
import {
|
||||
type ProviderAuthAliasLookupParams,
|
||||
resolveProviderIdForAuth,
|
||||
} from "./provider-auth-aliases.js";
|
||||
|
||||
type AuthProfileSource = "store";
|
||||
|
||||
@@ -279,6 +282,8 @@ export function buildAuthHealthSummary(params: {
|
||||
providers?: string[];
|
||||
runtimeCredentialsByProvider?: ReadonlyMap<string, AuthProfileCredential>;
|
||||
allowKeychainPrompt?: boolean;
|
||||
/** Exact prepared metadata for request paths that must not rediscover plugin aliases. */
|
||||
authAliasLookupParams?: ProviderAuthAliasLookupParams;
|
||||
}): AuthHealthSummary {
|
||||
const now = Date.now();
|
||||
const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS;
|
||||
@@ -338,7 +343,10 @@ export function buildAuthHealthSummary(params: {
|
||||
}
|
||||
|
||||
const resolveExplicitAuthOrder = (provider: string): string[] | undefined => {
|
||||
const authProvider = resolveProviderIdForAuth(provider, { config: params.cfg });
|
||||
const authProvider = resolveProviderIdForAuth(provider, {
|
||||
config: params.cfg,
|
||||
...params.authAliasLookupParams,
|
||||
});
|
||||
return (
|
||||
findNormalizedProviderValue(params.store.order, authProvider) ??
|
||||
findNormalizedProviderValue(params.store.order, provider) ??
|
||||
@@ -357,6 +365,7 @@ export function buildAuthHealthSummary(params: {
|
||||
cfg: params.cfg,
|
||||
store: params.store,
|
||||
provider: provider.provider,
|
||||
authAliasLookupParams: params.authAliasLookupParams,
|
||||
});
|
||||
const orderedProfiles = ordered
|
||||
.map((profileId) => provider.profiles.find((profile) => profile.profileId === profileId))
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
overlayRuntimeExternalOAuthProfiles,
|
||||
type RuntimeExternalOAuthProfile,
|
||||
} from "./oauth-shared.js";
|
||||
import {
|
||||
getRuntimeExternalCliProfileIds,
|
||||
removeRuntimeExternalProfileReferences,
|
||||
setRuntimeExternalCliProfileIds,
|
||||
} from "./runtime-external-profile-references.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
type ExternalAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
|
||||
@@ -81,55 +86,38 @@ function isExternalAuthProfileAllowed(
|
||||
});
|
||||
}
|
||||
|
||||
function resolveExternalAuthProfileMap(params: {
|
||||
function resolveExternalAuthProfiles(params: {
|
||||
store: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
externalCli?: ExternalCliOverlayOptions;
|
||||
}): ExternalAuthProfileMap {
|
||||
}): {
|
||||
profiles: ExternalAuthProfileMap;
|
||||
pluginProfileIds: ReadonlySet<string>;
|
||||
runtimeExternalCliProfileIds: ReadonlySet<string>;
|
||||
} {
|
||||
const env = params.env ?? process.env;
|
||||
const resolveProfiles =
|
||||
resolveExternalAuthProfilesForRuntime ?? resolveExternalAuthProfilesWithPlugins;
|
||||
const profiles = resolveProfiles({
|
||||
env,
|
||||
config: params.externalCli?.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
context: {
|
||||
config: params.externalCli?.config,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
workspaceDir: undefined,
|
||||
env,
|
||||
store: params.store,
|
||||
},
|
||||
});
|
||||
|
||||
const resolved: ExternalAuthProfileMap = new Map();
|
||||
const resolved = resolveExternalCliAuthProfileMap(params);
|
||||
const runtimeExternalCliProfileIds = new Set(
|
||||
[...resolved.values()]
|
||||
.filter((profile) => profile.persistence !== "persisted")
|
||||
.map((profile) => profile.profileId),
|
||||
);
|
||||
const pluginProfileIds = new Set<string>();
|
||||
const explicitProfileIds = resolveExplicitProfileIds(params.externalCli?.externalCliProfileIds);
|
||||
const cliProfiles =
|
||||
externalCliSync.resolveExternalCliAuthProfiles?.(params.store, {
|
||||
allowKeychainPrompt: params.externalCli?.allowKeychainPrompt,
|
||||
providerIds: params.externalCli?.externalCliProviderIds,
|
||||
profileIds: explicitProfileIds,
|
||||
}) ?? [];
|
||||
for (const profile of cliProfiles) {
|
||||
if (
|
||||
!isExternalAuthProfileAllowed(
|
||||
profile,
|
||||
params.store,
|
||||
params.externalCli?.config,
|
||||
explicitProfileIds,
|
||||
env,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
resolved.set(profile.profileId, {
|
||||
profileId: profile.profileId,
|
||||
credential: profile.credential,
|
||||
persistence: profile.persistence ?? "runtime-only",
|
||||
});
|
||||
}
|
||||
for (const rawProfile of profiles) {
|
||||
const profile = normalizeExternalAuthProfile(rawProfile);
|
||||
if (!profile) {
|
||||
@@ -147,26 +135,68 @@ function resolveExternalAuthProfileMap(params: {
|
||||
continue;
|
||||
}
|
||||
resolved.set(profile.profileId, profile);
|
||||
pluginProfileIds.add(profile.profileId);
|
||||
runtimeExternalCliProfileIds.delete(profile.profileId);
|
||||
}
|
||||
return resolved;
|
||||
return { profiles: resolved, pluginProfileIds, runtimeExternalCliProfileIds };
|
||||
}
|
||||
|
||||
function resolveAllowedExternalCliAuthProfiles(params: {
|
||||
store: AuthProfileStore;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
externalCli?: ExternalCliOverlayOptions;
|
||||
}): ProviderExternalAuthProfile[] {
|
||||
const env = params.env ?? process.env;
|
||||
const explicitProfileIds = resolveExplicitProfileIds(params.externalCli?.externalCliProfileIds);
|
||||
const cliProfiles =
|
||||
externalCliSync.resolveExternalCliAuthProfiles?.(params.store, {
|
||||
allowKeychainPrompt: params.externalCli?.allowKeychainPrompt,
|
||||
providerIds: params.externalCli?.externalCliProviderIds,
|
||||
profileIds: explicitProfileIds,
|
||||
}) ?? [];
|
||||
return cliProfiles.flatMap((profile) =>
|
||||
isExternalAuthProfileAllowed(
|
||||
profile,
|
||||
params.store,
|
||||
params.externalCli?.config,
|
||||
explicitProfileIds,
|
||||
env,
|
||||
)
|
||||
? [
|
||||
{
|
||||
profileId: profile.profileId,
|
||||
credential: profile.credential,
|
||||
persistence: profile.persistence ?? "runtime-only",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExternalCliAuthProfileMap(params: {
|
||||
store: AuthProfileStore;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
externalCli?: ExternalCliOverlayOptions;
|
||||
}): ExternalAuthProfileMap {
|
||||
return new Map(
|
||||
resolveAllowedExternalCliAuthProfiles(params).map((profile) => [profile.profileId, profile]),
|
||||
);
|
||||
}
|
||||
|
||||
/** List runtime-only and persisted external auth profiles for this store. */
|
||||
export function listRuntimeExternalAuthProfiles(params: {
|
||||
store: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
externalCli?: ExternalCliOverlayOptions;
|
||||
}): RuntimeExternalOAuthProfile[] {
|
||||
return Array.from(
|
||||
resolveExternalAuthProfileMap({
|
||||
resolveExternalAuthProfiles({
|
||||
store: params.store,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
externalCli: params.externalCli,
|
||||
}).values(),
|
||||
}).profiles.values(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,22 +223,73 @@ function hasScopedExternalCliOverlay(params?: ExternalCliOverlayOptions): boolea
|
||||
/** Overlay external auth profiles onto a cloned auth store for runtime use. */
|
||||
export function overlayExternalAuthProfiles(
|
||||
store: AuthProfileStore,
|
||||
params?: {
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} & ExternalCliOverlayOptions,
|
||||
params?: { agentDir?: string; env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
|
||||
): AuthProfileStore {
|
||||
const profiles = listRuntimeExternalAuthProfiles({
|
||||
store,
|
||||
const scoped = hasScopedExternalCliOverlay(params);
|
||||
const refreshedProfileIds = new Set(
|
||||
getRuntimeExternalCliProfileIds(store).filter(
|
||||
(profileId) =>
|
||||
scoped &&
|
||||
externalCliSync.isExternalCliAuthProfileInScope({
|
||||
store,
|
||||
profileId,
|
||||
providerIds: params?.externalCliProviderIds,
|
||||
profileIds: params?.externalCliProfileIds,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const base = removeRuntimeExternalProfileReferences({ store, profileIds: refreshedProfileIds });
|
||||
const resolved = resolveExternalAuthProfiles({
|
||||
store: base,
|
||||
agentDir: params?.agentDir,
|
||||
workspaceDir: params?.workspaceDir,
|
||||
env: params?.env,
|
||||
externalCli: params,
|
||||
});
|
||||
return overlayRuntimeExternalOAuthProfiles(store, profiles, {
|
||||
runtimeExternalProfileIdsAuthoritative: !hasScopedExternalCliOverlay(params),
|
||||
const next = overlayRuntimeExternalOAuthProfiles(base, resolved.profiles.values(), {
|
||||
runtimeExternalProfileIdsAuthoritative: !scoped,
|
||||
});
|
||||
const retainedCliProfileIds = getRuntimeExternalCliProfileIds(base).filter(
|
||||
(profileId) => !resolved.pluginProfileIds.has(profileId),
|
||||
);
|
||||
setRuntimeExternalCliProfileIds(next, [
|
||||
...retainedCliProfileIds,
|
||||
...resolved.runtimeExternalCliProfileIds,
|
||||
]);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Refresh external CLI credentials without reevaluating lifecycle-owned provider hooks. */
|
||||
export function overlayExternalCliAuthProfiles(
|
||||
store: AuthProfileStore,
|
||||
params?: { env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
|
||||
): AuthProfileStore {
|
||||
const scoped = hasScopedExternalCliOverlay(params);
|
||||
const refreshedProfileIds = new Set(
|
||||
getRuntimeExternalCliProfileIds(store).filter(
|
||||
(profileId) =>
|
||||
scoped &&
|
||||
externalCliSync.isExternalCliAuthProfileInScope({
|
||||
store,
|
||||
profileId,
|
||||
providerIds: params?.externalCliProviderIds,
|
||||
profileIds: params?.externalCliProfileIds,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const base = removeRuntimeExternalProfileReferences({ store, profileIds: refreshedProfileIds });
|
||||
const profiles = resolveAllowedExternalCliAuthProfiles({
|
||||
store: base,
|
||||
env: params?.env,
|
||||
externalCli: params,
|
||||
});
|
||||
const next = overlayRuntimeExternalOAuthProfiles(base, profiles);
|
||||
setRuntimeExternalCliProfileIds(next, [
|
||||
...getRuntimeExternalCliProfileIds(base),
|
||||
...profiles
|
||||
.filter((profile) => profile.persistence !== "persisted")
|
||||
.map((profile) => profile.profileId),
|
||||
]);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Persist safe external CLI OAuth profiles that own their local profile slot. */
|
||||
@@ -219,19 +300,11 @@ export function syncPersistedExternalCliAuthProfiles(
|
||||
if (!hasPersistableExternalCliSyncCandidate(store, params)) {
|
||||
return store;
|
||||
}
|
||||
const env = params?.env ?? process.env;
|
||||
const explicitProfileIds = resolveExplicitProfileIds(params?.externalCliProfileIds);
|
||||
const cliProfiles =
|
||||
externalCliSync.resolveExternalCliAuthProfiles?.(store, {
|
||||
allowKeychainPrompt: params?.allowKeychainPrompt,
|
||||
providerIds: params?.externalCliProviderIds,
|
||||
profileIds: explicitProfileIds,
|
||||
}) ?? [];
|
||||
const persistedProfiles = cliProfiles.filter(
|
||||
(profile) =>
|
||||
profile.persistence === "persisted" &&
|
||||
isExternalAuthProfileAllowed(profile, store, params?.config, explicitProfileIds, env),
|
||||
);
|
||||
const persistedProfiles = resolveAllowedExternalCliAuthProfiles({
|
||||
store,
|
||||
env: params?.env,
|
||||
externalCli: params,
|
||||
}).filter((profile) => profile.persistence === "persisted");
|
||||
if (persistedProfiles.length === 0) {
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -266,6 +266,30 @@ function isExternalCliProviderInScope(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/** True when a previously resolved built-in CLI profile belongs to this refresh scope. */
|
||||
export function isExternalCliAuthProfileInScope(params: {
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
providerIds?: Iterable<string>;
|
||||
profileIds?: Iterable<string>;
|
||||
}): boolean {
|
||||
const credential = params.store.profiles[params.profileId];
|
||||
const providerConfig = resolveExternalCliSyncProvider({
|
||||
profileId: params.profileId,
|
||||
...(credential?.type === "oauth" ? { credential } : {}),
|
||||
});
|
||||
return providerConfig
|
||||
? isExternalCliProviderInScope({
|
||||
providerConfig,
|
||||
store: params.store,
|
||||
options: {
|
||||
...(params.providerIds ? { providerIds: params.providerIds } : {}),
|
||||
...(params.profileIds ? { profileIds: params.profileIds } : {}),
|
||||
},
|
||||
})
|
||||
: false;
|
||||
}
|
||||
|
||||
function listScopedExternalCliProfileIds(params: {
|
||||
providerConfig: ExternalCliSyncProvider;
|
||||
store: AuthProfileStore;
|
||||
|
||||
@@ -8,16 +8,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderExternalAuthProfile } from "../../plugins/types.js";
|
||||
import { resolveAgentCredentialMapFromStore } from "../agent-auth-credentials.js";
|
||||
import { addEnvBackedAgentCredentials } from "../agent-auth-discovery-core.js";
|
||||
import { overlayExternalAuthProfiles } from "./external-auth.js";
|
||||
import { overlayExternalAuthProfiles, overlayExternalCliAuthProfiles } from "./external-auth.js";
|
||||
import { testing } from "./external-auth.test-support.js";
|
||||
import { readExternalCliBootstrapCredential } from "./external-cli-sync.js";
|
||||
import { getRuntimeExternalCliProfileIds } from "./runtime-external-profile-references.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
registerRuntimeAuthProfileStoreMutationListener,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
} from "./runtime-snapshots.js";
|
||||
import { ensureAuthProfileStore, getRuntimeAuthProfileStoreSnapshot } from "./store.js";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
import type { AuthProfileStore, OAuthCredential, RuntimeAuthProfileStore } from "./types.js";
|
||||
|
||||
const resolveExternalAuthProfilesWithPluginsMock = vi.fn<
|
||||
(params: unknown) => ProviderExternalAuthProfile[]
|
||||
@@ -113,6 +114,109 @@ describe("auth external oauth helpers", () => {
|
||||
expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes and removes a prepared built-in CLI profile authoritatively", () => {
|
||||
readCodexCliCredentialsCachedMock.mockReturnValueOnce(
|
||||
createCredential({ access: "startup-access", refresh: "startup-refresh" }),
|
||||
);
|
||||
const startup = overlayExternalAuthProfiles(
|
||||
{
|
||||
...createStore(),
|
||||
order: { openai: ["openai:default"] },
|
||||
lastGood: { openai: "openai:default" },
|
||||
usageStats: { "openai:default": { lastUsed: 1 } },
|
||||
},
|
||||
{
|
||||
externalCliProviderIds: ["openai"],
|
||||
},
|
||||
);
|
||||
expect(getRuntimeExternalCliProfileIds(startup)).toEqual(["openai:default"]);
|
||||
|
||||
const retained = overlayExternalAuthProfiles(startup);
|
||||
expect(retained.profiles["openai:default"]).toMatchObject({
|
||||
access: "startup-access",
|
||||
refresh: "startup-refresh",
|
||||
});
|
||||
expect(getRuntimeExternalCliProfileIds(retained)).toEqual(["openai:default"]);
|
||||
|
||||
readCodexCliCredentialsCachedMock.mockReturnValueOnce(
|
||||
createCredential({ access: "rotated-access", refresh: "rotated-refresh" }),
|
||||
);
|
||||
const rotated = overlayExternalCliAuthProfiles(retained, {
|
||||
externalCliProfileIds: ["openai:default"],
|
||||
});
|
||||
expect(rotated.profiles["openai:default"]).toMatchObject({
|
||||
access: "rotated-access",
|
||||
refresh: "rotated-refresh",
|
||||
});
|
||||
expect(getRuntimeExternalCliProfileIds(rotated)).toEqual(["openai:default"]);
|
||||
|
||||
readCodexCliCredentialsCachedMock.mockReturnValueOnce(null);
|
||||
const loggedOut = overlayExternalCliAuthProfiles(rotated, {
|
||||
externalCliProviderIds: ["openai"],
|
||||
});
|
||||
expect(loggedOut.profiles["openai:default"]).toBeUndefined();
|
||||
expect(loggedOut.order).toBeUndefined();
|
||||
expect(loggedOut.lastGood).toBeUndefined();
|
||||
expect(loggedOut.usageStats).toBeUndefined();
|
||||
expect(getRuntimeExternalCliProfileIds(loggedOut)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves a plugin winner that collides with a built-in CLI profile id", () => {
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(
|
||||
createCredential({ access: "cli-access", refresh: "cli-refresh" }),
|
||||
);
|
||||
resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([
|
||||
{
|
||||
profileId: "openai:default",
|
||||
credential: createCredential({ access: "plugin-access", refresh: "plugin-refresh" }),
|
||||
},
|
||||
]);
|
||||
const prepared = overlayExternalAuthProfiles(createStore(), {
|
||||
externalCliProviderIds: ["openai"],
|
||||
});
|
||||
expect(prepared.profiles["openai:default"]).toMatchObject({
|
||||
access: "plugin-access",
|
||||
refresh: "plugin-refresh",
|
||||
});
|
||||
expect(prepared.runtimeExternalProfileIds).toEqual(["openai:default"]);
|
||||
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([]);
|
||||
|
||||
resolveExternalAuthProfilesWithPluginsMock.mockClear();
|
||||
const refreshed = overlayExternalCliAuthProfiles(prepared, {
|
||||
externalCliProviderIds: ["openai"],
|
||||
});
|
||||
expect(refreshed.profiles["openai:default"]).toMatchObject({
|
||||
access: "plugin-access",
|
||||
refresh: "plugin-refresh",
|
||||
});
|
||||
expect(resolveExternalAuthProfilesWithPluginsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces CLI provenance only inside the requested refresh scope", () => {
|
||||
const store: RuntimeAuthProfileStore = {
|
||||
...createStore({
|
||||
"openai:default": createCredential(),
|
||||
"claude-cli:default": createCredential({
|
||||
provider: "claude-cli",
|
||||
access: "claude-access",
|
||||
refresh: "claude-refresh",
|
||||
}),
|
||||
}),
|
||||
runtimeExternalProfileIds: ["claude-cli:default", "openai:default"],
|
||||
runtimeExternalCliProfileIds: ["claude-cli:default", "openai:default"],
|
||||
};
|
||||
|
||||
const refreshed = overlayExternalCliAuthProfiles(store, {
|
||||
externalCliProfileIds: ["openai:default"],
|
||||
});
|
||||
|
||||
expect(refreshed.profiles["openai:default"]).toBeUndefined();
|
||||
expect(refreshed.profiles["claude-cli:default"]).toMatchObject({
|
||||
access: "claude-access",
|
||||
});
|
||||
expect(getRuntimeExternalCliProfileIds(refreshed)).toEqual(["claude-cli:default"]);
|
||||
});
|
||||
|
||||
it("publishes a usable scoped CLI bootstrap into the runtime auth owner", () => {
|
||||
const agentDir = "/tmp/openclaw-external-oauth-publication";
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(
|
||||
|
||||
@@ -129,6 +129,7 @@ function isConfiguredProfileCompatibleWithAuthProvider(params: {
|
||||
|
||||
function listProfilesCompatibleWithAuthProvider(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
authAliasLookupParams?: ProviderAuthAliasLookupParams;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
providerAuthKey: string;
|
||||
@@ -140,6 +141,7 @@ function listProfilesCompatibleWithAuthProvider(params: {
|
||||
.filter(([, credential]) =>
|
||||
isCredentialProviderCompatibleWithAuthProvider({
|
||||
cfg: params.cfg,
|
||||
authAliasLookupParams: params.authAliasLookupParams,
|
||||
providerAuthKey: params.providerAuthKey,
|
||||
credential,
|
||||
}),
|
||||
@@ -263,6 +265,8 @@ type ResolveAuthProfileOrderParams = {
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
/** Exact prepared metadata for request paths that must not rediscover plugin aliases. */
|
||||
authAliasLookupParams?: ProviderAuthAliasLookupParams;
|
||||
preferredProfile?: string;
|
||||
/** Model that will consume the profile, for model-scoped cooldowns. */
|
||||
forModel?: string;
|
||||
@@ -282,7 +286,10 @@ export function resolveAuthProfileOrderWithMetadata(
|
||||
): AuthProfileOrderResolution {
|
||||
const { cfg, store, provider, preferredProfile, forModel } = params;
|
||||
const providerKey = normalizeProviderId(provider);
|
||||
const providerAuthKey = resolveProviderIdForAuth(provider, { config: cfg });
|
||||
const providerAuthKey = resolveProviderIdForAuth(provider, {
|
||||
config: cfg,
|
||||
...params.authAliasLookupParams,
|
||||
});
|
||||
const now = Date.now();
|
||||
|
||||
// Clear any cooldowns that have expired since the last check so profiles
|
||||
@@ -316,6 +323,7 @@ export function resolveAuthProfileOrderWithMetadata(
|
||||
.filter(([profileId, profile]) =>
|
||||
isConfiguredProfileCompatibleWithAuthProvider({
|
||||
cfg,
|
||||
authAliasLookupParams: params.authAliasLookupParams,
|
||||
providerAuthKey,
|
||||
provider: profile.provider,
|
||||
mode: profile.mode,
|
||||
@@ -326,6 +334,7 @@ export function resolveAuthProfileOrderWithMetadata(
|
||||
: [];
|
||||
const storeProfiles = listProfilesCompatibleWithAuthProvider({
|
||||
cfg,
|
||||
authAliasLookupParams: params.authAliasLookupParams,
|
||||
store,
|
||||
provider,
|
||||
providerAuthKey,
|
||||
@@ -335,6 +344,7 @@ export function resolveAuthProfileOrderWithMetadata(
|
||||
? storeProfiles.filter((profileId) =>
|
||||
isNativeCredentialProviderCompatibleWithAuthProvider({
|
||||
cfg,
|
||||
authAliasLookupParams: params.authAliasLookupParams,
|
||||
providerAuthKey,
|
||||
credential: store.profiles[profileId],
|
||||
}),
|
||||
@@ -357,6 +367,7 @@ export function resolveAuthProfileOrderWithMetadata(
|
||||
const isValidProfile = (profileId: string): boolean => {
|
||||
const eligibility = resolveAuthProfileEligibility({
|
||||
cfg,
|
||||
authAliasLookupParams: params.authAliasLookupParams,
|
||||
store,
|
||||
provider,
|
||||
profileId,
|
||||
@@ -445,6 +456,7 @@ function resolveAuthOrder(
|
||||
|
||||
function isNativeCredentialProviderCompatibleWithAuthProvider(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
authAliasLookupParams?: ProviderAuthAliasLookupParams;
|
||||
providerAuthKey: string;
|
||||
credential: AuthProfileCredential | undefined;
|
||||
}): boolean {
|
||||
@@ -452,8 +464,10 @@ function isNativeCredentialProviderCompatibleWithAuthProvider(params: {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
resolveProviderIdForAuth(params.credential.provider, { config: params.cfg }) ===
|
||||
params.providerAuthKey
|
||||
resolveProviderIdForAuth(params.credential.provider, {
|
||||
config: params.cfg,
|
||||
...params.authAliasLookupParams,
|
||||
}) === params.providerAuthKey
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
coercePersistedAuthProfileStore,
|
||||
mergeAuthProfileStores,
|
||||
} from "./persisted.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { getRuntimeExternalCliProfileIds } from "./runtime-external-profile-references.js";
|
||||
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
|
||||
|
||||
describe("persisted auth profile boundary", () => {
|
||||
it("normalizes malformed persisted credentials and state before runtime use", () => {
|
||||
@@ -428,6 +429,47 @@ describe("persisted auth profile boundary", () => {
|
||||
expect(merged.order?.anthropic).toEqual([profileId]);
|
||||
expect(merged.lastGood?.anthropic).toBe(profileId);
|
||||
});
|
||||
|
||||
it("carries built-in CLI provenance only with the winning external profile", () => {
|
||||
const profileId = "openai:default";
|
||||
const base: RuntimeAuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
runtimeExternalProfileIds: [profileId],
|
||||
runtimeExternalCliProfileIds: [profileId],
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
access: "cli-access",
|
||||
refresh: "cli-refresh",
|
||||
expires: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
const inherited = mergeAuthProfileStores(
|
||||
base,
|
||||
{ version: AUTH_STORE_VERSION, profiles: {} },
|
||||
{ preserveBaseRuntimeExternalProfiles: true },
|
||||
);
|
||||
expect(getRuntimeExternalCliProfileIds(inherited)).toEqual([profileId]);
|
||||
|
||||
const pluginOverride: RuntimeAuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
runtimeExternalProfileIds: [profileId],
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
access: "plugin-access",
|
||||
refresh: "plugin-refresh",
|
||||
expires: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
const collided = mergeAuthProfileStores(base, pluginOverride);
|
||||
expect(collided.profiles[profileId]).toMatchObject({ access: "plugin-access" });
|
||||
expect(getRuntimeExternalCliProfileIds(collided)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyLegacyAuthStore", () => {
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
normalizeAuthEmailToken,
|
||||
normalizeAuthIdentityToken,
|
||||
} from "./oauth-shared.js";
|
||||
import {
|
||||
getRuntimeExternalCliProfileIds,
|
||||
setRuntimeExternalCliProfileIds,
|
||||
} from "./runtime-external-profile-references.js";
|
||||
import { readPersistedAuthProfileStoreRaw } from "./sqlite.js";
|
||||
import {
|
||||
coerceAuthProfileState,
|
||||
@@ -555,7 +559,7 @@ function replaceMergedProfileReferences(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const next = {
|
||||
...store,
|
||||
profiles,
|
||||
...(order && Object.keys(order).length > 0 ? { order } : { order: undefined }),
|
||||
@@ -564,6 +568,13 @@ function replaceMergedProfileReferences(params: {
|
||||
? { usageStats }
|
||||
: { usageStats: undefined }),
|
||||
};
|
||||
setRuntimeExternalCliProfileIds(
|
||||
next,
|
||||
getRuntimeExternalCliProfileIds(store).map(
|
||||
(profileId) => replacements.get(profileId) ?? profileId,
|
||||
),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
function reconcileMainStoreOAuthProfileDrift(params: {
|
||||
@@ -609,7 +620,8 @@ export function mergeAuthProfileStores(
|
||||
override.runtimeLocalProfileIds === undefined &&
|
||||
override.runtimeInheritsMainState === undefined &&
|
||||
override.runtimeExternalProfileIds === undefined &&
|
||||
override.runtimeExternalProfileIdsAuthoritative !== true
|
||||
override.runtimeExternalProfileIdsAuthoritative !== true &&
|
||||
getRuntimeExternalCliProfileIds(override).length === 0
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
@@ -705,7 +717,14 @@ export function mergeAuthProfileStores(
|
||||
: {}),
|
||||
}
|
||||
: {};
|
||||
return reconcileMainStoreOAuthProfileDrift({
|
||||
const runtimeExternalCliProfileIds = [
|
||||
...getRuntimeExternalCliProfileIds(base).filter(
|
||||
(profileId) =>
|
||||
!overrideProfileIds.has(profileId) && !removedRuntimeExternalProfileIds.has(profileId),
|
||||
),
|
||||
...getRuntimeExternalCliProfileIds(override),
|
||||
];
|
||||
const result = reconcileMainStoreOAuthProfileDrift({
|
||||
base,
|
||||
override,
|
||||
merged: {
|
||||
@@ -720,6 +739,8 @@ export function mergeAuthProfileStores(
|
||||
...runtimeExternalProfileMetadata,
|
||||
},
|
||||
}) as RuntimeAuthProfileStore;
|
||||
setRuntimeExternalCliProfileIds(result, runtimeExternalCliProfileIds);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Builds the persisted secrets store, stripping resolved literals when refs exist. */
|
||||
|
||||
@@ -599,6 +599,33 @@ describe("promoteAuthProfileInOrder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist built-in CLI ownership metadata", async () => {
|
||||
await withAuthProfileTestState("openclaw-auth-cli-provenance-", async ({ agentDir }) => {
|
||||
const profileId = "openai:default";
|
||||
const runtimeStore: RuntimeAuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
access: "external-access",
|
||||
refresh: "external-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
runtimeExternalProfileIds: [profileId],
|
||||
runtimeExternalCliProfileIds: [profileId],
|
||||
};
|
||||
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: runtimeStore }]);
|
||||
|
||||
saveAuthProfileStore(runtimeStore, agentDir);
|
||||
|
||||
const persisted = loadPersistedAuthProfileStore(agentDir);
|
||||
expect(persisted).not.toHaveProperty("runtimeExternalCliProfileIds");
|
||||
expect(persisted?.profiles[profileId]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["before save", "before publication"] as const)(
|
||||
"preserves a runtime-only OAuth mutation %s",
|
||||
async (mutationTiming) => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { cloneAuthProfileStore } from "./clone.js";
|
||||
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
|
||||
|
||||
type RuntimeExternalCliStore = AuthProfileStore &
|
||||
Pick<RuntimeAuthProfileStore, "runtimeExternalCliProfileIds">;
|
||||
|
||||
export function getRuntimeExternalCliProfileIds(store: AuthProfileStore): readonly string[] {
|
||||
return (store as RuntimeExternalCliStore).runtimeExternalCliProfileIds ?? [];
|
||||
}
|
||||
|
||||
export function setRuntimeExternalCliProfileIds(
|
||||
store: AuthProfileStore,
|
||||
profileIds: Iterable<string>,
|
||||
): void {
|
||||
const ids = [...new Set(profileIds)].filter((profileId) => store.profiles[profileId]).toSorted();
|
||||
(store as RuntimeExternalCliStore).runtimeExternalCliProfileIds =
|
||||
ids.length > 0 ? ids : undefined;
|
||||
}
|
||||
|
||||
export function removeRuntimeExternalProfileReferences(params: {
|
||||
store: AuthProfileStore;
|
||||
profileIds: ReadonlySet<string>;
|
||||
}): AuthProfileStore {
|
||||
if (params.profileIds.size === 0) {
|
||||
return params.store;
|
||||
}
|
||||
const next = cloneAuthProfileStore(params.store);
|
||||
for (const profileId of params.profileIds) {
|
||||
delete next.profiles[profileId];
|
||||
if (next.usageStats) {
|
||||
delete next.usageStats[profileId];
|
||||
}
|
||||
}
|
||||
if (next.order) {
|
||||
const order = Object.fromEntries(
|
||||
Object.entries(next.order)
|
||||
.map(
|
||||
([provider, profileIds]) =>
|
||||
[
|
||||
provider,
|
||||
profileIds.filter((profileId) => !params.profileIds.has(profileId)),
|
||||
] as const,
|
||||
)
|
||||
.filter(([, profileIds]) => profileIds.length > 0),
|
||||
);
|
||||
next.order = Object.keys(order).length > 0 ? order : undefined;
|
||||
}
|
||||
if (next.lastGood) {
|
||||
const lastGood = Object.fromEntries(
|
||||
Object.entries(next.lastGood).filter(([, profileId]) => !params.profileIds.has(profileId)),
|
||||
);
|
||||
next.lastGood = Object.keys(lastGood).length > 0 ? lastGood : undefined;
|
||||
}
|
||||
if (next.usageStats && Object.keys(next.usageStats).length === 0) {
|
||||
next.usageStats = undefined;
|
||||
}
|
||||
next.runtimePersistedProfileIds = next.runtimePersistedProfileIds?.filter(
|
||||
(profileId) => !params.profileIds.has(profileId),
|
||||
);
|
||||
if (next.runtimePersistedProfileIds?.length === 0) {
|
||||
next.runtimePersistedProfileIds = undefined;
|
||||
}
|
||||
next.runtimeExternalProfileIds = next.runtimeExternalProfileIds?.filter(
|
||||
(profileId) => !params.profileIds.has(profileId),
|
||||
);
|
||||
if (
|
||||
next.runtimeExternalProfileIds?.length === 0 &&
|
||||
next.runtimeExternalProfileIdsAuthoritative !== true
|
||||
) {
|
||||
next.runtimeExternalProfileIds = undefined;
|
||||
}
|
||||
setRuntimeExternalCliProfileIds(
|
||||
next,
|
||||
getRuntimeExternalCliProfileIds(next).filter((profileId) => !params.profileIds.has(profileId)),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Carries lifecycle-owned external profiles across a durable-store refresh. */
|
||||
export function mergeRuntimeExternalProfileReferences(params: {
|
||||
next: AuthProfileStore;
|
||||
existing: AuthProfileStore;
|
||||
}): AuthProfileStore {
|
||||
const runtimeExternalProfileIds = new Set(params.existing.runtimeExternalProfileIds ?? []);
|
||||
if (params.next.runtimeExternalProfileIdsAuthoritative === true) {
|
||||
return params.next;
|
||||
}
|
||||
if (runtimeExternalProfileIds.size === 0) {
|
||||
return params.next;
|
||||
}
|
||||
const merged = cloneAuthProfileStore(params.next);
|
||||
const mergedRuntimeExternalProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
|
||||
const mergedRuntimeExternalCliProfileIds = new Set(getRuntimeExternalCliProfileIds(merged));
|
||||
const existingRuntimeExternalCliProfileIds = new Set(
|
||||
getRuntimeExternalCliProfileIds(params.existing),
|
||||
);
|
||||
const backfilledRuntimeExternalProfileIds = new Set<string>();
|
||||
for (const profileId of runtimeExternalProfileIds) {
|
||||
const existingCredential = params.existing.profiles[profileId];
|
||||
const nextCredential = merged.profiles[profileId];
|
||||
if (nextCredential) {
|
||||
if (
|
||||
mergedRuntimeExternalProfileIds.has(profileId) ||
|
||||
(existingCredential && isDeepStrictEqual(nextCredential, existingCredential))
|
||||
) {
|
||||
mergedRuntimeExternalProfileIds.add(profileId);
|
||||
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
|
||||
mergedRuntimeExternalCliProfileIds.add(profileId);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!existingCredential) {
|
||||
continue;
|
||||
}
|
||||
merged.profiles[profileId] = existingCredential;
|
||||
mergedRuntimeExternalProfileIds.add(profileId);
|
||||
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
|
||||
mergedRuntimeExternalCliProfileIds.add(profileId);
|
||||
}
|
||||
backfilledRuntimeExternalProfileIds.add(profileId);
|
||||
if (params.existing.usageStats?.[profileId]) {
|
||||
merged.usageStats = {
|
||||
...merged.usageStats,
|
||||
[profileId]: params.existing.usageStats[profileId],
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const [provider, profileIds] of Object.entries(params.existing.order ?? {})) {
|
||||
const externalProfileIds = profileIds.filter((profileId) =>
|
||||
backfilledRuntimeExternalProfileIds.has(profileId),
|
||||
);
|
||||
if (externalProfileIds.length === 0 || merged.order?.[provider]) {
|
||||
continue;
|
||||
}
|
||||
merged.order = {
|
||||
...merged.order,
|
||||
[provider]: externalProfileIds,
|
||||
};
|
||||
}
|
||||
for (const [provider, profileId] of Object.entries(params.existing.lastGood ?? {})) {
|
||||
if (!backfilledRuntimeExternalProfileIds.has(profileId) || merged.lastGood?.[provider]) {
|
||||
continue;
|
||||
}
|
||||
merged.lastGood = {
|
||||
...merged.lastGood,
|
||||
[provider]: profileId,
|
||||
};
|
||||
}
|
||||
const profileIds = [...mergedRuntimeExternalProfileIds].toSorted();
|
||||
merged.runtimeExternalProfileIds =
|
||||
profileIds.length > 0 || params.existing.runtimeExternalProfileIdsAuthoritative === true
|
||||
? profileIds
|
||||
: undefined;
|
||||
merged.runtimeExternalProfileIdsAuthoritative =
|
||||
params.existing.runtimeExternalProfileIdsAuthoritative === true ? true : undefined;
|
||||
setRuntimeExternalCliProfileIds(merged, mergedRuntimeExternalCliProfileIds);
|
||||
return merged;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
setRuntimeAuthProfileStoreSnapshot,
|
||||
} from "./runtime-snapshots.js";
|
||||
import { testing } from "./runtime-snapshots.test-support.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
|
||||
|
||||
function createStore(access: string): AuthProfileStore {
|
||||
return {
|
||||
@@ -240,6 +240,36 @@ describe("runtime auth profile snapshots", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("notifies when identical external credentials change from CLI to plugin ownership", () => {
|
||||
const agentDir = "/tmp/openclaw-auth-runtime-external-owner";
|
||||
const store: RuntimeAuthProfileStore = {
|
||||
...createStore("same-credential"),
|
||||
runtimeExternalProfileIds: ["openai:default"],
|
||||
runtimeExternalCliProfileIds: ["openai:default"],
|
||||
};
|
||||
setRuntimeAuthProfileStoreSnapshot(store, agentDir);
|
||||
const listener = vi.fn();
|
||||
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
|
||||
try {
|
||||
const pluginOwned: RuntimeAuthProfileStore = {
|
||||
...store,
|
||||
runtimeExternalCliProfileIds: undefined,
|
||||
};
|
||||
replaceRuntimeAuthProfileStoreSnapshots([
|
||||
{
|
||||
agentDir,
|
||||
store: pluginOwned,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith({ affectsInheritedStores: true });
|
||||
} finally {
|
||||
unregister();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
});
|
||||
|
||||
it("notifies when an empty runtime snapshot starts or stops shadowing persisted auth", () => {
|
||||
const agentDir = "/tmp/openclaw-auth-runtime-empty-owner";
|
||||
const listener = vi.fn();
|
||||
|
||||
@@ -144,6 +144,7 @@ function ownerState(
|
||||
| "runtimePersistedProfileIds"
|
||||
| "runtimeExternalProfileIds"
|
||||
| "runtimeExternalProfileIdsAuthoritative"
|
||||
| "runtimeExternalCliProfileIds"
|
||||
| "runtimeLocalProfileIds"
|
||||
| "runtimeInheritsMainState"
|
||||
>
|
||||
@@ -157,6 +158,7 @@ function ownerState(
|
||||
runtimePersistedProfileIds: store.runtimePersistedProfileIds,
|
||||
runtimeExternalProfileIds: store.runtimeExternalProfileIds,
|
||||
runtimeExternalProfileIdsAuthoritative: store.runtimeExternalProfileIdsAuthoritative,
|
||||
runtimeExternalCliProfileIds: store.runtimeExternalCliProfileIds,
|
||||
runtimeLocalProfileIds: store.runtimeLocalProfileIds,
|
||||
runtimeInheritsMainState: store.runtimeInheritsMainState,
|
||||
};
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
loadPersistedAuthProfileStore,
|
||||
mergeAuthProfileStores,
|
||||
} from "./persisted.js";
|
||||
import {
|
||||
getRuntimeExternalCliProfileIds,
|
||||
mergeRuntimeExternalProfileReferences,
|
||||
setRuntimeExternalCliProfileIds,
|
||||
} from "./runtime-external-profile-references.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshotCore,
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
@@ -545,6 +550,10 @@ function pruneAuthProfileStoreReferences(
|
||||
store.runtimeExternalProfileIds = store.runtimeExternalProfileIds
|
||||
?.filter((profileId) => keptProfileIds.has(profileId))
|
||||
.toSorted();
|
||||
setRuntimeExternalCliProfileIds(
|
||||
store,
|
||||
getRuntimeExternalCliProfileIds(store).filter((profileId) => keptProfileIds.has(profileId)),
|
||||
);
|
||||
if (
|
||||
store.runtimeExternalProfileIds?.length === 0 &&
|
||||
store.runtimeExternalProfileIdsAuthoritative !== true
|
||||
@@ -616,6 +625,7 @@ function buildLocalAuthProfileStoreForSave(params: {
|
||||
if (params.options?.filterExternalAuthProfiles !== false) {
|
||||
localStore.runtimeExternalProfileIds = undefined;
|
||||
localStore.runtimeExternalProfileIdsAuthoritative = undefined;
|
||||
setRuntimeExternalCliProfileIds(localStore, []);
|
||||
}
|
||||
return localStore;
|
||||
}
|
||||
@@ -646,6 +656,7 @@ function stripRuntimeExternalProfileMetadata(store: AuthProfileStore): AuthProfi
|
||||
const stripped = { ...store };
|
||||
delete stripped.runtimeExternalProfileIds;
|
||||
delete stripped.runtimeExternalProfileIdsAuthoritative;
|
||||
setRuntimeExternalCliProfileIds(stripped, []);
|
||||
return stripped;
|
||||
}
|
||||
|
||||
@@ -727,81 +738,12 @@ function setRuntimeExternalProfileMetadata(params: {
|
||||
params.store.runtimeExternalProfileIds =
|
||||
profileIds.length > 0 || params.authoritative ? profileIds : undefined;
|
||||
params.store.runtimeExternalProfileIdsAuthoritative = params.authoritative ? true : undefined;
|
||||
}
|
||||
|
||||
function mergeRuntimeExternalProfileReferences(params: {
|
||||
next: AuthProfileStore;
|
||||
existing: AuthProfileStore;
|
||||
}): AuthProfileStore {
|
||||
const runtimeExternalProfileIds = new Set(params.existing.runtimeExternalProfileIds ?? []);
|
||||
if (params.next.runtimeExternalProfileIdsAuthoritative === true) {
|
||||
return params.next;
|
||||
}
|
||||
if (runtimeExternalProfileIds.size === 0) {
|
||||
return params.next;
|
||||
}
|
||||
const merged = cloneAuthProfileStore(params.next);
|
||||
const mergedRuntimeExternalProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
|
||||
const backfilledRuntimeExternalProfileIds = new Set<string>();
|
||||
for (const profileId of runtimeExternalProfileIds) {
|
||||
const existingCredential = params.existing.profiles[profileId];
|
||||
const nextCredential = merged.profiles[profileId];
|
||||
if (nextCredential) {
|
||||
if (
|
||||
mergedRuntimeExternalProfileIds.has(profileId) ||
|
||||
(existingCredential && isDeepStrictEqual(nextCredential, existingCredential))
|
||||
) {
|
||||
mergedRuntimeExternalProfileIds.add(profileId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!existingCredential) {
|
||||
continue;
|
||||
}
|
||||
merged.profiles[profileId] = existingCredential;
|
||||
mergedRuntimeExternalProfileIds.add(profileId);
|
||||
backfilledRuntimeExternalProfileIds.add(profileId);
|
||||
if (params.existing.usageStats?.[profileId]) {
|
||||
merged.usageStats = {
|
||||
...merged.usageStats,
|
||||
[profileId]: params.existing.usageStats[profileId],
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const [provider, profileIds] of Object.entries(params.existing.order ?? {})) {
|
||||
const externalProfileIds = profileIds.filter((profileId) =>
|
||||
backfilledRuntimeExternalProfileIds.has(profileId),
|
||||
);
|
||||
if (externalProfileIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (merged.order?.[provider]) {
|
||||
continue;
|
||||
}
|
||||
const existingOrder = merged.order?.[provider] ?? [];
|
||||
merged.order = {
|
||||
...merged.order,
|
||||
[provider]: [
|
||||
...externalProfileIds,
|
||||
...existingOrder.filter((profileId) => !externalProfileIds.includes(profileId)),
|
||||
],
|
||||
};
|
||||
}
|
||||
for (const [provider, profileId] of Object.entries(params.existing.lastGood ?? {})) {
|
||||
if (!backfilledRuntimeExternalProfileIds.has(profileId) || merged.lastGood?.[provider]) {
|
||||
continue;
|
||||
}
|
||||
merged.lastGood = {
|
||||
...merged.lastGood,
|
||||
[provider]: profileId,
|
||||
};
|
||||
}
|
||||
setRuntimeExternalProfileMetadata({
|
||||
store: merged,
|
||||
profileIds: mergedRuntimeExternalProfileIds,
|
||||
authoritative: params.existing.runtimeExternalProfileIdsAuthoritative === true,
|
||||
});
|
||||
return merged;
|
||||
setRuntimeExternalCliProfileIds(
|
||||
params.store,
|
||||
getRuntimeExternalCliProfileIds(params.store).filter((profileId) =>
|
||||
params.profileIds.has(profileId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function preserveResolvedSecretBackedCredentials(params: {
|
||||
@@ -844,6 +786,10 @@ function mergeRuntimeExternalProfileState(params: {
|
||||
}
|
||||
const merged = cloneAuthProfileStore(params.next);
|
||||
const mergedRuntimeProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
|
||||
const existingRuntimeExternalCliProfileIds = new Set(
|
||||
getRuntimeExternalCliProfileIds(params.existing),
|
||||
);
|
||||
const mergedRuntimeExternalCliProfileIds = new Set(getRuntimeExternalCliProfileIds(merged));
|
||||
const activeRuntimeProfileIds = new Set<string>();
|
||||
const nextRuntimeProfileIdsAuthoritative =
|
||||
params.next.runtimeExternalProfileIdsAuthoritative === true;
|
||||
@@ -863,12 +809,18 @@ function mergeRuntimeExternalProfileState(params: {
|
||||
) {
|
||||
mergedRuntimeProfileIds.add(profileId);
|
||||
activeRuntimeProfileIds.add(profileId);
|
||||
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
|
||||
mergedRuntimeExternalCliProfileIds.add(profileId);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
merged.profiles[profileId] = existingCredential;
|
||||
mergedRuntimeProfileIds.add(profileId);
|
||||
activeRuntimeProfileIds.add(profileId);
|
||||
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
|
||||
mergedRuntimeExternalCliProfileIds.add(profileId);
|
||||
}
|
||||
}
|
||||
if (activeRuntimeProfileIds.size === 0) {
|
||||
return params.next;
|
||||
@@ -907,6 +859,7 @@ function mergeRuntimeExternalProfileState(params: {
|
||||
profileIds: mergedRuntimeProfileIds,
|
||||
authoritative: params.existing.runtimeExternalProfileIdsAuthoritative === true,
|
||||
});
|
||||
setRuntimeExternalCliProfileIds(merged, mergedRuntimeExternalCliProfileIds);
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ export type AuthProfileStore = AuthProfileSecretsStore &
|
||||
|
||||
/** Internal effective-store ownership metadata; never exposed through the plugin SDK. */
|
||||
export type RuntimeAuthProfileStore = AuthProfileStore & {
|
||||
/** Runtime-only built-in CLI winners; internal provenance, never exposed or persisted. */
|
||||
runtimeExternalCliProfileIds?: string[];
|
||||
runtimeLocalProfileIds?: string[];
|
||||
runtimeInheritsMainState?: boolean;
|
||||
};
|
||||
|
||||
@@ -101,6 +101,40 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
|
||||
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
|
||||
});
|
||||
|
||||
it("keeps automatic wildcard reads on prepared facts", async () => {
|
||||
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
|
||||
readOnly ? readOnlyCatalog : fullCatalog,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadPreparedModelCatalogSnapshotForBrowse({
|
||||
cfg: config({ providerWildcard: true }),
|
||||
view: "configured",
|
||||
preparedOnly: true,
|
||||
loadCatalog,
|
||||
}),
|
||||
).resolves.toBe(readOnlyCatalog);
|
||||
|
||||
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true });
|
||||
});
|
||||
|
||||
it("forwards explicit refresh to full discovery", async () => {
|
||||
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
|
||||
readOnly ? readOnlyCatalog : fullCatalog,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadPreparedModelCatalogSnapshotForBrowse({
|
||||
cfg: config(),
|
||||
view: "all",
|
||||
refresh: true,
|
||||
loadCatalog,
|
||||
}),
|
||||
).resolves.toBe(fullCatalog);
|
||||
|
||||
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false, refresh: true });
|
||||
});
|
||||
|
||||
it("uses the read-only catalog for provider-config views without picker allowlists", async () => {
|
||||
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
|
||||
readOnly ? readOnlyCatalog : fullCatalog,
|
||||
|
||||
@@ -75,18 +75,22 @@ async function loadCatalogForBrowse<T>(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
view?: ModelCatalogBrowseView;
|
||||
loadCatalog: (params: { readOnly: boolean }) => Promise<T>;
|
||||
preparedOnly?: boolean;
|
||||
refresh?: boolean;
|
||||
loadCatalog: (params: { readOnly: boolean; refresh?: boolean }) => Promise<T>;
|
||||
empty: T;
|
||||
timeoutFullDiscovery?: boolean;
|
||||
timeoutMs?: number;
|
||||
onTimeout?: (timeoutMs: number) => void;
|
||||
}): Promise<T> {
|
||||
const view = params.view ?? "default";
|
||||
const requiresFullDiscovery = modelCatalogBrowseRequiresFullDiscovery({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
view,
|
||||
});
|
||||
const requiresFullDiscovery =
|
||||
params.preparedOnly !== true &&
|
||||
modelCatalogBrowseRequiresFullDiscovery({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
view,
|
||||
});
|
||||
// Provider-policy wildcards newly escalate ordinary inventory views to live discovery.
|
||||
// Keep those implicit loads within the browse deadline; explicit all/configured loads retain
|
||||
// their existing completion semantics unless the caller requests a timeout.
|
||||
@@ -94,12 +98,18 @@ async function loadCatalogForBrowse<T>(params: {
|
||||
params.timeoutFullDiscovery ||
|
||||
(requiresFullDiscovery && (view === "default" || view === "provider-config"));
|
||||
if (requiresFullDiscovery && !shouldTimeoutFullDiscovery) {
|
||||
return await params.loadCatalog({ readOnly: false });
|
||||
return await params.loadCatalog({
|
||||
readOnly: false,
|
||||
...(params.refresh ? { refresh: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const timeoutMs = resolveModelCatalogBrowseTimeoutMs(params.timeoutMs);
|
||||
const catalogPromise = params.loadCatalog({ readOnly: !requiresFullDiscovery });
|
||||
const catalogPromise = params.loadCatalog({
|
||||
readOnly: !requiresFullDiscovery,
|
||||
...(requiresFullDiscovery && params.refresh ? { refresh: true } : {}),
|
||||
});
|
||||
const catalogResult = catalogPromise.then((value) => ({ kind: "catalog" as const, value }));
|
||||
const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => {
|
||||
timeout = globalThis.setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
||||
@@ -127,7 +137,11 @@ export function loadPreparedModelCatalogSnapshotForBrowse(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
view?: ModelCatalogBrowseView;
|
||||
loadCatalog: (params: { readOnly: boolean }) => Promise<ModelCatalogSnapshot>;
|
||||
/** Never starts provider discovery; a completed generation cache may still be reused. */
|
||||
preparedOnly?: boolean;
|
||||
/** Replaces the completed generation cache when discovery is otherwise required. */
|
||||
refresh?: boolean;
|
||||
loadCatalog: (params: { readOnly: boolean; refresh?: boolean }) => Promise<ModelCatalogSnapshot>;
|
||||
timeoutFullDiscovery?: boolean;
|
||||
timeoutMs?: number;
|
||||
onTimeout?: (timeoutMs: number) => void;
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
loadPreparedGatewayModelCatalogSnapshot,
|
||||
} from "../gateway/server-model-catalog.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { OPENAI_CODEX_DEFAULT_PROFILE_ID } from "./auth-profiles/constants.js";
|
||||
import { getRuntimeExternalCliProfileIds } from "./auth-profiles/runtime-external-profile-references.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
} from "./auth-profiles/runtime-snapshots.js";
|
||||
import { saveAuthProfileStore } from "./auth-profiles/store.js";
|
||||
import { ensureAuthProfileStore, saveAuthProfileStore } from "./auth-profiles/store.js";
|
||||
import {
|
||||
encodePluginModelCatalogRelativePath,
|
||||
PLUGIN_MODEL_CATALOG_GENERATED_BY,
|
||||
@@ -47,10 +49,6 @@ const REF_ONLY_TOKEN_PROVIDER_ID = `${PROVIDER_ID}-ref-token`;
|
||||
const REF_ONLY_TOKEN_ENV = "OPENCLAW_WORKER_REF_ONLY_TOKEN";
|
||||
const DURABLE_AUTH_PROVIDER_ID = `${PROVIDER_ID}-durable-auth`;
|
||||
const DURABLE_AUTH_KEY = "post-startup-durable-key-not-real";
|
||||
const WORKSPACE_AUTH_PLUGIN_ID = `${PLUGIN_ID}-workspace-auth`;
|
||||
const WORKSPACE_AUTH_PROVIDER_ID = `${PROVIDER_ID}-workspace-auth`;
|
||||
const WORKSPACE_AUTH_PROFILE_ID = `${WORKSPACE_AUTH_PROVIDER_ID}:workspace`;
|
||||
const WORKSPACE_AUTH_KEY = "workspace-external-auth-key-not-real";
|
||||
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
|
||||
afterEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
@@ -59,11 +57,30 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
|
||||
});
|
||||
});
|
||||
|
||||
function createJwtWithExp(exp: number): string {
|
||||
const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
|
||||
function createJwtWithExp(exp: number, marker?: string): string {
|
||||
const payload = Buffer.from(JSON.stringify({ exp, ...(marker ? { marker } : {}) })).toString(
|
||||
"base64url",
|
||||
);
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
|
||||
function writeCodexAuth(codexHome: string, marker: string): void {
|
||||
const authPath = path.join(codexHome, "auth.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: createJwtWithExp(Math.floor(Date.now() / 1000) + 3600, marker),
|
||||
refresh_token: `refresh-${marker}-not-real`,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const future = new Date(Date.now() + 2_000);
|
||||
fs.utimesSync(authPath, future, future);
|
||||
}
|
||||
|
||||
function writeFixturePlugin(params: { root: string; spinMs: number }): string {
|
||||
const pluginDir = path.join(params.root, "plugin");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
@@ -139,57 +156,11 @@ module.exports = {
|
||||
return pluginFile;
|
||||
}
|
||||
|
||||
function writeWorkspaceExternalAuthPlugin(workspaceDir: string): void {
|
||||
const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", WORKSPACE_AUTH_PLUGIN_ID);
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `@openclaw/${WORKSPACE_AUTH_PLUGIN_ID}`,
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: WORKSPACE_AUTH_PLUGIN_ID,
|
||||
providers: [WORKSPACE_AUTH_PROVIDER_ID],
|
||||
contracts: { externalAuthProviders: [WORKSPACE_AUTH_PROVIDER_ID] },
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.cjs"),
|
||||
`module.exports = {
|
||||
id: ${JSON.stringify(WORKSPACE_AUTH_PLUGIN_ID)},
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: ${JSON.stringify(WORKSPACE_AUTH_PROVIDER_ID)},
|
||||
label: "Workspace external auth fixture",
|
||||
auth: [],
|
||||
resolveExternalAuthProfiles() {
|
||||
return [{
|
||||
profileId: ${JSON.stringify(WORKSPACE_AUTH_PROFILE_ID)},
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: ${JSON.stringify(WORKSPACE_AUTH_PROVIDER_ID)},
|
||||
key: ${JSON.stringify(WORKSPACE_AUTH_KEY)},
|
||||
},
|
||||
persistence: "runtime-only",
|
||||
}];
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessEnv = {}) {
|
||||
async function createStaticSnapshot(
|
||||
spinMs: number,
|
||||
envOverride: NodeJS.ProcessEnv = {},
|
||||
options?: { hydrateExternalCliProviderIds?: readonly string[] },
|
||||
) {
|
||||
const root = tempDirs.make("openclaw-model-catalog-worker-");
|
||||
const stateDir = path.join(root, "state");
|
||||
const agentDir = path.join(stateDir, "agents", "main", "agent");
|
||||
@@ -198,7 +169,6 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
const pluginFile = writeFixturePlugin({ root, spinMs });
|
||||
writeWorkspaceExternalAuthPlugin(workspaceDir);
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
||||
@@ -211,7 +181,7 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
|
||||
const config = {
|
||||
agents: { defaults: { model: `${PROVIDER_ID}/sqlite-model` } },
|
||||
plugins: {
|
||||
allow: [PLUGIN_ID, WORKSPACE_AUTH_PLUGIN_ID],
|
||||
allow: [PLUGIN_ID],
|
||||
load: { paths: [pluginFile] },
|
||||
entries: { [PLUGIN_ID]: { enabled: true } },
|
||||
},
|
||||
@@ -239,6 +209,15 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
|
||||
},
|
||||
},
|
||||
]);
|
||||
const hydratedAuthStore = options?.hydrateExternalCliProviderIds
|
||||
? ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
config,
|
||||
externalCliProviderIds: options.hydrateExternalCliProviderIds,
|
||||
readOnly: true,
|
||||
syncExternalCli: false,
|
||||
})
|
||||
: undefined;
|
||||
replacePersistedPluginModelCatalogs({
|
||||
agentDir,
|
||||
pluginCatalogWrites: {
|
||||
@@ -268,6 +247,7 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
|
||||
config,
|
||||
env,
|
||||
marker,
|
||||
hydratedAuthStore,
|
||||
pluginMetadataSnapshot: build.pluginGeneration.pluginMetadataSnapshot,
|
||||
snapshot: build.snapshot,
|
||||
supersede: () => (current = false),
|
||||
@@ -352,32 +332,6 @@ describe("prepared model catalog worker boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes workspace plugin external auth in both worker operations", async () => {
|
||||
const fixture = await createStaticSnapshot(0);
|
||||
|
||||
const refreshed = await loadPreparedModelRuntimeAuth(fixture.snapshot, [
|
||||
WORKSPACE_AUTH_PROVIDER_ID,
|
||||
]);
|
||||
expect(refreshed).toMatchObject({
|
||||
authModes: { [WORKSPACE_AUTH_PROVIDER_ID]: "api_key" },
|
||||
authStore: {
|
||||
profiles: {
|
||||
[WORKSPACE_AUTH_PROFILE_ID]: expect.objectContaining({ key: WORKSPACE_AUTH_KEY }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const catalog = await fixture.snapshot.loadFullModelCatalog?.();
|
||||
expect(getPreparedModelFullCatalogAuth(catalog!)).toMatchObject({
|
||||
authModes: { [WORKSPACE_AUTH_PROVIDER_ID]: "api_key" },
|
||||
authStore: {
|
||||
profiles: {
|
||||
[WORKSPACE_AUTH_PROFILE_ID]: expect.objectContaining({ key: WORKSPACE_AUTH_KEY }),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes durable auth profiles added, updated, and removed after startup", async () => {
|
||||
const fixture = await createStaticSnapshot(0);
|
||||
const route = {
|
||||
@@ -407,7 +361,7 @@ describe("prepared model catalog worker boundary", () => {
|
||||
modelCatalog: { entries: [route], routeVariants: [route] },
|
||||
});
|
||||
const project = async () => {
|
||||
const fullCatalog = await fixture.snapshot.loadFullModelCatalog?.();
|
||||
const fullCatalog = await fixture.snapshot.loadFullModelCatalog?.({ refresh: true });
|
||||
const fullAuth = fullCatalog && getPreparedModelFullCatalogAuth(fullCatalog);
|
||||
if (!fullAuth) {
|
||||
throw new Error("full catalog omitted prepared auth");
|
||||
@@ -558,7 +512,7 @@ describe("prepared model catalog worker boundary", () => {
|
||||
loadGatewayModelCatalogSnapshot: loadSnapshot,
|
||||
logGateway: { debug: () => undefined },
|
||||
} as unknown as GatewayRequestContext;
|
||||
return await buildModelsListResult({ context, params: { view: "all" } });
|
||||
return await buildModelsListResult({ context, params: { view: "all", refresh: true } });
|
||||
};
|
||||
|
||||
await expect(listModels()).resolves.toMatchObject({
|
||||
@@ -585,7 +539,52 @@ describe("prepared model catalog worker boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shares in-flight discovery but reruns completed refreshes with prepared auth and SQLite facts", async () => {
|
||||
it("refreshes and removes a Codex login that existed in the prepared generation", async () => {
|
||||
const codexHome = tempDirs.make("openclaw-prepared-codex-");
|
||||
writeCodexAuth(codexHome, "startup");
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
let fixture: Awaited<ReturnType<typeof createStaticSnapshot>>;
|
||||
try {
|
||||
fixture = await createStaticSnapshot(0, {}, { hydrateExternalCliProviderIds: ["openai"] });
|
||||
} finally {
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
}
|
||||
const preparedStore = getPreparedModelRuntimeAuthStore(fixture.snapshot);
|
||||
expect(fixture.hydratedAuthStore?.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-startup-not-real",
|
||||
});
|
||||
expect(preparedStore?.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-startup-not-real",
|
||||
});
|
||||
expect(preparedStore && getRuntimeExternalCliProfileIds(preparedStore)).toEqual([
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
]);
|
||||
|
||||
writeCodexAuth(codexHome, "rotated");
|
||||
const rotated = await loadPreparedModelRuntimeAuth(fixture.snapshot, {
|
||||
providerIds: [],
|
||||
profileIds: [OPENAI_CODEX_DEFAULT_PROFILE_ID],
|
||||
});
|
||||
expect(rotated?.authStore.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-rotated-not-real",
|
||||
});
|
||||
|
||||
fs.rmSync(path.join(codexHome, "auth.json"));
|
||||
const loggedOut = await loadPreparedModelRuntimeAuth(fixture.snapshot, {
|
||||
providerIds: ["openai"],
|
||||
});
|
||||
expect(loggedOut?.authStore.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shares in-flight discovery, caches completion, and explicitly refreshes prepared facts", async () => {
|
||||
const fixture = await createStaticSnapshot(750);
|
||||
let settled = false;
|
||||
const first = fixture.snapshot.loadFullModelCatalog?.().finally(() => {
|
||||
@@ -603,7 +602,8 @@ describe("prepared model catalog worker boundary", () => {
|
||||
id: "proof-refresh-1-sqlite-true-shared-true-unrelated-true",
|
||||
}),
|
||||
);
|
||||
await expect(fixture.snapshot.loadFullModelCatalog?.()).resolves.toEqual(
|
||||
await expect(fixture.snapshot.loadFullModelCatalog?.()).resolves.toBe(catalog);
|
||||
await expect(fixture.snapshot.loadFullModelCatalog?.({ refresh: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
entries: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { createPreparedModelCatalogWorkerInput } from "./prepared-model-catalog-worker.js";
|
||||
import {
|
||||
createPreparedModelAuthRefreshWorkerInput,
|
||||
createPreparedModelCatalogWorkerInput,
|
||||
} from "./prepared-model-catalog-worker.js";
|
||||
import type { PreparedModelRuntimeAgentFacts } from "./prepared-model-runtime.facts.js";
|
||||
|
||||
vi.mock("../plugins/manifest-registry-installed.js", () => ({
|
||||
@@ -8,6 +11,27 @@ vi.mock("../plugins/manifest-registry-installed.js", () => ({
|
||||
}));
|
||||
|
||||
describe("prepared model catalog worker input", () => {
|
||||
it("serializes and fingerprints auth refresh profile scope", () => {
|
||||
const params = {
|
||||
agentDir: "/tmp/agent",
|
||||
authStore: { version: 1, profiles: {} },
|
||||
config: {},
|
||||
env: {},
|
||||
providerIds: ["openai"],
|
||||
};
|
||||
const scoped = createPreparedModelAuthRefreshWorkerInput({
|
||||
...params,
|
||||
profileIds: ["openai:work", "openai:default", "openai:work"],
|
||||
});
|
||||
const other = createPreparedModelAuthRefreshWorkerInput({
|
||||
...params,
|
||||
profileIds: ["openai:default"],
|
||||
});
|
||||
|
||||
expect(structuredClone(scoped).profileIds).toEqual(["openai:default", "openai:work"]);
|
||||
expect(scoped.generationFingerprint).not.toBe(other.generationFingerprint);
|
||||
});
|
||||
|
||||
it("preserves SecretRef identity beside materialized literals", () => {
|
||||
const authStore = {
|
||||
version: 1,
|
||||
|
||||
@@ -30,10 +30,10 @@ export type PreparedModelAuthRefreshWorkerInput = Readonly<{
|
||||
generationFingerprint: string;
|
||||
agentDir: string;
|
||||
inheritedAuthDir?: string;
|
||||
workspaceDir?: string;
|
||||
authStore: AuthProfileStore;
|
||||
config: PreparedModelRuntimeInput["config"];
|
||||
env: NodeJS.ProcessEnv;
|
||||
profileIds?: readonly string[];
|
||||
providerIds: readonly string[];
|
||||
}>;
|
||||
|
||||
@@ -140,10 +140,10 @@ export function createPreparedModelCatalogWorkerInput(params: {
|
||||
export function createPreparedModelAuthRefreshWorkerInput(params: {
|
||||
agentDir: string;
|
||||
inheritedAuthDir?: string;
|
||||
workspaceDir?: string;
|
||||
authStore: AuthProfileStore;
|
||||
config: PreparedModelRuntimeInput["config"];
|
||||
env: NodeJS.ProcessEnv;
|
||||
profileIds?: readonly string[];
|
||||
providerIds: readonly string[];
|
||||
}): PreparedModelAuthRefreshWorkerInput {
|
||||
const providerIds = [...new Set(params.providerIds)].toSorted((left, right) =>
|
||||
@@ -151,23 +151,26 @@ export function createPreparedModelAuthRefreshWorkerInput(params: {
|
||||
);
|
||||
const authStore = cloneAuthProfileStore(params.authStore);
|
||||
const env = { ...params.env };
|
||||
const profileIds = params.profileIds
|
||||
? [...new Set(params.profileIds)].toSorted((left, right) => left.localeCompare(right))
|
||||
: undefined;
|
||||
return {
|
||||
kind: "auth-refresh",
|
||||
agentDir: params.agentDir,
|
||||
...(params.inheritedAuthDir ? { inheritedAuthDir: params.inheritedAuthDir } : {}),
|
||||
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||
generationFingerprint: fingerprintPreparedRuntimeFacts({
|
||||
agentDir: params.agentDir,
|
||||
inheritedAuthDir: params.inheritedAuthDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
authStore,
|
||||
config: params.config,
|
||||
env,
|
||||
profileIds,
|
||||
providerIds,
|
||||
}),
|
||||
authStore,
|
||||
config: params.config,
|
||||
env,
|
||||
...(profileIds ? { profileIds } : {}),
|
||||
providerIds,
|
||||
};
|
||||
}
|
||||
@@ -230,7 +233,11 @@ function runPreparedModelWorker<T>(params: {
|
||||
worker.removeAllListeners();
|
||||
const finish = () => {
|
||||
if (outcome.status === "resolved") {
|
||||
resolve(outcome.value);
|
||||
if (params.isCurrent()) {
|
||||
resolve(outcome.value);
|
||||
} else {
|
||||
reject(superseded());
|
||||
}
|
||||
} else {
|
||||
reject(outcome.error);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalo
|
||||
import type { ResolvedPublishedModelCatalogOwner } from "./prepared-model-catalog.types.js";
|
||||
import {
|
||||
getPreparedModelRuntimeAuthMaterializations,
|
||||
loadPreparedModelRuntimeAuth,
|
||||
setPreparedModelRuntimeAuthMaterializations,
|
||||
setPreparedModelRuntimeAuthLoader,
|
||||
setPreparedModelRuntimeAuthStore,
|
||||
} from "./prepared-model-runtime-auth.js";
|
||||
import { isPreparedModelCatalogFull } from "./prepared-model-runtime.facts.js";
|
||||
@@ -48,6 +50,8 @@ export type LoadPreparedModelCatalogParams = {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
/** Rebuilds a completed full catalog instead of reusing this generation's cache. */
|
||||
refreshFullCatalog?: boolean;
|
||||
/** Scoped read-only loads may run live discovery for the scoped providers only. */
|
||||
scopedLiveProviderDiscovery?: boolean;
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
@@ -63,11 +67,18 @@ type PreparedModelCatalogConfigPolicy = "exact" | "published";
|
||||
async function materializeRequestedModelCatalog(
|
||||
snapshot: PreparedModelRuntimeSnapshot,
|
||||
readOnly: boolean | undefined,
|
||||
refreshFullCatalog: boolean | undefined,
|
||||
): Promise<PreparedModelRuntimeSnapshot> {
|
||||
if (readOnly === true || !snapshot.loadFullModelCatalog) {
|
||||
if (!snapshot.loadFullModelCatalog) {
|
||||
return snapshot;
|
||||
}
|
||||
const modelCatalog =
|
||||
readOnly === true
|
||||
? snapshot.readFullModelCatalog?.()
|
||||
: await snapshot.loadFullModelCatalog({ refresh: refreshFullCatalog === true });
|
||||
if (!modelCatalog) {
|
||||
return snapshot;
|
||||
}
|
||||
const modelCatalog = await snapshot.loadFullModelCatalog();
|
||||
const fullAuth = getPreparedModelFullCatalogAuth(modelCatalog);
|
||||
if (!fullAuth) {
|
||||
throw new Error("prepared full model catalog omitted its auth generation");
|
||||
@@ -77,9 +88,13 @@ async function materializeRequestedModelCatalog(
|
||||
authModes: fullAuth.authModes,
|
||||
modelCatalog,
|
||||
});
|
||||
// A materialized full read owns the worker's refreshed auth generation. Do not copy the
|
||||
// original loader or Gateway projection would start a second, split refresh after discovery.
|
||||
setPreparedModelRuntimeAuthStore(materialized, fullAuth.authStore);
|
||||
// Later explicit auth refreshes stay bound to the original owner generation. Ordinary reads
|
||||
// consume the full worker's paired auth without invoking this loader.
|
||||
setPreparedModelRuntimeAuthLoader(
|
||||
materialized,
|
||||
async (scope) => (await loadPreparedModelRuntimeAuth(snapshot, scope)) ?? fullAuth,
|
||||
);
|
||||
setPreparedModelRuntimeAuthMaterializations(
|
||||
materialized,
|
||||
getPreparedModelRuntimeAuthMaterializations(snapshot),
|
||||
@@ -288,6 +303,7 @@ async function loadPreparedModelCatalogOwnerSnapshotWithPolicy(
|
||||
return await materializeRequestedModelCatalog(
|
||||
await resolvePreparedModelCatalogOwnerSnapshotWithPolicy(params, configPolicy),
|
||||
params.readOnly,
|
||||
params.refreshFullCatalog,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
resolveUsableAgentCredentialModes,
|
||||
} from "./agent-auth-credentials.js";
|
||||
import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js";
|
||||
import { overlayExternalAuthProfiles } from "./auth-profiles/external-auth.js";
|
||||
import { overlayExternalCliAuthProfiles } from "./auth-profiles/external-auth.js";
|
||||
import { listExternalCliSyncProviderIds } from "./auth-profiles/external-cli-sync.js";
|
||||
import { mergeRuntimeExternalProfileReferences } from "./auth-profiles/runtime-external-profile-references.js";
|
||||
import { replaceRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js";
|
||||
import {
|
||||
loadAuthProfileStoreWithoutExternalProfiles,
|
||||
@@ -27,7 +28,7 @@ function refreshAuthStore(params: {
|
||||
authStore: PreparedModelCatalogWorkerInput["authStore"];
|
||||
config: PreparedModelCatalogWorkerInput["input"]["config"];
|
||||
env: NodeJS.ProcessEnv;
|
||||
workspaceDir?: string;
|
||||
profileIds?: readonly string[];
|
||||
providerIds?: readonly string[];
|
||||
}) {
|
||||
const durable = preserveResolvedSecretBackedCredentials({
|
||||
@@ -48,12 +49,15 @@ function refreshAuthStore(params: {
|
||||
durable.profiles[profileId] = credential;
|
||||
}
|
||||
}
|
||||
return overlayExternalAuthProfiles(durable, {
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
const prepared = mergeRuntimeExternalProfileReferences({
|
||||
next: durable,
|
||||
existing: params.authStore,
|
||||
});
|
||||
return overlayExternalCliAuthProfiles(prepared, {
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
...(params.providerIds ? { externalCliProviderIds: params.providerIds } : {}),
|
||||
...(params.profileIds ? { externalCliProfileIds: params.profileIds } : {}),
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
}
|
||||
@@ -66,10 +70,10 @@ export async function runPreparedModelCatalogWorkerInput(
|
||||
const authStore = refreshAuthStore({
|
||||
agentDir: value.agentDir,
|
||||
inheritedAuthDir: value.inheritedAuthDir,
|
||||
workspaceDir: value.workspaceDir,
|
||||
authStore: value.authStore,
|
||||
config: value.config,
|
||||
env: value.env,
|
||||
...(value.profileIds ? { profileIds: value.profileIds } : {}),
|
||||
providerIds: value.providerIds,
|
||||
});
|
||||
return {
|
||||
@@ -100,17 +104,14 @@ export async function runPreparedModelCatalogWorkerInput(
|
||||
}
|
||||
// Full discovery is one point-in-time operation: refresh first, then let every provider hook
|
||||
// and the returned availability projection consume the same exact store.
|
||||
const authStore = withPluginRuntimeRegistryScope(prepared.pluginGeneration.pluginRegistry, () =>
|
||||
refreshAuthStore({
|
||||
agentDir: value.input.agentDir,
|
||||
inheritedAuthDir: value.input.inheritedAuthDir,
|
||||
authStore: value.authStore,
|
||||
config: value.input.config,
|
||||
env: value.input.env ?? process.env,
|
||||
providerIds: listExternalCliSyncProviderIds(),
|
||||
workspaceDir: value.input.workspaceDir,
|
||||
}),
|
||||
);
|
||||
const authStore = refreshAuthStore({
|
||||
agentDir: value.input.agentDir,
|
||||
inheritedAuthDir: value.input.inheritedAuthDir,
|
||||
authStore: value.authStore,
|
||||
config: value.input.config,
|
||||
env: value.input.env ?? process.env,
|
||||
providerIds: listExternalCliSyncProviderIds(),
|
||||
});
|
||||
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir: value.input.agentDir, store: authStore }]);
|
||||
const ambientCredentials = withPluginRuntimeRegistryScope(
|
||||
prepared.pluginGeneration.pluginRegistry,
|
||||
|
||||
@@ -7,12 +7,17 @@ export type PreparedModelRuntimeAuth = Readonly<{
|
||||
authModes: PreparedAgentCredentialModes;
|
||||
}>;
|
||||
|
||||
export type PreparedModelRuntimeAuthScope = Readonly<{
|
||||
providerIds: readonly string[];
|
||||
profileIds?: readonly string[];
|
||||
}>;
|
||||
|
||||
/** Private auth facts owned by an immutable prepared model generation. */
|
||||
const authStoreBySnapshot = new WeakMap<object, AuthProfileStore>();
|
||||
const materializationsBySnapshot = new WeakMap<object, readonly RuntimeAuthMaterialization[]>();
|
||||
const authLoaderBySnapshot = new WeakMap<
|
||||
object,
|
||||
(providerIds: readonly string[]) => Promise<PreparedModelRuntimeAuth>
|
||||
(scope: PreparedModelRuntimeAuthScope) => Promise<PreparedModelRuntimeAuth>
|
||||
>();
|
||||
|
||||
// Secret-bearing state stays lifecycle-owned without becoming part of the public snapshot shape.
|
||||
@@ -29,18 +34,18 @@ export function getPreparedModelRuntimeAuthStore(snapshot: object): AuthProfileS
|
||||
|
||||
export function setPreparedModelRuntimeAuthLoader(
|
||||
snapshot: object,
|
||||
loader: (providerIds: readonly string[]) => Promise<PreparedModelRuntimeAuth>,
|
||||
loader: (scope: PreparedModelRuntimeAuthScope) => Promise<PreparedModelRuntimeAuth>,
|
||||
): void {
|
||||
authLoaderBySnapshot.set(snapshot, loader);
|
||||
}
|
||||
|
||||
export async function loadPreparedModelRuntimeAuth(
|
||||
snapshot: object & { authModes?: PreparedAgentCredentialModes },
|
||||
providerIds: readonly string[],
|
||||
scope: PreparedModelRuntimeAuthScope,
|
||||
): Promise<PreparedModelRuntimeAuth | undefined> {
|
||||
const loader = authLoaderBySnapshot.get(snapshot);
|
||||
if (loader) {
|
||||
return await loader(providerIds);
|
||||
return await loader(scope);
|
||||
}
|
||||
const authStore = authStoreBySnapshot.get(snapshot);
|
||||
return authStore ? { authStore, authModes: snapshot.authModes ?? {} } : undefined;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
setPreparedModelRuntimeAuthLoader,
|
||||
setPreparedModelRuntimeAuthStore,
|
||||
type PreparedModelRuntimeAuth,
|
||||
type PreparedModelRuntimeAuthScope,
|
||||
} from "./prepared-model-runtime-auth.js";
|
||||
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
|
||||
import {
|
||||
@@ -49,8 +50,9 @@ const MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS = 1;
|
||||
const limitFullModelCatalogBuild = pLimit(MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS);
|
||||
|
||||
type PreparedModelRuntimeCatalogAccess = Readonly<{
|
||||
loadFullModelCatalog: () => Promise<ModelCatalogSnapshot>;
|
||||
loadAuth: (providerIds: readonly string[]) => Promise<PreparedModelRuntimeAuth>;
|
||||
readFullModelCatalog: () => ModelCatalogSnapshot | undefined;
|
||||
loadFullModelCatalog: (options?: { refresh?: boolean }) => Promise<ModelCatalogSnapshot>;
|
||||
loadAuth: (scope: PreparedModelRuntimeAuthScope) => Promise<PreparedModelRuntimeAuth>;
|
||||
}>;
|
||||
type PreparedModelRuntimeBuildGuards =
|
||||
| ReadonlyMap<PreparedModelRuntimeInput, () => boolean>
|
||||
@@ -119,8 +121,9 @@ function createFullModelCatalogAccess(params: {
|
||||
agentBuildCompletions: Map<string, Promise<void>>;
|
||||
isCurrent: () => boolean;
|
||||
}): PreparedModelRuntimeCatalogAccess {
|
||||
// Concurrent readers share discovery, but completed results are discarded so
|
||||
// refreshable providers can publish changed inventory on the next explicit read.
|
||||
// The completed catalog is generation-owned. Explicit refresh replaces it only after a
|
||||
// successful build, so failed refreshes cannot discard the last verified inventory.
|
||||
let fullCatalog: ModelCatalogSnapshot | undefined;
|
||||
let pending: Promise<ModelCatalogSnapshot> | undefined;
|
||||
let pendingAuth:
|
||||
| {
|
||||
@@ -136,27 +139,25 @@ function createFullModelCatalogAccess(params: {
|
||||
}
|
||||
};
|
||||
return {
|
||||
loadAuth: (providerIds) => {
|
||||
loadAuth: ({ providerIds, profileIds }) => {
|
||||
const key = [...new Set(providerIds)]
|
||||
.toSorted((left, right) => left.localeCompare(right))
|
||||
.join("\0");
|
||||
if (key.length === 0) {
|
||||
return Promise.resolve({
|
||||
authStore: params.agentFacts.authStore,
|
||||
authModes: resolveUsableAgentCredentialModes(params.agentFacts.credentials),
|
||||
});
|
||||
}
|
||||
if (pendingAuth?.key === key) {
|
||||
const profileKey = [...new Set(profileIds ?? [])]
|
||||
.toSorted((left, right) => left.localeCompare(right))
|
||||
.join("\0");
|
||||
const cacheKey = `${key}\0\0${profileKey}`;
|
||||
if (pendingAuth?.key === cacheKey) {
|
||||
return pendingAuth.promise;
|
||||
}
|
||||
const input = createPreparedModelAuthRefreshWorkerInput({
|
||||
agentDir: params.agentFacts.input.agentDir,
|
||||
inheritedAuthDir: params.agentFacts.input.inheritedAuthDir,
|
||||
workspaceDir: params.agentFacts.input.workspaceDir,
|
||||
authStore: params.agentFacts.authStore,
|
||||
config: params.agentFacts.input.config,
|
||||
env: params.agentFacts.env,
|
||||
providerIds,
|
||||
...(profileIds?.length ? { profileIds } : {}),
|
||||
});
|
||||
const promise = runPreparedModelAuthRefreshWorker({
|
||||
input,
|
||||
@@ -177,12 +178,24 @@ function createFullModelCatalogAccess(params: {
|
||||
pendingAuth = undefined;
|
||||
}
|
||||
});
|
||||
pendingAuth = { key, promise };
|
||||
pendingAuth = { key: cacheKey, promise };
|
||||
return promise;
|
||||
},
|
||||
loadFullModelCatalog: () => {
|
||||
readFullModelCatalog: () => {
|
||||
assertCurrent();
|
||||
return fullCatalog;
|
||||
},
|
||||
loadFullModelCatalog: (options) => {
|
||||
try {
|
||||
assertCurrent();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!options?.refresh && fullCatalog) {
|
||||
return Promise.resolve(fullCatalog);
|
||||
}
|
||||
if (!pending) {
|
||||
pending = runSerializedPreparedModelRuntimeTask({
|
||||
const build = runSerializedPreparedModelRuntimeTask({
|
||||
agentDir: params.agentFacts.input.agentDir,
|
||||
agentBuildCompletions: params.agentBuildCompletions,
|
||||
isCurrent: params.isCurrent,
|
||||
@@ -201,9 +214,15 @@ function createFullModelCatalogAccess(params: {
|
||||
assertCurrent();
|
||||
return catalog;
|
||||
}),
|
||||
}).finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
pending = build
|
||||
.then((catalog) => {
|
||||
fullCatalog = catalog;
|
||||
return catalog;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
@@ -241,6 +260,7 @@ function createSnapshot(
|
||||
...(messageToolCatalog ? { messageToolCatalog } : {}),
|
||||
...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}),
|
||||
modelCatalog,
|
||||
readFullModelCatalog: catalogAccess.readFullModelCatalog,
|
||||
loadFullModelCatalog: catalogAccess.loadFullModelCatalog,
|
||||
configuredRuntimeModels,
|
||||
inlineProviderModels,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
discoverModels,
|
||||
discoverModelsFromCapturedSources,
|
||||
} from "./agent-model-discovery.js";
|
||||
import { getPreparedRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/store.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import {
|
||||
buildInlineProviderModels,
|
||||
@@ -122,12 +123,25 @@ function prepareAgentFacts(
|
||||
additionalProviderIds: readonly string[] = [],
|
||||
): PreparedModelRuntimeAgentBaseFacts {
|
||||
const env = input.env ?? process.env;
|
||||
const publishedStore = getPreparedRuntimeAuthProfileStoreSnapshot(
|
||||
input.agentDir,
|
||||
input.inheritedAuthDir,
|
||||
);
|
||||
// Runtime-only external profiles exist only in the published auth generation. Re-reading the
|
||||
// durable store here would erase startup hydration before this owner can carry it forward.
|
||||
const preparedStore =
|
||||
publishedStore &&
|
||||
(publishedStore.runtimeExternalProfileIds !== undefined ||
|
||||
publishedStore.runtimeExternalProfileIdsAuthoritative === true)
|
||||
? publishedStore
|
||||
: undefined;
|
||||
const authFacts = discoverAuthStorageFacts(input.agentDir, {
|
||||
config: input.config,
|
||||
// Prepared owners consume only the already-published runtime auth generation. External CLI
|
||||
// hydration belongs to startup/control-plane and turn-time producers, never rebuilds.
|
||||
readOnly: true,
|
||||
ambientCredentials,
|
||||
...(preparedStore ? { preparedStore } : {}),
|
||||
...(input.skipCredentials ? { skipCredentials: true } : {}),
|
||||
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
|
||||
@@ -168,6 +168,7 @@ vi.mock("./agent-scope.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/runtime-snapshots.js", () => ({
|
||||
getPreparedRuntimeAuthProfileStoreSnapshotCore: () => undefined,
|
||||
registerRuntimeAuthProfileStoreMutationListener: (
|
||||
listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void,
|
||||
) => {
|
||||
@@ -416,7 +417,17 @@ describe("prepared model runtime Gateway catalog mode", () => {
|
||||
routeVariants: [],
|
||||
});
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce();
|
||||
expect(snapshot?.readFullModelCatalog?.()).toEqual({ entries: [], routeVariants: [] });
|
||||
|
||||
await snapshot?.loadFullModelCatalog?.({ refresh: true });
|
||||
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2);
|
||||
mocks.runPreparedModelCatalogWorker.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
await expect(snapshot?.loadFullModelCatalog?.({ refresh: true })).rejects.toThrow(
|
||||
"refresh failed",
|
||||
);
|
||||
expect(snapshot?.readFullModelCatalog?.()).toEqual({ entries: [], routeVariants: [] });
|
||||
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce();
|
||||
expect(mocks.discoverModels).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -425,7 +436,7 @@ describe("prepared model runtime Gateway catalog mode", () => {
|
||||
affectsInheritedStores: false,
|
||||
});
|
||||
await expect(snapshot?.loadFullModelCatalog?.()).rejects.toThrow("superseded");
|
||||
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("publishes exact dynamic configured models without building a live catalog", async () => {
|
||||
|
||||
@@ -53,8 +53,10 @@ export type PreparedModelRuntimeSnapshot = Readonly<{
|
||||
* Full inventory discovery is deliberately outside the startup publication boundary.
|
||||
*/
|
||||
modelCatalog: ModelCatalogSnapshot;
|
||||
/** Reads a completed full catalog without starting provider discovery. */
|
||||
readFullModelCatalog?: () => ModelCatalogSnapshot | undefined;
|
||||
/** Builds this generation's full control-plane catalog without replacing turn facts. */
|
||||
loadFullModelCatalog?: () => Promise<ModelCatalogSnapshot>;
|
||||
loadFullModelCatalog?: (options?: { refresh?: boolean }) => Promise<ModelCatalogSnapshot>;
|
||||
/** Full static models for configured refs, resolved once at the lifecycle boundary. */
|
||||
configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[];
|
||||
/** Inline provider projection prepared once for all resolutions owned by this snapshot. */
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("local gateway request context", () => {
|
||||
|
||||
const list = () =>
|
||||
withLocalGatewayRequestScope({ deps: {} as CliDeps, getRuntimeConfig: () => cfg }, () =>
|
||||
dispatchGatewayMethodInProcessRaw("models.list", { view: "all" }),
|
||||
dispatchGatewayMethodInProcessRaw("models.list", { view: "configured", refresh: true }),
|
||||
);
|
||||
const loggedIn = await list();
|
||||
const loggedOut = await list();
|
||||
@@ -162,10 +162,14 @@ describe("local gateway request context", () => {
|
||||
});
|
||||
expect(loggedOut).toMatchObject({
|
||||
ok: true,
|
||||
payload: { models: [expect.objectContaining({ id: "local-auth-model", available: false })] },
|
||||
payload: { models: [] },
|
||||
});
|
||||
expect(refreshAuth).toHaveBeenNthCalledWith(1, {
|
||||
providerIds: ["local-auth-provider"],
|
||||
});
|
||||
expect(refreshAuth).toHaveBeenNthCalledWith(2, {
|
||||
providerIds: ["local-auth-provider"],
|
||||
});
|
||||
expect(refreshAuth).toHaveBeenNthCalledWith(1, ["local-auth-provider"]);
|
||||
expect(refreshAuth).toHaveBeenNthCalledWith(2, ["local-auth-provider"]);
|
||||
loadOwner.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ function createLocalGatewayRequestContext(
|
||||
loadPreparedGatewayModelCatalogSnapshot({
|
||||
...loadParams,
|
||||
getConfig: params.getRuntimeConfig,
|
||||
refreshAuth: true,
|
||||
}),
|
||||
readPrepared: (loadParams) =>
|
||||
readPreparedGatewayModelCatalogOwnerSnapshot({
|
||||
|
||||
@@ -95,8 +95,7 @@ const readPreparedGatewayModelCatalogOwnerSnapshot: ReadPreparedGatewayModelCata
|
||||
};
|
||||
|
||||
registerGatewayModelCatalogPrivateAccess(loadGatewayModelCatalogSnapshot, {
|
||||
loadDeferred: (params) =>
|
||||
loadPreparedGatewayModelCatalogSnapshot({ ...params, refreshAuth: true }),
|
||||
loadDeferred: (params) => loadPreparedGatewayModelCatalogSnapshot(params),
|
||||
readPrepared: readPreparedGatewayModelCatalogOwnerSnapshot,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,11 +32,6 @@ const mocks = vi.hoisted(() => ({
|
||||
agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`,
|
||||
),
|
||||
resolveDefaultAgentId: vi.fn(() => "main"),
|
||||
ensureAuthProfileStore: vi.fn((agentDir?: string, options?: unknown): AuthProfileStore => {
|
||||
void agentDir;
|
||||
void options;
|
||||
return { version: 1, profiles: {} };
|
||||
}),
|
||||
ensureAuthProfileStoreWithoutExternalProfiles: vi.fn((agentDir?: string): AuthProfileStore => {
|
||||
void agentDir;
|
||||
return { version: 1, profiles: {} };
|
||||
@@ -49,10 +44,11 @@ const mocks = vi.hoisted(() => ({
|
||||
resolvePersistedAuthProfileOwnerAgentDir: vi.fn(
|
||||
(params: { agentDir?: string }) => params.agentDir,
|
||||
),
|
||||
clearRuntimeAuthProfileStoreSnapshots: vi.fn(),
|
||||
refreshActiveProviderAuthRuntimeSnapshot: vi.fn(async () => false),
|
||||
clearCurrentProviderAuthState: vi.fn(),
|
||||
warmCurrentProviderAuthStateOffMainThread: vi.fn(async (_cfg: unknown) => {}),
|
||||
loadDeferredCatalog: vi.fn(),
|
||||
readPreparedCatalog: vi.fn(),
|
||||
buildAuthHealthSummary: vi.fn<BuildAuthHealthSummary>(
|
||||
(): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }),
|
||||
),
|
||||
@@ -75,14 +71,12 @@ vi.mock("../../agents/auth-profiles.js", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
|
||||
ensureAuthProfileStoreWithoutExternalProfiles:
|
||||
mocks.ensureAuthProfileStoreWithoutExternalProfiles,
|
||||
listProfilesForProvider: mocks.listProfilesForProvider,
|
||||
removeAuthProfilesAcrossOwnerStores: mocks.removeAuthProfilesAcrossOwnerStores,
|
||||
removeProviderAuthProfilesWithLock: mocks.removeProviderAuthProfilesWithLock,
|
||||
resolvePersistedAuthProfileOwnerAgentDir: mocks.resolvePersistedAuthProfileOwnerAgentDir,
|
||||
clearRuntimeAuthProfileStoreSnapshots: mocks.clearRuntimeAuthProfileStoreSnapshots,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -109,6 +103,11 @@ vi.mock("../../agents/model-provider-auth.js", () => ({
|
||||
warmCurrentProviderAuthStateOffMainThread: mocks.warmCurrentProviderAuthStateOffMainThread,
|
||||
}));
|
||||
|
||||
vi.mock("../server-model-catalog-auth.js", () => ({
|
||||
loadDeferredCatalog: mocks.loadDeferredCatalog,
|
||||
readPreparedCatalog: mocks.readPreparedCatalog,
|
||||
}));
|
||||
|
||||
import {
|
||||
aggregateRefreshableAuthStatus,
|
||||
invalidateModelAuthStatusCache,
|
||||
@@ -196,6 +195,35 @@ function createLogoutOptions(
|
||||
}
|
||||
|
||||
const requireRecord = createRequireRecord("record", "expected-non-array-record");
|
||||
let preparedAuthStore: AuthProfileStore = { version: 1, profiles: {} };
|
||||
let preparedMetadataSnapshot: unknown;
|
||||
|
||||
function setPreparedAuthStore(store: AuthProfileStore): void {
|
||||
preparedAuthStore = store;
|
||||
}
|
||||
|
||||
function setPreparedMetadataSnapshot(snapshot: unknown): void {
|
||||
preparedMetadataSnapshot = snapshot;
|
||||
}
|
||||
|
||||
function createPreparedOwnerSnapshot(agentId: string) {
|
||||
const config = mocks.getRuntimeConfig.mock.results.at(-1)?.value ?? {};
|
||||
const agentDir =
|
||||
mocks.resolveAgentDir.mock.results.at(-1)?.value ??
|
||||
(agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`);
|
||||
return {
|
||||
agentId,
|
||||
agentDir,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config,
|
||||
entries: [],
|
||||
routeVariants: [],
|
||||
authModes: {},
|
||||
authStore: preparedAuthStore,
|
||||
authMaterializations: [],
|
||||
metadataSnapshot: preparedMetadataSnapshot as never,
|
||||
};
|
||||
}
|
||||
|
||||
function firstRespondCall(
|
||||
opts: GatewayRequestHandlerOptions & { respond: ReturnType<typeof vi.fn> },
|
||||
@@ -203,10 +231,6 @@ function firstRespondCall(
|
||||
return opts.respond.mock.calls[0];
|
||||
}
|
||||
|
||||
function firstEnsureAuthProfileStoreCall() {
|
||||
return mocks.ensureAuthProfileStore.mock.calls[0];
|
||||
}
|
||||
|
||||
function firstBuildAuthHealthSummaryCall() {
|
||||
return mocks.buildAuthHealthSummary.mock.calls[0] as unknown as
|
||||
| [{ providers?: string[]; allowKeychainPrompt?: boolean }]
|
||||
@@ -246,7 +270,18 @@ function resetAuthStatusMocks(): void {
|
||||
agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`,
|
||||
);
|
||||
mocks.resolveDefaultAgentId.mockReturnValue("main");
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
|
||||
setPreparedAuthStore({ version: 1, profiles: {} });
|
||||
setPreparedMetadataSnapshot({
|
||||
index: { plugins: [] },
|
||||
manifestRegistry: { plugins: [] },
|
||||
plugins: [],
|
||||
});
|
||||
mocks.readPreparedCatalog.mockImplementation(async (_context, agentId: string) =>
|
||||
createPreparedOwnerSnapshot(agentId),
|
||||
);
|
||||
mocks.loadDeferredCatalog.mockImplementation(async (_context, agentId: string) =>
|
||||
createPreparedOwnerSnapshot(agentId),
|
||||
);
|
||||
mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({
|
||||
version: 1,
|
||||
profiles: {},
|
||||
@@ -267,17 +302,20 @@ function resetAuthStatusMocks(): void {
|
||||
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockResolvedValue(false);
|
||||
}
|
||||
|
||||
function firstDeferredAuthScope() {
|
||||
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
|
||||
const [, agentId, options] = mocks.loadDeferredCatalog.mock.calls[0] ?? [];
|
||||
expect(agentId).toBe("main");
|
||||
const deferredOptions = requireRecord(options);
|
||||
expect(deferredOptions.readOnly).toBe(true);
|
||||
expect(deferredOptions.refreshAuth).toBe(true);
|
||||
return requireRecord(deferredOptions.authScope);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function firstExternalCliAuthOption() {
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
|
||||
expect(firstEnsureAuthProfileStoreCall()?.[0]).toBe("/tmp/agent");
|
||||
const [, options] = firstEnsureAuthProfileStoreCall() ?? [];
|
||||
return requireRecord(requireRecord(options).externalCli);
|
||||
}
|
||||
|
||||
function expectLogoutFailurePreservesRun(params: {
|
||||
opts: ReturnType<typeof createLogoutOptions>;
|
||||
runId: string;
|
||||
@@ -358,10 +396,7 @@ describe("models.authStatus", () => {
|
||||
await handler(opts);
|
||||
|
||||
expect(mocks.resolveAgentDir).toHaveBeenCalledWith(cfg, expectedAgentId);
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith(
|
||||
expectedAgentId === "main" ? "/tmp/agent" : "/tmp/agent-writer",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.readPreparedCatalog).toHaveBeenCalledWith(expect.anything(), expectedAgentId);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -374,7 +409,8 @@ describe("models.authStatus", () => {
|
||||
await handler(opts);
|
||||
|
||||
expect(mocks.resolveAgentDir).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAuthProfileStore).not.toHaveBeenCalled();
|
||||
expect(mocks.readPreparedCatalog).not.toHaveBeenCalled();
|
||||
expect(mocks.loadDeferredCatalog).not.toHaveBeenCalled();
|
||||
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).not.toHaveBeenCalled();
|
||||
const [ok, payload, error] = firstRespondCall(opts) ?? [];
|
||||
expect(ok).toBe(false);
|
||||
@@ -395,10 +431,7 @@ describe("models.authStatus", () => {
|
||||
await handler(opts);
|
||||
|
||||
expect(mocks.resolveAgentDir).toHaveBeenCalledWith(cfg, "_writer");
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith(
|
||||
"/tmp/agent-_writer",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.readPreparedCatalog).toHaveBeenCalledWith(expect.anything(), "_writer");
|
||||
expect(firstRespondCall(opts)?.[0]).toBe(true);
|
||||
});
|
||||
|
||||
@@ -413,7 +446,7 @@ describe("models.authStatus", () => {
|
||||
await handler(opts);
|
||||
|
||||
expect(mocks.resolveAgentDir).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAuthProfileStore).not.toHaveBeenCalled();
|
||||
expect(mocks.readPreparedCatalog).not.toHaveBeenCalled();
|
||||
expect(firstRespondCall(opts)?.[2]).toEqual({
|
||||
code: "INVALID_REQUEST",
|
||||
message: `unknown agent id "${agentId}"`,
|
||||
@@ -422,7 +455,7 @@ describe("models.authStatus", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("rebuilds fresh auth snapshots for each requested agent", async () => {
|
||||
it("reads the published auth owner for each requested agent", async () => {
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } };
|
||||
mocks.getRuntimeConfig.mockReturnValue(cfg);
|
||||
mocks.listAgentIds.mockReturnValue(["main", "writer"]);
|
||||
@@ -432,22 +465,11 @@ describe("models.authStatus", () => {
|
||||
const freshMain = createOptions({ agentId: "main" });
|
||||
await handler(freshMain);
|
||||
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/tmp/agent",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/tmp/agent-writer",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"/tmp/agent",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.readPreparedCatalog).toHaveBeenNthCalledWith(1, expect.anything(), "main");
|
||||
expect(mocks.readPreparedCatalog).toHaveBeenNthCalledWith(2, expect.anything(), "writer");
|
||||
expect(mocks.readPreparedCatalog).toHaveBeenNthCalledWith(3, expect.anything(), "main");
|
||||
expect(mocks.readPreparedCatalog).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.loadDeferredCatalog).not.toHaveBeenCalled();
|
||||
expect(firstRespondCall(freshMain)?.[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -462,13 +484,15 @@ describe("models.authStatus", () => {
|
||||
await handler(createOptions({ refresh: true }));
|
||||
|
||||
expect(mocks.getRuntimeConfig).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.readPreparedCatalog).not.toHaveBeenCalled();
|
||||
expect(mocks.loadDeferredCatalog).toHaveBeenCalledOnce();
|
||||
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cfg: after }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a serialisable snapshot on first call", async () => {
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
@@ -509,9 +533,52 @@ describe("models.authStatus", () => {
|
||||
expect(result.providers[0]?.profiles[0]?.logoutSupported).toBe(true);
|
||||
});
|
||||
|
||||
it("projects provider capabilities from the published lifecycle metadata", async () => {
|
||||
const plugins = [
|
||||
{
|
||||
id: "provider-auth",
|
||||
origin: "bundled",
|
||||
providerAuthAliases: { "openai-legacy": "openai" },
|
||||
providerAuthChoices: [
|
||||
{
|
||||
provider: "openai-legacy",
|
||||
method: "api-key",
|
||||
choiceId: "openai-api-key",
|
||||
choiceLabel: "OpenAI API key",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
method: "oauth",
|
||||
choiceId: "openai-oauth",
|
||||
choiceLabel: "OpenAI OAuth",
|
||||
},
|
||||
{
|
||||
provider: "github-copilot",
|
||||
method: "oauth",
|
||||
choiceId: "github-copilot-oauth",
|
||||
choiceLabel: "GitHub Copilot OAuth",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
setPreparedMetadataSnapshot({
|
||||
index: { plugins: [] },
|
||||
manifestRegistry: { plugins },
|
||||
plugins,
|
||||
});
|
||||
|
||||
const result = await readAuthStatus();
|
||||
|
||||
expect(result.providerCapabilities).toEqual([
|
||||
{ provider: "github-copilot", apiKeySupported: false, quickApiKeySetup: false },
|
||||
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not offer logout for runtime external CLI profiles", async () => {
|
||||
const health = createOpenAiCodexOauthHealthSummary();
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles: {},
|
||||
runtimeExternalProfileIds: ["openai:default"],
|
||||
@@ -540,7 +607,7 @@ describe("models.authStatus", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: { type: "token", provider: "openrouter", token: "placeholder" },
|
||||
@@ -736,7 +803,7 @@ describe("models.authStatus", () => {
|
||||
providers: { anthropic: Object.fromEntries([["apiKey", profileId]]) },
|
||||
},
|
||||
});
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
@@ -809,20 +876,16 @@ describe("models.authStatus", () => {
|
||||
await handler(createOptions({ refresh: true }));
|
||||
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).toHaveBeenCalledTimes(1);
|
||||
const clearOrder = mocks.clearRuntimeAuthProfileStoreSnapshots.mock.invocationCallOrder[0];
|
||||
const refreshReadOrder = mocks.ensureAuthProfileStore.mock.invocationCallOrder.at(-1);
|
||||
expect(clearOrder).toBeLessThan(refreshReadOrder ?? 0);
|
||||
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps refreshed secrets runtime snapshots on explicit refresh", async () => {
|
||||
it("refreshes the transient owner after secrets runtime refresh", async () => {
|
||||
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockResolvedValueOnce(true);
|
||||
|
||||
await handler(createOptions({ refresh: true }));
|
||||
|
||||
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps last-good secrets runtime snapshots when explicit refresh fails", async () => {
|
||||
@@ -833,8 +896,7 @@ describe("models.authStatus", () => {
|
||||
await handler(createOptions({ refresh: true }));
|
||||
|
||||
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invalidateModelAuthStatusCache() preserves fresh auth reads", async () => {
|
||||
@@ -1125,7 +1187,7 @@ describe("models.authStatus", () => {
|
||||
|
||||
it("does not reuse usage after credentials rotate within the same provider", async () => {
|
||||
mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary());
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
@@ -1154,7 +1216,7 @@ describe("models.authStatus", () => {
|
||||
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
|
||||
});
|
||||
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
@@ -1224,7 +1286,7 @@ describe("models.authStatus", () => {
|
||||
expires: 1_000_000,
|
||||
},
|
||||
};
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles,
|
||||
lastGood: { openai: "openai:first" },
|
||||
@@ -1246,7 +1308,7 @@ describe("models.authStatus", () => {
|
||||
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
|
||||
});
|
||||
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
setPreparedAuthStore({
|
||||
version: 1,
|
||||
profiles,
|
||||
lastGood: { openai: "openai:second" },
|
||||
@@ -1279,24 +1341,19 @@ describe("models.authStatus", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await handler(createOptions());
|
||||
await handler(createOptions({ refresh: true }));
|
||||
|
||||
const externalCli = firstExternalCliAuthOption();
|
||||
expect(externalCli.mode).toBe("scoped");
|
||||
expect(externalCli.allowKeychainPrompt).toBe(false);
|
||||
requireRecord(externalCli.config);
|
||||
expect(externalCli.providerIds).toContain("opencode-go");
|
||||
expect(externalCli.providerIds).not.toContain("claude-cli");
|
||||
expect(externalCli.profileIds).toEqual(["opencode-go:default"]);
|
||||
const authScope = firstDeferredAuthScope();
|
||||
expect(authScope.providerIds).toContain("opencode-go");
|
||||
expect(authScope.providerIds).not.toContain("claude-cli");
|
||||
expect(authScope.profileIds).toEqual(["opencode-go:default"]);
|
||||
});
|
||||
|
||||
it("disables external CLI auth overlays when config has no provider signal", async () => {
|
||||
await handler(createOptions());
|
||||
await handler(createOptions({ refresh: true }));
|
||||
|
||||
const externalCli = firstExternalCliAuthOption();
|
||||
expect(externalCli.mode).toBe("none");
|
||||
expect(externalCli.allowKeychainPrompt).toBe(false);
|
||||
requireRecord(externalCli.config);
|
||||
const authScope = firstDeferredAuthScope();
|
||||
expect(authScope).toEqual({ providerIds: [] });
|
||||
});
|
||||
|
||||
it("still returns providers when usage fetch fails", async () => {
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
} from "../../agents/auth-health.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
ensureAuthProfileStore,
|
||||
ensureAuthProfileStoreWithoutExternalProfiles,
|
||||
externalCliDiscoveryForConfigStatus,
|
||||
listProfilesForProvider,
|
||||
@@ -49,8 +47,11 @@ import { coerceSecretRef, hasConfiguredSecretInput } from "../../config/types.se
|
||||
import { providerUsageLabel, resolveUsageProviderId } from "../../infra/provider-usage.shared.js";
|
||||
import type { UsageProviderId } from "../../infra/provider-usage.types.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-choices.js";
|
||||
import { refreshActiveProviderAuthRuntimeSnapshot } from "../../secrets/runtime.js";
|
||||
import { supportsSetupManualSecret } from "../../system-agent/setup-inference-auth-options.js";
|
||||
import { abortChatRunsForProvider, type ChatAbortOps } from "../chat-abort.js";
|
||||
import { loadDeferredCatalog, readPreparedCatalog } from "../server-model-catalog-auth.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import { modelAuthAgentScopeError, resolveModelAuthAgentScope } from "./model-auth-agent-scope.js";
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ import type {
|
||||
ModelAuthLogoutResult,
|
||||
ModelAuthStatusProvider,
|
||||
ModelAuthStatusResult,
|
||||
ModelProviderCapability,
|
||||
} from "./models-auth-status.types.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
@@ -73,11 +75,63 @@ export type {
|
||||
ModelAuthStatusProfile,
|
||||
ModelAuthStatusProvider,
|
||||
ModelAuthStatusResult,
|
||||
ModelProviderCapability,
|
||||
} from "./models-auth-status.types.js";
|
||||
|
||||
const log = createSubsystemLogger("models-auth-status");
|
||||
const apiKeyUsageStatusProviders = new Set<UsageProviderId>(["clawrouter", "deepseek"]);
|
||||
|
||||
function buildProviderCapabilities(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
metadataSnapshot: NonNullable<
|
||||
Awaited<ReturnType<typeof readPreparedCatalog>>
|
||||
>["metadataSnapshot"];
|
||||
}): ModelProviderCapability[] {
|
||||
const capabilities = new Map<string, ModelProviderCapability>();
|
||||
for (const choice of resolveManifestProviderAuthChoices({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
})) {
|
||||
const provider = resolveProviderIdForAuth(choice.providerId, {
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
});
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
const current = capabilities.get(provider);
|
||||
const apiKeySupported = choice.methodId === "api-key";
|
||||
const quickApiKeySetup = apiKeySupported && supportsSetupManualSecret(choice);
|
||||
capabilities.set(provider, {
|
||||
provider,
|
||||
apiKeySupported: current?.apiKeySupported === true || apiKeySupported,
|
||||
quickApiKeySetup: current?.quickApiKeySetup === true || quickApiKeySetup,
|
||||
});
|
||||
}
|
||||
return [...capabilities.values()].toSorted((a, b) => a.provider.localeCompare(b.provider));
|
||||
}
|
||||
|
||||
function resolveAuthRefreshScope(cfg: OpenClawConfig): {
|
||||
providerIds: string[];
|
||||
profileIds?: string[];
|
||||
} {
|
||||
const discovery = externalCliDiscoveryForConfigStatus({ cfg });
|
||||
if (discovery.mode !== "scoped") {
|
||||
return { providerIds: [] };
|
||||
}
|
||||
const providerIds = [...(discovery.providerIds ?? [])];
|
||||
const profileIds = [...(discovery.profileIds ?? [])];
|
||||
return {
|
||||
providerIds,
|
||||
...(profileIds.length > 0 ? { profileIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate auxiliary usage and prepared provider-auth state after an auth
|
||||
* mutation. Auth health itself is rebuilt on every request; only outbound
|
||||
@@ -99,16 +153,10 @@ async function refreshModelAuthStatusRuntimeState(): Promise<void> {
|
||||
// still uses invalidateModelAuthStatusCache() and clears usage immediately.
|
||||
clearCurrentProviderAuthState();
|
||||
try {
|
||||
if (await refreshActiveProviderAuthRuntimeSnapshot()) {
|
||||
return;
|
||||
}
|
||||
await refreshActiveProviderAuthRuntimeSnapshot();
|
||||
} catch (err) {
|
||||
log.warn(`runtime auth snapshot refresh before auth status failed: ${formatForLog(err)}`);
|
||||
return;
|
||||
}
|
||||
// Explicit status refresh follows CLI/doctor repairs. If no secrets runtime is
|
||||
// active, drop runtime auth snapshots so the next status read observes disk.
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
|
||||
function readProviderParam(params: Record<string, unknown>): string | null {
|
||||
@@ -578,12 +626,18 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { agentId, agentDir } = scope;
|
||||
// Use the external-profile-aware store for status reads so the dashboard
|
||||
// reflects CLI-discovered credentials without persisting them here.
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
externalCli: externalCliDiscoveryForConfigStatus({ cfg }),
|
||||
});
|
||||
const preparedSnapshot = refreshRequested
|
||||
? await loadDeferredCatalog(context, scope.agentId, {
|
||||
readOnly: true,
|
||||
authScope: resolveAuthRefreshScope(cfg),
|
||||
refreshAuth: true,
|
||||
})
|
||||
: await readPreparedCatalog(context, scope.agentId);
|
||||
if (!preparedSnapshot) {
|
||||
throw new Error(`prepared model auth owner is unavailable (${scope.agentId})`);
|
||||
}
|
||||
cfg = preparedSnapshot.config;
|
||||
const { agentId, agentDir, authStore: store, workspaceDir } = preparedSnapshot;
|
||||
const apiKeys = resolveProviderApiKeys(cfg, store);
|
||||
const configured = resolveConfiguredProviders(cfg, apiKeys);
|
||||
const statusProviderIds = new Set(configured.providers);
|
||||
@@ -601,6 +655,11 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
cfg,
|
||||
providers: statusProviderIds.size > 0 ? [...statusProviderIds] : undefined,
|
||||
allowKeychainPrompt: false,
|
||||
authAliasLookupParams: {
|
||||
workspaceDir,
|
||||
metadataSnapshot: preparedSnapshot.metadataSnapshot,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Usage queries usually need refreshable credentials. Keep API-key status
|
||||
@@ -657,7 +716,12 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
configBoundProfileIds,
|
||||
),
|
||||
);
|
||||
const result: ModelAuthStatusResult = { ts: now, providers };
|
||||
const providerCapabilities = buildProviderCapabilities({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
metadataSnapshot: preparedSnapshot.metadataSnapshot,
|
||||
});
|
||||
const result: ModelAuthStatusResult = { ts: now, providers, providerCapabilities };
|
||||
respond(true, result, undefined);
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
|
||||
@@ -47,10 +47,18 @@ export type ModelAuthStatusProvider = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ModelProviderCapability = {
|
||||
provider: string;
|
||||
apiKeySupported: boolean;
|
||||
quickApiKeySetup: boolean;
|
||||
};
|
||||
|
||||
export type ModelAuthStatusResult = {
|
||||
/** Snapshot build time, ms since epoch. 0 = never loaded (UI fallback sentinel). */
|
||||
ts: number;
|
||||
providers: ModelAuthStatusProvider[];
|
||||
/** Process-stable provider setup capabilities from the active plugin generation. */
|
||||
providerCapabilities?: ModelProviderCapability[];
|
||||
};
|
||||
|
||||
export type ModelAuthLogoutResult = {
|
||||
|
||||
@@ -511,6 +511,8 @@ export async function buildModelsListResult(
|
||||
const initialConfig = params.context.getRuntimeConfig();
|
||||
const initialAgentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(initialConfig));
|
||||
const view = resolveModelsListView(params.params);
|
||||
const preparedOnly = params.params.preparedOnly === true;
|
||||
const refresh = params.params.refresh === true;
|
||||
const preloadedCatalog =
|
||||
params.preloadedCatalog?.agentId === initialAgentId &&
|
||||
preparedModelRuntimeConfigsMatch(params.preloadedCatalog.config, initialConfig)
|
||||
@@ -532,6 +534,8 @@ export async function buildModelsListResult(
|
||||
cfg: initialConfig,
|
||||
agentId: initialAgentId,
|
||||
view,
|
||||
preparedOnly,
|
||||
refresh,
|
||||
loadCatalog: async (loadParams) => {
|
||||
loadedReadOnly = loadParams.readOnly ?? true;
|
||||
// A read-only preload cannot satisfy a full-discovery request. Reuse it only when the
|
||||
@@ -546,7 +550,11 @@ export async function buildModelsListResult(
|
||||
if (params.preloadedOnly) {
|
||||
return { entries: [], routeVariants: [] };
|
||||
}
|
||||
loadedSnapshot = await loadDeferredCatalog(params.context, initialAgentId, loadedReadOnly);
|
||||
loadedSnapshot = await loadDeferredCatalog(params.context, initialAgentId, {
|
||||
readOnly: loadedReadOnly,
|
||||
refreshAuth: refresh && loadedReadOnly,
|
||||
refreshFullCatalog: loadParams.refresh === true,
|
||||
});
|
||||
return loadedSnapshot;
|
||||
},
|
||||
onTimeout: handleCatalogTimeout,
|
||||
@@ -554,6 +562,7 @@ export async function buildModelsListResult(
|
||||
if (
|
||||
loadedSnapshot &&
|
||||
loadedReadOnly &&
|
||||
!preparedOnly &&
|
||||
modelCatalogBrowseRequiresFullDiscovery({
|
||||
cfg: loadedSnapshot.config,
|
||||
agentId: loadedSnapshot.agentId,
|
||||
@@ -567,8 +576,13 @@ export async function buildModelsListResult(
|
||||
cfg: loadedSnapshot.config,
|
||||
agentId: escalationAgentId,
|
||||
view,
|
||||
refresh,
|
||||
loadCatalog: async ({ readOnly }) => {
|
||||
fullSnapshot = await loadDeferredCatalog(params.context, escalationAgentId, readOnly);
|
||||
fullSnapshot = await loadDeferredCatalog(params.context, escalationAgentId, {
|
||||
readOnly,
|
||||
refreshAuth: refresh && readOnly,
|
||||
refreshFullCatalog: refresh,
|
||||
});
|
||||
return fullSnapshot;
|
||||
},
|
||||
timeoutFullDiscovery: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
// Models gateway methods expose model catalog browse results without triggering
|
||||
// auth probes or fresh provider discovery on each request.
|
||||
// Models gateway methods expose prepared, cached, and explicitly refreshed catalog views.
|
||||
import { validateModelsListParams } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
|
||||
import { buildModelsListResult } from "./models-list-result.js";
|
||||
@@ -9,9 +8,7 @@ import { assertValidParams } from "./validation.js";
|
||||
|
||||
export { buildModelsListResult };
|
||||
|
||||
// The gateway model list is a browse API, not an auth probe. It reuses the
|
||||
// current runtime catalog snapshot and applies visibility rules without doing
|
||||
// extra runtime discovery on each request.
|
||||
// Automatic clients opt into preparedOnly; omitted mode preserves shipped wildcard discovery.
|
||||
export const modelsHandlers: GatewayRequestHandlers = {
|
||||
"models.list": async ({ params, respond, context }) => {
|
||||
if (!assertValidParams(params, validateModelsListParams, "models.list", respond)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeAuthMaterialization } from "../agents/auth-profiles/runtime-materializations.js";
|
||||
import type { ResolvedPublishedModelCatalogOwner } from "../agents/prepared-model-catalog.types.js";
|
||||
import type { PreparedModelRuntimeAuthScope } from "../agents/prepared-model-runtime-auth.js";
|
||||
import type { GatewayRequestContext } from "./server-methods/shared-types.js";
|
||||
import type { GatewayModelCatalogSnapshot } from "./server-model-catalog.types.js";
|
||||
|
||||
@@ -11,7 +12,10 @@ export type PreparedGatewayModelCatalogSnapshot = GatewayModelCatalogSnapshot &
|
||||
type GatewayModelCatalogReadParams = {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
authScope?: PreparedModelRuntimeAuthScope;
|
||||
readOnly?: boolean;
|
||||
refreshAuth?: boolean;
|
||||
refreshFullCatalog?: boolean;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
@@ -50,9 +54,12 @@ function requirePrivateAccess(
|
||||
export async function loadDeferredCatalog(
|
||||
context: Pick<GatewayRequestContext, "loadGatewayModelCatalogSnapshot">,
|
||||
agentId: string,
|
||||
readOnly: boolean,
|
||||
options: Pick<
|
||||
GatewayModelCatalogReadParams,
|
||||
"authScope" | "readOnly" | "refreshAuth" | "refreshFullCatalog"
|
||||
>,
|
||||
): Promise<PreparedGatewayModelCatalogSnapshot> {
|
||||
return await requirePrivateAccess(context).loadDeferred({ agentId, readOnly });
|
||||
return await requirePrivateAccess(context).loadDeferred({ agentId, ...options });
|
||||
}
|
||||
|
||||
export async function readPreparedCatalog(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
|
||||
import type { PublishedModelCatalogOwnerCandidate } from "../agents/prepared-model-catalog.types.js";
|
||||
import { setPreparedModelRuntimeAuthLoader } from "../agents/prepared-model-runtime-auth.js";
|
||||
import { PreparedModelRuntimePublicationSupersededError } from "../agents/prepared-model-runtime.errors.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
loadDeferredCatalog,
|
||||
@@ -134,24 +135,34 @@ describe("gateway prepared model catalog", () => {
|
||||
}),
|
||||
);
|
||||
registerGatewayModelCatalogPrivateAccess(publicLoader, {
|
||||
loadDeferred: () =>
|
||||
loadDeferred: (params) =>
|
||||
loadPreparedGatewayModelCatalogSnapshot({
|
||||
...params,
|
||||
getConfig: () => config,
|
||||
loadPublishedPreparedModelCatalogOwnerSnapshot,
|
||||
refreshAuth: true,
|
||||
}),
|
||||
readPrepared: async () => undefined,
|
||||
});
|
||||
const loaded = await loadDeferredCatalog(
|
||||
{ loadGatewayModelCatalogSnapshot: publicLoader },
|
||||
"main",
|
||||
true,
|
||||
{
|
||||
readOnly: true,
|
||||
authScope: {
|
||||
providerIds: ["openai"],
|
||||
profileIds: ["openai:refreshed"],
|
||||
},
|
||||
refreshAuth: true,
|
||||
},
|
||||
);
|
||||
expect(loaded.authStore).toEqual(
|
||||
expect.objectContaining({ profiles: { "openai:refreshed": expect.any(Object) } }),
|
||||
);
|
||||
expect(loaded.authModes).toEqual({ openai: "api_key" });
|
||||
expect(loadAuth).toHaveBeenCalledWith(["openai"]);
|
||||
expect(loadAuth).toHaveBeenCalledWith({
|
||||
providerIds: ["openai"],
|
||||
profileIds: ["openai:refreshed"],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes stale prepared auth modes when deferred auth observes logout", async () => {
|
||||
@@ -172,24 +183,88 @@ describe("gateway prepared model catalog", () => {
|
||||
}),
|
||||
);
|
||||
registerGatewayModelCatalogPrivateAccess(publicLoader, {
|
||||
loadDeferred: () =>
|
||||
loadDeferred: (params) =>
|
||||
loadPreparedGatewayModelCatalogSnapshot({
|
||||
...params,
|
||||
getConfig: () => config,
|
||||
loadPublishedPreparedModelCatalogOwnerSnapshot: async () => candidate,
|
||||
refreshAuth: true,
|
||||
}),
|
||||
readPrepared: async () => undefined,
|
||||
});
|
||||
const loaded = await loadDeferredCatalog(
|
||||
{ loadGatewayModelCatalogSnapshot: publicLoader },
|
||||
"main",
|
||||
true,
|
||||
{ readOnly: true, refreshAuth: true },
|
||||
);
|
||||
|
||||
expect(loaded.authStore?.profiles).toEqual({});
|
||||
expect(loaded.authModes).toEqual({});
|
||||
});
|
||||
|
||||
it("retries the whole owner projection when deferred auth supersedes its generation", async () => {
|
||||
const staleConfig = ownerConfig("main", { logging: { level: "info" } });
|
||||
const currentConfig = ownerConfig("main", { logging: { level: "debug" } });
|
||||
const staleCatalog: ModelCatalogSnapshot = {
|
||||
entries: [{ provider: "openai", id: "stale", name: "Stale" }],
|
||||
routeVariants: [],
|
||||
};
|
||||
const currentCatalog: ModelCatalogSnapshot = {
|
||||
entries: [{ provider: "openai", id: "current", name: "Current" }],
|
||||
routeVariants: [],
|
||||
};
|
||||
const stale = {
|
||||
...ownerSnapshot(staleConfig, staleCatalog),
|
||||
authModes: { openai: "oauth" as const },
|
||||
authStore: {
|
||||
version: 1 as const,
|
||||
profiles: {
|
||||
"openai:stale": {
|
||||
type: "token" as const,
|
||||
provider: "openai",
|
||||
token: "stale-token-not-real",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const current = {
|
||||
...ownerSnapshot(currentConfig, currentCatalog),
|
||||
authModes: { openai: "api_key" as const },
|
||||
authStore: {
|
||||
version: 1 as const,
|
||||
profiles: {
|
||||
"openai:current": {
|
||||
type: "api_key" as const,
|
||||
provider: "openai",
|
||||
key: "current-key-not-real",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setPreparedModelRuntimeAuthLoader(stale, async () => {
|
||||
throw new PreparedModelRuntimePublicationSupersededError("superseded");
|
||||
});
|
||||
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(current);
|
||||
|
||||
await expect(
|
||||
loadPreparedGatewayModelCatalogSnapshot({
|
||||
getConfig: () => staleConfig,
|
||||
loadPublishedPreparedModelCatalogOwnerSnapshot,
|
||||
refreshAuth: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
config: currentConfig,
|
||||
entries: currentCatalog.entries,
|
||||
authModes: { openai: "api_key" },
|
||||
authStore: {
|
||||
profiles: { "openai:current": expect.any(Object) },
|
||||
},
|
||||
});
|
||||
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects an ambiguous owner without an authoritative agent identity", async () => {
|
||||
const config = {
|
||||
agents: {
|
||||
@@ -248,11 +323,13 @@ describe("gateway prepared model catalog", () => {
|
||||
getConfig: () => config,
|
||||
loadPublishedPreparedModelCatalogOwnerSnapshot,
|
||||
readOnly: false,
|
||||
refreshFullCatalog: true,
|
||||
}),
|
||||
).resolves.toMatchObject(snapshot);
|
||||
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledWith({
|
||||
config,
|
||||
readOnly: false,
|
||||
refreshFullCatalog: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
import {
|
||||
getPreparedModelRuntimeAuthMaterializations,
|
||||
loadPreparedModelRuntimeAuth,
|
||||
type PreparedModelRuntimeAuthScope,
|
||||
} from "../agents/prepared-model-runtime-auth.js";
|
||||
import { PreparedModelRuntimePublicationSupersededError } from "../agents/prepared-model-runtime.errors.js";
|
||||
// Gateway catalog reads use the atomic prepared runtime generation.
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import type { PreparedGatewayModelCatalogSnapshot } from "./server-model-catalog-auth.js";
|
||||
@@ -21,6 +23,7 @@ type LoadPublishedPreparedModelCatalogOwnerSnapshot = (params: {
|
||||
agentDir?: string;
|
||||
config: GatewayModelCatalogConfig;
|
||||
readOnly?: boolean;
|
||||
refreshFullCatalog?: boolean;
|
||||
workspaceDir?: string;
|
||||
}) => Promise<PublishedModelCatalogOwnerCandidate>;
|
||||
type LoadGatewayModelCatalogParams = {
|
||||
@@ -29,9 +32,11 @@ type LoadGatewayModelCatalogParams = {
|
||||
getConfig?: () => GatewayModelCatalogConfig;
|
||||
loadPublishedPreparedModelCatalogOwnerSnapshot?: LoadPublishedPreparedModelCatalogOwnerSnapshot;
|
||||
readOnly?: boolean;
|
||||
refreshFullCatalog?: boolean;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
type LoadPreparedGatewayModelCatalogParams = LoadGatewayModelCatalogParams & {
|
||||
authScope?: PreparedModelRuntimeAuthScope;
|
||||
refreshAuth?: boolean;
|
||||
};
|
||||
|
||||
@@ -71,6 +76,7 @@ async function loadGatewayModelCatalogOwnerSnapshot(
|
||||
...(params?.agentDir ? { agentDir: params.agentDir } : {}),
|
||||
config: (params?.getConfig ?? getRuntimeConfig)(),
|
||||
readOnly: params?.readOnly !== false,
|
||||
...(params?.refreshFullCatalog ? { refreshFullCatalog: true } : {}),
|
||||
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||
});
|
||||
const owner = resolvePublishedModelCatalogOwner(candidate);
|
||||
@@ -101,20 +107,34 @@ function projectGatewayModelCatalogSnapshot(
|
||||
export async function loadPreparedGatewayModelCatalogSnapshot(
|
||||
params?: LoadPreparedGatewayModelCatalogParams,
|
||||
): Promise<PreparedGatewayModelCatalogSnapshot> {
|
||||
const { candidate, owner } = await loadGatewayModelCatalogOwnerSnapshot(params);
|
||||
const refreshedAuth = params?.refreshAuth
|
||||
? await loadPreparedModelRuntimeAuth(
|
||||
candidate,
|
||||
owner.modelCatalog.entries.map((entry) => entry.provider),
|
||||
).catch(() => undefined)
|
||||
: undefined;
|
||||
return {
|
||||
...projectGatewayModelCatalogSnapshot(owner),
|
||||
authModes: refreshedAuth?.authModes ?? owner.authModes,
|
||||
authStore: refreshedAuth?.authStore ?? owner.authStore,
|
||||
metadataSnapshot: owner.metadataSnapshot,
|
||||
authMaterializations: owner.authMaterializations,
|
||||
};
|
||||
for (;;) {
|
||||
const { candidate, owner } = await loadGatewayModelCatalogOwnerSnapshot(params);
|
||||
let refreshedAuth: Awaited<ReturnType<typeof loadPreparedModelRuntimeAuth>>;
|
||||
try {
|
||||
refreshedAuth = params?.refreshAuth
|
||||
? await loadPreparedModelRuntimeAuth(
|
||||
candidate,
|
||||
params.authScope ?? {
|
||||
providerIds: owner.modelCatalog.entries.map((entry) => entry.provider),
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
if (error instanceof PreparedModelRuntimePublicationSupersededError) {
|
||||
// Supersession invalidates every captured owner fact. Reacquire the whole owner so
|
||||
// replacement auth cannot be combined with stale catalog or metadata.
|
||||
continue;
|
||||
}
|
||||
refreshedAuth = undefined;
|
||||
}
|
||||
return {
|
||||
...projectGatewayModelCatalogSnapshot(owner),
|
||||
authModes: refreshedAuth?.authModes ?? owner.authModes,
|
||||
authStore: refreshedAuth?.authStore ?? owner.authStore,
|
||||
metadataSnapshot: owner.metadataSnapshot,
|
||||
authMaterializations: owner.authMaterializations,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadGatewayModelCatalogSnapshot(
|
||||
|
||||
@@ -27,6 +27,7 @@ type ModelPickerParams = {
|
||||
invalid?: boolean;
|
||||
describedBy?: string;
|
||||
};
|
||||
onOpen?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
@@ -52,6 +53,7 @@ export function renderModelPicker(params: ModelPickerParams) {
|
||||
title: params.title,
|
||||
placement: params.placement,
|
||||
className: `model-picker__select ${params.className ?? ""}`,
|
||||
onOpen: params.onOpen,
|
||||
renderLeading: (option) =>
|
||||
option.provider
|
||||
? renderProviderBrandIcon(option.provider, { className: "model-picker__provider-icon" })
|
||||
|
||||
@@ -17,6 +17,7 @@ export type PickerParams<Option extends PickerOption> = {
|
||||
className?: string;
|
||||
title?: string;
|
||||
placement?: "top" | "bottom";
|
||||
onOpen?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
onChangeTarget?: (value: string, select: HTMLElement) => void;
|
||||
renderLeading?: (option: Option) => unknown;
|
||||
@@ -46,6 +47,7 @@ export function renderPicker<Option extends PickerOption>(params: PickerParams<O
|
||||
placement=${params.placement ?? nothing}
|
||||
.value=${params.value}
|
||||
?disabled=${params.disabled}
|
||||
@wa-show=${() => params.onOpen?.()}
|
||||
@change=${(event: Event) => {
|
||||
const value = (event.currentTarget as HTMLElement & { value?: unknown }).value;
|
||||
const option = typeof value === "string" && options.find((entry) => entry.value === value);
|
||||
|
||||
@@ -160,9 +160,7 @@ suite.define(() => {
|
||||
|
||||
await expect.poll(() => model.isVisible()).toBe(true);
|
||||
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
||||
const modelRequests = await gateway.getRequests("models.list");
|
||||
expect(modelRequests).toHaveLength(1);
|
||||
expect(modelRequests[0]?.params).toEqual({ view: "configured" });
|
||||
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
||||
await expect.poll(() => contextUsage.isVisible()).toBe(true);
|
||||
await expect.poll(() => usage.isVisible()).toBe(false);
|
||||
await expect.poll(() => settings.isVisible()).toBe(true);
|
||||
@@ -846,9 +844,7 @@ suite.define(() => {
|
||||
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
|
||||
)
|
||||
.toBe(1);
|
||||
expect(await gateway.getRequests("models.list")).toEqual([
|
||||
expect.objectContaining({ params: { view: "configured" } }),
|
||||
]);
|
||||
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
||||
|
||||
await navigateToControlUiSession(page, "agent:other:main");
|
||||
const startupRequests = await gateway.getRequests("chat.startup");
|
||||
@@ -870,10 +866,7 @@ suite.define(() => {
|
||||
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
|
||||
)
|
||||
.toBe(0);
|
||||
expect(await gateway.getRequests("models.list")).toEqual([
|
||||
expect.objectContaining({ params: { view: "configured" } }),
|
||||
expect.objectContaining({ params: { agentId: "other", view: "configured" } }),
|
||||
]);
|
||||
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -921,7 +914,7 @@ suite.define(() => {
|
||||
"models.list": {
|
||||
cases: [
|
||||
{
|
||||
match: { agentId: "work", view: "configured" },
|
||||
match: { agentId: "work", view: "configured", preparedOnly: true },
|
||||
response: { models: [] },
|
||||
},
|
||||
],
|
||||
@@ -946,7 +939,9 @@ suite.define(() => {
|
||||
.not.toContain("GPT Default");
|
||||
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
||||
expect(await gateway.getRequests("models.list")).toEqual([
|
||||
expect.objectContaining({ params: { agentId: "work", view: "configured" } }),
|
||||
expect.objectContaining({
|
||||
params: { agentId: "work", view: "configured", preparedOnly: true },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("surfaces rejected provider credentials as the primary setup action", async () => {
|
||||
it("defers live provider discovery until refresh while preserving model setup", async () => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
@@ -80,7 +80,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const config = { auth: { profiles: { "openai:chatgpt": { provider: "openai" } } } };
|
||||
await installMockGateway(page, {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "models.probe", "openclaw.setup.detect"],
|
||||
methodResponses: {
|
||||
"config.get": {
|
||||
@@ -95,7 +95,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
cases: [
|
||||
{ match: { view: "configured" }, response: { models: [] } },
|
||||
{
|
||||
match: { view: "all", includeProviderCapabilities: true },
|
||||
match: { view: "all", agentId: "main", refresh: true },
|
||||
response: {
|
||||
models: [],
|
||||
providerOutcomes: [{ provider: "openai", status: "auth-rejected" }],
|
||||
@@ -135,8 +135,19 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
.poll(async () => readiness.textContent())
|
||||
.toContain("Connect a verified AI model");
|
||||
await expect.poll(async () => readiness.textContent()).toContain("Model required");
|
||||
await expect.poll(async () => openaiCard.textContent()).toContain("Credentials rejected");
|
||||
await expect.poll(async () => openaiCard.textContent()).toContain("Credentials configured");
|
||||
await expect.poll(async () => openaiCard.textContent()).not.toContain("Signed in");
|
||||
expect(
|
||||
(await gateway.getRequests("models.list")).filter(
|
||||
(request) => (request.params as { view?: string } | undefined)?.view === "all",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(await gateway.getRequests("models.list")).toEqual([
|
||||
expect.objectContaining({
|
||||
params: { agentId: "main", preparedOnly: true, view: "configured" },
|
||||
}),
|
||||
]);
|
||||
expect(await page.getByRole("heading", { name: "Add provider" }).count()).toBe(0);
|
||||
expect(await page.locator(".model-providers__defaults").count()).toBe(0);
|
||||
|
||||
if (recordVisuals) {
|
||||
@@ -166,6 +177,14 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
await page.setViewportSize({ height: 1000, width: 1440 });
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expect.poll(async () => openaiCard.textContent()).toContain("Credentials rejected");
|
||||
expect(
|
||||
(await gateway.getRequests("models.list")).filter(
|
||||
(request) => (request.params as { view?: string } | undefined)?.view === "all",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await readiness.getByRole("button", { name: "Connect a verified AI model" }).click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/model-setup");
|
||||
} finally {
|
||||
@@ -189,6 +208,11 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
methodResponses: {
|
||||
"models.authStatus": {
|
||||
ts: NOW,
|
||||
providerCapabilities: [
|
||||
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "google", apiKeySupported: true, quickApiKeySetup: true },
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provider: "claude-cli",
|
||||
@@ -274,7 +298,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
.poll(async () => claudeCard.locator(".settings-row__desc").first().textContent())
|
||||
.toContain("anthropic");
|
||||
await expect.poll(async () => claudeCard.textContent()).toContain("Max 20x");
|
||||
await expect.poll(async () => claudeCard.textContent()).toContain("Ready");
|
||||
await expect.poll(async () => claudeCard.textContent()).toContain("Credentials configured");
|
||||
await expect.poll(async () => claudeCard.textContent()).toContain("$4.20");
|
||||
await claudeCard.locator(".provider-usage-progress").first().waitFor();
|
||||
|
||||
@@ -433,7 +457,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
match: { view: "all", includeProviderCapabilities: true },
|
||||
match: { view: "all", agentId: "main", refresh: true },
|
||||
response: {
|
||||
models: [
|
||||
{
|
||||
@@ -464,6 +488,11 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
},
|
||||
"models.authStatus": {
|
||||
ts: NOW,
|
||||
providerCapabilities: [
|
||||
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "google", apiKeySupported: true, quickApiKeySetup: true },
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
@@ -495,6 +524,16 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
await page.goto(`${server.baseUrl}settings/model-providers`);
|
||||
const openaiCard = page.locator('[data-provider-id="openai"]');
|
||||
await openaiCard.waitFor();
|
||||
expect(
|
||||
(await gateway.getRequests("models.list")).filter(
|
||||
(request) => (request.params as { view?: string } | undefined)?.view === "all",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(await gateway.getRequests("models.list")).toEqual([
|
||||
expect.objectContaining({
|
||||
params: { agentId: "main", preparedOnly: true, view: "configured" },
|
||||
}),
|
||||
]);
|
||||
await expect.poll(async () => openaiCard.textContent()).toContain("API key set in config");
|
||||
await expect
|
||||
.poll(() => modelPickerValue(page.locator(".model-providers__defaults wa-select").first()))
|
||||
@@ -588,6 +627,11 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
|
||||
});
|
||||
await gateway.setMethodResponse("models.authStatus", {
|
||||
ts: NOW,
|
||||
providerCapabilities: [
|
||||
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "google", apiKeySupported: true, quickApiKeySetup: true },
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
|
||||
@@ -4047,6 +4047,7 @@ export const en: TranslationMap = {
|
||||
missing: "Not signed in",
|
||||
apiKey: "API key",
|
||||
denied: "Credentials rejected",
|
||||
configured: "Credentials configured",
|
||||
},
|
||||
expiresIn: "Credential expires in {time}",
|
||||
models: "{count} models",
|
||||
|
||||
@@ -282,7 +282,10 @@ describe("cron controller", () => {
|
||||
|
||||
await loadCronModelSuggestions(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
|
||||
expect(request).toHaveBeenCalledWith("models.list", {
|
||||
view: "configured",
|
||||
preparedOnly: true,
|
||||
});
|
||||
expect(state.cronModelSuggestions).toEqual(["a-model", "z-model"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -394,7 +394,10 @@ export async function loadCronModelSuggestions(state: CronModelSuggestionsState)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await state.client.request("models.list", { view: "configured" });
|
||||
const res = await state.client.request("models.list", {
|
||||
view: "configured",
|
||||
preparedOnly: true,
|
||||
});
|
||||
const models = (res as { models?: unknown[] } | null)?.models;
|
||||
if (!Array.isArray(models)) {
|
||||
state.cronModelSuggestions = [];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { loadGatewayDiagnostics } from "./gateway-diagnostics.ts";
|
||||
|
||||
describe("loadGatewayDiagnostics", () => {
|
||||
it("reads only the prepared model catalog during automatic diagnostics", async () => {
|
||||
const request = vi.fn(async (method: string) =>
|
||||
method === "models.list" ? { models: [] } : {},
|
||||
);
|
||||
|
||||
await loadGatewayDiagnostics({ request } as unknown as GatewayBrowserClient);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"models.list",
|
||||
{ preparedOnly: true },
|
||||
{ signal: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ export async function loadGatewayDiagnostics(
|
||||
const [status, health, models, heartbeat] = await Promise.all([
|
||||
client.request("status", {}, { signal }),
|
||||
client.request("health", {}, { signal }),
|
||||
client.request("models.list", {}, { signal }),
|
||||
client.request("models.list", { preparedOnly: true }, { signal }),
|
||||
client.request("last-heartbeat", {}, { signal }),
|
||||
]);
|
||||
const modelPayload = models as { models?: unknown[] } | undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { readChatSessionActionAccess } from "./chat-session-action-access.ts";
|
||||
import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts";
|
||||
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||
import { refreshChatModelCatalogOnDemand } from "./chat-state-refresh.ts";
|
||||
import type { ChatProps } from "./chat-view.ts";
|
||||
import { renderChatModelControls } from "./components/chat-model-controls.ts";
|
||||
|
||||
@@ -74,6 +75,7 @@ export function renderChatPaneComposerControls(params: {
|
||||
effortAccess.allowed
|
||||
? switchChatFastMode(state, next, targetSessionKey)
|
||||
: Promise.resolve(false),
|
||||
onModelPickerOpen: () => refreshChatModelCatalogOnDemand(state),
|
||||
onModelSelect: (next, targetSessionKey) =>
|
||||
modelAccess.allowed
|
||||
? switchChatModel(state, next, targetSessionKey)
|
||||
|
||||
@@ -363,7 +363,7 @@ describe("refreshChat", () => {
|
||||
expect(requestUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses explicit model discovery after startup metadata", async () => {
|
||||
it("uses prepared models delivered with startup metadata", async () => {
|
||||
const startup = createDeferred<unknown>();
|
||||
const host = makeChatHost({
|
||||
hello: {
|
||||
@@ -371,16 +371,6 @@ describe("refreshChat", () => {
|
||||
} as TestChatHost["hello"],
|
||||
requestHandlers: {
|
||||
"chat.startup": () => startup.promise,
|
||||
"models.list": {
|
||||
models: [
|
||||
{
|
||||
available: true,
|
||||
id: "live-model",
|
||||
name: "Live Model",
|
||||
provider: "openai",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -412,14 +402,14 @@ describe("refreshChat", () => {
|
||||
expect(host.chatModelCatalog).toEqual([
|
||||
{
|
||||
available: true,
|
||||
id: "live-model",
|
||||
name: "Live Model",
|
||||
id: "startup-model",
|
||||
name: "Startup Model",
|
||||
provider: "openai",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(host.request).not.toHaveBeenCalledWith("chat.metadata", expect.anything());
|
||||
expect(host.request).toHaveBeenCalledWith("models.list", { view: "configured" });
|
||||
expect(host.request).not.toHaveBeenCalledWith("models.list", expect.anything());
|
||||
expect(host.request).not.toHaveBeenCalledWith("commands.list", expect.anything());
|
||||
});
|
||||
|
||||
@@ -479,7 +469,10 @@ describe("refreshChat", () => {
|
||||
]),
|
||||
);
|
||||
expect(SLASH_COMMANDS.some((command) => command.name === "startup-gap-command")).toBe(true);
|
||||
expect(host.request).toHaveBeenCalledWith("models.list", { view: "configured" });
|
||||
expect(host.request).toHaveBeenCalledWith("models.list", {
|
||||
view: "configured",
|
||||
preparedOnly: true,
|
||||
});
|
||||
expect(host.request).toHaveBeenCalledWith(
|
||||
"commands.list",
|
||||
expect.objectContaining({ includeArgs: true, scope: "text" }),
|
||||
|
||||
@@ -185,7 +185,7 @@ async function refreshCompatibilityModelCatalog(
|
||||
: request.agentId?.trim() || undefined;
|
||||
const models = await loadModels(request.client, {
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(opts?.refresh ? { refresh: true } : {}),
|
||||
...(opts?.refresh ? { refresh: true } : { preparedOnly: true }),
|
||||
});
|
||||
if (ownsChatMetadataRequest(request)) {
|
||||
request.host.chatModelCatalog = models;
|
||||
@@ -298,6 +298,39 @@ export async function refreshChatModelAuthStatus(host: ChatPageHost, opts?: { re
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshChatModelCatalogOnDemand(host: ChatPageHost): Promise<void> {
|
||||
if (!host.client || !host.connected) {
|
||||
return;
|
||||
}
|
||||
const client = host.client;
|
||||
const agentId = resolveChatAgentId(host);
|
||||
const connectionEpoch = host.connectionEpoch;
|
||||
host.chatModelsLoading = true;
|
||||
try {
|
||||
const models = await loadModels(client, {
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
if (
|
||||
host.client === client &&
|
||||
host.connected &&
|
||||
host.connectionEpoch === connectionEpoch &&
|
||||
resolveChatAgentId(host) === agentId
|
||||
) {
|
||||
host.chatModelCatalog = models;
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
host.client === client &&
|
||||
host.connected &&
|
||||
host.connectionEpoch === connectionEpoch &&
|
||||
resolveChatAgentId(host) === agentId
|
||||
) {
|
||||
host.chatModelsLoading = false;
|
||||
host.requestUpdate?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChat(
|
||||
host: ChatPageHost,
|
||||
opts?: ChatRefreshOptions & {
|
||||
@@ -445,11 +478,9 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) {
|
||||
return;
|
||||
}
|
||||
rememberChatMetadata(client, agentId, metadata);
|
||||
// Startup metadata stays on the published static catalog so opening chat never waits on
|
||||
// provider discovery. The explicit models.list read below owns the live picker inventory.
|
||||
const applied = applyChatMetadataResult(host, client, agentId, metadata, { models: false });
|
||||
const applied = applyChatMetadataResult(host, client, agentId, metadata);
|
||||
if (!applied.models || !applied.commands) {
|
||||
await refreshMissingChatMetadata(request, applied, { refreshModelCatalog: true });
|
||||
await refreshMissingChatMetadata(request, applied);
|
||||
}
|
||||
} finally {
|
||||
if (ownsChatMetadataRequest(request)) {
|
||||
|
||||
@@ -1609,7 +1609,7 @@ describe("refreshChatMetadata", () => {
|
||||
it("loads compatibility models when the gateway does not advertise chat metadata", async () => {
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "models.list") {
|
||||
expect(params).toEqual({ view: "configured" });
|
||||
expect(params).toEqual({ view: "configured", preparedOnly: true });
|
||||
return {
|
||||
models: [{ id: "compat-model", name: "Compat Model", provider: "openai" }],
|
||||
};
|
||||
@@ -1661,7 +1661,7 @@ describe("refreshChatMetadata", () => {
|
||||
it("loads agent-scoped compatibility models for a non-default agent", async () => {
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "models.list") {
|
||||
expect(params).toEqual({ view: "configured", agentId: "work" });
|
||||
expect(params).toEqual({ view: "configured", agentId: "work", preparedOnly: true });
|
||||
return {
|
||||
models: [{ id: "work-model", name: "Work Model", provider: "openai" }],
|
||||
};
|
||||
|
||||
@@ -5484,6 +5484,18 @@ describe("chat model controls", () => {
|
||||
expect(onModelSelect).toHaveBeenCalledWith(modelOption?.dataset.chatModelOption, "main");
|
||||
});
|
||||
|
||||
it("requests live wildcard discovery when the model picker opens", () => {
|
||||
const { state } = createOpenAiHeaderState();
|
||||
const onModelPickerOpen = vi.fn();
|
||||
const container = renderModelControls(state, { onModelPickerOpen });
|
||||
|
||||
getChatModelSelect(container).dispatchEvent(
|
||||
new CustomEvent("wa-show", { bubbles: true, composed: true }),
|
||||
);
|
||||
|
||||
expect(onModelPickerOpen).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps model enabled while write-only access disables effort controls", () => {
|
||||
const { state } = createOpenAiHeaderState();
|
||||
const onFastModeSelect = vi.fn(async () => true);
|
||||
|
||||
@@ -47,6 +47,7 @@ type ChatModelControlsProps = {
|
||||
thinkingSession?: ChatThinkingTarget;
|
||||
onFastModeSelect?: (value: ChatFastModeSelectValue, sessionKey: string) => unknown;
|
||||
onModelSetup?: () => void;
|
||||
onModelPickerOpen?: () => unknown;
|
||||
onModelSelect?: (value: string, sessionKey: string) => unknown;
|
||||
onModelPickerTargetSelect?: (groupId: string, value: string) => unknown;
|
||||
onRequestUpdate?: () => void;
|
||||
@@ -336,6 +337,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
|
||||
triggerModelLabel: formatPickerModelLabel(committedModelLabel),
|
||||
triggerStatusLabel: catalogTriggerStatus,
|
||||
onModelSetup: props.onModelSetup,
|
||||
onOpen: props.onModelPickerOpen,
|
||||
onModelSelect: async (next, targetSessionKey) =>
|
||||
props.onModelSelect?.(next, targetSessionKey),
|
||||
onTargetSelect: props.onModelPickerTargetSelect,
|
||||
|
||||
@@ -32,6 +32,7 @@ type ChatModelPickerParams = {
|
||||
triggerModelLabel: string;
|
||||
triggerStatusLabel?: string;
|
||||
onModelSetup?: () => void;
|
||||
onOpen?: () => unknown;
|
||||
onModelSelect: (value: string, sessionKey: string) => Promise<unknown>;
|
||||
onTargetSelect?: (groupId: string, value: string) => unknown;
|
||||
onRequestUpdate?: () => void;
|
||||
@@ -378,6 +379,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
|
||||
resetModelSearch(details);
|
||||
return;
|
||||
}
|
||||
void params.onOpen?.();
|
||||
queueMicrotask(() => {
|
||||
const input = details.querySelector<HTMLInputElement>("[data-chat-model-search]");
|
||||
if (input) {
|
||||
|
||||
@@ -19,6 +19,17 @@ describe("loadModels", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests only the prepared catalog for automatic reads", async () => {
|
||||
const request = vi.fn(async () => ({ models: [] }));
|
||||
|
||||
await loadModels({ request } as unknown as GatewayBrowserClient, { preparedOnly: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("models.list", {
|
||||
view: "configured",
|
||||
preparedOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the configured model list while the cache is fresh", async () => {
|
||||
const request = vi.fn(async () => ({
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }],
|
||||
@@ -82,6 +93,28 @@ describe("loadModels", () => {
|
||||
expect(await loadModels(client)).toEqual(fresh);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("coalesces concurrent refreshes without reusing a completed refresh", async () => {
|
||||
let releaseRefresh: (() => void) | undefined;
|
||||
const refreshGate = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
const request = vi.fn(async () => {
|
||||
await refreshGate;
|
||||
return { models: [{ id: "fresh", name: "Fresh", provider: "openai" }] };
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
const first = loadModels(client, { agentId: "writer", refresh: true });
|
||||
const concurrent = loadModels(client, { agentId: "writer", refresh: true });
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
releaseRefresh?.();
|
||||
expect(await concurrent).toBe(await first);
|
||||
|
||||
await loadModels(client, { agentId: "writer", refresh: true });
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyModelCatalogResult", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ type ModelCatalogCacheEntry = {
|
||||
expiresAt: number;
|
||||
models: ModelCatalogEntry[];
|
||||
inFlight?: Promise<ModelCatalogEntry[]>;
|
||||
inFlightRefresh?: boolean;
|
||||
};
|
||||
|
||||
const modelCatalogCache = new WeakMap<GatewayBrowserClient, Map<string, ModelCatalogCacheEntry>>();
|
||||
@@ -23,16 +24,17 @@ function modelCatalogCacheFor(client: GatewayBrowserClient): Map<string, ModelCa
|
||||
|
||||
export async function loadModels(
|
||||
client: GatewayBrowserClient,
|
||||
opts?: { agentId?: string; refresh?: boolean },
|
||||
opts?: { agentId?: string; preparedOnly?: boolean; refresh?: boolean },
|
||||
): Promise<ModelCatalogEntry[]> {
|
||||
const cache = modelCatalogCacheFor(client);
|
||||
const cacheKey = opts?.agentId?.trim() ?? "";
|
||||
const agentId = opts?.agentId?.trim() ?? "";
|
||||
const cacheKey = `${agentId}\0${opts?.preparedOnly ? "prepared" : "exact"}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
if (!opts?.refresh && cached?.models && cached.expiresAt > now) {
|
||||
return cached.models;
|
||||
}
|
||||
if (!opts?.refresh && cached?.inFlight) {
|
||||
if (cached?.inFlight && (!opts?.refresh || cached.inFlightRefresh === true)) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
|
||||
@@ -42,7 +44,8 @@ export async function loadModels(
|
||||
const inFlight: Promise<ModelCatalogEntry[]> = requestModels(
|
||||
client,
|
||||
cached?.models,
|
||||
cacheKey || undefined,
|
||||
agentId || undefined,
|
||||
opts?.preparedOnly === true,
|
||||
)
|
||||
.then((result) => {
|
||||
const latest = cache.get(cacheKey);
|
||||
@@ -64,6 +67,7 @@ export async function loadModels(
|
||||
expiresAt: cached?.expiresAt ?? 0,
|
||||
models: cached?.models ?? [],
|
||||
inFlight,
|
||||
...(opts?.refresh ? { inFlightRefresh: true } : {}),
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
@@ -79,11 +83,13 @@ async function requestModels(
|
||||
client: GatewayBrowserClient,
|
||||
fallback: ModelCatalogEntry[] | undefined,
|
||||
agentId: string | undefined,
|
||||
preparedOnly: boolean,
|
||||
): Promise<{ models: ModelCatalogEntry[]; fresh: boolean }> {
|
||||
try {
|
||||
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {
|
||||
view: "configured",
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(preparedOnly ? { preparedOnly: true } : {}),
|
||||
});
|
||||
return { models: result?.models ?? [], fresh: true };
|
||||
} catch {
|
||||
|
||||
@@ -466,6 +466,8 @@ describe("ConfigPage session observer models", () => {
|
||||
await firstLoad;
|
||||
expect(state.sessionObserverModels).toEqual(currentModels);
|
||||
expect(chatModels.loadModels).toHaveBeenCalledTimes(2);
|
||||
expect(chatModels.loadModels).toHaveBeenNthCalledWith(1, firstClient, { preparedOnly: true });
|
||||
expect(chatModels.loadModels).toHaveBeenNthCalledWith(2, secondClient, { preparedOnly: true });
|
||||
});
|
||||
|
||||
it("retries a transient catalog failure on the next status refresh", async () => {
|
||||
@@ -498,6 +500,7 @@ describe("ConfigPage session observer models", () => {
|
||||
expect(state.sessionObserverModels).toEqual(recoveredModels);
|
||||
expect(state.sessionObserverModelsUnavailable).toBe(false);
|
||||
expect(chatModels.loadModels).toHaveBeenCalledTimes(2);
|
||||
expect(chatModels.loadModels).toHaveBeenLastCalledWith(client, { preparedOnly: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -754,7 +754,7 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
return existing;
|
||||
}
|
||||
const gatewaySource = this.systemInfoGatewaySource;
|
||||
const promise = loadModels(client)
|
||||
const promise = loadModels(client, { preparedOnly: true })
|
||||
.then((models) => {
|
||||
if (
|
||||
this.isConnected &&
|
||||
|
||||
@@ -19,8 +19,11 @@ function catalogEntry(overrides: Partial<ModelCatalogEntry> & { provider: string
|
||||
} satisfies ModelCatalogEntry;
|
||||
}
|
||||
|
||||
function authStatus(providers: ModelAuthStatusResult["providers"]): ModelAuthStatusResult {
|
||||
return { ts: 1, providers };
|
||||
function authStatus(
|
||||
providers: ModelAuthStatusResult["providers"],
|
||||
providerCapabilities?: ModelAuthStatusResult["providerCapabilities"],
|
||||
): ModelAuthStatusResult {
|
||||
return { ts: 1, providers, ...(providerCapabilities ? { providerCapabilities } : {}) };
|
||||
}
|
||||
|
||||
function firstCard(cards: ReturnType<typeof buildModelProviderCards>) {
|
||||
@@ -75,7 +78,10 @@ describe("buildModelProviderCards", () => {
|
||||
const cards = buildModelProviderCards({
|
||||
...EMPTY_INPUT,
|
||||
models: [catalogEntry({ provider: "github-copilot", available: true })],
|
||||
catalogModels: [catalogEntry({ provider: "github-copilot", apiKeySupported: false })],
|
||||
authStatus: authStatus(
|
||||
[],
|
||||
[{ provider: "github-copilot", apiKeySupported: false, quickApiKeySetup: false }],
|
||||
),
|
||||
});
|
||||
expect(firstCard(cards).apiKeySupported).toBe(false);
|
||||
});
|
||||
@@ -365,10 +371,9 @@ describe("model provider configuration data", () => {
|
||||
it("lists known providers that are not configured", () => {
|
||||
const options = buildUnconfiguredProviderOptions(
|
||||
[
|
||||
catalogEntry({ provider: "openai", apiKeySupported: true }),
|
||||
catalogEntry({ provider: "anthropic", apiKeySupported: true }),
|
||||
catalogEntry({ provider: "anthropic", id: "anthropic/other", apiKeySupported: true }),
|
||||
catalogEntry({ provider: "github-copilot", apiKeySupported: false }),
|
||||
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
|
||||
{ provider: "github-copilot", apiKeySupported: true, quickApiKeySetup: false },
|
||||
],
|
||||
["openai"],
|
||||
);
|
||||
|
||||
@@ -64,7 +64,6 @@ export type ModelProviderCard = {
|
||||
type ModelProviderCardsInput = {
|
||||
authStatus: ModelAuthStatusResult | null;
|
||||
models: ModelCatalogEntry[] | null;
|
||||
catalogModels?: ModelCatalogEntry[] | null;
|
||||
providerOutcomes?: ModelCatalogProviderOutcome[];
|
||||
configProviderIds?: string[] | null;
|
||||
configApiKeyProviderIds?: string[] | null;
|
||||
@@ -194,12 +193,12 @@ function addLogoutTarget(
|
||||
export function buildModelProviderCards(input: ModelProviderCardsInput): ModelProviderCard[] {
|
||||
const drafts: CardDraft[] = [];
|
||||
const apiKeyCapabilities = new Map<string, boolean>();
|
||||
for (const entry of input.catalogModels ?? []) {
|
||||
const id = canonicalProviderId(entry.provider);
|
||||
if (!id || entry.apiKeySupported === undefined) {
|
||||
for (const capability of input.authStatus?.providerCapabilities ?? []) {
|
||||
const id = canonicalProviderId(capability.provider);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
apiKeyCapabilities.set(id, apiKeyCapabilities.get(id) === true || entry.apiKeySupported);
|
||||
apiKeyCapabilities.set(id, apiKeyCapabilities.get(id) === true || capability.apiKeySupported);
|
||||
}
|
||||
|
||||
for (const provider of input.configProviderIds ?? []) {
|
||||
@@ -462,15 +461,17 @@ export function readModelProviderConfig(config: Record<string, unknown> | null):
|
||||
|
||||
export type ProviderOption = { id: string; displayName: string };
|
||||
|
||||
type ModelProviderCapability = NonNullable<ModelAuthStatusResult["providerCapabilities"]>[number];
|
||||
|
||||
export function buildUnconfiguredProviderOptions(
|
||||
models: ModelCatalogEntry[] | null,
|
||||
capabilities: ModelProviderCapability[] | undefined,
|
||||
configuredProviderIds: Iterable<string>,
|
||||
): ProviderOption[] {
|
||||
const configured = new Set(Array.from(configuredProviderIds, canonicalProviderId));
|
||||
const options = new Map<string, ProviderOption>();
|
||||
for (const model of models ?? []) {
|
||||
const id = canonicalProviderId(model.provider);
|
||||
if (model.apiKeySupported === true && id && !configured.has(id) && !options.has(id)) {
|
||||
for (const capability of capabilities ?? []) {
|
||||
const id = canonicalProviderId(capability.provider);
|
||||
if (capability.quickApiKeySetup && id && !configured.has(id) && !options.has(id)) {
|
||||
options.set(id, { id, displayName: providerDisplayLabel(id) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,40 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { loadModelProvidersData } from "./load.ts";
|
||||
|
||||
describe("loadModelProvidersData", () => {
|
||||
it("keeps full catalog discovery out of the initial page load", async () => {
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
switch (method) {
|
||||
case "models.authStatus":
|
||||
return { ts: 1, providers: [], providerCapabilities: [] };
|
||||
case "models.list":
|
||||
return { models: [] };
|
||||
case "config.get":
|
||||
return { config: {}, hash: "hash" };
|
||||
case "usage.status":
|
||||
return { updatedAt: 1, providers: [] };
|
||||
case "sessions.usage":
|
||||
return { aggregates: { byProvider: [] } };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
await loadModelProvidersData(client, { agentId: "writer" });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("models.list", {
|
||||
view: "configured",
|
||||
agentId: "writer",
|
||||
preparedOnly: true,
|
||||
});
|
||||
expect(
|
||||
request.mock.calls.filter(
|
||||
([method, params]) =>
|
||||
method === "models.list" && (params as { view?: string } | undefined)?.view === "all",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("scopes only credential status to the selected agent", async () => {
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
switch (method) {
|
||||
@@ -28,6 +62,15 @@ describe("loadModelProvidersData", () => {
|
||||
refresh: true,
|
||||
agentId: "writer",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("models.list", {
|
||||
view: "all",
|
||||
agentId: "writer",
|
||||
refresh: true,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("models.list", {
|
||||
view: "configured",
|
||||
agentId: "writer",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("usage.status");
|
||||
const sessionUsageCall = request.mock.calls.find(([method]) => method === "sessions.usage");
|
||||
expect(sessionUsageCall?.[1]).not.toHaveProperty("agentId");
|
||||
@@ -57,10 +100,45 @@ describe("loadModelProvidersData", () => {
|
||||
|
||||
expect(result.authStatus).toBeNull();
|
||||
expect(result.models).toEqual([]);
|
||||
expect(result.catalogModels).toEqual([]);
|
||||
expect(result.providerOutcomes).toEqual([]);
|
||||
expect(result.catalogError).toBeNull();
|
||||
expect(result.config).toEqual({});
|
||||
expect(result.providerUsage).toEqual({ updatedAt: 1, providers: [] });
|
||||
expect(result.costByProvider).toEqual([]);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an explicit catalog refresh failure while retaining cached configured models", async () => {
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "models.list" && (params as { view?: string } | undefined)?.view === "all") {
|
||||
throw new Error("catalog refresh failed");
|
||||
}
|
||||
switch (method) {
|
||||
case "models.authStatus":
|
||||
return { ts: 1, providers: [], providerCapabilities: [] };
|
||||
case "models.list":
|
||||
return {
|
||||
models: [{ id: "cached", name: "Cached", provider: "openai" }],
|
||||
};
|
||||
case "config.get":
|
||||
return { config: {}, hash: "hash" };
|
||||
case "usage.status":
|
||||
return { updatedAt: 1, providers: [] };
|
||||
case "sessions.usage":
|
||||
return { aggregates: { byProvider: [] } };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
const result = await loadModelProvidersData(client, { refresh: true, agentId: "writer" });
|
||||
|
||||
expect(result.catalogError).toBe("catalog refresh failed");
|
||||
expect(result.models).toEqual([{ id: "cached", name: "Cached", provider: "openai" }]);
|
||||
expect(request).toHaveBeenCalledWith("models.list", {
|
||||
view: "configured",
|
||||
agentId: "writer",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,8 @@ export const MODEL_PROVIDERS_COST_DAYS = 30;
|
||||
export type ModelProvidersData = {
|
||||
authStatus: ModelAuthStatusResult | null;
|
||||
models: ModelCatalogEntry[] | null;
|
||||
catalogModels: ModelCatalogEntry[] | null;
|
||||
providerOutcomes: ModelCatalogProviderOutcome[];
|
||||
catalogError: string | null;
|
||||
config: Record<string, unknown> | null;
|
||||
providerUsage: UsageSummary | null;
|
||||
costByProvider: SessionModelUsage[] | null;
|
||||
@@ -35,15 +35,14 @@ export type ModelProvidersData = {
|
||||
};
|
||||
|
||||
type ModelProvidersCatalogResult = {
|
||||
models: ModelCatalogEntry[];
|
||||
providerOutcomes?: ModelCatalogProviderOutcome[];
|
||||
};
|
||||
|
||||
export const EMPTY_MODEL_PROVIDERS_DATA: ModelProvidersData = {
|
||||
authStatus: null,
|
||||
models: null,
|
||||
catalogModels: null,
|
||||
providerOutcomes: [],
|
||||
catalogError: null,
|
||||
config: null,
|
||||
providerUsage: null,
|
||||
costByProvider: null,
|
||||
@@ -77,19 +76,34 @@ export async function loadModelProvidersData(
|
||||
: params === undefined
|
||||
? client.request<T>(method)
|
||||
: client.request<T>(method, params);
|
||||
const catalogRefresh = opts?.refresh
|
||||
? request<ModelProvidersCatalogResult>("models.list", {
|
||||
view: "all",
|
||||
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
||||
refresh: true,
|
||||
})
|
||||
.then((result) => ({ ok: true as const, result: result ?? null }))
|
||||
.catch((error: unknown) => ({ ok: false as const, error }))
|
||||
: Promise.resolve({ ok: true as const, result: null });
|
||||
const modelsLoad = opts?.refresh
|
||||
? catalogRefresh.then(() =>
|
||||
loadModels(client, {
|
||||
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
||||
refresh: true,
|
||||
}),
|
||||
)
|
||||
: loadModels(client, {
|
||||
...(opts?.agentId ? { agentId: opts.agentId } : {}),
|
||||
preparedOnly: true,
|
||||
}).catch(() => null);
|
||||
const [authStatus, models, catalogResult, config, providerUsage, costByProvider] =
|
||||
await Promise.all([
|
||||
loadModelAuthStatus(client, opts).then(
|
||||
(result) => ({ ok: true as const, result }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
),
|
||||
loadModels(client, opts).catch(() => null),
|
||||
request<ModelProvidersCatalogResult>("models.list", {
|
||||
view: "all",
|
||||
includeProviderCapabilities: true,
|
||||
})
|
||||
.then((result) => result ?? null)
|
||||
.catch(() => null),
|
||||
modelsLoad,
|
||||
catalogRefresh,
|
||||
request<ConfigSnapshot>("config.get", {})
|
||||
.then((snapshot) => resolveEditableSnapshotConfig(snapshot))
|
||||
.catch(() => null),
|
||||
@@ -107,8 +121,8 @@ export async function loadModelProvidersData(
|
||||
authStatus:
|
||||
authStatus.ok && Array.isArray(authStatus.result?.providers) ? authStatus.result : null,
|
||||
models,
|
||||
catalogModels: catalogResult?.models ?? null,
|
||||
providerOutcomes: catalogResult?.providerOutcomes ?? [],
|
||||
providerOutcomes: catalogResult.ok ? (catalogResult.result?.providerOutcomes ?? []) : [],
|
||||
catalogError: catalogResult.ok ? null : errorMessage(catalogResult.error),
|
||||
config,
|
||||
providerUsage,
|
||||
costByProvider,
|
||||
|
||||
@@ -57,7 +57,13 @@ function createHarness(initialScopeId: string) {
|
||||
pendingAuthStatus = null;
|
||||
await gate;
|
||||
}
|
||||
return { ts: 1, providers: [] };
|
||||
return {
|
||||
ts: 1,
|
||||
providers: [],
|
||||
providerCapabilities: [
|
||||
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
case "models.list":
|
||||
return { models: [] };
|
||||
|
||||
@@ -628,7 +628,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
|
||||
connected: gatewaySnapshot.phase === "connected",
|
||||
loading: gatewaySnapshot.phase === "connected" && this.data === null,
|
||||
refreshing: this.refreshTask.status === TaskStatus.PENDING,
|
||||
error: data.error,
|
||||
error: data.error ?? data.catalogError,
|
||||
updatedAt: data.updatedAt,
|
||||
costDays: MODEL_PROVIDERS_COST_DAYS,
|
||||
credentialAgentLabel: selectedAgentLabel,
|
||||
@@ -638,8 +638,9 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
|
||||
defaultModelsDirty: this.defaultsDraft !== null,
|
||||
...modelBehavior,
|
||||
configBusy,
|
||||
quickAddSupported: data.authStatus?.providerCapabilities !== undefined,
|
||||
unconfiguredProviders: buildUnconfiguredProviderOptions(
|
||||
data.catalogModels,
|
||||
data.authStatus?.providerCapabilities,
|
||||
configuredProviderIds,
|
||||
),
|
||||
canMutate: this.canMutate(),
|
||||
|
||||
@@ -39,10 +39,13 @@ function hasProviderCredentials(card: ModelProviderCard): boolean {
|
||||
return card.hasConfigApiKey || Boolean(card.apiKey) || card.profiles.length > 0;
|
||||
}
|
||||
|
||||
export function hasValidProviderSignIn(card: ModelProviderCard): boolean {
|
||||
const catalogUnavailable =
|
||||
card.catalogStatus === "auth-rejected" || card.catalogStatus === "unavailable";
|
||||
return card.auth?.kind === "ok" && !catalogUnavailable;
|
||||
export function hasVerifiedProvider(card: ModelProviderCard): boolean {
|
||||
return (
|
||||
card.catalogStatus === "ready" &&
|
||||
card.auth?.kind !== "expired" &&
|
||||
card.auth?.kind !== "missing" &&
|
||||
card.auth?.kind !== "expiring"
|
||||
);
|
||||
}
|
||||
|
||||
export function renderProviderStatus(card: ModelProviderCard) {
|
||||
@@ -65,16 +68,19 @@ export function renderProviderStatus(card: ModelProviderCard) {
|
||||
if (!hasProviderCredentials(card)) {
|
||||
return renderAuthStatus(card);
|
||||
}
|
||||
if (card.availableModelCount > 0 && (hasValidProviderSignIn(card) || !card.auth)) {
|
||||
if (hasVerifiedProvider(card) && card.availableModelCount > 0) {
|
||||
return renderSettingsStatus({
|
||||
kind: "ok",
|
||||
label: t("modelProviders.status.ready"),
|
||||
});
|
||||
}
|
||||
return hasValidProviderSignIn(card)
|
||||
return hasVerifiedProvider(card)
|
||||
? renderSettingsStatus({
|
||||
kind: "muted",
|
||||
label: t("modelProviders.status.ok"),
|
||||
})
|
||||
: renderAuthStatus(card);
|
||||
: renderSettingsStatus({
|
||||
kind: "muted",
|
||||
label: t("modelProviders.status.configured"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ function props(overrides: Partial<ModelProvidersViewProps> = {}): ModelProviders
|
||||
fastMode: false,
|
||||
fastModeOverridden: true,
|
||||
configBusy: false,
|
||||
quickAddSupported: true,
|
||||
unconfiguredProviders: [{ id: "anthropic", displayName: "Anthropic" }],
|
||||
canMutate: true,
|
||||
mutationBlockedReason: null,
|
||||
@@ -121,6 +122,19 @@ describe("renderModelProviders", () => {
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
it("hides quick API-key setup when provider capabilities are unavailable", () => {
|
||||
const container = mount(
|
||||
props({
|
||||
configuredModels: [],
|
||||
quickAddSupported: false,
|
||||
unconfiguredProviders: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(text(container)).not.toContain("Add provider");
|
||||
expect(container.querySelector('[data-model-readiness="model-required"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const container of document.body.querySelectorAll("div")) {
|
||||
render(nothing, container);
|
||||
@@ -528,12 +542,14 @@ describe("renderModelProviders", () => {
|
||||
|
||||
const readiness = container.querySelector('[data-model-readiness="model-required"]');
|
||||
expect(text(readiness)).toContain("Connect a verified AI model");
|
||||
expect(text(readiness)).toContain("No models available");
|
||||
expect(text(readiness)).toContain("Choose another provider");
|
||||
expect(text(readiness)).toContain("Model required");
|
||||
expect(text(readiness)).toContain("Connect a verified AI model");
|
||||
expect(container.querySelector(".model-providers__defaults")).toBeNull();
|
||||
expect(text(container.querySelector('[data-provider-id="openai"]'))).toContain("Signed in");
|
||||
expect(text(container.querySelector('[data-provider-id="openai"]'))).toContain(
|
||||
"Credentials configured",
|
||||
);
|
||||
|
||||
button(readiness!, "Choose another provider")?.click();
|
||||
button(readiness!, "Connect a verified AI model")?.click();
|
||||
expect(onOpenModelSetup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -571,7 +587,7 @@ describe("renderModelProviders", () => {
|
||||
);
|
||||
|
||||
const provider = container.querySelector('[data-provider-id="openai"]');
|
||||
expect(text(provider)).toContain("API key");
|
||||
expect(text(provider)).toContain("Credentials configured");
|
||||
expect(text(provider)).not.toContain("Ready");
|
||||
});
|
||||
|
||||
@@ -762,7 +778,7 @@ describe("renderModelProviders", () => {
|
||||
);
|
||||
|
||||
const provider = container.querySelector('[data-provider-id="openai"]');
|
||||
expect(text(provider)).toContain("Signed in");
|
||||
expect(text(provider)).toContain("Credentials configured");
|
||||
expect(text(provider)).toContain("No models available");
|
||||
expect(text(provider)).not.toContain("Connection failed");
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
ProviderOption,
|
||||
} from "./data.ts";
|
||||
import { renderDefaultModels } from "./default-models-view.ts";
|
||||
import { hasValidProviderSignIn, renderProviderStatus } from "./view-status.ts";
|
||||
import { hasVerifiedProvider, renderProviderStatus } from "./view-status.ts";
|
||||
|
||||
export type ModelProviderRowMessage = {
|
||||
kind: "success" | "error";
|
||||
@@ -55,6 +55,7 @@ type ModelProvidersViewProps = {
|
||||
fastMode: FastMode | undefined;
|
||||
fastModeOverridden: boolean;
|
||||
configBusy: boolean;
|
||||
quickAddSupported: boolean;
|
||||
unconfiguredProviders: ProviderOption[];
|
||||
canMutate: boolean;
|
||||
mutationBlockedReason: string | null;
|
||||
@@ -554,7 +555,7 @@ function renderAddProvider(props: ModelProvidersViewProps) {
|
||||
}
|
||||
|
||||
function renderModelReadiness(props: ModelProvidersViewProps) {
|
||||
const signedIn = props.cards.some(hasValidProviderSignIn);
|
||||
const signedIn = props.cards.some(hasVerifiedProvider);
|
||||
return html`
|
||||
<div class="model-providers__setup" data-model-readiness="model-required">
|
||||
${renderSettingsSection(
|
||||
@@ -650,7 +651,7 @@ export function renderModelProviders(props: ModelProvidersViewProps) {
|
||||
},
|
||||
providerRows,
|
||||
)}
|
||||
${renderAddProvider(props)}
|
||||
${props.quickAddSupported ? renderAddProvider(props) : nothing}
|
||||
${props.mutationBlockedReason
|
||||
? html`<div class="callout warning">${props.mutationBlockedReason}</div>`
|
||||
: nothing}
|
||||
|
||||
Reference in New Issue
Block a user