mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 01:21:41 -06:00
06f5f73e47
* refactor(agents): prepare model runtime catalogs Build lifecycle-owned model and auth snapshots, carry prepared stores into hot agent paths, and serialize config/auth publication. Credits @zeroaltitude's #90741 investigation and benchmark approach. * refactor: finish lifecycle-owned model discovery * fix: align prepared model catalog contracts * test: align lifecycle catalog mocks * test: fix prepared catalog type fixtures * refactor: split prepared model runtime ownership * fix: import prepared runtime replacement gate type * test: split media runtime coverage * test: preserve image auth fixture key types * test: isolate lifecycle gate fixtures * chore: keep release changelog owned * refactor: finish prepared model catalog migration * test: keep catalog review fixtures scanner-safe * refactor: preserve lifecycle model runtime ownership * fix: close prepared runtime lifecycle races * fix: preserve compaction workspace fallback * chore: document btw generation rebinding * fix: preserve prepared generation boundaries * fix: keep model-list discovery flag explicit * fix: serialize standalone model runtime activation * refactor: migrate subagent model catalog lookup * refactor: clarify doctor catalog lookup seam * chore: refresh plugin sdk api baseline * test: migrate swarm catalog dependency * refactor(telegram): rename runtime catalog seam * refactor: extract model-aware tool context * test(models): isolate lifecycle catalog fixtures * refactor(agents): avoid btw parameter rebinding * fix(net-policy): align root ipaddr dependency * fix(build): keep net policy dependency bundled * fix(deadcode): document net policy compile dependency
233 lines
8.2 KiB
TypeScript
233 lines
8.2 KiB
TypeScript
// Post-selection model/auth sanity checks shown during onboarding and agent setup.
|
|
import { normalizeProviderIdForAuth } from "@openclaw/model-catalog-core/provider-id";
|
|
import {
|
|
resolveAgentDir,
|
|
resolveAgentWorkspaceDir,
|
|
resolveDefaultAgentDir,
|
|
resolveDefaultAgentId,
|
|
} from "../agents/agent-scope.js";
|
|
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
|
import { createModelAuthAvailabilityResolver } from "../agents/model-auth-availability.js";
|
|
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
|
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
|
import { publishPreparedModelRuntimeSnapshot } from "../agents/prepared-model-runtime.js";
|
|
import { buildProviderAuthRecoveryHint } from "../agents/provider-auth-recovery-hint.js";
|
|
import { canonicalizeProviderModelId } from "../agents/provider-model-route.js";
|
|
import type { ModelApi } from "../config/types.models.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import type { ProviderModelRouteAuthRequirement } from "../plugin-sdk/provider-model-types.js";
|
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
|
|
|
type ModelRouteObservation = {
|
|
api?: ModelApi | null;
|
|
baseUrl?: unknown;
|
|
};
|
|
|
|
type DefaultModelAuthStatus = {
|
|
provider: string;
|
|
model: string;
|
|
} & (
|
|
| { status: "ready"; hasAuth: true }
|
|
| {
|
|
status: "missing";
|
|
hasAuth: false;
|
|
authRequirement?: ProviderModelRouteAuthRequirement;
|
|
}
|
|
| { status: "indeterminate"; hasAuth: false }
|
|
| { status: "incompatible"; hasAuth: false; code: string; message: string }
|
|
);
|
|
|
|
/**
|
|
* Resolve the default model ref and its auth readiness. A catalog observation
|
|
* makes transport-specific auth exact; absent observations remain
|
|
* indeterminate when provider facts cannot choose one route. Shared by the
|
|
* onboarding model check and the finalize hatch gating.
|
|
*/
|
|
export function resolveDefaultModelAuthStatus(
|
|
config: OpenClawConfig,
|
|
options?: {
|
|
agentId?: string;
|
|
agentDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
observedRoutes?: readonly ModelRouteObservation[];
|
|
},
|
|
): DefaultModelAuthStatus {
|
|
const ref = resolveDefaultModelForAgent({
|
|
cfg: config,
|
|
agentId: options?.agentId,
|
|
});
|
|
const store = ensureAuthProfileStore(options?.agentDir, {
|
|
allowKeychainPrompt: false,
|
|
config,
|
|
...(ref.provider === "openai" ? { externalCliProviderIds: ["openai"] } : {}),
|
|
readOnly: true,
|
|
});
|
|
const evaluation = createModelAuthAvailabilityResolver({
|
|
cfg: config,
|
|
authStore: store,
|
|
...(options?.agentDir ? { agentDir: options.agentDir } : {}),
|
|
...(options?.env ? { env: options.env } : {}),
|
|
}).evaluateModelAuth(ref.provider, {
|
|
modelId: ref.model,
|
|
...(options?.observedRoutes?.length ? { observedRoutes: options.observedRoutes } : {}),
|
|
});
|
|
if (evaluation.routeResolution?.kind === "incompatible") {
|
|
return {
|
|
provider: ref.provider,
|
|
model: ref.model,
|
|
status: "incompatible",
|
|
hasAuth: false,
|
|
code: evaluation.routeResolution.code,
|
|
message: evaluation.routeResolution.message,
|
|
};
|
|
}
|
|
const availability = evaluation.availability;
|
|
const authRequirement = evaluation.selectedRoute?.authRequirement;
|
|
if (availability === true) {
|
|
return { provider: ref.provider, model: ref.model, status: "ready", hasAuth: true };
|
|
}
|
|
if (
|
|
availability === undefined &&
|
|
(normalizeProviderIdForAuth(ref.provider) === "openai" ||
|
|
evaluation.routeResolution !== null ||
|
|
evaluation.evidence !== undefined)
|
|
) {
|
|
return { provider: ref.provider, model: ref.model, status: "indeterminate", hasAuth: false };
|
|
}
|
|
return {
|
|
provider: ref.provider,
|
|
model: ref.model,
|
|
status: "missing",
|
|
hasAuth: false,
|
|
...(authRequirement ? { authRequirement } : {}),
|
|
};
|
|
}
|
|
|
|
function catalogRouteObservation(
|
|
entry: ModelCatalogEntry | undefined,
|
|
): ModelRouteObservation | undefined {
|
|
if (!entry) {
|
|
return undefined;
|
|
}
|
|
const baseUrl = entry.baseUrl;
|
|
if (entry.api === undefined && baseUrl === undefined) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
...(entry.api !== undefined ? { api: entry.api } : {}),
|
|
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
};
|
|
}
|
|
|
|
type DefaultModelCatalogFacts = {
|
|
found: boolean;
|
|
observedRoutes?: readonly ModelRouteObservation[];
|
|
};
|
|
|
|
/** Resolve logical model identity and every physical route represented by a catalog. */
|
|
export function resolveDefaultModelCatalogFacts(
|
|
config: OpenClawConfig,
|
|
catalog: readonly ModelCatalogEntry[],
|
|
options?: { agentId?: string; routeVariants?: readonly ModelCatalogEntry[] },
|
|
): DefaultModelCatalogFacts {
|
|
const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId });
|
|
const provider = normalizeProviderIdForAuth(ref.provider);
|
|
const modelId = canonicalizeProviderModelId(provider, ref.model);
|
|
const matches = (entry: ModelCatalogEntry) =>
|
|
normalizeProviderIdForAuth(entry.provider) === provider &&
|
|
canonicalizeProviderModelId(provider, entry.id) === modelId;
|
|
const routeVariants = options?.routeVariants ?? catalog;
|
|
const observedRoutes = routeVariants
|
|
.filter(matches)
|
|
.map(catalogRouteObservation)
|
|
.filter((route): route is ModelRouteObservation => route !== undefined);
|
|
return {
|
|
found: catalog.some(matches) || routeVariants.some(matches),
|
|
...(observedRoutes.length > 0 ? { observedRoutes } : {}),
|
|
};
|
|
}
|
|
|
|
/** Warn when the selected default model is unknown or has no usable credentials. */
|
|
export async function warnIfModelConfigLooksOff(
|
|
config: OpenClawConfig,
|
|
prompter: WizardPrompter,
|
|
options?: {
|
|
agentId?: string;
|
|
agentDir?: string;
|
|
validateCatalog?: boolean;
|
|
env?: NodeJS.ProcessEnv;
|
|
observedRoutes?: readonly ModelRouteObservation[];
|
|
},
|
|
) {
|
|
const ref = resolveDefaultModelForAgent({
|
|
cfg: config,
|
|
agentId: options?.agentId,
|
|
});
|
|
const warnings: string[] = [];
|
|
const validationAgentId = options?.agentId ?? resolveDefaultAgentId(config);
|
|
const snapshot =
|
|
options?.validateCatalog === false
|
|
? { entries: [], routeVariants: [] }
|
|
: (
|
|
await publishPreparedModelRuntimeSnapshot(
|
|
{
|
|
config,
|
|
agentId: validationAgentId,
|
|
agentDir:
|
|
options?.agentDir ??
|
|
(options?.agentId
|
|
? resolveAgentDir(config, options.agentId)
|
|
: resolveDefaultAgentDir(config)),
|
|
inheritedAuthDir: resolveDefaultAgentDir(config),
|
|
workspaceDir: resolveAgentWorkspaceDir(config, validationAgentId),
|
|
},
|
|
{ force: true, provenance: "explicit" },
|
|
)
|
|
).modelCatalog;
|
|
const catalog = snapshot.entries;
|
|
const catalogFacts = resolveDefaultModelCatalogFacts(config, catalog, {
|
|
...(options?.agentId ? { agentId: options.agentId } : {}),
|
|
routeVariants: snapshot.routeVariants,
|
|
});
|
|
const observedRoutes = options?.observedRoutes ?? catalogFacts.observedRoutes;
|
|
if (options?.validateCatalog !== false) {
|
|
if (catalog.length > 0) {
|
|
if (!catalogFacts.found) {
|
|
warnings.push(
|
|
`Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const authStatus = resolveDefaultModelAuthStatus(config, {
|
|
...(options?.agentId ? { agentId: options.agentId } : {}),
|
|
...(options?.agentDir ? { agentDir: options.agentDir } : {}),
|
|
...(options?.env ? { env: options.env } : {}),
|
|
...(observedRoutes ? { observedRoutes } : {}),
|
|
});
|
|
if (authStatus.status === "missing") {
|
|
warnings.push(
|
|
`No auth configured for provider "${ref.provider}". The agent may fail until credentials are added. ${buildProviderAuthRecoveryHint(
|
|
{
|
|
provider: ref.provider,
|
|
config,
|
|
includeEnvVar: authStatus.authRequirement !== "subscription",
|
|
},
|
|
)}`,
|
|
);
|
|
} else if (authStatus.status === "incompatible") {
|
|
warnings.push(
|
|
`Model route is incompatible for "${ref.provider}/${ref.model}": ${authStatus.message}`,
|
|
);
|
|
} else if (authStatus.status === "indeterminate") {
|
|
warnings.push(
|
|
`Auth readiness could not be confirmed for "${ref.provider}/${ref.model}". Verify the selected model route and credential source before continuing.`,
|
|
);
|
|
}
|
|
|
|
if (warnings.length > 0) {
|
|
await prompter.note(warnings.join("\n"), "Model check");
|
|
}
|
|
}
|