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:
Ravi Tharuma
2026-08-18 15:52:43 +02:00
committed by GitHub
parent 228ef6fba9
commit 3d0ffb49a4
41 changed files with 2416 additions and 91 deletions

View File

@@ -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"],

View 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);

View File

@@ -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}`);
}

View 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);