feat: expand AI provider support and update descriptions

- Updated AIProviderBase and AIProviderUpdate to support new API formats: 'anthropic' and 'ollama'.
- Added SVG icons for Anthropic, Azure, Ollama, and Z.ai providers.
- Updated AI provider payload interface to include new formats.
- Enhanced English and Chinese localization for new providers and updated descriptions for OpenAI and Anthropic.
- Added new provider templates for Azure OpenAI, Anthropic, Z.ai, and Ollama in the settings tab.
- Updated the API format selection in the settings tab to include new options.
This commit is contained in:
shiyu
2026-01-11 22:29:22 +08:00
parent e7cf8dbdb8
commit 87770176b6
10 changed files with 836 additions and 52 deletions

View File

@@ -2,6 +2,7 @@ import json
import httpx
from typing import Any, AsyncIterator, Dict, List, Sequence, Tuple
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from models.database import AIModel, AIProvider
from .service import AIProviderService
@@ -20,9 +21,16 @@ async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
"""
try:
model, provider = await _require_model("vision")
if provider.api_format == "openai":
fmt = str(provider.api_format or "").lower()
if fmt == "openai":
return await _describe_with_openai(provider, model, base64_image, detail)
return await _describe_with_gemini(provider, model, base64_image, detail)
if fmt == "gemini":
return await _describe_with_gemini(provider, model, base64_image, detail)
if fmt == "anthropic":
return await _describe_with_anthropic(provider, model, base64_image, detail)
if fmt == "ollama":
return await _describe_with_ollama(provider, model, base64_image)
raise MissingModelError(f"不支持的视觉模型接口类型: {provider.api_format}")
except MissingModelError as exc:
return str(exc)
except httpx.ReadTimeout:
@@ -36,9 +44,14 @@ async def get_text_embedding(text: str) -> List[float]:
传入文本,返回嵌入向量。若未配置模型则抛出异常。
"""
model, provider = await _require_model("embedding")
if provider.api_format == "openai":
fmt = str(provider.api_format or "").lower()
if fmt == "openai":
return await _embedding_with_openai(provider, model, text)
return await _embedding_with_gemini(provider, model, text)
if fmt == "gemini":
return await _embedding_with_gemini(provider, model, text)
if fmt == "ollama":
return await _embedding_with_ollama(provider, model, text)
raise MissingModelError(f"不支持的嵌入模型接口类型: {provider.api_format}")
async def rerank_texts(query: str, documents: Sequence[str]) -> List[float]:
@@ -51,9 +64,12 @@ async def rerank_texts(query: str, documents: Sequence[str]) -> List[float]:
return []
try:
if provider.api_format == "openai":
fmt = str(provider.api_format or "").lower()
if fmt == "openai":
return await _rerank_with_openai(provider, model, query, documents)
return await _rerank_with_gemini(provider, model, query, documents)
if fmt == "gemini":
return await _rerank_with_gemini(provider, model, query, documents)
return []
except Exception: # noqa: BLE001
return []
@@ -74,19 +90,47 @@ async def _require_model(ability: str) -> Tuple[AIModel, AIProvider]:
def _openai_endpoint(provider: AIProvider, path: str) -> str:
base = (provider.base_url or "").rstrip("/")
if not base:
raw_base = str(provider.base_url or "").strip()
if not raw_base:
raise MissingModelError("提供商 API 地址未配置。")
return f"{base}/{path.lstrip('/')}"
base = urlsplit(raw_base)
extra_path, _, extra_query = str(path or "").partition("?")
base_path = base.path.rstrip("/")
extra_path = "/" + extra_path.lstrip("/")
merged_path = (base_path + extra_path) if base_path else extra_path
query_pairs = list(parse_qsl(base.query, keep_blank_values=True))
if extra_query:
query_pairs.extend(parse_qsl(extra_query, keep_blank_values=True))
query_map = {k: v for k, v in query_pairs if k}
if _is_azure_openai(provider) and "api-version" not in query_map:
query_map["api-version"] = "2024-02-15-preview"
merged_query = urlencode(query_map, doseq=True)
return urlunsplit((base.scheme, base.netloc, merged_path, merged_query, base.fragment))
def _openai_headers(provider: AIProvider) -> dict:
headers = {"Content-Type": "application/json"}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
if _is_azure_openai(provider):
headers["api-key"] = provider.api_key
else:
headers["Authorization"] = f"Bearer {provider.api_key}"
return headers
def _is_azure_openai(provider: AIProvider) -> bool:
identifier = str(provider.identifier or "").lower()
if identifier == "azure-openai":
return True
base_url = str(provider.base_url or "").lower()
return ".openai.azure.com" in base_url
def _gemini_endpoint(provider: AIProvider, path: str) -> str:
base = (provider.base_url or "").rstrip("/")
if not base:
@@ -98,6 +142,575 @@ def _gemini_endpoint(provider: AIProvider, path: str) -> str:
return url
ANTHROPIC_VERSION = "2023-06-01"
ANTHROPIC_DEFAULT_MAX_TOKENS = 1024
def _anthropic_endpoint(provider: AIProvider, path: str) -> str:
raw_base = str(provider.base_url or "").strip()
if not raw_base:
raise MissingModelError("提供商 API 地址未配置。")
base = urlsplit(raw_base)
extra_path, _, extra_query = str(path or "").partition("?")
base_path = base.path.rstrip("/")
extra_path = "/" + extra_path.lstrip("/")
merged_path = (base_path + extra_path) if base_path else extra_path
query_pairs = list(parse_qsl(base.query, keep_blank_values=True))
if extra_query:
query_pairs.extend(parse_qsl(extra_query, keep_blank_values=True))
merged_query = urlencode(query_pairs, doseq=True)
return urlunsplit((base.scheme, base.netloc, merged_path, merged_query, base.fragment))
def _anthropic_headers(provider: AIProvider) -> dict:
headers = {
"Content-Type": "application/json",
"anthropic-version": ANTHROPIC_VERSION,
}
if provider.api_key:
headers["x-api-key"] = provider.api_key
extra = provider.extra_config if isinstance(provider.extra_config, dict) else {}
version = extra.get("anthropic_version") or extra.get("anthropic-version")
if isinstance(version, str) and version.strip():
headers["anthropic-version"] = version.strip()
beta = extra.get("anthropic_beta") or extra.get("anthropic-beta")
if isinstance(beta, str) and beta.strip():
headers["anthropic-beta"] = beta.strip()
return headers
def _openai_content_to_anthropic_blocks(content: Any) -> List[Dict[str, Any]]:
if content is None:
return []
if isinstance(content, str):
text = content.strip("\n")
return [{"type": "text", "text": text}] if text else []
if isinstance(content, list):
blocks: List[Dict[str, Any]] = []
for part in content:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type == "text" and isinstance(part.get("text"), str):
text = part["text"].strip("\n")
if text:
blocks.append({"type": "text", "text": text})
continue
if part_type == "image_url" and isinstance(part.get("image_url"), dict):
url = part["image_url"].get("url")
if not isinstance(url, str) or not url.startswith("data:") or ";base64," not in url:
continue
header, data = url.split(",", 1)
media_type = header[5:].split(";", 1)[0] or "image/jpeg"
data = data.strip()
if not data:
continue
blocks.append({
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data},
})
return blocks
return [{"type": "text", "text": str(content)}]
def _openai_tools_to_anthropic(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for tool in tools:
if not isinstance(tool, dict):
continue
if tool.get("type") != "function":
continue
fn = tool.get("function")
if not isinstance(fn, dict):
continue
name = fn.get("name")
if not isinstance(name, str) or not name.strip():
continue
description = fn.get("description")
input_schema = fn.get("parameters")
input_schema = input_schema if isinstance(input_schema, dict) else {}
out.append({
"name": name,
"description": description if isinstance(description, str) else "",
"input_schema": input_schema,
})
return out
def _openai_messages_to_anthropic(messages: List[Dict[str, Any]]) -> Tuple[str, List[Dict[str, Any]]]:
system_parts: List[str] = []
prepared: List[Dict[str, Any]] = []
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "system":
content = msg.get("content")
if isinstance(content, str) and content.strip():
system_parts.append(content.strip())
continue
if role == "tool":
tool_use_id = msg.get("tool_call_id")
content = msg.get("content")
tool_use_id = str(tool_use_id or "").strip()
content_str = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
if not tool_use_id:
prepared.append({"role": "user", "content": [{"type": "text", "text": content_str}]})
else:
prepared.append({
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": content_str}],
})
continue
if role not in {"user", "assistant"}:
continue
blocks = _openai_content_to_anthropic_blocks(msg.get("content"))
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for call in tool_calls:
if not isinstance(call, dict):
continue
call_id = call.get("id")
fn = call.get("function")
fn = fn if isinstance(fn, dict) else {}
name = fn.get("name")
args = fn.get("arguments")
if not isinstance(name, str) or not name.strip():
continue
args_obj: Dict[str, Any] = {}
if isinstance(args, str) and args.strip():
try:
parsed = json.loads(args)
if isinstance(parsed, dict):
args_obj = parsed
except json.JSONDecodeError:
args_obj = {}
blocks.append({
"type": "tool_use",
"id": str(call_id or ""),
"name": name,
"input": args_obj,
})
if not blocks:
blocks = [{"type": "text", "text": ""}]
prepared.append({"role": role, "content": blocks})
merged: List[Dict[str, Any]] = []
for item in prepared:
if merged and merged[-1].get("role") == item.get("role"):
merged[-1]["content"].extend(item.get("content") or [])
continue
merged.append(item)
if merged and merged[0].get("role") != "user":
merged.insert(0, {"role": "user", "content": [{"type": "text", "text": ""}]})
system = "\n\n".join(system_parts).strip()
return system, merged
def _anthropic_body_to_openai_message(body: Dict[str, Any]) -> Dict[str, Any]:
content_blocks = body.get("content") or []
text_parts: List[str] = []
tool_calls: List[Dict[str, Any]] = []
if isinstance(content_blocks, list):
for block in content_blocks:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type == "text" and isinstance(block.get("text"), str):
text_parts.append(block["text"])
continue
if block_type == "tool_use":
call_id = block.get("id")
name = block.get("name")
tool_input = block.get("input")
tool_input = tool_input if isinstance(tool_input, dict) else {}
if not isinstance(name, str) or not name.strip():
continue
tool_calls.append({
"id": str(call_id or ""),
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(tool_input, ensure_ascii=False),
},
})
message: Dict[str, Any] = {"role": "assistant", "content": "".join(text_parts)}
if tool_calls:
message["tool_calls"] = tool_calls
return message
async def _chat_with_anthropic(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
temperature: float | None,
timeout: float,
) -> Dict[str, Any]:
url = _anthropic_endpoint(provider, "/messages")
system, anthropic_messages = _openai_messages_to_anthropic(messages)
payload: Dict[str, Any] = {
"model": model.name,
"max_tokens": ANTHROPIC_DEFAULT_MAX_TOKENS,
"messages": anthropic_messages,
}
if system:
payload["system"] = system
if tools:
payload["tools"] = _openai_tools_to_anthropic(tools)
if temperature is not None:
payload["temperature"] = float(temperature)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, headers=_anthropic_headers(provider), json=payload)
response.raise_for_status()
body = response.json()
return _anthropic_body_to_openai_message(body if isinstance(body, dict) else {})
async def _chat_stream_with_anthropic(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
temperature: float | None,
timeout: float,
) -> AsyncIterator[Dict[str, Any]]:
url = _anthropic_endpoint(provider, "/messages")
system, anthropic_messages = _openai_messages_to_anthropic(messages)
payload: Dict[str, Any] = {
"model": model.name,
"max_tokens": ANTHROPIC_DEFAULT_MAX_TOKENS,
"messages": anthropic_messages,
"stream": True,
}
if system:
payload["system"] = system
if tools:
payload["tools"] = _openai_tools_to_anthropic(tools)
if temperature is not None:
payload["temperature"] = float(temperature)
content_parts: List[str] = []
tool_call_map: Dict[int, Dict[str, Any]] = {}
finish_reason: str | None = None
current_event: str | None = None
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, headers=_anthropic_headers(provider), json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
if line.startswith("event:"):
current_event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data:
continue
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if not isinstance(chunk, dict):
continue
if current_event == "content_block_start":
idx = chunk.get("index")
block = chunk.get("content_block")
if not isinstance(idx, int) or not isinstance(block, dict):
continue
if block.get("type") != "tool_use":
continue
tool_call_map[idx] = {
"id": str(block.get("id") or ""),
"name": str(block.get("name") or ""),
"input": block.get("input") if isinstance(block.get("input"), dict) else None,
"arguments": "",
}
continue
if current_event == "content_block_delta":
idx = chunk.get("index")
delta = chunk.get("delta")
if not isinstance(idx, int) or not isinstance(delta, dict):
continue
delta_type = delta.get("type")
if delta_type == "text_delta":
text = delta.get("text")
if isinstance(text, str) and text:
content_parts.append(text)
yield {"type": "delta", "delta": text}
continue
if delta_type == "input_json_delta":
partial = delta.get("partial_json")
if not isinstance(partial, str) or not partial:
continue
entry = tool_call_map.setdefault(
idx,
{"id": "", "name": "", "input": None, "arguments": ""},
)
entry["arguments"] += partial
continue
if current_event == "message_delta":
delta = chunk.get("delta")
if isinstance(delta, dict) and isinstance(delta.get("stop_reason"), str):
finish_reason = delta["stop_reason"]
continue
if current_event == "message_stop":
break
content = "".join(content_parts)
message: Dict[str, Any] = {"role": "assistant", "content": content}
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(tool_call_map.keys()):
item = tool_call_map[idx]
name = str(item.get("name") or "").strip()
if not name:
continue
args_text = str(item.get("arguments") or "")
if not args_text:
args_obj = item.get("input")
args_obj = args_obj if isinstance(args_obj, dict) else {}
args_text = json.dumps(args_obj, ensure_ascii=False)
tool_calls.append({
"id": str(item.get("id") or f"call_{idx}"),
"type": "function",
"function": {"name": name, "arguments": args_text},
})
if tool_calls:
message["tool_calls"] = tool_calls
yield {"type": "message", "message": message, "finish_reason": finish_reason}
def _ollama_endpoint(provider: AIProvider, path: str) -> str:
raw_base = str(provider.base_url or "").strip()
if not raw_base:
raise MissingModelError("提供商 API 地址未配置。")
base = urlsplit(raw_base)
extra_path, _, extra_query = str(path or "").partition("?")
base_path = base.path.rstrip("/")
extra_path = "/" + extra_path.lstrip("/")
merged_path = (base_path + extra_path) if base_path else extra_path
query_pairs = list(parse_qsl(base.query, keep_blank_values=True))
if extra_query:
query_pairs.extend(parse_qsl(extra_query, keep_blank_values=True))
merged_query = urlencode(query_pairs, doseq=True)
return urlunsplit((base.scheme, base.netloc, merged_path, merged_query, base.fragment))
def _openai_content_to_ollama_message(content: Any) -> Tuple[str, List[str]]:
if content is None:
return "", []
if isinstance(content, str):
return content, []
if isinstance(content, list):
text_parts: List[str] = []
images: List[str] = []
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "text" and isinstance(part.get("text"), str):
text_parts.append(part["text"])
continue
if part.get("type") == "image_url" and isinstance(part.get("image_url"), dict):
url = part["image_url"].get("url")
if not isinstance(url, str) or not url.startswith("data:") or ";base64," not in url:
continue
_, data = url.split(",", 1)
data = data.strip()
if data:
images.append(data)
return "\n".join([t for t in text_parts if t]), images
return str(content), []
def _openai_messages_to_ollama(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "tool":
content = msg.get("content")
call_id = str(msg.get("tool_call_id") or "").strip()
content_str = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
if call_id:
content_str = f"[tool:{call_id}] {content_str}"
out.append({"role": "user", "content": content_str})
continue
if role not in {"system", "user", "assistant"}:
continue
text, images = _openai_content_to_ollama_message(msg.get("content"))
item: Dict[str, Any] = {"role": role, "content": text}
if images:
item["images"] = images
out.append(item)
return out
def _ollama_message_to_openai(message: Dict[str, Any]) -> Dict[str, Any]:
role = message.get("role") if isinstance(message.get("role"), str) else "assistant"
content = message.get("content") if isinstance(message.get("content"), str) else ""
tool_calls: List[Dict[str, Any]] = []
raw_tool_calls = message.get("tool_calls")
if isinstance(raw_tool_calls, list):
for idx, call in enumerate(raw_tool_calls):
if not isinstance(call, dict):
continue
fn = call.get("function")
if not isinstance(fn, dict):
continue
name = fn.get("name")
if not isinstance(name, str) or not name.strip():
continue
raw_args = fn.get("arguments")
if isinstance(raw_args, dict):
args_text = json.dumps(raw_args, ensure_ascii=False)
elif isinstance(raw_args, str):
args_text = raw_args
else:
args_text = ""
tool_calls.append({
"id": str(call.get("id") or f"call_{idx}"),
"type": "function",
"function": {"name": name, "arguments": args_text},
})
out: Dict[str, Any] = {"role": role, "content": content}
if tool_calls:
out["tool_calls"] = tool_calls
return out
async def _chat_with_ollama(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
temperature: float | None,
timeout: float,
) -> Dict[str, Any]:
url = _ollama_endpoint(provider, "/api/chat")
ollama_messages = _openai_messages_to_ollama(messages)
payload: Dict[str, Any] = {
"model": model.name,
"messages": ollama_messages,
"stream": False,
}
if temperature is not None:
payload["options"] = {"temperature": float(temperature)}
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
body = response.json()
message = body.get("message") if isinstance(body, dict) else None
if not isinstance(message, dict):
raise RuntimeError("对话接口返回格式异常")
return _ollama_message_to_openai(message)
async def _chat_stream_with_ollama(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
temperature: float | None,
timeout: float,
) -> AsyncIterator[Dict[str, Any]]:
url = _ollama_endpoint(provider, "/api/chat")
ollama_messages = _openai_messages_to_ollama(messages)
payload: Dict[str, Any] = {
"model": model.name,
"messages": ollama_messages,
"stream": True,
}
if temperature is not None:
payload["options"] = {"temperature": float(temperature)}
content_parts: List[str] = []
last_message: Dict[str, Any] | None = None
finish_reason: str | None = None
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(chunk, dict):
continue
msg = chunk.get("message")
if isinstance(msg, dict):
last_message = msg
delta = msg.get("content")
if isinstance(delta, str) and delta:
content_parts.append(delta)
yield {"type": "delta", "delta": delta}
done = chunk.get("done")
if done is True:
finish_reason = str(chunk.get("done_reason") or "stop")
break
if not isinstance(last_message, dict):
last_message = {"role": "assistant", "content": "".join(content_parts)}
else:
last_message = dict(last_message)
if content_parts:
last_message["content"] = "".join(content_parts)
message = _ollama_message_to_openai(last_message)
yield {"type": "message", "message": message, "finish_reason": finish_reason}
async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
url = _openai_endpoint(provider, "/chat/completions")
payload = {
@@ -125,6 +738,57 @@ async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_ima
return body["choices"][0]["message"]["content"]
async def _describe_with_anthropic(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
url = _anthropic_endpoint(provider, "/messages")
detail_text = f"描述这个图片,细节等级:{detail}"
payload: Dict[str, Any] = {
"model": model.name,
"max_tokens": 512,
"messages": [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": base64_image,
},
},
{"type": "text", "text": detail_text},
],
}
],
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=_anthropic_headers(provider), json=payload)
response.raise_for_status()
body = response.json()
message = _anthropic_body_to_openai_message(body if isinstance(body, dict) else {})
return str(message.get("content") or "")
async def _describe_with_ollama(provider: AIProvider, model: AIModel, base64_image: str) -> str:
url = _ollama_endpoint(provider, "/api/chat")
payload = {
"model": model.name,
"messages": [
{"role": "user", "content": "描述这个图片", "images": [base64_image]},
],
"stream": False,
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
body = response.json()
message = body.get("message") if isinstance(body, dict) else None
if not isinstance(message, dict):
return ""
return str(message.get("content") or "")
async def _describe_with_gemini(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
detail_text = f"描述这个图片,细节等级:{detail}"
model_name = model.name if model.name.startswith("models/") else f"models/{model.name}"
@@ -187,6 +851,23 @@ async def _embedding_with_gemini(provider: AIProvider, model: AIModel, text: str
return embedding.get("values") or []
async def _embedding_with_ollama(provider: AIProvider, model: AIModel, text: str) -> List[float]:
url = _ollama_endpoint(provider, "/api/embeddings")
payload = {
"model": model.name,
"prompt": text,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
body = response.json()
embedding = body.get("embedding") if isinstance(body, dict) else None
if not isinstance(embedding, list):
return []
return [float(v) for v in embedding if isinstance(v, (int, float))]
async def _rerank_with_openai(
provider: AIProvider,
model: AIModel,
@@ -257,17 +938,36 @@ async def chat_completion(
timeout: float = 60.0,
) -> Dict[str, Any]:
model, provider = await _require_model(ability)
if provider.api_format != "openai":
raise MissingModelError("当前仅支持 OpenAI 兼容接口的对话模型。")
return await _chat_with_openai(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
)
fmt = str(provider.api_format or "").lower()
if fmt == "openai":
return await _chat_with_openai(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
)
if fmt == "anthropic":
return await _chat_with_anthropic(
provider,
model,
messages,
tools=tools,
temperature=temperature,
timeout=timeout,
)
if fmt == "ollama":
return await _chat_with_ollama(
provider,
model,
messages,
tools=tools,
temperature=temperature,
timeout=timeout,
)
raise MissingModelError(f"当前不支持该对话模型接口类型: {provider.api_format}")
async def _chat_with_openai(
@@ -315,18 +1015,42 @@ async def chat_completion_stream(
timeout: float = 60.0,
) -> AsyncIterator[Dict[str, Any]]:
model, provider = await _require_model(ability)
if provider.api_format != "openai":
raise MissingModelError("当前仅支持 OpenAI 兼容接口的对话模型。")
async for event in _chat_stream_with_openai(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
):
yield event
fmt = str(provider.api_format or "").lower()
if fmt == "openai":
async for event in _chat_stream_with_openai(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
):
yield event
return
if fmt == "anthropic":
async for event in _chat_stream_with_anthropic(
provider,
model,
messages,
tools=tools,
temperature=temperature,
timeout=timeout,
):
yield event
return
if fmt == "ollama":
async for event in _chat_stream_with_ollama(
provider,
model,
messages,
tools=tools,
temperature=temperature,
timeout=timeout,
):
yield event
return
raise MissingModelError(f"当前不支持该对话模型接口类型: {provider.api_format}")
async def _chat_stream_with_openai(

View File

@@ -30,8 +30,8 @@ class AIProviderBase(BaseModel):
@classmethod
def normalize_format(cls, value: str) -> str:
fmt = value.lower()
if fmt not in {"openai", "gemini"}:
raise ValueError("api_format must be 'openai' or 'gemini'")
if fmt not in {"openai", "gemini", "anthropic", "ollama"}:
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
return fmt
@@ -54,8 +54,8 @@ class AIProviderUpdate(BaseModel):
if value is None:
return value
fmt = value.lower()
if fmt not in {"openai", "gemini"}:
raise ValueError("api_format must be 'openai' or 'gemini'")
if fmt not in {"openai", "gemini", "anthropic", "ollama"}:
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
return fmt

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

1
web/public/icon/zai.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -6,7 +6,7 @@ export interface AIProviderPayload {
name: string;
identifier: string;
provider_type?: string | null;
api_format: 'openai' | 'gemini';
api_format: 'openai' | 'gemini' | 'anthropic' | 'ollama';
base_url?: string | null;
api_key?: string | null;
logo_url?: string | null;

View File

@@ -415,7 +415,7 @@
"Custom Provider": "Custom Provider",
"Custom Provider Description": "Bring your own endpoint compatible with OpenAI or Gemini formats.",
"OpenAI Provider": "OpenAI",
"OpenAI Provider Description": "Access GPT-4o, GPT-4.1, GPT-3.5 and more models from OpenAI.",
"OpenAI Provider Description": "Access GPT-4o, GPT-4.1, GPT-5 and more models from OpenAI.",
"Azure OpenAI Provider": "Azure OpenAI",
"Azure OpenAI Provider Description": "Use OpenAI models deployed on Microsoft Azure.",
"Google AI Provider": "Google AI",
@@ -425,13 +425,15 @@
"OpenRouter Provider": "OpenRouter",
"OpenRouter Provider Description": "Connect to multiple AI providers through a single OpenAI-style endpoint.",
"Anthropic Provider": "Anthropic",
"Anthropic Provider Description": "Claude 3 family models exposed through the Claude API.",
"Anthropic Provider Description": "Claude 4 family models exposed through the Claude API.",
"Z.ai Provider": "Z.ai",
"Z.ai Provider Description": "Z.ai models served via BigModel Open Platform (OpenAI-style).",
"DeepSeek Provider": "DeepSeek",
"DeepSeek Provider Description": "DeepSeek language models via OpenAI-compatible API.",
"Grok Provider": "Grok (xAI)",
"Grok Provider Description": "Grok models powered by xAI with OpenAI-style routes.",
"Ollama Provider": "Ollama",
"Ollama Provider Description": "Self-host and run models locally with Ollama's OpenAI bridge.",
"Ollama Provider Description": "Self-host and run models locally with Ollama's native HTTP API.",
"Voyage Provider": "Voyage AI",
"Voyage Provider Description": "High-quality embeddings and rerankers from Voyage AI.",
"Delete provider?": "Delete provider?",

View File

@@ -411,15 +411,23 @@
"Added {count} models": "已添加 {count} 个模型",
"Custom Provider": "自定义提供商",
"Custom Provider Description": "自定义兼容 OpenAI 或 Gemini 标准的 API 端点。",
"OpenAI Provider Description": "访问 OpenAI 的 GPT-4o、GPT-4.1、GPT-3.5 等模型。",
"OpenAI Provider": "OpenAI",
"OpenAI Provider Description": "访问 OpenAI 的 GPT-4o、GPT-4.1、GPT-5 等模型。",
"Azure OpenAI Provider": "Azure OpenAI",
"Azure OpenAI Provider Description": "使用托管在微软 Azure 上的 OpenAI 模型。",
"Google AI Provider": "Google AI",
"Google AI Provider Description": "Google AI 平台提供的 Gemini 系列模型。",
"SiliconFlow Provider": "硅基流动",
"SiliconFlow Provider Description": "硅基流动高性能推理平台,兼容 OpenAI 接口。",
"OpenRouter Provider Description": "通过一个 OpenAI 风格入口接入多家 AI 提供商。",
"Anthropic Provider Description": "通过 Claude API 使用 Claude 3 系列模型。",
"Anthropic Provider": "Anthropic",
"Anthropic Provider Description": "通过 Claude API 使用 Claude 4 系列模型。",
"Z.ai Provider": "Z.ai",
"Z.ai Provider Description": "通过智谱开放平台接入OpenAI 风格接口)。",
"DeepSeek Provider": "DeepSeek",
"DeepSeek Provider Description": "DeepSeek 语言模型,支持 OpenAI 兼容接口。",
"Grok Provider Description": "xAI 的 Grok 模型,提供 OpenAI 风格接口。",
"Ollama Provider": "Ollama",
"Ollama Provider Description": "使用 Ollama 在本地运行并管理大模型。",
"Voyage Provider Description": "Voyage AI 提供的高质量嵌入与重排序模型。",
"Delete provider?": "确认删除该提供商?",

View File

@@ -80,7 +80,7 @@ interface ProviderTemplate {
key: string;
nameKey: string;
descriptionKey: string;
api_format: 'openai' | 'gemini';
api_format: AIProviderPayload['api_format'];
identifier: string;
base_url?: string;
logo_url?: string;
@@ -150,6 +150,28 @@ const providerTemplates: ProviderTemplate[] = [
provider_type: 'builtin',
doc_url: 'https://platform.openai.com/docs/api-reference',
},
{
key: 'azure-openai',
nameKey: 'Azure OpenAI Provider',
descriptionKey: 'Azure OpenAI Provider Description',
api_format: 'openai',
identifier: 'azure-openai',
base_url: 'https://{resource-name}.openai.azure.com/openai/deployments/{deployment-name}',
logo_url: '/icon/azure-color.svg',
provider_type: 'builtin',
doc_url: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/reference',
},
{
key: 'anthropic',
nameKey: 'Anthropic Provider',
descriptionKey: 'Anthropic Provider Description',
api_format: 'anthropic',
identifier: 'anthropic',
base_url: 'https://api.anthropic.com/v1',
logo_url: '/icon/anthropic.svg',
provider_type: 'builtin',
doc_url: 'https://docs.anthropic.com/claude/reference/messages_post',
},
{
key: 'google-ai',
nameKey: 'Google AI Provider',
@@ -161,6 +183,17 @@ const providerTemplates: ProviderTemplate[] = [
provider_type: 'builtin',
doc_url: 'https://ai.google.dev/api/rest',
},
{
key: 'zai',
nameKey: 'Z.ai Provider',
descriptionKey: 'Z.ai Provider Description',
api_format: 'openai',
identifier: 'zai',
base_url: 'https://open.bigmodel.cn/api/paas/v4',
logo_url: '/icon/zai.svg',
provider_type: 'builtin',
doc_url: 'https://open.bigmodel.cn/dev/api',
},
{
key: 'siliconflow',
nameKey: 'SiliconFlow Provider',
@@ -183,6 +216,17 @@ const providerTemplates: ProviderTemplate[] = [
provider_type: 'builtin',
doc_url: 'https://platform.deepseek.com/api-docs',
},
{
key: 'ollama',
nameKey: 'Ollama Provider',
descriptionKey: 'Ollama Provider Description',
api_format: 'ollama',
identifier: 'ollama',
base_url: 'http://localhost:11434',
logo_url: '/icon/ollama.svg',
provider_type: 'builtin',
doc_url: 'https://github.com/ollama/ollama/blob/main/docs/api.md',
},
];
const abilityTagColor: Record<AIAbility, string> = {
@@ -1071,14 +1115,16 @@ export default function AiSettingsTab() {
label={t('API Format')}
rules={[{ required: true }]}
>
<Select
disabled={!allowFormatChange}
options={[
{ value: 'openai', label: 'OpenAI Compatible' },
{ value: 'gemini', label: 'Gemini Compatible' },
]}
/>
</Form.Item>
<Select
disabled={!allowFormatChange}
options={[
{ value: 'openai', label: 'OpenAI Compatible' },
{ value: 'gemini', label: 'Gemini Compatible' },
{ value: 'anthropic', label: 'Anthropic Native' },
{ value: 'ollama', label: 'Ollama Native' },
]}
/>
</Form.Item>
<Form.Item name="base_url" label={t('Base URL')} rules={[{ required: true, message: t('Enter base url') }]}>
<Input placeholder="https://" />
</Form.Item>