feat(providers): add four OpenAI-compatible aggregator providers (dahl, atria, agnes, bai)

Adds Dahl Inference, Atria Dawn, Agnes AI, and B.AI following the existing
registry pattern. Each uses DefaultExecutor with no custom translator needed.
This commit is contained in:
Nikan Wystaf
2026-09-26 16:04:40 +07:00
committed by decolua
parent 90b0693423
commit 06112c136c
14 changed files with 314 additions and 0 deletions

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

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

BIN
public/providers/agnes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
public/providers/atria.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/providers/bai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
public/providers/dahl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -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({

View File

@@ -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" };

View File

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

View File

@@ -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"
}
}

View File

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