mirror of https://github.com/openclaw/openclaw.git
fix(xai): restore config-backed auth discovery
This commit is contained in:
parent
2a950157b1
commit
1e424990a2
|
|
@ -39,6 +39,13 @@ describe("xai stream wrappers", () => {
|
|||
}),
|
||||
).toBe("grok-3-fast");
|
||||
expect(captureWrappedModelId({ modelId: "grok-4", fastMode: true })).toBe("grok-4-fast");
|
||||
expect(
|
||||
captureWrappedModelId({
|
||||
modelId: "grok-3",
|
||||
fastMode: true,
|
||||
api: "openai-responses",
|
||||
}),
|
||||
).toBe("grok-3-fast");
|
||||
});
|
||||
|
||||
it("leaves unsupported or disabled models unchanged", () => {
|
||||
|
|
@ -83,4 +90,31 @@ describe("xai stream wrappers", () => {
|
|||
expect(payload).not.toHaveProperty("reasoning_effort");
|
||||
expect(payload.tools[0]?.function).not.toHaveProperty("strict");
|
||||
});
|
||||
|
||||
it("strips unsupported reasoning controls from xai payloads", () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
reasoning: { effort: "high" },
|
||||
reasoningEffort: "high",
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
options?.onPayload?.(payload, {} as Model<"openai-responses">);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-responses",
|
||||
provider: "xai",
|
||||
id: "grok-4-fast",
|
||||
} as Model<"openai-responses">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(payload).not.toHaveProperty("reasoning");
|
||||
expect(payload).not.toHaveProperty("reasoningEffort");
|
||||
expect(payload).not.toHaveProperty("reasoning_effort");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,6 +111,20 @@ afterEach(() => {
|
|||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
async function withoutEnv<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env[key];
|
||||
delete process.env[key];
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCustomProviderConfig(
|
||||
baseUrl: string,
|
||||
modelId = "llama3",
|
||||
|
|
@ -386,23 +400,25 @@ describe("resolveUsableCustomProviderApiKey", () => {
|
|||
|
||||
describe("resolveApiKeyForProvider", () => {
|
||||
it("reuses the xai plugin web search key without models.providers.xai", async () => {
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "xai",
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret
|
||||
const resolved = await withoutEnv("XAI_API_KEY", () =>
|
||||
resolveApiKeyForProvider({
|
||||
provider: "xai",
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
apiKey: "xai-plugin-fallback-key",
|
||||
|
|
@ -440,11 +456,13 @@ describe("resolveApiKeyForProvider", () => {
|
|||
};
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "xai",
|
||||
cfg: sourceConfig,
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
const resolved = await withoutEnv("XAI_API_KEY", () =>
|
||||
resolveApiKeyForProvider({
|
||||
provider: "xai",
|
||||
cfg: sourceConfig,
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
apiKey: "xai-runtime-key",
|
||||
|
|
@ -455,24 +473,26 @@ describe("resolveApiKeyForProvider", () => {
|
|||
|
||||
it("does not reuse xai fallback auth when the xai plugin is disabled", async () => {
|
||||
await expect(
|
||||
resolveApiKeyForProvider({
|
||||
provider: "xai",
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
enabled: false,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret
|
||||
withoutEnv("XAI_API_KEY", () =>
|
||||
resolveApiKeyForProvider({
|
||||
provider: "xai",
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
enabled: false,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('No API key found for provider "xai"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types
|
|||
import { coerceSecretRef } from "../config/types.secrets.js";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { formatApiKeyPreview } from "../plugins/provider-auth-input.js";
|
||||
import {
|
||||
buildProviderMissingAuthMessageWithPlugin,
|
||||
resolveProviderSyntheticAuthWithPlugin,
|
||||
|
|
@ -34,6 +33,7 @@ import {
|
|||
type ResolvedProviderAuth,
|
||||
} from "./model-auth-runtime-shared.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "./xai-auth-trace.js";
|
||||
|
||||
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||
export { requireApiKey, resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js";
|
||||
|
|
@ -41,21 +41,6 @@ export type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
|
|||
|
||||
const log = createSubsystemLogger("model-auth");
|
||||
|
||||
function shouldTraceProviderAuth(provider: string): boolean {
|
||||
return normalizeProviderId(provider) === "xai";
|
||||
}
|
||||
|
||||
function summarizeProviderAuthKey(apiKey: string | undefined): string {
|
||||
const trimmed = apiKey?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return "missing";
|
||||
}
|
||||
if (isNonSecretApiKeyMarker(trimmed)) {
|
||||
return `marker:${trimmed}`;
|
||||
}
|
||||
return formatApiKeyPreview(trimmed);
|
||||
}
|
||||
|
||||
function logProviderAuthDecision(params: {
|
||||
provider: string;
|
||||
stage: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { formatApiKeyPreview } from "../plugins/provider-auth-input.js";
|
||||
import { resolveProviderWebSearchPluginConfig } from "../plugin-sdk/provider-web-search.js";
|
||||
import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js";
|
||||
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
|
||||
import { listProfilesForProvider } from "./auth-profiles/profiles.js";
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
resolveNonEnvSecretRefHeaderValueMarker,
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js";
|
||||
import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "./xai-auth-trace.js";
|
||||
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
|
|
@ -49,21 +50,6 @@ export type ProviderAuthResolver = (
|
|||
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
||||
const log = createSubsystemLogger("agents/model-providers");
|
||||
|
||||
function shouldTraceProviderAuth(provider: string): boolean {
|
||||
return provider.trim().toLowerCase() === "xai";
|
||||
}
|
||||
|
||||
function summarizeProviderAuthKey(apiKey: string | undefined): string {
|
||||
const trimmed = apiKey?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return "missing";
|
||||
}
|
||||
if (isNonSecretApiKeyMarker(trimmed)) {
|
||||
return `marker:${trimmed}`;
|
||||
}
|
||||
return formatApiKeyPreview(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeApiKeyConfig(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed);
|
||||
|
|
@ -438,15 +424,19 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op
|
|||
source: "config";
|
||||
}
|
||||
| undefined {
|
||||
const synthetic = resolveProviderSyntheticAuthWithPlugin({
|
||||
provider: params.provider,
|
||||
config: params.config,
|
||||
context: {
|
||||
config: params.config,
|
||||
// Providers own any provider-specific fallback auth logic via
|
||||
// resolveSyntheticAuth(...). Discovery/bootstrap callers may consume
|
||||
// non-secret markers from source config, but must never persist plaintext.
|
||||
const synthetic =
|
||||
resolveProviderSyntheticAuthWithPlugin({
|
||||
provider: params.provider,
|
||||
providerConfig: params.config?.models?.providers?.[params.provider],
|
||||
},
|
||||
});
|
||||
config: params.config,
|
||||
context: {
|
||||
config: params.config,
|
||||
provider: params.provider,
|
||||
providerConfig: params.config?.models?.providers?.[params.provider],
|
||||
},
|
||||
}) ?? resolveXaiConfigFallbackAuth(params);
|
||||
const apiKey = synthetic?.apiKey?.trim();
|
||||
if (!apiKey) {
|
||||
if (shouldTraceProviderAuth(params.provider)) {
|
||||
|
|
@ -473,3 +463,68 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op
|
|||
source: "config",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveXaiConfigFallbackAuth(params: { provider: string; config?: OpenClawConfig }):
|
||||
| {
|
||||
apiKey: string;
|
||||
source: string;
|
||||
mode: "api-key";
|
||||
}
|
||||
| undefined {
|
||||
if (params.provider.trim().toLowerCase() !== "xai") {
|
||||
return undefined;
|
||||
}
|
||||
const xaiPluginEntry = params.config?.plugins?.entries?.xai;
|
||||
if (xaiPluginEntry?.enabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
const pluginApiKey = normalizeOptionalSecretInput(
|
||||
resolveProviderWebSearchPluginConfig(
|
||||
params.config as Record<string, unknown> | undefined,
|
||||
"xai",
|
||||
)?.apiKey,
|
||||
);
|
||||
if (pluginApiKey) {
|
||||
return {
|
||||
apiKey: pluginApiKey,
|
||||
source: "plugins.entries.xai.config.webSearch.apiKey",
|
||||
mode: "api-key",
|
||||
};
|
||||
}
|
||||
const pluginApiKeyRef = coerceSecretRef(
|
||||
resolveProviderWebSearchPluginConfig(
|
||||
params.config as Record<string, unknown> | undefined,
|
||||
"xai",
|
||||
)?.apiKey,
|
||||
);
|
||||
if (pluginApiKeyRef) {
|
||||
return {
|
||||
apiKey:
|
||||
pluginApiKeyRef.source === "env"
|
||||
? pluginApiKeyRef.id.trim()
|
||||
: resolveNonEnvSecretRefApiKeyMarker(pluginApiKeyRef.source),
|
||||
source: "plugins.entries.xai.config.webSearch.apiKey",
|
||||
mode: "api-key",
|
||||
};
|
||||
}
|
||||
const grokApiKey = normalizeOptionalSecretInput(params.config?.tools?.web?.search?.grok?.apiKey);
|
||||
if (grokApiKey) {
|
||||
return {
|
||||
apiKey: grokApiKey,
|
||||
source: "tools.web.search.grok.apiKey",
|
||||
mode: "api-key",
|
||||
};
|
||||
}
|
||||
const grokApiKeyRef = coerceSecretRef(params.config?.tools?.web?.search?.grok?.apiKey);
|
||||
if (!grokApiKeyRef) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
apiKey:
|
||||
grokApiKeyRef.source === "env"
|
||||
? grokApiKeyRef.id.trim()
|
||||
: resolveNonEnvSecretRefApiKeyMarker(grokApiKeyRef.source),
|
||||
source: "tools.web.search.grok.apiKey",
|
||||
mode: "api-key",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
|||
import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js";
|
||||
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
|
||||
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
|
||||
import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "../../xai-auth-trace.js";
|
||||
import { isRunnerAbortError } from "../abort.js";
|
||||
import { isCacheTtlEligibleProvider } from "../cache-ttl.js";
|
||||
import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js";
|
||||
|
|
@ -214,18 +215,6 @@ export {
|
|||
|
||||
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
|
||||
|
||||
function shouldTraceProviderAuth(provider: string): boolean {
|
||||
return provider.trim().toLowerCase() === "xai";
|
||||
}
|
||||
|
||||
function summarizeProviderAuthKey(apiKey: string | undefined): string {
|
||||
const trimmed = apiKey?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return "missing";
|
||||
}
|
||||
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function resolveEmbeddedAgentStreamFn(params: {
|
||||
currentStreamFn: StreamFn | undefined;
|
||||
providerStreamFn?: StreamFn;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
|
||||
import { formatApiKeyPreview } from "../../../plugins/provider-auth-input.js";
|
||||
import { prepareProviderRuntimeAuth } from "../../../plugins/provider-runtime.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
|
|
@ -16,6 +15,7 @@ import {
|
|||
type FailoverReason,
|
||||
} from "../../pi-embedded-helpers.js";
|
||||
import { clampRuntimeAuthRefreshDelayMs } from "../../runtime-auth-refresh.js";
|
||||
import { shouldTraceProviderAuth, summarizeProviderAuthKey } from "../../xai-auth-trace.js";
|
||||
import { describeUnknownError } from "../utils.js";
|
||||
import {
|
||||
RUNTIME_AUTH_REFRESH_MARGIN_MS,
|
||||
|
|
@ -37,15 +37,6 @@ type LogLike = {
|
|||
warn(message: string): void;
|
||||
};
|
||||
|
||||
function shouldTraceProviderAuth(provider: string): boolean {
|
||||
return provider.trim().toLowerCase() === "xai";
|
||||
}
|
||||
|
||||
function summarizeProviderAuthKey(apiKey: string | undefined): string {
|
||||
const trimmed = apiKey?.trim() ?? "";
|
||||
return trimmed ? formatApiKeyPreview(trimmed) : "missing";
|
||||
}
|
||||
|
||||
export function createEmbeddedRunAuthController(params: {
|
||||
config: RunEmbeddedPiAgentParams["config"];
|
||||
agentDir: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { isNonSecretApiKeyMarker } from "./model-auth-markers.js";
|
||||
|
||||
const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 };
|
||||
|
||||
function formatApiKeyPreview(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "…";
|
||||
}
|
||||
const { head, tail } = DEFAULT_KEY_PREVIEW;
|
||||
if (trimmed.length <= head + tail) {
|
||||
const shortHead = Math.min(2, trimmed.length);
|
||||
const shortTail = Math.min(2, trimmed.length - shortHead);
|
||||
if (shortTail <= 0) {
|
||||
return `${trimmed.slice(0, shortHead)}…`;
|
||||
}
|
||||
return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`;
|
||||
}
|
||||
return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`;
|
||||
}
|
||||
|
||||
export function shouldTraceProviderAuth(provider: string): boolean {
|
||||
return provider.trim().toLowerCase() === "xai";
|
||||
}
|
||||
|
||||
export function summarizeProviderAuthKey(apiKey: string | undefined): string {
|
||||
const trimmed = apiKey?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return "missing";
|
||||
}
|
||||
if (isNonSecretApiKeyMarker(trimmed)) {
|
||||
return `marker:${trimmed}`;
|
||||
}
|
||||
return formatApiKeyPreview(trimmed);
|
||||
}
|
||||
|
|
@ -3,6 +3,57 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyLitellmProviderConfig } from "../../extensions/litellm/onboard.js";
|
||||
import {
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxApiProviderConfig,
|
||||
} from "../../extensions/minimax/onboard.js";
|
||||
import { buildMistralModelDefinition as buildBundledMistralModelDefinition } from "../../extensions/mistral/model-definitions.js";
|
||||
import {
|
||||
applyMistralConfig,
|
||||
applyMistralProviderConfig,
|
||||
MISTRAL_DEFAULT_MODEL_REF,
|
||||
} from "../../extensions/mistral/onboard.js";
|
||||
import {
|
||||
applyOpencodeGoConfig,
|
||||
applyOpencodeGoProviderConfig,
|
||||
} from "../../extensions/opencode-go/onboard.js";
|
||||
import {
|
||||
applyOpencodeZenConfig,
|
||||
applyOpencodeZenProviderConfig,
|
||||
} from "../../extensions/opencode/onboard.js";
|
||||
import {
|
||||
applyOpenrouterConfig,
|
||||
applyOpenrouterProviderConfig,
|
||||
OPENROUTER_DEFAULT_MODEL_REF,
|
||||
} from "../../extensions/openrouter/onboard.js";
|
||||
import {
|
||||
applySyntheticConfig,
|
||||
applySyntheticProviderConfig,
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
} from "../../extensions/synthetic/onboard.js";
|
||||
import {
|
||||
applyXaiConfig,
|
||||
applyXaiProviderConfig,
|
||||
XAI_DEFAULT_MODEL_REF,
|
||||
} from "../../extensions/xai/onboard.js";
|
||||
import { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js";
|
||||
import {
|
||||
ZAI_CODING_CN_BASE_URL,
|
||||
ZAI_GLOBAL_BASE_URL,
|
||||
} from "../../extensions/zai/model-definitions.js";
|
||||
import { applyZaiConfig, applyZaiProviderConfig } from "../../extensions/zai/onboard.js";
|
||||
import {
|
||||
createConfigWithFallbacks,
|
||||
createLegacyProviderConfig,
|
||||
EXPECTED_FALLBACKS,
|
||||
} from "../../test/helpers/extensions/onboard-config.js";
|
||||
import { SYNTHETIC_DEFAULT_MODEL_ID } from "../agents/synthetic-models.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentModelFallbackValues,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "../config/model-input.js";
|
||||
import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js";
|
||||
import { setMinimaxApiKey, writeOAuthCredentials } from "../plugins/provider-auth-storage.js";
|
||||
import {
|
||||
|
|
@ -11,6 +62,26 @@ import {
|
|||
setupAuthTestEnv,
|
||||
} from "./test-wizard-helpers.js";
|
||||
|
||||
function expectPrimaryModelPreserved(cfg: OpenClawConfig): void {
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
|
||||
"anthropic/claude-opus-4-5",
|
||||
);
|
||||
}
|
||||
|
||||
function expectFallbacksPreserved(cfg: OpenClawConfig): void {
|
||||
expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([
|
||||
...EXPECTED_FALLBACKS,
|
||||
]);
|
||||
}
|
||||
|
||||
function expectAllowlistContains(cfg: OpenClawConfig, modelRef: string): void {
|
||||
expect(Object.keys(cfg.agents?.defaults?.models ?? {})).toContain(modelRef);
|
||||
}
|
||||
|
||||
function expectAliasPreserved(cfg: OpenClawConfig, modelRef: string, alias: string): void {
|
||||
expect(cfg.agents?.defaults?.models?.[modelRef]?.alias).toBe(alias);
|
||||
}
|
||||
|
||||
describe("writeOAuthCredentials", () => {
|
||||
const lifecycle = createAuthTestLifecycle([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
|
|
@ -320,3 +391,426 @@ describe("applyAuthProfileConfig", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMinimaxApiConfig", () => {
|
||||
it("adds minimax provider with correct settings", () => {
|
||||
const cfg = applyMinimaxApiConfig({});
|
||||
expect(cfg.models?.providers?.minimax).toMatchObject({
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps reasoning enabled for MiniMax-M2.7", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7");
|
||||
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves existing model params when adding alias", () => {
|
||||
const cfg = applyMinimaxApiConfig(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"minimax/MiniMax-M2.7": {
|
||||
alias: "MiniMax",
|
||||
params: { custom: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"MiniMax-M2.7",
|
||||
);
|
||||
expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.7"]).toMatchObject({
|
||||
alias: "Minimax",
|
||||
params: { custom: "value" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges existing minimax provider models", () => {
|
||||
const cfg = applyMinimaxApiConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: "minimax",
|
||||
api: "openai-completions",
|
||||
}),
|
||||
);
|
||||
expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
|
||||
expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages");
|
||||
expect(cfg.models?.providers?.minimax?.authHeader).toBe(true);
|
||||
expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key");
|
||||
expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([
|
||||
"old-model",
|
||||
"MiniMax-M2.7",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves other providers when adding minimax", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "anthropic-key", // pragma: allowlist secret
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.models?.providers?.anthropic).toBeDefined();
|
||||
expect(cfg.models?.providers?.minimax).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves existing models mode", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
models: { mode: "replace", providers: {} },
|
||||
});
|
||||
expect(cfg.models?.mode).toBe("replace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider config helpers", () => {
|
||||
it("does not overwrite existing primary model", () => {
|
||||
const providerConfigAppliers = [applyMinimaxApiProviderConfig, applyZaiProviderConfig];
|
||||
for (const applyConfig of providerConfigAppliers) {
|
||||
const cfg = applyConfig({
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
});
|
||||
expectPrimaryModelPreserved(cfg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyZaiConfig", () => {
|
||||
it("adds zai provider with correct settings", () => {
|
||||
const cfg = applyZaiConfig({});
|
||||
expect(cfg.models?.providers?.zai).toMatchObject({
|
||||
// Default: general (non-coding) endpoint. Coding Plan endpoint is detected during setup.
|
||||
baseUrl: ZAI_GLOBAL_BASE_URL,
|
||||
api: "openai-completions",
|
||||
});
|
||||
const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id);
|
||||
expect(ids).toContain("glm-5");
|
||||
expect(ids).toContain("glm-5-turbo");
|
||||
expect(ids).toContain("glm-4.7");
|
||||
expect(ids).toContain("glm-4.7-flash");
|
||||
expect(ids).toContain("glm-4.7-flashx");
|
||||
});
|
||||
|
||||
it("supports CN endpoint for supported coding models", () => {
|
||||
for (const modelId of ["glm-4.7-flash", "glm-4.7-flashx"] as const) {
|
||||
const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId });
|
||||
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL);
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySyntheticConfig", () => {
|
||||
it("adds synthetic provider with correct settings", () => {
|
||||
const cfg = applySyntheticConfig({});
|
||||
expect(cfg.models?.providers?.synthetic).toMatchObject({
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
api: "anthropic-messages",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges existing synthetic provider models", () => {
|
||||
const cfg = applySyntheticProviderConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: "synthetic",
|
||||
api: "openai-completions",
|
||||
}),
|
||||
);
|
||||
expect(cfg.models?.providers?.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic");
|
||||
expect(cfg.models?.providers?.synthetic?.api).toBe("anthropic-messages");
|
||||
expect(cfg.models?.providers?.synthetic?.apiKey).toBe("old-key");
|
||||
const ids = cfg.models?.providers?.synthetic?.models.map((m) => m.id);
|
||||
expect(ids).toContain("old-model");
|
||||
expect(ids).toContain(SYNTHETIC_DEFAULT_MODEL_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("primary model defaults", () => {
|
||||
it("sets correct primary model", () => {
|
||||
const configCases = [
|
||||
{
|
||||
getConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7-highspeed"),
|
||||
primaryModel: "minimax/MiniMax-M2.7-highspeed",
|
||||
},
|
||||
{
|
||||
getConfig: () => applyZaiConfig({}, { modelId: "glm-5" }),
|
||||
primaryModel: "zai/glm-5",
|
||||
},
|
||||
{
|
||||
getConfig: () => applySyntheticConfig({}),
|
||||
primaryModel: SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
},
|
||||
] as const;
|
||||
for (const { getConfig, primaryModel } of configCases) {
|
||||
const cfg = getConfig();
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyXiaomiConfig", () => {
|
||||
it("adds Xiaomi provider with correct settings", () => {
|
||||
const cfg = applyXiaomiConfig({});
|
||||
expect(cfg.models?.providers?.xiaomi).toMatchObject({
|
||||
baseUrl: "https://api.xiaomimimo.com/v1",
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([
|
||||
"mimo-v2-flash",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
]);
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe("xiaomi/mimo-v2-flash");
|
||||
});
|
||||
|
||||
it("merges Xiaomi models and keeps existing provider overrides", () => {
|
||||
const cfg = applyXiaomiProviderConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: "xiaomi",
|
||||
api: "openai-completions",
|
||||
modelId: "custom-model",
|
||||
modelName: "Custom",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/v1");
|
||||
expect(cfg.models?.providers?.xiaomi?.api).toBe("openai-completions");
|
||||
expect(cfg.models?.providers?.xiaomi?.apiKey).toBe("old-key");
|
||||
expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([
|
||||
"custom-model",
|
||||
"mimo-v2-flash",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyXaiConfig", () => {
|
||||
it("adds xAI provider with correct settings", () => {
|
||||
const cfg = applyXaiConfig({});
|
||||
expect(cfg.models?.providers?.xai).toMatchObject({
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
api: "openai-responses",
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(XAI_DEFAULT_MODEL_REF);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyXaiProviderConfig", () => {
|
||||
it("merges xAI models and keeps existing provider overrides", () => {
|
||||
const cfg = applyXaiProviderConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: "xai",
|
||||
api: "anthropic-messages",
|
||||
modelId: "custom-model",
|
||||
modelName: "Custom",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
|
||||
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
|
||||
expect(cfg.models?.providers?.xai?.apiKey).toBe("old-key");
|
||||
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"custom-model",
|
||||
"grok-4",
|
||||
"grok-4-1-fast",
|
||||
"grok-4.20-beta-latest-reasoning",
|
||||
"grok-code-fast-1",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMistralConfig", () => {
|
||||
it("adds Mistral provider with correct settings", () => {
|
||||
const cfg = applyMistralConfig({});
|
||||
expect(cfg.models?.providers?.mistral).toMatchObject({
|
||||
baseUrl: "https://api.mistral.ai/v1",
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(
|
||||
MISTRAL_DEFAULT_MODEL_REF,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMistralProviderConfig", () => {
|
||||
it("merges Mistral models and keeps existing provider overrides", () => {
|
||||
const cfg = applyMistralProviderConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: "mistral",
|
||||
api: "anthropic-messages",
|
||||
modelId: "custom-model",
|
||||
modelName: "Custom",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cfg.models?.providers?.mistral?.baseUrl).toBe("https://api.mistral.ai/v1");
|
||||
expect(cfg.models?.providers?.mistral?.api).toBe("openai-completions");
|
||||
expect(cfg.models?.providers?.mistral?.apiKey).toBe("old-key");
|
||||
expect(cfg.models?.providers?.mistral?.models.map((m) => m.id)).toEqual([
|
||||
"custom-model",
|
||||
"mistral-large-latest",
|
||||
]);
|
||||
const mistralDefault = cfg.models?.providers?.mistral?.models.find(
|
||||
(model) => model.id === "mistral-large-latest",
|
||||
);
|
||||
expect(mistralDefault?.contextWindow).toBe(262144);
|
||||
expect(mistralDefault?.maxTokens).toBe(16384);
|
||||
});
|
||||
|
||||
it("uses the bundled mistral default model definition", () => {
|
||||
const bundled = buildBundledMistralModelDefinition();
|
||||
const cfg = applyMistralProviderConfig({});
|
||||
const defaultModel = cfg.models?.providers?.mistral?.models.find(
|
||||
(model) => model.id === bundled.id,
|
||||
);
|
||||
|
||||
expect(defaultModel).toMatchObject({
|
||||
id: bundled.id,
|
||||
contextWindow: bundled.contextWindow,
|
||||
maxTokens: bundled.maxTokens,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback preservation helpers", () => {
|
||||
it("preserves existing model fallbacks", () => {
|
||||
const fallbackCases = [applyMinimaxApiConfig, applyXaiConfig, applyMistralConfig] as const;
|
||||
for (const applyConfig of fallbackCases) {
|
||||
const cfg = applyConfig(createConfigWithFallbacks());
|
||||
expectFallbacksPreserved(cfg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider alias defaults", () => {
|
||||
it("adds expected alias for provider defaults", () => {
|
||||
const aliasCases = [
|
||||
{
|
||||
applyConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7"),
|
||||
modelRef: "minimax/MiniMax-M2.7",
|
||||
alias: "Minimax",
|
||||
},
|
||||
{
|
||||
applyConfig: () => applyXaiProviderConfig({}),
|
||||
modelRef: XAI_DEFAULT_MODEL_REF,
|
||||
alias: "Grok",
|
||||
},
|
||||
{
|
||||
applyConfig: () => applyMistralProviderConfig({}),
|
||||
modelRef: MISTRAL_DEFAULT_MODEL_REF,
|
||||
alias: "Mistral",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of aliasCases) {
|
||||
const cfg = testCase.applyConfig();
|
||||
expect(cfg.agents?.defaults?.models?.[testCase.modelRef]?.alias).toBe(testCase.alias);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowlist provider helpers", () => {
|
||||
it("adds allowlist entry and preserves alias", () => {
|
||||
const providerCases = [
|
||||
{
|
||||
applyConfig: applyOpencodeZenProviderConfig,
|
||||
modelRef: "opencode/claude-opus-4-6",
|
||||
alias: "My Opus",
|
||||
},
|
||||
{
|
||||
applyConfig: applyOpencodeGoProviderConfig,
|
||||
modelRef: "opencode-go/kimi-k2.5",
|
||||
alias: "Kimi",
|
||||
},
|
||||
{
|
||||
applyConfig: applyOpenrouterProviderConfig,
|
||||
modelRef: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
alias: "Router",
|
||||
},
|
||||
] as const;
|
||||
for (const { applyConfig, modelRef, alias } of providerCases) {
|
||||
const withDefault = applyConfig({});
|
||||
expectAllowlistContains(withDefault, modelRef);
|
||||
|
||||
const withAlias = applyConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[modelRef]: { alias },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expectAliasPreserved(withAlias, modelRef, alias);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyLitellmProviderConfig", () => {
|
||||
it("preserves existing baseUrl and api key while adding the default model", () => {
|
||||
const cfg = applyLitellmProviderConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: "litellm",
|
||||
api: "anthropic-messages",
|
||||
modelId: "custom-model",
|
||||
modelName: "Custom",
|
||||
baseUrl: "https://litellm.example/v1",
|
||||
apiKey: " old-key ",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cfg.models?.providers?.litellm?.baseUrl).toBe("https://litellm.example/v1");
|
||||
expect(cfg.models?.providers?.litellm?.api).toBe("openai-completions");
|
||||
expect(cfg.models?.providers?.litellm?.apiKey).toBe("old-key");
|
||||
expect(cfg.models?.providers?.litellm?.models.map((m) => m.id)).toEqual([
|
||||
"custom-model",
|
||||
"claude-opus-4-6",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default-model config helpers", () => {
|
||||
it("sets primary model and preserves existing model fallbacks", () => {
|
||||
const configCases = [
|
||||
{
|
||||
applyConfig: applyOpencodeZenConfig,
|
||||
primaryModel: "opencode/claude-opus-4-6",
|
||||
},
|
||||
{
|
||||
applyConfig: applyOpencodeGoConfig,
|
||||
primaryModel: "opencode-go/kimi-k2.5",
|
||||
},
|
||||
{
|
||||
applyConfig: applyOpenrouterConfig,
|
||||
primaryModel: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
},
|
||||
] as const;
|
||||
for (const { applyConfig, primaryModel } of configCases) {
|
||||
const cfg = applyConfig({});
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(primaryModel);
|
||||
|
||||
const cfgWithFallbacks = applyConfig(createConfigWithFallbacks());
|
||||
expectFallbacksPreserved(cfgWithFallbacks);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue