mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
feat(providers): complete Jina + Gemini Embedding 2 multimodal via OmniRoute (#10581)
* 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>
This commit is contained in:
@@ -35,7 +35,7 @@ const FETCH_PROVIDERS: FetchProviderDef[] = [
|
||||
},
|
||||
{
|
||||
id: "jina-reader",
|
||||
name: "Jina Reader",
|
||||
name: "Jina Reader (r.jina.ai)",
|
||||
costPerQuery: 0.0005,
|
||||
freeMonthlyQuota: 1000,
|
||||
fetchFormats: ["markdown", "text"],
|
||||
|
||||
79
src/app/api/v1/classify/route.ts
Normal file
79
src/app/api/v1/classify/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { handleJinaFoundationProxy } from "@omniroute/open-sse/handlers/jinaFoundation.ts";
|
||||
import {
|
||||
getProviderCredentialsWithQuotaPreflight,
|
||||
clearRecoveredProviderState,
|
||||
} from "@/sse/services/auth";
|
||||
import { withInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { v1ClassifySchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import {
|
||||
isAllRateLimitedCredentials,
|
||||
rateLimitedProviderResponse,
|
||||
} from "@/app/api/v1/_shared/rateLimit";
|
||||
import { JINA_FOUNDATION_BASE_URL, JINA_FOUNDATION_PROVIDER_ID } from "@/lib/providers/jina";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/classify — Jina zero/few-shot classification.
|
||||
*
|
||||
* Proxies to https://api.jina.ai/v1/classify using jina-ai dashboard
|
||||
* credentials (or JINA_AI_API_KEY when no dashboard key exists).
|
||||
*/
|
||||
async function postHandler(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
const validation = validateBody(v1ClassifySchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
|
||||
}
|
||||
const body = validation.data;
|
||||
const model = typeof body.model === "string" ? body.model : undefined;
|
||||
|
||||
const policy = await enforceApiKeyPolicy(request, model || "jina-ai/classify");
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const credentials = await getProviderCredentialsWithQuotaPreflight(JINA_FOUNDATION_PROVIDER_ID);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No credentials for provider: ${JINA_FOUNDATION_PROVIDER_ID}`
|
||||
);
|
||||
}
|
||||
if (isAllRateLimitedCredentials(credentials)) {
|
||||
return rateLimitedProviderResponse(JINA_FOUNDATION_PROVIDER_ID, credentials);
|
||||
}
|
||||
|
||||
const response = await handleJinaFoundationProxy({
|
||||
path: "/v1/classify",
|
||||
upstreamUrl: `${JINA_FOUNDATION_BASE_URL}/v1/classify`,
|
||||
body,
|
||||
credentials,
|
||||
provider: JINA_FOUNDATION_PROVIDER_ID,
|
||||
model: model || null,
|
||||
});
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const POST = withInjectionGuard(postHandler);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
getAllSearchProviders,
|
||||
getSearchProvider,
|
||||
resolveSearchProvider,
|
||||
selectProvider,
|
||||
supportsSearchType,
|
||||
SEARCH_PROVIDERS,
|
||||
@@ -129,7 +130,7 @@ async function postHandler(request: Request, context: unknown) {
|
||||
|
||||
// Resolve provider and credentials
|
||||
if (body.provider) {
|
||||
const explicitProvider = getSearchProvider(body.provider);
|
||||
const explicitProvider = resolveSearchProvider(body.provider);
|
||||
if (!explicitProvider) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown search provider: ${body.provider}`);
|
||||
}
|
||||
|
||||
79
src/app/api/v1/segment/route.ts
Normal file
79
src/app/api/v1/segment/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { handleJinaFoundationProxy } from "@omniroute/open-sse/handlers/jinaFoundation.ts";
|
||||
import {
|
||||
getProviderCredentialsWithQuotaPreflight,
|
||||
clearRecoveredProviderState,
|
||||
} from "@/sse/services/auth";
|
||||
import { withInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { v1SegmentSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import {
|
||||
isAllRateLimitedCredentials,
|
||||
rateLimitedProviderResponse,
|
||||
} from "@/app/api/v1/_shared/rateLimit";
|
||||
import { JINA_FOUNDATION_PROVIDER_ID, JINA_SEGMENT_BASE_URL } from "@/lib/providers/jina";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/segment — Jina segmenter (tokenize / chunk).
|
||||
*
|
||||
* Proxies to https://segment.jina.ai/ using the same jina-ai credentials as
|
||||
* embeddings and classify. Segment lives on a dedicated host; the UI card is
|
||||
* still Foundation API, not Reader.
|
||||
*/
|
||||
async function postHandler(request: Request) {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
const validation = validateBody(v1SegmentSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
|
||||
}
|
||||
const body = validation.data;
|
||||
|
||||
const policy = await enforceApiKeyPolicy(request, "jina-ai/segment");
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const credentials = await getProviderCredentialsWithQuotaPreflight(JINA_FOUNDATION_PROVIDER_ID);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No credentials for provider: ${JINA_FOUNDATION_PROVIDER_ID}`
|
||||
);
|
||||
}
|
||||
if (isAllRateLimitedCredentials(credentials)) {
|
||||
return rateLimitedProviderResponse(JINA_FOUNDATION_PROVIDER_ID, credentials);
|
||||
}
|
||||
|
||||
const response = await handleJinaFoundationProxy({
|
||||
path: "/v1/segment",
|
||||
upstreamUrl: `${JINA_SEGMENT_BASE_URL}/`,
|
||||
body,
|
||||
credentials,
|
||||
provider: JINA_FOUNDATION_PROVIDER_ID,
|
||||
model: "segment",
|
||||
});
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const POST = withInjectionGuard(postHandler);
|
||||
@@ -6094,8 +6094,8 @@
|
||||
"inception": "Inception Labs is OpenAI-compatible at https://api.inceptionlabs.ai/v1. mercury-2 is the first diffusion LLM (dLLM) in the catalog — 5-10x faster generation than comparable autoregressive models, with tool calling, json_mode, and structured outputs.",
|
||||
"inference-net": "$25 free credits on signup plus research grants available",
|
||||
"internlm": "Free monthly quota ~1M input / 3M output tokens (~10 RPM)",
|
||||
"jina-ai": "Bearer API key for the Jina AI rerank API.",
|
||||
"jina-reader": "Connect Jina Reader with an API key.",
|
||||
"jina-ai": "Bearer API key for api.jina.ai (embeddings, rerank, classify, segment, search). Not the Reader / r.jina.ai card. Dashboard keys take precedence over JINA_AI_API_KEY.",
|
||||
"jina-reader": "Bearer API key for r.jina.ai URL-to-markdown only. Does not serve /v1/embeddings or /v1/rerank. The same Jina token as Foundation API works.",
|
||||
"kenari": "Kenari exposes an OpenAI-compatible chat completions endpoint at https://kenari.id/v1/chat/completions, plus a live /v1/models catalog covering Claude, GPT, DeepSeek, GLM, Kimi and more. OmniRoute uses the OpenAI protocol and lists models via passthrough.",
|
||||
"kie": "Connect KIE.AI with an API key.",
|
||||
"kilo-gateway": "Connect Kilo Gateway with an API key.",
|
||||
|
||||
@@ -311,6 +311,7 @@ export async function createEmbeddingResponse(
|
||||
connectionId:
|
||||
((credentials as { connectionId?: string } | null)?.connectionId) ||
|
||||
options.connectionId ||
|
||||
connectionIdForProxy ||
|
||||
null,
|
||||
});
|
||||
|
||||
|
||||
86
src/lib/providers/gemini.ts
Normal file
86
src/lib/providers/gemini.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Shared Gemini (Google AI Studio) integration helpers.
|
||||
*
|
||||
* Dashboard `gemini` connections stay preferred. GEMINI_API_KEY /
|
||||
* GOOGLE_API_KEY are a headless fallback when no usable dashboard key
|
||||
* exists — the same class of bug as unused JINA_AI_API_KEY. Do not treat
|
||||
* the env as billed if a dashboard connection is selected (fill-first).
|
||||
*/
|
||||
|
||||
export const GEMINI_PROVIDER_ID = "gemini";
|
||||
|
||||
/** Call-log / credential sentinel when the request used the process env key. */
|
||||
export const GEMINI_ENV_CONNECTION_ID = "env:GEMINI_API_KEY";
|
||||
|
||||
export const GEMINI_ENV_API_KEY_NAMES = ["GEMINI_API_KEY", "GOOGLE_API_KEY"] as const;
|
||||
|
||||
export interface GeminiEnvCredentials {
|
||||
apiKey: string;
|
||||
accessToken: null;
|
||||
connectionId: typeof GEMINI_ENV_CONNECTION_ID;
|
||||
id: typeof GEMINI_ENV_CONNECTION_ID;
|
||||
provider: string;
|
||||
authType: "apikey";
|
||||
defaultModel: null;
|
||||
}
|
||||
|
||||
export function isGeminiCredentialProvider(providerId: string | null | undefined): boolean {
|
||||
return providerId === GEMINI_PROVIDER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first non-empty Gemini env key. Dashboard connections always win
|
||||
* when getProviderCredentials finds one.
|
||||
*/
|
||||
export function readGeminiEnvApiKey(): string | null {
|
||||
for (const name of GEMINI_ENV_API_KEY_NAMES) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic credentials for headless / Docker operators who inject
|
||||
* GEMINI_API_KEY (or GOOGLE_API_KEY) instead of adding a dashboard connection.
|
||||
*/
|
||||
export function buildGeminiEnvCredentials(
|
||||
providerId: string,
|
||||
options: {
|
||||
forcedConnectionId?: string | null;
|
||||
allowedConnections?: string[] | null;
|
||||
excludedConnectionIds?: Iterable<string> | null;
|
||||
} = {}
|
||||
): GeminiEnvCredentials | null {
|
||||
if (!isGeminiCredentialProvider(providerId)) return null;
|
||||
|
||||
const forced =
|
||||
typeof options.forcedConnectionId === "string" && options.forcedConnectionId.trim().length > 0
|
||||
? options.forcedConnectionId.trim()
|
||||
: null;
|
||||
if (forced && forced !== GEMINI_ENV_CONNECTION_ID) return null;
|
||||
|
||||
const allowed = options.allowedConnections;
|
||||
if (Array.isArray(allowed) && allowed.length > 0 && !allowed.includes(GEMINI_ENV_CONNECTION_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.excludedConnectionIds) {
|
||||
for (const excluded of options.excludedConnectionIds) {
|
||||
if (excluded === GEMINI_ENV_CONNECTION_ID) return null;
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = readGeminiEnvApiKey();
|
||||
if (!apiKey) return null;
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
accessToken: null,
|
||||
connectionId: GEMINI_ENV_CONNECTION_ID,
|
||||
id: GEMINI_ENV_CONNECTION_ID,
|
||||
provider: providerId,
|
||||
authType: "apikey",
|
||||
defaultModel: null,
|
||||
};
|
||||
}
|
||||
103
src/lib/providers/jina.ts
Normal file
103
src/lib/providers/jina.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Shared Jina AI integration helpers.
|
||||
*
|
||||
* OmniRoute keeps two dashboard cards because the hosts differ:
|
||||
* - jina-ai Foundation API https://api.jina.ai
|
||||
* - jina-reader Reader https://r.jina.ai
|
||||
*
|
||||
* One Jina token works on both hosts. Dashboard connections stay preferred;
|
||||
* JINA_AI_API_KEY / JINA_API_KEY are a headless fallback when no usable
|
||||
* dashboard key exists. Do not treat the env as billed if a dashboard
|
||||
* connection is selected (fill-first, priority ascending).
|
||||
*/
|
||||
|
||||
export const JINA_FOUNDATION_PROVIDER_ID = "jina-ai";
|
||||
export const JINA_READER_PROVIDER_ID = "jina-reader";
|
||||
export const JINA_SEARCH_PROVIDER_ID = "jina-search";
|
||||
|
||||
export const JINA_FOUNDATION_BASE_URL = "https://api.jina.ai";
|
||||
export const JINA_READER_BASE_URL = "https://r.jina.ai";
|
||||
export const JINA_SEARCH_BASE_URL = "https://s.jina.ai";
|
||||
export const JINA_SEGMENT_BASE_URL = "https://segment.jina.ai";
|
||||
|
||||
/** Call-log / credential sentinel when the request used the process env key. */
|
||||
export const JINA_ENV_CONNECTION_ID = "env:JINA_AI_API_KEY";
|
||||
|
||||
export const JINA_ENV_API_KEY_NAMES = ["JINA_AI_API_KEY", "JINA_API_KEY"] as const;
|
||||
|
||||
const JINA_CREDENTIAL_PROVIDERS = new Set<string>([
|
||||
JINA_FOUNDATION_PROVIDER_ID,
|
||||
JINA_READER_PROVIDER_ID,
|
||||
JINA_SEARCH_PROVIDER_ID,
|
||||
]);
|
||||
|
||||
export interface JinaEnvCredentials {
|
||||
apiKey: string;
|
||||
accessToken: null;
|
||||
connectionId: typeof JINA_ENV_CONNECTION_ID;
|
||||
id: typeof JINA_ENV_CONNECTION_ID;
|
||||
provider: string;
|
||||
authType: "apikey";
|
||||
defaultModel: null;
|
||||
}
|
||||
|
||||
export function isJinaCredentialProvider(providerId: string | null | undefined): boolean {
|
||||
return typeof providerId === "string" && JINA_CREDENTIAL_PROVIDERS.has(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first non-empty Jina env key. Dashboard connections always win
|
||||
* when getProviderCredentials finds one.
|
||||
*/
|
||||
export function readJinaEnvApiKey(): string | null {
|
||||
for (const name of JINA_ENV_API_KEY_NAMES) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic credentials for headless / Docker operators who inject
|
||||
* JINA_AI_API_KEY instead of adding a dashboard connection.
|
||||
*/
|
||||
export function buildJinaEnvCredentials(
|
||||
providerId: string,
|
||||
options: {
|
||||
forcedConnectionId?: string | null;
|
||||
allowedConnections?: string[] | null;
|
||||
excludedConnectionIds?: Iterable<string> | null;
|
||||
} = {}
|
||||
): JinaEnvCredentials | null {
|
||||
if (!isJinaCredentialProvider(providerId)) return null;
|
||||
|
||||
const forced =
|
||||
typeof options.forcedConnectionId === "string" && options.forcedConnectionId.trim().length > 0
|
||||
? options.forcedConnectionId.trim()
|
||||
: null;
|
||||
if (forced && forced !== JINA_ENV_CONNECTION_ID) return null;
|
||||
|
||||
const allowed = options.allowedConnections;
|
||||
if (Array.isArray(allowed) && allowed.length > 0 && !allowed.includes(JINA_ENV_CONNECTION_ID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.excludedConnectionIds) {
|
||||
for (const excluded of options.excludedConnectionIds) {
|
||||
if (excluded === JINA_ENV_CONNECTION_ID) return null;
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = readJinaEnvApiKey();
|
||||
if (!apiKey) return null;
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
accessToken: null,
|
||||
connectionId: JINA_ENV_CONNECTION_ID,
|
||||
id: JINA_ENV_CONNECTION_ID,
|
||||
provider: providerId,
|
||||
authType: "apikey",
|
||||
defaultModel: null,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import {
|
||||
isClaudeCodeCompatibleProvider,
|
||||
@@ -86,6 +85,7 @@ import { validateSearchProvider, SEARCH_VALIDATOR_CONFIGS } from "./validation/s
|
||||
import {
|
||||
validateClarifaiProvider,
|
||||
validateEmbeddingApiProvider,
|
||||
validateJinaFoundationProvider,
|
||||
validateRerankApiProvider,
|
||||
} from "./validation/embeddingProviders";
|
||||
import {
|
||||
@@ -281,15 +281,8 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
modelId: embeddingProvider?.models?.[0]?.id || "voyage-4-lite",
|
||||
});
|
||||
},
|
||||
"jina-ai": ({ apiKey, providerSpecificData }: any) => {
|
||||
const rerankProvider = getRerankProvider("jina-ai");
|
||||
return validateRerankApiProvider({
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
url: rerankProvider?.baseUrl,
|
||||
modelId: rerankProvider?.models?.[0]?.id || "jina-reranker-v3",
|
||||
});
|
||||
},
|
||||
"jina-ai": ({ apiKey, providerSpecificData }: any) =>
|
||||
validateJinaFoundationProvider({ apiKey, providerSpecificData }),
|
||||
gitlab: ({ apiKey, providerSpecificData }: any) =>
|
||||
validateGitlabProvider({ apiKey, providerSpecificData, isLocal }),
|
||||
vertex: validateVertexProvider,
|
||||
|
||||
@@ -101,6 +101,107 @@ export async function validateEmbeddingApiProvider({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jina Foundation API key probe.
|
||||
*
|
||||
* Dashboard Test used to POST rerank with jina-reranker-v3, which can 200
|
||||
* while production Omni embed / rerank-v3.5 403. Prefer GET /v1/models
|
||||
* (key validity). Embeddings fallback hits jina-embeddings-v5-omni-small
|
||||
* so Test exercises the Omni SKU, not a text-only stand-in. Always report
|
||||
* the endpoint and model that were hit.
|
||||
*/
|
||||
export async function validateJinaFoundationProvider({
|
||||
apiKey,
|
||||
providerSpecificData = {},
|
||||
}: {
|
||||
apiKey: string;
|
||||
providerSpecificData?: { validationModelId?: string; [key: string]: unknown };
|
||||
}) {
|
||||
const modelsUrl = "https://api.jina.ai/v1/models";
|
||||
const embeddingsUrl = "https://api.jina.ai/v1/embeddings";
|
||||
const embeddingsModel =
|
||||
providerSpecificData?.validationModelId || "jina-embeddings-v5-omni-small";
|
||||
|
||||
try {
|
||||
const modelsRes = await validationRead(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
});
|
||||
|
||||
if (modelsRes.ok) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "jina_models",
|
||||
testedEndpoint: "GET https://api.jina.ai/v1/models",
|
||||
};
|
||||
}
|
||||
|
||||
if (modelsRes.status === 401 || modelsRes.status === 403) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid API key (GET https://api.jina.ai/v1/models)`,
|
||||
method: "jina_models",
|
||||
testedEndpoint: "GET https://api.jina.ai/v1/models",
|
||||
};
|
||||
}
|
||||
|
||||
const embedRes = await validationWrite(embeddingsUrl, {
|
||||
method: "POST",
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: JSON.stringify({
|
||||
model: embeddingsModel,
|
||||
input: ["test"],
|
||||
}),
|
||||
});
|
||||
|
||||
if (embedRes.status === 401 || embedRes.status === 403) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid API key (POST https://api.jina.ai/v1/embeddings model=${embeddingsModel})`,
|
||||
method: "jina_embeddings",
|
||||
testedEndpoint: "POST https://api.jina.ai/v1/embeddings",
|
||||
testedModel: embeddingsModel,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
embedRes.ok ||
|
||||
embedRes.status === 400 ||
|
||||
embedRes.status === 422 ||
|
||||
embedRes.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "jina_embeddings",
|
||||
testedEndpoint: "POST https://api.jina.ai/v1/embeddings",
|
||||
testedModel: embeddingsModel,
|
||||
};
|
||||
}
|
||||
|
||||
if (embedRes.status >= 500) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Provider unavailable (${embedRes.status}) at POST https://api.jina.ai/v1/embeddings model=${embeddingsModel}`,
|
||||
method: "jina_embeddings",
|
||||
testedEndpoint: "POST https://api.jina.ai/v1/embeddings",
|
||||
testedModel: embeddingsModel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Validation failed: ${embedRes.status} (POST https://api.jina.ai/v1/embeddings model=${embeddingsModel})`,
|
||||
method: "jina_embeddings",
|
||||
testedEndpoint: "POST https://api.jina.ai/v1/embeddings",
|
||||
testedModel: embeddingsModel,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateRerankApiProvider({ apiKey, providerSpecificData = {}, url, modelId }: any) {
|
||||
if (!url) {
|
||||
return { valid: false, error: "Missing rerank endpoint" };
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as defaultLog from "@/sse/utils/logger";
|
||||
import {
|
||||
getAllSearchProviders,
|
||||
getSearchProvider,
|
||||
resolveSearchProvider,
|
||||
selectProvider,
|
||||
supportsSearchType,
|
||||
SEARCH_CREDENTIAL_FALLBACKS,
|
||||
@@ -121,7 +122,7 @@ export async function executeWebSearch(
|
||||
const searchType = input.search_type || "web";
|
||||
|
||||
if (input.provider) {
|
||||
const explicitProvider = getSearchProvider(input.provider);
|
||||
const explicitProvider = resolveSearchProvider(input.provider);
|
||||
if (!explicitProvider) {
|
||||
throw new WebSearchExecutionError(`Unknown search provider: ${input.provider}`, 400);
|
||||
}
|
||||
|
||||
@@ -152,12 +152,13 @@ export const APIKEY_PROVIDERS_SPECIALTY = {
|
||||
"jina-ai": {
|
||||
id: "jina-ai",
|
||||
alias: "jina",
|
||||
name: "Jina AI",
|
||||
name: "Jina AI (Foundation API)",
|
||||
icon: "sort",
|
||||
color: "#2563EB",
|
||||
textIcon: "JA",
|
||||
website: "https://jina.ai",
|
||||
authHint: "Bearer API key for the Jina AI rerank API.",
|
||||
authHint:
|
||||
"Bearer API key for api.jina.ai — embeddings, rerank, classify, segment, and search. Dashboard keys take precedence over JINA_AI_API_KEY. This is not the Reader / r.jina.ai card and does not fetch URLs.",
|
||||
hasFree: true,
|
||||
freeNote: "10M free tokens on signup (non-commercial), no credit card required",
|
||||
},
|
||||
@@ -262,14 +263,16 @@ export const APIKEY_PROVIDERS_SPECIALTY = {
|
||||
"jina-reader": {
|
||||
id: "jina-reader",
|
||||
alias: "jr",
|
||||
name: "Jina Reader",
|
||||
name: "Jina Reader (r.jina.ai)",
|
||||
icon: "menu_book",
|
||||
color: "#0EA5E9",
|
||||
textIcon: "JR",
|
||||
website: "https://jina.ai/reader",
|
||||
authHint:
|
||||
"Bearer API key for r.jina.ai URL-to-markdown (/v1/web/fetch only). Does not serve /v1/embeddings or /v1/rerank. The same Jina token as Foundation API works; OmniRoute reuses a jina-ai dashboard key or JINA_AI_API_KEY when this card is empty.",
|
||||
hasFree: true,
|
||||
notice: {
|
||||
text: "Free tier: 1M fetches/month.",
|
||||
text: "Reader / r.jina.ai only — not embeddings or rerank. Free tier: 1M fetches/month.",
|
||||
apiKeyUrl: "https://jina.ai/api-dashboard",
|
||||
},
|
||||
serviceKinds: ["webFetch"],
|
||||
|
||||
126
src/shared/validation/geminiNativeEmbeddingInput.ts
Normal file
126
src/shared/validation/geminiNativeEmbeddingInput.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Gemini Embedding 2 native items (Google AI Studio embedContent / batchEmbedContents).
|
||||
*
|
||||
* Official 2026 contract (ai.google.dev/gemini-api/docs/embeddings):
|
||||
* - Model id: gemini-embedding-2 (GA April 2026). Legacy text-only: gemini-embedding-001.
|
||||
* - One Content (parts[]) → one embedding. Multiple parts in one Content fuse.
|
||||
* - N Content objects / N batchEmbedContents requests → N embeddings.
|
||||
* - Parts: { text }, { inline_data: { mime_type, data } }, { file_data: { mime_type, file_uri } }.
|
||||
* CamelCase SDK spellings (inlineData / fileData) are accepted and forwarded.
|
||||
*
|
||||
* These are not OmniRoute's canonical `{ type, source }` items. For gemini
|
||||
* they must reach generativelanguage.googleapis.com as Content parts — do
|
||||
* not collapse the OpenAI `input` array to string[].
|
||||
*/
|
||||
|
||||
import { isCanonicalEmbeddingItem, isPlainObject } from "./jinaNativeEmbeddingInput";
|
||||
|
||||
export type GeminiEmbeddingModality = "text" | "image" | "audio" | "video" | "document";
|
||||
|
||||
const GEMINI_EMBEDDING_2_IDS = new Set(["gemini-embedding-2", "gemini-embedding-2-preview"]);
|
||||
|
||||
export function isGeminiEmbedding2Family(modelId: string | null | undefined): boolean {
|
||||
return typeof modelId === "string" && GEMINI_EMBEDDING_2_IDS.has(modelId);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isPlainObject(value) ? value : null;
|
||||
}
|
||||
|
||||
function mimeFromInline(value: Record<string, unknown>): string | null {
|
||||
const snake = asRecord(value.inline_data);
|
||||
if (typeof snake?.mime_type === "string") return snake.mime_type;
|
||||
const camel = asRecord(value.inlineData);
|
||||
if (typeof camel?.mimeType === "string") return camel.mimeType;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mimeFromFile(value: Record<string, unknown>): string | null {
|
||||
const snake = asRecord(value.file_data);
|
||||
if (typeof snake?.mime_type === "string") return snake.mime_type;
|
||||
const camel = asRecord(value.fileData);
|
||||
if (typeof camel?.mimeType === "string") return camel.mimeType;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function modalityFromGeminiMime(mimeType: string): GeminiEmbeddingModality {
|
||||
const mime = mimeType.trim().toLowerCase();
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf" || mime.startsWith("application/pdf")) return "document";
|
||||
return "document";
|
||||
}
|
||||
|
||||
export function isGeminiNativePart(value: unknown): boolean {
|
||||
const record = asRecord(value);
|
||||
if (!record || isCanonicalEmbeddingItem(record)) return false;
|
||||
if (typeof record.text === "string" && record.text.trim().length > 0) {
|
||||
return !("image" in record) && !("audio" in record) && !("video" in record) && !("pdf" in record);
|
||||
}
|
||||
if (asRecord(record.inline_data)?.data || asRecord(record.inlineData)?.data) return true;
|
||||
if (asRecord(record.file_data)?.file_uri || asRecord(record.fileData)?.fileUri) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isGeminiNativeContent(value: unknown): boolean {
|
||||
const record = asRecord(value);
|
||||
if (!record || isCanonicalEmbeddingItem(record)) return false;
|
||||
if (!Array.isArray(record.parts) || record.parts.length === 0) return false;
|
||||
return record.parts.every((part) => isGeminiNativePart(part));
|
||||
}
|
||||
|
||||
export function isGeminiNativeEmbedRequest(value: unknown): boolean {
|
||||
const record = asRecord(value);
|
||||
if (!record || isCanonicalEmbeddingItem(record)) return false;
|
||||
const content = record.content;
|
||||
if (Array.isArray(content)) return false;
|
||||
return isGeminiNativeContent(content);
|
||||
}
|
||||
|
||||
export function isGeminiNativeEmbeddingItem(value: unknown): boolean {
|
||||
return isGeminiNativePart(value) || isGeminiNativeContent(value) || isGeminiNativeEmbedRequest(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request already uses Gemini's documented multimodal contract
|
||||
* (a part, a Content with parts, or an EmbedContentRequest).
|
||||
*/
|
||||
export function isGeminiNativeEmbeddingInput(input: unknown): boolean {
|
||||
if (isGeminiNativeEmbeddingItem(input)) return true;
|
||||
if (!Array.isArray(input)) return false;
|
||||
return input.some((item) => isGeminiNativeEmbeddingItem(item));
|
||||
}
|
||||
|
||||
export function collectGeminiNativeModalities(input: unknown): GeminiEmbeddingModality[] {
|
||||
const found = new Set<GeminiEmbeddingModality>();
|
||||
|
||||
const visitPart = (value: unknown) => {
|
||||
const record = asRecord(value);
|
||||
if (!record) return;
|
||||
if (typeof record.text === "string" && record.text.trim().length > 0) found.add("text");
|
||||
const inlineMime = mimeFromInline(record);
|
||||
if (inlineMime) found.add(modalityFromGeminiMime(inlineMime));
|
||||
const fileMime = mimeFromFile(record);
|
||||
if (fileMime) found.add(modalityFromGeminiMime(fileMime));
|
||||
};
|
||||
|
||||
const visit = (value: unknown) => {
|
||||
if (isGeminiNativeEmbedRequest(value)) {
|
||||
visit((value as { content: unknown }).content);
|
||||
return;
|
||||
}
|
||||
if (isGeminiNativeContent(value)) {
|
||||
for (const part of (value as { parts: unknown[] }).parts) visitPart(part);
|
||||
return;
|
||||
}
|
||||
if (isGeminiNativePart(value)) visitPart(value);
|
||||
};
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) visit(item);
|
||||
} else {
|
||||
visit(input);
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
87
src/shared/validation/jinaNativeEmbeddingInput.ts
Normal file
87
src/shared/validation/jinaNativeEmbeddingInput.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Jina Search Foundation native embedding items (api.jina.ai EmbeddingsV5Request).
|
||||
*
|
||||
* Official 2026 input shapes (OpenAPI 2026.07.27):
|
||||
* TextDoc { text }
|
||||
* ImageDoc { image } URL or base64 / data URI
|
||||
* AudioDoc { audio }
|
||||
* VideoDoc { video }
|
||||
* PDFDoc { pdf } single input only upstream; we still accept it in a list
|
||||
* MergedContentGroup { content: [TextDoc|ImageDoc|AudioDoc|VideoDoc, ...] }
|
||||
*
|
||||
* These are not OmniRoute's canonical `{ type, source }` items. For jina-ai
|
||||
* they must be forwarded intact — do not stringify, do not fetch image URLs
|
||||
* into data URIs. Jina fetches public media itself.
|
||||
*/
|
||||
|
||||
export const JINA_NATIVE_MEDIA_KEYS = ["text", "image", "audio", "video", "pdf"] as const;
|
||||
export type JinaNativeMediaKey = (typeof JINA_NATIVE_MEDIA_KEYS)[number];
|
||||
|
||||
const NATIVE_KEY_TO_MODALITY: Record<JinaNativeMediaKey, "text" | "image" | "audio" | "video" | "document"> =
|
||||
{
|
||||
text: "text",
|
||||
image: "image",
|
||||
audio: "audio",
|
||||
video: "video",
|
||||
pdf: "document",
|
||||
};
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** OmniRoute canonical structured item — leave those on the translator path. */
|
||||
export function isCanonicalEmbeddingItem(value: unknown): boolean {
|
||||
return isPlainObject(value) && "type" in value && typeof value.type === "string";
|
||||
}
|
||||
|
||||
export function isJinaNativeDoc(value: unknown): boolean {
|
||||
if (!isPlainObject(value) || isCanonicalEmbeddingItem(value)) return false;
|
||||
if ("content" in value && Array.isArray(value.content)) return false;
|
||||
const present = JINA_NATIVE_MEDIA_KEYS.filter((key) => key in value);
|
||||
if (present.length !== 1) return false;
|
||||
return typeof value[present[0]] === "string" && String(value[present[0]]).trim().length > 0;
|
||||
}
|
||||
|
||||
export function isJinaMergedContentGroup(value: unknown): boolean {
|
||||
if (!isPlainObject(value) || isCanonicalEmbeddingItem(value)) return false;
|
||||
if (!Array.isArray(value.content) || value.content.length === 0) return false;
|
||||
return value.content.every((item) => isJinaNativeDoc(item) && !("pdf" in (item as object)));
|
||||
}
|
||||
|
||||
export function isJinaNativeEmbeddingItem(value: unknown): boolean {
|
||||
return isJinaNativeDoc(value) || isJinaMergedContentGroup(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request already uses Jina's documented multimodal contract
|
||||
* (single doc, mixed string+doc batch, or fused content groups).
|
||||
*/
|
||||
export function isJinaNativeEmbeddingInput(input: unknown): boolean {
|
||||
if (isJinaNativeEmbeddingItem(input)) return true;
|
||||
if (!Array.isArray(input)) return false;
|
||||
return input.some((item) => isJinaNativeEmbeddingItem(item));
|
||||
}
|
||||
|
||||
export function collectJinaNativeModalities(
|
||||
input: unknown
|
||||
): Array<"text" | "image" | "audio" | "video" | "document"> {
|
||||
const found = new Set<"text" | "image" | "audio" | "video" | "document">();
|
||||
|
||||
const visit = (value: unknown) => {
|
||||
if (isJinaMergedContentGroup(value)) {
|
||||
for (const item of (value as { content: unknown[] }).content) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isJinaNativeDoc(value)) return;
|
||||
const key = JINA_NATIVE_MEDIA_KEYS.find((mediaKey) => mediaKey in (value as object));
|
||||
if (key) found.add(NATIVE_KEY_TO_MODALITY[key]);
|
||||
};
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) visit(item);
|
||||
} else {
|
||||
visit(input);
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
} 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))
|
||||
@@ -110,15 +115,260 @@ export const embeddingMultimodalItemSchema = z.discriminatedUnion("type", [
|
||||
),
|
||||
]);
|
||||
|
||||
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) => {
|
||||
if (item.type === "text" || item.source.type !== "base64") return total;
|
||||
return total + decodedBase64Bytes(item.source.data);
|
||||
}, 0);
|
||||
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",
|
||||
@@ -133,6 +383,10 @@ export const embeddingInputSchema = z.union([
|
||||
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>;
|
||||
@@ -244,6 +498,30 @@ export const v1RerankSchema = z
|
||||
})
|
||||
.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,
|
||||
@@ -305,6 +583,9 @@ export const v1SearchSchema = z
|
||||
"youcom-search",
|
||||
"searxng-search",
|
||||
"zai-search",
|
||||
"jina-search",
|
||||
"jina-ai",
|
||||
"jina",
|
||||
"duckduckgo-free",
|
||||
])
|
||||
.optional(),
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "@/lib/db/providers";
|
||||
import { validateApiKey } from "@/lib/db/apiKeys";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { buildJinaEnvCredentials } from "@/lib/providers/jina";
|
||||
import { buildGeminiEnvCredentials } from "@/lib/providers/gemini";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
import {
|
||||
createLazyConnectionView,
|
||||
@@ -969,6 +971,12 @@ const PROVIDER_SEARCH_PAIRS: string[][] = [
|
||||
// The model layer canonicalizes `agy/` to `antigravity`, but the Antigravity
|
||||
// CLI card stores its connection under `agy`. Same account, either id serves.
|
||||
["antigravity", "agy"],
|
||||
// One Jina token works on api.jina.ai, r.jina.ai, and s.jina.ai.
|
||||
// Requested id stays first so embed/rerank do not silently pick a
|
||||
// Reader-only row when both cards are filled. jina-search has no
|
||||
// dashboard card — it must still see jina-ai / jina-reader keys
|
||||
// before falling through to JINA_AI_API_KEY.
|
||||
["jina-ai", "jina-reader", "jina-search"],
|
||||
];
|
||||
/**
|
||||
* Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup
|
||||
@@ -977,8 +985,8 @@ async function getProviderSearchPool(provider: string): Promise<string[]> {
|
||||
const canonicalProvider = resolveProviderId(provider);
|
||||
const canonicalAlias = getProviderAlias(canonicalProvider);
|
||||
|
||||
const pair = PROVIDER_SEARCH_PAIRS.find((aliases) => aliases.includes(provider));
|
||||
if (pair) return pair[0] === provider ? pair : [pair[1], pair[0]];
|
||||
const group = PROVIDER_SEARCH_PAIRS.find((aliases) => aliases.includes(provider));
|
||||
if (group) return [provider, ...group.filter((id) => id !== provider)];
|
||||
|
||||
const searchPool = new Set([provider, canonicalProvider, canonicalAlias].filter(Boolean));
|
||||
|
||||
@@ -1287,6 +1295,24 @@ export async function getProviderCredentials(
|
||||
allowedConnections
|
||||
);
|
||||
if (syntheticFallback) return syntheticFallback;
|
||||
const jinaEnvCredentials = buildJinaEnvCredentials(resolvedId, {
|
||||
forcedConnectionId,
|
||||
allowedConnections,
|
||||
excludedConnectionIds,
|
||||
});
|
||||
if (jinaEnvCredentials) {
|
||||
log.info("AUTH", `${provider} | using ${jinaEnvCredentials.connectionId} env fallback`);
|
||||
return jinaEnvCredentials;
|
||||
}
|
||||
const geminiEnvCredentials = buildGeminiEnvCredentials(resolvedId, {
|
||||
forcedConnectionId,
|
||||
allowedConnections,
|
||||
excludedConnectionIds,
|
||||
});
|
||||
if (geminiEnvCredentials) {
|
||||
log.info("AUTH", `${provider} | using ${geminiEnvCredentials.connectionId} env fallback`);
|
||||
return geminiEnvCredentials;
|
||||
}
|
||||
log.warn("AUTH", `No credentials for ${provider}`);
|
||||
return null;
|
||||
}
|
||||
@@ -1993,7 +2019,7 @@ export async function getProviderCredentialsWithQuotaPreflight(
|
||||
if (legacyForceDisable) return credentials;
|
||||
|
||||
const hasConnectionOverrides = Object.keys(perConnectionWindowOverrides).length > 0;
|
||||
const legacyForceEnable = isQuotaPreflightEnabled(credentials);
|
||||
const legacyForceEnable = isQuotaPreflightEnabled(credentials as Record<string, unknown>);
|
||||
if (
|
||||
!hasConnectionOverrides &&
|
||||
!providerHasDefaults &&
|
||||
@@ -2029,10 +2055,15 @@ export async function getProviderCredentialsWithQuotaPreflight(
|
||||
requestedModel && modelAwarePreflight ? { ...credentials, requestedModel } : credentials;
|
||||
let preflight;
|
||||
try {
|
||||
preflight = await preflightQuota(provider, connectionId, preflightCredentials, {
|
||||
resolveMinRemainingPercent,
|
||||
resolveWarnRemainingPercent: () => warnThresholdPercent,
|
||||
});
|
||||
preflight = await preflightQuota(
|
||||
provider,
|
||||
connectionId,
|
||||
preflightCredentials as Record<string, unknown>,
|
||||
{
|
||||
resolveMinRemainingPercent,
|
||||
resolveWarnRemainingPercent: () => warnThresholdPercent,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
selectedCredentials.releaseOAuthSession?.();
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user