GigaChat: preserve config and runtime fallbacks

This commit is contained in:
Alexander Davydov 2026-03-18 23:23:12 +03:00
parent c875368c84
commit d6daa108e3
15 changed files with 465 additions and 59 deletions

View File

@ -0,0 +1,45 @@
import type { AuthProfileStore } from "./auth-profiles.js";
export type GigachatAuthMetadata = Record<string, string> | undefined;
export function resolveGigachatAuthProfileMetadata(
store: Pick<AuthProfileStore, "profiles">,
authProfileId?: string,
): GigachatAuthMetadata {
const profileIds = [authProfileId?.trim(), "gigachat:default"].filter(
(profileId): profileId is string => Boolean(profileId),
);
for (const profileId of profileIds) {
const credential = store.profiles[profileId];
if (credential?.type === "api_key" && credential.provider === "gigachat") {
return credential.metadata;
}
}
return undefined;
}
function looksLikeGigachatBasicCredentials(apiKey: string | undefined): boolean {
const trimmed = apiKey?.trim();
if (!trimmed) {
return false;
}
const separatorIndex = trimmed.indexOf(":");
return separatorIndex > 0;
}
export function resolveGigachatAuthMode(params: {
metadata?: GigachatAuthMetadata;
apiKey?: string;
authProfileId?: string;
}): "oauth" | "basic" {
const metadataAuthMode = params.metadata?.authMode;
if (metadataAuthMode === "basic" || metadataAuthMode === "oauth") {
return metadataAuthMode;
}
if (!params.authProfileId?.trim() && looksLikeGigachatBasicCredentials(params.apiKey)) {
return "basic";
}
return "oauth";
}

View File

@ -652,4 +652,74 @@ describe("createGigachatStreamFn tool calling", () => {
expect(clientConfigs[0]?.user).toBeUndefined();
expect(clientConfigs[0]?.password).toBeUndefined();
});
it("falls back to the SDK default oauth scope when no metadata scope is available", async () => {
request.mockResolvedValueOnce({
status: 200,
data: createSseStream(['data: {"choices":[{"delta":{"content":"done"}}]}', "data: [DONE]"]),
});
const streamFn = createGigachatStreamFn({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
authMode: "oauth",
});
const stream = await streamFn(
{ api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never,
{ messages: [], tools: [] } as never,
{ apiKey: "oauth-credential" } as never,
);
await expect(stream.result()).resolves.toMatchObject({
content: [{ type: "text", text: "done" }],
});
expect(clientConfigs).toHaveLength(1);
expect(clientConfigs[0]?.credentials).toBe("oauth-credential");
expect(clientConfigs[0]).not.toHaveProperty("scope");
});
it("runs outbound payload hooks before sending the chat request", async () => {
request.mockResolvedValueOnce({
status: 200,
data: createSseStream(['data: {"choices":[{"delta":{"content":"done"}}]}', "data: [DONE]"]),
});
const onPayload = vi.fn((payload: unknown) => ({
...(payload as Record<string, unknown>),
parallel_tool_calls: true,
}));
const streamFn = createGigachatStreamFn({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
authMode: "oauth",
});
const stream = await streamFn(
{ api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never,
{ messages: [], tools: [] } as never,
{ apiKey: "token", onPayload } as never,
);
await expect(stream.result()).resolves.toMatchObject({
content: [{ type: "text", text: "done" }],
});
expect(onPayload).toHaveBeenCalledWith(
expect.objectContaining({
model: "GigaChat-2-Max",
stream: true,
}),
expect.objectContaining({
id: "GigaChat-2-Max",
}),
);
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
parallel_tool_calls: true,
stream: true,
}),
}),
);
});
});

View File

