feat: add GigaChat provider — streaming, auth, and onboarding

Add native GigaChat provider support with:

- Custom SSE streaming with schema cleaning, retry logic, and error
  extraction (src/agents/gigachat-stream.ts)
- OAuth (personal/business) and Basic auth onboarding flows
- Provider-specific config stored in auth profile credential metadata
  (not in ModelProviderConfig), keeping core types untouched
- GigaChat model definitions, env var mappings, and CLI flags
This commit is contained in:
Alexander Davydov 2026-03-12 19:27:54 +03:00 committed by Давыдов Александр Юрьевич
parent 97683071b5
commit b125d2e98b
21 changed files with 1384 additions and 1 deletions

View File

@ -100,6 +100,28 @@ OpenClaw ships with the piai catalog. These providers require **no**
}
```
### GigaChat
- Provider: `gigachat`
- Auth:
- OAuth-style credentials via `GIGACHAT_CREDENTIALS`
- Basic auth flow supported during onboarding
- Example model: `gigachat/GigaChat-2-Max`
- CLI:
- `openclaw onboard --auth-choice gigachat-personal`
- `openclaw onboard --auth-choice gigachat-business`
- `openclaw onboard --gigachat-api-key <credentials>`
- Notes:
- OpenClaw uses a dedicated GigaChat runtime path because the provider does not support the full OpenAI-compatible tool/schema surface.
- Tool definitions may be simplified before being sent to GigaChat.
- Custom base URL and Basic auth flows are supported for environments that require them.
```json5
{
agents: { defaults: { model: { primary: "gigachat/GigaChat-2-Max" } } },
}
```
### Google Gemini (API key)
- Provider: `google`
@ -163,6 +185,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
- Cerebras: `cerebras` (`CEREBRAS_API_KEY`)
- GLM models on Cerebras use ids `zai-glm-4.7` and `zai-glm-4.6`.
- OpenAI-compatible base URL: `https://api.cerebras.ai/v1`.
- GigaChat: `gigachat` (`GIGACHAT_CREDENTIALS`; Basic auth also supported via onboarding)
- GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`)
- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`) — OpenAI-compatible router; example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface).

View File

@ -368,6 +368,7 @@
"dotenv": "^17.3.1",
"express": "^5.2.1",
"file-type": "^21.3.1",
"gigachat": "^0.0.18",
"grammy": "^1.41.1",
"hono": "4.12.7",
"https-proxy-agent": "^8.0.0",

13
pnpm-lock.yaml generated
View File

@ -118,6 +118,9 @@ importers:
file-type:
specifier: 21.3.1
version: 21.3.1
gigachat:
specifier: ^0.0.18
version: 0.0.18
grammy:
specifier: ^1.41.1
version: 1.41.1
@ -4610,6 +4613,9 @@ packages:
getpass@0.1.7:
resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
gigachat@0.0.18:
resolution: {integrity: sha512-xolIdUv1DfX4KdvFNXy5rvDHnhb+Mao1jJS4Tk1aMELiMPsUDsWCPEl9VJU35A2HiUOt/RZcri6jDQhhMpEpuw==}
gitignore-to-glob@0.3.0:
resolution: {integrity: sha512-mk74BdnK7lIwDHnotHddx1wsjMOFIThpLY3cPNniJ/2fA/tlLzHnFxIdR+4sLOu5KGgQJdij4kjJ2RoUNnCNMA==}
engines: {node: '>=4.4 <5 || >=6.9'}
@ -11851,6 +11857,13 @@ snapshots:
dependencies:
assert-plus: 1.0.0
gigachat@0.0.18:
dependencies:
axios: 1.13.5
uuid: 11.1.0
transitivePeerDependencies:
- debug
gitignore-to-glob@0.3.0: {}
glob-parent@5.1.2:

View File

@ -0,0 +1,63 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
const updateToken = vi.fn(async () => {});
const request = vi.fn();
vi.mock("gigachat", () => {
class MockGigaChat {
_client = { request };
_accessToken = { access_token: "test-token" };
updateToken = updateToken;
}
return { GigaChat: MockGigaChat };
});
import { createGigachatStreamFn } from "./gigachat-stream.js";
function createSseStream(lines: string[]): Readable {
return Readable.from(lines.map((line) => `${line}\n`));
}
describe("GigaChat leaked function-call prelude cleanup", () => {
it("drops leaked assistant text preludes when a function call is present", async () => {
request.mockResolvedValueOnce({
status: 200,
data: createSseStream([
'data: {"choices":[{"delta":{"content":"assistant function callrecipient{","role":"assistant"},"index":0}]}',
'data: {"choices":[{"delta":{"content":"","role":"assistant","function_call":{"name":"message","arguments":"{\\"action\\":\\"send\\",\\"message\\":\\"hello\\"}"}},"index":0}]}',
"data: [DONE]",
]),
});
const streamFn = createGigachatStreamFn({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
authMode: "oauth",
});
const stream = streamFn(
{ api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never,
{
messages: [],
tools: [
{ name: "message", description: "send", parameters: { type: "object", properties: {} } },
],
} as never,
{ apiKey: "token" } as never,
);
const event = await stream.result();
expect(updateToken).toHaveBeenCalled();
expect(event.stopReason).toBe("toolUse");
expect(event.content).toEqual([
expect.objectContaining({
type: "toolCall",
name: "message",
arguments: { action: "send", message: "hello" },
}),
]);
});
});

View File

@ -0,0 +1,153 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
const updateToken = vi.fn(async () => {});
const request = vi.fn();
vi.mock("gigachat", () => {
class MockGigaChat {
_client = { request };
_accessToken = { access_token: "test-token" };
updateToken = updateToken;
}
return { GigaChat: MockGigaChat };
});
import { createGigachatStreamFn } from "./gigachat-stream.js";
function createSseStream(lines: string[]): Readable {
return Readable.from(lines.map((line) => `${line}\n`));
}
describe("createGigachatStreamFn tool calling", () => {
it("round-trips sanitized tool names for streamed function calls", async () => {
request.mockResolvedValueOnce({
status: 200,
data: createSseStream([
'data: {"choices":[{"delta":{"function_call":{"name":"llm_task"}}}]}',
'data: {"choices":[{"delta":{"function_call":{"arguments":"{\\"prompt\\":\\"hi\\"}"}}}]}',
"data: [DONE]",
]),
});
const streamFn = createGigachatStreamFn({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
authMode: "oauth",
});
const stream = streamFn(
{ api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never,
{
messages: [],
tools: [
{
name: "llm-task",
description: "Run a task",
parameters: {
type: "object",
properties: {
prompt: { type: "string" },
},
},
},
],
} as never,
{ apiKey: "token" } as never,
);
const event = await stream.result();
expect(updateToken).toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
functions: [
expect.objectContaining({
name: "llm_task",
}),
],
}),
}),
);
expect(event.role).toBe("assistant");
expect(event.stopReason).toBe("toolUse");
expect(event.content).toEqual([
expect.objectContaining({
type: "toolCall",
name: "llm-task",
arguments: { prompt: "hi" },
}),
]);
});
it("sanitizes historical assistant/tool result names in the outbound request", 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 = streamFn(
{ api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never,
{
messages: [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1",
name: "llm-task",
arguments: { prompt: "hi" },
},
],
},
{
role: "toolResult",
toolName: "llm-task",
content: "ok",
},
],
tools: [
{
name: "llm-task",
description: "Run a task",
parameters: {
type: "object",
properties: {
prompt: { type: "string" },
},
},
},
],
} as never,
{ apiKey: "token" } as never,
);
const event = await stream.result();
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
messages: [
expect.objectContaining({
role: "assistant",
function_call: expect.objectContaining({ name: "llm_task" }),
}),
expect.objectContaining({
role: "function",
name: "llm_task",
}),
],
}),
}),
);
expect(event.content).toEqual([{ type: "text", text: "done" }]);
});
});

