From d6daa108e312034c86b918816bf25f402285fc93 Mon Sep 17 00:00:00 2001 From: Alexander Davydov Date: Wed, 18 Mar 2026 23:23:12 +0300 Subject: [PATCH] GigaChat: preserve config and runtime fallbacks --- src/agents/gigachat-auth.ts | 45 +++++++ src/agents/gigachat-stream.tool-calls.test.ts | 70 +++++++++++ src/agents/gigachat-stream.ts | 14 ++- src/agents/models-config.e2e-harness.ts | 1 + .../models-config.providers.gigachat.test.ts | 23 ++++ src/agents/models-config.providers.ts | 27 +++++ .../pi-embedded-runner/compact.hooks.test.ts | 53 +++++++- src/agents/pi-embedded-runner/compact.ts | 17 ++- .../pi-embedded-runner/run/attempt.test.ts | 16 +++ src/agents/pi-embedded-runner/run/attempt.ts | 29 ++--- .../onboard-auth.config-core.gigachat.test.ts | 20 ++++ src/commands/onboard-auth.config-core.ts | 8 +- .../onboard-auth.config-shared.test.ts | 113 ++++++++++++++++++ src/commands/onboard-auth.config-shared.ts | 49 +++++--- src/plugins/provider-onboarding-config.ts | 39 +++--- 15 files changed, 465 insertions(+), 59 deletions(-) create mode 100644 src/agents/gigachat-auth.ts create mode 100644 src/agents/models-config.providers.gigachat.test.ts diff --git a/src/agents/gigachat-auth.ts b/src/agents/gigachat-auth.ts new file mode 100644 index 00000000000..3a268214cb5 --- /dev/null +++ b/src/agents/gigachat-auth.ts @@ -0,0 +1,45 @@ +import type { AuthProfileStore } from "./auth-profiles.js"; + +export type GigachatAuthMetadata = Record | undefined; + +export function resolveGigachatAuthProfileMetadata( + store: Pick, + 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"; +} diff --git a/src/agents/gigachat-stream.tool-calls.test.ts b/src/agents/gigachat-stream.tool-calls.test.ts index d498b6b9c75..ffb31f7354c 100644 --- a/src/agents/gigachat-stream.tool-calls.test.ts +++ b/src/agents/gigachat-stream.tool-calls.test.ts @@ -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), + 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, + }), + }), + ); + }); }); diff --git a/src/agents/gigachat-stream.ts b/src/agents/gigachat-stream.ts index c5dbda51159..86fc8dbfd52 100644 --- a/src/agents/gigachat-stream.ts +++ b/src/agents/gigachat-stream.ts @@ -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), diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 81518ec9aee..e5c11dcc0a2 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -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", diff --git a/src/agents/models-config.providers.gigachat.test.ts b/src/agents/models-config.providers.gigachat.test.ts new file mode 100644 index 00000000000..aa318bcc105 --- /dev/null +++ b/src/agents/models-config.providers.gigachat.test.ts @@ -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"]); + }); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index af9c3d6e34a..48ff3cbbe5c 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -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 { @@ -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, diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 0c43f320edf..0ebe414ed66 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -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)", () => { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 9499df9be08..75d13c282e2 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 81420f92973..e66824c48ae 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -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(); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 5b1767983ea..2e6c08566b9 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, - authProfileId?: string, -): Record | 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, }); diff --git a/src/commands/onboard-auth.config-core.gigachat.test.ts b/src/commands/onboard-auth.config-core.gigachat.test.ts index 2fd10b84c68..5f5ffc0dbe1 100644 --- a/src/commands/onboard-auth.config-core.gigachat.test.ts +++ b/src/commands/onboard-auth.config-core.gigachat.test.ts @@ -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", () => { diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index ffc8460a6ee..ddd65a11fa4 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -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, }); diff --git a/src/commands/onboard-auth.config-shared.test.ts b/src/commands/onboard-auth.config-shared.test.ts index 01cda96ae74..2dd9c5ef6c9 100644 --- a/src/commands/onboard-auth.config-shared.test.ts +++ b/src/commands/onboard-auth.config-shared.test.ts @@ -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); + }); }); diff --git a/src/commands/onboard-auth.config-shared.ts b/src/commands/onboard-auth.config-shared.ts index a417b19c36e..c730db53fb4 100644 --- a/src/commands/onboard-auth.config-shared.ts +++ b/src/commands/onboard-auth.config-shared.ts @@ -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; - 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, }; } diff --git a/src/plugins/provider-onboarding-config.ts b/src/plugins/provider-onboarding-config.ts index 9e70eaac192..c730db53fb4 100644 --- a/src/plugins/provider-onboarding-config.ts +++ b/src/plugins/provider-onboarding-config.ts @@ -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, }; }