@ -556,8 +556,13 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn {
log.debug(`GigaChat auth: basic mode`);
} else {
clientConfig.credentials = apiKey;
clientConfig.scope = opts.scope ?? "GIGACHAT_API_PERS";
log.debug(`GigaChat auth: oauth scope=${clientConfig.scope}`);
const configuredScope = opts.scope?.trim();
if (configuredScope) {
clientConfig.scope = configuredScope;
}
log.debug(
`GigaChat auth: oauth${clientConfig.scope ? ` scope=${clientConfig.scope}` : " (sdk default scope)"}`,
);
}
return clientConfig;
@ -729,6 +734,9 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn {
} else {
chatRequest.top_p = 0;
}
const outboundPayload = { ...chatRequest, stream: true };
const requestPayload = (options?.onPayload?.(outboundPayload, model) ??
outboundPayload) as Chat & { stream: true };
log.debug(`GigaChat request: ${messages.length} messages, ${functions.length} functions`);
@ -741,7 +749,7 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn {
return axiosClient.request({
method: "POST",
url: "/chat/completions",
data: { ...chatRequest, stream: true },
data: requestPayload,
responseType: "stream",
headers: {
...resolveGigachatModelHeaders(model),

View File

@ -108,6 +108,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"TOGETHER_API_KEY",
"VOLCANO_ENGINE_API_KEY",
"BYTEPLUS_API_KEY",
"GIGACHAT_CREDENTIALS",
"KILOCODE_API_KEY",
"KIMI_API_KEY",
"KIMICODE_API_KEY",

View File

@ -0,0 +1,23 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("GigaChat implicit provider", () => {
it("injects the default provider when GIGACHAT_CREDENTIALS is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ GIGACHAT_CREDENTIALS: "user:password" }, async () => {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.gigachat).toMatchObject({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
api: "openai-completions",
apiKey: "GIGACHAT_CREDENTIALS",
});
expect(providers?.gigachat?.models?.map((model) => model.id)).toEqual(["GigaChat-2-Max"]);
});
});
});

View File