View File

@ -0,0 +1,764 @@
import { randomUUID } from "node:crypto";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, StopReason, TextContent, ToolCall } from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { GigaChat, type GigaChatClientConfig } from "gigachat";
import type {
Chat,
ChatCompletionChunk,
FunctionParameters,
Function as GigaFunction,
Message,
} from "gigachat/interfaces";
// Extended types for API fields not in library type definitions
interface ExtendedChatCompletionChunk extends ChatCompletionChunk {
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
}
import https from "node:https";
import { createSubsystemLogger } from "../logging/subsystem.js";
export type GigachatAuthMode = "oauth" | "basic";
import {
buildAssistantMessage as buildStreamAssistantMessage,
buildStreamErrorAssistantMessage,
buildUsageWithNoCost,
} from "./stream-message-shared.js";
const log = createSubsystemLogger("gigachat-stream");
export type GigachatStreamOptions = {
baseUrl: string;
authMode: GigachatAuthMode;
insecureTls?: boolean;
/** OAuth: the credentials key. Basic: "username:password". */
scope?: string;
};
// ── Function name sanitization ──────────────────────────────────────────────
// GigaChat requires function names to be alphanumeric + underscore only.
const MAX_FUNCTION_NAME_LENGTH = 64;
export function sanitizeFunctionName(name: string): string {
// Replace non-alphanumeric (except underscore) with underscore
let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_");
// Collapse multiple underscores
sanitized = sanitized.replace(/_+/g, "_");
// Remove leading/trailing underscores
sanitized = sanitized.replace(/^_+|_+$/g, "");
// Truncate to max length
if (sanitized.length > MAX_FUNCTION_NAME_LENGTH) {
sanitized = sanitized.slice(0, MAX_FUNCTION_NAME_LENGTH);
}
// Ensure not empty
return sanitized || "func";
}
// ── Reserved tool name mapping ──────────────────────────────────────────────
const RESERVED_NAME_CLIENT_TO_GIGA: Record<string, string> = {
web_search: "__gpt2giga_user_search_web",
};
const RESERVED_NAME_GIGA_TO_CLIENT: Record<string, string> = Object.fromEntries(
Object.entries(RESERVED_NAME_CLIENT_TO_GIGA).map(([k, v]) => [v, k]),
);
export function mapToolNameToGigaChat(name: string): string {
return RESERVED_NAME_CLIENT_TO_GIGA[name] ?? name;
}
export function mapToolNameFromGigaChat(name: string): string {
return RESERVED_NAME_GIGA_TO_CLIENT[name] ?? name;
}
// ── Schema cleaning ─────────────────────────────────────────────────────────
// GigaChat doesn't support many JSON Schema features. We track modifications
// to help debug issues with tool definitions.
type SchemaModifications = {
enumsTruncated: string[];
nestedObjectsFlattened: string[];
arrayItemsSimplified: string[];
constraintsRemoved: string[];
};
const GIGACHAT_UNSUPPORTED_SCHEMA_KEYS = new Set([
"patternProperties",
"additionalProperties",
"$ref",
"$schema",
"$id",
"$defs",
"definitions",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
"dependentSchemas",
"dependentRequired",
"unevaluatedProperties",
"unevaluatedItems",
"contentEncoding",
"contentMediaType",
"format",
"default",
"examples",
"deprecated",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"minItems",
"maxItems",
"minProperties",
"maxProperties",
"pattern",
"uniqueItems",
"const",
]);
export function cleanSchemaForGigaChat(
schema: unknown,
depth = 0,
path = "",
modifications?: SchemaModifications,
): unknown {
if (schema === null || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map((s, i) => cleanSchemaForGigaChat(s, depth, `${path}[${i}]`, modifications));
}
const schemaObj = schema as Record<string, unknown>;
// Handle nullable types: type: ["string", "null"] -> type: "string"
if (Array.isArray(schemaObj.type)) {
const types = schemaObj.type as string[];
const nonNullType = types.find((t) => t !== "null") ?? "string";
const newSchema = { ...schemaObj, type: nonNullType };
return cleanSchemaForGigaChat(newSchema, depth, path, modifications);
}
// At depth > 0, convert type:"object" to type:"string" (gpt2giga behavior)
if (depth > 0 && schemaObj.type === "object") {
modifications?.nestedObjectsFlattened.push(path || "root");
const desc = typeof schemaObj.description === "string" ? schemaObj.description : "";
return {
type: "string",
description: desc ? `${desc} (JSON object)` : "JSON object",
};
}
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schemaObj)) {
if (GIGACHAT_UNSUPPORTED_SCHEMA_KEYS.has(key)) {
// Track constraint removals for important keys
if (["minimum", "maximum", "minLength", "maxLength", "pattern"].includes(key)) {
modifications?.constraintsRemoved.push(`${path}.${key}`);
}
continue;
}
if (key === "type" && Array.isArray(value)) {
const types = value as string[];
cleaned[key] = types.find((t) => t !== "null") ?? "string";
continue;
}
if (key === "required" && Array.isArray(value) && value.length === 0) {
continue;
}
if (key === "enum" && Array.isArray(value) && value.length > 20) {
modifications?.enumsTruncated.push(`${path} (${value.length} → 20)`);
cleaned[key] = value.slice(0, 20);
continue;
}
if (key === "items" && typeof value === "object" && value !== null) {
modifications?.arrayItemsSimplified.push(path || "root");
cleaned[key] = { type: "string" };
continue;
}
if (key === "properties" && typeof value === "object" && value !== null) {
const props = value as Record<string, unknown>;
const cleanedProps: Record<string, unknown> = {};
for (const [propName, propSchema] of Object.entries(props)) {
const propPath = path ? `${path}.${propName}` : propName;
cleanedProps[propName] = cleanSchemaForGigaChat(
propSchema,
depth + 1,
propPath,
modifications,
);
}
cleaned[key] = cleanedProps;
continue;
}
cleaned[key] = cleanSchemaForGigaChat(value, depth, path, modifications);
}
// Ensure type: "object" has properties
if (depth === 0 && cleaned.type === "object" && !("properties" in cleaned)) {
cleaned.properties = {};
}
return cleaned;
}
function logSchemaModifications(toolName: string, mods: SchemaModifications): void {
const parts: string[] = [];
if (mods.enumsTruncated.length > 0) {
parts.push(`enums truncated: ${mods.enumsTruncated.join(", ")}`);
}
if (mods.nestedObjectsFlattened.length > 0) {
parts.push(`nested objects → strings: ${mods.nestedObjectsFlattened.join(", ")}`);
}
if (mods.arrayItemsSimplified.length > 0) {
parts.push(`array items → strings: ${mods.arrayItemsSimplified.join(", ")}`);
}
if (mods.constraintsRemoved.length > 0) {
parts.push(`constraints removed: ${mods.constraintsRemoved.join(", ")}`);
}
if (parts.length > 0) {
log.debug(`GigaChat schema cleaning for "${toolName}": ${parts.join("; ")}`);
}
}
// ── Content sanitization ────────────────────────────────────────────────────
function sanitizeContent(content: string | null | undefined): string {
if (!content) {
return "";
}
return (
content
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/[\u201C\u201D]/g, '"')
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u2013\u2014]/g, "-")
.replace(/\u00A0/g, " ")
.replace(/\u2026/g, "...")
);
}
/**
* Coerce tool result content to a JSON object string (gpt2giga compatibility).
* GigaChat expects tool results to be JSON objects. If the content is already
* a valid JSON object, it's returned as-is. Otherwise, it's wrapped in
* `{"result": "..."}`.
*
* This behavior is intentionally consistent with gpt2giga proxy.
*/
export function ensureJsonObjectStr(content: string, toolName?: string): string {
const trimmed = content.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
JSON.parse(trimmed);
return trimmed;
} catch {
// Invalid JSON that looks like an object - wrap it
log.debug(`GigaChat: wrapping invalid JSON-like tool result for "${toolName ?? "unknown"}"`);
}
} else {
log.debug(`GigaChat: wrapping non-object tool result for "${toolName ?? "unknown"}"`);
}
return JSON.stringify({ result: content });
}
// ── Error message extraction ─────────────────────────────────────────────────
// GigaChat library exceptions pass `response.data` (an object) to the Error
// constructor, so `.message` ends up as "[object Object]". We dig into
// `.response.data` for the real details.
export function extractGigaChatErrorMessage(err: unknown): string {
if (err instanceof Error) {
// Check for Axios/GigaChat errors that carry response data
const errWithResponse = err as Error & {
response?: {
status?: number;
data?: unknown;
statusText?: string;
config?: { baseURL?: string; url?: string };
};
config?: { baseURL?: string; url?: string };
};
const respData = errWithResponse.response?.data;
// Build URL suffix for error context (Axios errors have config on err.config,
// GigaChat library exceptions store AxiosResponse so config is on err.response.config)
const cfg = errWithResponse.config ?? errWithResponse.response?.config;
const url = [cfg?.baseURL, cfg?.url]
.filter(Boolean)
.join("")
.replace(/([^:])\/\//g, "$1/");
const urlSuffix = url ? ` (${url})` : "";
if (respData && typeof respData === "object") {
const data = respData as Record<string, unknown>;
// GigaChat API error shapes: { message: "..." }, { error: { message: "..." } }, { detail: "..." }
const detail =
typeof data.message === "string"
? data.message
: typeof data.detail === "string"
? data.detail
: typeof data.error === "object" &&
data.error !== null &&
typeof (data.error as Record<string, unknown>).message === "string"
? ((data.error as Record<string, unknown>).message as string)
: typeof data.error === "string"
? data.error
: null;
if (detail) {
const status = errWithResponse.response?.status;
return status ? `GigaChat API ${status}${urlSuffix}: ${detail}` : detail;
}
// Fallback: stringify the response data
try {
const status = errWithResponse.response?.status;
const json = JSON.stringify(respData);
return status ? `GigaChat API ${status}${urlSuffix}: ${json}` : json;
} catch {
// circular or unserializable
}
}
// If .message is "[object Object]", try to recover from response status
if (err.message === "[object Object]") {
const status = errWithResponse.response?.status;
const statusText = errWithResponse.response?.statusText;
if (status) {
return `GigaChat API error ${status}${urlSuffix}${statusText ? `: ${statusText}` : ""}`;
}
return `${err.name || "Error"} (no details available)`;
}
return err.message;
}
if (typeof err === "object" && err !== null) {
const errObj = err as Record<string, unknown>;
if (typeof errObj.message === "string") {
return errObj.message;
}
if (typeof errObj.error === "string") {
return errObj.error;
}
if (typeof errObj.detail === "string") {
return errObj.detail;
}
try {
return JSON.stringify(err);
} catch {
return Object.prototype.toString.call(err);
}
}
return String(err);
}
// ── Retry helper ────────────────────────────────────────────────────────────
const MAX_RETRIES = 3;
const INITIAL_BACKOFF_MS = 1000;
async function withRetry<T>(
operation: () => Promise<T>,
operationName: string,
maxRetries = MAX_RETRIES,
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err instanceof Error ? err : new Error(extractGigaChatErrorMessage(err));
if (attempt < maxRetries) {
const backoffMs = INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1);
log.warn(
`GigaChat ${operationName} failed (attempt ${attempt}/${maxRetries}): ${extractGigaChatErrorMessage(lastError)}. ` +
`Retrying in ${backoffMs}ms...`,
);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
}
throw lastError;
}
// ── Stream function ─────────────────────────────────────────────────────────
export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn {
const envBaseUrl = process.env.GIGACHAT_BASE_URL?.trim();
const effectiveBaseUrl =
envBaseUrl || opts.baseUrl || "https://gigachat.devices.sberbank.ru/api/v1";
const envVerifySsl = process.env.GIGACHAT_VERIFY_SSL_CERTS?.trim().toLowerCase();
const insecureTls = opts.insecureTls ?? (envVerifySsl === "false" || envVerifySsl === "0");
// Security warning for insecure TLS
if (insecureTls) {
log.warn(
"⚠️ SECURITY WARNING: TLS certificate verification is DISABLED for GigaChat. " +
"This makes the connection vulnerable to man-in-the-middle attacks. " +
"Only use this in controlled environments with trusted networks.",
);
}
return (model, context, options) => {
const stream = createAssistantMessageEventStream();
const run = async () => {
log.debug(
`GigaChat stream: model=${model.id} baseUrl=${effectiveBaseUrl} authMode=${opts.authMode}`,
);
try {
const disableFunctions = process.env.GIGACHAT_DISABLE_FUNCTIONS?.trim().toLowerCase();
const functionsEnabled = disableFunctions !== "1" && disableFunctions !== "true";
// Build messages for GigaChat format
const messages: Message[] = [];
if (context.systemPrompt) {
messages.push({ role: "system", content: sanitizeContent(context.systemPrompt) });
}
for (const msg of context.messages ?? []) {
if (msg.role === "user") {
const content = msg.content;
if (typeof content === "string") {
messages.push({ role: "user", content: sanitizeContent(content) });
} else if (Array.isArray(content)) {
const textParts = content
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text);
if (textParts.length > 0) {
messages.push({ role: "user", content: sanitizeContent(textParts.join("\n")) });
}
}
} else if (msg.role === "assistant") {
const contentParts = msg.content ?? [];
const text = contentParts
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("");
const toolCall = contentParts.find((c): c is ToolCall => c.type === "toolCall");
if (toolCall && toolCall.name && functionsEnabled) {
messages.push({
role: "assistant",
content: text ? sanitizeContent(text) : "",
function_call: {
name: mapToolNameToGigaChat(toolCall.name),
arguments: toolCall.arguments ?? {},
},
});
} else if (text) {
messages.push({ role: "assistant", content: sanitizeContent(text) });
} else if (toolCall && !functionsEnabled) {
messages.push({ role: "assistant", content: `[Called ${toolCall.name}]` });
}
} else if (msg.role === "toolResult" && functionsEnabled) {
const toolName = msg.toolName ?? "unknown";
const msgContent = msg.content;
const resultContent = Array.isArray(msgContent)
? msgContent
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("\n")
: typeof msgContent === "string"
? msgContent
: JSON.stringify(msgContent ?? {});
const coercedContent = ensureJsonObjectStr(
sanitizeContent(resultContent || "ok"),
toolName,
);
messages.push({
role: "function",
content: coercedContent,
name: mapToolNameToGigaChat(toolName),
});
}
}
// Build functions with schema cleaning and name sanitization
const functions: GigaFunction[] = [];
if (functionsEnabled) {
for (const tool of context.tools ?? []) {
if (!tool.parameters) {
log.debug(`GigaChat: skipping tool "${tool.name}" (no parameters)`);
continue;
}
// Track schema modifications for debugging
const modifications: SchemaModifications = {
enumsTruncated: [],
nestedObjectsFlattened: [],
arrayItemsSimplified: [],
constraintsRemoved: [],
};
const cleanedParams = cleanSchemaForGigaChat(
tool.parameters,
0,
"",
modifications,
) as FunctionParameters;
logSchemaModifications(tool.name, modifications);
// Sanitize function name and map reserved names
const mappedName = mapToolNameToGigaChat(tool.name);
const sanitizedName = sanitizeFunctionName(mappedName);
if (sanitizedName !== tool.name) {
log.debug(`GigaChat: sanitized function name "${tool.name}" → "${sanitizedName}"`);
}
functions.push({
name: sanitizedName,
description: tool.description ?? "",
parameters: cleanedParams,
});
}
}
// Build auth config
const apiKey = options?.apiKey ?? "";
const isUserPassCredentials = apiKey.includes(":");
const clientConfig: GigaChatClientConfig = {
baseUrl: effectiveBaseUrl,
// Explicitly set to undefined to prevent the library from adding profanity_check
profanityCheck: undefined,
timeout: 120,
};
// Configure TLS
if (insecureTls) {
clientConfig.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
// Set credentials based on auth mode
if (isUserPassCredentials) {
const [user, password] = apiKey.split(":", 2);
clientConfig.user = user;
clientConfig.password = password;
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 client = new GigaChat(clientConfig);
// Build chat request - explicitly omit profanity_check
const chatRequest: Chat = {
model: model.id,
messages,
};
if (functions.length > 0 && functionsEnabled) {
chatRequest.functions = functions;
chatRequest.function_call = "auto";
}
if (typeof options?.maxTokens === "number") {
chatRequest.max_tokens = options.maxTokens;
}
if (typeof options?.temperature === "number" && options.temperature > 0) {
chatRequest.temperature = options.temperature;
} else {
chatRequest.top_p = 0;
}
log.debug(`GigaChat request: ${messages.length} messages, ${functions.length} functions`);
// Use the library for auth, but our own SSE parsing (library's parseChunk is buggy)
// Wrap token refresh in retry logic for transient failures
await withRetry(() => client.updateToken(), "token refresh");
const axiosClient = client._client;
// Access the token (protected property, so we cast)
const accessToken = (client as unknown as { _accessToken?: { access_token: string } })
._accessToken?.access_token;
if (!accessToken) {
throw new Error("GigaChat: failed to obtain access token after retries");
}
const requestId = randomUUID();
log.debug(`GigaChat request ${requestId}: starting`);
const response = await axiosClient.request({
method: "POST",
url: "/chat/completions",
data: { ...chatRequest, stream: true },
responseType: "stream",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "text/event-stream",
"Cache-Control": "no-store",
"X-Request-ID": requestId,
},
signal: options?.signal,
});
if (response.status !== 200) {
let errorText = "unknown error";
try {
if (typeof response.data === "string") {
errorText = response.data;
} else if (response.data && typeof response.data.pipe === "function") {
// It's a stream, try to read it
const chunks: Buffer[] = [];
for await (const chunk of response.data) {
chunks.push(chunk);
}
errorText = Buffer.concat(chunks).toString();
}
} catch {
errorText = `status ${response.status}`;
}
throw new Error(
`GigaChat API error ${response.status} (${effectiveBaseUrl}/chat/completions): ${errorText}`,
);
}
let accumulatedContent = "";
const accumulatedToolCalls: ToolCall[] = [];
let functionCallBuffer: { name: string; arguments: string } | null = null;
let promptTokens = 0;
let completionTokens = 0;
// Our own SSE parsing that handles `: ` in JSON correctly
let sseBuffer = "";
for await (const chunk of response.data) {
sseBuffer += chunk.toString();
const lines = sseBuffer.split("\n");
sseBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(":")) {
continue;
}
if (trimmed === "data: [DONE]") {
continue;
}
if (trimmed.startsWith("data: ")) {
// Fix: only split on first `: ` occurrence
const jsonStr = trimmed.slice(6); // Remove "data: " prefix
try {
const parsed = JSON.parse(jsonStr) as ExtendedChatCompletionChunk;
const choice = parsed.choices?.[0];
if (choice?.delta?.content) {
accumulatedContent += choice.delta.content;
}
if (choice?.delta?.function_call) {
if (!functionCallBuffer) {
functionCallBuffer = { name: "", arguments: "" };
}
if (choice.delta.function_call.name) {
functionCallBuffer.name += choice.delta.function_call.name;
}
if (choice.delta.function_call.arguments) {
const args = choice.delta.function_call.arguments;
functionCallBuffer.arguments +=
typeof args === "string" ? args : JSON.stringify(args);
}
}
if (parsed.usage) {
promptTokens = parsed.usage.prompt_tokens ?? 0;
completionTokens = parsed.usage.completion_tokens ?? 0;
}
} catch (e) {
log.warn(`Failed to parse SSE chunk: ${String(e)}`);
}
}
}
}
if (functionCallBuffer && functionCallBuffer.name) {
let parsedArgs: Record<string, unknown> = {};
try {
if (functionCallBuffer.arguments) {
parsedArgs = JSON.parse(functionCallBuffer.arguments) as Record<string, unknown>;
}
} catch (parseErr) {
const errMsg = parseErr instanceof Error ? parseErr.message : String(parseErr);
log.error(
`GigaChat: failed to parse function arguments for "${functionCallBuffer.name}": ${errMsg}. ` +
`Raw arguments: ${functionCallBuffer.arguments.slice(0, 500)}`,
);
// Return error instead of continuing with empty args
throw new Error(
`Failed to parse function call arguments for "${functionCallBuffer.name}": ${errMsg}`,
{ cause: parseErr },
);
}
const clientName = mapToolNameFromGigaChat(functionCallBuffer.name);
accumulatedToolCalls.push({
type: "toolCall",
id: randomUUID(),
name: clientName,
arguments: parsedArgs,
});
}
const content: AssistantMessage["content"] = [];
if (accumulatedContent) {
content.push({ type: "text", text: accumulatedContent });
}
for (const tc of accumulatedToolCalls) {
content.push(tc);
}
const stopReason: StopReason = accumulatedToolCalls.length > 0 ? "toolUse" : "stop";
// Warn if usage info is missing (common in streaming mode)
if (promptTokens === 0 && completionTokens === 0) {
log.debug(
`GigaChat request ${requestId}: no usage information returned (streaming mode may not include token counts)`,
);
}
const assistantMessage = buildStreamAssistantMessage({
model: { api: model.api, provider: model.provider, id: model.id },
content,
stopReason,
usage: buildUsageWithNoCost({
input: promptTokens,
output: completionTokens,
totalTokens: promptTokens + completionTokens,
}),
});
stream.push({
type: "done",
reason: stopReason === "toolUse" ? "toolUse" : "stop",
message: assistantMessage,
});
} catch (err) {
const errorMessage = extractGigaChatErrorMessage(err);
log.error(`GigaChat error: ${errorMessage}`);
stream.push({
type: "error",
reason: "error",
error: buildStreamErrorAssistantMessage({
model,
errorMessage,
}),
});
} finally {
stream.end();
}
};
queueMicrotask(() => void run());
return stream;
};
}

