GigaChat: preserve OAuth keys and tool JSON
This commit is contained in:
parent
fb642a08e2
commit
5d553b19fe
@ -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: {
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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"}',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user