From 5d553b19feff615a3c247f16fd370f11d309ae49 Mon Sep 17 00:00:00 2001 From: Alexander Davydov Date: Fri, 20 Mar 2026 10:26:44 +0300 Subject: [PATCH] GigaChat: preserve OAuth keys and tool JSON --- src/agents/gigachat-auth.ts | 4 +- src/agents/gigachat-stream.test.ts | 5 ++ src/agents/gigachat-stream.tool-calls.test.ts | 5 +- src/agents/gigachat-stream.ts | 46 ++++++++++++------- .../models-config.providers.gigachat.test.ts | 6 +++ .../pi-embedded-runner/run/attempt.test.ts | 6 ++- src/commands/auth-choice.test.ts | 35 ++++++++++++++ .../auth-choice.api-key-providers.test.ts | 44 ++++++++++++++++++ 8 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/agents/gigachat-auth.ts b/src/agents/gigachat-auth.ts index 3a268214cb5..8378062bf41 100644 --- a/src/agents/gigachat-auth.ts +++ b/src/agents/gigachat-auth.ts @@ -24,7 +24,9 @@ function looksLikeGigachatBasicCredentials(apiKey: string | undefined): boolean return false; } const separatorIndex = trimmed.indexOf(":"); - return separatorIndex > 0; + // OAuth credential keys can legitimately contain additional ":" segments, so + // only infer Basic auth for the obvious single-separator user:password shape. + return separatorIndex > 0 && separatorIndex === trimmed.lastIndexOf(":"); } export function resolveGigachatAuthMode(params: { diff --git a/src/agents/gigachat-stream.test.ts b/src/agents/gigachat-stream.test.ts index 5502bf9bdc7..d49eeee7179 100644 --- a/src/agents/gigachat-stream.test.ts +++ b/src/agents/gigachat-stream.test.ts @@ -73,6 +73,11 @@ describe("gigachat stream helpers", () => { expect(ensureJsonObjectStr('{"ok":true}')).toBe('{"ok":true}'); }); + it("preserves valid JSON object tool results before sanitizing text", () => { + const content = '{"summary":"He said “hi” — then left"}'; + expect(ensureJsonObjectStr(content)).toBe(content); + }); + it("extracts readable API errors from GigaChat/Axios-like responses", () => { const err = new Error("[object Object]") as Error & { response?: { diff --git a/src/agents/gigachat-stream.tool-calls.test.ts b/src/agents/gigachat-stream.tool-calls.test.ts index dd0f2b697ef..f3510f29850 100644 --- a/src/agents/gigachat-stream.tool-calls.test.ts +++ b/src/agents/gigachat-stream.tool-calls.test.ts @@ -432,7 +432,7 @@ describe("createGigachatStreamFn tool calling", () => { ); }); - it("sanitizes historical assistant/tool result names in the outbound request", async () => { + it("sanitizes historical assistant/tool result names and preserves structured JSON tool results", async () => { request.mockResolvedValueOnce({ status: 200, data: createSseStream(['data: {"choices":[{"delta":{"content":"done"}}]}', "data: [DONE]"]), @@ -461,7 +461,7 @@ describe("createGigachatStreamFn tool calling", () => { { role: "toolResult", toolName: "llm-task", - content: "ok", + content: '{"summary":"He said “hi” — then left"}', }, ], tools: [ @@ -493,6 +493,7 @@ describe("createGigachatStreamFn tool calling", () => { expect.objectContaining({ role: "function", name: "llm_task", + content: '{"summary":"He said “hi” — then left"}', }), ], }), diff --git a/src/agents/gigachat-stream.ts b/src/agents/gigachat-stream.ts index eb5c3060763..2f98de6c535 100644 --- a/src/agents/gigachat-stream.ts +++ b/src/agents/gigachat-stream.ts @@ -301,6 +301,19 @@ function sanitizeContent(content: string | null | undefined): string { ); } +function tryParseJsonObjectString(content: string): string | null { + const trimmed = content.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? trimmed : null; + } catch { + return null; + } +} + /** * Coerce tool result content to a JSON object string (gpt2giga compatibility). * GigaChat expects tool results to be JSON objects. If the content is already @@ -310,19 +323,23 @@ function sanitizeContent(content: string | null | undefined): string { * 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"}"`); + const parsedOriginal = tryParseJsonObjectString(content); + if (parsedOriginal) { + return parsedOriginal; } - return JSON.stringify({ result: content }); + + const sanitized = sanitizeContent(content); + const parsedSanitized = sanitized === content ? null : tryParseJsonObjectString(sanitized); + if (parsedSanitized) { + return parsedSanitized; + } + + if (!content.trim().startsWith("{") || !content.trim().endsWith("}")) { + log.debug(`GigaChat: wrapping non-object tool result for "${toolName ?? "unknown"}"`); + } else { + log.debug(`GigaChat: wrapping invalid JSON-like tool result for "${toolName ?? "unknown"}"`); + } + return JSON.stringify({ result: sanitized }); } // ── Error message extraction ───────────────────────────────────────────────── @@ -663,10 +680,7 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { : typeof msgContent === "string" ? msgContent : JSON.stringify(msgContent ?? {}); - const coercedContent = ensureJsonObjectStr( - sanitizeContent(resultContent || "ok"), - toolName, - ); + const coercedContent = ensureJsonObjectStr(resultContent || "ok", toolName); const gigaToolName = rememberToolNameMapping(toolNameToGiga, gigaToToolName, toolName); messages.push({ role: "function", diff --git a/src/agents/models-config.providers.gigachat.test.ts b/src/agents/models-config.providers.gigachat.test.ts index f9a98661aba..9d4814fbf4f 100644 --- a/src/agents/models-config.providers.gigachat.test.ts +++ b/src/agents/models-config.providers.gigachat.test.ts @@ -15,6 +15,12 @@ describe("GigaChat implicit provider", () => { ); }); + it("keeps the OAuth default host for OAuth credentials keys that contain colons", async () => { + expect(resolveImplicitGigachatBaseUrl({ apiKey: "oauth:credential:with:colon" })).toBe( + GIGACHAT_BASE_URL, + ); + }); + it("honors GIGACHAT_BASE_URL for implicit providers", async () => { expect( resolveImplicitGigachatBaseUrl({ diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index e66824c48ae..a78f42e9920 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -207,10 +207,14 @@ describe("resolveGigachatAuthProfileMetadata", () => { }); describe("resolveGigachatAuthMode", () => { - it("infers basic auth for env-backed combined credentials without profile metadata", () => { + it("infers basic auth for single-separator combined credentials without profile metadata", () => { expect(resolveGigachatAuthMode({ apiKey: "user:password" })).toBe("basic"); }); + it("keeps oauth as the fallback for colon-containing credentials keys", () => { + expect(resolveGigachatAuthMode({ apiKey: "oauth:credential:with:colon" })).toBe("oauth"); + }); + it("keeps oauth as the fallback when a profile is selected but has no metadata", () => { expect( resolveGigachatAuthMode({ diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 3cceb592319..2df356da6f1 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -383,6 +383,41 @@ describe("applyAuthChoice", () => { await expect(readAuthProfile("gigachat:default")).rejects.toThrow(); }); + it("accepts OAuth GigaChat credentials keys that contain colons", async () => { + await setupTempState(); + + delete process.env.GIGACHAT_CREDENTIALS; + delete process.env.GIGACHAT_USER; + delete process.env.GIGACHAT_PASSWORD; + delete process.env.GIGACHAT_BASE_URL; + + const { prompter, runtime } = createApiKeyPromptHarness(); + + const result = await applyAuthChoice({ + authChoice: "gigachat-personal", + config: {}, + prompter, + runtime, + setDefaultModel: false, + opts: { gigachatApiKey: "oauth:credential:with:colon" }, + }); + + expect(result.config.auth?.profiles?.["gigachat:default"]).toMatchObject({ + provider: "gigachat", + mode: "api_key", + }); + const profile = await readAuthProfile("gigachat:default"); + expect(profile).toMatchObject({ + type: "api_key", + provider: "gigachat", + metadata: { + authMode: "oauth", + scope: "GIGACHAT_API_PERS", + insecureTls: "false", + }, + }); + }); + it("resets a custom Basic GigaChat base URL when switching to OAuth", async () => { await setupTempState(); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts index 8ee246d6703..28e3aef178f 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts @@ -170,6 +170,50 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + it("accepts OAuth credentials keys that contain colons", async () => { + const agentDir = "/tmp/openclaw-agents/work/agent"; + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + const runtime: RuntimeEnv = { + error: vi.fn(), + exit: vi.fn(), + log: vi.fn(), + }; + const resolveApiKey = vi.fn(async () => ({ + key: "oauth:credential:with:colon", + source: "env" as const, + })); + const maybeSetResolvedApiKey = vi.fn(async (resolved, setter) => { + await setter(resolved.key); + return true; + }); + + const result = await applySimpleNonInteractiveApiKeyChoice({ + authChoice: "gigachat-api-key", + nextConfig, + baseConfig: nextConfig, + opts: {} as never, + runtime, + agentDir, + apiKeyStorageOptions: undefined, + resolveApiKey, + maybeSetResolvedApiKey, + }); + + expect(result).toBe(nextConfig); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + expect(setGigachatApiKey).toHaveBeenCalledWith( + "oauth:credential:with:colon", + agentDir, + undefined, + { + authMode: "oauth", + insecureTls: "false", + scope: "GIGACHAT_API_PERS", + }, + ); + }); + it("resets the GigaChat provider base URL when replacing a Basic profile with OAuth", async () => { const agentDir = "/tmp/openclaw-agents/work/agent"; const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;