GigaChat: fix review follow-ups

This commit is contained in:
Alexander Davydov 2026-03-16 12:31:39 +03:00
parent b3a2ceec8a
commit 9134eb7af2
4 changed files with 197 additions and 6 deletions

View File

@ -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",
);
});
});

View File

@ -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<string, string>,
reverse: Map<string, string>,
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<string, string>();
const gigaToToolName = new Map<string, string>();
// 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(),

View File

@ -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, {

View File

@ -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",