Agents: sanitize OpenRouter Gemini thoughtSignature
This commit is contained in:
committed by
Peter Steinberger
parent
d42b69df74
commit
ef36e24522
@@ -143,6 +143,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||||
|
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841)
|
||||||
|
|
||||||
## 2026.1.12-1
|
## 2026.1.12-1
|
||||||
|
|
||||||
|
|||||||
755
src/agents/pi-embedded-helpers.test.ts
Normal file
755
src/agents/pi-embedded-helpers.test.ts
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
buildBootstrapContextFiles,
|
||||||
|
classifyFailoverReason,
|
||||||
|
DEFAULT_BOOTSTRAP_MAX_CHARS,
|
||||||
|
formatAssistantErrorText,
|
||||||
|
isAuthErrorMessage,
|
||||||
|
isBillingErrorMessage,
|
||||||
|
isCloudCodeAssistFormatError,
|
||||||
|
isCompactionFailureError,
|
||||||
|
isContextOverflowError,
|
||||||
|
isFailoverErrorMessage,
|
||||||
|
isMessagingToolDuplicate,
|
||||||
|
normalizeTextForComparison,
|
||||||
|
resolveBootstrapMaxChars,
|
||||||
|
sanitizeGoogleTurnOrdering,
|
||||||
|
sanitizeSessionMessagesImages,
|
||||||
|
sanitizeToolCallId,
|
||||||
|
stripThoughtSignatures,
|
||||||
|
} from "./pi-embedded-helpers.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_AGENTS_FILENAME,
|
||||||
|
type WorkspaceBootstrapFile,
|
||||||
|
} from "./workspace.js";
|
||||||
|
|
||||||
|
const makeFile = (
|
||||||
|
overrides: Partial<WorkspaceBootstrapFile>,
|
||||||
|
): WorkspaceBootstrapFile => ({
|
||||||
|
name: DEFAULT_AGENTS_FILENAME,
|
||||||
|
path: "/tmp/AGENTS.md",
|
||||||
|
content: "",
|
||||||
|
missing: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
describe("buildBootstrapContextFiles", () => {
|
||||||
|
it("keeps missing markers", () => {
|
||||||
|
const files = [makeFile({ missing: true, content: undefined })];
|
||||||
|
expect(buildBootstrapContextFiles(files)).toEqual([
|
||||||
|
{
|
||||||
|
path: DEFAULT_AGENTS_FILENAME,
|
||||||
|
content: "[MISSING] Expected at: /tmp/AGENTS.md",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty or whitespace-only content", () => {
|
||||||
|
const files = [makeFile({ content: " \n " })];
|
||||||
|
expect(buildBootstrapContextFiles(files)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates large bootstrap content", () => {
|
||||||
|
const head = `HEAD-${"a".repeat(600)}`;
|
||||||
|
const tail = `${"b".repeat(300)}-TAIL`;
|
||||||
|
const long = `${head}${tail}`;
|
||||||
|
const files = [makeFile({ name: "TOOLS.md", content: long })];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const maxChars = 200;
|
||||||
|
const expectedTailChars = Math.floor(maxChars * 0.2);
|
||||||
|
const [result] = buildBootstrapContextFiles(files, {
|
||||||
|
maxChars,
|
||||||
|
warn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
expect(result?.content).toContain(
|
||||||
|
"[...truncated, read TOOLS.md for full content...]",
|
||||||
|
);
|
||||||
|
expect(result?.content.length).toBeLessThan(long.length);
|
||||||
|
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
|
||||||
|
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
|
||||||
|
expect(warnings).toHaveLength(1);
|
||||||
|
expect(warnings[0]).toContain("TOOLS.md");
|
||||||
|
expect(warnings[0]).toContain("limit 200");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content under the default limit", () => {
|
||||||
|
const long = "a".repeat(DEFAULT_BOOTSTRAP_MAX_CHARS - 10);
|
||||||
|
const files = [makeFile({ content: long })];
|
||||||
|
const [result] = buildBootstrapContextFiles(files);
|
||||||
|
expect(result?.content).toBe(long);
|
||||||
|
expect(result?.content).not.toContain(
|
||||||
|
"[...truncated, read AGENTS.md for full content...]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveBootstrapMaxChars", () => {
|
||||||
|
it("returns default when unset", () => {
|
||||||
|
expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured value when valid", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: { defaults: { bootstrapMaxChars: 12345 } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveBootstrapMaxChars(cfg)).toBe(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when invalid", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: { defaults: { bootstrapMaxChars: -1 } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isContextOverflowError", () => {
|
||||||
|
it("matches known overflow hints", () => {
|
||||||
|
const samples = [
|
||||||
|
"request_too_large",
|
||||||
|
"Request exceeds the maximum size",
|
||||||
|
"context length exceeded",
|
||||||
|
"Maximum context length",
|
||||||
|
"prompt is too long: 208423 tokens > 200000 maximum",
|
||||||
|
"Context overflow: Summarization failed",
|
||||||
|
"413 Request Entity Too Large",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isContextOverflowError(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated errors", () => {
|
||||||
|
expect(isContextOverflowError("rate limit exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isCompactionFailureError", () => {
|
||||||
|
it("matches compaction overflow failures", () => {
|
||||||
|
const samples = [
|
||||||
|
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
|
||||||
|
"auto-compaction failed due to context overflow",
|
||||||
|
"Compaction failed: prompt is too long",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isCompactionFailureError(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-compaction overflow errors", () => {
|
||||||
|
expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isCompactionFailureError("rate limit exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBillingErrorMessage", () => {
|
||||||
|
it("matches credit / payment failures", () => {
|
||||||
|
const samples = [
|
||||||
|
"Your credit balance is too low to access the Anthropic API.",
|
||||||
|
"insufficient credits",
|
||||||
|
"Payment Required",
|
||||||
|
"HTTP 402 Payment Required",
|
||||||
|
"plans & billing",
|
||||||
|
"billing: please upgrade your plan",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isBillingErrorMessage(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated errors", () => {
|
||||||
|
expect(isBillingErrorMessage("rate limit exceeded")).toBe(false);
|
||||||
|
expect(isBillingErrorMessage("invalid api key")).toBe(false);
|
||||||
|
expect(isBillingErrorMessage("context length exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAuthErrorMessage", () => {
|
||||||
|
it("matches credential validation errors", () => {
|
||||||
|
const samples = [
|
||||||
|
'No credentials found for profile "anthropic:claude-cli".',
|
||||||
|
"No API key found for profile openai.",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isAuthErrorMessage(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated errors", () => {
|
||||||
|
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
|
||||||
|
expect(isAuthErrorMessage("billing issue detected")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isFailoverErrorMessage", () => {
|
||||||
|
it("matches auth/rate/billing/timeout", () => {
|
||||||
|
const samples = [
|
||||||
|
"invalid api key",
|
||||||
|
"429 rate limit exceeded",
|
||||||
|
"Your credit balance is too low",
|
||||||
|
"request timed out",
|
||||||
|
"invalid request format",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isFailoverErrorMessage(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyFailoverReason", () => {
|
||||||
|
it("returns a stable reason", () => {
|
||||||
|
expect(classifyFailoverReason("invalid api key")).toBe("auth");
|
||||||
|
expect(classifyFailoverReason("no credentials found")).toBe("auth");
|
||||||
|
expect(classifyFailoverReason("no api key found")).toBe("auth");
|
||||||
|
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
|
||||||
|
expect(classifyFailoverReason("resource has been exhausted")).toBe(
|
||||||
|
"rate_limit",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
classifyFailoverReason(
|
||||||
|
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
|
||||||
|
),
|
||||||
|
).toBe("rate_limit");
|
||||||
|
expect(classifyFailoverReason("invalid request format")).toBe("format");
|
||||||
|
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
|
||||||
|
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
||||||
|
expect(classifyFailoverReason("string should match pattern")).toBe(
|
||||||
|
"format",
|
||||||
|
);
|
||||||
|
expect(classifyFailoverReason("bad request")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies OpenAI usage limit errors as rate_limit", () => {
|
||||||
|
expect(
|
||||||
|
classifyFailoverReason(
|
||||||
|
"You have hit your ChatGPT usage limit (plus plan)",
|
||||||
|
),
|
||||||
|
).toBe("rate_limit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isCloudCodeAssistFormatError", () => {
|
||||||
|
it("matches format errors", () => {
|
||||||
|
const samples = [
|
||||||
|
"INVALID_REQUEST_ERROR: string should match pattern",
|
||||||
|
"messages.1.content.1.tool_use.id",
|
||||||
|
"tool_use.id should match pattern",
|
||||||
|
"invalid request format",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(isCloudCodeAssistFormatError(sample)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated errors", () => {
|
||||||
|
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatAssistantErrorText", () => {
|
||||||
|
const makeAssistantError = (errorMessage: string): AssistantMessage =>
|
||||||
|
({
|
||||||
|
stopReason: "error",
|
||||||
|
errorMessage,
|
||||||
|
}) as AssistantMessage;
|
||||||
|
|
||||||
|
it("returns a friendly message for context overflow", () => {
|
||||||
|
const msg = makeAssistantError("request_too_large");
|
||||||
|
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly message for Anthropic role ordering", () => {
|
||||||
|
const msg = makeAssistantError(
|
||||||
|
'messages: roles must alternate between "user" and "assistant"',
|
||||||
|
);
|
||||||
|
expect(formatAssistantErrorText(msg)).toContain(
|
||||||
|
"Message ordering conflict",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly message for Anthropic overload errors", () => {
|
||||||
|
const msg = makeAssistantError(
|
||||||
|
'{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_123"}',
|
||||||
|
);
|
||||||
|
expect(formatAssistantErrorText(msg)).toBe(
|
||||||
|
"The AI service is temporarily overloaded. Please try again in a moment.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeToolCallId", () => {
|
||||||
|
it("keeps valid tool call IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces invalid characters with underscores", () => {
|
||||||
|
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default for empty IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("")).toBe("default_tool_id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeGoogleTurnOrdering", () => {
|
||||||
|
it("prepends a synthetic user turn when history starts with assistant", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call_1", name: "exec", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = sanitizeGoogleTurnOrdering(input);
|
||||||
|
expect(out[0]?.role).toBe("user");
|
||||||
|
expect(out[1]?.role).toBe("assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when history starts with user", () => {
|
||||||
|
const input = [{ role: "user", content: "hi" }] satisfies AgentMessage[];
|
||||||
|
const out = sanitizeGoogleTurnOrdering(input);
|
||||||
|
expect(out).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeSessionMessagesImages", () => {
|
||||||
|
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
const content = (out[0] as { content?: unknown }).content;
|
||||||
|
expect(Array.isArray(content)).toBe(true);
|
||||||
|
expect(content).toHaveLength(1);
|
||||||
|
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes tool ids for assistant blocks and tool results when enabled", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolUse", id: "call_abc|item:123", name: "test", input: {} },
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_abc|item:456",
|
||||||
|
name: "exec",
|
||||||
|
arguments: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolUseId: "call_abc|item:123",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistant = out[0] as { content?: Array<{ id?: string }> };
|
||||||
|
expect(assistant.content?.[0]?.id).toBe("call_abc_item_123");
|
||||||
|
expect(assistant.content?.[1]?.id).toBe("call_abc_item_456");
|
||||||
|
|
||||||
|
const toolResult = out[1] as { toolUseId?: string };
|
||||||
|
expect(toolResult.toolUseId).toBe("call_abc_item_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters whitespace-only assistant text blocks", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: " " },
|
||||||
|
{ type: "text", text: "ok" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
const content = (out[0] as { content?: unknown }).content;
|
||||||
|
expect(Array.isArray(content)).toBe(true);
|
||||||
|
expect(content).toHaveLength(1);
|
||||||
|
expect((content as Array<{ text?: string }>)[0]?.text).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops assistant messages that only contain empty text", async () => {
|
||||||
|
const input = [
|
||||||
|
{ role: "user", content: "hello" },
|
||||||
|
{ role: "assistant", content: [{ type: "text", text: "" }] },
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]?.role).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty assistant error messages", async () => {
|
||||||
|
const input = [
|
||||||
|
{ role: "user", content: "hello" },
|
||||||
|
{ role: "assistant", stopReason: "error", content: [] },
|
||||||
|
{ role: "assistant", stopReason: "error" },
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]?.role).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-assistant messages unchanged", async () => {
|
||||||
|
const input = [
|
||||||
|
{ role: "user", content: "hello" },
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
content: [{ type: "text", text: "result" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
expect(out[0]?.role).toBe("user");
|
||||||
|
expect(out[1]?.role).toBe("toolResult");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps tool call + tool result IDs unchanged by default", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_123|fc_456",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "package.json" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_123|fc_456",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
|
const assistant = out[0] as unknown as { role?: string; content?: unknown };
|
||||||
|
expect(assistant.role).toBe("assistant");
|
||||||
|
expect(Array.isArray(assistant.content)).toBe(true);
|
||||||
|
const toolCall = (
|
||||||
|
assistant.content as Array<{ type?: string; id?: string }>
|
||||||
|
).find((b) => b.type === "toolCall");
|
||||||
|
expect(toolCall?.id).toBe("call_123|fc_456");
|
||||||
|
|
||||||
|
const toolResult = out[1] as unknown as {
|
||||||
|
role?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
};
|
||||||
|
expect(toolResult.role).toBe("toolResult");
|
||||||
|
expect(toolResult.toolCallId).toBe("call_123|fc_456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes tool call + tool result IDs when enabled", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_123|fc_456",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "package.json" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_123|fc_456",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistant = out[0] as unknown as { role?: string; content?: unknown };
|
||||||
|
expect(assistant.role).toBe("assistant");
|
||||||
|
expect(Array.isArray(assistant.content)).toBe(true);
|
||||||
|
const toolCall = (
|
||||||
|
assistant.content as Array<{ type?: string; id?: string }>
|
||||||
|
).find((b) => b.type === "toolCall");
|
||||||
|
expect(toolCall?.id).toBe("call_123_fc_456");
|
||||||
|
|
||||||
|
const toolResult = out[1] as unknown as {
|
||||||
|
role?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
};
|
||||||
|
expect(toolResult.role).toBe("toolResult");
|
||||||
|
expect(toolResult.toolCallId).toBe("call_123_fc_456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops assistant blocks after a tool call when enforceToolCallLast is enabled", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "before" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
{ type: "thinking", thinking: "after", thinkingSignature: "sig" },
|
||||||
|
{ type: "text", text: "after text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||||
|
enforceToolCallLast: true,
|
||||||
|
});
|
||||||
|
const assistant = out[0] as { content?: Array<{ type?: string }> };
|
||||||
|
expect(assistant.content?.map((b) => b.type)).toEqual(["text", "toolCall"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps assistant blocks after a tool call when enforceToolCallLast is disabled", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "before" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
{ type: "thinking", thinking: "after", thinkingSignature: "sig" },
|
||||||
|
{ type: "text", text: "after text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
const assistant = out[0] as { content?: Array<{ type?: string }> };
|
||||||
|
expect(assistant.content?.map((b) => b.type)).toEqual([
|
||||||
|
"text",
|
||||||
|
"toolCall",
|
||||||
|
"thinking",
|
||||||
|
"text",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeTextForComparison", () => {
|
||||||
|
it("lowercases text", () => {
|
||||||
|
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace", () => {
|
||||||
|
expect(normalizeTextForComparison(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses multiple spaces", () => {
|
||||||
|
expect(normalizeTextForComparison("hello world")).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips emoji", () => {
|
||||||
|
expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed normalization", () => {
|
||||||
|
expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe(
|
||||||
|
"hello world",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stripThoughtSignatures", () => {
|
||||||
|
it("returns non-array content unchanged", () => {
|
||||||
|
expect(stripThoughtSignatures("hello")).toBe("hello");
|
||||||
|
expect(stripThoughtSignatures(null)).toBe(null);
|
||||||
|
expect(stripThoughtSignatures(undefined)).toBe(undefined);
|
||||||
|
expect(stripThoughtSignatures(123)).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes msg_-prefixed thought_signature from content blocks", () => {
|
||||||
|
const input = [
|
||||||
|
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||||
|
{ type: "thinking", thinking: "test", thought_signature: "AQID" },
|
||||||
|
];
|
||||||
|
const result = stripThoughtSignatures(input);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({ type: "text", text: "hello" });
|
||||||
|
expect(result[1]).toEqual({
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "test",
|
||||||
|
thought_signature: "AQID",
|
||||||
|
});
|
||||||
|
expect("thought_signature" in result[0]).toBe(false);
|
||||||
|
expect("thought_signature" in result[1]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves blocks without thought_signature", () => {
|
||||||
|
const input = [
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
];
|
||||||
|
const result = stripThoughtSignatures(input);
|
||||||
|
|
||||||
|
expect(result).toEqual(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed blocks with and without thought_signature", () => {
|
||||||
|
const input = [
|
||||||
|
{ type: "text", text: "hello", thought_signature: "msg_abc" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
{ type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" },
|
||||||
|
];
|
||||||
|
const result = stripThoughtSignatures(input);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
|
{ type: "thinking", thinking: "hmm" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty array", () => {
|
||||||
|
expect(stripThoughtSignatures([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null/undefined blocks in array", () => {
|
||||||
|
const input = [null, undefined, { type: "text", text: "hello" }];
|
||||||
|
const result = stripThoughtSignatures(input);
|
||||||
|
expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips non-base64 thought signatures when configured for Gemini", () => {
|
||||||
|
const input = [
|
||||||
|
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||||
|
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
||||||
|
{ type: "toolCall", id: "call_1", thoughtSignature: '{"id":1}' },
|
||||||
|
{ type: "toolCall", id: "call_2", thoughtSignature: "c2ln" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = stripThoughtSignatures(input, {
|
||||||
|
allowBase64Only: true,
|
||||||
|
includeCamelCase: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
||||||
|
{ type: "toolCall", id: "call_1" },
|
||||||
|
{ type: "toolCall", id: "call_2", thoughtSignature: "c2ln" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeSessionMessagesImages - thought_signature stripping", () => {
|
||||||
|
it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "reasoning",
|
||||||
|
thought_signature: "AQID",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
const content = (out[0] as { content?: unknown[] }).content;
|
||||||
|
expect(content).toHaveLength(2);
|
||||||
|
expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false);
|
||||||
|
expect(
|
||||||
|
(content?.[1] as { thought_signature?: unknown })?.thought_signature,
|
||||||
|
).toBe("AQID");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isMessagingToolDuplicate", () => {
|
||||||
|
it("returns false for empty sentTexts", () => {
|
||||||
|
expect(isMessagingToolDuplicate("hello world", [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for short texts", () => {
|
||||||
|
expect(isMessagingToolDuplicate("short", ["short"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects exact duplicates", () => {
|
||||||
|
expect(
|
||||||
|
isMessagingToolDuplicate("Hello, this is a test message!", [
|
||||||
|
"Hello, this is a test message!",
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicates with different casing", () => {
|
||||||
|
expect(
|
||||||
|
isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [
|
||||||
|
"hello, this is a test message!",
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicates with emoji variations", () => {
|
||||||
|
expect(
|
||||||
|
isMessagingToolDuplicate("Hello! 👋 This is a test message!", [
|
||||||
|
"Hello! This is a test message!",
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects substring duplicates (LLM elaboration)", () => {
|
||||||
|
expect(
|
||||||
|
isMessagingToolDuplicate(
|
||||||
|
'I sent the message: "Hello, this is a test message!"',
|
||||||
|
["Hello, this is a test message!"],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects when sent text contains block reply (reverse substring)", () => {
|
||||||
|
expect(
|
||||||
|
isMessagingToolDuplicate("Hello, this is a test message!", [
|
||||||
|
'I sent the message: "Hello, this is a test message!"',
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for non-matching texts", () => {
|
||||||
|
expect(
|
||||||
|
isMessagingToolDuplicate("This is completely different content.", [
|
||||||
|
"Hello, this is a test message!",
|
||||||
|
]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,26 +9,65 @@ import type { EmbeddedContextFile } from "./types.js";
|
|||||||
|
|
||||||
type ContentBlockWithSignature = {
|
type ContentBlockWithSignature = {
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
|
thoughtSignature?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ThoughtSignatureSanitizeOptions = {
|
||||||
|
allowBase64Only?: boolean;
|
||||||
|
includeCamelCase?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBase64Signature(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const compact = trimmed.replace(/\s+/g, "");
|
||||||
|
if (!/^[A-Za-z0-9+/=_-]+$/.test(compact)) return false;
|
||||||
|
const isUrl = compact.includes("-") || compact.includes("_");
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(compact, isUrl ? "base64url" : "base64");
|
||||||
|
if (buf.length === 0) return false;
|
||||||
|
const encoded = buf.toString(isUrl ? "base64url" : "base64");
|
||||||
|
const normalize = (input: string) => input.replace(/=+$/g, "");
|
||||||
|
return normalize(encoded) === normalize(compact);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips Claude-style thought_signature fields from content blocks.
|
* Strips Claude-style thought_signature fields from content blocks.
|
||||||
*
|
*
|
||||||
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
||||||
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
||||||
*/
|
*/
|
||||||
export function stripThoughtSignatures<T>(content: T): T {
|
export function stripThoughtSignatures<T>(
|
||||||
|
content: T,
|
||||||
|
options?: ThoughtSignatureSanitizeOptions,
|
||||||
|
): T {
|
||||||
if (!Array.isArray(content)) return content;
|
if (!Array.isArray(content)) return content;
|
||||||
|
const allowBase64Only = options?.allowBase64Only ?? false;
|
||||||
|
const includeCamelCase = options?.includeCamelCase ?? false;
|
||||||
|
const shouldStripSignature = (value: unknown): boolean => {
|
||||||
|
if (!allowBase64Only) {
|
||||||
|
return typeof value === "string" && value.startsWith("msg_");
|
||||||
|
}
|
||||||
|
return typeof value !== "string" || !isBase64Signature(value);
|
||||||
|
};
|
||||||
return content.map((block) => {
|
return content.map((block) => {
|
||||||
if (!block || typeof block !== "object") return block;
|
if (!block || typeof block !== "object") return block;
|
||||||
const rec = block as ContentBlockWithSignature;
|
const rec = block as ContentBlockWithSignature;
|
||||||
const signature = rec.thought_signature;
|
const stripSnake = shouldStripSignature(rec.thought_signature);
|
||||||
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
|
const stripCamel = includeCamelCase
|
||||||
|
? shouldStripSignature(rec.thoughtSignature)
|
||||||
|
: false;
|
||||||
|
if (!stripSnake && !stripCamel) {
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
const { thought_signature: _signature, ...rest } = rec;
|
const next = { ...rec };
|
||||||
return rest;
|
if (stripSnake) delete next.thought_signature;
|
||||||
|
if (stripCamel) delete next.thoughtSignature;
|
||||||
|
return next;
|
||||||
}) as T;
|
}) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export { sanitizeGoogleTurnOrdering };
|
|||||||
type GeminiToolCallBlock = {
|
type GeminiToolCallBlock = {
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
|
thoughtSignature?: unknown;
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
toolCallId?: unknown;
|
toolCallId?: unknown;
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
@@ -118,7 +119,8 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
|
|||||||
const blockRecord = block as GeminiToolCallBlock;
|
const blockRecord = block as GeminiToolCallBlock;
|
||||||
const type = blockRecord.type;
|
const type = blockRecord.type;
|
||||||
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
||||||
const hasSignature = Boolean(blockRecord.thought_signature);
|
const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature;
|
||||||
|
const hasSignature = Boolean(signature);
|
||||||
if (!hasSignature) {
|
if (!hasSignature) {
|
||||||
const id =
|
const id =
|
||||||
typeof blockRecord.id === "string"
|
typeof blockRecord.id === "string"
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
enforceToolCallLast?: boolean;
|
enforceToolCallLast?: boolean;
|
||||||
preserveSignatures?: boolean;
|
preserveSignatures?: boolean;
|
||||||
|
sanitizeThoughtSignatures?: {
|
||||||
|
allowBase64Only?: boolean;
|
||||||
|
includeCamelCase?: boolean;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
): Promise<AgentMessage[]> {
|
): Promise<AgentMessage[]> {
|
||||||
// We sanitize historical session messages because Anthropic can reject a request
|
// We sanitize historical session messages because Anthropic can reject a request
|
||||||
@@ -82,7 +86,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const strippedContent = options?.preserveSignatures
|
const strippedContent = options?.preserveSignatures
|
||||||
? content // Keep signatures for Antigravity Claude
|
? content // Keep signatures for Antigravity Claude
|
||||||
: stripThoughtSignatures(content); // Strip for Gemini
|
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
|
||||||
|
|
||||||
const filteredContent = strippedContent.filter((block) => {
|
const filteredContent = strippedContent.filter((block) => {
|
||||||
if (!block || typeof block !== "object") return true;
|
if (!block || typeof block !== "object") return true;
|
||||||
|
|||||||
@@ -145,6 +145,44 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
expect(assistant.content?.[1]?.text).toBe("internal note");
|
expect(assistant.content?.[1]?.text).toBe("internal note");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips non-base64 thought signatures for OpenRouter Gemini", async () => {
|
||||||
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "hi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||||
|
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
||||||
|
{ type: "toolCall", id: "call_1", thoughtSignature: "{\"id\":1}" },
|
||||||
|
{ type: "toolCall", id: "call_2", thoughtSignature: "c2ln" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionHistory({
|
||||||
|
messages: input,
|
||||||
|
modelApi: "openrouter",
|
||||||
|
provider: "openrouter",
|
||||||
|
modelId: "google/gemini-1.5-pro",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "session:openrouter-gemini",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
|
content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>;
|
||||||
|
};
|
||||||
|
expect(assistant.content).toEqual([
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
||||||
|
{ type: "toolCall", id: "call_1" },
|
||||||
|
{ type: "toolCall", id: "call_2", thoughtSignature: "c2ln" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -308,6 +308,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
modelApi: model.api,
|
modelApi: model.api,
|
||||||
modelId,
|
modelId,
|
||||||
|
provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,17 +185,26 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
modelApi?: string | null;
|
modelApi?: string | null;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
provider?: string;
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||||
|
const provider = (params.provider ?? "").toLowerCase();
|
||||||
|
const modelId = (params.modelId ?? "").toLowerCase();
|
||||||
|
const isOpenRouterGemini =
|
||||||
|
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
||||||
|
const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini;
|
||||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||||
|
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||||
|
? { allowBase64Only: true, includeCamelCase: true }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||||
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
|
const shouldDowngradeGemini = isGeminiLike && !isAntigravityClaudeModel;
|
||||||
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
||||||
const downgradedThinking = shouldDowngradeGemini
|
const downgradedThinking = shouldDowngradeGemini
|
||||||
? downgradeGeminiThinkingBlocks(repairedTools)
|
? downgradeGeminiThinkingBlocks(repairedTools)
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ export async function runEmbeddedAttempt(
|
|||||||
messages: activeSession.messages,
|
messages: activeSession.messages,
|
||||||
modelApi: params.model.api,
|
modelApi: params.model.api,
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
|
provider: params.provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user