fix(model): repair provider replay edge cases

This commit is contained in:
Peter Steinberger
2026-05-07 06:41:28 +01:00
parent a8d8d49ab8
commit 85b914a4e1
16 changed files with 219 additions and 37 deletions

View File

@@ -137,6 +137,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI backends: keep versioned OAuth identity matches reusable when auth profile ids rotate, so Claude CLI sessions do not reset and lose continuity during same-account OAuth refresh/profile alias changes. Fixes #78541.
- Model providers: normalize APNG sniffed PNG uploads, preserve Gemini 3 tool-call thought-signature replay with documented fallback signatures, accept legacy `__env__:VAR` custom-provider keys, and repair snake_case tool-call transcript sanitization. Fixes #51881, #48915, #77566, and #42858.
- Telegram/models: parse provider ids containing dots in `/models` callback buttons so `hf.co` model lists render as inline keyboard buttons. Fixes #38745.
- Anthropic: reject uppercase provider-prefixed forward-compat model ids locally instead of sending malformed dynamic ids upstream. Fixes #73715.
- OpenAI/embeddings: pass configured output dimensionality through single and batched embedding requests so memory embedding indexes can request smaller vectors. Fixes #55126.
- CLI/infer: normalize HEIC/HEIF image files to JPEG before model-run requests, avoiding providers that reject Apple image container formats. Fixes #50081.

View File

