GigaChat: preserve OAuth keys and tool JSON

This commit is contained in:
Alexander Davydov 2026-03-20 10:26:44 +03:00
parent fb642a08e2
commit 5d553b19fe
8 changed files with 131 additions and 20 deletions

View File

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

View File

@ -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?: {

View File

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

View File

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

View File

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

View File

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

View File

@ -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();

View File

@ -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;