mirror of
https://github.com/yunionio/cloudpods.git
synced 2026-08-29 03:51:54 +08:00
feat(aiproxy): add Z.AI / Zhipu provider with dual OpenAI and Anthropic API modes (#25131)
Register zhipu as a public OpenAI-compat provider with GLM catalog seeds, Anthropic passthrough routing, and v4-style base URL path handling.
This commit is contained in:
@@ -51,6 +51,7 @@ climc ai-test-chat
|
||||
| `DASHSCOPE_API_KEY` | 通义千问(`provider=aliyun`) |
|
||||
| `MIMO_API_KEY` | 小米 MiMo(`provider=xiaomi`) |
|
||||
| `MOONSHOT_API_KEY` | Moonshot / Kimi(`provider=moonshot`) |
|
||||
| `ZHIPU_API_KEY` / `ZAI_API_KEY` | Z.AI / 智谱(`provider=zhipu`) |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic 直通 |
|
||||
| `DEEPSEEK_API_KEY` | DeepSeek(Anthropic 兼容场景) |
|
||||
| `AIPROXY_TEST_SKIP_STREAM` | `1` 跳过流式;`0` 强制流式 |
|
||||
@@ -112,6 +113,48 @@ climc ai-test-chat --provider moonshot --model kimi-k2.6 --api-key "$MOONSHOT_AP
|
||||
|
||||
其它 catalog 模型:`kimi-k2.7-code`、`kimi-k2.5`、`moonshot-v1-8k` 等(id 形如 `moonshot-kimi-k2.6`)。
|
||||
|
||||
### Z.AI / 智谱(zhipu)
|
||||
|
||||
控制台展示为 **Z.AI**,`provider_key` 固定为 **`zhipu`**。上游 OpenAI 兼容 base 为 `https://open.bigmodel.cn/api/paas/v4`;Anthropic 兼容 base 为 `https://open.bigmodel.cn/api/anthropic`。支持 `config.api_mode=openai|anthropic`(与 DeepSeek 类似的双 API 模式)。
|
||||
|
||||
**OpenAI 兼容(默认 `api_mode=openai`)**:
|
||||
|
||||
```bash
|
||||
export ZHIPU_API_KEY='你的智谱 API Key'
|
||||
climc ai-test-chat --provider zhipu --model glm-5.2 --api-key "$ZHIPU_API_KEY"
|
||||
```
|
||||
|
||||
**Anthropic 兼容(`config.api_mode=anthropic`)**:aiproxy 将 Anthropic SDK 请求直通 `https://open.bigmodel.cn/api/anthropic/v1/messages`(`base_url` 可仍填 OpenAI 默认,由 aiproxy 自动切换)。
|
||||
|
||||
```bash
|
||||
climc ai-test-anthropic --provider zhipu --model glm-5.2 \
|
||||
--api-key "$ZHIPU_API_KEY" --upstream-base-url https://open.bigmodel.cn/api/anthropic
|
||||
```
|
||||
|
||||
创建 provider 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"generate_name": "my-zhipu",
|
||||
"provider_key": "zhipu",
|
||||
"secret": "<zhipu-api-key>",
|
||||
"config": {
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"api_mode": "openai"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其它 catalog 模型:`glm-5.1`、`glm-5-turbo`、`glm-4.7`、`glm-4.7-flash` 等(id 形如 `zhipu-glm-5.2`)。
|
||||
|
||||
非交互:
|
||||
|
||||
```bash
|
||||
export AIPROXY_TEST_PROVIDER=zhipu AIPROXY_TEST_MODEL=glm-5.2
|
||||
export AIPROXY_TEST_API_KEY="$ZHIPU_API_KEY"
|
||||
climc ai-test-chat
|
||||
```
|
||||
|
||||
### Anthropic Messages API
|
||||
|
||||
数据面 **`POST /ai/anthropic/v1/messages`**,认证为 `Authorization: Bearer <virtual_key>`(**不是**上游 Anthropic/DeepSeek API Key)。
|
||||
|
||||
@@ -41,6 +41,8 @@ func DefaultModelForProvider(providerKey string) string {
|
||||
return "kimi-k2.6"
|
||||
case api.ProviderKeyDeepseek:
|
||||
return "deepseek-v4-flash"
|
||||
case api.ProviderKeyZhipu:
|
||||
return "glm-5.2"
|
||||
case api.ProviderKeyOpenAI:
|
||||
return "gpt-4o-mini"
|
||||
case api.ProviderKeyAnthropic:
|
||||
@@ -58,6 +60,8 @@ func DefaultPromptForProvider(providerKey string) string {
|
||||
return "用一句话介绍小米 MiMo"
|
||||
case api.ProviderKeyMoonshot:
|
||||
return "用一句话介绍 Kimi"
|
||||
case api.ProviderKeyZhipu:
|
||||
return "用一句话介绍 Z.AI GLM"
|
||||
default:
|
||||
return "用一句话介绍这个模型"
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ func resolveApiKeyFromEnv(providerKey string) string {
|
||||
return os.Getenv("MIMO_API_KEY")
|
||||
case "moonshot":
|
||||
return os.Getenv("MOONSHOT_API_KEY")
|
||||
case "zhipu":
|
||||
if v := os.Getenv("ZHIPU_API_KEY"); v != "" {
|
||||
return v
|
||||
}
|
||||
return os.Getenv("ZAI_API_KEY")
|
||||
case "anthropic":
|
||||
return os.Getenv("ANTHROPIC_API_KEY")
|
||||
case "openai":
|
||||
|
||||
@@ -254,6 +254,8 @@ func catalogSeedModelsForProvider(providerKey string) []catalogSeedModel {
|
||||
return xiaomiMimoSeedModels()
|
||||
case api.ProviderKeyMoonshot:
|
||||
return moonshotKimiSeedModels()
|
||||
case api.ProviderKeyZhipu:
|
||||
return zhipuGLMSeedModels()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -358,3 +360,15 @@ func moonshotKimiSeedModels() []catalogSeedModel {
|
||||
{ModelKey: "moonshot-v1-128k-vision-preview", Description: "Moonshot v1 128K Vision"},
|
||||
}
|
||||
}
|
||||
|
||||
func zhipuGLMSeedModels() []catalogSeedModel {
|
||||
return []catalogSeedModel{
|
||||
{ModelKey: "glm-5.2", Description: "Z.AI GLM-5.2 flagship; 1M context; long-horizon agents"},
|
||||
{ModelKey: "glm-5.1", Description: "Z.AI GLM-5.1; long-horizon tasks"},
|
||||
{ModelKey: "glm-5-turbo", Description: "Z.AI GLM-5-Turbo; cost-efficient"},
|
||||
{ModelKey: "glm-4.7", Description: "Z.AI GLM-4.7; general chat and coding"},
|
||||
{ModelKey: "glm-4.7-flash", Description: "Z.AI GLM-4.7-Flash; free tier"},
|
||||
{ModelKey: "glm-4.6", Description: "Z.AI GLM-4.6; 200K context"},
|
||||
{ModelKey: "glm-5v-turbo", Description: "Z.AI GLM-5V-Turbo; vision"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ func TestCatalogSeedModelsForPublicProviders(t *testing.T) {
|
||||
api.ProviderKeyGemini,
|
||||
api.ProviderKeyMoonshot,
|
||||
api.ProviderKeyXiaomi,
|
||||
api.ProviderKeyZhipu,
|
||||
}
|
||||
for _, pk := range publicKeys {
|
||||
if !api.HasDefaultPublicBaseURL(pk) {
|
||||
|
||||
@@ -123,6 +123,32 @@ func TestGetAdapterCustomAnthropicPassthrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdapterZhipuAnthropicPassthrough(t *testing.T) {
|
||||
adapter, err := GetAdapter("zhipu", "anthropic")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !adapter.AnthropicStreamPassthrough() {
|
||||
t.Fatal("expected passthrough stream for zhipu anthropic mode")
|
||||
}
|
||||
body := jsonutils.NewDict()
|
||||
body.Set("model", jsonutils.NewString("glm-5.2"))
|
||||
body.Set("max_tokens", jsonutils.NewInt(100))
|
||||
user := jsonutils.NewDict()
|
||||
user.Set("role", jsonutils.NewString("user"))
|
||||
user.Set("content", jsonutils.NewString("hi"))
|
||||
body.Set("messages", jsonutils.NewArray(user))
|
||||
|
||||
req, err := adapter.BuildUpstreamRequest(testChatCtx("zhipu", "https://open.bigmodel.cn/api/anthropic", "zhipu-key", "glm-5.2"), body, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://open.bigmodel.cn/api/anthropic/v1/messages"
|
||||
if req.URL != want {
|
||||
t.Fatalf("url: %s, want %s", req.URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdapterBlocksGemini(t *testing.T) {
|
||||
if _, err := GetAdapter("gemini", ""); err == nil {
|
||||
t.Fatal("expected gemini to be unsupported")
|
||||
|
||||
@@ -137,10 +137,7 @@ func ChatCompletionsURL(baseURL string) string {
|
||||
if strings.HasSuffix(base, "/chat/completions") {
|
||||
return base
|
||||
}
|
||||
if strings.HasSuffix(base, "/v2") {
|
||||
return base + "/chat/completions"
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
if hasAPIVersionPathSuffix(base) {
|
||||
return base + "/chat/completions"
|
||||
}
|
||||
return base + "/v1/chat/completions"
|
||||
@@ -152,10 +149,7 @@ func CompletionsURL(baseURL string) string {
|
||||
if strings.HasSuffix(base, "/completions") {
|
||||
return base
|
||||
}
|
||||
if strings.HasSuffix(base, "/v2") {
|
||||
return base + "/completions"
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
if hasAPIVersionPathSuffix(base) {
|
||||
return base + "/completions"
|
||||
}
|
||||
return base + "/v1/completions"
|
||||
@@ -167,10 +161,7 @@ func EmbeddingsURL(baseURL string) string {
|
||||
if strings.HasSuffix(base, "/embeddings") {
|
||||
return base
|
||||
}
|
||||
if strings.HasSuffix(base, "/v2") {
|
||||
return base + "/embeddings"
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
if hasAPIVersionPathSuffix(base) {
|
||||
return base + "/embeddings"
|
||||
}
|
||||
return base + "/v1/embeddings"
|
||||
@@ -182,15 +173,21 @@ func ImagesGenerationsURL(baseURL string) string {
|
||||
if strings.HasSuffix(base, "/images/generations") {
|
||||
return base
|
||||
}
|
||||
if strings.HasSuffix(base, "/v2") {
|
||||
return base + "/images/generations"
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
if hasAPIVersionPathSuffix(base) {
|
||||
return base + "/images/generations"
|
||||
}
|
||||
return base + "/v1/images/generations"
|
||||
}
|
||||
|
||||
func hasAPIVersionPathSuffix(base string) bool {
|
||||
idx := strings.LastIndex(base, "/")
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
seg := base[idx+1:]
|
||||
return len(seg) >= 2 && seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9'
|
||||
}
|
||||
|
||||
// BearerAuthHeaders returns standard OpenAI bearer auth headers.
|
||||
func BearerAuthHeaders(apiKey string) map[string]string {
|
||||
return map[string]string{
|
||||
|
||||
@@ -23,3 +23,11 @@ func TestChatCompletionsURLMoonshot(t *testing.T) {
|
||||
t.Fatalf("ChatCompletionsURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsURLZhipu(t *testing.T) {
|
||||
got := ChatCompletionsURL("https://open.bigmodel.cn/api/paas/v4")
|
||||
want := "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
if got != want {
|
||||
t.Fatalf("ChatCompletionsURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,10 @@ func (e *Error) Error() string {
|
||||
// BaseURL is the origin + optional path prefix (e.g. https://dashscope.aliyuncs.com/compatible-mode).
|
||||
func ChatCompletionsURL(baseURL string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
if strings.HasSuffix(base, "/chat/completions") {
|
||||
return base
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") || hasAPIVersionPathSuffix(base) {
|
||||
return base + "/chat/completions"
|
||||
}
|
||||
return base + "/v1/chat/completions"
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestModelsURL(t *testing.T) {
|
||||
{"https://api.openai.com/v1", "https://api.openai.com/v1/models"},
|
||||
{"https://generativelanguage.googleapis.com/v1beta", "https://generativelanguage.googleapis.com/v1beta/models"},
|
||||
{"https://api.deepseek.com/anthropic", "https://api.deepseek.com/anthropic/v1/models"},
|
||||
{"https://open.bigmodel.cn/api/paas/v4", "https://open.bigmodel.cn/api/paas/v4/models"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ModelsURL(tc.base); got != tc.want {
|
||||
|
||||
@@ -83,14 +83,26 @@ func (c *SAiProviderConfig) EffectiveBaseURL(providerKey string) string {
|
||||
return base
|
||||
}
|
||||
pk := strings.ToLower(strings.TrimSpace(providerKey))
|
||||
if pk != ProviderKeyDeepseek {
|
||||
switch pk {
|
||||
case ProviderKeyDeepseek:
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.HasSuffix(strings.ToLower(base), "/anthropic") {
|
||||
return base
|
||||
}
|
||||
return base + "/anthropic"
|
||||
case ProviderKeyZhipu:
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.HasSuffix(strings.ToLower(base), "/api/anthropic") {
|
||||
return base
|
||||
}
|
||||
openaiDefault := strings.TrimRight(DefaultPublicBaseURL(ProviderKeyZhipu), "/")
|
||||
if base == "" || strings.EqualFold(base, openaiDefault) {
|
||||
return DefaultZhipuAnthropicBaseURL()
|
||||
}
|
||||
return base
|
||||
default:
|
||||
return base
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.HasSuffix(strings.ToLower(base), "/anthropic") {
|
||||
return base
|
||||
}
|
||||
return base + "/anthropic"
|
||||
}
|
||||
|
||||
// String implements gotypes.ISerializable for sqlchemy JSON/compound columns.
|
||||
|
||||
@@ -40,6 +40,9 @@ func TestSupportsDualAPIMode(t *testing.T) {
|
||||
if !SupportsDualAPIMode(ProviderKeyCustom) {
|
||||
t.Fatal("custom should support dual api mode")
|
||||
}
|
||||
if !SupportsDualAPIMode(ProviderKeyZhipu) {
|
||||
t.Fatal("zhipu should support dual api mode")
|
||||
}
|
||||
if SupportsDualAPIMode(ProviderKeyOpenAI) {
|
||||
t.Fatal("openai should not support dual api mode")
|
||||
}
|
||||
@@ -100,4 +103,40 @@ func TestHasDefaultPublicBaseURL(t *testing.T) {
|
||||
if DefaultPublicBaseURL(ProviderKeyMoonshot) != "https://api.moonshot.cn" {
|
||||
t.Fatalf("moonshot default base = %q", DefaultPublicBaseURL(ProviderKeyMoonshot))
|
||||
}
|
||||
if !HasDefaultPublicBaseURL(ProviderKeyZhipu) {
|
||||
t.Fatal("zhipu should have default base url")
|
||||
}
|
||||
if DefaultPublicBaseURL(ProviderKeyZhipu) != "https://open.bigmodel.cn/api/paas/v4" {
|
||||
t.Fatalf("zhipu default base = %q", DefaultPublicBaseURL(ProviderKeyZhipu))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveBaseURLZhipuAnthropic(t *testing.T) {
|
||||
cfg := &SAiProviderConfig{
|
||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
APIMode: ProviderAPIModeAnthropic,
|
||||
}
|
||||
got := cfg.EffectiveBaseURL(ProviderKeyZhipu)
|
||||
want := DefaultZhipuAnthropicBaseURL()
|
||||
if got != want {
|
||||
t.Fatalf("EffectiveBaseURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveBaseURLZhipuAnthropicFallback(t *testing.T) {
|
||||
cfg := &SAiProviderConfig{APIMode: ProviderAPIModeAnthropic}
|
||||
got := cfg.EffectiveBaseURL(ProviderKeyZhipu)
|
||||
want := DefaultZhipuAnthropicBaseURL()
|
||||
if got != want {
|
||||
t.Fatalf("EffectiveBaseURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveBaseURLZhipuOpenAI(t *testing.T) {
|
||||
cfg := &SAiProviderConfig{}
|
||||
got := cfg.EffectiveBaseURL(ProviderKeyZhipu)
|
||||
want := "https://open.bigmodel.cn/api/paas/v4"
|
||||
if got != want {
|
||||
t.Fatalf("EffectiveBaseURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,18 @@ func DefaultPublicBaseURL(providerKey string) string {
|
||||
return "https://api.xiaomimimo.com"
|
||||
case ProviderKeyMoonshot:
|
||||
return "https://api.moonshot.cn"
|
||||
case ProviderKeyZhipu:
|
||||
return "https://open.bigmodel.cn/api/paas/v4"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultZhipuAnthropicBaseURL is the upstream base for Zhipu Claude-compatible API.
|
||||
func DefaultZhipuAnthropicBaseURL() string {
|
||||
return "https://open.bigmodel.cn/api/anthropic"
|
||||
}
|
||||
|
||||
// HasDefaultPublicBaseURL reports whether provider_key has a built-in public base URL.
|
||||
func HasDefaultPublicBaseURL(providerKey string) bool {
|
||||
return DefaultPublicBaseURL(providerKey) != ""
|
||||
|
||||
@@ -47,6 +47,7 @@ const (
|
||||
ProviderKeyVLLM = "vllm"
|
||||
ProviderKeyXai = "xai"
|
||||
ProviderKeyXiaomi = "xiaomi"
|
||||
ProviderKeyZhipu = "zhipu"
|
||||
)
|
||||
|
||||
// OpenAICompatProviderKeys are catalog keys routed through openai.NewCompat.
|
||||
@@ -67,6 +68,7 @@ var OpenAICompatProviderKeys = []string{
|
||||
ProviderKeyOllama,
|
||||
ProviderKeyXiaomi,
|
||||
ProviderKeyMoonshot,
|
||||
ProviderKeyZhipu,
|
||||
}
|
||||
|
||||
var nativeMessagesAdapterProviderKeys = map[string]struct{}{
|
||||
@@ -105,6 +107,7 @@ const (
|
||||
var DualAPIProviderKeys = map[string]struct{}{
|
||||
ProviderKeyCustom: {},
|
||||
ProviderKeyDeepseek: {},
|
||||
ProviderKeyZhipu: {},
|
||||
}
|
||||
|
||||
// IsCustomProviderKey reports whether provider_key is the user-defined custom gateway type.
|
||||
|
||||
Reference in New Issue
Block a user