284 lines
11 KiB
Python
284 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from .config import read_llm_provider, read_network_settings, write_llm_provider
|
|
from .http_client import HttpRequestError, request_json
|
|
from .utils import parse_json_loose
|
|
|
|
|
|
def provider_issues(provider: dict[str, Any] | None = None) -> list[str]:
|
|
provider = provider or read_llm_provider()
|
|
issues = []
|
|
if not provider.get("baseUrl"):
|
|
issues.append("LLM base URL is missing.")
|
|
if not provider.get("apiKey"):
|
|
issues.append("LLM API key is missing.")
|
|
if not provider.get("model"):
|
|
issues.append("LLM model is missing.")
|
|
if provider.get("apiStyle") not in {"openai", "anthropic"}:
|
|
issues.append("LLM apiStyle must be openai or anthropic.")
|
|
return issues
|
|
|
|
|
|
def provider_status(provider: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
provider = provider or read_llm_provider()
|
|
issues = provider_issues(provider)
|
|
return {
|
|
"preset": provider.get("preset"),
|
|
"apiStyle": provider.get("apiStyle"),
|
|
"model": provider.get("model"),
|
|
"baseUrl": provider.get("baseUrl"),
|
|
"configured": not issues,
|
|
"issues": issues,
|
|
}
|
|
|
|
|
|
def _join_endpoint(base_url: str, suffix: str) -> str:
|
|
base = str(base_url or "").rstrip("/")
|
|
if base.endswith(suffix):
|
|
return base
|
|
return f"{base}{suffix}"
|
|
|
|
|
|
def _normalized_api_base_url(base_url: str, api_style: str) -> str:
|
|
raw = str(base_url or "").strip().rstrip("/")
|
|
if not raw:
|
|
return raw
|
|
parsed = urlparse(raw)
|
|
path = str(parsed.path or "").rstrip("/")
|
|
if api_style in {"openai", "anthropic"} and not path:
|
|
return f"{raw}/v1"
|
|
return raw
|
|
|
|
|
|
def _gateway_hint(provider: dict[str, Any], request_url: str) -> str | None:
|
|
base_url = str(provider.get("baseUrl") or "").strip()
|
|
parsed = urlparse(base_url)
|
|
host = str(parsed.hostname or "").lower()
|
|
api_style = str(provider.get("apiStyle") or "").strip().lower()
|
|
if api_style == "anthropic" and host and host not in {"api.anthropic.com", "console.anthropic.com"}:
|
|
return (
|
|
"当前 Base URL 不是 Anthropic 官方域名。很多第三方网关虽然支持 Claude 模型名,"
|
|
"但接口实际上是 OpenAI compatible。系统会继续自动尝试 OpenAI compatible。"
|
|
f" 当前请求地址:{request_url}"
|
|
)
|
|
return None
|
|
|
|
|
|
def _provider_transport_candidates(provider: dict[str, Any]) -> list[str]:
|
|
configured = str(provider.get("apiStyle") or "").strip().lower()
|
|
candidates: list[str] = []
|
|
if configured in {"openai", "anthropic"}:
|
|
candidates.append(configured)
|
|
preset = str(provider.get("preset") or "").strip().lower()
|
|
host = str(urlparse(str(provider.get("baseUrl") or "")).hostname or "").lower()
|
|
if preset == "claude" and host and host not in {"api.anthropic.com", "console.anthropic.com"}:
|
|
fallback = "openai" if configured != "openai" else "anthropic"
|
|
if fallback not in candidates:
|
|
candidates.append(fallback)
|
|
if not candidates:
|
|
candidates.append("openai")
|
|
return candidates
|
|
|
|
|
|
def _persist_provider_api_style(provider: dict[str, Any], resolved_api_style: str) -> dict[str, Any] | None:
|
|
current_style = str(provider.get("apiStyle") or "").strip().lower()
|
|
if resolved_api_style == current_style:
|
|
return None
|
|
return write_llm_provider(
|
|
{
|
|
"preset": provider.get("preset"),
|
|
"apiStyle": resolved_api_style,
|
|
"model": provider.get("model"),
|
|
"baseUrl": provider.get("baseUrl"),
|
|
"apiKey": provider.get("apiKey"),
|
|
"timeoutSeconds": provider.get("timeoutSeconds"),
|
|
"temperature": provider.get("temperature"),
|
|
"maxOutputTokens": provider.get("maxOutputTokens"),
|
|
"anthropicVersion": provider.get("anthropicVersion"),
|
|
"customHeaders": provider.get("customHeaders") if isinstance(provider.get("customHeaders"), dict) else {},
|
|
}
|
|
)
|
|
|
|
|
|
def _openai_messages(prompt_text: str) -> list[dict[str, str]]:
|
|
return [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"You are a disciplined crypto futures trading assistant. "
|
|
"Return strict JSON only. Do not include markdown fences."
|
|
),
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": prompt_text,
|
|
},
|
|
]
|
|
|
|
|
|
def _anthropic_message(prompt_text: str) -> tuple[str, list[dict[str, Any]]]:
|
|
system = (
|
|
"You are a disciplined crypto futures trading assistant. "
|
|
"Return strict JSON only. Do not include markdown fences."
|
|
)
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": prompt_text,
|
|
}
|
|
]
|
|
return system, messages
|
|
|
|
|
|
def _extract_openai_text(response: dict[str, Any]) -> str:
|
|
choices = response.get("choices") if isinstance(response.get("choices"), list) else []
|
|
if not choices:
|
|
raise ValueError("OpenAI-compatible response did not include choices.")
|
|
message = choices[0].get("message") if isinstance(choices[0], dict) else {}
|
|
content = message.get("content") if isinstance(message, dict) else None
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
text_parts = []
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
text_parts.append(str(item.get("text") or ""))
|
|
if text_parts:
|
|
return "\n".join(text_parts)
|
|
raise ValueError("OpenAI-compatible response did not include message content.")
|
|
|
|
|
|
def _extract_anthropic_text(response: dict[str, Any]) -> str:
|
|
content = response.get("content") if isinstance(response.get("content"), list) else []
|
|
text_parts = []
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "text":
|
|
text_parts.append(str(block.get("text") or ""))
|
|
if not text_parts:
|
|
raise ValueError("Anthropic response did not include text content.")
|
|
return "\n".join(text_parts)
|
|
|
|
|
|
def _request_provider_text(
|
|
prompt_text: str,
|
|
provider: dict[str, Any],
|
|
request_api_style: str,
|
|
network_settings: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
headers = dict(provider.get("customHeaders") or {})
|
|
timeout_seconds = int(provider.get("timeoutSeconds") or 45)
|
|
api_base_url = _normalized_api_base_url(provider.get("baseUrl") or "", request_api_style)
|
|
if request_api_style == "anthropic":
|
|
system, messages = _anthropic_message(prompt_text)
|
|
url = _join_endpoint(api_base_url, "/messages")
|
|
headers.update(
|
|
{
|
|
"x-api-key": provider["apiKey"],
|
|
"anthropic-version": provider.get("anthropicVersion") or "2023-06-01",
|
|
"content-type": "application/json",
|
|
}
|
|
)
|
|
payload = {
|
|
"model": provider["model"],
|
|
"system": system,
|
|
"messages": messages,
|
|
"temperature": provider["temperature"],
|
|
"max_tokens": provider["maxOutputTokens"],
|
|
}
|
|
try:
|
|
raw_response = request_json(
|
|
"POST",
|
|
url,
|
|
headers=headers,
|
|
payload=payload,
|
|
timeout_seconds=timeout_seconds,
|
|
network_settings=network_settings,
|
|
)
|
|
except HttpRequestError as error:
|
|
hint = _gateway_hint({**provider, "apiStyle": request_api_style}, url)
|
|
if hint:
|
|
raise HttpRequestError(f"{error} | {hint}") from error
|
|
raise
|
|
raw_text = _extract_anthropic_text(raw_response)
|
|
else:
|
|
url = _join_endpoint(api_base_url, "/chat/completions")
|
|
headers.update(
|
|
{
|
|
"Authorization": f"Bearer {provider['apiKey']}",
|
|
"content-type": "application/json",
|
|
}
|
|
)
|
|
payload = {
|
|
"model": provider["model"],
|
|
"messages": _openai_messages(prompt_text),
|
|
"temperature": provider["temperature"],
|
|
"max_tokens": provider["maxOutputTokens"],
|
|
}
|
|
try:
|
|
raw_response = request_json(
|
|
"POST",
|
|
url,
|
|
headers=headers,
|
|
payload=payload,
|
|
timeout_seconds=timeout_seconds,
|
|
network_settings=network_settings,
|
|
)
|
|
except HttpRequestError as error:
|
|
raise HttpRequestError(f"{error} | 当前请求地址:{url}") from error
|
|
raw_text = _extract_openai_text(raw_response)
|
|
return {
|
|
"rawResponse": raw_response,
|
|
"rawText": raw_text,
|
|
"requestUrl": url,
|
|
"resolvedBaseUrl": api_base_url,
|
|
"resolvedApiStyle": request_api_style,
|
|
}
|
|
|
|
|
|
def generate_trading_decision(prompt_text: str, provider: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
provider = provider or read_llm_provider()
|
|
issues = provider_issues(provider)
|
|
if issues:
|
|
raise ValueError(" ".join(issues))
|
|
configured_api_style = str(provider.get("apiStyle") or "").strip().lower()
|
|
network_settings = read_network_settings()
|
|
attempts: list[str] = []
|
|
last_error: Exception | None = None
|
|
selected_result: dict[str, Any] | None = None
|
|
for request_api_style in _provider_transport_candidates(provider):
|
|
try:
|
|
selected_result = _request_provider_text(prompt_text, provider, request_api_style, network_settings)
|
|
break
|
|
except (HttpRequestError, ValueError) as error:
|
|
last_error = error
|
|
attempts.append(f"{request_api_style}: {error}")
|
|
continue
|
|
if selected_result is None:
|
|
if len(attempts) > 1:
|
|
raise HttpRequestError(f"模型网关自动探测失败。已尝试 { ' | '.join(attempts) }") from last_error
|
|
if last_error:
|
|
raise last_error
|
|
raise HttpRequestError("模型请求失败,未获得可用响应。")
|
|
resolved_api_style = selected_result["resolvedApiStyle"]
|
|
saved_provider = _persist_provider_api_style(provider, resolved_api_style)
|
|
parsed = parse_json_loose(selected_result["rawText"])
|
|
return {
|
|
"provider": {
|
|
"preset": provider.get("preset"),
|
|
"apiStyle": configured_api_style,
|
|
"resolvedApiStyle": resolved_api_style,
|
|
"model": provider.get("model"),
|
|
"baseUrl": provider.get("baseUrl"),
|
|
"resolvedBaseUrl": selected_result["resolvedBaseUrl"],
|
|
"requestUrl": selected_result["requestUrl"],
|
|
"autoConfigured": resolved_api_style != configured_api_style,
|
|
"autoConfiguredSaved": saved_provider is not None,
|
|
},
|
|
"rawResponse": selected_result["rawResponse"],
|
|
"rawText": selected_result["rawText"],
|
|
"parsed": parsed,
|
|
}
|