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:
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user