From 2c4597c5f1701cbc84f387768c0e4f4bc2341b84 Mon Sep 17 00:00:00 2001 From: DGrid Date: Wed, 24 Jun 2026 20:27:31 +0800 Subject: [PATCH] add DGrid AI gateway provider Rebased onto release/v3.8.37; the dgrid APIKEY_PROVIDERS entry was relocated into src/shared/constants/providers/apikey/gateways.ts (the inline object was god-file-split into semantic-family files) and a CHANGELOG entry added. Co-authored-by: diegosouzapw --- CHANGELOG.md | 2 + open-sse/config/providers/index.ts | 2 + .../config/providers/registry/dgrid/index.ts | 15 ++ src/app/api/providers/[id]/models/route.ts | 3 + src/shared/constants/config.ts | 1 + .../constants/providers/apikey/gateways.ts | 17 ++ tests/unit/dgrid-provider.test.ts | 162 ++++++++++++++++++ 7 files changed, 202 insertions(+) create mode 100644 open-sse/config/providers/registry/dgrid/index.ts create mode 100644 tests/unit/dgrid-provider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 399c68cc50b9..3e681d0a9a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **feat(providers):** add DGrid AI gateway provider — OpenAI-compatible gateway at `api.dgrid.ai/v1` (alias `dgrid`, API-key auth, passthrough models). Free router tier (10 RPM / 100 RPD); a $5 lifetime top-up raises limits to 20 RPM / 1,000 RPD. (thanks @dgridOP) + - **feat(providers):** add Pioneer AI (Fastino Labs) provider — OpenAI-compatible chat completions at `api.pioneer.ai/v1`. Registered with alias `pn`, `X-API-Key` auth, and a catalog of 10 open-tier serverless models (Qwen3, Llama 3.1/3.2, Gemma 3, SmolLM3). Free $75 credits, no credit card required. Gated enterprise models (Claude/GPT/Gemini) require prior fine-tuning on the Pioneer platform and are intentionally excluded from the catalog. (thanks @HikiNarou) - **feat(sse): auto-promote successful combo model**: a new opt-in `comboAutoPromoteEnabled` setting reorders a combo's persisted model list so that, when a combo model responds successfully, it is moved to position #1 for future requests. (thanks @arssnndr) diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 988e419a79d7..d02172990fbe 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -40,6 +40,7 @@ import { baichuanProvider } from "./registry/baichuan/index.ts"; import { yiProvider } from "./registry/yi/index.ts"; import { deepseekProvider } from "./registry/deepseek/index.ts"; import { deepseek_webProvider } from "./registry/deepseek/web/index.ts"; +import { dgridProvider } from "./registry/dgrid/index.ts"; import { kimi_coding_apikeyProvider } from "./registry/kimi/coding-apikey/index.ts"; import { kimi_codingProvider } from "./registry/kimi/coding/index.ts"; import { kimiProvider } from "./registry/kimi/index.ts"; @@ -208,6 +209,7 @@ export const REGISTRY: Record = { yi: yiProvider, deepseek: deepseekProvider, "deepseek-web": deepseek_webProvider, + dgrid: dgridProvider, "kimi-coding-apikey": kimi_coding_apikeyProvider, "kimi-coding": kimi_codingProvider, kimi: kimiProvider, diff --git a/open-sse/config/providers/registry/dgrid/index.ts b/open-sse/config/providers/registry/dgrid/index.ts new file mode 100644 index 000000000000..e759bc13566f --- /dev/null +++ b/open-sse/config/providers/registry/dgrid/index.ts @@ -0,0 +1,15 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const dgridProvider: RegistryEntry = { + id: "dgrid", + alias: "dgrid", + format: "openai", + executor: "default", + baseUrl: "https://api.dgrid.ai/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + modelsUrl: "https://api.dgrid.ai/v1/models", + defaultContextLength: 128000, + models: [{ id: "dgridai/free", name: "DGrid Free Models Router" }], + passthroughModels: true, +}; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 51053397c4fd..21b3894eead2 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -191,6 +191,9 @@ const NAMED_OPENAI_STYLE_PROVIDERS = new Set([ // but was left out of the sweep, so it served a stale hardcoded seed (grok-3, grok-2-1212, // claude-3.7-sonnet …). Live fetch keeps it fresh; seed stays as the offline fallback. "api-airforce", + // DGrid is an OpenAI-compatible gateway whose default seed is the free auto-router; + // the full model catalog is discovered live from https://api.dgrid.ai/v1/models. + "dgrid", ]); function isNamedOpenAIStyleProvider(provider: string): boolean { diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index 453a32e51354..2a1e373360a5 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -20,6 +20,7 @@ export const API_ENDPOINTS = { export const PROVIDER_ENDPOINTS = { agentrouter: "https://agentrouter.org/v1/chat/completions", openrouter: "https://openrouter.ai/api/v1/chat/completions", + dgrid: "https://api.dgrid.ai/v1/chat/completions", glm: "https://api.z.ai/api/anthropic/v1/messages", glmt: "https://api.z.ai/api/anthropic/v1/messages", "bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages", diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 51ca63fbaeb6..3b668e567f18 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -40,6 +40,23 @@ export const APIKEY_PROVIDERS_GATEWAYS = { hasFree: true, freeNote: "Free models at $0/token with :free suffix - 20 RPM / 200 RPD", }, + dgrid: { + id: "dgrid", + alias: "dgrid", + name: "DGrid", + icon: "router", + color: "#65A30D", + textIcon: "DG", + passthroughModels: true, + website: "https://dgrid.ai", + hasFree: true, + freeNote: + "DGrid Free Models Router: 10 requests/minute and 100 requests/day. " + + "A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day.", + apiHint: + "Create a DGrid API key at https://dgrid.ai, then use https://api.dgrid.ai/v1 " + + "as the OpenAI-compatible base URL.", + }, orcarouter: { id: "orcarouter", alias: "orcarouter", diff --git a/tests/unit/dgrid-provider.test.ts b/tests/unit/dgrid-provider.test.ts new file mode 100644 index 000000000000..4542afad330c --- /dev/null +++ b/tests/unit/dgrid-provider.test.ts @@ -0,0 +1,162 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { PROVIDER_ENDPOINTS } = await import("../../src/shared/constants/config.ts"); +const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); +const { isValidModel } = await import("../../src/shared/constants/models.ts"); + +const DGRID_CHAT_URL = "https://api.dgrid.ai/v1/chat/completions"; +const DGRID_MODELS_URL = "https://api.dgrid.ai/v1/models"; +const DGRID_FREE_ROUTER = "dgridai/free"; + +test("DGrid is registered as a free API-key provider", () => { + const entry = APIKEY_PROVIDERS.dgrid; + assert.ok(entry, "APIKEY_PROVIDERS.dgrid must be defined"); + assert.equal(entry.id, "dgrid"); + assert.equal(entry.alias, "dgrid"); + assert.equal(entry.name, "DGrid"); + assert.equal(entry.website, "https://dgrid.ai"); + assert.equal(entry.hasFree, true); + assert.equal(entry.passthroughModels, true); + assert.match(entry.freeNote, /10 requests\/minute/); + assert.match(entry.freeNote, /100 requests\/day/); + assert.match(entry.freeNote, /\$5 lifetime top-up/); +}); + +test("DGrid exposes the OpenAI-compatible chat completions endpoint", () => { + assert.equal(PROVIDER_ENDPOINTS.dgrid, DGRID_CHAT_URL); +}); + +test("DGrid registry entry uses OpenAI format with bearer API-key auth", () => { + const entry = providerRegistry.dgrid; + assert.ok(entry, "providerRegistry.dgrid must be defined"); + assert.equal(entry.id, "dgrid"); + assert.equal(entry.alias, "dgrid"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, DGRID_CHAT_URL); + assert.equal(entry.modelsUrl, DGRID_MODELS_URL); + assert.equal(entry.passthroughModels, true); +}); + +test("DGrid ships the Free Models Router as the seeded default model", () => { + const models = providerRegistry.dgrid.models; + const ids = models.map((model: { id: string }) => model.id); + assert.ok(ids.includes(DGRID_FREE_ROUTER), "seed list must include dgridai/free"); + assert.equal(new Set(ids).size, ids.length, "model ids must be unique"); + assert.equal( + models.find((model: { id: string }) => model.id === DGRID_FREE_ROUTER)?.name, + "DGrid Free Models Router" + ); +}); + +test("DGrid accepts the free router and the 200+ live catalog via passthrough models", () => { + assert.equal(isValidModel("dgrid", DGRID_FREE_ROUTER), true); + assert.equal(isValidModel("dgrid", "openai/gpt-4o"), true); + assert.equal(isValidModel("dgrid", "anthropic/claude-sonnet-4"), true); +}); + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-dgrid-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +interface ModelsBody { + provider: string; + connectionId: string; + models: Array<{ id: string }>; + source?: string; +} + +test("DGrid import fetches the live /v1/models catalog", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "dgrid", + authType: "apikey", + name: "dgrid-live", + apiKey: "dgrid-key", + }); + + let fetched = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (String(url) === DGRID_MODELS_URL) { + fetched = true; + return Response.json({ + object: "list", + data: [ + { id: DGRID_FREE_ROUTER }, + { id: "openai/gpt-4o" }, + { id: "anthropic/claude-sonnet-4" }, + ], + }); + } + return new Response("not found", { status: 404 }); + }; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = (await response.json()) as ModelsBody; + assert.equal(body.provider, "dgrid"); + assert.equal(body.source, "api", "should serve the live upstream catalog"); + assert.ok(fetched, `should have probed ${DGRID_MODELS_URL}`); + const ids = body.models.map((model) => model.id); + assert.ok(ids.includes(DGRID_FREE_ROUTER), `free router missing: ${ids.join(",")}`); + assert.ok(ids.includes("openai/gpt-4o"), `live catalog model missing: ${ids.join(",")}`); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("DGrid import falls back to the seeded free router when live fetch fails", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "dgrid", + authType: "apikey", + name: "dgrid-fallback", + apiKey: "dgrid-key-2", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response("bad gateway", { status: 502 }); + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = (await response.json()) as ModelsBody; + assert.equal(body.provider, "dgrid"); + assert.equal(body.source, "local_catalog", "import must not break when upstream is down"); + assert.deepEqual( + body.models.map((model) => model.id), + [DGRID_FREE_ROUTER] + ); + } finally { + globalThis.fetch = originalFetch; + } +});