From 4569b7354768574ad8a051ffabbb31c6852d7b03 Mon Sep 17 00:00:00 2001 From: vikneshbala2021 Date: Thu, 18 Jun 2026 16:54:12 +0800 Subject: [PATCH] changes for azure foundry support --- .../app/services/oasis_profile_generator.py | 37 +++- .../services/simulation_config_generator.py | 37 +++- backend/app/utils/llm_client.py | 194 +++++++++++++++--- backend/pyproject.toml | 1 + backend/requirements.txt | 2 + backend/uv.lock | 21 ++ frontend/package-lock.json | 4 - 7 files changed, 254 insertions(+), 42 deletions(-) diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py index 7704a627eb..7e9e4d22ac 100644 --- a/backend/app/services/oasis_profile_generator.py +++ b/backend/app/services/oasis_profile_generator.py @@ -527,16 +527,39 @@ def _generate_profile_with_llm( for attempt in range(max_attempts): try: - response = self.client.chat.completions.create( - model=self.model_name, - messages=[ + kwargs = { + "model": self.model_name, + "messages": [ {"role": "system", "content": self._get_system_prompt(is_individual)}, {"role": "user", "content": prompt} ], - response_format={"type": "json_object"}, - temperature=0.7 - (attempt * 0.1) # 每次重试降低温度 - # 不设置max_tokens,让LLM自由发挥 - ) + "response_format": {"type": "json_object"}, + # 部分模型不支持自定义温度,后续会自动降级重试 + "temperature": 0.7 - (attempt * 0.1) + } + + response = None + for _ in range(3): + try: + response = self.client.chat.completions.create(**kwargs) + break + except Exception as e: + err_text = str(e).lower() + + if "unsupported parameter" in err_text and "max_tokens" in err_text and "max_tokens" in kwargs: + kwargs.pop("max_tokens", None) + continue + + if "temperature" in err_text and ( + "unsupported value" in err_text or "default (1) value" in err_text + ) and "temperature" in kwargs: + kwargs.pop("temperature", None) + continue + + raise + + if response is None: + raise RuntimeError("OpenAI chat completion failed after compatibility retries") content = response.choices[0].message.content diff --git a/backend/app/services/simulation_config_generator.py b/backend/app/services/simulation_config_generator.py index cb77f6b6cd..25994adcc8 100644 --- a/backend/app/services/simulation_config_generator.py +++ b/backend/app/services/simulation_config_generator.py @@ -440,16 +440,39 @@ def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any for attempt in range(max_attempts): try: - response = self.client.chat.completions.create( - model=self.model_name, - messages=[ + kwargs = { + "model": self.model_name, + "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt} ], - response_format={"type": "json_object"}, - temperature=0.7 - (attempt * 0.1) # 每次重试降低温度 - # 不设置max_tokens,让LLM自由发挥 - ) + "response_format": {"type": "json_object"}, + # 部分模型不支持自定义温度,后续会自动降级重试 + "temperature": 0.7 - (attempt * 0.1) + } + + response = None + for _ in range(3): + try: + response = self.client.chat.completions.create(**kwargs) + break + except Exception as e: + err_text = str(e).lower() + + if "unsupported parameter" in err_text and "max_tokens" in err_text and "max_tokens" in kwargs: + kwargs.pop("max_tokens", None) + continue + + if "temperature" in err_text and ( + "unsupported value" in err_text or "default (1) value" in err_text + ) and "temperature" in kwargs: + kwargs.pop("temperature", None) + continue + + raise + + if response is None: + raise RuntimeError("OpenAI chat completion failed after compatibility retries") content = response.choices[0].message.content finish_reason = response.choices[0].finish_reason diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py index 6c1a81f49b..cd19d59c5e 100644 --- a/backend/app/utils/llm_client.py +++ b/backend/app/utils/llm_client.py @@ -1,18 +1,47 @@ """ LLM客户端封装 -统一使用OpenAI格式调用 +支持OpenAI和Anthropic格式的API调用 """ import json import re +from urllib.parse import urlparse from typing import Optional, Dict, Any, List from openai import OpenAI from ..config import Config +# 尝试导入Anthropic SDK,如果不可用则设为None +try: + from anthropic import Anthropic +except ImportError: + Anthropic = None + +try: + from anthropic import AnthropicFoundry +except ImportError: + AnthropicFoundry = None + + +def _foundry_resource_from_endpoint(endpoint: str) -> str: + """从 Foundry endpoint 提取 resource 名称。""" + if not endpoint: + raise ValueError("LLM_BASE_URL is empty") + if "://" not in endpoint: + return endpoint.strip() + + host = urlparse(endpoint).netloc + m = re.match(r"([^.]+)\.services\.ai\.azure\.com$", host) + if not m: + raise ValueError( + f"Could not parse Foundry resource from endpoint: {endpoint!r}. " + "Expected https://.services.ai.azure.com/anthropic/" + ) + return m.group(1) + class LLMClient: - """LLM客户端""" + """LLM客户端 - 支持OpenAI和Anthropic格式""" def __init__( self, @@ -27,10 +56,60 @@ def __init__( if not self.api_key: raise ValueError("LLM_API_KEY 未配置") - self.client = OpenAI( - api_key=self.api_key, - base_url=self.base_url - ) + # 检测是否是Anthropic格式的端点 + self.is_anthropic = "anthropic" in self.base_url.lower() + + if self.is_anthropic: + # 使用Anthropic SDK + if Anthropic is None: + raise ImportError("Anthropic SDK未安装。请运行: pip install anthropic") + is_foundry_endpoint = "services.ai.azure.com" in self.base_url.lower() + if is_foundry_endpoint and AnthropicFoundry is not None: + resource = _foundry_resource_from_endpoint(self.base_url) + self.client = AnthropicFoundry( + api_key=self.api_key, + resource=resource, + ) + else: + self.client = Anthropic( + api_key=self.api_key, + base_url=self.base_url + ) + self.client_type = "anthropic" + else: + # 使用OpenAI SDK + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url + ) + self.client_type = "openai" + + @staticmethod + def _convert_messages_for_anthropic(messages: List[Dict[str, str]]) -> Dict[str, Any]: + """将 OpenAI 风格消息转换为 Anthropic Messages API 所需格式。""" + system_parts: List[str] = [] + anthropic_messages: List[Dict[str, str]] = [] + + for msg in messages: + role = msg.get("role", "") + content = msg.get("content", "") + if role == "system": + if content: + system_parts.append(content) + continue + + if role not in {"user", "assistant"}: + role = "user" + + anthropic_messages.append({"role": role, "content": content}) + + if not anthropic_messages: + anthropic_messages = [{"role": "user", "content": ""}] + + result: Dict[str, Any] = {"messages": anthropic_messages} + if system_parts: + result["system"] = "\n\n".join(system_parts) + return result def chat( self, @@ -51,18 +130,65 @@ def chat( Returns: 模型响应文本 """ - kwargs = { - "model": self.model, - "messages": messages, - "temperature": temperature, - "max_tokens": max_tokens, - } - - if response_format: - kwargs["response_format"] = response_format + if self.client_type == "anthropic": + # 使用Anthropic SDK(system 需单独传递,messages 仅支持 user/assistant) + converted = self._convert_messages_for_anthropic(messages) + create_kwargs = { + "model": self.model, + "messages": converted["messages"], + "system": converted.get("system"), + "max_tokens": max_tokens, + "temperature": temperature, + } + try: + response = self.client.messages.create(**create_kwargs) + except Exception as e: + # 某些新模型会拒绝 temperature 参数(报错: temperature is deprecated) + if "temperature" in str(e).lower() and "deprecated" in str(e).lower(): + create_kwargs.pop("temperature", None) + response = self.client.messages.create(**create_kwargs) + else: + raise + content = response.content[0].text + else: + # 使用OpenAI SDK + kwargs = { + "model": self.model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + + if response_format: + kwargs["response_format"] = response_format + + response = None + for _ in range(3): + try: + response = self.client.chat.completions.create(**kwargs) + break + except Exception as e: + err_text = str(e).lower() + + # 某些模型(如部分 GPT-5 系列)不支持 max_tokens,仅支持 max_completion_tokens + if "unsupported parameter" in err_text and "max_tokens" in err_text and "max_tokens" in kwargs: + kwargs.pop("max_tokens", None) + kwargs["max_completion_tokens"] = max_tokens + continue + + # 某些模型仅支持默认 temperature(通常是 1) + if "temperature" in err_text and ( + "unsupported value" in err_text or "default (1) value" in err_text + ) and "temperature" in kwargs: + kwargs.pop("temperature", None) + continue + + raise + + if response is None: + raise RuntimeError("OpenAI chat completion failed after compatibility retries") + content = response.choices[0].message.content - response = self.client.chat.completions.create(**kwargs) - content = response.choices[0].message.content # 部分模型(如MiniMax M2.5)会在content中包含思考内容,需要移除 content = re.sub(r'[\s\S]*?', '', content).strip() return content @@ -84,14 +210,34 @@ def chat_json( Returns: 解析后的JSON对象 """ - response = self.chat( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - response_format={"type": "json_object"} - ) + if self.client_type == "anthropic": + modified_messages = [dict(m) for m in messages] + json_instruction = "IMPORTANT: You must respond with valid JSON only, no other text." + system_index = next((i for i, m in enumerate(modified_messages) if m.get("role") == "system"), None) + if system_index is None: + modified_messages.insert(0, {"role": "system", "content": json_instruction}) + else: + modified_messages[system_index]["content"] = ( + f"{modified_messages[system_index].get('content', '')}\n\n{json_instruction}" + ) + + response_text = self.chat( + messages=modified_messages, + temperature=temperature, + max_tokens=max_tokens, + ) + else: + # OpenAI格式:使用response_format + response = self.chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format={"type": "json_object"} + ) + response_text = response + # 清理markdown代码块标记 - cleaned_response = response.strip() + cleaned_response = response_text.strip() cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE) cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response) cleaned_response = cleaned_response.strip() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8c65b7294a..e9c6ffb5e1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ # LLM 相关 "openai>=1.0.0", + "anthropic>=0.71.0", # Zep Cloud "zep-cloud==3.13.0", diff --git a/backend/requirements.txt b/backend/requirements.txt index 4f146296ba..03b030ae4d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,8 @@ flask-cors>=6.0.0 # ============= LLM 相关 ============= # OpenAI SDK(统一使用 OpenAI 格式调用 LLM) openai>=1.0.0 +# Anthropic SDK(支持 Azure Foundry Anthropic 端点) +anthropic>=0.71.0 # ============= Zep Cloud ============= zep-cloud==3.13.0 diff --git a/backend/uv.lock b/backend/uv.lock index 642dd9c363..5055d80c54 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -24,6 +24,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.109.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/b7/9a8e2f79011e89dd6eeb599c27332aed765dac9d6fbee3a55e68e4e3ec25/anthropic-0.109.2.tar.gz", hash = "sha256:d37db299597c7bc124b49b767ff135f1e6456b64af2b2fad4b63b2a1df333cf0", size = 927559, upload-time = "2026-06-15T17:30:25.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/f2/bee5de8a2699fc8a3cce34d61c7a2626a2c310ddde7ea5611327eb0ddbe9/anthropic-0.109.2-py3-none-any.whl", hash = "sha256:e0fb4ca5df0ed983248c9c6c3242adc81d9cfddb8725902da53698554117abac", size = 923800, upload-time = "2026-06-15T17:30:23.124Z" }, +] + [[package]] name = "anyio" version = "4.12.0" @@ -988,6 +1007,7 @@ name = "mirofish-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "anthropic" }, { name = "camel-ai" }, { name = "camel-oasis" }, { name = "chardet" }, @@ -1016,6 +1036,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=0.71.0" }, { name = "camel-ai", specifier = "==0.2.78" }, { name = "camel-oasis", specifier = "==0.2.5" }, { name = "chardet", specifier = ">=5.0.0" }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3e56d752df..fdab7ac412 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1435,7 +1435,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1913,7 +1912,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2053,7 +2051,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2128,7 +2125,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25",