diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d1e503ed..92943a9e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index a3e30f42c7..32850c900f 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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 = { "grok-web": grok_webProvider, kie: kieProvider, monsterapi: monsterapiProvider, + modelscope: modelscopeProvider, sensenova: sensenovaProvider, hyperbolic: hyperbolicProvider, "lambda-ai": lambda_aiProvider, diff --git a/open-sse/config/providers/registry/modelscope/index.ts b/open-sse/config/providers/registry/modelscope/index.ts new file mode 100644 index 0000000000..f82b7cbe72 --- /dev/null +++ b/open-sse/config/providers/registry/modelscope/index.ts @@ -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: [], +}; diff --git a/src/shared/constants/providers/apikey/inference-hosts.ts b/src/shared/constants/providers/apikey/inference-hosts.ts index f019501590..a0f00fae36 100644 --- a/src/shared/constants/providers/apikey/inference-hosts.ts +++ b/src/shared/constants/providers/apikey/inference-hosts.ts @@ -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", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index b535763f8a..312c66b7b9 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2709,6 +2709,29 @@ "stream": "https://api.modal.ai/v1/chat/completions" } }, + "modelscope": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "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": { diff --git a/tests/unit/modelscope-provider.test.ts b/tests/unit/modelscope-provider.test.ts new file mode 100644 index 0000000000..c4bdbfce73 --- /dev/null +++ b/tests/unit/modelscope-provider.test.ts @@ -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>).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" + ); +}); diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 601d03f72e..25ad085af1 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -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/.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).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", () => {