View File

@ -31,6 +31,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record<string, string[]> = {
synthetic: ["SYNTHETIC_API_KEY"],
venice: ["VENICE_API_KEY"],
mistral: ["MISTRAL_API_KEY"],
gigachat: ["GIGACHAT_CREDENTIALS"],
together: ["TOGETHER_API_KEY"],
qianfan: ["QIANFAN_API_KEY"],
modelstudio: ["MODELSTUDIO_API_KEY"],

View File

@ -34,6 +34,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
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 {
analyzeBootstrapBudget,
buildBootstrapPromptWarning,
@ -50,6 +51,7 @@ 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 { createGigachatStreamFn } from "../../gigachat-stream.js";
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import { normalizeProviderId, resolveDefaultModelForAgent } from "../../model-selection.js";
@ -1519,6 +1521,26 @@ export async function runEmbeddedAttempt(
});
activeSession.agent.streamFn = ollamaStreamFn;
ensureCustomApiRegistered(params.model.api, ollamaStreamFn);
} else if (normalizeProviderId(params.provider) === "gigachat") {
const providerConfig = params.config?.models?.providers?.[params.provider];
const baseUrl =
(typeof providerConfig?.baseUrl === "string" ? providerConfig.baseUrl : undefined) ??
(typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined) ??
process.env.GIGACHAT_BASE_URL?.trim() ??
"https://gigachat.devices.sberbank.ru/api/v1";
// Read GigaChat-specific config from auth profile credential metadata.
const gigachatStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const gigachatCred = gigachatStore.profiles["gigachat:default"];
const gigachatMeta = gigachatCred?.type === "api_key" ? gigachatCred.metadata : undefined;
const gigachatStreamFn = createGigachatStreamFn({
baseUrl,
authMode: (gigachatMeta?.authMode as "oauth" | "basic") ?? "oauth",
insecureTls: gigachatMeta?.insecureTls === "true",
scope: gigachatMeta?.scope,
});
activeSession.agent.streamFn = gigachatStreamFn;
} else if (params.model.api === "openai-responses" && params.provider === "openai") {
const wsApiKey = await params.authStorage.getApiKey(params.provider);
if (wsApiKey) {

View File

@ -22,6 +22,9 @@ describe("buildAuthChoiceOptions", () => {
for (const value of [
"github-copilot",
"token",
"gigachat-api-key",
"gigachat-personal",
"gigachat-business",
"zai-api-key",
"xiaomi-api-key",
"minimax-api",
@ -105,4 +108,16 @@ describe("buildAuthChoiceOptions", () => {
expect(ollamaGroup).toBeDefined();
expect(ollamaGroup?.options.some((opt) => opt.value === "ollama")).toBe(true);
});
it("shows GigaChat in grouped provider selection", () => {
const { groups } = buildAuthChoiceGroups({
store: EMPTY_STORE,
includeSkip: false,
});
const gigachatGroup = groups.find((group) => group.value === "gigachat");
expect(gigachatGroup).toBeDefined();
expect(gigachatGroup?.options.some((opt) => opt.value === "gigachat-personal")).toBe(true);
expect(gigachatGroup?.options.some((opt) => opt.value === "gigachat-business")).toBe(true);
});
});

View File

@ -83,6 +83,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "API key",
choices: ["mistral-api-key"],
},
{
value: "gigachat",
label: "GigaChat",
hint: "OAuth + Basic auth",
choices: ["gigachat-personal", "gigachat-business"],
},
{
value: "volcengine",
label: "Volcano Engine",
@ -216,6 +222,8 @@ const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial<Record<AuthChoice, string>> =
"cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway",
"opencode-zen": "OpenCode Zen catalog",
"opencode-go": "OpenCode Go catalog",
"gigachat-personal": "GigaChat Personal",
"gigachat-business": "GigaChat Business",
};
function buildProviderAuthChoiceOptions(): AuthChoiceOption[] {
@ -329,6 +337,16 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
label: "Coding Plan API Key for Global/Intl (subscription)",
hint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
},
{
value: "gigachat-personal",
label: "GigaChat Personal",
hint: "Individual developer account (OAuth)",
},
{
value: "gigachat-business",
label: "GigaChat Business",
hint: "Corporate account (OAuth or Basic auth)",
},
{ value: "custom-api-key", label: "Custom Provider" },
];

View File

@ -28,6 +28,8 @@ import {
applyKimiCodeProviderConfig,
applyLitellmConfig,
applyLitellmProviderConfig,
applyGigachatConfig,
applyGigachatProviderConfig,
applyMistralConfig,
applyMistralProviderConfig,
applyMoonshotConfig,
@ -56,6 +58,7 @@ import {
QIANFAN_DEFAULT_MODEL_REF,
KIMI_CODING_MODEL_REF,
MOONSHOT_DEFAULT_MODEL_REF,
GIGACHAT_DEFAULT_MODEL_REF,
MISTRAL_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_REF,
TOGETHER_DEFAULT_MODEL_REF,
@ -68,6 +71,7 @@ import {
setKilocodeApiKey,
setLitellmApiKey,
setKimiCodingApiKey,
setGigachatApiKey,
setMistralApiKey,
setMoonshotApiKey,
setOpencodeGoApiKey,
@ -107,6 +111,7 @@ const API_KEY_TOKEN_PROVIDER_AUTH_CHOICE: Record<string, AuthChoice> = {
together: "together-api-key",
huggingface: "huggingface-api-key",
mistral: "mistral-api-key",
gigachat: "gigachat-oauth",
opencode: "opencode-zen",
"opencode-go": "opencode-go",
kilocode: "kilocode-api-key",
@ -744,5 +749,175 @@ export async function applyAuthChoiceApiProviders(
return applyAuthChoiceHuggingface({ ...params, authChoice });
}
// Scope selected during personal/business flow, passed to gigachat-basic if needed
let gigachatBasicScope: string | undefined;
if (
authChoice === "gigachat-personal" ||
authChoice === "gigachat-business" ||
authChoice === "gigachat-oauth" ||
authChoice === "gigachat-api-key"
) {
const isPersonal =
authChoice === "gigachat-personal" ||
authChoice === "gigachat-oauth" ||
authChoice === "gigachat-api-key";
const accountLabel = isPersonal ? "Personal" : "Business";
// For business, ask billing type first, then auth method.
// For personal, scope is fixed (GIGACHAT_API_PERS).
let gigachatScope: string;
if (isPersonal) {
gigachatScope = "GIGACHAT_API_PERS";
} else {
const billingChoice = String(
await params.prompter.select({
message: "Select billing type",
options: [
{ value: "GIGACHAT_API_B2B", label: "Prepaid" },
{ value: "GIGACHAT_API_CORP", label: "Postpaid" },
],
}),
);
gigachatScope = billingChoice;
}
const selectedAuth = String(
await params.prompter.select({
message: `Select ${accountLabel} authentication method`,
options: [
{ value: "oauth", label: "OAuth", hint: "credentials key → access token (recommended)" },
{ value: "basic", label: "Basic auth", hint: "username + password + custom URL" },
],
}),
);
if (selectedAuth === "basic") {
authChoice = "gigachat-basic";
gigachatBasicScope = gigachatScope;
} else {
const gigachatMetadata: Record<string, string> = {
authMode: "oauth",
scope: gigachatScope,
insecureTls: "true",
};
await ensureApiKeyFromOptionEnvOrPrompt({
token: params.opts?.token,
provider: "gigachat",
tokenProvider: normalizedTokenProvider,
secretInputMode: requestedSecretInputMode,
config: nextConfig,
expectedProviders: ["gigachat"],
envLabel: "GIGACHAT_CREDENTIALS",
promptMessage: "Enter GigaChat credentials key (from developers.sber.ru/studio)",
setCredential: async (apiKey, mode) => {
await setGigachatApiKey(
apiKey,
params.agentDir,
{ secretInputMode: mode ?? requestedSecretInputMode },
gigachatMetadata,
);
},
noteMessage: [
`GigaChat ${accountLabel} (OAuth, ${gigachatScope}).`,
"Your credentials key will be exchanged for an access token automatically.",
"Get your key at: https://developers.sber.ru/studio/",
].join("\n"),
noteTitle: `GigaChat (${accountLabel})`,
// GigaChat credentials are base64-encoded (end with "==") — the default
// normalizeApiKeyInput false-matches the trailing "=" as a shell assignment.
normalize: (value) => String(value ?? "").trim(),
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
prompter: params.prompter,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "gigachat:default",
provider: "gigachat",
mode: "api_key",
});
await applyProviderDefaultModel({
defaultModel: GIGACHAT_DEFAULT_MODEL_REF,
applyDefaultConfig: applyGigachatConfig,
applyProviderConfig: applyGigachatProviderConfig,
noteDefault: GIGACHAT_DEFAULT_MODEL_REF,
});
return { config: nextConfig, agentModelOverride };
}
}
if (authChoice === "gigachat-basic") {
const envBaseUrl = process.env.GIGACHAT_BASE_URL?.trim() ?? "";
const envUser = process.env.GIGACHAT_USER?.trim() ?? "";
const envPassword = process.env.GIGACHAT_PASSWORD?.trim() ?? "";
let baseUrl = envBaseUrl;
if (!baseUrl) {
const baseUrlValue = await params.prompter.text({
message: "Enter GigaChat base URL",
initialValue: "https://gigachat.ift.sberdevices.ru/v1",
validate: (val) => (String(val ?? "").trim() ? undefined : "Base URL is required"),
});
baseUrl = String(baseUrlValue ?? "").trim();
}
let username = envUser;
if (!username) {
const usernameValue = await params.prompter.text({
message: "Enter GigaChat username",
validate: (val) => (String(val ?? "").trim() ? undefined : "Username is required"),
});
username = String(usernameValue ?? "").trim();
}
let password = envPassword;
if (!password) {
const passwordValue = await params.prompter.text({
message: "Enter GigaChat password",
validate: (val) => (String(val ?? "").trim() ? undefined : "Password is required"),
});
password = String(passwordValue ?? "").trim();
}
const basicMetadata: Record<string, string> = {
authMode: "basic",
insecureTls: "true",
...(gigachatBasicScope ? { scope: gigachatBasicScope } : {}),
};
await setGigachatApiKey(
`${username}:${password}`,
params.agentDir,
{ secretInputMode: requestedSecretInputMode },
basicMetadata,
);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "gigachat:default",
provider: "gigachat",
mode: "api_key",
});
await applyProviderDefaultModel({
defaultModel: GIGACHAT_DEFAULT_MODEL_REF,
applyDefaultConfig: (cfg) => applyGigachatConfig(cfg, { baseUrl }),
applyProviderConfig: (cfg) => applyGigachatProviderConfig(cfg, { baseUrl }),
noteDefault: GIGACHAT_DEFAULT_MODEL_REF,
});
await params.prompter.note(
[
"GigaChat (Basic auth).",
`Base URL: ${baseUrl}`,
`Username: ${username}`,
...(gigachatBasicScope ? [`Scope: ${gigachatBasicScope}`] : []),
].join("\n"),
"GigaChat configured",
);
return { config: nextConfig, agentModelOverride };
}
return null;
}

View File

@ -32,6 +32,7 @@ import type { OpenClawConfig } from "../config/config.js";
import type { ModelApi } from "../config/types.models.js";
import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js";
import {
GIGACHAT_DEFAULT_MODEL_REF,
HUGGINGFACE_DEFAULT_MODEL_REF,
KILOCODE_DEFAULT_MODEL_REF,
MISTRAL_DEFAULT_MODEL_REF,
@ -61,11 +62,14 @@ import {
applyProviderConfigWithModelCatalog,
} from "./onboard-auth.config-shared.js";
import {
buildGigachatModelDefinition,
buildMistralModelDefinition,
buildZaiModelDefinition,
buildMoonshotModelDefinition,
buildXaiModelDefinition,
buildModelStudioModelDefinition,
GIGACHAT_BASE_URL,
GIGACHAT_DEFAULT_MODEL_ID,
MISTRAL_BASE_URL,
MISTRAL_DEFAULT_MODEL_ID,
QIANFAN_BASE_URL,
@ -437,6 +441,36 @@ export function applyMistralConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(next, MISTRAL_DEFAULT_MODEL_REF);
}
export function applyGigachatProviderConfig(
cfg: OpenClawConfig,
opts?: { baseUrl?: string },
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[GIGACHAT_DEFAULT_MODEL_REF] = {
...models[GIGACHAT_DEFAULT_MODEL_REF],
alias: models[GIGACHAT_DEFAULT_MODEL_REF]?.alias ?? "GigaChat",
};
const defaultModel = buildGigachatModelDefinition();
return applyProviderConfigWithDefaultModel(cfg, {
agentModels: models,
providerId: "gigachat",
api: "openai-completions",
baseUrl: opts?.baseUrl ?? GIGACHAT_BASE_URL,
defaultModel,
defaultModelId: GIGACHAT_DEFAULT_MODEL_ID,
});
}
export function applyGigachatConfig(
cfg: OpenClawConfig,
opts?: { baseUrl?: string },
): OpenClawConfig {
const next = applyGigachatProviderConfig(cfg, opts);
return applyAgentDefaultModelPrimary(next, GIGACHAT_DEFAULT_MODEL_REF);
}
export { KILOCODE_BASE_URL };
/**

View File

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
setByteplusApiKey,
setCloudflareAiGatewayConfig,
setGigachatApiKey,
setMoonshotApiKey,
setOpencodeZenApiKey,
setOpenaiApiKey,
@ -230,4 +231,35 @@ describe("onboard auth credentials secret refs", () => {
keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" },
});
});
it("stores GigaChat metadata alongside an env-backed key ref", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-gigachat-");
lifecycle.setStateDir(env.stateDir);
process.env.GIGACHAT_CREDENTIALS = "gigachat-env-secret"; // pragma: allowlist secret
await setGigachatApiKey(
"gigachat-env-secret",
env.agentDir,
{ secretInputMode: "ref" },
{
authMode: "oauth",
scope: "GIGACHAT_API_PERS",
insecureTls: "false",
},
);
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown; metadata?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["gigachat:default"]).toMatchObject({
keyRef: { source: "env", provider: "default", id: "GIGACHAT_CREDENTIALS" },
metadata: {
authMode: "oauth",
scope: "GIGACHAT_API_PERS",
insecureTls: "false",
},
});
expect(parsed.profiles?.["gigachat:default"]?.key).toBeUndefined();
});
});

View File

@ -16,6 +16,7 @@ import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import type { SecretInputMode } from "./onboard-types.js";
export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js";
export {
GIGACHAT_DEFAULT_MODEL_REF,
MISTRAL_DEFAULT_MODEL_REF,
XAI_DEFAULT_MODEL_REF,
MODELSTUDIO_DEFAULT_MODEL_REF,
@ -527,7 +528,20 @@ export async function setMistralApiKey(
});
}
export async function setKilocodeApiKey(
export async function setGigachatApiKey(
key: SecretInput,
agentDir?: string,
options?: ApiKeyStorageOptions,
metadata?: Record<string, string>,
) {
upsertAuthProfile({
profileId: "gigachat:default",
credential: buildApiKeyCredential("gigachat", key, metadata, options),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export function setKilocodeApiKey(
key: SecretInput,
agentDir?: string,
options?: ApiKeyStorageOptions,

View File

@ -213,6 +213,30 @@ export function buildXaiModelDefinition(): ModelDefinitionConfig {
};
}
export const GIGACHAT_BASE_URL = "https://gigachat.devices.sberbank.ru/api/v1";
export const GIGACHAT_DEFAULT_MODEL_ID = "GigaChat-2-Max";
export const GIGACHAT_DEFAULT_MODEL_REF = `gigachat/${GIGACHAT_DEFAULT_MODEL_ID}`;
export const GIGACHAT_DEFAULT_CONTEXT_WINDOW = 128000;
export const GIGACHAT_DEFAULT_MAX_TOKENS = 8192;
export const GIGACHAT_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export function buildGigachatModelDefinition(): ModelDefinitionConfig {
return {
id: GIGACHAT_DEFAULT_MODEL_ID,
name: "GigaChat 2 Max",
reasoning: false,
input: ["text"],
cost: GIGACHAT_DEFAULT_COST,
contextWindow: GIGACHAT_DEFAULT_CONTEXT_WINDOW,
maxTokens: GIGACHAT_DEFAULT_MAX_TOKENS,
};
}
export function buildKilocodeModelDefinition(): ModelDefinitionConfig {
return {
id: KILOCODE_DEFAULT_MODEL_ID,

View File

@ -17,6 +17,8 @@ export {
applyKimiCodeProviderConfig,
applyLitellmConfig,
applyLitellmProviderConfig,
applyGigachatConfig,
applyGigachatProviderConfig,
applyMistralConfig,
applyMistralProviderConfig,
applyMoonshotConfig,
@ -79,6 +81,7 @@ export {
setLitellmApiKey,
setKimiCodingApiKey,
setMinimaxApiKey,
setGigachatApiKey,
setMistralApiKey,
setMoonshotApiKey,
setOpencodeGoApiKey,
@ -100,6 +103,7 @@ export {
XIAOMI_DEFAULT_MODEL_REF,
ZAI_DEFAULT_MODEL_REF,
TOGETHER_DEFAULT_MODEL_REF,
GIGACHAT_DEFAULT_MODEL_REF,
MISTRAL_DEFAULT_MODEL_REF,
XAI_DEFAULT_MODEL_REF,
MODELSTUDIO_DEFAULT_MODEL_REF,
@ -108,6 +112,7 @@ export {
buildKilocodeModelDefinition,
buildMinimaxApiModelDefinition,
buildMinimaxModelDefinition,
buildGigachatModelDefinition,
buildMistralModelDefinition,
buildMoonshotModelDefinition,
buildZaiModelDefinition,

View File

@ -13,6 +13,7 @@ type AuthChoiceFlagOptions = Pick<
| "geminiApiKey"
| "openaiApiKey"
| "mistralApiKey"
| "gigachatApiKey"
| "openrouterApiKey"
| "kilocodeApiKey"
| "aiGatewayApiKey"

View File

@ -5,6 +5,7 @@ type OnboardProviderAuthOptionKey = keyof Pick<
| "anthropicApiKey"
| "openaiApiKey"
| "mistralApiKey"
| "gigachatApiKey"
| "openrouterApiKey"
| "kilocodeApiKey"
| "aiGatewayApiKey"
@ -61,6 +62,13 @@ export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray<OnboardProviderAuthFlag>
cliOption: "--mistral-api-key <key>",
description: "Mistral API key",
},
{
optionKey: "gigachatApiKey",
authChoice: "gigachat-api-key",
cliFlag: "--gigachat-api-key",
cliOption: "--gigachat-api-key <key>",
description: "GigaChat credentials key",
},
{
optionKey: "openrouterApiKey",
authChoice: "openrouter-api-key",

View File

@ -48,6 +48,11 @@ export type AuthChoice =
| "qwen-portal"
| "xai-api-key"
| "mistral-api-key"
| "gigachat-oauth"
| "gigachat-api-key"
| "gigachat-personal"
| "gigachat-business"
| "gigachat-basic"
| "volcengine-api-key"
| "byteplus-api-key"
| "qianfan-api-key"
@ -76,6 +81,7 @@ export type AuthChoiceGroupId =
| "synthetic"
| "venice"
| "mistral"
| "gigachat"
| "qwen"
| "together"
| "huggingface"
@ -119,6 +125,7 @@ export type OnboardOptions = {
anthropicApiKey?: string;
openaiApiKey?: string;
mistralApiKey?: string;
gigachatApiKey?: string;
openrouterApiKey?: string;
kilocodeApiKey?: string;
litellmApiKey?: string;

View File

@ -31,4 +31,13 @@ describe("provider env vars", () => {
expect(env.Github_Token).toBeUndefined();
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
});
it("includes GigaChat credential env vars in the known secret lists", () => {
expect(listKnownSecretEnvVarNames()).toEqual(
expect.arrayContaining(["GIGACHAT_CREDENTIALS", "GIGACHAT_PASSWORD"]),
);
expect(listKnownProviderAuthEnvVarNames()).toEqual(
expect.arrayContaining(["GIGACHAT_CREDENTIALS", "GIGACHAT_PASSWORD"]),
);
});
});

View File

@ -21,6 +21,7 @@ export const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
qianfan: ["QIANFAN_API_KEY"],
xai: ["XAI_API_KEY"],
mistral: ["MISTRAL_API_KEY"],
gigachat: ["GIGACHAT_CREDENTIALS", "GIGACHAT_PASSWORD"],
kilocode: ["KILOCODE_API_KEY"],
modelstudio: ["MODELSTUDIO_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],