free-short-video 6.5.2 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -7
- package/core/api/chat_providers.py +55 -0
- package/core/api/providers/__init__.py +23 -0
- package/core/api/providers/anthropic.py +84 -0
- package/core/api/providers/base.py +273 -0
- package/core/api/providers/openai.py +93 -0
- package/core/config.py +147 -1
- package/core/gallery_cache.py +36 -4
- package/core/i18n_backend.py +398 -0
- package/core/pipelines/__init__.py +22 -1
- package/core/pipelines/multi_scene.py +5 -2
- package/core/pipelines/simple_video.py +6 -2
- package/core/screenwriter/__init__.py +3 -1
- package/models/task.py +10 -0
- package/package.json +1 -1
- package/server.py +9 -0
- package/static/assets/ar-DVEg_H6f.js +1 -0
- package/static/assets/bn-BiFSnXzB.js +1 -0
- package/static/assets/de-uhexDoxx.js +1 -0
- package/static/assets/es-DUPqgRup.js +1 -0
- package/static/assets/fa-CNvLTccz.js +1 -0
- package/static/assets/fr-BagKMN93.js +1 -0
- package/static/assets/hi-DeWOhJ1q.js +1 -0
- package/static/assets/id-nGPSnGvK.js +1 -0
- package/static/assets/index-CJDvYGjk.css +1 -0
- package/static/assets/index-CfGG_wN-.js +45 -0
- package/static/assets/it-BJGGcHvC.js +1 -0
- package/static/assets/ja-cR8f9YPd.js +1 -0
- package/static/assets/ko-C0xGw09S.js +1 -0
- package/static/assets/ms-Cgs1dWE8.js +1 -0
- package/static/assets/nl-Cwg9KBwg.js +1 -0
- package/static/assets/pt-B9Hhu0CQ.js +1 -0
- package/static/assets/ru-CafubBrt.js +1 -0
- package/static/assets/th-2Muse1NU.js +1 -0
- package/static/assets/tl-DGVTGEV4.js +1 -0
- package/static/assets/tr-BPcMq5fF.js +1 -0
- package/static/assets/ur-BTthCgqj.js +1 -0
- package/static/assets/vi-Bi-nuXdz.js +1 -0
- package/static/index.html +2 -2
- package/utils/network.py +31 -18
- package/web/deps.py +16 -8
- package/web/middleware.py +71 -0
- package/web/routes/config_routes.py +248 -0
- package/web/routes/gallery_routes.py +24 -26
- package/web/routes/image_routes.py +14 -6
- package/web/routes/preview_routes.py +3 -2
- package/web/routes/task_creation_routes.py +22 -11
- package/web/routes/task_routes.py +18 -7
- package/web/routes/video_routes.py +37 -14
- package/static/assets/ar-CvuF8gFF.js +0 -1
- package/static/assets/bn-CWLD7QCi.js +0 -1
- package/static/assets/de-DOVh35jv.js +0 -1
- package/static/assets/es-CLzRtmI7.js +0 -1
- package/static/assets/fa-DNTSi8NJ.js +0 -1
- package/static/assets/fr-BpUCsjaC.js +0 -1
- package/static/assets/hi-Bkg-ypdW.js +0 -1
- package/static/assets/id-CJjRdrXY.js +0 -1
- package/static/assets/index-CgXb4MrZ.css +0 -1
- package/static/assets/index-Dn7-Q6vG.js +0 -45
- package/static/assets/it-CyVEADj1.js +0 -1
- package/static/assets/ja-yWWPg0BT.js +0 -1
- package/static/assets/ko-BbbGMI2i.js +0 -1
- package/static/assets/ms-CJ1UCmU_.js +0 -1
- package/static/assets/nl-7OqowihO.js +0 -1
- package/static/assets/pt-C8Z7vWM8.js +0 -1
- package/static/assets/ru-BJpeOLhl.js +0 -1
- package/static/assets/th-BOYCdRJN.js +0 -1
- package/static/assets/tl-DzOYHZWC.js +0 -1
- package/static/assets/tr-IYge5J2a.js +0 -1
- package/static/assets/ur-C24aPBX9.js +0 -1
- package/static/assets/vi-BfnnHjVU.js +0 -1
package/README.md
CHANGED
|
@@ -1,20 +1,52 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
|
-
# What's New in
|
|
3
|
+
# What's New in v7.0.0
|
|
4
4
|
|
|
5
5
|
## What's New
|
|
6
6
|
|
|
7
7
|
### Features & Improvements
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
- **Custom text model providers (OpenAI-compatible / Anthropic-compatible).** Configure any
|
|
10
|
+
LLM endpoint with `base URL + API key` and two supported wire protocols
|
|
11
|
+
(`openai-completions`, `anthropic-messages`). Once selected, every text call — screenplay
|
|
12
|
+
breakdown, scene planning, poetry segmentation, image-prompt rewriting — is routed to your
|
|
13
|
+
provider automatically. The built-in Agnes provider is always available, cannot be deleted,
|
|
14
|
+
and keeps the previous behaviour when no custom provider is selected.
|
|
15
|
+
- **Provider management panel.** A dedicated settings section lists all text providers, lets
|
|
16
|
+
you add, edit and delete them, and keeps the Agnes key/domain editing inside the provider
|
|
17
|
+
modal. Keys are masked in the UI, and the provider's model list is a single editable list:
|
|
18
|
+
fetch it from the endpoint or type model ids by hand. Model selection is a two-level
|
|
19
|
+
provider → model picker that merges the models of all providers.
|
|
20
|
+
- **Interface-language aware backend messages.** Task progress messages, failure panels, HTTP
|
|
21
|
+
error details and the diagnostics report are now localized according to the UI language
|
|
22
|
+
instead of being hard-coded Chinese. The UI language travels with every request
|
|
23
|
+
(`X-Agnes-UI-Lang`, falling back to `Accept-Language`), is snapshotted into each task at
|
|
24
|
+
creation time, and therefore does not drift if the user switches language later. Chinese and
|
|
25
|
+
English are covered; the remaining languages fall back to the existing behaviour.
|
|
26
|
+
- **Clearer network failure diagnosis.** TLS handshake resets (`connection reset`) are now
|
|
27
|
+
attributed to local-connection problems, and the diagnostics report shows the interface
|
|
28
|
+
language of the reporting user, making failure reports easier to triage.
|
|
29
|
+
|
|
30
|
+
### Refactoring & Optimizations
|
|
31
|
+
|
|
32
|
+
- **Decoupled text-generation layer.** A new protocol-client layer
|
|
33
|
+
(`core/api/providers/` → base / OpenAI / Anthropic) plus a provider factory replaces the two
|
|
34
|
+
hard-coded Agnes chat client constructions, so pipelines and the screenwriter are untouched
|
|
35
|
+
while the underlying model source becomes pluggable. Custom providers reuse the existing
|
|
36
|
+
shared rate limiter and exponential-backoff retry policy, and failures are recorded in the
|
|
37
|
+
same error log as before.
|
|
38
|
+
- **Backend i18n runtime.** A new translation runtime plus request-scoped language middleware
|
|
39
|
+
gives the FastAPI side the same localization guarantees the frontend already had, with a
|
|
40
|
+
safe fallback chain that never turns a missing translation into a request failure.
|
|
14
41
|
|
|
15
42
|
### Bug Fixes
|
|
16
43
|
|
|
17
|
-
|
|
44
|
+
- Model pulling for an already saved provider now reuses its stored credentials instead of
|
|
45
|
+
falling back to the Agnes key.
|
|
46
|
+
- The built-in Agnes provider no longer appears twice in the provider picker and is now
|
|
47
|
+
labelled as text-only.
|
|
48
|
+
- Non-Chinese interfaces no longer receive hard-coded Chinese network-diagnosis and task
|
|
49
|
+
messages (issue #64).
|
|
18
50
|
|
|
19
51
|
---
|
|
20
52
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""core.api.chat_providers — 文本模型客户端工厂(v7.0 多文本模型)。
|
|
2
|
+
|
|
3
|
+
按当前所选文本供应商(``models.text_provider``)分派:
|
|
4
|
+
- 空 / ``agnes`` → ``AgnesChatAPI``(现有行为 100% 不变);
|
|
5
|
+
- 自定义供应商 → ``OpenAIChatClient`` / ``AnthropicChatClient``。
|
|
6
|
+
|
|
7
|
+
供 screenwriter / video_routes 等取代硬编码的 ``AgnesChatAPI(...)`` 构造点。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from core.config import API_ANTHROPIC, resolve_text_chat
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_or_build_text_chat_client(api_key: str | None = None, model: str | None = None):
|
|
20
|
+
"""构造当前所选文本供应商的聊天客户端(与 AgnesChatAPI 相同接口)。
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: 显式 API Key。仅对 agnes 供应商生效(等价原 ``AgnesChatAPI``
|
|
24
|
+
构造时 `api_key=api_key`);自定义供应商使用其自身配置的 api_key。
|
|
25
|
+
model: 显式模型;省略则使用当前所选配置(``models.text`` 或供应商首个模型)。
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
满足 ``chat / chat_json / chat_multimodal`` 接口的客户端实例。
|
|
29
|
+
"""
|
|
30
|
+
cfg = resolve_text_chat()
|
|
31
|
+
if cfg["kind"] == "agnes":
|
|
32
|
+
from core.api.agnes_chat import AgnesChatAPI
|
|
33
|
+
from core.config import get_api_key
|
|
34
|
+
|
|
35
|
+
return AgnesChatAPI(
|
|
36
|
+
api_key=api_key or get_api_key(),
|
|
37
|
+
model=model or cfg["model"],
|
|
38
|
+
)
|
|
39
|
+
# custom
|
|
40
|
+
m = model or cfg["model"]
|
|
41
|
+
if cfg["api"] == API_ANTHROPIC:
|
|
42
|
+
from core.api.providers.anthropic import AnthropicChatClient
|
|
43
|
+
|
|
44
|
+
return AnthropicChatClient(
|
|
45
|
+
base_url=cfg["base_url"],
|
|
46
|
+
api_key=cfg["api_key"],
|
|
47
|
+
model=m,
|
|
48
|
+
)
|
|
49
|
+
from core.api.providers.openai import OpenAIChatClient
|
|
50
|
+
|
|
51
|
+
return OpenAIChatClient(
|
|
52
|
+
base_url=cfg["base_url"],
|
|
53
|
+
api_key=cfg["api_key"],
|
|
54
|
+
model=m,
|
|
55
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""core.api.providers — 可插拔文本模型供应商的协议客户端包。
|
|
2
|
+
|
|
3
|
+
提供 OpenAI 兼容(``OpenAIChatClient``)与 Anthropic 兼容(``AnthropicChatClient``)
|
|
4
|
+
两种线协议实现,以及端点拼接纯函数(``get_chat_endpoint`` / ``get_models_endpoint``)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from core.api.providers.anthropic import AnthropicChatClient
|
|
8
|
+
from core.api.providers.base import (
|
|
9
|
+
get_chat_endpoint,
|
|
10
|
+
get_models_endpoint,
|
|
11
|
+
probe_text_models,
|
|
12
|
+
request_with_retry,
|
|
13
|
+
)
|
|
14
|
+
from core.api.providers.openai import OpenAIChatClient
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AnthropicChatClient",
|
|
18
|
+
"OpenAIChatClient",
|
|
19
|
+
"get_chat_endpoint",
|
|
20
|
+
"get_models_endpoint",
|
|
21
|
+
"probe_text_models",
|
|
22
|
+
"request_with_retry",
|
|
23
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""core.api.providers.anthropic — Anthropic 兼容线协议客户端
|
|
2
|
+
|
|
3
|
+
协议:``POST {base}/v1/messages``(base 已含 /v1 则 ``{base}/messages``),Header
|
|
4
|
+
``x-api-key`` + ``anthropic-version: 2023-06-01``;body:``model / system(顶层) /
|
|
5
|
+
messages / max_tokens / temperature``。取结果 ``data.content[0].text``。
|
|
6
|
+
|
|
7
|
+
多模态本期不做:``chat_multimodal`` 告警并退回纯文本(忽略 images)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from core.api.providers.base import (
|
|
17
|
+
BaseProviderClient,
|
|
18
|
+
request_with_retry,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_ANTHROPIC_VERSION = "2023-06-01"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AnthropicChatClient(BaseProviderClient):
|
|
27
|
+
"""Anthropic Messages API 兼容客户端(text only)。"""
|
|
28
|
+
|
|
29
|
+
api = "anthropic-messages"
|
|
30
|
+
|
|
31
|
+
def _chat_endpoint(self) -> str:
|
|
32
|
+
base = self.base_url
|
|
33
|
+
if base.endswith("/v1"):
|
|
34
|
+
return f"{base}/messages"
|
|
35
|
+
return f"{base}/v1/messages"
|
|
36
|
+
|
|
37
|
+
def _auth_headers(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"x-api-key": self.api_key,
|
|
40
|
+
"anthropic-version": _ANTHROPIC_VERSION,
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def _extract_content(self, data: dict) -> str:
|
|
45
|
+
content = data.get("content") or []
|
|
46
|
+
if content:
|
|
47
|
+
return content[0].get("text", "")
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
def _request(self, payload: dict, timeout: int = 120) -> dict:
|
|
51
|
+
url = self._chat_endpoint()
|
|
52
|
+
headers = self._auth_headers()
|
|
53
|
+
return request_with_retry(
|
|
54
|
+
requests.post, url, headers, payload=payload, timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def chat(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> str:
|
|
58
|
+
logger.info(
|
|
59
|
+
f"[AnthropicProvider] Calling chat ({self.model}), prompt: {len(user_prompt)} chars..."
|
|
60
|
+
)
|
|
61
|
+
data = self._request(
|
|
62
|
+
{
|
|
63
|
+
"model": self.model,
|
|
64
|
+
"system": system_prompt,
|
|
65
|
+
"messages": [{"role": "user", "content": user_prompt}],
|
|
66
|
+
"max_tokens": max_tokens,
|
|
67
|
+
"temperature": 0.7,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
return self._extract_content(data)
|
|
71
|
+
|
|
72
|
+
def chat_multimodal(
|
|
73
|
+
self,
|
|
74
|
+
system_prompt: str,
|
|
75
|
+
text_prompt: str,
|
|
76
|
+
image_paths: list,
|
|
77
|
+
max_tokens: int = 4096,
|
|
78
|
+
) -> str:
|
|
79
|
+
# 本期不支持 Anthropic 多模态:告警并退回纯文本(忽略 images)
|
|
80
|
+
logger.warning(
|
|
81
|
+
f"[AnthropicProvider] Multimodal not supported, falling back to text-only "
|
|
82
|
+
f"({len(image_paths)} image(s) ignored)"
|
|
83
|
+
)
|
|
84
|
+
return self.chat(system_prompt, text_prompt, max_tokens=max_tokens)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""core.api.providers.base — 文本供应商协议客户端的共享底层
|
|
2
|
+
|
|
3
|
+
提供:
|
|
4
|
+
- 端点拼接纯函数 ``get_chat_endpoint`` / ``get_models_endpoint``(OpenAI 兼容 /
|
|
5
|
+
Anthropic 兼容的 /v1 是否已含处理);
|
|
6
|
+
- 受限速 + 指数退避的底层请求 ``request_with_retry``(不复用 Agnes KeyRing,
|
|
7
|
+
仅共享全局令牌桶 `get_rate_limiter()`;5xx/429 最多 3 次指数退避,4xx 直接抛);
|
|
8
|
+
- ``BaseProviderClient``:OpenAI / Anthropic 客户端共用的胶水(chat_json 解析、
|
|
9
|
+
多模态拼装 header 等),具体协议差异在子类实现。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from core.api.agnes_chat import (
|
|
21
|
+
_JSON_BLOCK_RE,
|
|
22
|
+
repair_json,
|
|
23
|
+
strip_code_fence,
|
|
24
|
+
)
|
|
25
|
+
from core.api.error_collector import collect_error, collect_error_from_exception
|
|
26
|
+
from core.api.rate_limiter import get_rate_limiter
|
|
27
|
+
from core.config import API_OPENAI
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_MAX_RETRIES = 3
|
|
32
|
+
_RETRY_BASE_DELAY = 15 # 秒,指数退避基数
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_chat_endpoint(base_url: str, api: str) -> str:
|
|
36
|
+
"""OpenAI 兼容 chat 端点:``{base}/chat/completions``(base_url 去除尾 `/`)。
|
|
37
|
+
|
|
38
|
+
(Anthropic 聊天使用独立构造,见 ``_anthropic_messages_endpoint``;本函数对
|
|
39
|
+
Anthropic 语义为 ``{base}/v1/messages``,供探测结果一致性参照。)
|
|
40
|
+
"""
|
|
41
|
+
base = (base_url or "").rstrip("/")
|
|
42
|
+
if api == "anthropic-messages":
|
|
43
|
+
if base.endswith("/v1"):
|
|
44
|
+
return f"{base}/messages"
|
|
45
|
+
return f"{base}/v1/messages"
|
|
46
|
+
return f"{base}/chat/completions"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_models_endpoint(base_url: str, api: str) -> str:
|
|
50
|
+
"""OpenAI 兼容模型列表端点:``{base}/models``。
|
|
51
|
+
|
|
52
|
+
Anthropic 语义:base 以 ``/v1`` 结尾 → ``{base}/models``,否则 ``{base}/v1/models``。
|
|
53
|
+
"""
|
|
54
|
+
base = (base_url or "").rstrip("/")
|
|
55
|
+
if api == "anthropic-messages":
|
|
56
|
+
if base.endswith("/v1"):
|
|
57
|
+
return f"{base}/models"
|
|
58
|
+
return f"{base}/v1/models"
|
|
59
|
+
return f"{base}/models"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def request_with_retry(
|
|
63
|
+
requester,
|
|
64
|
+
url: str,
|
|
65
|
+
headers: dict,
|
|
66
|
+
*,
|
|
67
|
+
payload: dict | None = None,
|
|
68
|
+
json_body: bool = True,
|
|
69
|
+
timeout: int = 120,
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""受限速的指数退避请求(自定义供应商专用,不换 Key)。
|
|
72
|
+
|
|
73
|
+
规则:
|
|
74
|
+
1. 每次请求前 ``get_rate_limiter().acquire()``(共享桶);
|
|
75
|
+
2. 5xx / 429 → 指数退避(delay = 15 × (retries+1)),最多 3 次;
|
|
76
|
+
3. 4xx(非 429)不重试,直接 ``raise requests.HTTPError``;
|
|
77
|
+
4. 失败调用 ``collect_error`` / ``collect_error_from_exception`` 落盘。
|
|
78
|
+
"""
|
|
79
|
+
prompt = ""
|
|
80
|
+
if payload and json_body:
|
|
81
|
+
prompt = _extract_prompt_from_payload(payload)
|
|
82
|
+
retries = 0
|
|
83
|
+
while True:
|
|
84
|
+
get_rate_limiter().acquire()
|
|
85
|
+
try:
|
|
86
|
+
if json_body:
|
|
87
|
+
resp = requester(url, headers=headers, json=payload, timeout=timeout)
|
|
88
|
+
else:
|
|
89
|
+
resp = requester(url, headers=headers, timeout=timeout)
|
|
90
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
|
91
|
+
collect_error_from_exception(
|
|
92
|
+
"chat", "chat", exc=e, prompt=prompt, retry_count=retries,
|
|
93
|
+
)
|
|
94
|
+
if retries < _MAX_RETRIES:
|
|
95
|
+
delay = _RETRY_BASE_DELAY * (retries + 1)
|
|
96
|
+
logger.warning(
|
|
97
|
+
f"[ChatProvider] {type(e).__name__}, 退避 {delay}s 后重试 "
|
|
98
|
+
f"({retries + 1}/{_MAX_RETRIES})"
|
|
99
|
+
)
|
|
100
|
+
time.sleep(delay)
|
|
101
|
+
retries += 1
|
|
102
|
+
continue
|
|
103
|
+
raise
|
|
104
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
105
|
+
if retries < _MAX_RETRIES:
|
|
106
|
+
delay = _RETRY_BASE_DELAY * (retries + 1)
|
|
107
|
+
logger.warning(
|
|
108
|
+
f"[ChatProvider] HTTP {resp.status_code}, 退避 {delay}s 后重试 "
|
|
109
|
+
f"({retries + 1}/{_MAX_RETRIES})"
|
|
110
|
+
)
|
|
111
|
+
time.sleep(delay)
|
|
112
|
+
retries += 1
|
|
113
|
+
continue
|
|
114
|
+
# 重试耗尽:记录最终失败
|
|
115
|
+
collect_error(
|
|
116
|
+
"chat", "chat",
|
|
117
|
+
prompt=prompt,
|
|
118
|
+
error_type="RateLimit429" if resp.status_code == 429 else f"HTTP{resp.status_code}",
|
|
119
|
+
error_message=f"HTTP {resp.status_code}: retries exhausted",
|
|
120
|
+
status_code=resp.status_code,
|
|
121
|
+
response_body=resp.text,
|
|
122
|
+
retry_count=_MAX_RETRIES,
|
|
123
|
+
)
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
if resp.status_code >= 400:
|
|
126
|
+
# 4xx(含 429 已耗尽)不可重试
|
|
127
|
+
try:
|
|
128
|
+
collect_error(
|
|
129
|
+
"chat", "chat",
|
|
130
|
+
prompt=prompt,
|
|
131
|
+
error_type=f"HTTP{resp.status_code}",
|
|
132
|
+
error_message=f"HTTP {resp.status_code}: {resp.text[:500]}",
|
|
133
|
+
status_code=resp.status_code,
|
|
134
|
+
response_body=resp.text[:5000],
|
|
135
|
+
retry_count=retries,
|
|
136
|
+
)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
return resp.json()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_prompt_from_payload(payload: dict) -> str:
|
|
144
|
+
"""从 Chat payload 中提取 user prompt 文本(用于错误收集)。"""
|
|
145
|
+
messages = payload.get("messages", [])
|
|
146
|
+
for msg in reversed(messages):
|
|
147
|
+
content = msg.get("content", "")
|
|
148
|
+
if isinstance(content, list):
|
|
149
|
+
texts = [
|
|
150
|
+
item.get("text", "")
|
|
151
|
+
for item in content
|
|
152
|
+
if isinstance(item, dict) and item.get("type") == "text"
|
|
153
|
+
]
|
|
154
|
+
if texts:
|
|
155
|
+
return texts[0]
|
|
156
|
+
elif isinstance(content, str) and content.strip():
|
|
157
|
+
return content
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class BaseProviderClient:
|
|
162
|
+
"""协议客户端基类:持有 base_url/api_key/model,含共享的 chat_json 解析逻辑。
|
|
163
|
+
|
|
164
|
+
子类必须实现:``_auth_headers()``、``chat()``、``chat_multimodal()``。
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
api = API_OPENAI
|
|
168
|
+
|
|
169
|
+
def __init__(self, base_url: str, api_key: str, model: str):
|
|
170
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
171
|
+
self.api_key = api_key
|
|
172
|
+
self.model = model
|
|
173
|
+
|
|
174
|
+
def _auth_headers(self) -> dict:
|
|
175
|
+
raise NotImplementedError
|
|
176
|
+
|
|
177
|
+
def _extract_content(self, data: dict) -> str:
|
|
178
|
+
raise NotImplementedError
|
|
179
|
+
|
|
180
|
+
def _request(self, payload: dict, timeout: int = 120) -> dict:
|
|
181
|
+
url = get_chat_endpoint(self.base_url, self.api)
|
|
182
|
+
headers = self._auth_headers()
|
|
183
|
+
return request_with_retry(
|
|
184
|
+
requests.post, url, headers, payload=payload, timeout=timeout,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def chat_json(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> dict:
|
|
188
|
+
"""Chat 调用并解析 JSON 响应(与 agnes_chat.chat_json 一致的健壮流程)。"""
|
|
189
|
+
for retry in range(2):
|
|
190
|
+
content = self.chat(system_prompt, user_prompt, max_tokens=max_tokens)
|
|
191
|
+
cleaned = strip_code_fence(content)
|
|
192
|
+
try:
|
|
193
|
+
return json.loads(cleaned)
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass
|
|
196
|
+
match = _JSON_BLOCK_RE.search(cleaned)
|
|
197
|
+
if match:
|
|
198
|
+
try:
|
|
199
|
+
return json.loads(match.group())
|
|
200
|
+
except ValueError:
|
|
201
|
+
pass
|
|
202
|
+
if repair_json is not None:
|
|
203
|
+
try:
|
|
204
|
+
repaired = repair_json(cleaned, return_objects=True)
|
|
205
|
+
if isinstance(repaired, dict):
|
|
206
|
+
logger.info(f"[{self._log_prefix}] JSON repaired via json_repair")
|
|
207
|
+
return repaired
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
if retry == 0:
|
|
211
|
+
logger.warning(f"[{self._log_prefix}] JSON parse failed, retrying chat call...")
|
|
212
|
+
continue
|
|
213
|
+
preview = content[:200]
|
|
214
|
+
error_msg = (
|
|
215
|
+
f"[{self._log_prefix}] Failed to parse JSON after 2 attempts. "
|
|
216
|
+
f"Response preview: {preview}..."
|
|
217
|
+
)
|
|
218
|
+
collect_error(
|
|
219
|
+
"chat", "chat_json",
|
|
220
|
+
prompt=user_prompt, system_prompt=system_prompt,
|
|
221
|
+
error_type="JSONParseError",
|
|
222
|
+
error_message=error_msg,
|
|
223
|
+
response_body=content[:5000],
|
|
224
|
+
retry_count=2,
|
|
225
|
+
)
|
|
226
|
+
raise ValueError(error_msg)
|
|
227
|
+
raise ValueError(f"[{self._log_prefix}] Unexpected flow in chat_json")
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def _log_prefix(self) -> str:
|
|
231
|
+
return self.__class__.__name__
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def probe_text_models(base_url: str, api_key: str, api: str) -> list:
|
|
235
|
+
"""用传入的 base_url+key+api 探测模型列表(不落盘,用于 test 端点)。
|
|
236
|
+
|
|
237
|
+
OpenAI 兼容用 ``Authorization: Bearer``;Anthropic 兼容用 ``x-api-key`` +
|
|
238
|
+
``anthropic-version``。成功返回模型 id 列表,失败抛出 ``requests.HTTPError``。
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
base_url: 用户输入的 base_url。
|
|
242
|
+
api_key: 用户此刻输入的 key。
|
|
243
|
+
api: 线协议(openai-completions / anthropic-messages)。
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
模型 id 列表(``data[].id`` / ``data[].id``)。
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
requests.HTTPError: 4xx/5xx/网络错误(由调用方捕获返回 ``{ok:false,error}``)。
|
|
250
|
+
"""
|
|
251
|
+
url = get_models_endpoint(base_url, api)
|
|
252
|
+
headers = {"Content-Type": "application/json"}
|
|
253
|
+
if api == "anthropic-messages":
|
|
254
|
+
headers["x-api-key"] = api_key
|
|
255
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
256
|
+
else:
|
|
257
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
258
|
+
resp = requests.get(url, headers=headers, timeout=60)
|
|
259
|
+
if resp.status_code >= 400:
|
|
260
|
+
try:
|
|
261
|
+
logger.warning(
|
|
262
|
+
f"[ChatProvider] Probe models failed HTTP {resp.status_code}: {resp.text[:500]}"
|
|
263
|
+
)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
resp.raise_for_status()
|
|
267
|
+
data = resp.json()
|
|
268
|
+
models = data.get("data") or []
|
|
269
|
+
ids = []
|
|
270
|
+
for m in models:
|
|
271
|
+
if isinstance(m, dict) and m.get("id"):
|
|
272
|
+
ids.append(str(m["id"]))
|
|
273
|
+
return ids
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""core.api.providers.openai — OpenAI 兼容线协议客户端
|
|
2
|
+
|
|
3
|
+
协议:``POST {base}/chat/completions``,Header ``Authorization: Bearer <key>``,
|
|
4
|
+
body 与 Agnes 相同结构;多模态 content 为列表 + image_url(base64 data URI 透传)。
|
|
5
|
+
取结果 ``data.choices[0].message.content``。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import logging
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from core.api.providers.base import BaseProviderClient
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenAIChatClient(BaseProviderClient):
|
|
21
|
+
"""OpenAI 兼容协议客户端(text + multimodal)。"""
|
|
22
|
+
|
|
23
|
+
api = "openai-completions"
|
|
24
|
+
|
|
25
|
+
def _auth_headers(self) -> dict:
|
|
26
|
+
return {
|
|
27
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def _image_to_b64_uri(self, path: str) -> str:
|
|
32
|
+
"""将本地图片转为 OpenAI ``data:`` URI(与 agnes_chat 一致)。"""
|
|
33
|
+
with open(path, "rb") as f:
|
|
34
|
+
b64 = base64.b64encode(f.read()).decode("utf-8")
|
|
35
|
+
mime = mimetypes.guess_type(path)[0] or "image/png"
|
|
36
|
+
return f"data:{mime};base64,{b64}"
|
|
37
|
+
|
|
38
|
+
def _extract_content(self, data: dict) -> str:
|
|
39
|
+
return data["choices"][0]["message"]["content"]
|
|
40
|
+
|
|
41
|
+
def chat(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> str:
|
|
42
|
+
logger.info(
|
|
43
|
+
f"[OpenAIProvider] Calling chat ({self.model}), prompt: {len(user_prompt)} chars..."
|
|
44
|
+
)
|
|
45
|
+
data = self._request(
|
|
46
|
+
{
|
|
47
|
+
"model": self.model,
|
|
48
|
+
"messages": [
|
|
49
|
+
{"role": "system", "content": system_prompt},
|
|
50
|
+
{"role": "user", "content": user_prompt},
|
|
51
|
+
],
|
|
52
|
+
"temperature": 0.7,
|
|
53
|
+
"max_tokens": max_tokens,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return self._extract_content(data)
|
|
57
|
+
|
|
58
|
+
def chat_multimodal(
|
|
59
|
+
self,
|
|
60
|
+
system_prompt: str,
|
|
61
|
+
text_prompt: str,
|
|
62
|
+
image_paths: list,
|
|
63
|
+
max_tokens: int = 4096,
|
|
64
|
+
) -> str:
|
|
65
|
+
messages = [{"role": "system", "content": system_prompt}]
|
|
66
|
+
user_content = [{"type": "text", "text": text_prompt}]
|
|
67
|
+
for img_path in image_paths:
|
|
68
|
+
if img_path.startswith(("http://", "https://")):
|
|
69
|
+
user_content.append({
|
|
70
|
+
"type": "image_url",
|
|
71
|
+
"image_url": {"url": img_path},
|
|
72
|
+
})
|
|
73
|
+
elif os.path.exists(img_path):
|
|
74
|
+
b64_uri = self._image_to_b64_uri(img_path)
|
|
75
|
+
user_content.append({
|
|
76
|
+
"type": "image_url",
|
|
77
|
+
"image_url": {"url": b64_uri},
|
|
78
|
+
})
|
|
79
|
+
messages.append({"role": "user", "content": user_content})
|
|
80
|
+
logger.info(
|
|
81
|
+
f"[OpenAIProvider] Calling multimodal ({self.model}), "
|
|
82
|
+
f"{len(image_paths)} image(s), prompt: {len(text_prompt)} chars..."
|
|
83
|
+
)
|
|
84
|
+
data = self._request(
|
|
85
|
+
{
|
|
86
|
+
"model": self.model,
|
|
87
|
+
"messages": messages,
|
|
88
|
+
"temperature": 0.7,
|
|
89
|
+
"max_tokens": max_tokens,
|
|
90
|
+
},
|
|
91
|
+
timeout=300,
|
|
92
|
+
)
|
|
93
|
+
return self._extract_content(data)
|