mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
138 lines
5.1 KiB
TypeScript
138 lines
5.1 KiB
TypeScript
/**
|
|
* AI SDK compatibility helpers (T26).
|
|
*/
|
|
|
|
export type StreamDefaultMode = "legacy" | "json";
|
|
|
|
export interface ResolveStreamFlagOptions {
|
|
userAgent?: unknown;
|
|
streamDefaultMode?: unknown;
|
|
}
|
|
|
|
function normalizeResolveStreamFlagOptions(optionsOrUserAgent?: unknown): ResolveStreamFlagOptions {
|
|
if (
|
|
optionsOrUserAgent &&
|
|
typeof optionsOrUserAgent === "object" &&
|
|
!Array.isArray(optionsOrUserAgent)
|
|
) {
|
|
return optionsOrUserAgent as ResolveStreamFlagOptions;
|
|
}
|
|
return { userAgent: optionsOrUserAgent };
|
|
}
|
|
|
|
export function normalizeStreamDefaultMode(value: unknown): StreamDefaultMode {
|
|
return value === "json" ? "json" : "legacy";
|
|
}
|
|
|
|
/**
|
|
* Detects when a client explicitly prefers JSON (non-SSE) responses.
|
|
*/
|
|
export function clientWantsJsonResponse(acceptHeader: unknown): boolean {
|
|
if (typeof acceptHeader !== "string") return false;
|
|
const normalized = acceptHeader.toLowerCase();
|
|
return normalized.includes("application/json") && !normalized.includes("text/event-stream");
|
|
}
|
|
|
|
/**
|
|
* Resolves stream behavior from request body + Accept header.
|
|
* Priority: explicit `stream: true/false` in body wins.
|
|
* Accept header only acts as fallback when stream is not explicitly set.
|
|
* Fixes #656: clients sending both `stream: true` and `Accept: application/json`
|
|
* should still get streaming responses — body intent takes precedence.
|
|
*
|
|
* Optional `sourceFormat` argument lets callers apply spec-correct defaults
|
|
* when both `stream` and `Accept` are ambiguous. The Anthropic Messages API
|
|
* defaults to non-stream when the body omits `stream`, regardless of Accept
|
|
* header. Without this hint, OmniRoute previously routed Anthropic /v1/messages
|
|
* requests with a curl-default wildcard Accept header through the streaming
|
|
* branch even though upstream returned JSON, producing STREAM_EARLY_EOF /
|
|
* HTTP 502 errors.
|
|
*/
|
|
export function resolveStreamFlag(
|
|
bodyStream: unknown,
|
|
acceptHeader: unknown,
|
|
sourceFormat?: string,
|
|
optionsOrUserAgent?: unknown
|
|
): boolean {
|
|
// Explicit body value always wins
|
|
if (bodyStream === true) return true;
|
|
if (bodyStream === false) return false;
|
|
|
|
const options = normalizeResolveStreamFlagOptions(optionsOrUserAgent);
|
|
const streamDefaultMode = normalizeStreamDefaultMode(options.streamDefaultMode);
|
|
|
|
const acceptsEventStream =
|
|
typeof acceptHeader === "string" && /text\/event-stream/i.test(acceptHeader);
|
|
|
|
// Anthropic Messages API spec: stream defaults to false when body omits it.
|
|
// Only honor an explicit text/event-stream Accept header as a streaming opt-in
|
|
// for /v1/messages — otherwise default to non-stream so upstream JSON responses
|
|
// are surfaced correctly instead of triggering stream_early_eof.
|
|
if (sourceFormat === "claude") {
|
|
if (acceptsEventStream) return true;
|
|
return false;
|
|
}
|
|
|
|
// Nextcloud's OpenAI/LocalAI integration sends synchronous JSON requests and
|
|
// does not set `stream: false`. With a wildcard/empty Accept header, the legacy
|
|
// OmniRoute fallback would force SSE upstream and fail JSON-only providers as
|
|
// STREAM_EARLY_EOF before Nextcloud could receive a response.
|
|
if (isKnownJsonOnlyClient(options.userAgent) && !acceptsEventStream) {
|
|
return false;
|
|
}
|
|
|
|
// Per-key compatibility mode for synchronous OpenAI-compatible clients that
|
|
// omit `stream`. This preserves legacy behavior by default while allowing an
|
|
// API key to use the OpenAI-compatible JSON default unless SSE is explicit.
|
|
if (streamDefaultMode === "json" && !acceptsEventStream) {
|
|
return false;
|
|
}
|
|
|
|
// No explicit stream param — preserve OmniRoute's streaming default unless
|
|
// the client explicitly asks for JSON and does not also accept SSE.
|
|
return !clientWantsJsonResponse(acceptHeader);
|
|
}
|
|
|
|
export function isKnownJsonOnlyClient(userAgent: unknown): boolean {
|
|
if (typeof userAgent !== "string") return false;
|
|
return /nextcloud\s+openai\/localai\s+integration/i.test(userAgent);
|
|
}
|
|
|
|
/**
|
|
* Resolves explicit stream aliases used by non-standard clients.
|
|
* Returns:
|
|
* - `true` -> explicit streaming intent
|
|
* - `false` -> explicit non-stream intent
|
|
* - `undefined` -> no explicit alias present
|
|
*/
|
|
export function resolveExplicitStreamAlias(body: unknown): boolean | undefined {
|
|
if (!body || typeof body !== "object") return undefined;
|
|
const b = body as Record<string, unknown>;
|
|
|
|
if (b.streaming === true) return true;
|
|
if (b.streaming === false) return false;
|
|
if (b.non_stream === true) return false;
|
|
if (b.disable_stream === true) return false;
|
|
if (b.disable_streaming === true) return false;
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Backward-compatible helper used by tests/legacy call sites.
|
|
*/
|
|
export function hasExplicitNoStreamParam(body: unknown): boolean {
|
|
return resolveExplicitStreamAlias(body) === false;
|
|
}
|
|
|
|
/**
|
|
* Removes surrounding markdown code fences when Claude wraps JSON payloads.
|
|
* Example: ```json\n{"ok":true}\n``` -> {"ok":true}
|
|
*/
|
|
export function stripMarkdownCodeFence(text: unknown): unknown {
|
|
if (typeof text !== "string") return text;
|
|
const codeBlockRegex = /^```(?:json|javascript|typescript|js|ts)?\s*\n?([\s\S]*?)\n?```\s*$/i;
|
|
const match = text.trim().match(codeBlockRegex);
|
|
return match ? match[1].trim() : text;
|
|
}
|