diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 3d6c5caf5d..c56df4e72c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -182,6 +182,14 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { "aisingapore/Qwen-SEA-LION-v4-32B-IT", "allenai/Olmo-3-32B-Think", ]), + glhf: buildModels([ + "hf:meta-llama/Llama-3.3-70B-Instruct", + "hf:Qwen/Qwen2.5-72B-Instruct", + "hf:deepseek-ai/DeepSeek-V3", + ]), + cablyai: buildModels(["gpt-4o", "gpt-4o-mini", "deepseek-chat"]), + thebai: buildModels(["gpt-4o", "claude-3.5-sonnet", "llama-3.3-70b"]), + fenayai: buildModels(["gpt-4o", "claude-3.5-sonnet", "deepseek-chat"]), moonshot: buildModels(["kimi-k2.5", "kimi-latest", "moonshot-v1-auto"]), "meta-llama": buildModels([ "Llama-3.3-70B-Instruct", @@ -1872,6 +1880,58 @@ export const REGISTRY: Record = { models: CHAT_OPENAI_COMPAT_MODELS.publicai, }, + glhf: { + id: "glhf", + alias: "glhf", + format: "openai", + executor: "default", + baseUrl: "https://glhf.chat/api/openai/v1/chat/completions", + modelsUrl: "https://glhf.chat/api/openai/v1/models", + authType: "apikey", + authHeader: "bearer", + models: CHAT_OPENAI_COMPAT_MODELS.glhf, + passthroughModels: true, + }, + + cablyai: { + id: "cablyai", + alias: "cablyai", + format: "openai", + executor: "default", + baseUrl: "https://cablyai.com/v1/chat/completions", + modelsUrl: "https://cablyai.com/v1/models", + authType: "apikey", + authHeader: "bearer", + models: CHAT_OPENAI_COMPAT_MODELS.cablyai, + passthroughModels: true, + }, + + thebai: { + id: "thebai", + alias: "thebai", + format: "openai", + executor: "default", + baseUrl: "https://api.theb.ai/v1/chat/completions", + modelsUrl: "https://api.theb.ai/v1/models", + authType: "apikey", + authHeader: "bearer", + models: CHAT_OPENAI_COMPAT_MODELS.thebai, + passthroughModels: true, + }, + + fenayai: { + id: "fenayai", + alias: "fenayai", + format: "openai", + executor: "default", + baseUrl: "https://fenayai.com/v1/chat/completions", + modelsUrl: "https://fenayai.com/v1/models", + authType: "apikey", + authHeader: "bearer", + models: CHAT_OPENAI_COMPAT_MODELS.fenayai, + passthroughModels: true, + }, + moonshot: { id: "moonshot", alias: "moonshot", diff --git a/package.json b/package.json index 81ff09242f..b96bec6cc6 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,8 @@ "electron:build:win": "npm run build && cd electron && npm run build:win", "electron:build:mac": "npm run build && cd electron && npm run build:mac", "electron:build:linux": "npm run build && cd electron && npm run build:linux", - "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.ts", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.ts", + "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=10 tests/unit/*.test.ts", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=10 tests/unit/*.test.ts", "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/plan3-p0.test.ts", "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/fixes-p1.test.ts", "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/security-fase01.test.ts", @@ -89,7 +89,7 @@ "test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs", "test:vitest": "vitest run --config vitest.mcp.config.ts", "test:ecosystem": "node scripts/run-ecosystem-tests.mjs", - "test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --import tsx/esm --test --test-concurrency=1 tests/unit/*.test.ts", + "test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --import tsx/esm --test --test-concurrency=10 tests/unit/*.test.ts", "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.ts", "coverage:report": "c8 report --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", "coverage:summary": "node scripts/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md --threshold 60", diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 700902ab1f..b33350f69d 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -303,6 +303,38 @@ const PROVIDER_MODELS_CONFIG: Record = { authPrefix: "Bearer ", parseResponse: (data) => data.data || [], }, + glhf: { + url: "https://glhf.chat/api/openai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + cablyai: { + url: "https://cablyai.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + thebai: { + url: "https://api.theb.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + fenayai: { + url: "https://fenayai.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, kimi: { url: "https://api.moonshot.ai/v1/models", method: "GET", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 97ddbb43b9..cd5ddb8d88 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -904,6 +904,50 @@ export const APIKEY_PROVIDERS = { website: "https://api.laozhang.ai", passthroughModels: true, }, + glhf: { + id: "glhf", + alias: "glhf", + name: "GLHF Chat", + icon: "hub", + color: "#10B981", + textIcon: "GH", + website: "https://glhf.chat", + authHint: "Bearer API key for the GLHF OpenAI-compatible gateway.", + passthroughModels: true, + }, + cablyai: { + id: "cablyai", + alias: "cablyai", + name: "CablyAI", + icon: "hub", + color: "#FF4081", + textIcon: "CA", + website: "https://cablyai.com", + authHint: "Bearer API key for the CablyAI OpenAI-compatible gateway.", + passthroughModels: true, + }, + thebai: { + id: "thebai", + alias: "thebai", + name: "TheB.AI", + icon: "hub", + color: "#3B82F6", + textIcon: "TB", + website: "https://theb.ai", + authHint: "Bearer API key for the TheB.AI OpenAI-compatible gateway.", + passthroughModels: true, + }, + fenayai: { + id: "fenayai", + alias: "fenayai", + name: "FenayAI", + icon: "hub", + color: "#FF9800", + textIcon: "FN", + website: "https://fenayai.com", + authHint: "Bearer API key for the FenayAI OpenAI-compatible gateway.", + passthroughModels: true, + }, "fal-ai": { id: "fal-ai", alias: "fal", diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 0b8dd55896..c30e6b6496 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -272,6 +272,60 @@ test("provider models route prefers the remote OpenRouter /models API over stati assert.deepEqual(body.models, [{ id: "openai/gpt-4.1", name: "GPT-4.1 via OpenRouter" }]); }); +test("provider models route fetches remote catalogs for new OpenAI-compatible gateway providers", async () => { + const cases = [ + { + provider: "glhf", + apiKey: "glhf-key", + expectedUrl: "https://glhf.chat/api/openai/v1/models", + model: { id: "hf:Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B via GLHF" }, + }, + { + provider: "cablyai", + apiKey: "cably-key", + expectedUrl: "https://cablyai.com/v1/models", + model: { id: "gpt-4o", name: "GPT-4o via CablyAI" }, + }, + { + provider: "thebai", + apiKey: "theb-key", + expectedUrl: "https://api.theb.ai/v1/models", + model: { id: "gpt-4o", name: "GPT-4o via TheB.AI" }, + }, + { + provider: "fenayai", + apiKey: "fenay-key", + expectedUrl: "https://fenayai.com/v1/models", + model: { id: "deepseek-chat", name: "DeepSeek Chat via FenayAI" }, + }, + ]; + + for (const entry of cases) { + const connection = await seedConnection(entry.provider, { + apiKey: entry.apiKey, + }); + const seenUrls = []; + + globalThis.fetch = async (url, init = {}) => { + seenUrls.push(String(url)); + assert.equal(init.method, "GET"); + assert.equal(init.headers.Authorization, `Bearer ${entry.apiKey}`); + return Response.json({ + data: [entry.model], + }); + }; + + const response = await callRoute(connection.id, "?refresh=true"); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.provider, entry.provider); + assert.equal(body.source, "api"); + assert.deepEqual(seenUrls, [entry.expectedUrl]); + assert.deepEqual(body.models, [entry.model]); + } +}); + test("provider models route returns the local catalog for new built-in chat-openai-compat providers", async () => { const connection = await seedConnection("deepinfra", { apiKey: "deepinfra-key", diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 1dc7a4f43b..8db1f1191f 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -220,6 +220,7 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre const searchProvider = providerPageUtils.resolveDashboardProviderInfo("brave-search"); const audioProvider = providerPageUtils.resolveDashboardProviderInfo("assemblyai"); const webCookieProvider = providerPageUtils.resolveDashboardProviderInfo("grok-web"); + const apiKeyProvider = providerPageUtils.resolveDashboardProviderInfo("glhf"); const perplexityWebProvider = providerPageUtils.resolveDashboardProviderInfo("perplexity-web"); const blackboxWebProvider = providerPageUtils.resolveDashboardProviderInfo("blackbox-web"); const museSparkWebProvider = providerPageUtils.resolveDashboardProviderInfo("muse-spark-web"); @@ -236,6 +237,9 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre assert.equal(audioProvider?.category, "audio"); assert.equal(audioProvider?.name, providers.AUDIO_ONLY_PROVIDERS.assemblyai.name); + assert.equal(apiKeyProvider?.category, "apikey"); + assert.equal(apiKeyProvider?.name, providers.APIKEY_PROVIDERS.glhf.name); + assert.equal(webCookieProvider?.category, "web-cookie"); assert.equal(webCookieProvider?.name, providers.WEB_COOKIE_PROVIDERS["grok-web"].name); @@ -257,6 +261,10 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre test("managed provider connection ids include supported static categories and exclude upstream proxy", () => { assert.equal(providerCatalog.isManagedProviderConnectionId("qoder"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("glhf"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("cablyai"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("thebai"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("fenayai"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("sdwebui"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("lm-studio"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("vllm"), true); @@ -285,6 +293,10 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent assert.equal("blackbox-web" in providers.WEB_COOKIE_PROVIDERS, true); assert.equal("muse-spark-web" in providers.APIKEY_PROVIDERS, false); assert.equal("muse-spark-web" in providers.WEB_COOKIE_PROVIDERS, true); + assert.equal("glhf" in providers.APIKEY_PROVIDERS, true); + assert.equal("cablyai" in providers.APIKEY_PROVIDERS, true); + assert.equal("thebai" in providers.APIKEY_PROVIDERS, true); + assert.equal("fenayai" in providers.APIKEY_PROVIDERS, true); const apiKeyEntries = providerPageUtils.buildStaticProviderEntries("apikey", () => ({ total: 0, @@ -316,6 +328,22 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent apiKeyEntries.some((entry) => entry.providerId === "grok-web"), false ); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "glhf"), + true + ); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "cablyai"), + true + ); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "thebai"), + true + ); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "fenayai"), + true + ); assert.equal( localEntries.some((entry) => entry.providerId === "sdwebui"), true diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index 2fd14f0488..48f81b6452 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -30,6 +30,38 @@ test.after(() => { test("providers route accepts managed local, audio, web-cookie and search providers", async () => { const cases = [ + { + provider: "glhf", + body: { + provider: "glhf", + apiKey: "glhf-key", + name: "GLHF Chat", + }, + }, + { + provider: "cablyai", + body: { + provider: "cablyai", + apiKey: "cably-key", + name: "CablyAI Primary", + }, + }, + { + provider: "thebai", + body: { + provider: "thebai", + apiKey: "theb-key", + name: "TheB.AI Primary", + }, + }, + { + provider: "fenayai", + body: { + provider: "fenayai", + apiKey: "fenay-key", + name: "FenayAI Primary", + }, + }, { provider: "sdwebui", body: {