@ -3,6 +3,10 @@ import {
QIANFAN_DEFAULT_MODEL_ID,
} from "../../extensions/qianfan/provider-catalog.js";
import { XIAOMI_DEFAULT_MODEL_ID } from "../../extensions/xiaomi/provider-catalog.js";
import {
buildGigachatModelDefinition,
GIGACHAT_BASE_URL,
} from "../commands/onboard-auth.models.js";
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { isRecord } from "../utils.js";
@ -84,6 +88,15 @@ function normalizeProviderBaseUrl(baseUrl: string | undefined): string {
}
}
function buildGigachatProvider(apiKey?: string): ProviderConfig {
return {
baseUrl: GIGACHAT_BASE_URL,
api: "openai-completions",
...(apiKey ? { apiKey } : {}),
models: [buildGigachatModelDefinition()],
} satisfies ProviderConfig;
}
function withStreamingUsageCompat(provider: ProviderConfig): ProviderConfig {
if (!Array.isArray(provider.models) || provider.models.length === 0) {
return provider;
@ -694,6 +707,15 @@ async function resolvePluginImplicitProviders(
return Object.keys(discovered).length > 0 ? discovered : undefined;
}
function resolveImplicitGigachatProvider(ctx: ImplicitProviderContext): ProviderConfig | null {
const auth = ctx.resolveProviderAuth("gigachat");
if (!auth.apiKey) {
return null;
}
return buildGigachatProvider(auth.apiKey);
}
export async function resolveImplicitProviders(
params: ImplicitProviderParams,
): Promise<ModelsConfig["providers"]> {
@ -793,6 +815,11 @@ export async function resolveImplicitProviders(
mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "paired"));
mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "late"));
const implicitGigachat = resolveImplicitGigachatProvider(context);
if (implicitGigachat) {
providers.gigachat = implicitGigachat;
}
const implicitBedrock = await resolveImplicitBedrockProvider({
agentDir: params.agentDir,
config: params.config,

View File

@ -584,11 +584,11 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }),
);
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
{
expect.objectContaining({
messageCount: 1,
tokenCount: 10,
compactedCount: 1,
},
}),
expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }),
);
});
@ -906,6 +906,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
profiles: {
"gigachat:business": {
type: "api_key",
provider: "gigachat",
metadata: {
authMode: "basic",
insecureTls: "true",
@ -1015,6 +1016,54 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
expect(result.ok, result.reason).toBe(true);
});
it("infers basic auth for env-backed GigaChat credentials without stored profile metadata", async () => {
resolveModelMock.mockReturnValue({
model: {
provider: "gigachat",
api: "openai-completions",
id: "GigaChat-2-Max",
input: ["text"],
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
},
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
} as never);
vi.mocked(getApiKeyForModel).mockResolvedValueOnce({
apiKey: "user:password",
mode: "api-key",
source: "env: GIGACHAT_CREDENTIALS",
});
ensureAuthProfileStoreMock.mockReturnValue({ profiles: {} });
sessionCompactImpl.mockImplementation(async () => {
expect(createGigachatStreamFnMock).toHaveBeenCalledWith({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
authMode: "basic",
insecureTls: false,
scope: undefined,
});
return {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
details: { ok: true },
};
});
const result = await compactEmbeddedPiSessionDirect({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: gigachatTestConfig(),
provider: "gigachat",
model: "GigaChat-2-Max",
customInstructions: "focus on decisions",
});
expect(result.ok, result.reason).toBe(true);
});
});
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {

View File

@ -43,6 +43,7 @@ import { ensureCustomApiRegistered } from "../custom-api-registry.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { resolveOpenClawDocsPath } from "../docs-path.js";
import { resolveGigachatAuthMode, resolveGigachatAuthProfileMetadata } from "../gigachat-auth.js";
import { createGigachatStreamFn } from "../gigachat-stream.js";
import { resolveMemorySearchConfig } from "../memory-search.js";
import {
@ -851,15 +852,19 @@ export async function compactEmbeddedPiSessionDirect(
process.env.GIGACHAT_BASE_URL?.trim() ??
"https://gigachat.devices.sberbank.ru/api/v1";
const gigachatStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const profileId =
apiKeyInfo?.profileId?.trim() || authProfileId?.trim() || "gigachat:default";
const gigachatCred =
gigachatStore.profiles[profileId] ?? gigachatStore.profiles["gigachat:default"];
const gigachatMeta = gigachatCred?.type === "api_key" ? gigachatCred.metadata : undefined;
const resolvedGigachatProfileId = apiKeyInfo?.profileId?.trim() || authProfileId?.trim();
const gigachatMeta = resolveGigachatAuthProfileMetadata(
gigachatStore,
resolvedGigachatProfileId,
);
session.agent.streamFn = createGigachatStreamFn({
baseUrl,
authMode: (gigachatMeta?.authMode as "oauth" | "basic") ?? "oauth",
authMode: resolveGigachatAuthMode({
metadata: gigachatMeta,
apiKey: apiKeyInfo?.apiKey,
authProfileId: resolvedGigachatProfileId,
}),
insecureTls: gigachatMeta?.insecureTls === "true",
scope: gigachatMeta?.scope,
});

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { appendBootstrapPromptWarning } from "../../bootstrap-budget.js";
import { resolveGigachatAuthMode } from "../../gigachat-auth.js";
import { resolveOllamaBaseUrlForRun } from "../../ollama-stream.js";
import { buildAgentSystemPrompt } from "../../system-prompt.js";
import {
@ -205,6 +206,21 @@ describe("resolveGigachatAuthProfileMetadata", () => {
});
});
describe("resolveGigachatAuthMode", () => {
it("infers basic auth for env-backed combined credentials without profile metadata", () => {
expect(resolveGigachatAuthMode({ apiKey: "user:password" })).toBe("basic");
});
it("keeps oauth as the fallback when a profile is selected but has no metadata", () => {
expect(
resolveGigachatAuthMode({
apiKey: "oauth:credential:with:colon",
authProfileId: "gigachat:business",
}),
).toBe("oauth");
});
});
describe("composeSystemPromptWithHookContext", () => {
it("returns undefined when no hook system context is provided", () => {
expect(composeSystemPromptWithHookContext({ baseSystemPrompt: "base" })).toBeUndefined();

View File

@ -37,7 +37,6 @@ import { resolveOpenClawAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import { ensureAuthProfileStore } from "../../auth-profiles.js";
import type { AuthProfileStore } from "../../auth-profiles.js";
import {
analyzeBootstrapBudget,
buildBootstrapPromptWarning,
@ -55,6 +54,10 @@ import { ensureCustomApiRegistered } from "../../custom-api-registry.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js";
import { resolveOpenClawDocsPath } from "../../docs-path.js";
import { isTimeoutError } from "../../failover-error.js";
import {
resolveGigachatAuthMode,
resolveGigachatAuthProfileMetadata,
} from "../../gigachat-auth.js";
import { createGigachatStreamFn } from "../../gigachat-stream.js";
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
import { resolveModelAuthMode } from "../../model-auth.js";
@ -225,22 +228,7 @@ function createYieldAbortedResponse(model: { api?: string; provider?: string; id
result: async () => message,
};
}
export function resolveGigachatAuthProfileMetadata(
store: Pick<AuthProfileStore, "profiles">,
authProfileId?: string,
): Record<string, string> | undefined {
const profileIds = [authProfileId?.trim(), "gigachat:default"].filter(
(profileId): profileId is string => Boolean(profileId),
);
for (const profileId of profileIds) {
const credential = store.profiles[profileId];
if (credential?.type === "api_key" && credential.provider === "gigachat") {
return credential.metadata;
}
}
return undefined;
}
export { resolveGigachatAuthProfileMetadata } from "../../gigachat-auth.js";
// Queue a hidden steering message so pi-agent-core skips any remaining tool calls.
function queueSessionsYieldInterruptMessage(activeSession: {
@ -1995,10 +1983,15 @@ export async function runEmbeddedAttempt(
gigachatStore,
params.authProfileId,
);
const gigachatApiKey = await params.authStorage.getApiKey(params.provider);
const gigachatStreamFn = createGigachatStreamFn({
baseUrl,
authMode: (gigachatMeta?.authMode as "oauth" | "basic") ?? "oauth",
authMode: resolveGigachatAuthMode({
metadata: gigachatMeta,
apiKey: gigachatApiKey ?? undefined,
authProfileId: params.authProfileId,
}),
insecureTls: gigachatMeta?.insecureTls === "true",
scope: gigachatMeta?.scope,
});

View File

@ -54,6 +54,26 @@ describe("GigaChat provider config", () => {
expect(result.agents?.defaults?.models?.[GIGACHAT_DEFAULT_MODEL_REF]?.alias).toBe("GigaChat");
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5");
});
it("preserves an existing custom base URL when re-auth does not pass one", () => {
const cfg: OpenClawConfig = {
models: {
providers: {
gigachat: {
baseUrl: "https://preview.gigachat.example/api/v1",
api: "openai-completions",
models: [],
},
},
},
};
const result = applyGigachatProviderConfig(cfg);
expect(result.models?.providers?.gigachat?.baseUrl).toBe(
"https://preview.gigachat.example/api/v1",
);
});
});
describe("applyGigachatConfig", () => {

View File

@ -11,6 +11,7 @@ import {
QIANFAN_DEFAULT_MODEL_ID,
XIAOMI_DEFAULT_MODEL_ID,
} from "../agents/models-config.providers.static.js";
import { findNormalizedProviderValue } from "../agents/provider-id.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
@ -467,12 +468,17 @@ export function applyGigachatProviderConfig(
};
const defaultModel = buildGigachatModelDefinition();
const existingProvider = findNormalizedProviderValue(cfg.models?.providers, "gigachat");
const baseUrl =
opts?.baseUrl?.trim() ||
(typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl : "") ||
GIGACHAT_BASE_URL;
return applyProviderConfigWithDefaultModel(cfg, {
agentModels: models,
providerId: "gigachat",
api: "openai-completions",
baseUrl: opts?.baseUrl ?? GIGACHAT_BASE_URL,
baseUrl,
defaultModel,
defaultModelId: GIGACHAT_DEFAULT_MODEL_ID,
});

View File

@ -56,6 +56,35 @@ describe("onboard auth provider config merges", () => {
expect(next.agents?.defaults?.models).toEqual(agentModels);
});
it("appends missing catalog models even when the default model already exists", () => {
const cfg: OpenClawConfig = {
models: {
providers: {
qianfan: {
api: "openai-completions",
baseUrl: "https://qianfan.example.com/v1",
models: [makeModel("deepseek-v3.2"), makeModel("legacy-only")],
},
},
},
};
const next = applyProviderConfigWithDefaultModels(cfg, {
agentModels,
providerId: "qianfan",
api: "openai-completions",
baseUrl: "https://qianfan.example.com/v1",
defaultModels: [makeModel("deepseek-v3.2"), makeModel("ernie-5.0-thinking-preview")],
defaultModelId: "deepseek-v3.2",
});
expect(next.models?.providers?.qianfan?.models?.map((m) => m.id)).toEqual([
"deepseek-v3.2",
"legacy-only",
"ernie-5.0-thinking-preview",
]);
});
it("merges model catalogs without duplicating existing model ids", () => {
const cfg: OpenClawConfig = {
models: {
@ -97,4 +126,88 @@ describe("onboard auth provider config merges", () => {
expect(next.models?.providers?.custom?.models?.map((m) => m.id)).toEqual(["model-z"]);
});
it("preserves bedrock discovery when onboarding rewrites providers", () => {
const cfg: OpenClawConfig = {
models: {
mode: "replace",
bedrockDiscovery: {
enabled: true,
region: "us-west-2",
},
},
};
const next = applyProviderConfigWithDefaultModel(cfg, {
agentModels,
providerId: "custom",
api: "openai-completions",
baseUrl: "https://example.com/v1",
defaultModel: makeModel("model-z"),
});
expect(next.models?.mode).toBe("replace");
expect(next.models?.bedrockDiscovery).toEqual({
enabled: true,
region: "us-west-2",
});
});
it("matches aliased provider ids and rewrites them to the canonical key", () => {
const cfg: OpenClawConfig = {
models: {
providers: {
"z-ai": {
api: "openai-completions",
baseUrl: "https://old.example.com/v1",
apiKey: " test-key ",
models: [makeModel("model-a")],
},
},
},
};
const next = applyProviderConfigWithDefaultModels(cfg, {
agentModels,
providerId: "zai",
api: "openai-completions",
baseUrl: "https://new.example.com/v1",
defaultModels: [makeModel("model-b")],
defaultModelId: "model-b",
});
expect(Object.keys(next.models?.providers ?? {})).toEqual(["zai"]);
expect(next.models?.providers?.zai?.apiKey).toBe("test-key");
expect(next.models?.providers?.zai?.models?.map((m) => m.id)).toEqual(["model-a", "model-b"]);
});
it("keeps secret-ref api keys when rewriting provider config", () => {
const secretRef = {
source: "env" as const,
provider: "default",
id: "CUSTOM_API_KEY",
};
const cfg: OpenClawConfig = {
models: {
providers: {
custom: {
api: "openai-completions",
baseUrl: "https://old.example.com/v1",
apiKey: secretRef,
models: [makeModel("model-a")],
},
},
},
};
const next = applyProviderConfigWithDefaultModel(cfg, {
agentModels,
providerId: "custom",
api: "openai-completions",
baseUrl: "https://new.example.com/v1",
defaultModel: makeModel("model-b"),
});
expect(next.models?.providers?.custom?.apiKey).toEqual(secretRef);
});
});

View File

@ -1,3 +1,4 @@
import { findNormalizedProviderKey } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js";
import type {
@ -5,6 +6,7 @@ import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "../config/types.models.js";
import type { SecretInput } from "../config/types.secrets.js";
function extractAgentDefaultModelFallbacks(model: unknown): string[] | undefined {
if (!model || typeof model !== "object") {
@ -34,6 +36,7 @@ export function applyOnboardAuthAgentModelsAndProviders(
},
},
models: {
...cfg.models,
mode: cfg.models?.mode ?? "merge",
providers: params.providers,
},
@ -72,18 +75,10 @@ export function applyProviderConfigWithDefaultModels(
},
): OpenClawConfig {
const providerState = resolveProviderModelMergeState(cfg, params.providerId);
const defaultModels = params.defaultModels;
const defaultModelId = params.defaultModelId ?? defaultModels[0]?.id;
const hasDefaultModel = defaultModelId
? providerState.existingModels.some((model) => model.id === defaultModelId)
: true;
const mergedModels =
providerState.existingModels.length > 0
? hasDefaultModel || defaultModels.length === 0
? providerState.existingModels
: [...providerState.existingModels, ...defaultModels]
: defaultModels;
? mergeMissingModelsById(providerState.existingModels, params.defaultModels)
: params.defaultModels;
return applyProviderConfigWithMergedModels(cfg, {
agentModels: params.agentModels,
providerId: params.providerId,
@ -91,7 +86,7 @@ export function applyProviderConfigWithDefaultModels(
api: params.api,
baseUrl: params.baseUrl,
mergedModels,
fallbackModels: defaultModels,
fallbackModels: params.defaultModels,
});
}
@ -154,15 +149,38 @@ type ProviderModelMergeState = {
existingModels: ModelDefinitionConfig[];
};
function mergeMissingModelsById(
existingModels: ModelDefinitionConfig[],
incomingModels: ModelDefinitionConfig[],
): ModelDefinitionConfig[] {
if (incomingModels.length === 0) {
return existingModels;
}
return [
...existingModels,
...incomingModels.filter(
(model) => !existingModels.some((existing) => existing.id === model.id),
),
];
}
function resolveProviderModelMergeState(
cfg: OpenClawConfig,
providerId: string,
): ProviderModelMergeState {
const providers = { ...cfg.models?.providers } as Record<string, ModelProviderConfig>;
const existingProvider = providers[providerId] as ModelProviderConfig | undefined;
const existingProviderKey = findNormalizedProviderKey(providers, providerId);
const existingProvider =
existingProviderKey !== undefined
? (providers[existingProviderKey] as ModelProviderConfig | undefined)
: undefined;
const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models)
? existingProvider.models
: [];
if (existingProviderKey && existingProviderKey !== providerId) {
delete providers[existingProviderKey];
}
return { providers, existingProvider, existingModels };
}
@ -199,15 +217,16 @@ function buildProviderConfig(params: {
fallbackModels: ModelDefinitionConfig[];
}): ModelProviderConfig {
const { apiKey: existingApiKey, ...existingProviderRest } = (params.existingProvider ?? {}) as {
apiKey?: string;
apiKey?: SecretInput;
};
const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined;
const normalizedApiKey =
typeof existingApiKey === "string" ? existingApiKey.trim() || undefined : existingApiKey;
return {
...existingProviderRest,
baseUrl: params.baseUrl,
api: params.api,
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
...(normalizedApiKey !== undefined ? { apiKey: normalizedApiKey } : {}),
models: params.mergedModels.length > 0 ? params.mergedModels : params.fallbackModels,
};
}

View File

@ -6,6 +6,7 @@ import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "../config/types.models.js";
import type { SecretInput } from "../config/types.secrets.js";
function extractAgentDefaultModelFallbacks(model: unknown): string[] | undefined {
if (!model || typeof model !== "object") {
@ -35,6 +36,7 @@ export function applyOnboardAuthAgentModelsAndProviders(
},
},
models: {
...cfg.models,
mode: cfg.models?.mode ?? "merge",
providers: params.providers,
},
@ -73,18 +75,10 @@ export function applyProviderConfigWithDefaultModels(
},
): OpenClawConfig {
const providerState = resolveProviderModelMergeState(cfg, params.providerId);
const defaultModels = params.defaultModels;
const defaultModelId = params.defaultModelId ?? defaultModels[0]?.id;
const hasDefaultModel = defaultModelId
? providerState.existingModels.some((model) => model.id === defaultModelId)
: true;
const mergedModels =
providerState.existingModels.length > 0
? hasDefaultModel || defaultModels.length === 0
? providerState.existingModels
: [...providerState.existingModels, ...defaultModels]
: defaultModels;
? mergeMissingModelsById(providerState.existingModels, params.defaultModels)
: params.defaultModels;
return applyProviderConfigWithMergedModels(cfg, {
agentModels: params.agentModels,
providerId: params.providerId,
@ -92,7 +86,7 @@ export function applyProviderConfigWithDefaultModels(
api: params.api,
baseUrl: params.baseUrl,
mergedModels,
fallbackModels: defaultModels,
fallbackModels: params.defaultModels,
});
}
@ -155,6 +149,22 @@ type ProviderModelMergeState = {
existingModels: ModelDefinitionConfig[];
};
function mergeMissingModelsById(
existingModels: ModelDefinitionConfig[],
incomingModels: ModelDefinitionConfig[],
): ModelDefinitionConfig[] {
if (incomingModels.length === 0) {
return existingModels;
}
return [
...existingModels,
...incomingModels.filter(
(model) => !existingModels.some((existing) => existing.id === model.id),
),
];
}
function resolveProviderModelMergeState(
cfg: OpenClawConfig,
providerId: string,
@ -207,15 +217,16 @@ function buildProviderConfig(params: {
fallbackModels: ModelDefinitionConfig[];
}): ModelProviderConfig {
const { apiKey: existingApiKey, ...existingProviderRest } = (params.existingProvider ?? {}) as {
apiKey?: string;
apiKey?: SecretInput;
};
const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined;
const normalizedApiKey =
typeof existingApiKey === "string" ? existingApiKey.trim() || undefined : existingApiKey;
return {
...existingProviderRest,
baseUrl: params.baseUrl,
api: params.api,
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
...(normalizedApiKey !== undefined ? { apiKey: normalizedApiKey } : {}),
models: params.mergedModels.length > 0 ? params.mergedModels : params.fallbackModels,
};
}