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:
parent
97683071b5
commit
b125d2e98b
@ -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 <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).
|
||||
|
||||
|
||||
@ -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
13
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
63
src/agents/gigachat-stream.leaked-prelude.test.ts
Normal file
63
src/agents/gigachat-stream.leaked-prelude.test.ts
Normal 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" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
153
src/agents/gigachat-stream.tool-calls.test.ts
Normal file
153
src/agents/gigachat-stream.tool-calls.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
764
src/agents/gigachat-stream.ts
Normal file
764
src/agents/gigachat-stream.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@ -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"],
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -13,6 +13,7 @@ type AuthChoiceFlagOptions = Pick<
|
||||
| "geminiApiKey"
|
||||
| "openaiApiKey"
|
||||
| "mistralApiKey"
|
||||
| "gigachatApiKey"
|
||||
| "openrouterApiKey"
|
||||
| "kilocodeApiKey"
|
||||
| "aiGatewayApiKey"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user