Files
OmniRoute/open-sse/handlers/chatCore/upstreamBody.ts
rinseaid 458ab1aac0 fix(vision): preserve high detail for inline images (#10554)
* fix(vision): preserve high detail for inline images

* fix(vision): scope high-detail image default to OpenCode clients

defaultImageDetail() was applied at prepareUpstreamBody, the shared
upstream-body prep path for every provider and format, not just the
OpenCode path the fix targets. Gate it on isOpencodeClient (the
existing User-Agent/x-opencode-* header signal already used for
bypassDefaultToolLimit at this call site) so non-OpenCode callers keep
the provider's own image detail default. Adds a regression test
covering a non-OpenCode caller against the same opencode-zen provider.

* fix(vision): document and test the global vs OpenCode-only detail scope

The OpenCode-only high-detail default in chatCore/upstreamBody.ts
(defaultImageDetail, gated on isOpencodeClient) forwards the caller's
own image_url.detail and was already correctly scoped in a prior
commit on this branch.

The internal vision-bridge describe self-loop (visionBridgeHelpers.ts)
is architecturally global: VisionBridgeGuardrail runs for every
caller/provider whenever the target model lacks vision support, and
there is no client-identity signal at that layer to gate on. Its
describe prompt explicitly asks the vision model to transcribe visible
text, so requesting "high" detail unconditionally is justified on its
own merits (OCR accuracy), independent of the OpenCode motivation.

Adds a compatibility assertion proving the Anthropic wire-format
branch of the same describe self-loop carries no `detail` field (it
has no such concept) and is therefore unaffected by this default, and
documents the split (OpenCode-only forwarding vs. global describe
default) in docs/security/GUARDRAILS.md.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: rinseaid <rinseaid@rinseaid.net>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:11 -03:00

244 lines
8.5 KiB
TypeScript

/**
* chatCore upstream body preparation (Quality Gate v2 / Fase 9 — chatCore god-file decomposition,
* #3501 — first internal sub-slice of executeProviderRequest).
*
* Extracted from handleChatCore's execute() closure: prepares the body actually sent upstream for a
* given target model. Pins the model id, applies the configured payload rules, truncates the tool
* list to the provider's effective limit and injects an OpenAI `prompt_cache_key` for
* caching-capable providers. Pure with respect to handler
* state (returns a fresh body, only logs as a side effect); behaviour is byte-identical to the
* previous inline block. Split into small private steps so each stays under the complexity cap.
*/
import {
applyConfiguredPayloadRules,
resolvePayloadRuleProtocols,
} from "../../services/payloadRules.ts";
import { getEffectiveToolLimit, getKnownToolLimit } from "../../services/toolLimitDetector.ts";
import {
providerSupportsCaching,
resolveConnectionCacheOverride,
type ConnectionCacheOverride,
} from "../../utils/cacheControlPolicy.ts";
import { FORMATS } from "../../translator/formats.ts";
import { sanitizeRequestForResolvedTarget } from "../../services/targetRequestSanitizer.ts";
type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;
type Body = Record<string, unknown>;
type CredentialsLike =
| {
apiKey?: unknown;
accessToken?: unknown;
providerSpecificData?: Record<string, unknown> | null;
}
| null
| undefined;
function buildAppliedRulesSummary(
applied: Array<{ type: string; path: string; value?: unknown }>
): string {
return applied
.map((rule) => {
if (rule.type === "filter") return `${rule.type}:${rule.path}`;
const serializedValue = JSON.stringify(rule.value);
const safeValue =
typeof serializedValue === "string" && serializedValue.length > 80
? `${serializedValue.slice(0, 77)}...`
: serializedValue;
return `${rule.type}:${rule.path}=${safeValue}`;
})
.join(", ");
}
function truncateToolList(
bodyToSend: Body,
provider: string | null | undefined,
bypassDefaultToolLimit: boolean,
log?: LoggerLike
): Body {
if (!Array.isArray(bodyToSend.tools)) return bodyToSend;
const knownLimit = getKnownToolLimit(provider);
if (knownLimit !== null) {
if (bodyToSend.tools.length > knownLimit) {
const originalCount = bodyToSend.tools.length;
const truncatedTools = bodyToSend.tools.slice(0, knownLimit);
bodyToSend = { ...bodyToSend, tools: truncatedTools };
log?.debug?.(
"TOOL_LIMIT",
`Truncated ${originalCount} tools to ${knownLimit} for ${provider}`
);
}
return bodyToSend;
}
if (bypassDefaultToolLimit === true) return bodyToSend;
const effectiveToolLimit = getEffectiveToolLimit(provider);
if (bodyToSend.tools.length > effectiveToolLimit) {
const originalCount = bodyToSend.tools.length;
const truncatedTools = bodyToSend.tools.slice(0, effectiveToolLimit);
bodyToSend = { ...bodyToSend, tools: truncatedTools };
log?.debug?.(
"TOOL_LIMIT",
`Truncated ${originalCount} tools to ${effectiveToolLimit} for ${provider}`
);
}
return bodyToSend;
}
// OpenCode's AI SDK file-part serializer omits `image_url.detail`, which makes wide, text-dense
// screenshots fall back to low-detail vision sampling upstream. Gated on `isOpencodeClient` (the
// request's User-Agent / `x-opencode-*` header signal, not the `provider` field — `provider` is
// the upstream target and can be anything regardless of which client sent the request) so this
// override doesn't change the detail default for non-OpenCode callers on any provider.
function defaultImageDetail(bodyToSend: Body, isOpencodeClient: boolean): Body {
if (!isOpencodeClient) return bodyToSend;
let nextBody = bodyToSend;
if (Array.isArray(bodyToSend.messages)) {
const messages = bodyToSend.messages.map((message) => {
if (!message || typeof message !== "object" || Array.isArray(message)) return message;
const messageRecord = message as Record<string, unknown>;
if (!Array.isArray(messageRecord.content)) return message;
let changed = false;
const content = messageRecord.content.map((part) => {
if (!part || typeof part !== "object" || Array.isArray(part)) return part;
const partRecord = part as Record<string, unknown>;
const imageUrl = partRecord.image_url;
if (
partRecord.type !== "image_url" ||
!imageUrl ||
typeof imageUrl !== "object" ||
Array.isArray(imageUrl)
) {
return part;
}
const imageUrlRecord = imageUrl as Record<string, unknown>;
if (imageUrlRecord.detail !== undefined) return part;
changed = true;
return { ...partRecord, image_url: { ...imageUrlRecord, detail: "high" } };
});
return changed ? { ...messageRecord, content } : message;
});
if (messages.some((message, index) => message !== bodyToSend.messages?.[index])) {
nextBody = { ...nextBody, messages };
}
}
if (Array.isArray(bodyToSend.input)) {
const input = bodyToSend.input.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const itemRecord = item as Record<string, unknown>;
if (!Array.isArray(itemRecord.content)) return item;
let changed = false;
const content = itemRecord.content.map((part) => {
if (!part || typeof part !== "object" || Array.isArray(part)) return part;
const partRecord = part as Record<string, unknown>;
if (partRecord.type !== "input_image" || partRecord.detail !== undefined) return part;
changed = true;
return { ...partRecord, detail: "high" };
});
return changed ? { ...itemRecord, content } : item;
});
if (input.some((item, index) => item !== bodyToSend.input?.[index])) {
nextBody = { ...nextBody, input };
}
}
return nextBody;
}
// Inject prompt_cache_key only for providers that support it.
async function injectPromptCacheKey(
bodyToSend: Body,
provider: string | null | undefined,
targetFormat: string,
connectionCacheOverride: ConnectionCacheOverride | null
): Promise<Body> {
if (
targetFormat === FORMATS.OPENAI &&
providerSupportsCaching(provider, undefined, connectionCacheOverride) &&
!bodyToSend.prompt_cache_key &&
Array.isArray(bodyToSend.messages) &&
!["nvidia", "xai"].includes(provider)
) {
const { generatePromptCacheKey } = await import("@/lib/promptCache");
const cacheKey = generatePromptCacheKey(bodyToSend.messages);
if (cacheKey) {
bodyToSend = { ...bodyToSend, prompt_cache_key: cacheKey };
}
}
return bodyToSend;
}
export async function prepareUpstreamBody(opts: {
translatedBody: Body;
modelToCall: string;
provider: string | null | undefined;
targetFormat: string;
credentials: CredentialsLike;
bypassDefaultToolLimit?: boolean;
isOpencodeClient?: boolean;
log?: LoggerLike;
}): Promise<Body> {
const {
translatedBody,
modelToCall,
provider,
targetFormat,
credentials,
bypassDefaultToolLimit = false,
isOpencodeClient = false,
log,
} = opts;
let bodyToSend: Body =
translatedBody.model === modelToCall
? translatedBody
: { ...translatedBody, model: modelToCall };
const payloadRuleModel =
typeof bodyToSend.model === "string" && bodyToSend.model.length > 0
? bodyToSend.model
: modelToCall;
const payloadRuleProtocols = resolvePayloadRuleProtocols({ provider, targetFormat });
const payloadRuleResult = await applyConfiguredPayloadRules(
bodyToSend,
payloadRuleModel,
payloadRuleProtocols
);
bodyToSend = payloadRuleResult.payload;
if (payloadRuleResult.applied.length > 0) {
log?.debug?.(
"PAYLOAD_RULES",
`Applied ${payloadRuleResult.applied.length} rule(s) for ${payloadRuleModel} (${payloadRuleProtocols.join(", ")}): ${buildAppliedRulesSummary(payloadRuleResult.applied)}`
);
}
bodyToSend = sanitizeRequestForResolvedTarget(bodyToSend, {
provider,
model: payloadRuleModel,
log,
});
bodyToSend = defaultImageDetail(bodyToSend, isOpencodeClient);
bodyToSend = truncateToolList(bodyToSend, provider, bypassDefaultToolLimit ?? false, log);
const connectionCacheOverride = resolveConnectionCacheOverride(credentials?.providerSpecificData);
bodyToSend = await injectPromptCacheKey(
bodyToSend,
provider,
targetFormat,
connectionCacheOverride
);
return bodyToSend;
}