GigaChat: preserve config and runtime fallbacks
This commit is contained in:
parent
c875368c84
commit
d6daa108e3
45
src/agents/gigachat-auth.ts
Normal file
45
src/agents/gigachat-auth.ts
Normal 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";
|
||||
}
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
|
||||
23
src/agents/models-config.providers.gigachat.test.ts
Normal file
23
src/agents/models-config.providers.gigachat.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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)", () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user