mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
* feat(providers): complete Jina AI via OmniRoute including Omni multimodal
Dashboard and env keys share one Jina credential pool, native v5 Omni
{text}/{image}/{content} docs pass through /v1/embeddings intact, and
classify/segment/search are proxied without a third unused Jina card.
* chore(changelog): name Jina complete-provider fragment for #10581
* feat(providers): make Gemini Embedding 2 multimodal work via OmniRoute
Route gemini-embedding-2 through embedContent/batchEmbedContents so N
OpenAI input items become N vectors, pass through native multimodal
parts, and use dashboard Gemini keys (GEMINI_API_KEY only as fallback).
* fix(providers): resolve rebase fallout for Jina/Gemini embeddings
- narrow the two new no-explicit-any violations introduced by this PR
(validateJinaFoundationProvider's params + catch, search.ts's
normalizeJinaSearchResponse data param)
- cast credentials to Record<string, unknown> at the two quota-preflight
call sites in src/sse/services/auth.ts so the new JinaEnvCredentials /
GeminiEnvCredentials union members type-check without loosening the
allRateLimited narrowing used elsewhere in the same function
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
700 lines
24 KiB
TypeScript
700 lines
24 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
ACCOUNT_FALLBACK_STRATEGY_VALUES,
|
|
ROUTING_STRATEGY_VALUES,
|
|
} from "@/shared/constants/routingStrategies";
|
|
import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
|
|
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
|
|
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
|
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
|
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
|
import {
|
|
isForbiddenUpstreamHeaderName,
|
|
isForbiddenCustomHeaderName,
|
|
} from "@/shared/constants/upstreamHeaders";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
|
import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard";
|
|
import {
|
|
effortRequestSchema,
|
|
thinkingRequestSchema,
|
|
} from "@/shared/reasoning/effortStandardization";
|
|
|
|
import { modelIdSchema, nonEmptyStringSchema } from "./misc.ts";
|
|
import {
|
|
isCanonicalEmbeddingItem,
|
|
JINA_NATIVE_MEDIA_KEYS,
|
|
} from "../jinaNativeEmbeddingInput.ts";
|
|
import { isGeminiNativeEmbeddingItem } from "../geminiNativeEmbeddingInput.ts";
|
|
|
|
export const embeddingTokenArraySchema = z
|
|
.array(z.number().int().min(0))
|
|
.min(1, "input token array must contain at least one item");
|
|
|
|
export const MAX_EMBEDDING_INPUT_ITEMS = 32;
|
|
export const MAX_EMBEDDING_INLINE_ITEM_BYTES = 8 * 1024 * 1024;
|
|
export const MAX_EMBEDDING_INLINE_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
const MAX_EMBEDDING_TEXT_LENGTH = 1_000_000;
|
|
const MAX_EMBEDDING_URL_LENGTH = 2048;
|
|
const MAX_MEDIA_TYPE_LENGTH = 255;
|
|
// Four base64 characters encode at most three bytes. Reject by encoded length first
|
|
// so multi-megabyte oversize payloads never reach format validation.
|
|
const MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH = Math.ceil(MAX_EMBEDDING_INLINE_ITEM_BYTES / 3) * 4;
|
|
const BASE64_CHUNK_RE = /^[A-Za-z0-9+/]{4}$/;
|
|
const BASE64_LAST_CHUNK_RE = /^(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
|
|
|
|
function decodedBase64Bytes(data: string): number {
|
|
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
return (data.length * 3) / 4 - padding;
|
|
}
|
|
|
|
/** Validate base64 without one giant RegExp over multi-megabyte strings. */
|
|
function isValidBase64(data: string): boolean {
|
|
if (data.length === 0 || data.length % 4 !== 0) return false;
|
|
for (let i = 0; i < data.length - 4; i += 4) {
|
|
if (!BASE64_CHUNK_RE.test(data.slice(i, i + 4))) return false;
|
|
}
|
|
return BASE64_LAST_CHUNK_RE.test(data.slice(data.length - 4));
|
|
}
|
|
|
|
const embeddingUrlSourceSchema = z.object({
|
|
type: z.literal("url"),
|
|
url: z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(MAX_EMBEDDING_URL_LENGTH)
|
|
.superRefine((value, context) => {
|
|
try {
|
|
const url = parseAndValidatePublicUrl(value);
|
|
if (url.protocol !== "https:") {
|
|
context.addIssue({ code: "custom", message: "media URLs must use HTTPS" });
|
|
}
|
|
} catch {
|
|
context.addIssue({ code: "custom", message: "media URL must be a safe public HTTPS URL" });
|
|
}
|
|
}),
|
|
});
|
|
|
|
const embeddingBase64SourceSchema = z.object({
|
|
type: z.literal("base64"),
|
|
data: z
|
|
.string()
|
|
.min(1)
|
|
.superRefine((data, context) => {
|
|
// Cheap encoded-length guard first. Same encoded length can still decode to
|
|
// 8 MiB + 1, so the decoded-byte check also runs before format validation.
|
|
if (
|
|
data.length > MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH ||
|
|
decodedBase64Bytes(data) > MAX_EMBEDDING_INLINE_ITEM_BYTES
|
|
) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "decoded inline media must not exceed 8 MiB",
|
|
});
|
|
return;
|
|
}
|
|
if (!isValidBase64(data)) {
|
|
context.addIssue({ code: "custom", message: "data must be valid base64" });
|
|
}
|
|
}),
|
|
media_type: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH),
|
|
});
|
|
|
|
const embeddingMediaSourceSchema = z.discriminatedUnion("type", [
|
|
embeddingUrlSourceSchema,
|
|
embeddingBase64SourceSchema,
|
|
]);
|
|
|
|
export const embeddingMultimodalItemSchema = z.discriminatedUnion("type", [
|
|
z.object({
|
|
type: z.literal("text"),
|
|
text: z.string().min(1).max(MAX_EMBEDDING_TEXT_LENGTH),
|
|
}),
|
|
...(["image", "audio", "video", "document"] as const).map((type) =>
|
|
z.object({ type: z.literal(type), source: embeddingMediaSourceSchema })
|
|
),
|
|
]);
|
|
|
|
function decodedInlineBytesFromEmbeddingItem(item: unknown): number {
|
|
if (!item || typeof item !== "object") return 0;
|
|
const record = item as Record<string, unknown>;
|
|
if (
|
|
"type" in record &&
|
|
record.type !== "text" &&
|
|
record.source &&
|
|
typeof record.source === "object"
|
|
) {
|
|
const source = record.source as { type?: string; data?: string };
|
|
if (source.type === "base64" && typeof source.data === "string") {
|
|
return decodedBase64Bytes(source.data);
|
|
}
|
|
}
|
|
for (const key of JINA_NATIVE_MEDIA_KEYS) {
|
|
if (key === "text" || typeof record[key] !== "string") continue;
|
|
const value = String(record[key]);
|
|
const dataUri = /^data:([^;,]+);base64,(.+)$/i.exec(value);
|
|
if (dataUri) return decodedBase64Bytes(dataUri[2]);
|
|
if (/^https:\/\//i.test(value)) return 0;
|
|
return decodedBase64Bytes(value);
|
|
}
|
|
if (Array.isArray(record.content)) {
|
|
return record.content.reduce(
|
|
(total, chunk) => total + decodedInlineBytesFromEmbeddingItem(chunk),
|
|
0
|
|
);
|
|
}
|
|
if (record.content && typeof record.content === "object" && !Array.isArray(record.content)) {
|
|
return decodedInlineBytesFromEmbeddingItem(record.content);
|
|
}
|
|
if (Array.isArray(record.parts)) {
|
|
return record.parts.reduce(
|
|
(total, chunk) => total + decodedInlineBytesFromEmbeddingItem(chunk),
|
|
0
|
|
);
|
|
}
|
|
const inline = record.inline_data ?? record.inlineData;
|
|
if (inline && typeof inline === "object") {
|
|
const data = (inline as { data?: unknown }).data;
|
|
if (typeof data === "string") return decodedBase64Bytes(data);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const embeddingMultimodalInputSchema = z
|
|
.array(embeddingMultimodalItemSchema)
|
|
.min(1, "input must contain at least one item")
|
|
.max(MAX_EMBEDDING_INPUT_ITEMS, `input must contain at most ${MAX_EMBEDDING_INPUT_ITEMS} items`)
|
|
.superRefine((items, context) => {
|
|
const totalBytes = items.reduce(
|
|
(total, item) => total + decodedInlineBytesFromEmbeddingItem(item),
|
|
0
|
|
);
|
|
if (totalBytes > MAX_EMBEDDING_INLINE_TOTAL_BYTES) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "decoded inline media must not exceed 16 MiB per request",
|
|
});
|
|
}
|
|
});
|
|
|
|
function refineJinaMediaString(value: string, context: z.RefinementCtx) {
|
|
const trimmed = value.trim();
|
|
if (/^https:\/\//i.test(trimmed)) {
|
|
if (trimmed.length > MAX_EMBEDDING_URL_LENGTH) {
|
|
context.addIssue({ code: "custom", message: "media URL is too long" });
|
|
return;
|
|
}
|
|
try {
|
|
const url = parseAndValidatePublicUrl(trimmed);
|
|
if (url.protocol !== "https:") {
|
|
context.addIssue({ code: "custom", message: "media URLs must use HTTPS" });
|
|
}
|
|
} catch {
|
|
context.addIssue({ code: "custom", message: "media URL must be a safe public HTTPS URL" });
|
|
}
|
|
return;
|
|
}
|
|
if (/^(https?:|file:|data:text\/html)/i.test(trimmed) && !trimmed.startsWith("data:")) {
|
|
context.addIssue({ code: "custom", message: "media URL must be a safe public HTTPS URL" });
|
|
return;
|
|
}
|
|
const dataUri = /^data:([^;,]+);base64,(.+)$/i.exec(trimmed);
|
|
const payload = dataUri ? dataUri[2] : trimmed;
|
|
if (
|
|
payload.length > MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH ||
|
|
decodedBase64Bytes(payload) > MAX_EMBEDDING_INLINE_ITEM_BYTES
|
|
) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "decoded inline media must not exceed 8 MiB",
|
|
});
|
|
}
|
|
}
|
|
|
|
const jinaNativeMediaStringSchema = z.string().trim().min(1).superRefine(refineJinaMediaString);
|
|
|
|
function exactlyOneJinaMediaKey(value: Record<string, unknown>, key: string): boolean {
|
|
if (isCanonicalEmbeddingItem(value)) return false;
|
|
return JINA_NATIVE_MEDIA_KEYS.filter((mediaKey) => mediaKey in value).length === 1 && key in value;
|
|
}
|
|
|
|
const jinaTextDocSchema = z
|
|
.object({ text: z.string().trim().min(1).max(MAX_EMBEDDING_TEXT_LENGTH) })
|
|
.passthrough()
|
|
.refine((value) => exactlyOneJinaMediaKey(value, "text"), {
|
|
message: "Jina TextDoc must be { text }",
|
|
});
|
|
|
|
const jinaImageDocSchema = z
|
|
.object({ image: jinaNativeMediaStringSchema })
|
|
.passthrough()
|
|
.refine((value) => exactlyOneJinaMediaKey(value, "image"), {
|
|
message: "Jina ImageDoc must be { image }",
|
|
});
|
|
|
|
const jinaAudioDocSchema = z
|
|
.object({ audio: jinaNativeMediaStringSchema })
|
|
.passthrough()
|
|
.refine((value) => exactlyOneJinaMediaKey(value, "audio"), {
|
|
message: "Jina AudioDoc must be { audio }",
|
|
});
|
|
|
|
const jinaVideoDocSchema = z
|
|
.object({ video: jinaNativeMediaStringSchema })
|
|
.passthrough()
|
|
.refine((value) => exactlyOneJinaMediaKey(value, "video"), {
|
|
message: "Jina VideoDoc must be { video }",
|
|
});
|
|
|
|
const jinaPdfDocSchema = z
|
|
.object({ pdf: jinaNativeMediaStringSchema })
|
|
.passthrough()
|
|
.refine((value) => exactlyOneJinaMediaKey(value, "pdf"), {
|
|
message: "Jina PDFDoc must be { pdf }",
|
|
});
|
|
|
|
export const jinaNativeDocSchema = z.union([
|
|
jinaTextDocSchema,
|
|
jinaImageDocSchema,
|
|
jinaAudioDocSchema,
|
|
jinaVideoDocSchema,
|
|
jinaPdfDocSchema,
|
|
]);
|
|
|
|
export const jinaMergedContentGroupSchema = z
|
|
.object({
|
|
content: z
|
|
.array(z.union([jinaTextDocSchema, jinaImageDocSchema, jinaAudioDocSchema, jinaVideoDocSchema]))
|
|
.min(1, "content must contain at least one chunk"),
|
|
})
|
|
.passthrough();
|
|
|
|
const geminiInlineBlobSchema = z
|
|
.object({
|
|
mime_type: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(),
|
|
mimeType: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(),
|
|
data: z.string().min(1),
|
|
})
|
|
.passthrough()
|
|
.superRefine((value, context) => {
|
|
if (!value.mime_type && !value.mimeType) {
|
|
context.addIssue({ code: "custom", message: "Gemini inline_data requires mime_type" });
|
|
}
|
|
const data = value.data;
|
|
if (
|
|
data.length > MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH ||
|
|
decodedBase64Bytes(data) > MAX_EMBEDDING_INLINE_ITEM_BYTES
|
|
) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "decoded inline media must not exceed 8 MiB",
|
|
});
|
|
}
|
|
});
|
|
|
|
const geminiFileUriSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(MAX_EMBEDDING_URL_LENGTH)
|
|
.superRefine((value, context) => {
|
|
if (value.startsWith("files/")) return;
|
|
try {
|
|
const url = parseAndValidatePublicUrl(value);
|
|
if (url.protocol !== "https:") {
|
|
context.addIssue({ code: "custom", message: "media URLs must use HTTPS" });
|
|
}
|
|
} catch {
|
|
context.addIssue({ code: "custom", message: "media URL must be a safe public HTTPS URL" });
|
|
}
|
|
});
|
|
|
|
const geminiFileDataSchema = z
|
|
.object({
|
|
mime_type: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(),
|
|
mimeType: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(),
|
|
file_uri: geminiFileUriSchema.optional(),
|
|
fileUri: geminiFileUriSchema.optional(),
|
|
})
|
|
.passthrough()
|
|
.refine((value) => Boolean(value.file_uri || value.fileUri), {
|
|
message: "Gemini file_data requires file_uri",
|
|
});
|
|
|
|
export const geminiNativePartSchema = z
|
|
.object({
|
|
text: z.string().trim().min(1).max(MAX_EMBEDDING_TEXT_LENGTH).optional(),
|
|
inline_data: geminiInlineBlobSchema.optional(),
|
|
inlineData: geminiInlineBlobSchema.optional(),
|
|
file_data: geminiFileDataSchema.optional(),
|
|
fileData: geminiFileDataSchema.optional(),
|
|
})
|
|
.passthrough()
|
|
.refine((value) => isGeminiNativeEmbeddingItem(value) && !("parts" in value) && !("content" in value), {
|
|
message: "Gemini part must be { text }, { inline_data }, or { file_data }",
|
|
});
|
|
|
|
export const geminiNativeContentSchema = z
|
|
.object({
|
|
parts: z.array(geminiNativePartSchema).min(1, "parts must contain at least one part"),
|
|
})
|
|
.passthrough();
|
|
|
|
export const geminiNativeEmbedRequestSchema = z
|
|
.object({
|
|
content: geminiNativeContentSchema,
|
|
})
|
|
.passthrough();
|
|
|
|
export const geminiNativeItemSchema = z.union([
|
|
geminiNativePartSchema,
|
|
geminiNativeContentSchema,
|
|
geminiNativeEmbedRequestSchema,
|
|
]);
|
|
|
|
const jinaNativeOrCanonicalArraySchema = z
|
|
.array(
|
|
z.union([
|
|
nonEmptyStringSchema,
|
|
embeddingMultimodalItemSchema,
|
|
jinaNativeDocSchema,
|
|
jinaMergedContentGroupSchema,
|
|
geminiNativeItemSchema,
|
|
])
|
|
)
|
|
.min(1, "input must contain at least one item")
|
|
.max(MAX_EMBEDDING_INPUT_ITEMS, `input must contain at most ${MAX_EMBEDDING_INPUT_ITEMS} items`)
|
|
.superRefine((items, context) => {
|
|
const totalBytes = items.reduce(
|
|
(total, item) => total + decodedInlineBytesFromEmbeddingItem(item),
|
|
0
|
|
);
|
|
if (totalBytes > MAX_EMBEDDING_INLINE_TOTAL_BYTES) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "decoded inline media must not exceed 16 MiB per request",
|
|
});
|
|
}
|
|
});
|
|
|
|
export const embeddingInputSchema = z.union([
|
|
nonEmptyStringSchema,
|
|
z.array(nonEmptyStringSchema).min(1, "input must contain at least one item"),
|
|
embeddingTokenArraySchema,
|
|
z.array(embeddingTokenArraySchema).min(1, "input must contain at least one item"),
|
|
embeddingMultimodalInputSchema,
|
|
jinaNativeDocSchema,
|
|
jinaMergedContentGroupSchema,
|
|
geminiNativeItemSchema,
|
|
jinaNativeOrCanonicalArraySchema,
|
|
]);
|
|
|
|
export type EmbeddingMultimodalItem = z.infer<typeof embeddingMultimodalItemSchema>;
|
|
|
|
export const chatMessageSchema = z
|
|
.object({
|
|
role: z.string().trim().min(1, "messages[].role is required"),
|
|
content: z.union([nonEmptyStringSchema, z.array(z.unknown()).min(1), z.null()]).optional(),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
export const countTokensMessageSchema = z
|
|
.object({
|
|
content: z.union([
|
|
nonEmptyStringSchema,
|
|
z
|
|
.array(
|
|
z
|
|
.object({
|
|
type: z.string().optional(),
|
|
text: z.string().optional(),
|
|
})
|
|
.catchall(z.unknown())
|
|
)
|
|
.min(1, "messages[].content must contain at least one item"),
|
|
]),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
export const v1EmbeddingsSchema = z
|
|
.object({
|
|
model: modelIdSchema,
|
|
input: embeddingInputSchema,
|
|
dimensions: z.coerce.number().int().positive().optional(),
|
|
encoding_format: z.enum(["float", "base64"]).optional(),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
export const v1ImageGenerationSchema = z
|
|
.object({
|
|
model: modelIdSchema,
|
|
prompt: nonEmptyStringSchema.optional(),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
// POST /v1/images/upscale — image→image super-resolution. `prompt` is optional here
|
|
// (only Stability conservative/creative need one, enforced by the route/handler), but a
|
|
// resolvable source image is mandatory and validated by the route after extraction.
|
|
export const v1ImageUpscaleSchema = z
|
|
.object({
|
|
model: modelIdSchema,
|
|
prompt: nonEmptyStringSchema.optional(),
|
|
factor: z.union([z.number(), z.string()]).optional(),
|
|
creativity: z.union([z.number(), z.string()]).optional(),
|
|
response_format: z.enum(["url", "b64_json"]).optional(),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
|
|
export const v1AudioSpeechSchema = z
|
|
.object({
|
|
model: modelIdSchema,
|
|
input: nonEmptyStringSchema,
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
export const v1ModerationSchema = z
|
|
.object({
|
|
model: modelIdSchema.optional(),
|
|
input: z.unknown().refine((value) => {
|
|
if (value === undefined || value === null) return false;
|
|
if (typeof value === "string") return value.trim().length > 0;
|
|
if (Array.isArray(value)) return value.length > 0;
|
|
return true;
|
|
}, "Input is required"),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
// Mistral OCR: `document` is a { type, document_url | image_url } object.
|
|
// Keep the schema permissive-but-typed — validate model + that a non-empty
|
|
// `document` object (or a document_url/image_url string shorthand) is present.
|
|
export const v1OcrDocumentSchema = z.union([
|
|
z
|
|
.object({
|
|
type: z.string().trim().min(1).optional(),
|
|
document_url: z.string().trim().min(1).optional(),
|
|
image_url: z.union([z.string().trim().min(1), z.record(z.string(), z.unknown())]).optional(),
|
|
})
|
|
.catchall(z.unknown())
|
|
.refine(
|
|
(value) => value.document_url !== undefined || value.image_url !== undefined,
|
|
"document must include document_url or image_url"
|
|
),
|
|
nonEmptyStringSchema,
|
|
]);
|
|
|
|
export const v1OcrSchema = z
|
|
.object({
|
|
model: modelIdSchema.optional(),
|
|
document: v1OcrDocumentSchema,
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
export const v1RerankSchema = z
|
|
.object({
|
|
model: modelIdSchema,
|
|
query: nonEmptyStringSchema,
|
|
documents: z.array(z.unknown()).min(1, "documents must contain at least one item"),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
// POST /v1/classify — Jina zero/few-shot classification (api.jina.ai).
|
|
export const v1ClassifySchema = z
|
|
.object({
|
|
model: modelIdSchema.optional(),
|
|
classifier_id: z.string().trim().min(1).optional(),
|
|
input: z.union([
|
|
nonEmptyStringSchema,
|
|
z.array(z.unknown()).min(1, "input must contain at least one item"),
|
|
]),
|
|
labels: z.array(z.string().trim().min(1)).min(1).optional(),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
// POST /v1/segment — Jina segmenter (segment.jina.ai).
|
|
export const v1SegmentSchema = z
|
|
.object({
|
|
content: nonEmptyStringSchema,
|
|
tokenizer: z.string().trim().min(1).optional(),
|
|
return_tokens: z.boolean().optional(),
|
|
return_chunks: z.boolean().optional(),
|
|
max_chunk_length: z.coerce.number().positive().optional(),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
export const providerChatCompletionSchema = z
|
|
.object({
|
|
model: modelIdSchema,
|
|
messages: z.array(chatMessageSchema).min(1).optional(),
|
|
input: z.union([nonEmptyStringSchema, z.array(z.unknown()).min(1)]).optional(),
|
|
prompt: nonEmptyStringSchema.optional(),
|
|
// Canonical, provider-agnostic reasoning controls (#6241). `effort` reuses the shared
|
|
// none/low/medium/high/xhigh vocabulary (UI tiers extra/max collapse onto xhigh);
|
|
// `thinking` is a simple boolean toggle. Both are optional and normalized onto the
|
|
// per-provider reasoning fields (reasoning_effort / reasoning.effort / thinking) by
|
|
// normalizeReasoningRequest before translation — an explicit client reasoning_effort /
|
|
// reasoning / object-shaped thinking always wins. See
|
|
// @/shared/reasoning/effortStandardization.
|
|
effort: effortRequestSchema.optional(),
|
|
thinking: thinkingRequestSchema.optional(),
|
|
})
|
|
.catchall(z.unknown())
|
|
.superRefine((value, ctx) => {
|
|
if (value.messages === undefined && value.input === undefined && value.prompt === undefined) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "messages, input or prompt is required",
|
|
path: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const v1CountTokensSchema = z
|
|
.object({
|
|
messages: z.array(countTokensMessageSchema).min(1, "messages must contain at least one item"),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
// ── Search Schemas ─────────────────────────────────────────────────────
|
|
// Unified search request/response schemas. Final contract — all fields optional
|
|
// with defaults. New features add implementations, not new fields.
|
|
// Multi-query deferred to POST /v1/search/batch (separate PRD).
|
|
|
|
export const v1SearchSchema = z
|
|
.object({
|
|
// Core
|
|
query: z
|
|
.string()
|
|
.trim()
|
|
.min(1, "Query is required")
|
|
.max(500, "Query must be 500 characters or fewer"),
|
|
provider: z
|
|
.enum([
|
|
"serper-search",
|
|
"brave-search",
|
|
"perplexity-search",
|
|
"exa-search",
|
|
"tavily-search",
|
|
"firecrawl",
|
|
"google-pse-search",
|
|
"linkup-search",
|
|
"ollama-search",
|
|
"searchapi-search",
|
|
"youcom-search",
|
|
"searxng-search",
|
|
"zai-search",
|
|
"jina-search",
|
|
"jina-ai",
|
|
"jina",
|
|
"duckduckgo-free",
|
|
])
|
|
.optional(),
|
|
max_results: z.coerce.number().int().min(1).max(100).default(5),
|
|
search_type: z.enum(["web", "news"]).default("web"),
|
|
offset: z.coerce.number().int().min(0).default(0),
|
|
|
|
// Locale
|
|
country: z.string().max(2).toUpperCase().optional(),
|
|
language: z.string().min(2).max(5).optional(),
|
|
time_range: z.enum(["any", "hour", "day", "week", "month", "year"]).optional(),
|
|
|
|
// Content control
|
|
content: z
|
|
.object({
|
|
snippet: z.boolean().default(true),
|
|
full_page: z.boolean().default(false),
|
|
format: z.enum(["text", "markdown"]).default("text"),
|
|
max_characters: z.coerce.number().int().min(100).max(100000).optional(),
|
|
})
|
|
.optional(),
|
|
|
|
// Filters
|
|
filters: z
|
|
.object({
|
|
include_domains: z.array(z.string().max(253)).max(20).optional(),
|
|
exclude_domains: z.array(z.string().max(253)).max(20).optional(),
|
|
safe_search: z.enum(["off", "moderate", "strict"]).optional(),
|
|
})
|
|
.optional(),
|
|
|
|
// Answer synthesis (Phase 2 — returns null until implemented)
|
|
synthesis: z
|
|
.object({
|
|
strategy: z.enum(["none", "auto", "provider", "internal"]).default("none"),
|
|
model: z.string().optional(),
|
|
max_tokens: z.coerce.number().int().min(1).max(4000).optional(),
|
|
})
|
|
.optional(),
|
|
|
|
// Provider-specific passthrough
|
|
provider_options: z.record(z.string(), z.unknown()).optional(),
|
|
|
|
// Strict mode — reject if provider doesn't support a requested filter
|
|
strict_filters: z.boolean().default(false),
|
|
})
|
|
.catchall(z.unknown());
|
|
|
|
export const searchResultSchema = z.object({
|
|
title: z.string(),
|
|
url: z.string(),
|
|
display_url: z.string().optional(),
|
|
snippet: z.string(),
|
|
position: z.number().int().positive(),
|
|
score: z.number().min(0).max(1).nullable().optional(),
|
|
published_at: z.string().nullable().optional(),
|
|
favicon_url: z.string().nullable().optional(),
|
|
content: z
|
|
.object({
|
|
format: z.enum(["text", "markdown"]).optional(),
|
|
text: z.string().optional(),
|
|
length: z.number().int().optional(),
|
|
})
|
|
.nullable()
|
|
.optional(),
|
|
metadata: z
|
|
.object({
|
|
author: z.string().nullable().optional(),
|
|
language: z.string().nullable().optional(),
|
|
source_type: z
|
|
.enum(["article", "blog", "forum", "video", "academic", "news", "other"])
|
|
.nullable()
|
|
.optional(),
|
|
image_url: z.string().nullable().optional(),
|
|
})
|
|
.nullable()
|
|
.optional(),
|
|
citation: z.object({
|
|
provider: z.string(),
|
|
retrieved_at: z.string(),
|
|
rank: z.number().int().positive(),
|
|
}),
|
|
provider_raw: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
});
|
|
|
|
export const v1BatchCreateSchema = z.object({
|
|
input_file_id: z.string().min(1),
|
|
endpoint: z.enum(SUPPORTED_BATCH_ENDPOINTS),
|
|
completion_window: z.enum(["24h"]),
|
|
metadata: z
|
|
.record(z.string().max(64), z.string().max(512))
|
|
.refine((m) => Object.keys(m).length <= 16, { message: "metadata may have at most 16 keys" })
|
|
.optional(),
|
|
output_expires_after: z
|
|
.object({
|
|
anchor: z.enum(["created_at"]),
|
|
seconds: z.number().int().min(3600).max(2592000),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
// ── Web Fetch ─────────────────────────────────────────────────────────────────
|
|
|
|
export const v1WebFetchSchema = z.object({
|
|
url: z.string().url("url must be a valid URL (http/https)"),
|
|
provider: z.enum(["firecrawl", "jina-reader", "tavily-search", "tinyfish"]).optional(),
|
|
format: z.enum(["markdown", "html", "links", "screenshot"]).default("markdown"),
|
|
depth: z.union([z.literal(0), z.literal(1), z.literal(2)]).default(0),
|
|
wait_for_selector: z.string().max(256).optional(),
|
|
include_metadata: z.boolean().default(false),
|
|
});
|