From 5629ad8360b288eb4431619868881ff3d230c228 Mon Sep 17 00:00:00 2001 From: Alexander Davydov Date: Thu, 12 Mar 2026 19:27:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20GigaChat=20provider=20=E2=80=94?= =?UTF-8?q?=20streaming,=20auth,=20and=20onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/concepts/model-providers.md | 23 + package.json | 1 + pnpm-lock.yaml | 13 + scripts/docker/install-sh-nonroot/run.sh | 36 - src/agents/gigachat-stream.ts | 764 ++++++++++++++++++ src/agents/model-auth-env-vars.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 22 + src/commands/auth-choice-options.test.ts | 15 + src/commands/auth-choice-options.ts | 18 + .../auth-choice.apply.api-providers.ts | 175 ++++ src/commands/onboard-auth.config-core.ts | 34 + src/commands/onboard-auth.credentials.test.ts | 32 + src/commands/onboard-auth.credentials.ts | 16 +- src/commands/onboard-auth.models.ts | 24 + src/commands/onboard-auth.ts | 5 + .../local/auth-choice-inference.ts | 1 + src/commands/onboard-provider-auth-flags.ts | 8 + src/commands/onboard-types.ts | 7 + src/secrets/provider-env-vars.test.ts | 9 + src/secrets/provider-env-vars.ts | 1 + 20 files changed, 1168 insertions(+), 37 deletions(-) delete mode 100644 scripts/docker/install-sh-nonroot/run.sh create mode 100644 src/agents/gigachat-stream.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 549875c77b4..e21bc3e6610 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -100,6 +100,28 @@ OpenClaw ships with the pi‑ai 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 ` +- 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). diff --git a/package.json b/package.json index 9c1100bc49f..1efe1dba442 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e26495971c..d2adfb7a496 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/docker/install-sh-nonroot/run.sh b/scripts/docker/install-sh-nonroot/run.sh deleted file mode 100644 index 787bfc8e809..00000000000 --- a/scripts/docker/install-sh-nonroot/run.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}" -DEFAULT_PACKAGE="openclaw" -PACKAGE_NAME="${OPENCLAW_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# shellcheck source=../install-sh-common/cli-verify.sh -source "$SCRIPT_DIR/../install-sh-common/cli-verify.sh" - -echo "==> Pre-flight: ensure git absent" -if command -v git >/dev/null; then - echo "git is present unexpectedly" >&2 - exit 1 -fi - -echo "==> Run installer (non-root user)" -curl -fsSL "$INSTALL_URL" | bash - -# Ensure PATH picks up user npm prefix -export PATH="$HOME/.npm-global/bin:$PATH" - -echo "==> Verify git installed" -command -v git >/dev/null - -EXPECTED_VERSION="${OPENCLAW_INSTALL_EXPECT_VERSION:-}" -if [[ -n "$EXPECTED_VERSION" ]]; then - LATEST_VERSION="$EXPECTED_VERSION" -else - LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)" -fi -echo "==> Verify CLI installed" -verify_installed_cli "$PACKAGE_NAME" "$LATEST_VERSION" - -echo "OK" diff --git a/src/agents/gigachat-stream.ts b/src/agents/gigachat-stream.ts new file mode 100644 index 00000000000..f9335e94a62 --- /dev/null +++ b/src/agents/gigachat-stream.ts @@ -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 = { + web_search: "__gpt2giga_user_search_web", +}; + +const RESERVED_NAME_GIGA_TO_CLIENT: Record = 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; + + // 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 = {}; + + 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; + const cleanedProps: Record = {}; + 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; + // 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).message === "string" + ? ((data.error as Record).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; + 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( + operation: () => Promise, + operationName: string, + maxRetries = MAX_RETRIES, +): Promise { + 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 = {}; + try { + if (functionCallBuffer.arguments) { + parsedArgs = JSON.parse(functionCallBuffer.arguments) as Record; + } + } 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; + }; +} diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index fbe5a78917d..6de55ecf6e0 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -31,6 +31,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { 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"], diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 2f77b46aff5..da9d5dc8d3c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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) { diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 462dbb32d11..5c982643997 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -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); + }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 077fee024b9..f594052d4d5 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -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> = "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 = [ 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" }, ]; diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 9e7419f7fda..df3e9f97d72 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -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 = { 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 = { + 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 = { + 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; } diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 4bda29df1bf..95228c3e4ad 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -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 }; /** diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts index e844ac501c2..73ed636a913 100644 --- a/src/commands/onboard-auth.credentials.test.ts +++ b/src/commands/onboard-auth.credentials.test.ts @@ -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; + }>(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(); + }); }); diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 92e1170b010..3cb459ad614 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -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, +) { + upsertAuthProfile({ + profileId: "gigachat:default", + credential: buildApiKeyCredential("gigachat", key, metadata, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + +export function setKilocodeApiKey( key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions, diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index 2945e7b4461..754195534b0 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -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, diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index cda460b6c19..d68807f3342 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -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, diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts index 212bb9dd890..1b8d166154c 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -13,6 +13,7 @@ type AuthChoiceFlagOptions = Pick< | "geminiApiKey" | "openaiApiKey" | "mistralApiKey" + | "gigachatApiKey" | "openrouterApiKey" | "kilocodeApiKey" | "aiGatewayApiKey" diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts index 7610727097f..743f810127a 100644 --- a/src/commands/onboard-provider-auth-flags.ts +++ b/src/commands/onboard-provider-auth-flags.ts @@ -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 cliOption: "--mistral-api-key ", description: "Mistral API key", }, + { + optionKey: "gigachatApiKey", + authChoice: "gigachat-api-key", + cliFlag: "--gigachat-api-key", + cliOption: "--gigachat-api-key ", + description: "GigaChat credentials key", + }, { optionKey: "openrouterApiKey", authChoice: "openrouter-api-key", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 40a02e85c15..850cfb39542 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -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; diff --git a/src/secrets/provider-env-vars.test.ts b/src/secrets/provider-env-vars.test.ts index 6e5b78f6643..0412d896a45 100644 --- a/src/secrets/provider-env-vars.test.ts +++ b/src/secrets/provider-env-vars.test.ts @@ -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"]), + ); + }); }); diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 88900893376..b168c2fba8f 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -21,6 +21,7 @@ export const PROVIDER_ENV_VARS: Record = { 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"],