From dab59345482e2d145a32197b3bc5a5c369d911e2 Mon Sep 17 00:00:00 2001 From: iacore Date: Mon, 27 Jul 2026 13:06:50 +0800 Subject: [PATCH] feat(catalog,ai): add SiliconFlow providers with dynamic-only model discovery Adds siliconflow and siliconflow-cn OpenAI-compatible providers (api.siliconflow.com / api.siliconflow.cn), with the model list fetched live from each region's /v1/models endpoint instead of a bundled catalog (dynamicModelsAuthoritative, no models.dev mapping). SiliconFlow's /v1/models serves every model type (chat, embedding, reranker, image, audio, video) with no per-model type field, so discovery filters non-chat ids out of the picker. The filter was validated against the live catalog to match the server's own type=text&sub_type=chat classification exactly (64/64, no false drops, no false keeps). Wires SILICONFLOW_API_KEY / SILICONFLOW_CN_API_KEY into env-key discovery and registers API-key login providers so `omp login siliconflow` / `omp login siliconflow-cn` stores a credential validated against each region's /v1/models endpoint. --- docs/environment-variables.md | 2 + docs/providers.md | 2 + packages/ai/CHANGELOG.md | 3 + packages/ai/src/registry/registry.ts | 4 + packages/ai/src/registry/siliconflow-cn.ts | 22 +++ packages/ai/src/registry/siliconflow.ts | 22 +++ packages/catalog/CHANGELOG.md | 3 + .../src/provider-models/descriptors.ts | 16 +++ .../src/provider-models/openai-compat.ts | 84 ++++++++++++ .../catalog/test/siliconflow-provider.test.ts | 129 ++++++++++++++++++ 10 files changed, 287 insertions(+) create mode 100644 packages/ai/src/registry/siliconflow-cn.ts create mode 100644 packages/ai/src/registry/siliconflow.ts create mode 100644 packages/catalog/test/siliconflow-provider.test.ts diff --git a/docs/environment-variables.md b/docs/environment-variables.md index f5f6536da..ecceb4ed9 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -83,6 +83,8 @@ These are consumed via `getEnvApiKey()` (`packages/ai/src/stream.ts`) unless not | `ALIBABA_TOKEN_PLAN_API_KEY` | QwenCloud Token Plan auth | Using `alibaba-token-plan` provider | Preferred provider-specific name | | `BAILIAN_TOKEN_PLAN_API_KEY` | QwenCloud Token Plan auth | Using `alibaba-token-plan` provider | Compatible with Qwen Code's Token Plan preset | | `DEEPSEEK_API_KEY` | DeepSeek auth | Using DeepSeek models | | +| `SILICONFLOW_API_KEY` | SiliconFlow auth | Using `siliconflow` provider | | +| `SILICONFLOW_CN_API_KEY` | SiliconFlow (China) auth | Using `siliconflow-cn` provider | | | `KILO_API_KEY` | Kilo auth | Using Kilo models | | | `OLLAMA_CLOUD_API_KEY` | Ollama Cloud auth | Using `ollama-cloud` provider | | | `WAFER_SERVERLESS_API_KEY` | Wafer Serverless auth | Using `wafer-serverless` provider | Pay-as-you-go Wafer SKU; validated against `https://pass.wafer.ai/v1/models` | diff --git a/docs/providers.md b/docs/providers.md index f41fda858..487ef0c0c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -99,6 +99,8 @@ Each provider has one or more environment variables that supply a key when no st |---|---| | `cerebras` | `CEREBRAS_API_KEY` | | `deepseek` | `DEEPSEEK_API_KEY` | +| `siliconflow` | `SILICONFLOW_API_KEY` | +| `siliconflow-cn` | `SILICONFLOW_CN_API_KEY` | | `fireworks` | `FIREWORKS_API_KEY` | | `together` | `TOGETHER_API_KEY` | | `nvidia` | `NVIDIA_API_KEY` | diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fd56ca808..3caf93299 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -419,6 +419,9 @@ - Fixed Azure Foundry Anthropic utility requests to omit the structured-output beta whenever strict tools are disabled, preventing `structured_outputs not supported in your workspace` failures for Sonnet 5 compaction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)). - Fixed OAuth `launchUrl` advertisement for flows whose redirect never returns to the local callback server: custom-scheme redirects (e.g. GitLab Duo's `vscode://` URI, which `new URL` parses without complaint) and fixed non-loopback hosts no longer receive a `http://localhost:/launch` copy target that misrepresents the callback endpoint and resolves nowhere for remote users. - Codex load balancing: clear stale persisted and in-memory usage-limit blocks for an `openai-codex` account when a fresh live usage report shows it is allowed and below all limits, including broker-backed gateway snapshots, so traffic returns to recovered accounts instead of funneling to one sibling. +### Added + +- Added SiliconFlow and SiliconFlow (China) to the built-in API-key login provider catalog so `omp login siliconflow` / `omp login siliconflow-cn` stores a reusable credential validated against each region's `/v1/models` endpoint. ## [16.3.11] - 2026-07-06 diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts index c6d02aa7d..58c45906c 100644 --- a/packages/ai/src/registry/registry.ts +++ b/packages/ai/src/registry/registry.ts @@ -51,6 +51,8 @@ import { perplexityProvider } from "./perplexity"; import { qianfanProvider } from "./qianfan"; import { qwenPortalProvider } from "./qwen-portal"; import { sakanaProvider } from "./sakana"; +import { siliconflowProvider } from "./siliconflow"; +import { siliconflowCnProvider } from "./siliconflow-cn"; import { syntheticProvider } from "./synthetic"; import { tavilyProvider } from "./tavily"; import { togetherProvider } from "./together"; @@ -121,6 +123,8 @@ const ALL = [ perplexityProvider, qianfanProvider, veniceProvider, + siliconflowProvider, + siliconflowCnProvider, syntheticProvider, nanogptProvider, waferServerlessProvider, diff --git a/packages/ai/src/registry/siliconflow-cn.ts b/packages/ai/src/registry/siliconflow-cn.ts new file mode 100644 index 000000000..ebc6a2f39 --- /dev/null +++ b/packages/ai/src/registry/siliconflow-cn.ts @@ -0,0 +1,22 @@ +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const loginSiliconFlowCn = createApiKeyLogin({ + providerLabel: "SiliconFlow (China)", + authUrl: "https://cloud.siliconflow.cn/account/ak", + instructions: "Create or copy your API key from the SiliconFlow console", + promptMessage: "Paste your SiliconFlow API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "siliconflow-cn", + modelsUrl: "https://api.siliconflow.cn/v1/models", + }, +}); + +export const siliconflowCnProvider = { + id: "siliconflow-cn", + name: "SiliconFlow (China)", + login: (cb: OAuthLoginCallbacks) => loginSiliconFlowCn(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/siliconflow.ts b/packages/ai/src/registry/siliconflow.ts new file mode 100644 index 000000000..71772c8b7 --- /dev/null +++ b/packages/ai/src/registry/siliconflow.ts @@ -0,0 +1,22 @@ +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const loginSiliconFlow = createApiKeyLogin({ + providerLabel: "SiliconFlow", + authUrl: "https://cloud.siliconflow.com/account/ak", + instructions: "Create or copy your API key from the SiliconFlow console", + promptMessage: "Paste your SiliconFlow API key", + placeholder: "sk-...", + validation: { + kind: "models-endpoint", + provider: "siliconflow", + modelsUrl: "https://api.siliconflow.com/v1/models", + }, +}); + +export const siliconflowProvider = { + id: "siliconflow", + name: "SiliconFlow", + login: (cb: OAuthLoginCallbacks) => loginSiliconFlow(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 8d4a0fd3f..c376742ba 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -278,6 +278,9 @@ - Fixed LiteLLM discovery stopping at `/model_group/info` when that endpoint omitted `supports_vision`; it now continues to `/model/info` and preserves `model_info.supports_vision=true` for vision-capable proxy models. ([#4747](https://github.com/can1357/oh-my-pi/issues/4747)) - Fixed LiteLLM discovery to fall back to bundled catalog metadata when `models.dev` lacks a model reference, preserving reasoning and thinking support for models such as `glm-5.2`. ([#4695](https://github.com/can1357/oh-my-pi/issues/4695)) - Detected Azure AI Inference / Foundry Anthropic routes as strict-tool-incompatible so resolved Anthropic compat disables strict tools before request construction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)). +### Added + +- Added SiliconFlow providers (`siliconflow`, `siliconflow-cn`) with dynamic-only OpenAI-compatible model discovery: no bundled catalog — the model list is fetched live from each region's `/v1/models` endpoint, with non-chat entries (embedding, reranker, image, audio, video) filtered out. `SILICONFLOW_API_KEY` / `SILICONFLOW_CN_API_KEY` environment variables are wired into `getEnvApiKey`. ## [16.3.11] - 2026-07-06 diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index e40781ece..c001e8b4b 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -41,6 +41,8 @@ import { qianfanModelManagerOptions, qwenPortalModelManagerOptions, sakanaModelManagerOptions, + siliconflowCnModelManagerOptions, + siliconflowModelManagerOptions, syntheticModelManagerOptions, togetherModelManagerOptions, umansModelManagerOptions, @@ -369,6 +371,20 @@ export const CATALOG_PROVIDERS = [ dynamicModelsAuthoritative: true, catalogDiscovery: { label: "Sakana AI" }, }, + { + id: "siliconflow", + defaultModel: "zai-org/GLM-5.1", + envVars: ["SILICONFLOW_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => siliconflowModelManagerOptions(config), + dynamicModelsAuthoritative: true, + }, + { + id: "siliconflow-cn", + defaultModel: "deepseek-ai/DeepSeek-V4-Pro", + envVars: ["SILICONFLOW_CN_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => siliconflowCnModelManagerOptions(config), + dynamicModelsAuthoritative: true, + }, { id: "synthetic", defaultModel: "hf:zai-org/GLM-5.1", diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index eb99fa298..ab0e82a2e 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1436,6 +1436,90 @@ export function deepseekModelManagerOptions( ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("deepseek", "https://api.deepseek.com", config); } + +// --------------------------------------------------------------------------- +// 6.6 SiliconFlow +// --------------------------------------------------------------------------- + +export interface SiliconFlowModelManagerConfig { + apiKey?: string; + baseUrl?: string; + fetch?: FetchImpl; +} + +/** + * SiliconFlow's `/v1/models` lists every served model — including embeddings, + * rerankers, image, audio, and video generators that cannot serve chat + * completions — and carries no per-model type field, so non-chat entries are + * dropped by id to keep the picker usable. + */ +const SILICONFLOW_NON_CHAT_MODEL_TOKENS = [ + "embedding", + "reranker", + "bge-", + "bce-", + "stable-diffusion", + "image", + "flux", + "kolors", + "sensevoice", + "cosyvoice", + "fish-speech", + "indextts", + "sovits", + "whisper", + "hunyuanvideo", + "wan2", + "ltx-video", + "speech", + "moderator", + "tts", +] as const; + +export function isLikelySiliconFlowChatModelId(id: string): boolean { + const normalized = id.trim().toLowerCase(); + if (!normalized) { + return false; + } + return !SILICONFLOW_NON_CHAT_MODEL_TOKENS.some(token => normalized.includes(token)); +} + +function createSiliconFlowModelManagerOptions( + providerId: "siliconflow" | "siliconflow-cn", + defaultBaseUrl: string, + config?: SiliconFlowModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? defaultBaseUrl; + return { + providerId, + dynamicModelsAuthoritative: true, + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: providerId, + baseUrl, + apiKey, + filterModel: (_entry, model) => isLikelySiliconFlowChatModelId(model.id), + fetch: config?.fetch, + }), + }), + }; +} + +export function siliconflowModelManagerOptions( + config?: SiliconFlowModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + return createSiliconFlowModelManagerOptions("siliconflow", "https://api.siliconflow.com/v1", config); +} + +export function siliconflowCnModelManagerOptions( + config?: SiliconFlowModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + return createSiliconFlowModelManagerOptions("siliconflow-cn", "https://api.siliconflow.cn/v1", config); +} + // --------------------------------------------------------------------------- // 6.7 Zhipu Coding Plan // --------------------------------------------------------------------------- diff --git a/packages/catalog/test/siliconflow-provider.test.ts b/packages/catalog/test/siliconflow-provider.test.ts new file mode 100644 index 000000000..46bedb094 --- /dev/null +++ b/packages/catalog/test/siliconflow-provider.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; +import type { GeneratedProvider } from "@oh-my-pi/pi-catalog/models"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { + MODELS_DEV_PROVIDER_DESCRIPTORS, + siliconflowCnModelManagerOptions, + siliconflowModelManagerOptions, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; + +function withEnv(key: string, value: string, run: () => void): void { + const previous = Bun.env[key]; + Bun.env[key] = value; + try { + run(); + } finally { + if (previous === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = previous; + } + } +} + +describe("siliconflow built-in providers", () => { + test("registers dynamic-authoritative runtime descriptors with env-key discovery", () => { + const intl = PROVIDER_DESCRIPTORS.find(item => item.providerId === "siliconflow"); + expect(intl).toBeDefined(); + expect(intl?.defaultModel).toBe("zai-org/GLM-5.1"); + expect(intl?.dynamicModelsAuthoritative).toBe(true); + expect(DEFAULT_MODEL_PER_PROVIDER.siliconflow).toBe("zai-org/GLM-5.1"); + + const cn = PROVIDER_DESCRIPTORS.find(item => item.providerId === "siliconflow-cn"); + expect(cn).toBeDefined(); + expect(cn?.defaultModel).toBe("deepseek-ai/DeepSeek-V4-Pro"); + expect(cn?.dynamicModelsAuthoritative).toBe(true); + expect(DEFAULT_MODEL_PER_PROVIDER["siliconflow-cn"]).toBe("deepseek-ai/DeepSeek-V4-Pro"); + }); + + test("ships no bundled catalog — the model list is discovered live", () => { + // Compile-time: neither provider id may appear in the bundled models.json. + type _SiliconflowNotBundled = + Extract<"siliconflow" | "siliconflow-cn", GeneratedProvider> extends never ? true : never; + const _check: _SiliconflowNotBundled = true; + expect(_check).toBe(true); + // Runtime: no models.dev mapping may feed the generator either. + expect(MODELS_DEV_PROVIDER_DESCRIPTORS.some(d => d.providerId === "siliconflow")).toBe(false); + expect(MODELS_DEV_PROVIDER_DESCRIPTORS.some(d => d.providerId === "siliconflow-cn")).toBe(false); + }); + + test("registers API-key login providers", () => { + const providers = getOAuthProviders(); + const intl = providers.find(item => item.id === "siliconflow"); + expect(intl?.name).toBe("SiliconFlow"); + expect(intl?.available).toBe(true); + const cn = providers.find(item => item.id === "siliconflow-cn"); + expect(cn?.name).toBe("SiliconFlow (China)"); + expect(cn?.available).toBe(true); + }); + + test("resolves SILICONFLOW_API_KEY / SILICONFLOW_CN_API_KEY via env", () => { + withEnv("SILICONFLOW_API_KEY", "siliconflow-test-key", () => { + expect(getEnvApiKey("siliconflow")).toBe("siliconflow-test-key"); + }); + withEnv("SILICONFLOW_CN_API_KEY", "siliconflow-cn-test-key", () => { + expect(getEnvApiKey("siliconflow-cn")).toBe("siliconflow-cn-test-key"); + }); + }); + + test("dynamic discovery drops non-chat models and keeps chat completions entries", async () => { + const seen: { url?: string; authorization?: string } = {}; + const stubFetch: FetchImpl = async (input, init) => { + seen.url = String(input); + const headers = new Headers(init?.headers); + seen.authorization = headers.get("Authorization") ?? undefined; + const payload = { + object: "list", + data: [ + { id: "Qwen/Qwen3.5-397B-A17B", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "zai-org/GLM-5.1", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "BAAI/bge-m3", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "BAAI/bge-reranker-v2-m3", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "Qwen/Qwen3-Embedding-8B", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "Qwen/Qwen-Image", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "stabilityai/stable-diffusion-3-5-large", object: "model", created: 0, owned_by: "sd" }, + { id: "Wan-AI/Wan2.2-T2V-A14B", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "TeleAI/TeleSpeechASR", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "FunAudioLLM/SenseVoiceSmall", object: "model", created: 0, owned_by: "siliconflow" }, + { id: "IndexTeam/IndexTTS-2", object: "model", created: 0, owned_by: "siliconflow" }, + ], + }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const options = siliconflowModelManagerOptions({ apiKey: "sk-test", fetch: stubFetch }); + expect(options.dynamicModelsAuthoritative).toBe(true); + const models = await options.fetchDynamicModels?.(); + expect(models).not.toBeNull(); + const ids = (models ?? []).map(model => model.id); + expect(ids).toEqual(["Qwen/Qwen3.5-397B-A17B", "zai-org/GLM-5.1"]); + const [first] = models ?? []; + expect(first?.provider).toBe("siliconflow"); + expect(first?.api).toBe("openai-completions"); + expect(first?.baseUrl).toBe("https://api.siliconflow.com/v1"); + expect(seen.url).toBe("https://api.siliconflow.com/v1/models"); + expect(seen.authorization).toBe("Bearer sk-test"); + }); + + test("cn variant discovers against the China endpoint", async () => { + const seen: { url?: string } = {}; + const stubFetch: FetchImpl = async input => { + seen.url = String(input); + return new Response(JSON.stringify({ object: "list", data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const options = siliconflowCnModelManagerOptions({ apiKey: "sk-test", fetch: stubFetch }); + const models = await options.fetchDynamicModels?.(); + expect(models).toEqual([]); + expect(seen.url).toBe("https://api.siliconflow.cn/v1/models"); + }); +});