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.
This commit is contained in:
iacore
2026-07-27 13:06:50 +08:00
parent d1239fd3e5
commit dab5934548
10 changed files with 287 additions and 0 deletions
+2
View File
@@ -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` |
+2
View File
@@ -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` |
+3
View File
@@ -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:<port>/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
+4
View File
@@ -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,
@@ -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;
+22
View File
@@ -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;
+3
View File
@@ -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
@@ -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",
@@ -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
// ---------------------------------------------------------------------------
@@ -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");
});
});