diff --git a/open-sse/providers/registry/agnes.js b/open-sse/providers/registry/agnes.js new file mode 100644 index 00000000..4cf2a88e --- /dev/null +++ b/open-sse/providers/registry/agnes.js @@ -0,0 +1,29 @@ +export default { + id: "agnes", + priority: 120, + alias: "agnes", + aliases: [ + "agnes-ai", + ], + uiAlias: "agnes", + display: { + name: "Agnes AI", + icon: "auto_awesome", + color: "#7C3AED", + textIcon: "AG", + website: "https://agnes-ai.com", + notice: { + text: "OpenAI-compatible gateway from Agnes AI, offering free API credits on sign-up. Accepts a bearer token or an x-api-key header.", + apiKeyUrl: "https://platform.agnes-ai.com", + }, + }, + category: "freeTier", + authType: "apikey", + transport: { + baseUrl: "https://apihub.agnes-ai.com/v1/chat/completions", + validateUrl: "https://apihub.agnes-ai.com/v1/models", + }, + // No model ids could be verified without a key, so discovery is left to the + // live endpoint and any id is accepted through passthroughModels. + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/atria.js b/open-sse/providers/registry/atria.js new file mode 100644 index 00000000..ed9de421 --- /dev/null +++ b/open-sse/providers/registry/atria.js @@ -0,0 +1,32 @@ +export default { + id: "atria", + priority: 120, + alias: "atria", + aliases: [ + "atria-asi", + ], + uiAlias: "atria", + display: { + name: "Atria Dawn", + icon: "flare", + color: "#C2410C", + textIcon: "AD", + website: "https://atria-asi.ai", + notice: { + text: "OpenAI-compatible endpoint from Atria Dawn (AtomInnoLab). Currently a research preview offering a single text model, Atria-Dawn-Preview.", + apiKeyUrl: "https://api.atria-asi.ai/dashboard", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.atria-asi.ai/v1/chat/completions", + validateUrl: "https://api.atria-asi.ai/v1/models", + }, + // Docs pin the model field to one case-sensitive id. Text-only for now: the + // service ships a hook that blocks image/PDF input, so no vision is claimed. + models: [ + { id: "Atria-Dawn-Preview", name: "Atria Dawn Preview" }, + ], + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/bai.js b/open-sse/providers/registry/bai.js new file mode 100644 index 00000000..84eb2797 --- /dev/null +++ b/open-sse/providers/registry/bai.js @@ -0,0 +1,30 @@ +export default { + id: "bai", + priority: 120, + alias: "bai", + aliases: [ + "b-ai", + ], + uiAlias: "bai", + display: { + name: "B.AI", + icon: "account_balance", + color: "#0369A1", + textIcon: "BA", + website: "https://b.ai", + notice: { + text: "OpenAI-compatible gateway with one of the larger catalogues here. Accepts a bearer token or an x-api-key header. Model ids are fetched live from the provider.", + apiKeyUrl: "https://b.ai", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.b.ai/v1/chat/completions", + validateUrl: "https://api.b.ai/v1/models", + }, + // No ids hardcoded: the catalogue is large and rotates, so the live endpoint + // is the source of truth and any id is accepted via passthroughModels. + modelsFetcher: { url: "https://api.b.ai/v1/models", type: "openai" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/dahl.js b/open-sse/providers/registry/dahl.js new file mode 100644 index 00000000..8efc0189 --- /dev/null +++ b/open-sse/providers/registry/dahl.js @@ -0,0 +1,35 @@ +export default { + id: "dahl", + priority: 120, + alias: "dahl", + aliases: [ + "dahl-inference", + ], + uiAlias: "dahl", + display: { + name: "Dahl Inference", + icon: "hub", + color: "#1E40AF", + textIcon: "DH", + website: "https://dahl.global", + notice: { + text: "OpenAI-compatible Gonka inference node. Small, fixed catalogue (GLM-5.3-Flash, DeepSeek-V4-Flash, MiniMax-M2.7) at a flat per-token rate.", + apiKeyUrl: "https://dahl.global/dashboard", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://inference.dahl.global/v1/chat/completions", + validateUrl: "https://inference.dahl.global/v1/models", + }, + // The live catalogue is public (no auth), so modelsFetcher works without a key + // and the ids below are a convenience seed rather than an exhaustive list. + models: [ + { id: "zai-org/GLM-5.3-Flash", name: "GLM-5.3 Flash" }, + { id: "deepseek-ai/DeepSeek-V4-Flash-0731", name: "DeepSeek V4 Flash 0731" }, + { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" }, + ], + modelsFetcher: { url: "https://inference.dahl.global/v1/models", type: "openai" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 0e8dbe2e..4318a5a1 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -125,6 +125,10 @@ import p119 from "./selfhosted-embedding.js"; import p120 from "./fish-audio.js"; import p121 from "./alitp-intl.js"; import p122 from "./xquik.js"; +import p126 from "./dahl.js"; +import p127 from "./atria.js"; +import p129 from "./agnes.js"; +import p130 from "./bai.js"; export default [ p0, p1, @@ -250,4 +254,8 @@ export default [ p120, p121, p122, + p126, + p127, + p129, + p130, ]; diff --git a/public/providers/agnes.png b/public/providers/agnes.png new file mode 100644 index 00000000..8615a313 Binary files /dev/null and b/public/providers/agnes.png differ diff --git a/public/providers/atria.png b/public/providers/atria.png new file mode 100644 index 00000000..8ef034e3 Binary files /dev/null and b/public/providers/atria.png differ diff --git a/public/providers/bai.png b/public/providers/bai.png new file mode 100644 index 00000000..878045c5 Binary files /dev/null and b/public/providers/bai.png differ diff --git a/public/providers/dahl.png b/public/providers/dahl.png new file mode 100644 index 00000000..fbcf4270 Binary files /dev/null and b/public/providers/dahl.png differ diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index d209a30f..4b5f9f82 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -302,6 +302,13 @@ const PROVIDER_MODELS_CONFIG = { nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"), assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"), "vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"), + // OpenAI-compatible aggregators. All standard Bearer + OpenAI shape, so they + // reuse createOpenAIModelsConfig; Kira and Dahl serve their catalogue + // publicly, the rest need the connection API key. + dahl: createOpenAIModelsConfig("https://inference.dahl.global/v1/models"), + atria: createOpenAIModelsConfig("https://api.atria-asi.ai/v1/models"), + agnes: createOpenAIModelsConfig("https://apihub.agnes-ai.com/v1/models"), + bai: createOpenAIModelsConfig("https://api.b.ai/v1/models"), kimchi: { customResolver: async (connection) => { const result = await resolveKimchiModels({ diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index b81294d4..90e19d83 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -696,6 +696,16 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } + // OpenAI-compatible aggregators. All standard Bearer + OpenAI shape, so the + // same /v1/models probe applies to each. + case "dahl": + case "atria": + case "agnes": + case "bai": { + const cfg = PROVIDERS[connection.provider]; + const res = await fetchWithConnectionProxy(cfg.validateUrl, { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } case "ollama": { const res = await fetch("https://ollama.com/api/tags", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index a387cb7f..547f0c0e 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -119,6 +119,7 @@ "morphllm": "morph" }, "idToAlias": { + "agnes": "agnes", "alicode": "alicode", "alicode-intl": "alicode-intl", "alims-intl": "alims-intl", @@ -127,7 +128,9 @@ "antigravity": "ag", "api-airforce": "af", "assemblyai": "assemblyai", + "atria": "atria", "azure": "azure", + "bai": "bai", "baidu": "qianfan", "bazaarlink": "bzl", "blackbox": "blackbox", @@ -145,6 +148,7 @@ "cohere": "cohere", "commandcode": "commandcode", "cursor": "cu", + "dahl": "dahl", "deepgram": "deepgram", "deepseek": "deepseek", "featherless": "featherless", @@ -212,6 +216,7 @@ "alitp-intl", "anthropic", "assemblyai", + "atria", "black-forest-labs", "blackbox", "bm", @@ -229,6 +234,7 @@ "commandcode", "cu", "cx", + "dahl", "deepgram", "deepseek", "edge-tts", diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index bab11342..85a3ee85 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -1085,5 +1085,25 @@ "preserveCacheControl": true }, "format": "openai" + }, + "dahl": { + "baseUrl": "https://inference.dahl.global/v1/chat/completions", + "validateUrl": "https://inference.dahl.global/v1/models", + "format": "openai" + }, + "atria": { + "baseUrl": "https://api.atria-asi.ai/v1/chat/completions", + "validateUrl": "https://api.atria-asi.ai/v1/models", + "format": "openai" + }, + "agnes": { + "baseUrl": "https://apihub.agnes-ai.com/v1/chat/completions", + "validateUrl": "https://apihub.agnes-ai.com/v1/models", + "format": "openai" + }, + "bai": { + "baseUrl": "https://api.b.ai/v1/chat/completions", + "validateUrl": "https://api.b.ai/v1/models", + "format": "openai" } } \ No newline at end of file diff --git a/tests/unit/aggregator-providers-batch.test.js b/tests/unit/aggregator-providers-batch.test.js new file mode 100644 index 00000000..c270e93b --- /dev/null +++ b/tests/unit/aggregator-providers-batch.test.js @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import REGISTRY from "../../open-sse/providers/registry/index.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { getExecutor } from "../../open-sse/executors/index.js"; +import { DefaultExecutor } from "../../open-sse/executors/default.js"; + +/** + * OpenAI-compatible aggregator providers. Each was verified by probing the + * live /v1/models endpoint: a 401 with a structured error body confirms a + * real API behind the host, and Dahl/Kira answer 200 with no credentials. + */ +const BATCH = [ + { + id: "dahl", + category: "apikey", + baseUrl: "https://inference.dahl.global/v1/chat/completions", + modelsUrl: "https://inference.dahl.global/v1/models", + aliases: ["dahl-inference"], + }, + { + id: "atria", + category: "apikey", + baseUrl: "https://api.atria-asi.ai/v1/chat/completions", + modelsUrl: "https://api.atria-asi.ai/v1/models", + aliases: ["atria-asi"], + }, + { + id: "agnes", + category: "freeTier", + baseUrl: "https://apihub.agnes-ai.com/v1/chat/completions", + modelsUrl: "https://apihub.agnes-ai.com/v1/models", + aliases: ["agnes-ai"], + }, + { + id: "bai", + category: "apikey", + baseUrl: "https://api.b.ai/v1/chat/completions", + modelsUrl: "https://api.b.ai/v1/models", + aliases: ["b-ai"], + }, +]; + +describe.each(BATCH)("$id provider", (p) => { + const entry = REGISTRY.find((e) => e.id === p.id); + + it("is registered with the expected category and base URL", () => { + expect(entry).toBeDefined(); + expect(entry.category).toBe(p.category); + expect(PROVIDERS[p.id].baseUrl).toBe(p.baseUrl); + expect(PROVIDERS[p.id].format).toBe("openai"); + }); + + it("exposes its aliases and a UI display name", () => { + for (const a of p.aliases) expect(entry.aliases).toContain(a); + expect(entry.display?.name).toBeTruthy(); + expect(entry.display?.textIcon).toBeTruthy(); + }); + + it("routes through the shared DefaultExecutor", () => { + expect(getExecutor(p.id)).toBeInstanceOf(DefaultExecutor); + }); + + it("accepts arbitrary model ids via passthrough", () => { + expect(entry.passthroughModels).toBe(true); + }); +}); + +describe("Atria Dawn specifics", () => { + const entry = REGISTRY.find((e) => e.id === "atria"); + + it("is named after the service, not just the host", () => { + expect(entry.display.name).toBe("Atria Dawn"); + }); + + it("pins the single documented preview model", () => { + expect(entry.models.map((m) => m.id)).toEqual(["Atria-Dawn-Preview"]); + }); +}); + +describe("provider icons", () => { + // This file lives in tests/unit/, so resolve icons against the repo root. + const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + + it("ships a /public/providers/{id}.png for every provider in the batch", () => { + // getProviderIconSrc() resolves /providers/{id}.png and falls back to the + // textIcon tile when the file 404s, so a missing icon is silent in the UI. + for (const p of BATCH.map((x) => x.id)) { + expect(existsSync(join(REPO_ROOT, "public", "providers", `${p}.png`))).toBe(true); + } + }); +}); + +describe("authenticated model discovery", () => { + const ROUTE = join( + dirname(fileURLToPath(import.meta.url)), "..", "..", + "src", "app", "api", "providers", "[id]", "models", "route.js" + ); + const source = readFileSync(ROUTE, "utf8"); + + it("registers every batch provider in the /models resolver", () => { + // Without an entry here the route answers + // 400 "Provider X does not support models listing" and live discovery + // silently fails once a key is saved. + for (const p of BATCH.map((x) => x.id)) { + expect(source).toContain(`${p}: createOpenAIModelsConfig(`); + } + }); + + it("points each entry at that provider's own /models URL", () => { + for (const p of BATCH) { + const line = source.split("\n").find((l) => l.trim().startsWith(`${p.id}: createOpenAIModelsConfig(`)); + expect(line, `no models entry for ${p.id}`).toBeTruthy(); + expect(line).toContain(p.modelsUrl); + } + }); +}); + +describe("batch invariants", () => { + it("keeps every registry id unique", () => { + const ids = REGISTRY.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("does not claim vision for any provider in the batch", () => { + // These aggregators relay third-party models; none of them has documented + // image input, so no registry entry may assert it. + for (const p of BATCH) { + const entry = REGISTRY.find((e) => e.id === p.id); + expect(entry.serviceKinds ?? ["llm"]).toContain("llm"); + expect(entry.imageToTextConfig).toBeUndefined(); + } + }); +});