GigaChat: reuse OAuth profiles and keep prompts exact

This commit is contained in:
Alexander Davydov 2026-03-20 11:09:17 +03:00
parent 5d553b19fe
commit 302201526e
4 changed files with 97 additions and 22 deletions

View File

@ -502,6 +502,57 @@ describe("createGigachatStreamFn tool calling", () => {
expect(event.content).toEqual([{ type: "text", text: "done" }]);
});
it("preserves exact Unicode punctuation in system, user, and assistant history text", async () => {
request.mockResolvedValueOnce({
status: 200,
data: createSseStream(['data: {"choices":[{"delta":{"content":"done"}}]}', "data: [DONE]"]),
});
const systemPrompt = "Keep “curly quotes”, em dashes —, NBSP\u00A0gaps, and ellipses…";
const userText = "User asked to preserve “exact” punctuation — including\u00A0spacing…";
const assistantText = "Assistant replied with “quoted” text — unchanged\u00A0too…";
const streamFn = createGigachatStreamFn({
baseUrl: "https://gigachat.devices.sberbank.ru/api/v1",
authMode: "oauth",
});
const stream = await streamFn(
{ api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never,
{
systemPrompt,
messages: [
{
role: "user",
content: userText,
},
{
role: "assistant",
content: [{ type: "text", text: assistantText }],
},
],
tools: [],
} as never,
{ apiKey: "token" } as never,
);
await expect(stream.result()).resolves.toMatchObject({
content: [{ type: "text", text: "done" }],
});
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
messages: [
expect.objectContaining({ role: "system", content: systemPrompt }),
expect.objectContaining({ role: "user", content: userText }),
expect.objectContaining({ role: "assistant", content: assistantText }),
],
}),
}),
);
});
it("preserves all historical tool calls from a single assistant turn", async () => {
request.mockResolvedValueOnce({
status: 200,

View File

@ -293,11 +293,6 @@ function sanitizeContent(content: string | null | undefined): string {
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, "...")
);
}

View File

@ -43,14 +43,17 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
applyLitellmConfig.mockImplementation((cfg: OpenClawConfig) => cfg);
});
it("disables profile fallback for GigaChat personal OAuth onboarding", async () => {
it("allows stored OAuth profile fallback for GigaChat personal OAuth onboarding", async () => {
const agentDir = "/tmp/openclaw-agents/work/agent";
const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;
const resolveApiKey = vi.fn(async () => ({
key: "gigachat-oauth-credentials",
source: "env" as const,
source: "profile" as const,
}));
const maybeSetResolvedApiKey = vi.fn(async (resolved, setter) => {
if (resolved.source === "profile") {
return true;
}
await setter(resolved.key);
return true;
});
@ -73,20 +76,11 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
flagName: "--gigachat-api-key",
envVar: "GIGACHAT_CREDENTIALS",
agentDir,
allowProfile: false,
allowProfile: true,
}),
);
expect(maybeSetResolvedApiKey).toHaveBeenCalledOnce();
expect(setGigachatApiKey).toHaveBeenCalledWith(
"gigachat-oauth-credentials",
agentDir,
undefined,
{
authMode: "oauth",
insecureTls: "false",
scope: "GIGACHAT_API_PERS",
},
);
expect(setGigachatApiKey).not.toHaveBeenCalled();
});
it("accepts the generic --token input for GigaChat non-interactive OAuth", async () => {
@ -120,7 +114,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
flagName: "--gigachat-api-key",
envVar: "GIGACHAT_CREDENTIALS",
agentDir,
allowProfile: false,
allowProfile: true,
}),
);
expect(setGigachatApiKey).toHaveBeenCalledWith(
@ -170,6 +164,41 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("rejects Basic-shaped stored profiles in the OAuth onboarding path", 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: "basic-user:basic-pass",
source: "profile" as const,
}));
const maybeSetResolvedApiKey = vi.fn();
const result = await applySimpleNonInteractiveApiKeyChoice({
authChoice: "gigachat-api-key",
nextConfig,
baseConfig: nextConfig,
opts: {} as never,
runtime,
agentDir,
apiKeyStorageOptions: undefined,
resolveApiKey,
maybeSetResolvedApiKey,
});
expect(result).toBeNull();
expect(maybeSetResolvedApiKey).not.toHaveBeenCalled();
expect(setGigachatApiKey).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Basic user:password credentials"),
);
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;

View File

@ -59,9 +59,9 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: {
envVar: "GIGACHAT_CREDENTIALS",
runtime: params.runtime,
agentDir: params.agentDir,
// Personal OAuth onboarding must not silently reuse an existing Basic
// username:password profile and then rewrite the provider to OAuth config.
allowProfile: false,
// Allow existing OAuth profiles to be reused, but reject Basic-shaped
// credentials below before any OAuth metadata/config rewrite happens.
allowProfile: true,
});
if (!resolved) {
return null;