mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
@@ -66,6 +66,17 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
],
|
||||
},
|
||||
|
||||
upstage: {
|
||||
id: "upstage",
|
||||
baseUrl: "https://api.upstage.ai/v1/embeddings",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "embedding-query", name: "Embedding Query", dimensions: 4096 },
|
||||
{ id: "embedding-passage", name: "Embedding Passage", dimensions: 4096 },
|
||||
],
|
||||
},
|
||||
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
baseUrl: "https://api.mistral.ai/v1/embeddings",
|
||||
|
||||
@@ -226,7 +226,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
gigachat: buildModels(["GigaChat-2-Max", "GigaChat-2-Pro", "GigaChat-2-Lite"]),
|
||||
venice: buildModels(["venice-latest"]),
|
||||
codestral: buildModels(["codestral-2405", "codestral-latest"]),
|
||||
upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]),
|
||||
upstage: buildModels(["solar-pro3", "solar-mini"]),
|
||||
maritalk: buildModels(["sabia-3", "sabia-3-small"]),
|
||||
"xiaomi-mimo": buildModels(["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]),
|
||||
"inference-net": buildModels([
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
SAFE_OUTBOUND_FETCH_PRESETS,
|
||||
SafeOutboundFetchError,
|
||||
getSafeOutboundFetchErrorStatus,
|
||||
safeOutboundFetch,
|
||||
} from "@/shared/network/safeOutboundFetch";
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type LocalCatalogModel = { id: string; name?: string };
|
||||
|
||||
const antigravityDiscoveryInflight = new Map<
|
||||
string,
|
||||
@@ -118,6 +120,19 @@ function isNamedOpenAIStyleProvider(provider: string): boolean {
|
||||
return NAMED_OPENAI_STYLE_PROVIDERS.has(provider);
|
||||
}
|
||||
|
||||
function mergeLocalCatalogModels<T extends LocalCatalogModel, U extends LocalCatalogModel>(
|
||||
registryCatalogModels: T[],
|
||||
specialtyCatalogModels: U[]
|
||||
): Array<T | U> {
|
||||
if (registryCatalogModels.length === 0) return specialtyCatalogModels;
|
||||
|
||||
const registryModelIds = new Set(registryCatalogModels.map((model) => model.id));
|
||||
return [
|
||||
...registryCatalogModels,
|
||||
...specialtyCatalogModels.filter((model) => !registryModelIds.has(model.id)),
|
||||
];
|
||||
}
|
||||
|
||||
function buildOptionalBearerHeaders(token: string | null | undefined): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
@@ -402,55 +417,48 @@ export function getStaticModelsForProvider(
|
||||
return staticModelsFn();
|
||||
}
|
||||
|
||||
const specialtyModels: Array<{ id: string; name: string }> = [];
|
||||
const appendModels = (models: Array<{ id: string; name?: string }>) => {
|
||||
for (const model of models) {
|
||||
if (specialtyModels.some((existing) => existing.id === model.id)) continue;
|
||||
specialtyModels.push({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const embeddingProvider = getEmbeddingProvider(provider);
|
||||
if (embeddingProvider) {
|
||||
return embeddingProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(embeddingProvider.models);
|
||||
}
|
||||
|
||||
const rerankProvider = getRerankProvider(provider);
|
||||
if (rerankProvider) {
|
||||
return rerankProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(rerankProvider.models);
|
||||
}
|
||||
|
||||
const imageProvider = getImageProvider(provider);
|
||||
if (imageProvider) {
|
||||
return imageProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(imageProvider.models);
|
||||
}
|
||||
|
||||
const videoProvider = getVideoProvider(provider);
|
||||
if (videoProvider) {
|
||||
return videoProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(videoProvider.models);
|
||||
}
|
||||
|
||||
const speechProvider = getSpeechProvider(provider);
|
||||
if (speechProvider) {
|
||||
return speechProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(speechProvider.models);
|
||||
}
|
||||
|
||||
const transcriptionProvider = getTranscriptionProvider(provider);
|
||||
if (transcriptionProvider) {
|
||||
return transcriptionProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
appendModels(transcriptionProvider.models);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return specialtyModels.length > 0 ? specialtyModels : undefined;
|
||||
}
|
||||
|
||||
// Provider models endpoints configuration
|
||||
@@ -825,9 +833,8 @@ export async function GET(
|
||||
const specialtyCatalogModels = getStaticModelsForProvider(provider) || [];
|
||||
|
||||
const toLocalCatalogModels = () => {
|
||||
const localCatalog =
|
||||
registryCatalogModels.length > 0 ? registryCatalogModels : specialtyCatalogModels;
|
||||
return localCatalog.map((model: any) => ({
|
||||
const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels);
|
||||
return localCatalog.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
|
||||
@@ -1755,13 +1762,12 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
const localCatalog =
|
||||
registryCatalogModels.length > 0 ? registryCatalogModels : specialtyCatalogModels;
|
||||
const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels);
|
||||
if (!config && localCatalog.length > 0) {
|
||||
return buildResponse({
|
||||
provider,
|
||||
connectionId,
|
||||
models: localCatalog.map((m: any) => ({
|
||||
models: localCatalog.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
|
||||
@@ -1895,6 +1901,10 @@ export async function GET(
|
||||
|
||||
return buildApiDiscoveryResponse(allModels);
|
||||
} catch (error) {
|
||||
if (error instanceof SafeOutboundFetchError && error.code === "URL_GUARD_BLOCKED") {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const status = getSafeOutboundFetchErrorStatus(error);
|
||||
if (status) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch models";
|
||||
|
||||
@@ -51,3 +51,13 @@ test("chat-openai-compat providers are registered across provider metadata, regi
|
||||
assert.ok(models.length > 0, `${providerId} models must not be empty`);
|
||||
}
|
||||
});
|
||||
|
||||
test("upstage chat catalog does not include non-chat specialty models", () => {
|
||||
const modelIds = REGISTRY.upstage.models.map((model) => model.id);
|
||||
|
||||
assert.ok(modelIds.includes("solar-pro3"));
|
||||
assert.ok(modelIds.includes("solar-mini"));
|
||||
assert.equal(modelIds.includes("document-parse"), false);
|
||||
assert.equal(modelIds.includes("embedding-query"), false);
|
||||
assert.equal(modelIds.includes("embedding-passage"), false);
|
||||
});
|
||||
|
||||
@@ -54,3 +54,22 @@ test("voyage-ai and jina-ai rerank registries expose supported models", () => {
|
||||
assert.ok(all.some((model) => model.id === "voyage-ai/rerank-2.5"));
|
||||
assert.ok(all.some((model) => model.id === "jina-ai/jina-reranker-v3"));
|
||||
});
|
||||
|
||||
test("upstage embedding registry exposes current embedding models", () => {
|
||||
const provider = getEmbeddingProvider("upstage");
|
||||
|
||||
assert.ok(provider);
|
||||
assert.equal(provider.baseUrl, "https://api.upstage.ai/v1/embeddings");
|
||||
assert.ok(provider.models.some((model) => model.id === "embedding-query"));
|
||||
assert.ok(provider.models.some((model) => model.id === "embedding-passage"));
|
||||
|
||||
const parsed = parseEmbeddingModel("upstage/embedding-query");
|
||||
assert.equal(parsed.provider, "upstage");
|
||||
assert.equal(parsed.model, "embedding-query");
|
||||
|
||||
const all = getAllEmbeddingModels().filter((model) => model.provider === "upstage");
|
||||
assert.deepEqual(
|
||||
all.map((model) => model.id),
|
||||
["upstage/embedding-query", "upstage/embedding-passage"]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,6 +113,49 @@ test("handleEmbedding supports resolved local providers without auth and preserv
|
||||
}
|
||||
});
|
||||
|
||||
test("handleEmbedding routes Upstage embedding models through the embedding endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: options.headers,
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 2, total_tokens: 2 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleEmbedding({
|
||||
body: {
|
||||
model: "upstage/embedding-query",
|
||||
input: "Solar embeddings are useful",
|
||||
},
|
||||
credentials: { apiKey: "upstage-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(captured.url, "https://api.upstage.ai/v1/embeddings");
|
||||
assert.equal(captured.headers.Authorization, "Bearer upstage-key");
|
||||
assert.deepEqual(captured.body, {
|
||||
model: "embedding-query",
|
||||
input: "Solar embeddings are useful",
|
||||
});
|
||||
assert.equal(result.data.model, "upstage/embedding-query");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleEmbedding rejects invalid model strings without provider prefix", async () => {
|
||||
const result = await handleEmbedding({
|
||||
body: { model: "not-a-known-embedding-model", input: "hello" },
|
||||
|
||||
@@ -435,6 +435,25 @@ test("provider models route returns the local catalog for new built-in chat-open
|
||||
assert.ok(body.models.some((model) => model.id === "Qwen/Qwen3-Coder-480B-A35B-Instruct"));
|
||||
});
|
||||
|
||||
test("provider models route merges Upstage chat and embedding catalogs", async () => {
|
||||
const connection = await seedConnection("upstage", {
|
||||
apiKey: "upstage-key",
|
||||
});
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
const modelIds = body.models.map((model) => model.id);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "upstage");
|
||||
assert.equal(body.source, "local_catalog");
|
||||
assert.ok(modelIds.includes("solar-pro3"));
|
||||
assert.ok(modelIds.includes("solar-mini"));
|
||||
assert.ok(modelIds.includes("embedding-query"));
|
||||
assert.ok(modelIds.includes("embedding-passage"));
|
||||
assert.equal(modelIds.includes("document-parse"), false);
|
||||
});
|
||||
|
||||
test("provider models route caches discovered opencode-go models per connection", async () => {
|
||||
const connection = await seedConnection("opencode-go", {
|
||||
apiKey: "opencode-go-key",
|
||||
|
||||
Reference in New Issue
Block a user