@@ -504,6 +504,44 @@ describe("google transport stream", () => {
});
});
it("uses Gemini skip-validator thought signatures for cross-provider tool-call replay", () => {
const model = buildGeminiModel({
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
});
const params = buildGoogleGenerativeAiParams(model, {
messages: [
{
role: "assistant",
provider: "anthropic",
api: "anthropic-messages",
model: "claude-opus-4-7",
stopReason: "toolUse",
timestamp: 0,
content: [
{
type: "toolCall",
id: "call_1",
name: "lookup",
arguments: { q: "hello" },
},
],
},
],
} as never);
expect(params.contents[0]).toMatchObject({
role: "model",
parts: [
{
thoughtSignature: "skip_thought_signature_validator",
functionCall: { name: "lookup", args: { q: "hello" } },
},
],
});
});
it("builds direct Gemini payloads without negative fallback thinking budgets", () => {
const model = {
id: "custom-gemini-model",

View File

@@ -134,6 +134,7 @@ type GoogleSseChunk = {
};
let toolCallCounter = 0;
const GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP = "skip_thought_signature_validator";
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -143,6 +144,10 @@ function requiresToolCallId(modelId: string): boolean {
return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-");
}
function requiresToolCallThoughtSignature(modelId: string): boolean {
return normalizeLowercaseStringOrEmpty(modelId).includes("gemini-3");
}
function supportsMultimodalFunctionResponse(modelId: string): boolean {
const match = normalizeLowercaseStringOrEmpty(modelId).match(/^gemini(?:-live)?-(\d+)/);
if (!match) {
@@ -377,8 +382,11 @@ function normalizeGoogleThinkingConfig(
function convertGoogleMessages(model: GoogleTransportModel, context: Context) {
const contents: Array<Record<string, unknown>> = [];
const transformedMessages = transformTransportMessages(context.messages, model, (id) =>
requiresToolCallId(model.id) ? normalizeToolCallId(id) : id,
const transformedMessages = transformTransportMessages(
context.messages,
model,
(id) => (requiresToolCallId(model.id) ? normalizeToolCallId(id) : id),
{ preserveCrossModelToolCallThoughtSignature: true },
);
for (const msg of transformedMessages) {
if (msg.role === "user") {
@@ -440,15 +448,18 @@ function convertGoogleMessages(model: GoogleTransportModel, context: Context) {
continue;
}
if (block.type === "toolCall") {
const thoughtSignature =
(isSameProviderAndModel ? block.thoughtSignature : undefined) ??
(requiresToolCallThoughtSignature(model.id)
? GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP
: undefined);
parts.push({
functionCall: {
name: block.name,
args: coerceTransportToolCallArguments(block.arguments),
...(requiresToolCallId(model.id) ? { id: block.id } : {}),
},
...(isSameProviderAndModel && block.thoughtSignature
? { thoughtSignature: block.thoughtSignature }
: {}),
...(thoughtSignature ? { thoughtSignature } : {}),
});
}
}

View File

@@ -18,6 +18,7 @@ describe("parseModelCallbackData", () => {
["mdl_back", { type: "back" }],
["mdl_list_anthropic_2", { type: "list", provider: "anthropic", page: 2 }],
["mdl_list_open-ai_1", { type: "list", provider: "open-ai", page: 1 }],
["mdl_list_hf.co_1", { type: "list", provider: "hf.co", page: 1 }],
[
"mdl_sel_anthropic/claude-sonnet-4-5",
{ type: "select", provider: "anthropic", model: "claude-sonnet-4-5" },

View File

@@ -63,7 +63,7 @@ export function parseModelCallbackData(data: string): ParsedModelCallback | null
}
// mdl_list_{provider}_{page}
const listMatch = trimmed.match(/^mdl_list_([a-z0-9_-]+)_(\d+)$/i);
const listMatch = trimmed.match(/^mdl_list_([a-z0-9_.-]+)_(\d+)$/i);
if (listMatch) {
const [, provider, pageStr] = listMatch;
const page = Number.parseInt(pageStr ?? "1", 10);

View File

@@ -2,6 +2,29 @@ import { describe, expect, it, vi } from "vitest";
import { resolveRemoteEmbeddingBearerClient } from "./embeddings-remote-client.js";
describe("resolveRemoteEmbeddingBearerClient", () => {
it("uses configured OpenAI provider baseUrl for memory embeddings", async () => {
const client = await resolveRemoteEmbeddingBearerClient({
provider: "openai",
defaultBaseUrl: "https://api.openai.com/v1",
options: {
agentDir: "/tmp/openclaw-agent",
config: {
models: {
providers: {
openai: {
apiKey: "sk-config",
baseUrl: "https://proxy.example.test/openai/v1",
},
},
},
} as never,
model: "text-embedding-3-small",
},
});
expect(client.baseUrl).toBe("https://proxy.example.test/openai/v1");
});
it("adds OpenClaw attribution to native OpenAI embedding requests", async () => {
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
const client = await resolveRemoteEmbeddingBearerClient({

View File

@@ -518,6 +518,36 @@ describe("resolveUsableCustomProviderApiKey", () => {
}
});
it("resolves legacy __env__ markers from process env for custom providers", () => {
const previous = process.env.BAILIAN_API_KEY;
process.env.BAILIAN_API_KEY = "sk-bailian-env"; // pragma: allowlist secret
try {
const resolved = resolveUsableCustomProviderApiKey({
cfg: {
models: {
providers: {
bailian: {
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
api: "openai-completions",
apiKey: "__env__:BAILIAN_API_KEY", // pragma: allowlist secret
models: [],
},
},
},
},
provider: "bailian",
});
expect(resolved?.apiKey).toBe("sk-bailian-env");
expect(resolved?.source).toContain("BAILIAN_API_KEY");
} finally {
if (previous === undefined) {
delete process.env.BAILIAN_API_KEY;
} else {
process.env.BAILIAN_API_KEY = previous;
}
}
});
it("does not resolve env SecretRefs when provider allowlist excludes the env id", () => {
const previous = process.env.MY_CUSTOM_KEY;
process.env.MY_CUSTOM_KEY = "sk-custom-secretref-env"; // pragma: allowlist secret

View File

@@ -2901,7 +2901,7 @@ describe("openai transport stream", () => {
);
});
it("does not replay thought_signature across a different API surface", () => {
it("uses the Gemini skip-validator signature across a different API surface", () => {
const params = buildOpenAICompletionsParams(
geminiModel,
{
@@ -2938,12 +2938,14 @@ describe("openai transport stream", () => {
) as { messages: Array<Record<string, unknown>> };
const assistant = params.messages.find((message) => message.role === "assistant") as
| { tool_calls?: Array<{ extra_content?: unknown }> }
| { tool_calls?: Array<{ extra_content?: { google?: { thought_signature?: string } } }> }
| undefined;
expect(assistant?.tool_calls?.[0]?.extra_content).toBeUndefined();
expect(assistant?.tool_calls?.[0]?.extra_content?.google?.thought_signature).toBe(
"skip_thought_signature_validator",
);
});
it("does not emit extra_content when no thought_signature was captured", () => {
it("uses the Gemini skip-validator signature when no thought_signature was captured", () => {
const params = buildOpenAICompletionsParams(
geminiModel,
{
@@ -2972,9 +2974,11 @@ describe("openai transport stream", () => {
) as { messages: Array<Record<string, unknown>> };
const assistant = params.messages.find((message) => message.role === "assistant") as
| { tool_calls?: Array<{ extra_content?: unknown }> }
| { tool_calls?: Array<{ extra_content?: { google?: { thought_signature?: string } } }> }
| undefined;
expect(assistant?.tool_calls?.[0]?.extra_content).toBeUndefined();
expect(assistant?.tool_calls?.[0]?.extra_content?.google?.thought_signature).toBe(
"skip_thought_signature_validator",
);
});
});

View File

@@ -58,6 +58,7 @@ import { mergeTransportMetadata, sanitizeTransportPayloadText } from "./transpor
const DEFAULT_AZURE_OPENAI_API_VERSION = "2024-12-01-preview";
const OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT = " ";
const GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP = "skip_thought_signature_validator";
const log = createSubsystemLogger("openai-transport");
type ReplayableResponseOutputMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
@@ -1800,6 +1801,10 @@ function isGoogleOpenAICompatModel(model: OpenAIModeModel): boolean {
);
}
function requiresGoogleCompatToolCallThoughtSignature(model: OpenAIModeModel): boolean {
return model.id.toLowerCase().includes("gemini-3");
}
function injectToolCallThoughtSignatures(
outgoingMessages: unknown[],
context: Context,
@@ -1809,18 +1814,14 @@ function injectToolCallThoughtSignatures(
return;
}
const sigById = new Map<string, string>();
const fallbackSig = requiresGoogleCompatToolCallThoughtSignature(model)
? GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP
: undefined;
for (const msg of context.messages ?? []) {
if ((msg as { role?: string }).role !== "assistant") {
continue;
}
const source = msg as { api?: string; provider?: string; model?: string; content?: unknown };
if (
source.api !== model.api ||
source.provider !== model.provider ||
source.model !== model.id
) {
continue;
}
if (!Array.isArray(source.content)) {
continue;
}
@@ -1831,11 +1832,15 @@ function injectToolCallThoughtSignatures(
const id = block.id;
const sig = block.thoughtSignature;
if (typeof id === "string" && typeof sig === "string" && sig.length > 0) {
sigById.set(id, sig);
const isSameRoute =
source.api === model.api &&
source.provider === model.provider &&
source.model === model.id;
sigById.set(id, isSameRoute ? sig : (fallbackSig ?? sig));
}
}
}
if (sigById.size === 0) {
if (sigById.size === 0 && !fallbackSig) {
return;
}
for (const message of outgoingMessages) {
@@ -1848,7 +1853,7 @@ function injectToolCallThoughtSignatures(
if (typeof id !== "string") {
continue;
}
const sig = sigById.get(id);
const sig = sigById.get(id) ?? fallbackSig;
if (!sig) {
continue;
}

View File

@@ -8,7 +8,14 @@ import {
} from "./session-transcript-repair.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
const TOOL_CALL_BLOCK_TYPES = new Set([
"toolCall",
"toolUse",
"functionCall",
"tool_call",
"tool_use",
"function_call",
]);
function getAssistantToolCallBlocks(messages: AgentMessage[]) {
const assistant = messages[0] as Extract<AgentMessage, { role: "assistant" }> | undefined;
@@ -316,6 +323,29 @@ describe("sanitizeToolUseResultPairing", () => {
});
});
describe("sanitizeToolCallInputs", () => {
it("drops malformed snake_case tool call blocks", () => {
const input = castAgentMessages([
{
role: "assistant",
content: [
{ type: "text", text: "before" },
{ type: "tool_use", id: "tool_1", name: "read" },
{ type: "tool_call", tool_call_id: "tool_2", name: "write", arguments: {} },
{ type: "function_call", call_id: "tool_3", name: "exec", arguments: "{}" },
],
},
]);
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["write", "exec"] });
expect(getAssistantToolCallBlocks(out)).toMatchObject([
{ type: "tool_call", name: "write" },
{ type: "function_call", name: "exec" },
]);
});
});
describe("sanitizeToolCallInputs", () => {
function sanitizeAssistantContent(
content: unknown[],

View File

@@ -16,11 +16,25 @@ import {
type RawToolCallBlock = {
type?: unknown;
id?: unknown;
call_id?: unknown;
toolCallId?: unknown;
toolUseId?: unknown;
tool_call_id?: unknown;
tool_use_id?: unknown;
name?: unknown;
input?: unknown;
arguments?: unknown;
};
const RAW_TOOL_CALL_BLOCK_TYPES = new Set([
"toolCall",
"toolUse",
"functionCall",
"tool_call",
"tool_use",
"function_call",
]);
function isThinkingLikeBlock(block: unknown): boolean {
if (!block || typeof block !== "object") {
return false;
@@ -34,10 +48,7 @@ function isRawToolCallBlock(block: unknown): block is RawToolCallBlock {
return false;
}
const type = (block as { type?: unknown }).type;
return (
typeof type === "string" &&
(type === "toolCall" || type === "toolUse" || type === "functionCall")
);
return typeof type === "string" && RAW_TOOL_CALL_BLOCK_TYPES.has(type);
}
function hasToolCallInput(block: RawToolCallBlock): boolean {
@@ -52,7 +63,14 @@ function hasNonEmptyStringField(value: unknown): boolean {
}
function hasToolCallId(block: RawToolCallBlock): boolean {
return hasNonEmptyStringField(block.id);
return (
hasNonEmptyStringField(block.id) ||
hasNonEmptyStringField(block.call_id) ||
hasNonEmptyStringField(block.toolCallId) ||
hasNonEmptyStringField(block.toolUseId) ||
hasNonEmptyStringField(block.tool_call_id) ||
hasNonEmptyStringField(block.tool_use_id)
);
}
function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown {
@@ -350,11 +368,7 @@ function repairToolCallInputs(
continue;
}
if (isRawToolCallBlock(block)) {
if (
(block as { type?: unknown }).type === "toolCall" ||
(block as { type?: unknown }).type === "toolUse" ||
(block as { type?: unknown }).type === "functionCall"
) {
if (RAW_TOOL_CALL_BLOCK_TYPES.has((block as { type?: string }).type ?? "")) {
// Only sanitize (redact) sessions_spawn blocks; all others are passed through
// unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic).
const blockName =

View File

@@ -45,6 +45,7 @@ export function transformTransportMessages(
targetModel: Model<Api>,
source: { provider: string; api: Api; model: string },
) => string,
options?: { preserveCrossModelToolCallThoughtSignature?: boolean },
): Context["messages"] {
const allowSyntheticToolResults = defaultAllowSyntheticToolResults(model.api);
const syntheticToolResultText = CODEX_STYLE_ABORTED_OUTPUT_APIS.has(model.api)
@@ -94,7 +95,11 @@ export function transformTransportMessages(
continue;
}
let normalizedToolCall = block;
if (!isSameModel && block.thoughtSignature) {
if (
!isSameModel &&
block.thoughtSignature &&
options?.preserveCrossModelToolCallThoughtSignature !== true
) {
normalizedToolCall = { ...normalizedToolCall };
delete normalizedToolCall.thoughtSignature;
}

View File

@@ -90,6 +90,11 @@ describe("parseLegacySecretRefEnvMarker", () => {
provider: "default",
id: "OPENAI_API_KEY",
});
expect(parseLegacySecretRefEnvMarker("__env__:BAILIAN_API_KEY")).toEqual({
source: "env",
provider: "default",
id: "BAILIAN_API_KEY",
});
expect(parseLegacySecretRefEnvMarker("secretref-env:not-valid")).toBeNull();
expect(
resolveSecretInputString({

View File

@@ -19,6 +19,7 @@ export type SecretInput = string | SecretRef;
export const DEFAULT_SECRET_PROVIDER_ALIAS = "default"; // pragma: allowlist secret
export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
export const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret
export const LEGACY_DOUBLE_UNDERSCORE_ENV_MARKER_PREFIX = "__env__:"; // pragma: allowlist secret
const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
export type SecretInputStringResolutionMode = "strict" | "inspect";
export type SecretInputStringResolution =
@@ -91,10 +92,15 @@ export function parseLegacySecretRefEnvMarker(
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith(LEGACY_SECRETREF_ENV_MARKER_PREFIX)) {
const prefix = trimmed.startsWith(LEGACY_SECRETREF_ENV_MARKER_PREFIX)
? LEGACY_SECRETREF_ENV_MARKER_PREFIX
: trimmed.startsWith(LEGACY_DOUBLE_UNDERSCORE_ENV_MARKER_PREFIX)
? LEGACY_DOUBLE_UNDERSCORE_ENV_MARKER_PREFIX
: undefined;
if (!prefix) {
return null;
}
const id = trimmed.slice(LEGACY_SECRETREF_ENV_MARKER_PREFIX.length);
const id = trimmed.slice(prefix.length);
if (!ENV_SECRET_REF_ID_RE.test(id)) {
return null;
}
@@ -109,6 +115,10 @@ export function coerceSecretRef(value: unknown, defaults?: SecretDefaults): Secr
if (isSecretRef(value)) {
return value;
}
const legacyEnvMarker = parseLegacySecretRefEnvMarker(value, defaults?.env);
if (legacyEnvMarker) {
return legacyEnvMarker;
}
if (isLegacySecretRefWithoutProvider(value)) {
const provider =
value.source === "env"

View File

@@ -207,6 +207,7 @@ describe("normalizeMimeType", () => {
it.each([
{ input: "Audio/MP4; codecs=mp4a.40.2", expected: "audio/mp4" },
{ input: "image/apng", expected: "image/png" },
{ input: " ", expected: undefined },
{ input: null, expected: undefined },
{ input: undefined, expected: undefined },

View File

@@ -74,6 +74,9 @@ export function normalizeMimeType(mime?: string | null): string | undefined {
return undefined;
}
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
if (cleaned === "image/apng") {
return "image/png";
}
return cleaned || undefined;
}
@@ -93,7 +96,7 @@ async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
const { fileTypeFromBuffer } = await fileTypeModuleLoader.load();
const type = await fileTypeFromBuffer(sliceMimeSniffBuffer(buffer));
if (type?.mime) {
return type.mime;
return normalizeMimeType(type.mime);
}
} catch {
// fall through to manual magic-byte sniffs