feat(providers): add ModelScope OpenAI-compatible provider (#5965)

* feat(providers): add ModelScope OpenAI-compatible provider

Ports ModelScope (Alibaba 魔搭) as a new API-key, OpenAI-compatible
provider — upstream 9router PR #1764. The upstream PR hardcoded
`https://api-inference.modelscope.ai/...` (`.ai` TLD); verified against
ModelScope's own API-Inference docs and third-party integration guides
that the real production domain is `api-inference.modelscope.cn`
(`.cn` TLD) and shipped that instead. Also drops the PR's static
5-model snapshot in favor of `passthroughModels: true` with an empty
seed list + `modelsUrl`, since ModelScope's open-model catalog moves
fast.

Updates the providers-constants-split characterization test's hardcoded
APIKEY_PROVIDERS count (159 -> 160) to match the new entry.

Co-authored-by: Umar Javed <114807145+tn5052@users.noreply.github.com>
Inspired-by: https://github.com/decolua/9router/pull/1764

* chore(changelog): restore release entries + add modelscope bullet

* test(golden): regenerate translate-path for modelscope provider

---------

Co-authored-by: Umar Javed <114807145+tn5052@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 00:38:06 -03:00
committed by GitHub
parent 196375b8a9
commit 7aaf109c0c
7 changed files with 136 additions and 6 deletions

View File

@@ -20,6 +20,7 @@
- **feat(oauth):** import a ChatGPT/Codex connection from a raw access token (no refresh token required). (thanks @ryanngit)
- **feat(providers):** add NVIDIA NIM image generation (FLUX models). (thanks @eng2007)
- **feat(providers):** add Augment (Auggie CLI) as a local no-auth provider. (thanks @chamdanilukman)
- **feat(providers):** add ModelScope as an OpenAI-compatible (API-key) provider. (thanks @tn5052)
### 🔧 Bug Fixes

View File

@@ -79,6 +79,7 @@ import { leonardoProvider } from "./registry/leonardo/index.ts";
import { grok_webProvider } from "./registry/grok-web/index.ts";
import { kieProvider } from "./registry/kie/index.ts";
import { monsterapiProvider } from "./registry/monsterapi/index.ts";
import { modelscopeProvider } from "./registry/modelscope/index.ts";
import { sensenovaProvider } from "./registry/sensenova/index.ts";
import { hyperbolicProvider } from "./registry/hyperbolic/index.ts";
import { lambda_aiProvider } from "./registry/lambda-ai/index.ts";
@@ -252,6 +253,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"grok-web": grok_webProvider,
kie: kieProvider,
monsterapi: monsterapiProvider,
modelscope: modelscopeProvider,
sensenova: sensenovaProvider,
hyperbolic: hyperbolicProvider,
"lambda-ai": lambda_aiProvider,

View File

@@ -0,0 +1,24 @@
import type { RegistryEntry } from "../../shared.ts";
// ModelScope (Alibaba 魔搭) — OpenAI-compatible API-Inference, ported from upstream
// 9router PR #1764 (@tn5052). The upstream PR hardcoded `https://api-inference.modelscope.ai/...`
// (`.ai` TLD) and a static 5-model list. Both were dropped here after verification:
//
// - baseUrl: ModelScope's own API-Inference docs (modelscope.cn/docs/model-service/API-Inference)
// and third-party integration guides (e.g. Alibaba Cloud Model Studio compatibility docs)
// consistently confirm the production domain is `api-inference.modelscope.cn` — the `.cn` TLD,
// not `.ai`. Using the unverified `.ai` domain would have shipped a broken provider.
// - models: passthrough + empty static seed instead of copying the PR's 5-model snapshot, since
// ModelScope hosts a large and fast-moving open-model catalog — `modelsUrl` keeps the list live.
export const modelscopeProvider: RegistryEntry = {
id: "modelscope",
alias: "ms",
format: "openai",
executor: "default",
baseUrl: "https://api-inference.modelscope.cn/v1/chat/completions",
modelsUrl: "https://api-inference.modelscope.cn/v1/models",
authType: "apikey",
authHeader: "bearer",
passthroughModels: true,
models: [],
};

View File

@@ -265,6 +265,21 @@ export const APIKEY_PROVIDERS_INFERENCE = {
passthroughModels: true,
authHint: "Get API key at monsterapi.ai",
},
modelscope: {
id: "modelscope",
alias: "ms",
name: "ModelScope",
icon: "cloud",
color: "#FF6A00",
textIcon: "MS",
website: "https://modelscope.cn",
hasFree: true,
// #1764 (upstream 9router): OpenAI-compatible API-Inference. Base URL verified
// live against ModelScope's own docs — the upstream PR used the `.ai` TLD, but
// the confirmed production domain is `api-inference.modelscope.cn` (see registry
// entry + test guard).
freeNote: "Free tier via ModelScope API-Inference — Alibaba account required.",
},
byteplus: {
id: "byteplus",
alias: "bpm",

View File

@@ -2709,6 +2709,29 @@
"stream": "https://api.modal.ai/v1/chat/completions"
}
},
"modelscope": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://api-inference.modelscope.cn/v1/chat/completions",
"stream": "https://api-inference.modelscope.cn/v1/chat/completions"
}
},
"monsterapi": {
"format": "openai",
"headers": {

View File

@@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import { modelscopeProvider } from "../../open-sse/config/providers/registry/modelscope/index.ts";
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
import { APIKEY_PROVIDERS } from "../../src/shared/constants/providers/apikey/index.ts";
import { AI_PROVIDERS, getProviderById } from "../../src/shared/constants/providers.ts";
// Upstream 9router PR #1764 (@tn5052) ports ModelScope (Alibaba 魔搭) as an OpenAI-compatible
// BYOK free-tier provider. The upstream PR hardcoded `https://api-inference.modelscope.ai/...`
// (`.ai` TLD) — ModelScope's own docs confirm the real production domain is
// `api-inference.modelscope.cn` (`.cn` TLD). This suite locks in the verified domain and
// guards against a regression back to the unverified `.ai` domain.
test("providers-shape: modelscope is registered in APIKEY_PROVIDERS metadata", () => {
assert.ok("modelscope" in APIKEY_PROVIDERS, "modelscope missing from APIKEY_PROVIDERS");
const meta = (APIKEY_PROVIDERS as Record<string, Record<string, unknown>>).modelscope;
assert.equal(meta.id, "modelscope");
assert.equal(meta.alias, "ms");
assert.equal(meta.name, "ModelScope");
assert.equal(meta.website, "https://modelscope.cn");
assert.equal(meta.hasFree, true);
});
test("providers-shape: modelscope is a canonical AI_PROVIDERS entry resolvable by id", () => {
assert.ok("modelscope" in AI_PROVIDERS, "modelscope missing from AI_PROVIDERS");
const provider = getProviderById("modelscope");
assert.ok(provider, "getProviderById('modelscope') returned nothing");
assert.equal(provider?.id, "modelscope");
});
test("registry resolution: modelscope resolves in the provider REGISTRY as OpenAI-compatible", () => {
assert.ok("modelscope" in REGISTRY, "modelscope missing from REGISTRY");
const entry = REGISTRY.modelscope;
assert.equal(entry.format, "openai");
assert.equal(entry.executor, "default");
assert.equal(entry.authType, "apikey");
assert.equal(entry.authHeader, "bearer");
assert.equal(entry, modelscopeProvider);
});
test("registry resolution: modelscope uses passthrough models with an empty static seed list", () => {
assert.equal(modelscopeProvider.passthroughModels, true);
assert.deepEqual(modelscopeProvider.models, []);
assert.equal(
modelscopeProvider.modelsUrl,
"https://api-inference.modelscope.cn/v1/models",
"modelsUrl must point at the verified .cn domain"
);
});
test("baseUrl guard: modelscope targets the verified api-inference.modelscope.cn domain (not .ai)", () => {
assert.equal(
modelscopeProvider.baseUrl,
"https://api-inference.modelscope.cn/v1/chat/completions"
);
assert.ok(
modelscopeProvider.baseUrl.includes("api-inference.modelscope.cn"),
`baseUrl must use the verified .cn domain, got: ${modelscopeProvider.baseUrl}`
);
assert.ok(
!modelscopeProvider.baseUrl.includes("modelscope.ai"),
"baseUrl must not regress to the upstream PR's unverified .ai domain"
);
});

View File

@@ -1,7 +1,7 @@
// Characterization of the providers.ts catalog split (god-file decomposition): the host became a
// barrel that re-exports 10 data catalogs now living under constants/providers/*, and APIKEY is
// merged from 6 semantic family files (apikey/<family>.ts). Locks: the public surface (every catalog
// + helpers still exported), the spread-merge integrity (159 APIKEY entries, no loss/dup), and that
// + helpers still exported), the spread-merge integrity (160 APIKEY entries, no loss/dup), and that
// load-time Zod validation still runs. Pure-data move → behavior must be identical.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -31,12 +31,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
test("APIKEY_PROVIDERS merges the 6 family files into 159 entries (no loss / no dup)", async () => {
test("APIKEY_PROVIDERS merges the 6 family files into 160 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
assert.equal(keys.length, 159);
assert.equal(new Set(keys).size, 159, "duplicate keys after spread-merge");
assert.equal(keys.length, 160);
assert.equal(new Set(keys).size, 160, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
// strict partition (every provider in exactly one), so the sum must be exactly 159.
// strict partition (every provider in exactly one), so the sum must be exactly 160.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -56,7 +56,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 159 entries (no loss / no
seen.add(k);
}
}
assert.equal(famTotal, 159, "families must partition all 159 providers");
assert.equal(famTotal, 160, "families must partition all 160 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {