mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 02:40:18 +00:00
Refactor release hardening follow-ups (#39959)
* build: fail fast on stale host-env swift policy * build: sync generated host env swift policy * build: guard bundled extension root dependency gaps * refactor: centralize provider capability quirks * test: table-drive provider regression coverage * fix: block merge when prep branch has unpushed commits * refactor: simplify models config merge preservation
This commit is contained in:
committed by
GitHub
parent
27558806b5
commit
eba9dcc67a
@@ -22,7 +22,7 @@ enum HostEnvSecurityPolicy {
|
|||||||
"PS4",
|
"PS4",
|
||||||
"GCONV_PATH",
|
"GCONV_PATH",
|
||||||
"IFS",
|
"IFS",
|
||||||
"SSLKEYLOGFILE",
|
"SSLKEYLOGFILE"
|
||||||
]
|
]
|
||||||
|
|
||||||
static let blockedOverrideKeys: Set<String> = [
|
static let blockedOverrideKeys: Set<String> = [
|
||||||
@@ -50,17 +50,17 @@ enum HostEnvSecurityPolicy {
|
|||||||
"OPENSSL_ENGINES",
|
"OPENSSL_ENGINES",
|
||||||
"PYTHONSTARTUP",
|
"PYTHONSTARTUP",
|
||||||
"WGETRC",
|
"WGETRC",
|
||||||
"CURL_HOME",
|
"CURL_HOME"
|
||||||
]
|
]
|
||||||
|
|
||||||
static let blockedOverridePrefixes: [String] = [
|
static let blockedOverridePrefixes: [String] = [
|
||||||
"GIT_CONFIG_",
|
"GIT_CONFIG_",
|
||||||
"NPM_CONFIG_",
|
"NPM_CONFIG_"
|
||||||
]
|
]
|
||||||
|
|
||||||
static let blockedPrefixes: [String] = [
|
static let blockedPrefixes: [String] = [
|
||||||
"DYLD_",
|
"DYLD_",
|
||||||
"LD_",
|
"LD_",
|
||||||
"BASH_FUNC_",
|
"BASH_FUNC_"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@
|
|||||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
|
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
|
||||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||||
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
||||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||||
|
|||||||
25
scripts/pr
25
scripts/pr
@@ -229,6 +229,30 @@ checkout_prep_branch() {
|
|||||||
git checkout "$prep_branch"
|
git checkout "$prep_branch"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verify_prep_branch_matches_prepared_head() {
|
||||||
|
local pr="$1"
|
||||||
|
local prepared_head_sha="$2"
|
||||||
|
|
||||||
|
require_artifact .local/prep-context.env
|
||||||
|
checkout_prep_branch "$pr"
|
||||||
|
|
||||||
|
local prep_branch_head_sha
|
||||||
|
prep_branch_head_sha=$(git rev-parse HEAD)
|
||||||
|
if [ "$prep_branch_head_sha" = "$prepared_head_sha" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Local prep branch moved after prepare-push (expected $prepared_head_sha, got $prep_branch_head_sha)."
|
||||||
|
if git merge-base --is-ancestor "$prepared_head_sha" "$prep_branch_head_sha" 2>/dev/null; then
|
||||||
|
echo "Unpushed local commits on prep branch:"
|
||||||
|
git log --oneline "${prepared_head_sha}..${prep_branch_head_sha}" | sed 's/^/ /' || true
|
||||||
|
echo "Run scripts/pr prepare-sync-head $pr to push them before merge."
|
||||||
|
else
|
||||||
|
echo "Prep branch no longer contains the prepared head. Re-run prepare-init."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
resolve_head_push_url() {
|
resolve_head_push_url() {
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source .local/pr-meta.env
|
source .local/pr-meta.env
|
||||||
@@ -1667,6 +1691,7 @@ merge_verify() {
|
|||||||
require_artifact .local/prep.env
|
require_artifact .local/prep.env
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source .local/prep.env
|
source .local/prep.env
|
||||||
|
verify_prep_branch_matches_prepared_head "$pr" "$PREP_HEAD_SHA"
|
||||||
|
|
||||||
local json
|
local json
|
||||||
json=$(pr_meta_json "$pr")
|
json=$(pr_meta_json "$pr")
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./s
|
|||||||
|
|
||||||
type PackFile = { path: string };
|
type PackFile = { path: string };
|
||||||
type PackResult = { files?: PackFile[] };
|
type PackResult = { files?: PackFile[] };
|
||||||
|
type PackageJson = {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
optionalDependencies?: Record<string, string>;
|
||||||
|
openclaw?: {
|
||||||
|
install?: {
|
||||||
|
npmSpec?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const requiredPathGroups = [
|
const requiredPathGroups = [
|
||||||
["dist/index.js", "dist/index.mjs"],
|
["dist/index.js", "dist/index.mjs"],
|
||||||
@@ -108,11 +119,6 @@ const appcastPath = resolve("appcast.xml");
|
|||||||
const laneBuildMin = 1_000_000_000;
|
const laneBuildMin = 1_000_000_000;
|
||||||
const laneFloorAdoptionDateKey = 20260227;
|
const laneFloorAdoptionDateKey = 20260227;
|
||||||
|
|
||||||
type PackageJson = {
|
|
||||||
name?: string;
|
|
||||||
version?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizePluginSyncVersion(version: string): string {
|
function normalizePluginSyncVersion(version: string): string {
|
||||||
const normalized = version.trim().replace(/^v/, "");
|
const normalized = version.trim().replace(/^v/, "");
|
||||||
const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1];
|
const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1];
|
||||||
@@ -122,6 +128,92 @@ function normalizePluginSyncVersion(version: string): string {
|
|||||||
return normalized.replace(/[-+].*$/, "");
|
return normalized.replace(/[-+].*$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS: Record<string, string[]> = {
|
||||||
|
googlechat: ["google-auth-library"],
|
||||||
|
matrix: ["@matrix-org/matrix-sdk-crypto-nodejs", "@vector-im/matrix-bot-sdk", "music-metadata"],
|
||||||
|
msteams: ["@microsoft/agents-hosting"],
|
||||||
|
nostr: ["nostr-tools"],
|
||||||
|
tlon: ["@tloncorp/api", "@tloncorp/tlon-skill", "@urbit/aura"],
|
||||||
|
zalouser: ["zca-js"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function collectBundledExtensionRootDependencyGapErrors(params: {
|
||||||
|
rootPackage: PackageJson;
|
||||||
|
extensions: Array<{ id: string; packageJson: PackageJson }>;
|
||||||
|
}): string[] {
|
||||||
|
const rootDeps = {
|
||||||
|
...params.rootPackage.dependencies,
|
||||||
|
...params.rootPackage.optionalDependencies,
|
||||||
|
};
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const extension of params.extensions) {
|
||||||
|
if (!extension.packageJson.openclaw?.install?.npmSpec) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = Object.keys(extension.packageJson.dependencies ?? {})
|
||||||
|
.filter((dep) => dep !== "openclaw" && !rootDeps[dep])
|
||||||
|
.toSorted();
|
||||||
|
const allowlisted = [
|
||||||
|
...(ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS[extension.id] ?? []),
|
||||||
|
].toSorted();
|
||||||
|
if (missing.join("\n") !== allowlisted.join("\n")) {
|
||||||
|
const unexpected = missing.filter((dep) => !allowlisted.includes(dep));
|
||||||
|
const resolved = allowlisted.filter((dep) => !missing.includes(dep));
|
||||||
|
const parts = [
|
||||||
|
`bundled extension '${extension.id}' root dependency mirror drift`,
|
||||||
|
`missing in root package: ${missing.length > 0 ? missing.join(", ") : "(none)"}`,
|
||||||
|
];
|
||||||
|
if (unexpected.length > 0) {
|
||||||
|
parts.push(`new gaps: ${unexpected.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (resolved.length > 0) {
|
||||||
|
parts.push(`remove stale allowlist entries: ${resolved.join(", ")}`);
|
||||||
|
}
|
||||||
|
errors.push(parts.join(" | "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJson }> {
|
||||||
|
const extensionsDir = resolve("extensions");
|
||||||
|
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
|
||||||
|
entry.isDirectory(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const packagePath = join(extensionsDir, entry.name, "package.json");
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: entry.name,
|
||||||
|
packageJson: JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBundledExtensionRootDependencyMirrors() {
|
||||||
|
const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson;
|
||||||
|
const errors = collectBundledExtensionRootDependencyGapErrors({
|
||||||
|
rootPackage,
|
||||||
|
extensions: collectBundledExtensions(),
|
||||||
|
});
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error("release-check: bundled extension root dependency mirror validation failed:");
|
||||||
|
for (const error of errors) {
|
||||||
|
console.error(` - ${error}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runPackDry(): PackResult[] {
|
function runPackDry(): PackResult[] {
|
||||||
const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
|
const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
@@ -321,6 +413,7 @@ function main() {
|
|||||||
checkPluginVersions();
|
checkPluginVersions();
|
||||||
checkAppcastSparkleVersions();
|
checkAppcastSparkleVersions();
|
||||||
checkPluginSdkExports();
|
checkPluginSdkExports();
|
||||||
|
checkBundledExtensionRootDependencyMirrors();
|
||||||
|
|
||||||
const results = runPackDry();
|
const results = runPackDry();
|
||||||
const files = results.flatMap((entry) => entry.files ?? []);
|
const files = results.flatMap((entry) => entry.files ?? []);
|
||||||
|
|||||||
@@ -246,6 +246,21 @@ describe("models-config", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces stale merged baseUrl when the provider api changes", async () => {
|
||||||
|
await withTempHome(async () => {
|
||||||
|
const parsed = await runCustomProviderMergeTest({
|
||||||
|
seedProvider: {
|
||||||
|
baseUrl: "https://agent.example/v1",
|
||||||
|
apiKey: "AGENT_KEY", // pragma: allowlist secret
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
|
||||||
|
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => {
|
it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
await writeAgentModelsJson({
|
await writeAgentModelsJson({
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ import {
|
|||||||
} from "./models-config.providers.js";
|
} from "./models-config.providers.js";
|
||||||
|
|
||||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||||
|
type ExistingProviderConfig = NonNullable<ModelsConfig["providers"]>[string] & {
|
||||||
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
api?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
|
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
|
||||||
const MODELS_JSON_WRITE_LOCKS = new Map<string, Promise<void>>();
|
const MODELS_JSON_WRITE_LOCKS = new Map<string, Promise<void>>();
|
||||||
const AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS = new Set(["openai-codex"]);
|
|
||||||
|
|
||||||
function isPositiveFiniteTokenLimit(value: unknown): value is number {
|
function isPositiveFiniteTokenLimit(value: unknown): value is number {
|
||||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||||
@@ -142,18 +146,10 @@ async function readJson(pathname: string): Promise<unknown> {
|
|||||||
async function resolveProvidersForModelsJson(params: {
|
async function resolveProvidersForModelsJson(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
}): Promise<{
|
}): Promise<Record<string, ProviderConfig>> {
|
||||||
providers: Record<string, ProviderConfig>;
|
|
||||||
authoritativeImplicitBaseUrlProviders: ReadonlySet<string>;
|
|
||||||
}> {
|
|
||||||
const { cfg, agentDir } = params;
|
const { cfg, agentDir } = params;
|
||||||
const explicitProviders = cfg.models?.providers ?? {};
|
const explicitProviders = cfg.models?.providers ?? {};
|
||||||
const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders });
|
const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders });
|
||||||
const authoritativeImplicitBaseUrlProviders = new Set<string>(
|
|
||||||
[...AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS].filter((key) =>
|
|
||||||
Boolean(implicitProviders?.[key]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const providers: Record<string, ProviderConfig> = mergeProviders({
|
const providers: Record<string, ProviderConfig> = mergeProviders({
|
||||||
implicit: implicitProviders,
|
implicit: implicitProviders,
|
||||||
explicit: explicitProviders,
|
explicit: explicitProviders,
|
||||||
@@ -171,52 +167,80 @@ async function resolveProvidersForModelsJson(params: {
|
|||||||
if (implicitCopilot && !providers["github-copilot"]) {
|
if (implicitCopilot && !providers["github-copilot"]) {
|
||||||
providers["github-copilot"] = implicitCopilot;
|
providers["github-copilot"] = implicitCopilot;
|
||||||
}
|
}
|
||||||
return { providers, authoritativeImplicitBaseUrlProviders };
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProviderApi(entry: { api?: unknown } | undefined): string | undefined {
|
||||||
|
if (typeof entry?.api !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const api = entry.api.trim();
|
||||||
|
return api || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPreserveExistingApiKey(params: {
|
||||||
|
providerKey: string;
|
||||||
|
existing: ExistingProviderConfig;
|
||||||
|
secretRefManagedProviders: ReadonlySet<string>;
|
||||||
|
}): boolean {
|
||||||
|
const { providerKey, existing, secretRefManagedProviders } = params;
|
||||||
|
return (
|
||||||
|
!secretRefManagedProviders.has(providerKey) &&
|
||||||
|
typeof existing.apiKey === "string" &&
|
||||||
|
existing.apiKey.length > 0 &&
|
||||||
|
!isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPreserveExistingBaseUrl(params: {
|
||||||
|
providerKey: string;
|
||||||
|
existing: ExistingProviderConfig;
|
||||||
|
nextEntry: ProviderConfig;
|
||||||
|
explicitBaseUrlProviders: ReadonlySet<string>;
|
||||||
|
}): boolean {
|
||||||
|
const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params;
|
||||||
|
if (
|
||||||
|
explicitBaseUrlProviders.has(providerKey) ||
|
||||||
|
typeof existing.baseUrl !== "string" ||
|
||||||
|
existing.baseUrl.length === 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingApi = resolveProviderApi(existing);
|
||||||
|
const nextApi = resolveProviderApi(nextEntry);
|
||||||
|
return !existingApi || !nextApi || existingApi === nextApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeWithExistingProviderSecrets(params: {
|
function mergeWithExistingProviderSecrets(params: {
|
||||||
nextProviders: Record<string, ProviderConfig>;
|
nextProviders: Record<string, ProviderConfig>;
|
||||||
existingProviders: Record<string, NonNullable<ModelsConfig["providers"]>[string]>;
|
existingProviders: Record<string, ExistingProviderConfig>;
|
||||||
secretRefManagedProviders: ReadonlySet<string>;
|
secretRefManagedProviders: ReadonlySet<string>;
|
||||||
explicitBaseUrlProviders: ReadonlySet<string>;
|
explicitBaseUrlProviders: ReadonlySet<string>;
|
||||||
authoritativeImplicitBaseUrlProviders: ReadonlySet<string>;
|
|
||||||
}): Record<string, ProviderConfig> {
|
}): Record<string, ProviderConfig> {
|
||||||
const {
|
const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } =
|
||||||
nextProviders,
|
params;
|
||||||
existingProviders,
|
|
||||||
secretRefManagedProviders,
|
|
||||||
explicitBaseUrlProviders,
|
|
||||||
authoritativeImplicitBaseUrlProviders,
|
|
||||||
} = params;
|
|
||||||
const mergedProviders: Record<string, ProviderConfig> = {};
|
const mergedProviders: Record<string, ProviderConfig> = {};
|
||||||
for (const [key, entry] of Object.entries(existingProviders)) {
|
for (const [key, entry] of Object.entries(existingProviders)) {
|
||||||
mergedProviders[key] = entry;
|
mergedProviders[key] = entry;
|
||||||
}
|
}
|
||||||
for (const [key, newEntry] of Object.entries(nextProviders)) {
|
for (const [key, newEntry] of Object.entries(nextProviders)) {
|
||||||
const existing = existingProviders[key] as
|
const existing = existingProviders[key];
|
||||||
| (NonNullable<ModelsConfig["providers"]>[string] & {
|
|
||||||
apiKey?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
})
|
|
||||||
| undefined;
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
mergedProviders[key] = newEntry;
|
mergedProviders[key] = newEntry;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const preserved: Record<string, unknown> = {};
|
const preserved: Record<string, unknown> = {};
|
||||||
if (
|
if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) {
|
||||||
!secretRefManagedProviders.has(key) &&
|
|
||||||
typeof existing.apiKey === "string" &&
|
|
||||||
existing.apiKey &&
|
|
||||||
!isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false })
|
|
||||||
) {
|
|
||||||
preserved.apiKey = existing.apiKey;
|
preserved.apiKey = existing.apiKey;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!authoritativeImplicitBaseUrlProviders.has(key) &&
|
shouldPreserveExistingBaseUrl({
|
||||||
!explicitBaseUrlProviders.has(key) &&
|
providerKey: key,
|
||||||
typeof existing.baseUrl === "string" &&
|
existing,
|
||||||
existing.baseUrl
|
nextEntry: newEntry,
|
||||||
|
explicitBaseUrlProviders,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
preserved.baseUrl = existing.baseUrl;
|
preserved.baseUrl = existing.baseUrl;
|
||||||
}
|
}
|
||||||
@@ -231,7 +255,6 @@ async function resolveProvidersForMode(params: {
|
|||||||
providers: Record<string, ProviderConfig>;
|
providers: Record<string, ProviderConfig>;
|
||||||
secretRefManagedProviders: ReadonlySet<string>;
|
secretRefManagedProviders: ReadonlySet<string>;
|
||||||
explicitBaseUrlProviders: ReadonlySet<string>;
|
explicitBaseUrlProviders: ReadonlySet<string>;
|
||||||
authoritativeImplicitBaseUrlProviders: ReadonlySet<string>;
|
|
||||||
}): Promise<Record<string, ProviderConfig>> {
|
}): Promise<Record<string, ProviderConfig>> {
|
||||||
if (params.mode !== "merge") {
|
if (params.mode !== "merge") {
|
||||||
return params.providers;
|
return params.providers;
|
||||||
@@ -246,10 +269,9 @@ async function resolveProvidersForMode(params: {
|
|||||||
>;
|
>;
|
||||||
return mergeWithExistingProviderSecrets({
|
return mergeWithExistingProviderSecrets({
|
||||||
nextProviders: params.providers,
|
nextProviders: params.providers,
|
||||||
existingProviders,
|
existingProviders: existingProviders as Record<string, ExistingProviderConfig>,
|
||||||
secretRefManagedProviders: params.secretRefManagedProviders,
|
secretRefManagedProviders: params.secretRefManagedProviders,
|
||||||
explicitBaseUrlProviders: params.explicitBaseUrlProviders,
|
explicitBaseUrlProviders: params.explicitBaseUrlProviders,
|
||||||
authoritativeImplicitBaseUrlProviders: params.authoritativeImplicitBaseUrlProviders,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +338,7 @@ export async function ensureOpenClawModelsJson(
|
|||||||
// through the full loadConfig() pipeline which applies these.
|
// through the full loadConfig() pipeline which applies these.
|
||||||
applyConfigEnvVars(cfg);
|
applyConfigEnvVars(cfg);
|
||||||
|
|
||||||
const { providers, authoritativeImplicitBaseUrlProviders } =
|
const providers = await resolveProvidersForModelsJson({ cfg, agentDir });
|
||||||
await resolveProvidersForModelsJson({ cfg, agentDir });
|
|
||||||
|
|
||||||
if (Object.keys(providers).length === 0) {
|
if (Object.keys(providers).length === 0) {
|
||||||
return { agentDir, wrote: false };
|
return { agentDir, wrote: false };
|
||||||
@@ -348,7 +369,6 @@ export async function ensureOpenClawModelsJson(
|
|||||||
providers: normalizedProviders,
|
providers: normalizedProviders,
|
||||||
secretRefManagedProviders,
|
secretRefManagedProviders,
|
||||||
explicitBaseUrlProviders,
|
explicitBaseUrlProviders,
|
||||||
authoritativeImplicitBaseUrlProviders,
|
|
||||||
});
|
});
|
||||||
const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
|
const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
|
||||||
const existingRaw = await readRawFile(targetPath);
|
const existingRaw = await readRawFile(targetPath);
|
||||||
|
|||||||
@@ -803,7 +803,11 @@ describe("applyExtraParamsToAgent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes anthropic tool_choice modes for kimi-coding endpoints", () => {
|
it.each([
|
||||||
|
{ input: { type: "auto" }, expected: "auto" },
|
||||||
|
{ input: { type: "none" }, expected: "none" },
|
||||||
|
{ input: { type: "required" }, expected: "required" },
|
||||||
|
])("normalizes anthropic tool_choice %j for kimi-coding endpoints", ({ input, expected }) => {
|
||||||
const payloads: Record<string, unknown>[] = [];
|
const payloads: Record<string, unknown>[] = [];
|
||||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
@@ -814,7 +818,7 @@ describe("applyExtraParamsToAgent", () => {
|
|||||||
input_schema: { type: "object", properties: {} },
|
input_schema: { type: "object", properties: {} },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tool_choice: { type: "auto" },
|
tool_choice: input,
|
||||||
};
|
};
|
||||||
options?.onPayload?.(payload);
|
options?.onPayload?.(payload);
|
||||||
payloads.push(payload);
|
payloads.push(payload);
|
||||||
@@ -834,7 +838,7 @@ describe("applyExtraParamsToAgent", () => {
|
|||||||
void agent.streamFn?.(model, context, {});
|
void agent.streamFn?.(model, context, {});
|
||||||
|
|
||||||
expect(payloads).toHaveLength(1);
|
expect(payloads).toHaveLength(1);
|
||||||
expect(payloads[0]?.tool_choice).toBe("auto");
|
expect(payloads[0]?.tool_choice).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not rewrite anthropic tool schema for non-kimi endpoints", () => {
|
it("does not rewrite anthropic tool schema for non-kimi endpoints", () => {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
|
|||||||
import { streamSimple } from "@mariozechner/pi-ai";
|
import { streamSimple } from "@mariozechner/pi-ai";
|
||||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import {
|
||||||
|
usesOpenAiFunctionAnthropicToolSchema,
|
||||||
|
usesOpenAiStringModeAnthropicToolChoice,
|
||||||
|
} from "../provider-capabilities.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
|
|
||||||
const OPENROUTER_APP_HEADERS: Record<string, string> = {
|
const OPENROUTER_APP_HEADERS: Record<string, string> = {
|
||||||
@@ -786,7 +790,7 @@ function createMoonshotThinkingWrapper(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKimiCodingAnthropicEndpoint(model: {
|
function requiresAnthropicToolPayloadCompatibility(model: {
|
||||||
api?: unknown;
|
api?: unknown;
|
||||||
provider?: unknown;
|
provider?: unknown;
|
||||||
baseUrl?: unknown;
|
baseUrl?: unknown;
|
||||||
@@ -795,7 +799,7 @@ function isKimiCodingAnthropicEndpoint(model: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof model.provider === "string" && model.provider.trim().toLowerCase() === "kimi-coding") {
|
if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,7 +818,9 @@ function isKimiCodingAnthropicEndpoint(model: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKimiCodingToolDefinition(tool: unknown): Record<string, unknown> | undefined {
|
function normalizeOpenAiFunctionAnthropicToolDefinition(
|
||||||
|
tool: unknown,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -852,7 +858,7 @@ function normalizeKimiCodingToolDefinition(tool: unknown): Record<string, unknow
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKimiCodingToolChoice(toolChoice: unknown): unknown {
|
function normalizeOpenAiStringModeAnthropicToolChoice(toolChoice: unknown): unknown {
|
||||||
if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) {
|
if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) {
|
||||||
return toolChoice;
|
return toolChoice;
|
||||||
}
|
}
|
||||||
@@ -881,24 +887,43 @@ function normalizeKimiCodingToolChoice(toolChoice: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kimi Coding's anthropic-messages endpoint expects OpenAI-style tool payloads
|
* Some anthropic-messages providers accept Anthropic framing but still expect
|
||||||
* (`tools[].function`) even when messages use Anthropic request framing.
|
* OpenAI-style tool payloads (`tools[].function`, string tool_choice modes).
|
||||||
*/
|
*/
|
||||||
function createKimiCodingAnthropicToolSchemaWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
function createAnthropicToolPayloadCompatibilityWrapper(
|
||||||
|
baseStreamFn: StreamFn | undefined,
|
||||||
|
): StreamFn {
|
||||||
const underlying = baseStreamFn ?? streamSimple;
|
const underlying = baseStreamFn ?? streamSimple;
|
||||||
return (model, context, options) => {
|
return (model, context, options) => {
|
||||||
const originalOnPayload = options?.onPayload;
|
const originalOnPayload = options?.onPayload;
|
||||||
return underlying(model, context, {
|
return underlying(model, context, {
|
||||||
...options,
|
...options,
|
||||||
onPayload: (payload) => {
|
onPayload: (payload) => {
|
||||||
if (payload && typeof payload === "object" && isKimiCodingAnthropicEndpoint(model)) {
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
requiresAnthropicToolPayloadCompatibility(model)
|
||||||
|
) {
|
||||||
const payloadObj = payload as Record<string, unknown>;
|
const payloadObj = payload as Record<string, unknown>;
|
||||||
if (Array.isArray(payloadObj.tools)) {
|
if (
|
||||||
|
Array.isArray(payloadObj.tools) &&
|
||||||
|
usesOpenAiFunctionAnthropicToolSchema(
|
||||||
|
typeof model.provider === "string" ? model.provider : undefined,
|
||||||
|
)
|
||||||
|
) {
|
||||||
payloadObj.tools = payloadObj.tools
|
payloadObj.tools = payloadObj.tools
|
||||||
.map((tool) => normalizeKimiCodingToolDefinition(tool))
|
.map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool))
|
||||||
.filter((tool): tool is Record<string, unknown> => !!tool);
|
.filter((tool): tool is Record<string, unknown> => !!tool);
|
||||||
}
|
}
|
||||||
payloadObj.tool_choice = normalizeKimiCodingToolChoice(payloadObj.tool_choice);
|
if (
|
||||||
|
usesOpenAiStringModeAnthropicToolChoice(
|
||||||
|
typeof model.provider === "string" ? model.provider : undefined,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice(
|
||||||
|
payloadObj.tool_choice,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
originalOnPayload?.(payload);
|
originalOnPayload?.(payload);
|
||||||
},
|
},
|
||||||
@@ -1245,7 +1270,7 @@ export function applyExtraParamsToAgent(
|
|||||||
agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, moonshotThinkingType);
|
agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, moonshotThinkingType);
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.streamFn = createKimiCodingAnthropicToolSchemaWrapper(agent.streamFn);
|
agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn);
|
||||||
|
|
||||||
if (provider === "openrouter") {
|
if (provider === "openrouter") {
|
||||||
log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`);
|
log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`);
|
||||||
|
|||||||
23
src/agents/provider-capabilities.test.ts
Normal file
23
src/agents/provider-capabilities.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveProviderCapabilities } from "./provider-capabilities.js";
|
||||||
|
|
||||||
|
describe("resolveProviderCapabilities", () => {
|
||||||
|
it("returns native anthropic defaults for ordinary providers", () => {
|
||||||
|
expect(resolveProviderCapabilities("anthropic")).toEqual({
|
||||||
|
anthropicToolSchemaMode: "native",
|
||||||
|
anthropicToolChoiceMode: "native",
|
||||||
|
preserveAnthropicThinkingSignatures: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes kimi aliases to the same capability set", () => {
|
||||||
|
expect(resolveProviderCapabilities("kimi-coding")).toEqual(
|
||||||
|
resolveProviderCapabilities("kimi-code"),
|
||||||
|
);
|
||||||
|
expect(resolveProviderCapabilities("kimi-code")).toEqual({
|
||||||
|
anthropicToolSchemaMode: "openai-functions",
|
||||||
|
anthropicToolChoiceMode: "openai-string-modes",
|
||||||
|
preserveAnthropicThinkingSignatures: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/agents/provider-capabilities.ts
Normal file
41
src/agents/provider-capabilities.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
|
|
||||||
|
export type ProviderCapabilities = {
|
||||||
|
anthropicToolSchemaMode: "native" | "openai-functions";
|
||||||
|
anthropicToolChoiceMode: "native" | "openai-string-modes";
|
||||||
|
preserveAnthropicThinkingSignatures: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
|
||||||
|
anthropicToolSchemaMode: "native",
|
||||||
|
anthropicToolChoiceMode: "native",
|
||||||
|
preserveAnthropicThinkingSignatures: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
|
||||||
|
"kimi-coding": {
|
||||||
|
anthropicToolSchemaMode: "openai-functions",
|
||||||
|
anthropicToolChoiceMode: "openai-string-modes",
|
||||||
|
preserveAnthropicThinkingSignatures: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveProviderCapabilities(provider?: string | null): ProviderCapabilities {
|
||||||
|
const normalized = normalizeProviderId(provider ?? "");
|
||||||
|
return {
|
||||||
|
...DEFAULT_PROVIDER_CAPABILITIES,
|
||||||
|
...PROVIDER_CAPABILITIES[normalized],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preservesAnthropicThinkingSignatures(provider?: string | null): boolean {
|
||||||
|
return resolveProviderCapabilities(provider).preserveAnthropicThinkingSignatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usesOpenAiFunctionAnthropicToolSchema(provider?: string | null): boolean {
|
||||||
|
return resolveProviderCapabilities(provider).anthropicToolSchemaMode === "openai-functions";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usesOpenAiStringModeAnthropicToolChoice(provider?: string | null): boolean {
|
||||||
|
return resolveProviderCapabilities(provider).anthropicToolChoiceMode === "openai-string-modes";
|
||||||
|
}
|
||||||
@@ -78,57 +78,58 @@ describe("resolveTranscriptPolicy", () => {
|
|||||||
expect(policy.sanitizeMode).toBe("full");
|
expect(policy.sanitizeMode).toBe("full");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves thinking signatures for Anthropic provider (#32526)", () => {
|
it.each([
|
||||||
const policy = resolveTranscriptPolicy({
|
{
|
||||||
|
title: "Anthropic provider",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
modelId: "claude-opus-4-5",
|
modelId: "claude-opus-4-5",
|
||||||
modelApi: "anthropic-messages",
|
modelApi: "anthropic-messages" as const,
|
||||||
});
|
preserveSignatures: true,
|
||||||
expect(policy.preserveSignatures).toBe(true);
|
},
|
||||||
});
|
{
|
||||||
|
title: "Bedrock Anthropic",
|
||||||
it("preserves thinking signatures for Bedrock Anthropic (#32526)", () => {
|
|
||||||
const policy = resolveTranscriptPolicy({
|
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
modelId: "us.anthropic.claude-opus-4-6-v1",
|
modelId: "us.anthropic.claude-opus-4-6-v1",
|
||||||
modelApi: "bedrock-converse-stream",
|
modelApi: "bedrock-converse-stream" as const,
|
||||||
});
|
preserveSignatures: true,
|
||||||
expect(policy.preserveSignatures).toBe(true);
|
},
|
||||||
});
|
{
|
||||||
|
title: "Google provider",
|
||||||
it("does not preserve signatures for Google provider (#32526)", () => {
|
|
||||||
const policy = resolveTranscriptPolicy({
|
|
||||||
provider: "google",
|
provider: "google",
|
||||||
modelId: "gemini-2.0-flash",
|
modelId: "gemini-2.0-flash",
|
||||||
modelApi: "google-generative-ai",
|
modelApi: "google-generative-ai" as const,
|
||||||
});
|
preserveSignatures: false,
|
||||||
expect(policy.preserveSignatures).toBe(false);
|
},
|
||||||
});
|
{
|
||||||
|
title: "OpenAI provider",
|
||||||
it("does not preserve signatures for OpenAI provider (#32526)", () => {
|
|
||||||
const policy = resolveTranscriptPolicy({
|
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
modelId: "gpt-4o",
|
modelId: "gpt-4o",
|
||||||
modelApi: "openai",
|
modelApi: "openai" as const,
|
||||||
});
|
preserveSignatures: false,
|
||||||
expect(policy.preserveSignatures).toBe(false);
|
},
|
||||||
});
|
{
|
||||||
|
title: "Mistral provider",
|
||||||
it("does not preserve signatures for Mistral provider (#32526)", () => {
|
|
||||||
const policy = resolveTranscriptPolicy({
|
|
||||||
provider: "mistral",
|
provider: "mistral",
|
||||||
modelId: "mistral-large-latest",
|
modelId: "mistral-large-latest",
|
||||||
});
|
preserveSignatures: false,
|
||||||
expect(policy.preserveSignatures).toBe(false);
|
},
|
||||||
});
|
{
|
||||||
|
title: "kimi-coding provider",
|
||||||
it("does not preserve signatures for kimi-coding provider (#39798)", () => {
|
|
||||||
const policy = resolveTranscriptPolicy({
|
|
||||||
provider: "kimi-coding",
|
provider: "kimi-coding",
|
||||||
modelId: "k2p5",
|
modelId: "k2p5",
|
||||||
modelApi: "anthropic-messages",
|
modelApi: "anthropic-messages" as const,
|
||||||
});
|
preserveSignatures: false,
|
||||||
expect(policy.preserveSignatures).toBe(false);
|
},
|
||||||
|
{
|
||||||
|
title: "kimi-code alias",
|
||||||
|
provider: "kimi-code",
|
||||||
|
modelId: "k2p5",
|
||||||
|
modelApi: "anthropic-messages" as const,
|
||||||
|
preserveSignatures: false,
|
||||||
|
},
|
||||||
|
])("sets preserveSignatures for $title (#32526, #39798)", ({ preserveSignatures, ...input }) => {
|
||||||
|
const policy = resolveTranscriptPolicy(input);
|
||||||
|
expect(policy.preserveSignatures).toBe(preserveSignatures);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables turn-ordering and assistant-merge for strict OpenAI-compatible providers (#38962)", () => {
|
it("enables turn-ordering and assistant-merge for strict OpenAI-compatible providers (#38962)", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { normalizeProviderId } from "./model-selection.js";
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
import { isGoogleModelApi } from "./pi-embedded-helpers/google.js";
|
import { isGoogleModelApi } from "./pi-embedded-helpers/google.js";
|
||||||
|
import { preservesAnthropicThinkingSignatures } from "./provider-capabilities.js";
|
||||||
import type { ToolCallIdMode } from "./tool-call-id.js";
|
import type { ToolCallIdMode } from "./tool-call-id.js";
|
||||||
|
|
||||||
export type TranscriptSanitizeMode = "full" | "images-only";
|
export type TranscriptSanitizeMode = "full" | "images-only";
|
||||||
@@ -39,8 +40,6 @@ const OPENAI_MODEL_APIS = new Set([
|
|||||||
]);
|
]);
|
||||||
const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]);
|
const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]);
|
||||||
const OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS = new Set(["openrouter", "opencode"]);
|
const OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS = new Set(["openrouter", "opencode"]);
|
||||||
// Providers that use anthropic-messages API but cannot handle re-sent thinkingSignature blobs (#39798)
|
|
||||||
const ANTHROPIC_API_SIGNATURE_EXCLUDED_PROVIDERS = new Set(["kimi-coding"]);
|
|
||||||
|
|
||||||
function isOpenAiApi(modelApi?: string | null): boolean {
|
function isOpenAiApi(modelApi?: string | null): boolean {
|
||||||
if (!modelApi) {
|
if (!modelApi) {
|
||||||
@@ -125,7 +124,7 @@ export function resolveTranscriptPolicy(params: {
|
|||||||
(!isOpenAi && sanitizeToolCallIds) || requiresOpenAiCompatibleToolIdSanitization,
|
(!isOpenAi && sanitizeToolCallIds) || requiresOpenAiCompatibleToolIdSanitization,
|
||||||
toolCallIdMode,
|
toolCallIdMode,
|
||||||
repairToolUseResultPairing,
|
repairToolUseResultPairing,
|
||||||
preserveSignatures: isAnthropic && !ANTHROPIC_API_SIGNATURE_EXCLUDED_PROVIDERS.has(provider),
|
preserveSignatures: isAnthropic && preservesAnthropicThinkingSignatures(provider),
|
||||||
sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures,
|
sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures,
|
||||||
sanitizeThinkingSignatures: false,
|
sanitizeThinkingSignatures: false,
|
||||||
dropThinkingBlocks,
|
dropThinkingBlocks,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { collectAppcastSparkleVersionErrors } from "../scripts/release-check.ts";
|
import {
|
||||||
|
collectAppcastSparkleVersionErrors,
|
||||||
|
collectBundledExtensionRootDependencyGapErrors,
|
||||||
|
} from "../scripts/release-check.ts";
|
||||||
|
|
||||||
function makeItem(shortVersion: string, sparkleVersion: string): string {
|
function makeItem(shortVersion: string, sparkleVersion: string): string {
|
||||||
return `<item><title>${shortVersion}</title><sparkle:shortVersionString>${shortVersion}</sparkle:shortVersionString><sparkle:version>${sparkleVersion}</sparkle:version></item>`;
|
return `<item><title>${shortVersion}</title><sparkle:shortVersionString>${shortVersion}</sparkle:shortVersionString><sparkle:version>${sparkleVersion}</sparkle:version></item>`;
|
||||||
@@ -26,3 +29,69 @@ describe("collectAppcastSparkleVersionErrors", () => {
|
|||||||
expect(collectAppcastSparkleVersionErrors(xml)).toEqual([]);
|
expect(collectAppcastSparkleVersionErrors(xml)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("collectBundledExtensionRootDependencyGapErrors", () => {
|
||||||
|
it("allows known gaps but still flags unallowlisted ones", () => {
|
||||||
|
expect(
|
||||||
|
collectBundledExtensionRootDependencyGapErrors({
|
||||||
|
rootPackage: { dependencies: {} },
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
id: "googlechat",
|
||||||
|
packageJson: {
|
||||||
|
dependencies: { "google-auth-library": "^1.0.0" },
|
||||||
|
openclaw: { install: { npmSpec: "@openclaw/googlechat" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "feishu",
|
||||||
|
packageJson: {
|
||||||
|
dependencies: { "@larksuiteoapi/node-sdk": "^1.59.0" },
|
||||||
|
openclaw: { install: { npmSpec: "@openclaw/feishu" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
"bundled extension 'feishu' root dependency mirror drift | missing in root package: @larksuiteoapi/node-sdk | new gaps: @larksuiteoapi/node-sdk",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags newly introduced bundled extension dependency gaps", () => {
|
||||||
|
expect(
|
||||||
|
collectBundledExtensionRootDependencyGapErrors({
|
||||||
|
rootPackage: { dependencies: {} },
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
id: "googlechat",
|
||||||
|
packageJson: {
|
||||||
|
dependencies: { "google-auth-library": "^1.0.0", undici: "^7.0.0" },
|
||||||
|
openclaw: { install: { npmSpec: "@openclaw/googlechat" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
"bundled extension 'googlechat' root dependency mirror drift | missing in root package: google-auth-library, undici | new gaps: undici",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags stale allowlist entries once a gap is resolved", () => {
|
||||||
|
expect(
|
||||||
|
collectBundledExtensionRootDependencyGapErrors({
|
||||||
|
rootPackage: { dependencies: { "google-auth-library": "^1.0.0" } },
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
id: "googlechat",
|
||||||
|
packageJson: {
|
||||||
|
dependencies: { "google-auth-library": "^1.0.0" },
|
||||||
|
openclaw: { install: { npmSpec: "@openclaw/googlechat" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
"bundled extension 'googlechat' root dependency mirror drift | missing in root package: (none) | remove stale allowlist entries: google-auth-library",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user