diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 51113a7c34..795c43c811 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -66,6 +66,17 @@ export const EMBEDDING_PROVIDERS: Record = { ], }, + 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", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 5ac00eee42..2830b4411b 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -226,7 +226,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { 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([ diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 61dfe76020..a980e93695 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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; +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( + registryCatalogModels: T[], + specialtyCatalogModels: U[] +): Array { + 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 { 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"; diff --git a/tests/unit/chat-openai-compat-providers.test.ts b/tests/unit/chat-openai-compat-providers.test.ts index 4c6481285c..0ded73cc32 100644 --- a/tests/unit/chat-openai-compat-providers.test.ts +++ b/tests/unit/chat-openai-compat-providers.test.ts @@ -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); +}); diff --git a/tests/unit/embedding-rerank-provider-registry.test.ts b/tests/unit/embedding-rerank-provider-registry.test.ts index 78f72d8bc2..d4475127a3 100644 --- a/tests/unit/embedding-rerank-provider-registry.test.ts +++ b/tests/unit/embedding-rerank-provider-registry.test.ts @@ -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"] + ); +}); diff --git a/tests/unit/embeddings-handler.test.ts b/tests/unit/embeddings-handler.test.ts index 34449b47fb..87e23f8e0b 100644 --- a/tests/unit/embeddings-handler.test.ts +++ b/tests/unit/embeddings-handler.test.ts @@ -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" }, diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 09ea2a6cc9..b524ff474d 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -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",