diff --git a/src/agents/gigachat-stream.test.ts b/src/agents/gigachat-stream.test.ts new file mode 100644 index 00000000000..5502bf9bdc7 --- /dev/null +++ b/src/agents/gigachat-stream.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + cleanSchemaForGigaChat, + ensureJsonObjectStr, + extractGigaChatErrorMessage, + mapToolNameFromGigaChat, + mapToolNameToGigaChat, + parseGigachatBasicCredentials, + sanitizeFunctionName, +} from "./gigachat-stream.js"; + +describe("gigachat stream helpers", () => { + it("maps reserved tool names to and from GigaChat-safe names", () => { + expect(mapToolNameToGigaChat("web_search")).toBe("__gpt2giga_user_search_web"); + expect(mapToolNameFromGigaChat("__gpt2giga_user_search_web")).toBe("web_search"); + }); + + it("sanitizes tool names to GigaChat-compatible identifiers", () => { + expect(sanitizeFunctionName("search-web!tool")).toBe("search_web_tool"); + expect(sanitizeFunctionName("___")).toBe("func"); + }); + + it("parses basic auth credentials without truncating colon-containing passwords", () => { + expect(parseGigachatBasicCredentials("user:p@ss:with:colons")).toEqual({ + user: "user", + password: "p@ss:with:colons", + }); + }); + + it("cleans unsupported schema features for GigaChat", () => { + const cleaned = cleanSchemaForGigaChat({ + type: "object", + properties: { + filters: { + type: "object", + description: "Advanced filters", + properties: { + level: { type: "string", enum: Array.from({ length: 25 }, (_, i) => `v${i}`) }, + }, + }, + tags: { + type: "array", + items: { type: "string", minLength: 1 }, + }, + count: { + type: ["integer", "null"], + minimum: 1, + }, + }, + additionalProperties: false, + }); + + expect(cleaned).toEqual({ + type: "object", + properties: { + filters: { + type: "string", + description: "Advanced filters (JSON object)", + }, + tags: { + type: "array", + items: { type: "string" }, + }, + count: { + type: "integer", + }, + }, + }); + }); + + it("wraps non-object tool results as JSON objects", () => { + expect(ensureJsonObjectStr("plain text")).toBe(JSON.stringify({ result: "plain text" })); + expect(ensureJsonObjectStr('{"ok":true}')).toBe('{"ok":true}'); + }); + + it("extracts readable API errors from GigaChat/Axios-like responses", () => { + const err = new Error("[object Object]") as Error & { + response?: { + status?: number; + data?: unknown; + config?: { baseURL?: string; url?: string }; + }; + }; + err.response = { + status: 401, + data: { message: "invalid credentials" }, + config: { + baseURL: "https://gigachat.devices.sberbank.ru/api/v1", + url: "/chat/completions", + }, + }; + + expect(extractGigaChatErrorMessage(err)).toBe( + "GigaChat API 401 (https://gigachat.devices.sberbank.ru/api/v1/chat/completions): invalid credentials", + ); + }); +}); diff --git a/src/agents/gigachat-stream.ts b/src/agents/gigachat-stream.ts index f9335e94a62..ceb6117cb11 100644 --- a/src/agents/gigachat-stream.ts +++ b/src/agents/gigachat-stream.ts @@ -77,6 +77,41 @@ export function mapToolNameFromGigaChat(name: string): string { return RESERVED_NAME_GIGA_TO_CLIENT[name] ?? name; } +export function parseGigachatBasicCredentials(credentials: string): { + user: string; + password: string; +} { + const separatorIndex = credentials.indexOf(":"); + if (separatorIndex < 0) { + return { user: credentials, password: "" }; + } + return { + user: credentials.slice(0, separatorIndex), + password: credentials.slice(separatorIndex + 1), + }; +} + +function toGigaChatToolName(name: string): string { + return sanitizeFunctionName(mapToolNameToGigaChat(name)); +} + +function rememberToolNameMapping( + forward: Map, + reverse: Map, + originalName: string, +): string { + const gigaName = toGigaChatToolName(originalName); + forward.set(originalName, gigaName); + if (!reverse.has(gigaName)) { + reverse.set(gigaName, originalName); + } else if (reverse.get(gigaName) !== originalName) { + log.warn( + `GigaChat: tool name collision after sanitization: "${originalName}" and "${reverse.get(gigaName)}" both map to "${gigaName}"`, + ); + } + return gigaName; +} + // ── Schema cleaning ───────────────────────────────────────────────────────── // GigaChat doesn't support many JSON Schema features. We track modifications // to help debug issues with tool definitions. @@ -421,6 +456,8 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { try { const disableFunctions = process.env.GIGACHAT_DISABLE_FUNCTIONS?.trim().toLowerCase(); const functionsEnabled = disableFunctions !== "1" && disableFunctions !== "true"; + const toolNameToGiga = new Map(); + const gigaToToolName = new Map(); // Build messages for GigaChat format const messages: Message[] = []; @@ -451,11 +488,16 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { const toolCall = contentParts.find((c): c is ToolCall => c.type === "toolCall"); if (toolCall && toolCall.name && functionsEnabled) { + const gigaToolName = rememberToolNameMapping( + toolNameToGiga, + gigaToToolName, + toolCall.name, + ); messages.push({ role: "assistant", content: text ? sanitizeContent(text) : "", function_call: { - name: mapToolNameToGigaChat(toolCall.name), + name: gigaToolName, arguments: toolCall.arguments ?? {}, }, }); @@ -479,10 +521,11 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { sanitizeContent(resultContent || "ok"), toolName, ); + const gigaToolName = rememberToolNameMapping(toolNameToGiga, gigaToToolName, toolName); messages.push({ role: "function", content: coercedContent, - name: mapToolNameToGigaChat(toolName), + name: gigaToolName, }); } } @@ -511,8 +554,11 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { logSchemaModifications(tool.name, modifications); // Sanitize function name and map reserved names - const mappedName = mapToolNameToGigaChat(tool.name); - const sanitizedName = sanitizeFunctionName(mappedName); + const sanitizedName = rememberToolNameMapping( + toolNameToGiga, + gigaToToolName, + tool.name, + ); if (sanitizedName !== tool.name) { log.debug(`GigaChat: sanitized function name "${tool.name}" → "${sanitizedName}"`); } @@ -543,7 +589,7 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { // Set credentials based on auth mode if (isUserPassCredentials) { - const [user, password] = apiKey.split(":", 2); + const { user, password } = parseGigachatBasicCredentials(apiKey); clientConfig.user = user; clientConfig.password = password; log.debug(`GigaChat auth: basic mode`); @@ -700,7 +746,9 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { { cause: parseErr }, ); } - const clientName = mapToolNameFromGigaChat(functionCallBuffer.name); + const clientName = + gigaToToolName.get(functionCallBuffer.name) ?? + mapToolNameFromGigaChat(functionCallBuffer.name); accumulatedToolCalls.push({ type: "toolCall", id: randomUUID(), diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 10de2ecbcb6..d940d738f26 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -320,6 +320,28 @@ describe("onboard (non-interactive): provider auth", () => { }); }); + it("infers GigaChat auth from --gigachat-api-key and stores personal OAuth metadata", async () => { + await withOnboardEnv("openclaw-onboard-gigachat-infer-", async (env) => { + const cfg = await runOnboardingAndReadConfig(env, { + gigachatApiKey: "gigachat-credentials==", // pragma: allowlist secret + }); + + expect(cfg.auth?.profiles?.["gigachat:default"]?.provider).toBe("gigachat"); + expect(cfg.auth?.profiles?.["gigachat:default"]?.mode).toBe("api_key"); + expect(cfg.agents?.defaults?.model?.primary).toBe("gigachat/GigaChat-2-Max"); + await expectApiKeyProfile({ + profileId: "gigachat:default", + provider: "gigachat", + key: "gigachat-credentials==", + metadata: { + authMode: "oauth", + scope: "GIGACHAT_API_PERS", + insecureTls: "false", + }, + }); + }); + }); + it("stores Volcano Engine API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-volcengine-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts index a04dda68fd1..f5da6f6e44a 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts @@ -9,6 +9,7 @@ import { applyKilocodeConfig, applyKimiCodeConfig, applyLitellmConfig, + applyGigachatConfig, applyMistralConfig, applyModelStudioConfig, applyModelStudioConfigCn, @@ -29,6 +30,7 @@ import { setHuggingfaceApiKey, setKilocodeApiKey, setKimiCodingApiKey, + setGigachatApiKey, setLitellmApiKey, setMistralApiKey, setModelStudioApiKey, @@ -149,6 +151,28 @@ function buildSimpleApiKeyAuthChoices(params: { opts: OnboardOptions }): SimpleA }), ), }, + { + authChoices: ["gigachat-api-key", "gigachat-oauth"], + provider: "gigachat", + flagValue: params.opts.gigachatApiKey, + flagName: "--gigachat-api-key", + envVar: "GIGACHAT_CREDENTIALS", + profileId: "gigachat:default", + setCredential: (value, options) => + setGigachatApiKey(value, undefined, options, { + authMode: "oauth", + scope: "GIGACHAT_API_PERS", + insecureTls: "false", + }), + applyConfig: (cfg) => + applyGigachatConfig( + applyAuthProfileConfig(cfg, { + profileId: "gigachat:default", + provider: "gigachat", + mode: "api_key", + }), + ), + }, { authChoices: ["mistral-api-key"], provider: "mistral",