mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(anthropic): native Claude models show incorrect availability (#129346)
* fix(anthropic): verify native Claude login before publishing runtime auth * fix(status): preserve native Claude CLI authentication labels * refactor(gateway): remove retired native Claude auth projection * fix(plugin-sdk): retain Claude compatibility types Keep the released Claude CLI credential reader source-compatible through its documented v2026.10 retirement window and explicitly account for both retained type exports in the public SDK budget. Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> * fix(plugin-sdk): preserve only shipped Claude compatibility exports * fix(ci): align Claude native-auth contracts Retire Doctor fixtures for credentials now owned by Claude CLI and remove the two never-shipped compatibility type exports while retaining the shipped reader through its documented v2026.10 window. Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> * test(onboard): remove retired Claude credential file fixture * fix(auth): skip incompatible profiles before plugin discovery * fix(auth): avoid cold plugin activation for retired profiles * test: repair upstream doctor and learning regressions * fix(onboard): validate authored aliases without plugin discovery * fix(agents): resolve compaction aliases without plugin discovery * test(ui): register pairing sidebar before lifecycle teardown * fix(anthropic): keep provider policy imports runtime-free * test(vitest): cover codex startup retry test family * fix(agents): avoid plugin discovery for internal sessions --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
fab76d4842
commit
fee4e77d91
@@ -32,3 +32,23 @@ it("does not inspect Claude token storage when the CLI reports logout", () => {
|
||||
|
||||
expect(probeClaudeCliAuthStatus()).toEqual({ status: "missing" });
|
||||
});
|
||||
|
||||
it("keeps the selected native-login root while removing inherited provider credentials", () => {
|
||||
spawnSync.mockReturnValue({ status: 0, stdout: JSON.stringify({ loggedIn: true }) });
|
||||
|
||||
expect(
|
||||
probeClaudeCliAuthStatus({
|
||||
command: "/custom/claude",
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "synthetic-ignored-api-key",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "synthetic-ignored-token",
|
||||
CLAUDE_CONFIG_DIR: "/tmp/selected-claude-account",
|
||||
},
|
||||
}),
|
||||
).toEqual({ status: "available" });
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
"/custom/claude",
|
||||
["auth", "status", "--json"],
|
||||
expect.objectContaining({ env: { CLAUDE_CONFIG_DIR: "/tmp/selected-claude-account" } }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { CLAUDE_CLI_CLEAR_ENV } from "./cli-constants.js";
|
||||
|
||||
type ClaudeCliAuthStatus = { status: "available" } | { status: "missing" | "unreadable" };
|
||||
|
||||
@@ -8,9 +9,13 @@ export function probeClaudeCliAuthStatus(params?: {
|
||||
command?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ClaudeCliAuthStatus {
|
||||
const env = { ...(params?.env ?? process.env) };
|
||||
for (const name of CLAUDE_CLI_CLEAR_ENV) {
|
||||
delete env[name];
|
||||
}
|
||||
const result = spawnSync(params?.command ?? "claude", ["auth", "status", "--json"], {
|
||||
encoding: "utf8",
|
||||
env: params?.env ?? process.env,
|
||||
env,
|
||||
maxBuffer: 64 * 1024,
|
||||
timeout: 3_000,
|
||||
windowsHide: true,
|
||||
|
||||
@@ -13,6 +13,61 @@ export const CLAUDE_CLI_OFF_THINKING_PROFILE = {
|
||||
} as const;
|
||||
/** Non-secret marker telling OpenClaw that the installed Claude CLI owns auth. */
|
||||
export const CLAUDE_CLI_NATIVE_AUTH_MARKER = ["openclaw", "claude-cli-native-auth"].join(":");
|
||||
|
||||
// Claude Code honors provider-routing, auth, and config-root env before
|
||||
// consulting its local login state, so inherited shell overrides must not
|
||||
// steer OpenClaw-managed Claude CLI runs toward a different provider,
|
||||
// endpoint, token source, plugin source, or telemetry bootstrap mode. Claude's
|
||||
// config directory remains inherited because it owns the selected native login.
|
||||
/** Environment variables removed before launching OpenClaw-managed Claude CLI runs. */
|
||||
export const CLAUDE_CLI_CLEAR_ENV = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_API_KEY_OLD",
|
||||
"ANTHROPIC_API_TOKEN",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_CUSTOM_HEADERS",
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"ANTHROPIC_UNIX_SOCKET",
|
||||
// Re-injected per run from OpenClaw's canonical context budget.
|
||||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
||||
// Re-injected only for 200K runs. Claude's user settings `env` block has
|
||||
// higher precedence than the spawned process environment by design.
|
||||
"CLAUDE_CODE_DISABLE_1M_CONTEXT",
|
||||
"CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING",
|
||||
// Re-injected per run from OpenClaw's effective thinking level.
|
||||
"MAX_THINKING_TOKENS",
|
||||
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_SCOPES",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
||||
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
|
||||
"CLAUDE_CODE_PLUGIN_SEED_DIR",
|
||||
"CLAUDE_CODE_REMOTE",
|
||||
"CLAUDE_CODE_USE_COWORK_PLUGINS",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_SDK_DISABLED",
|
||||
"OTEL_TRACES_EXPORTER",
|
||||
] as const;
|
||||
|
||||
/** Default Claude CLI model ref for agent defaults and live tests. */
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-5`;
|
||||
/** Provider-relative model id for Anthropic runtime-policy resolution. */
|
||||
|
||||
@@ -13,66 +13,13 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coe
|
||||
import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js";
|
||||
export {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
CLAUDE_CLI_CLEAR_ENV,
|
||||
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
CLAUDE_CLI_MODEL_ALIASES,
|
||||
CLAUDE_CLI_SESSION_ID_FIELDS,
|
||||
} from "./cli-constants.js";
|
||||
|
||||
// Claude Code honors provider-routing, auth, and config-root env before
|
||||
// consulting its local login state, so inherited shell overrides must not
|
||||
// steer OpenClaw-managed Claude CLI runs toward a different provider,
|
||||
// endpoint, token source, plugin source, or telemetry bootstrap mode. Claude's
|
||||
// config directory remains inherited because it owns the selected native login.
|
||||
/** Environment variables removed before launching OpenClaw-managed Claude CLI runs. */
|
||||
export const CLAUDE_CLI_CLEAR_ENV = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_API_KEY_OLD",
|
||||
"ANTHROPIC_API_TOKEN",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_CUSTOM_HEADERS",
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"ANTHROPIC_UNIX_SOCKET",
|
||||
// Re-injected per run from OpenClaw's canonical context budget.
|
||||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
||||
// Re-injected only for 200K runs. Claude's user settings `env` block has
|
||||
// higher precedence than the spawned process environment by design.
|
||||
"CLAUDE_CODE_DISABLE_1M_CONTEXT",
|
||||
"CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING",
|
||||
// Re-injected per run from OpenClaw's effective thinking level.
|
||||
"MAX_THINKING_TOKENS",
|
||||
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_SCOPES",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
||||
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
|
||||
"CLAUDE_CODE_PLUGIN_SEED_DIR",
|
||||
"CLAUDE_CODE_REMOTE",
|
||||
"CLAUDE_CODE_USE_COWORK_PLUGINS",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_SDK_DISABLED",
|
||||
"OTEL_TRACES_EXPORTER",
|
||||
] as const;
|
||||
|
||||
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
|
||||
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
|
||||
const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
|
||||
|
||||
@@ -1404,20 +1404,69 @@ describe("anthropic provider replay hooks", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves claude-cli with a non-secret native auth marker", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
it.each([
|
||||
{ status: "available", authenticated: true },
|
||||
{ status: "missing", authenticated: false },
|
||||
{ status: "unreadable", authenticated: false },
|
||||
] as const)(
|
||||
"publishes native Claude auth only when its CLI reports $status",
|
||||
async ({ status, authenticated }) => {
|
||||
probeClaudeCliAuthStatusMock.mockReturnValue({ status });
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const config = {};
|
||||
|
||||
const runtimeAuth = provider.resolveSyntheticAuth?.({
|
||||
provider: "claude-cli",
|
||||
} as never);
|
||||
const discoveryAuth = anthropicProviderDiscovery.resolveSyntheticAuth?.({
|
||||
provider: "claude-cli",
|
||||
} as never);
|
||||
for (const auth of [runtimeAuth, discoveryAuth]) {
|
||||
expect(auth?.apiKey).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER);
|
||||
expect(auth?.source).toBe("Claude CLI native auth");
|
||||
expect(auth?.mode).toBe("oauth");
|
||||
const runtimeAuth = provider.resolveSyntheticAuth?.({
|
||||
config,
|
||||
provider: "claude-cli",
|
||||
} as never);
|
||||
const discoveryAuth = anthropicProviderDiscovery.resolveSyntheticAuth?.({
|
||||
config,
|
||||
provider: "claude-cli",
|
||||
} as never);
|
||||
for (const auth of [runtimeAuth, discoveryAuth]) {
|
||||
expect(auth).toEqual(
|
||||
authenticated
|
||||
? {
|
||||
apiKey: CLAUDE_CLI_NATIVE_AUTH_MARKER,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "oauth",
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("reuses native login facts within one config generation and reprobes its replacement", async () => {
|
||||
probeClaudeCliAuthStatusMock.mockReturnValue({ status: "available" });
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const firstConfig = {};
|
||||
|
||||
for (let request = 0; request < 4; request += 1) {
|
||||
expect(
|
||||
anthropicProviderDiscovery.resolveSyntheticAuth?.({
|
||||
config: firstConfig,
|
||||
provider: "claude-cli",
|
||||
} as never)?.apiKey,
|
||||
).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER);
|
||||
expect(
|
||||
provider.resolveSyntheticAuth?.({ config: firstConfig, provider: "claude-cli" } as never)
|
||||
?.apiKey,
|
||||
).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER);
|
||||
}
|
||||
expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledOnce();
|
||||
|
||||
probeClaudeCliAuthStatusMock.mockReturnValue({ status: "missing" });
|
||||
expect(
|
||||
anthropicProviderDiscovery.resolveSyntheticAuth?.({
|
||||
config: {},
|
||||
provider: "claude-cli",
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledTimes(2);
|
||||
expect(provider.resolveSyntheticAuth?.({ provider: "claude-cli" } as never)).toBeUndefined();
|
||||
expect(probeClaudeCliAuthStatusMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not copy native Claude auth during anthropic cli migration", async () => {
|
||||
|
||||
@@ -3,11 +3,23 @@
|
||||
* synthetic auth for catalog/runtime discovery without full Anthropic registration.
|
||||
*/
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js";
|
||||
import { probeClaudeCliAuthStatus } from "./cli-auth-seam.js";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js";
|
||||
|
||||
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
const nativeLoginAvailabilityByConfig = new WeakMap<object, boolean>();
|
||||
|
||||
export function resolveClaudeCliSyntheticAuth() {
|
||||
export function resolveClaudeCliSyntheticAuth(config: object | undefined) {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
let available = nativeLoginAvailabilityByConfig.get(config);
|
||||
if (available === undefined) {
|
||||
available = probeClaudeCliAuthStatus().status === "available";
|
||||
nativeLoginAvailabilityByConfig.set(config, available);
|
||||
}
|
||||
if (!available) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
apiKey: CLAUDE_CLI_NATIVE_AUTH_MARKER,
|
||||
source: "Claude CLI native auth",
|
||||
@@ -20,8 +32,8 @@ const anthropicProviderDiscovery: ProviderPlugin = {
|
||||
label: "Claude CLI",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
resolveSyntheticAuth: ({ provider }) =>
|
||||
provider === CLAUDE_CLI_BACKEND_ID ? resolveClaudeCliSyntheticAuth() : undefined,
|
||||
resolveSyntheticAuth: ({ config, provider }) =>
|
||||
provider === CLAUDE_CLI_BACKEND_ID ? resolveClaudeCliSyntheticAuth(config) : undefined,
|
||||
};
|
||||
|
||||
export default anthropicProviderDiscovery;
|
||||
|
||||
@@ -1179,9 +1179,9 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
||||
);
|
||||
},
|
||||
normalizeResolvedModel: (ctx) => normalizeAnthropicResolvedModel(ctx),
|
||||
resolveSyntheticAuth: ({ provider }) =>
|
||||
resolveSyntheticAuth: ({ config, provider }) =>
|
||||
normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID
|
||||
? resolveClaudeCliSyntheticAuth()
|
||||
? resolveClaudeCliSyntheticAuth(config)
|
||||
: undefined,
|
||||
// Publish Claude CLI rows through the provider catalog hook.
|
||||
augmentModelCatalog: () => buildClaudeCliCatalogEntries(),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Coverage for building compaction runtime context from active runner state.
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js";
|
||||
import * as manifestModelIdNormalization from "../../plugins/manifest-model-id-normalization.js";
|
||||
import { addSession, deleteSession } from "../bash-process-registry.js";
|
||||
import { createProcessSessionFixture } from "../bash-process-registry.test-helpers.js";
|
||||
import * as providerModelNormalizationRuntime from "../provider-model-normalization.runtime.js";
|
||||
import {
|
||||
buildEmbeddedCompactionRuntimeContext,
|
||||
resolveCompactionContextTokenBudget,
|
||||
@@ -219,6 +221,41 @@ describe("buildEmbeddedCompactionRuntimeContext", () => {
|
||||
expect(result.authProfileId).toBe("openai:p1");
|
||||
});
|
||||
|
||||
it("resolves literal compaction overrides without discovering provider plugins", () => {
|
||||
const manifestNormalization = vi
|
||||
.spyOn(manifestModelIdNormalization, "normalizeProviderModelIdWithManifest")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("literal compaction overrides must not discover plugin manifests");
|
||||
});
|
||||
const runtimeNormalization = vi
|
||||
.spyOn(providerModelNormalizationRuntime, "normalizeProviderModelIdWithRuntime")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("literal compaction overrides must not activate provider plugins");
|
||||
});
|
||||
|
||||
try {
|
||||
const result = buildEmbeddedCompactionRuntimeContext({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentDir: "/tmp/agent",
|
||||
config: {
|
||||
agents: { defaults: { compaction: { model: "gpt-4o" } } },
|
||||
} as OpenClawConfig,
|
||||
provider: "openai",
|
||||
modelId: "gpt-3.5-turbo",
|
||||
authProfileId: "openai:p1",
|
||||
});
|
||||
|
||||
expect(result.provider).toBe("openai");
|
||||
expect(result.model).toBe("gpt-4o");
|
||||
expect(result.authProfileId).toBe("openai:p1");
|
||||
expect(manifestNormalization).not.toHaveBeenCalled();
|
||||
expect(runtimeNormalization).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
runtimeNormalization.mockRestore();
|
||||
manifestNormalization.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses session model when no compaction.model override configured", () => {
|
||||
const result = buildEmbeddedCompactionRuntimeContext({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
|
||||
@@ -15,7 +15,6 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_PROVIDER } from "../defaults.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
inferUniqueProviderFromConfiguredModels,
|
||||
resolveModelRefFromString,
|
||||
} from "../model-selection-shared.js";
|
||||
import { resolveSelectedOpenAIRuntimeProvider } from "../openai-routing.js";
|
||||
import { agentRuntimeAuthPlanMatchesTarget } from "../runtime-plan/prepare-auth.js";
|
||||
@@ -191,16 +190,11 @@ export function resolveEmbeddedCompactionTarget(params: {
|
||||
return assembleTarget(inferredLiteralProvider, override);
|
||||
}
|
||||
const defaultProvider = provider || DEFAULT_PROVIDER;
|
||||
const aliasResolution = resolveModelRefFromString({
|
||||
const aliasResolution = buildModelAliasIndex({
|
||||
cfg: config,
|
||||
raw: override,
|
||||
defaultProvider,
|
||||
aliasIndex: buildModelAliasIndex({
|
||||
cfg: config,
|
||||
defaultProvider,
|
||||
}),
|
||||
});
|
||||
if (aliasResolution?.alias) {
|
||||
}).byAlias.get(normalizeCompactionConfigKey(override));
|
||||
if (aliasResolution) {
|
||||
return assembleTarget(aliasResolution.ref.provider, aliasResolution.ref.model);
|
||||
}
|
||||
return assembleTarget(provider, override);
|
||||
|
||||
@@ -146,6 +146,33 @@ describe("createModelAuthAvailabilityResolver", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps prepared native-runtime authentication scoped to its exact owner", () => {
|
||||
const metadataSnapshot = {
|
||||
index: { plugins: [] },
|
||||
plugins: [
|
||||
{
|
||||
id: "anthropic",
|
||||
origin: "bundled",
|
||||
providerAuthAliases: { "claude-cli": "anthropic" },
|
||||
},
|
||||
],
|
||||
} as unknown as PluginMetadataSnapshot;
|
||||
const resolver = createModelAuthAvailabilityResolver({
|
||||
cfg: {},
|
||||
authStore: authStore(),
|
||||
env: {},
|
||||
metadataSnapshot,
|
||||
preparedRuntimeAuthModes: { "claude-cli": "api_key" },
|
||||
});
|
||||
|
||||
expect(resolver.evaluateModelAuth("claude-cli")).toMatchObject({
|
||||
availability: true,
|
||||
evidence: "runtime",
|
||||
selectedAuthMode: "api_key",
|
||||
});
|
||||
expect(resolver.evaluateModelAuth("anthropic").availability).not.toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "api_key" as const, selectedRoute: platformRoute },
|
||||
{ mode: "oauth" as const, selectedRoute: subscriptionRoute },
|
||||
|
||||
@@ -622,7 +622,9 @@ export function createModelAuthAvailabilityResolver(
|
||||
evidence: "aws-sdk",
|
||||
};
|
||||
}
|
||||
const preparedRuntimeAuthMode = params.preparedRuntimeAuthModes?.[normalizeProvider(provider)];
|
||||
const preparedRuntimeAuthMode =
|
||||
params.preparedRuntimeAuthModes?.[normalizeProviderIdForAuth(provider)] ??
|
||||
params.preparedRuntimeAuthModes?.[normalizeProvider(provider)];
|
||||
if (preparedRuntimeAuthMode) {
|
||||
return {
|
||||
availability: modeAllowed(provider, target, preparedRuntimeAuthMode),
|
||||
|
||||
@@ -39,20 +39,11 @@ export type ProviderCredentialPrecedence = "profile-first" | "env-first";
|
||||
|
||||
const log = createSubsystemLogger("model-auth");
|
||||
|
||||
function isAuthProfileRetired(params: {
|
||||
function assertAuthProfileNotRetired(params: {
|
||||
profileId: string;
|
||||
deprecatedProfileIds: ReadonlySet<string>;
|
||||
provider: string;
|
||||
store: AuthProfileStore;
|
||||
}): boolean {
|
||||
}): void {
|
||||
if (!params.deprecatedProfileIds.has(params.profileId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertAuthProfileNotRetired(params: Parameters<typeof isAuthProfileRetired>[0]): void {
|
||||
if (!isAuthProfileRetired(params)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
@@ -153,8 +144,6 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
assertAuthProfileNotRetired({
|
||||
profileId,
|
||||
deprecatedProfileIds: getDeprecatedProfileIds(),
|
||||
provider,
|
||||
store,
|
||||
});
|
||||
const configuredProfileType = store.profiles[profileId]?.type;
|
||||
if (configuredProfileType) {
|
||||
@@ -311,8 +300,6 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
assertAuthProfileNotRetired({
|
||||
profileId: providerEntryReference.profileId,
|
||||
deprecatedProfileIds: getDeprecatedProfileIds(),
|
||||
provider,
|
||||
store: providerEntryStore,
|
||||
});
|
||||
}
|
||||
const providerEntryBinding = await authConfig.resolveProviderEntryApiKeyBinding({
|
||||
@@ -417,19 +404,27 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
provider,
|
||||
preferredProfile,
|
||||
forModel: params.modelId,
|
||||
}).filter(
|
||||
(candidateProfileId) =>
|
||||
!isAuthProfileRetired({
|
||||
profileId: candidateProfileId,
|
||||
deprecatedProfileIds: getDeprecatedProfileIds(),
|
||||
provider,
|
||||
store,
|
||||
}),
|
||||
);
|
||||
});
|
||||
let deferredAuthProfileResult: ResolvedProviderAuth | null = null;
|
||||
let refreshFailure: OAuthRefreshFailureError | undefined;
|
||||
for (const candidate of order) {
|
||||
let candidateMode: ResolvedProviderAuth["mode"] | undefined;
|
||||
const candidateType = store.profiles[candidate]?.type;
|
||||
const candidateMode = candidateType
|
||||
? authConfig.profileTypeToAuthMode(candidateType)
|
||||
: undefined;
|
||||
if (
|
||||
candidateMode &&
|
||||
!isAuthModeAllowedForModel({
|
||||
provider,
|
||||
modelApi: params.modelApi,
|
||||
mode: candidateMode,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (getDeprecatedProfileIds().has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const awsSdkProfileAuth = authConfig.resolveConfiguredAwsSdkProfileAuth({
|
||||
cfg,
|
||||
@@ -439,18 +434,6 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
if (awsSdkProfileAuth) {
|
||||
return awsSdkProfileAuth;
|
||||
}
|
||||
const candidateType = store.profiles[candidate]?.type;
|
||||
candidateMode = candidateType ? authConfig.profileTypeToAuthMode(candidateType) : undefined;
|
||||
if (
|
||||
candidateMode &&
|
||||
!isAuthModeAllowedForModel({
|
||||
provider,
|
||||
modelApi: params.modelApi,
|
||||
mode: candidateMode,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
|
||||
@@ -206,6 +206,12 @@ vi.mock("./model-auth-env-vars.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const resolveProviderDeprecatedAuthProfileIdsMock = vi.hoisted(() =>
|
||||
vi.fn(({ provider }: { provider: string }) =>
|
||||
provider === "anthropic" || provider === "claude-cli" ? ["anthropic:claude-cli"] : [],
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
buildProviderMissingAuthMessageWithPlugin: (params: {
|
||||
provider: string;
|
||||
@@ -218,8 +224,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
},
|
||||
formatProviderAuthProfileApiKeyWithPlugin: async () => undefined,
|
||||
refreshProviderOAuthCredentialWithPlugin: async () => null,
|
||||
resolveProviderDeprecatedAuthProfileIds: ({ provider }: { provider: string }) =>
|
||||
provider === "anthropic" || provider === "claude-cli" ? ["anthropic:claude-cli"] : [],
|
||||
resolveProviderDeprecatedAuthProfileIds: resolveProviderDeprecatedAuthProfileIdsMock,
|
||||
resolveProviderSyntheticAuthWithPlugin: (params: {
|
||||
provider: string;
|
||||
context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } };
|
||||
@@ -267,6 +272,7 @@ vi.mock("./cli-credentials.js", () => cliCredentialMocks);
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
resolveProviderDeprecatedAuthProfileIdsMock.mockClear();
|
||||
cliCredentialMocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
cliCredentialMocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
});
|
||||
@@ -520,6 +526,28 @@ describe("getApiKeyForModelCore", () => {
|
||||
).rejects.toThrow(/requires an OpenAI API key profile/);
|
||||
});
|
||||
|
||||
it("skips incompatible OpenAI OAuth profiles before loading provider retirement policy", async () => {
|
||||
await withEnvAsync({ OPENAI_API_KEY: "direct-openai-audio-key" }, async () => {
|
||||
const resolved = await resolveApiKeyForProviderCore({
|
||||
provider: "openai",
|
||||
modelApi: "openai-audio-transcriptions",
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
...oauthFixture,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject({ apiKey: "direct-openai-audio-key", mode: "api-key" });
|
||||
expect(resolveProviderDeprecatedAuthProfileIdsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an explicit OpenAI API-key profile for the Codex transport", async () => {
|
||||
const store = {
|
||||
version: 1 as const,
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { ChannelMessageActionName } from "../../channels/plugins/types.publ
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resolveAllowedMessageActions } from "../../infra/outbound/outbound-policy.js";
|
||||
import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js";
|
||||
import {
|
||||
appendMessageToolReadHint,
|
||||
@@ -89,7 +89,7 @@ function inferDeliveryFromSessionKey(
|
||||
return null;
|
||||
}
|
||||
const channel = normalizeMessageChannel(route.channel);
|
||||
if (!channel) {
|
||||
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return null;
|
||||
}
|
||||
const accountId = route.accountId ? resolveAgentAccountId(route.accountId) : undefined;
|
||||
@@ -112,15 +112,12 @@ export function resolveEffectiveCurrentChannelContext(options?: MessageToolCurre
|
||||
} {
|
||||
const currentChannelProvider = options?.currentChannelProvider;
|
||||
const currentChannelId = options?.currentChannelId;
|
||||
const sessionDelivery = inferDeliveryFromSessionKey(options?.agentSessionKey);
|
||||
const sessionDeliveryChannel = normalizeMessageChannel(sessionDelivery?.channel);
|
||||
const preferSessionDeliveryContext =
|
||||
normalizeMessageChannel(currentChannelProvider) === "webchat" &&
|
||||
sessionDeliveryChannel !== undefined &&
|
||||
sessionDeliveryChannel !== "webchat" &&
|
||||
Boolean(sessionDelivery?.to);
|
||||
const sessionDelivery =
|
||||
normalizeMessageChannel(currentChannelProvider) === INTERNAL_MESSAGE_CHANNEL
|
||||
? inferDeliveryFromSessionKey(options?.agentSessionKey)
|
||||
: null;
|
||||
|
||||
if (!preferSessionDeliveryContext) {
|
||||
if (!sessionDelivery?.to) {
|
||||
return {
|
||||
currentChannelProvider,
|
||||
currentChannelId,
|
||||
@@ -129,12 +126,12 @@ export function resolveEffectiveCurrentChannelContext(options?: MessageToolCurre
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: sessionDelivery?.accountId,
|
||||
currentChannelProvider: sessionDeliveryChannel,
|
||||
currentChannelId: sessionDelivery?.to,
|
||||
currentChatType: sessionDelivery?.chatType,
|
||||
currentMessagingTarget: sessionDelivery?.to,
|
||||
currentThreadTs: sessionDelivery?.threadId,
|
||||
accountId: sessionDelivery.accountId,
|
||||
currentChannelProvider: sessionDelivery.channel,
|
||||
currentChannelId: sessionDelivery.to,
|
||||
currentChatType: sessionDelivery.chatType,
|
||||
currentMessagingTarget: sessionDelivery.to,
|
||||
currentThreadTs: sessionDelivery.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1174,6 +1174,21 @@ describe("message tool secret scoping", () => {
|
||||
expect(input?.params).toMatchObject({ action: "send", message: "hi" });
|
||||
});
|
||||
|
||||
it("does not discover bundled plugins for an internal WebChat session", async () => {
|
||||
const { getBundledChannelPlugin } = await import("../../channels/plugins/bundled.js");
|
||||
const bundledPluginLookup = vi.mocked(getBundledChannelPlugin);
|
||||
bundledPluginLookup.mockClear();
|
||||
|
||||
createMessageTool({
|
||||
config: { agents: { entries: { main: { default: true } } } },
|
||||
preparedMessageToolCatalog: EMPTY_PREPARED_MESSAGE_TOOL_CATALOG,
|
||||
currentChannelProvider: "webchat",
|
||||
agentSessionKey: "agent:main:webchat:dm:dashboard",
|
||||
});
|
||||
|
||||
expect(bundledPluginLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps automatic WebChat final-answer guidance while selecting the tool-local sink", async () => {
|
||||
mockSendResult();
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// Onboard custom config tests cover provider-specific config merging and context-window bounds.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import * as currentPluginMetadataSnapshot from "../plugins/current-plugin-metadata-snapshot.js";
|
||||
import * as manifestContractEligibility from "../plugins/manifest-contract-eligibility.js";
|
||||
import {
|
||||
applyCustomApiConfig,
|
||||
buildAnthropicVerificationProbeRequest,
|
||||
buildOpenAiVerificationProbeRequest,
|
||||
parseNonInteractiveCustomApiFlags,
|
||||
resolveCustomModelAliasError,
|
||||
resolveCustomModelImageInputInference,
|
||||
} from "./onboard-custom-config.js";
|
||||
|
||||
@@ -120,6 +123,55 @@ it("rejects custom aliases already used by the selected agent", () => {
|
||||
).toThrow("Alias Operations already points to openai/ops.");
|
||||
});
|
||||
|
||||
it("validates authored and inherited aliases without discovering plugin metadata", () => {
|
||||
const currentSnapshot = vi
|
||||
.spyOn(currentPluginMetadataSnapshot, "getCurrentPluginMetadataSnapshot")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("authored alias validation must not inspect plugin metadata");
|
||||
});
|
||||
const loadedSnapshot = vi
|
||||
.spyOn(manifestContractEligibility, "loadManifestMetadataSnapshot")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("authored alias validation must not load plugin metadata");
|
||||
});
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { models: { "anthropic/global": { alias: "Global" } } },
|
||||
entries: { ops: { models: { "openai/ops": { alias: "Operations" } } } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolveCustomModelAliasError({
|
||||
raw: "Operations",
|
||||
cfg,
|
||||
agentId: "ops",
|
||||
modelRef: "custom/new-model",
|
||||
}),
|
||||
).toBe("Alias Operations already points to openai/ops.");
|
||||
expect(
|
||||
resolveCustomModelAliasError({
|
||||
raw: "Global",
|
||||
cfg,
|
||||
agentId: "ops",
|
||||
modelRef: "custom/new-model",
|
||||
}),
|
||||
).toBe("Alias Global already points to anthropic/global.");
|
||||
expect(
|
||||
resolveCustomModelAliasError({
|
||||
raw: "Operations",
|
||||
cfg,
|
||||
agentId: "ops",
|
||||
modelRef: "openai/ops",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
currentSnapshot.mockRestore();
|
||||
loadedSnapshot.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a list-form roster when applying custom-provider model state", () => {
|
||||
const result = applyCustomApiConfig({
|
||||
config: {
|
||||
|
||||
@@ -317,6 +317,8 @@ export function resolveCustomModelAliasError(params: {
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
agentId: params.agentId,
|
||||
allowManifestNormalization: false,
|
||||
allowPluginNormalization: false,
|
||||
});
|
||||
const aliasKey = normalizeLowercaseStringOrEmpty(normalized);
|
||||
const existing = aliasIndex.byAlias.get(aliasKey);
|
||||
|
||||
@@ -37,4 +37,26 @@ describe("provider setup cold imports", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps bundled provider policy and config defaults off credential and execution runtime", () => {
|
||||
for (const file of [
|
||||
"extensions/anthropic/config-defaults.ts",
|
||||
"extensions/anthropic/provider-policy-api.ts",
|
||||
]) {
|
||||
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
|
||||
expect(
|
||||
source,
|
||||
`${file} must not load credential runtime for a provider-owned constant`,
|
||||
).not.toMatch(/from\s+["']openclaw\/plugin-sdk\/provider-auth["']/);
|
||||
}
|
||||
|
||||
const policySource = fs.readFileSync(
|
||||
path.join(repoRoot, "extensions/anthropic/provider-policy-api.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(
|
||||
policySource,
|
||||
"lightweight provider policy must not load CLI execution runtime",
|
||||
).not.toMatch(/from\s+["']\.\/cli-shared\.js["']/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credential-modes.js";
|
||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||
import { resolveExternalCliAuthScopeFromConfig } from "../../agents/auth-profiles/external-cli-scope.js";
|
||||
@@ -8,64 +7,22 @@ import {
|
||||
createModelAuthAvailabilityResolver,
|
||||
type ModelAuthAvailabilityResolver,
|
||||
} from "../../agents/model-auth-availability.js";
|
||||
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
|
||||
import { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizePluginsConfig } from "../../plugins/config-state.js";
|
||||
import { isActivatedManifestOwner } from "../../plugins/manifest-owner-policy.js";
|
||||
import { isManifestPluginAvailableForControlPlane } from "../../plugins/manifest-contract-eligibility.js";
|
||||
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { resolveModelChoiceAgentRuntime } from "./models-list-public-projection.js";
|
||||
|
||||
function listEnabledSyntheticAuthProviderRefs(
|
||||
metadataSnapshot: PluginMetadataSnapshot,
|
||||
config: OpenClawConfig,
|
||||
): readonly string[] {
|
||||
return metadataSnapshot.index.plugins
|
||||
.filter((plugin) => plugin.enabled)
|
||||
return metadataSnapshot.plugins
|
||||
.filter((plugin) =>
|
||||
isManifestPluginAvailableForControlPlane({ snapshot: metadataSnapshot, plugin, config }),
|
||||
)
|
||||
.flatMap((plugin) => plugin.syntheticAuthRefs ?? []);
|
||||
}
|
||||
|
||||
export function createPreparedSyntheticCliRuntimeResolver(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
metadataSnapshot: PluginMetadataSnapshot;
|
||||
}): (entry: ModelCatalogEntry) => string | undefined {
|
||||
const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins);
|
||||
const activatedPluginIds = new Set(
|
||||
params.metadataSnapshot.plugins
|
||||
.filter((plugin) =>
|
||||
isActivatedManifestOwner({
|
||||
plugin,
|
||||
normalizedConfig: normalizedPluginConfig,
|
||||
rootConfig: params.cfg,
|
||||
}),
|
||||
)
|
||||
.map((plugin) => plugin.id),
|
||||
);
|
||||
return (entry) => {
|
||||
const runtime = normalizeProviderId(
|
||||
resolveModelChoiceAgentRuntime({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
entry,
|
||||
})?.id ?? "",
|
||||
);
|
||||
if (!runtime || runtime === "openclaw") {
|
||||
return undefined;
|
||||
}
|
||||
const provider = normalizeProviderId(entry.provider);
|
||||
const providerOwners = new Set(params.metadataSnapshot.owners.providers.get(provider) ?? []);
|
||||
const owners = (params.metadataSnapshot.owners.cliBackends.get(runtime) ?? []).filter(
|
||||
(pluginId) =>
|
||||
providerOwners.has(pluginId) &&
|
||||
activatedPluginIds.has(pluginId) &&
|
||||
params.metadataSnapshot.byPluginId
|
||||
.get(pluginId)
|
||||
?.syntheticAuthRefs?.some((candidate) => normalizeProviderId(candidate) === runtime),
|
||||
);
|
||||
return owners.length === 1 ? runtime : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function createModelsListAuthResolver(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
@@ -87,7 +44,10 @@ export function createModelsListAuthResolver(params: {
|
||||
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
|
||||
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
|
||||
skipSetupProviderFallback: true,
|
||||
syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params.metadataSnapshot),
|
||||
syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(
|
||||
params.metadataSnapshot,
|
||||
params.cfg,
|
||||
),
|
||||
externalCliProviderIds: resolveExternalCliAuthScopeFromConfig(params.cfg)?.providerIds ?? [],
|
||||
preparedRuntimeAuthStore: params.preparedAuthStore,
|
||||
routeResolverFactory: params.routeResolverFactory,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
|
||||
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import {
|
||||
listModels,
|
||||
providerCatalogEntry,
|
||||
@@ -24,15 +22,21 @@ const config = {
|
||||
|
||||
async function listClaudeCliModel(
|
||||
params: {
|
||||
authenticated?: boolean;
|
||||
pluginDisabled?: boolean;
|
||||
cfg?: OpenClawConfig;
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
} = {},
|
||||
) {
|
||||
return await listModels({
|
||||
catalog: [],
|
||||
staticEntries: [providerCatalogEntry("anthropic", "claude-opus-5")],
|
||||
cfg: params.cfg ?? config,
|
||||
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
|
||||
cfg:
|
||||
params.cfg ??
|
||||
(params.pluginDisabled
|
||||
? { ...config, plugins: { entries: { anthropic: { enabled: false } } } }
|
||||
: config),
|
||||
preparedAuthModes:
|
||||
params.authenticated && !params.pluginDisabled ? { "claude-cli": "api_key" } : {},
|
||||
view: "configured",
|
||||
});
|
||||
}
|
||||
@@ -46,25 +50,18 @@ describe("models.list CLI runtime availability", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("marks a Claude CLI runtime model available through bundled synthetic auth", async () => {
|
||||
await expect(listClaudeCliModel()).resolves.toEqual({
|
||||
models: [expect.objectContaining({ id: "claude-opus-5", available: true })],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use synthetic auth from an explicitly disabled Anthropic plugin", async () => {
|
||||
await expect(
|
||||
listClaudeCliModel({
|
||||
cfg: {
|
||||
...config,
|
||||
plugins: { entries: { anthropic: { enabled: false } } },
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
models: [expect.objectContaining({ id: "claude-opus-5", available: false })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ authenticated: true, pluginDisabled: false, available: true },
|
||||
{ authenticated: false, pluginDisabled: false, available: false },
|
||||
{ authenticated: true, pluginDisabled: true, available: false },
|
||||
])(
|
||||
"reports native login=$authenticated and plugin disabled=$pluginDisabled",
|
||||
async (scenario) => {
|
||||
await expect(listClaudeCliModel(scenario)).resolves.toEqual({
|
||||
models: [expect.objectContaining({ id: "claude-opus-5", available: scenario.available })],
|
||||
});
|
||||
},
|
||||
);
|
||||
it("does not use synthetic auth when plugins are globally disabled", async () => {
|
||||
await expect(
|
||||
listClaudeCliModel({
|
||||
@@ -77,37 +74,4 @@ describe("models.list CLI runtime availability", () => {
|
||||
models: [expect.objectContaining({ id: "claude-opus-5", available: false })],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not choose between multiple active runtime owners", async () => {
|
||||
const metadataSnapshot = loadManifestMetadataSnapshot({ config, env: process.env });
|
||||
const anthropic = metadataSnapshot.plugins.find((plugin) => plugin.id === "anthropic");
|
||||
if (!anthropic) {
|
||||
throw new Error("Anthropic manifest missing from model availability fixture");
|
||||
}
|
||||
const duplicate = { ...anthropic, id: "anthropic-duplicate" };
|
||||
const providerOwners = new Map(metadataSnapshot.owners.providers);
|
||||
providerOwners.set("anthropic", [...(providerOwners.get("anthropic") ?? []), duplicate.id]);
|
||||
const cliBackendOwners = new Map(metadataSnapshot.owners.cliBackends);
|
||||
cliBackendOwners.set("claude-cli", [
|
||||
...(cliBackendOwners.get("claude-cli") ?? []),
|
||||
duplicate.id,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
listClaudeCliModel({
|
||||
metadataSnapshot: {
|
||||
...metadataSnapshot,
|
||||
plugins: [...metadataSnapshot.plugins, duplicate],
|
||||
byPluginId: new Map([...metadataSnapshot.byPluginId, [duplicate.id, duplicate]]),
|
||||
owners: {
|
||||
...metadataSnapshot.owners,
|
||||
providers: providerOwners,
|
||||
cliBackends: cliBackendOwners,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
models: [expect.objectContaining({ id: "claude-opus-5", available: false })],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credential-modes.js";
|
||||
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
|
||||
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
|
||||
import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js";
|
||||
@@ -47,6 +48,7 @@ export async function listModels(params: {
|
||||
staticEntries?: ModelCatalogEntry[];
|
||||
cfg?: OpenClawConfig;
|
||||
discoveryModes?: Record<string, "refreshable" | "runtime" | "static">;
|
||||
preparedAuthModes?: PreparedAgentCredentialModes;
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
|
||||
view?: "all" | "configured" | "provider-config" | "default";
|
||||
@@ -60,7 +62,7 @@ export async function listModels(params: {
|
||||
catalogComplete: false,
|
||||
workspaceDir: "/tmp/models-list-openai-workspace",
|
||||
config,
|
||||
authModes: {},
|
||||
authModes: params.preparedAuthModes ?? {},
|
||||
authStore: loadAuthProfileStoreWithoutExternalProfiles("/tmp/models-list-openai-agent", {
|
||||
allowKeychainPrompt: false,
|
||||
}),
|
||||
|
||||
@@ -51,10 +51,7 @@ import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { loadDeferredCatalog, readPreparedCatalog } from "../server-model-catalog-auth.js";
|
||||
import { resolveGatewayModelThinkingProfile } from "../session-utils-model.js";
|
||||
import { resolveModelProviderCapabilities } from "./model-provider-capabilities.js";
|
||||
import {
|
||||
createModelsListAuthResolver,
|
||||
createPreparedSyntheticCliRuntimeResolver,
|
||||
} from "./models-list-auth-resolver.js";
|
||||
import { createModelsListAuthResolver } from "./models-list-auth-resolver.js";
|
||||
import { prepareModelsListHarnessCatalog } from "./models-list-harness-catalog.js";
|
||||
import {
|
||||
buildPublicModelProjection,
|
||||
@@ -86,27 +83,24 @@ function resolveLegacyEntryAvailability(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
metadataSnapshot: PluginMetadataSnapshot;
|
||||
resolvePreparedSyntheticCliRuntime: (entry: ModelCatalogEntry) => string | undefined;
|
||||
}): ModelAuthAvailability {
|
||||
if (params.primaryAvailability === true) {
|
||||
return true;
|
||||
}
|
||||
let available = params.primaryAvailability;
|
||||
const preparedSyntheticRuntime = params.resolvePreparedSyntheticCliRuntime(params.entry);
|
||||
const runtimeProvider =
|
||||
resolveCliRuntimeExecutionProvider({
|
||||
provider: params.entry.provider,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
modelId: params.entry.id,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
}) ?? preparedSyntheticRuntime;
|
||||
const runtimeProvider = resolveCliRuntimeExecutionProvider({
|
||||
provider: params.entry.provider,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
modelId: params.entry.id,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
});
|
||||
if (
|
||||
runtimeProvider &&
|
||||
normalizeProviderId(runtimeProvider) !== normalizeProviderId(params.entry.provider)
|
||||
) {
|
||||
const runtimeAvailable = params.authResolver.resolveProviderAuthAvailability(runtimeProvider);
|
||||
if (runtimeAvailable === true || preparedSyntheticRuntime === runtimeProvider) {
|
||||
if (runtimeAvailable === true) {
|
||||
return true;
|
||||
}
|
||||
if (available === false && runtimeAvailable === undefined) {
|
||||
@@ -128,11 +122,6 @@ function createModelsListEntryEvaluator(params: {
|
||||
entry: ModelCatalogEntry,
|
||||
routeVariants?: readonly ModelCatalogEntry[],
|
||||
) => Promise<ModelAuthAvailabilityEvaluation> {
|
||||
const resolvePreparedSyntheticCliRuntime = createPreparedSyntheticCliRuntimeResolver({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
});
|
||||
const pending = new Map<string, Promise<ModelAuthAvailabilityEvaluation>>();
|
||||
return (entry, routeVariants = [entry]) => {
|
||||
const identity = openAIModelCatalogRoutePolicy.resolveIdentity(entry);
|
||||
@@ -162,7 +151,6 @@ function createModelsListEntryEvaluator(params: {
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
resolvePreparedSyntheticCliRuntime,
|
||||
}),
|
||||
}
|
||||
: evaluation;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
loadAuthProfileStoreWithoutExternalProfiles,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
|
||||
import type { PreparedModelRuntimeAuth } from "../../agents/prepared-model-runtime-auth.js";
|
||||
import { materializeRuntimeCapabilities } from "../../agents/prepared-model-runtime.configured-catalog.js";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
|
||||
@@ -1633,67 +1634,82 @@ describe("models.list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps catalog models available through a native CLI runtime", async () => {
|
||||
await withoutAnthropicEnvAuth(async () => {
|
||||
await withModelsTestState(
|
||||
{
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-models-list-cli-runtime-",
|
||||
agentEnv: "main",
|
||||
},
|
||||
async () => {
|
||||
const runtimeConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-8": {
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
it.each([
|
||||
{ authenticated: true, available: true },
|
||||
{ authenticated: false, available: false },
|
||||
])(
|
||||
"projects native Claude runtime availability when authenticated=$authenticated",
|
||||
async ({ authenticated, available }) => {
|
||||
await withoutAnthropicEnvAuth(async () => {
|
||||
await withModelsTestState(
|
||||
{
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-models-list-cli-runtime-",
|
||||
agentEnv: "main",
|
||||
},
|
||||
async () => {
|
||||
cliBackendsTesting.setDepsForTest({
|
||||
resolveRuntimeCliBackends: () =>
|
||||
[{ id: "claude-cli", modelProvider: "anthropic", pluginId: "anthropic" }] as never,
|
||||
});
|
||||
try {
|
||||
const runtimeConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-8": {
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "all",
|
||||
runtimeConfig,
|
||||
loadGatewayModelCatalog: vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
provider: "anthropic",
|
||||
},
|
||||
]),
|
||||
),
|
||||
reqId: "req-models-list-cli-runtime",
|
||||
});
|
||||
await request;
|
||||
} as unknown as OpenClawConfig;
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "all",
|
||||
runtimeConfig,
|
||||
preparedAuthModes: authenticated ? { "claude-cli": "api_key" } : {},
|
||||
loadGatewayModelCatalog: vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
provider: "anthropic",
|
||||
},
|
||||
]),
|
||||
),
|
||||
reqId: "req-models-list-cli-runtime",
|
||||
});
|
||||
await request;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
models: [
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
provider: "anthropic",
|
||||
agentRuntime: {
|
||||
id: "claude-cli",
|
||||
cloudPlacementSupported: false,
|
||||
devicePlacementSupported: false,
|
||||
source: "model",
|
||||
},
|
||||
available: true,
|
||||
tags: ["configured"],
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
provider: "anthropic",
|
||||
agentRuntime: {
|
||||
id: "claude-cli",
|
||||
cloudPlacementSupported: false,
|
||||
devicePlacementSupported: false,
|
||||
source: "model",
|
||||
},
|
||||
available,
|
||||
tags: ["configured"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
cliBackendsTesting.resetDepsForTest();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps file SecretRef provider availability unknown when read-only auth cannot resolve it", async () => {
|
||||
const catalog = [{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }];
|
||||
|
||||
@@ -625,6 +625,40 @@ describe("provider-runtime", () => {
|
||||
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not activate provider runtime to inspect retired auth profiles", () => {
|
||||
resolvePluginProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: DEMO_PROVIDER_ID,
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
deprecatedProfileIds: ["demo:retired"],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(resolveProviderDeprecatedAuthProfileIds({ provider: DEMO_PROVIDER_ID })).toEqual([]);
|
||||
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors retired auth profiles declared by an active provider", () => {
|
||||
const provider: ProviderPlugin = {
|
||||
id: DEMO_PROVIDER_ID,
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
deprecatedProfileIds: ["demo:retired"],
|
||||
};
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.providers.push({ pluginId: DEMO_PROVIDER_ID, provider, source: "test" });
|
||||
setActivePluginRegistry(registry, "startup-registry", "gateway-bindable", "/tmp/workspace");
|
||||
|
||||
expect(
|
||||
resolveProviderDeprecatedAuthProfileIds({
|
||||
provider: DEMO_PROVIDER_ID,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
).toEqual(["demo:retired"]);
|
||||
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the prepared run registry without repeating provider discovery", () => {
|
||||
const provider: ProviderPlugin = {
|
||||
id: DEMO_PROVIDER_ID,
|
||||
|
||||
Reference in New Issue
Block a user